+1154
crates/Cargo.lock
+1154
crates/Cargo.lock
···
···
1
+
# This file is automatically @generated by Cargo.
2
+
# It is not intended for manual editing.
3
+
version = 4
4
+
5
+
[[package]]
6
+
name = "addr2line"
7
+
version = "0.24.2"
8
+
source = "registry+https://github.com/rust-lang/crates.io-index"
9
+
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
10
+
dependencies = [
11
+
"gimli",
12
+
]
13
+
14
+
[[package]]
15
+
name = "adler2"
16
+
version = "2.0.1"
17
+
source = "registry+https://github.com/rust-lang/crates.io-index"
18
+
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
19
+
20
+
[[package]]
21
+
name = "aho-corasick"
22
+
version = "1.1.3"
23
+
source = "registry+https://github.com/rust-lang/crates.io-index"
24
+
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
25
+
dependencies = [
26
+
"memchr",
27
+
]
28
+
29
+
[[package]]
30
+
name = "anyhow"
31
+
version = "1.0.99"
32
+
source = "registry+https://github.com/rust-lang/crates.io-index"
33
+
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
34
+
35
+
[[package]]
36
+
name = "approx"
37
+
version = "0.5.1"
38
+
source = "registry+https://github.com/rust-lang/crates.io-index"
39
+
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
40
+
dependencies = [
41
+
"num-traits",
42
+
]
43
+
44
+
[[package]]
45
+
name = "async-trait"
46
+
version = "0.1.89"
47
+
source = "registry+https://github.com/rust-lang/crates.io-index"
48
+
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
49
+
dependencies = [
50
+
"proc-macro2",
51
+
"quote",
52
+
"syn",
53
+
]
54
+
55
+
[[package]]
56
+
name = "autocfg"
57
+
version = "1.5.0"
58
+
source = "registry+https://github.com/rust-lang/crates.io-index"
59
+
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
60
+
61
+
[[package]]
62
+
name = "backtrace"
63
+
version = "0.3.75"
64
+
source = "registry+https://github.com/rust-lang/crates.io-index"
65
+
checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
66
+
dependencies = [
67
+
"addr2line",
68
+
"cfg-if",
69
+
"libc",
70
+
"miniz_oxide",
71
+
"object",
72
+
"rustc-demangle",
73
+
"windows-targets 0.52.6",
74
+
]
75
+
76
+
[[package]]
77
+
name = "bitflags"
78
+
version = "2.9.3"
79
+
source = "registry+https://github.com/rust-lang/crates.io-index"
80
+
checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d"
81
+
82
+
[[package]]
83
+
name = "bubbletea-rs"
84
+
version = "0.0.7"
85
+
source = "registry+https://github.com/rust-lang/crates.io-index"
86
+
checksum = "f3794d723bd09a5e1a18a3879a7b8590e5d74040862d9151f12224ee92d0de50"
87
+
dependencies = [
88
+
"anyhow",
89
+
"async-trait",
90
+
"crossterm",
91
+
"futures",
92
+
"log",
93
+
"parking_lot",
94
+
"pin-project",
95
+
"thiserror",
96
+
"tokio",
97
+
"tokio-util",
98
+
]
99
+
100
+
[[package]]
101
+
name = "bubbletea-widgets"
102
+
version = "0.1.11"
103
+
source = "registry+https://github.com/rust-lang/crates.io-index"
104
+
checksum = "4f13678648f54f24614d60af1e7356fd8b39b1b9e2f3096db94d1b4626137408"
105
+
dependencies = [
106
+
"bubbletea-rs",
107
+
"crossterm",
108
+
"fuzzy-matcher",
109
+
"libc",
110
+
"lipgloss-extras",
111
+
"once_cell",
112
+
"strip-ansi-escapes",
113
+
"unicode-segmentation",
114
+
"unicode-width",
115
+
]
116
+
117
+
[[package]]
118
+
name = "by_address"
119
+
version = "1.2.1"
120
+
source = "registry+https://github.com/rust-lang/crates.io-index"
121
+
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
122
+
123
+
[[package]]
124
+
name = "bytes"
125
+
version = "1.10.1"
126
+
source = "registry+https://github.com/rust-lang/crates.io-index"
127
+
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
128
+
129
+
[[package]]
130
+
name = "cfg-if"
131
+
version = "1.0.3"
132
+
source = "registry+https://github.com/rust-lang/crates.io-index"
133
+
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
134
+
135
+
[[package]]
136
+
name = "convert_case"
137
+
version = "0.7.1"
138
+
source = "registry+https://github.com/rust-lang/crates.io-index"
139
+
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
140
+
dependencies = [
141
+
"unicode-segmentation",
142
+
]
143
+
144
+
[[package]]
145
+
name = "crossterm"
146
+
version = "0.29.0"
147
+
source = "registry+https://github.com/rust-lang/crates.io-index"
148
+
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
149
+
dependencies = [
150
+
"bitflags",
151
+
"crossterm_winapi",
152
+
"derive_more",
153
+
"document-features",
154
+
"futures-core",
155
+
"mio",
156
+
"parking_lot",
157
+
"rustix 1.0.8",
158
+
"signal-hook",
159
+
"signal-hook-mio",
160
+
"winapi",
161
+
]
162
+
163
+
[[package]]
164
+
name = "crossterm_winapi"
165
+
version = "0.9.1"
166
+
source = "registry+https://github.com/rust-lang/crates.io-index"
167
+
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
168
+
dependencies = [
169
+
"winapi",
170
+
]
171
+
172
+
[[package]]
173
+
name = "derive_more"
174
+
version = "2.0.1"
175
+
source = "registry+https://github.com/rust-lang/crates.io-index"
176
+
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
177
+
dependencies = [
178
+
"derive_more-impl",
179
+
]
180
+
181
+
[[package]]
182
+
name = "derive_more-impl"
183
+
version = "2.0.1"
184
+
source = "registry+https://github.com/rust-lang/crates.io-index"
185
+
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
186
+
dependencies = [
187
+
"convert_case",
188
+
"proc-macro2",
189
+
"quote",
190
+
"syn",
191
+
]
192
+
193
+
[[package]]
194
+
name = "document-features"
195
+
version = "0.2.11"
196
+
source = "registry+https://github.com/rust-lang/crates.io-index"
197
+
checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d"
198
+
dependencies = [
199
+
"litrs",
200
+
]
201
+
202
+
[[package]]
203
+
name = "either"
204
+
version = "1.15.0"
205
+
source = "registry+https://github.com/rust-lang/crates.io-index"
206
+
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
207
+
208
+
[[package]]
209
+
name = "errno"
210
+
version = "0.3.13"
211
+
source = "registry+https://github.com/rust-lang/crates.io-index"
212
+
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
213
+
dependencies = [
214
+
"libc",
215
+
"windows-sys 0.60.2",
216
+
]
217
+
218
+
[[package]]
219
+
name = "fast-srgb8"
220
+
version = "1.0.0"
221
+
source = "registry+https://github.com/rust-lang/crates.io-index"
222
+
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
223
+
224
+
[[package]]
225
+
name = "futures"
226
+
version = "0.3.31"
227
+
source = "registry+https://github.com/rust-lang/crates.io-index"
228
+
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
229
+
dependencies = [
230
+
"futures-channel",
231
+
"futures-core",
232
+
"futures-executor",
233
+
"futures-io",
234
+
"futures-sink",
235
+
"futures-task",
236
+
"futures-util",
237
+
]
238
+
239
+
[[package]]
240
+
name = "futures-channel"
241
+
version = "0.3.31"
242
+
source = "registry+https://github.com/rust-lang/crates.io-index"
243
+
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
244
+
dependencies = [
245
+
"futures-core",
246
+
"futures-sink",
247
+
]
248
+
249
+
[[package]]
250
+
name = "futures-core"
251
+
version = "0.3.31"
252
+
source = "registry+https://github.com/rust-lang/crates.io-index"
253
+
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
254
+
255
+
[[package]]
256
+
name = "futures-executor"
257
+
version = "0.3.31"
258
+
source = "registry+https://github.com/rust-lang/crates.io-index"
259
+
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
260
+
dependencies = [
261
+
"futures-core",
262
+
"futures-task",
263
+
"futures-util",
264
+
]
265
+
266
+
[[package]]
267
+
name = "futures-io"
268
+
version = "0.3.31"
269
+
source = "registry+https://github.com/rust-lang/crates.io-index"
270
+
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
271
+
272
+
[[package]]
273
+
name = "futures-macro"
274
+
version = "0.3.31"
275
+
source = "registry+https://github.com/rust-lang/crates.io-index"
276
+
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
277
+
dependencies = [
278
+
"proc-macro2",
279
+
"quote",
280
+
"syn",
281
+
]
282
+
283
+
[[package]]
284
+
name = "futures-sink"
285
+
version = "0.3.31"
286
+
source = "registry+https://github.com/rust-lang/crates.io-index"
287
+
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
288
+
289
+
[[package]]
290
+
name = "futures-task"
291
+
version = "0.3.31"
292
+
source = "registry+https://github.com/rust-lang/crates.io-index"
293
+
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
294
+
295
+
[[package]]
296
+
name = "futures-util"
297
+
version = "0.3.31"
298
+
source = "registry+https://github.com/rust-lang/crates.io-index"
299
+
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
300
+
dependencies = [
301
+
"futures-channel",
302
+
"futures-core",
303
+
"futures-io",
304
+
"futures-macro",
305
+
"futures-sink",
306
+
"futures-task",
307
+
"memchr",
308
+
"pin-project-lite",
309
+
"pin-utils",
310
+
"slab",
311
+
]
312
+
313
+
[[package]]
314
+
name = "fuzzy-matcher"
315
+
version = "0.3.7"
316
+
source = "registry+https://github.com/rust-lang/crates.io-index"
317
+
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
318
+
dependencies = [
319
+
"thread_local",
320
+
]
321
+
322
+
[[package]]
323
+
name = "gimli"
324
+
version = "0.31.1"
325
+
source = "registry+https://github.com/rust-lang/crates.io-index"
326
+
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
327
+
328
+
[[package]]
329
+
name = "home"
330
+
version = "0.5.11"
331
+
source = "registry+https://github.com/rust-lang/crates.io-index"
332
+
checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
333
+
dependencies = [
334
+
"windows-sys 0.59.0",
335
+
]
336
+
337
+
[[package]]
338
+
name = "io-uring"
339
+
version = "0.7.10"
340
+
source = "registry+https://github.com/rust-lang/crates.io-index"
341
+
checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
342
+
dependencies = [
343
+
"bitflags",
344
+
"cfg-if",
345
+
"libc",
346
+
]
347
+
348
+
[[package]]
349
+
name = "itoa"
350
+
version = "1.0.15"
351
+
source = "registry+https://github.com/rust-lang/crates.io-index"
352
+
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
353
+
354
+
[[package]]
355
+
name = "libc"
356
+
version = "0.2.175"
357
+
source = "registry+https://github.com/rust-lang/crates.io-index"
358
+
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
359
+
360
+
[[package]]
361
+
name = "linux-raw-sys"
362
+
version = "0.4.15"
363
+
source = "registry+https://github.com/rust-lang/crates.io-index"
364
+
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
365
+
366
+
[[package]]
367
+
name = "linux-raw-sys"
368
+
version = "0.9.4"
369
+
source = "registry+https://github.com/rust-lang/crates.io-index"
370
+
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
371
+
372
+
[[package]]
373
+
name = "lipgloss"
374
+
version = "0.1.0"
375
+
source = "registry+https://github.com/rust-lang/crates.io-index"
376
+
checksum = "a9b86565b27d147fee6923d6cfa7265fc3d7f7e2d00d51d0582f91e3b74848fd"
377
+
dependencies = [
378
+
"crossterm",
379
+
"palette",
380
+
"strip-ansi-escapes",
381
+
"unicode-width",
382
+
]
383
+
384
+
[[package]]
385
+
name = "lipgloss-extras"
386
+
version = "0.1.0"
387
+
source = "registry+https://github.com/rust-lang/crates.io-index"
388
+
checksum = "23c1c1cd78f4ef70cc3fc1d714ed1a526aef6a8c7ae782af068fa667abbe27e9"
389
+
dependencies = [
390
+
"lipgloss",
391
+
"lipgloss-list",
392
+
"lipgloss-table",
393
+
"lipgloss-tree",
394
+
]
395
+
396
+
[[package]]
397
+
name = "lipgloss-list"
398
+
version = "0.1.0"
399
+
source = "registry+https://github.com/rust-lang/crates.io-index"
400
+
checksum = "dc596c2e4d35d1b0cf6b628cf6cd2619a86c00a0fb7758aee6d34245cf17c673"
401
+
dependencies = [
402
+
"lipgloss",
403
+
"lipgloss-tree",
404
+
"unicode-width",
405
+
]
406
+
407
+
[[package]]
408
+
name = "lipgloss-table"
409
+
version = "0.1.0"
410
+
source = "registry+https://github.com/rust-lang/crates.io-index"
411
+
checksum = "c935bbed67e62d6c49b95a6318ecb42f80c2981e3d77e932e0761b5b69ed32e4"
412
+
dependencies = [
413
+
"lipgloss",
414
+
"unicode-width",
415
+
]
416
+
417
+
[[package]]
418
+
name = "lipgloss-tree"
419
+
version = "0.1.0"
420
+
source = "registry+https://github.com/rust-lang/crates.io-index"
421
+
checksum = "f91c9b17a7860a0bd4cc6dd760fcc309118de9d00e1d8f50ca2ebafd480faa45"
422
+
dependencies = [
423
+
"lipgloss",
424
+
"unicode-width",
425
+
]
426
+
427
+
[[package]]
428
+
name = "litrs"
429
+
version = "0.4.2"
430
+
source = "registry+https://github.com/rust-lang/crates.io-index"
431
+
checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed"
432
+
433
+
[[package]]
434
+
name = "lock_api"
435
+
version = "0.4.13"
436
+
source = "registry+https://github.com/rust-lang/crates.io-index"
437
+
checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
438
+
dependencies = [
439
+
"autocfg",
440
+
"scopeguard",
441
+
]
442
+
443
+
[[package]]
444
+
name = "log"
445
+
version = "0.4.27"
446
+
source = "registry+https://github.com/rust-lang/crates.io-index"
447
+
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
448
+
449
+
[[package]]
450
+
name = "memchr"
451
+
version = "2.7.5"
452
+
source = "registry+https://github.com/rust-lang/crates.io-index"
453
+
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
454
+
455
+
[[package]]
456
+
name = "miniz_oxide"
457
+
version = "0.8.9"
458
+
source = "registry+https://github.com/rust-lang/crates.io-index"
459
+
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
460
+
dependencies = [
461
+
"adler2",
462
+
]
463
+
464
+
[[package]]
465
+
name = "mio"
466
+
version = "1.0.4"
467
+
source = "registry+https://github.com/rust-lang/crates.io-index"
468
+
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
469
+
dependencies = [
470
+
"libc",
471
+
"log",
472
+
"wasi",
473
+
"windows-sys 0.59.0",
474
+
]
475
+
476
+
[[package]]
477
+
name = "num-traits"
478
+
version = "0.2.19"
479
+
source = "registry+https://github.com/rust-lang/crates.io-index"
480
+
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
481
+
dependencies = [
482
+
"autocfg",
483
+
]
484
+
485
+
[[package]]
486
+
name = "object"
487
+
version = "0.36.7"
488
+
source = "registry+https://github.com/rust-lang/crates.io-index"
489
+
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
490
+
dependencies = [
491
+
"memchr",
492
+
]
493
+
494
+
[[package]]
495
+
name = "once_cell"
496
+
version = "1.21.3"
497
+
source = "registry+https://github.com/rust-lang/crates.io-index"
498
+
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
499
+
500
+
[[package]]
501
+
name = "palette"
502
+
version = "0.7.6"
503
+
source = "registry+https://github.com/rust-lang/crates.io-index"
504
+
checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
505
+
dependencies = [
506
+
"approx",
507
+
"fast-srgb8",
508
+
"palette_derive",
509
+
"phf",
510
+
]
511
+
512
+
[[package]]
513
+
name = "palette_derive"
514
+
version = "0.7.6"
515
+
source = "registry+https://github.com/rust-lang/crates.io-index"
516
+
checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
517
+
dependencies = [
518
+
"by_address",
519
+
"proc-macro2",
520
+
"quote",
521
+
"syn",
522
+
]
523
+
524
+
[[package]]
525
+
name = "parking_lot"
526
+
version = "0.12.4"
527
+
source = "registry+https://github.com/rust-lang/crates.io-index"
528
+
checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
529
+
dependencies = [
530
+
"lock_api",
531
+
"parking_lot_core",
532
+
]
533
+
534
+
[[package]]
535
+
name = "parking_lot_core"
536
+
version = "0.9.11"
537
+
source = "registry+https://github.com/rust-lang/crates.io-index"
538
+
checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
539
+
dependencies = [
540
+
"cfg-if",
541
+
"libc",
542
+
"redox_syscall",
543
+
"smallvec",
544
+
"windows-targets 0.52.6",
545
+
]
546
+
547
+
[[package]]
548
+
name = "phf"
549
+
version = "0.11.3"
550
+
source = "registry+https://github.com/rust-lang/crates.io-index"
551
+
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
552
+
dependencies = [
553
+
"phf_macros",
554
+
"phf_shared",
555
+
]
556
+
557
+
[[package]]
558
+
name = "phf_generator"
559
+
version = "0.11.3"
560
+
source = "registry+https://github.com/rust-lang/crates.io-index"
561
+
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
562
+
dependencies = [
563
+
"phf_shared",
564
+
"rand",
565
+
]
566
+
567
+
[[package]]
568
+
name = "phf_macros"
569
+
version = "0.11.3"
570
+
source = "registry+https://github.com/rust-lang/crates.io-index"
571
+
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
572
+
dependencies = [
573
+
"phf_generator",
574
+
"phf_shared",
575
+
"proc-macro2",
576
+
"quote",
577
+
"syn",
578
+
]
579
+
580
+
[[package]]
581
+
name = "phf_shared"
582
+
version = "0.11.3"
583
+
source = "registry+https://github.com/rust-lang/crates.io-index"
584
+
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
585
+
dependencies = [
586
+
"siphasher",
587
+
]
588
+
589
+
[[package]]
590
+
name = "pin-project"
591
+
version = "1.1.10"
592
+
source = "registry+https://github.com/rust-lang/crates.io-index"
593
+
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
594
+
dependencies = [
595
+
"pin-project-internal",
596
+
]
597
+
598
+
[[package]]
599
+
name = "pin-project-internal"
600
+
version = "1.1.10"
601
+
source = "registry+https://github.com/rust-lang/crates.io-index"
602
+
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
603
+
dependencies = [
604
+
"proc-macro2",
605
+
"quote",
606
+
"syn",
607
+
]
608
+
609
+
[[package]]
610
+
name = "pin-project-lite"
611
+
version = "0.2.16"
612
+
source = "registry+https://github.com/rust-lang/crates.io-index"
613
+
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
614
+
615
+
[[package]]
616
+
name = "pin-utils"
617
+
version = "0.1.0"
618
+
source = "registry+https://github.com/rust-lang/crates.io-index"
619
+
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
620
+
621
+
[[package]]
622
+
name = "proc-macro2"
623
+
version = "1.0.101"
624
+
source = "registry+https://github.com/rust-lang/crates.io-index"
625
+
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
626
+
dependencies = [
627
+
"unicode-ident",
628
+
]
629
+
630
+
[[package]]
631
+
name = "quote"
632
+
version = "1.0.40"
633
+
source = "registry+https://github.com/rust-lang/crates.io-index"
634
+
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
635
+
dependencies = [
636
+
"proc-macro2",
637
+
]
638
+
639
+
[[package]]
640
+
name = "rand"
641
+
version = "0.8.5"
642
+
source = "registry+https://github.com/rust-lang/crates.io-index"
643
+
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
644
+
dependencies = [
645
+
"rand_core",
646
+
]
647
+
648
+
[[package]]
649
+
name = "rand_core"
650
+
version = "0.6.4"
651
+
source = "registry+https://github.com/rust-lang/crates.io-index"
652
+
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
653
+
654
+
[[package]]
655
+
name = "redox_syscall"
656
+
version = "0.5.17"
657
+
source = "registry+https://github.com/rust-lang/crates.io-index"
658
+
checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
659
+
dependencies = [
660
+
"bitflags",
661
+
]
662
+
663
+
[[package]]
664
+
name = "regex"
665
+
version = "1.11.1"
666
+
source = "registry+https://github.com/rust-lang/crates.io-index"
667
+
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
668
+
dependencies = [
669
+
"aho-corasick",
670
+
"memchr",
671
+
"regex-automata",
672
+
"regex-syntax",
673
+
]
674
+
675
+
[[package]]
676
+
name = "regex-automata"
677
+
version = "0.4.9"
678
+
source = "registry+https://github.com/rust-lang/crates.io-index"
679
+
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
680
+
dependencies = [
681
+
"aho-corasick",
682
+
"memchr",
683
+
"regex-syntax",
684
+
]
685
+
686
+
[[package]]
687
+
name = "regex-syntax"
688
+
version = "0.8.5"
689
+
source = "registry+https://github.com/rust-lang/crates.io-index"
690
+
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
691
+
692
+
[[package]]
693
+
name = "rustc-demangle"
694
+
version = "0.1.26"
695
+
source = "registry+https://github.com/rust-lang/crates.io-index"
696
+
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
697
+
698
+
[[package]]
699
+
name = "rustix"
700
+
version = "0.38.44"
701
+
source = "registry+https://github.com/rust-lang/crates.io-index"
702
+
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
703
+
dependencies = [
704
+
"bitflags",
705
+
"errno",
706
+
"libc",
707
+
"linux-raw-sys 0.4.15",
708
+
"windows-sys 0.59.0",
709
+
]
710
+
711
+
[[package]]
712
+
name = "rustix"
713
+
version = "1.0.8"
714
+
source = "registry+https://github.com/rust-lang/crates.io-index"
715
+
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
716
+
dependencies = [
717
+
"bitflags",
718
+
"errno",
719
+
"libc",
720
+
"linux-raw-sys 0.9.4",
721
+
"windows-sys 0.60.2",
722
+
]
723
+
724
+
[[package]]
725
+
name = "ryu"
726
+
version = "1.0.20"
727
+
source = "registry+https://github.com/rust-lang/crates.io-index"
728
+
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
729
+
730
+
[[package]]
731
+
name = "scopeguard"
732
+
version = "1.2.0"
733
+
source = "registry+https://github.com/rust-lang/crates.io-index"
734
+
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
735
+
736
+
[[package]]
737
+
name = "serde"
738
+
version = "1.0.219"
739
+
source = "registry+https://github.com/rust-lang/crates.io-index"
740
+
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
741
+
dependencies = [
742
+
"serde_derive",
743
+
]
744
+
745
+
[[package]]
746
+
name = "serde_derive"
747
+
version = "1.0.219"
748
+
source = "registry+https://github.com/rust-lang/crates.io-index"
749
+
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
750
+
dependencies = [
751
+
"proc-macro2",
752
+
"quote",
753
+
"syn",
754
+
]
755
+
756
+
[[package]]
757
+
name = "serde_json"
758
+
version = "1.0.143"
759
+
source = "registry+https://github.com/rust-lang/crates.io-index"
760
+
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
761
+
dependencies = [
762
+
"itoa",
763
+
"memchr",
764
+
"ryu",
765
+
"serde",
766
+
]
767
+
768
+
[[package]]
769
+
name = "signal-hook"
770
+
version = "0.3.18"
771
+
source = "registry+https://github.com/rust-lang/crates.io-index"
772
+
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
773
+
dependencies = [
774
+
"libc",
775
+
"signal-hook-registry",
776
+
]
777
+
778
+
[[package]]
779
+
name = "signal-hook-mio"
780
+
version = "0.2.4"
781
+
source = "registry+https://github.com/rust-lang/crates.io-index"
782
+
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
783
+
dependencies = [
784
+
"libc",
785
+
"mio",
786
+
"signal-hook",
787
+
]
788
+
789
+
[[package]]
790
+
name = "signal-hook-registry"
791
+
version = "1.4.6"
792
+
source = "registry+https://github.com/rust-lang/crates.io-index"
793
+
checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
794
+
dependencies = [
795
+
"libc",
796
+
]
797
+
798
+
[[package]]
799
+
name = "siphasher"
800
+
version = "1.0.1"
801
+
source = "registry+https://github.com/rust-lang/crates.io-index"
802
+
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
803
+
804
+
[[package]]
805
+
name = "slab"
806
+
version = "0.4.11"
807
+
source = "registry+https://github.com/rust-lang/crates.io-index"
808
+
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
809
+
810
+
[[package]]
811
+
name = "smallvec"
812
+
version = "1.15.1"
813
+
source = "registry+https://github.com/rust-lang/crates.io-index"
814
+
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
815
+
816
+
[[package]]
817
+
name = "socket2"
818
+
version = "0.6.0"
819
+
source = "registry+https://github.com/rust-lang/crates.io-index"
820
+
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
821
+
dependencies = [
822
+
"libc",
823
+
"windows-sys 0.59.0",
824
+
]
825
+
826
+
[[package]]
827
+
name = "strip-ansi-escapes"
828
+
version = "0.2.1"
829
+
source = "registry+https://github.com/rust-lang/crates.io-index"
830
+
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
831
+
dependencies = [
832
+
"vte",
833
+
]
834
+
835
+
[[package]]
836
+
name = "syn"
837
+
version = "2.0.106"
838
+
source = "registry+https://github.com/rust-lang/crates.io-index"
839
+
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
840
+
dependencies = [
841
+
"proc-macro2",
842
+
"quote",
843
+
"unicode-ident",
844
+
]
845
+
846
+
[[package]]
847
+
name = "thiserror"
848
+
version = "2.0.16"
849
+
source = "registry+https://github.com/rust-lang/crates.io-index"
850
+
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
851
+
dependencies = [
852
+
"thiserror-impl",
853
+
]
854
+
855
+
[[package]]
856
+
name = "thiserror-impl"
857
+
version = "2.0.16"
858
+
source = "registry+https://github.com/rust-lang/crates.io-index"
859
+
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
860
+
dependencies = [
861
+
"proc-macro2",
862
+
"quote",
863
+
"syn",
864
+
]
865
+
866
+
[[package]]
867
+
name = "thread_local"
868
+
version = "1.1.9"
869
+
source = "registry+https://github.com/rust-lang/crates.io-index"
870
+
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
871
+
dependencies = [
872
+
"cfg-if",
873
+
]
874
+
875
+
[[package]]
876
+
name = "tokio"
877
+
version = "1.47.1"
878
+
source = "registry+https://github.com/rust-lang/crates.io-index"
879
+
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
880
+
dependencies = [
881
+
"backtrace",
882
+
"bytes",
883
+
"io-uring",
884
+
"libc",
885
+
"mio",
886
+
"parking_lot",
887
+
"pin-project-lite",
888
+
"signal-hook-registry",
889
+
"slab",
890
+
"socket2",
891
+
"tokio-macros",
892
+
"windows-sys 0.59.0",
893
+
]
894
+
895
+
[[package]]
896
+
name = "tokio-macros"
897
+
version = "2.5.0"
898
+
source = "registry+https://github.com/rust-lang/crates.io-index"
899
+
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
900
+
dependencies = [
901
+
"proc-macro2",
902
+
"quote",
903
+
"syn",
904
+
]
905
+
906
+
[[package]]
907
+
name = "tokio-util"
908
+
version = "0.7.16"
909
+
source = "registry+https://github.com/rust-lang/crates.io-index"
910
+
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
911
+
dependencies = [
912
+
"bytes",
913
+
"futures-core",
914
+
"futures-sink",
915
+
"pin-project-lite",
916
+
"tokio",
917
+
]
918
+
919
+
[[package]]
920
+
name = "unicode-ident"
921
+
version = "1.0.18"
922
+
source = "registry+https://github.com/rust-lang/crates.io-index"
923
+
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
924
+
925
+
[[package]]
926
+
name = "unicode-segmentation"
927
+
version = "1.12.0"
928
+
source = "registry+https://github.com/rust-lang/crates.io-index"
929
+
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
930
+
931
+
[[package]]
932
+
name = "unicode-width"
933
+
version = "0.2.1"
934
+
source = "registry+https://github.com/rust-lang/crates.io-index"
935
+
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
936
+
937
+
[[package]]
938
+
name = "van"
939
+
version = "0.1.0"
940
+
dependencies = [
941
+
"bubbletea-rs",
942
+
"bubbletea-widgets",
943
+
"crossterm",
944
+
"futures",
945
+
"lipgloss",
946
+
"once_cell",
947
+
"regex",
948
+
"serde",
949
+
"serde_json",
950
+
"tokio",
951
+
"which",
952
+
]
953
+
954
+
[[package]]
955
+
name = "vte"
956
+
version = "0.14.1"
957
+
source = "registry+https://github.com/rust-lang/crates.io-index"
958
+
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
959
+
dependencies = [
960
+
"memchr",
961
+
]
962
+
963
+
[[package]]
964
+
name = "wasi"
965
+
version = "0.11.1+wasi-snapshot-preview1"
966
+
source = "registry+https://github.com/rust-lang/crates.io-index"
967
+
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
968
+
969
+
[[package]]
970
+
name = "which"
971
+
version = "4.4.2"
972
+
source = "registry+https://github.com/rust-lang/crates.io-index"
973
+
checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
974
+
dependencies = [
975
+
"either",
976
+
"home",
977
+
"once_cell",
978
+
"rustix 0.38.44",
979
+
]
980
+
981
+
[[package]]
982
+
name = "winapi"
983
+
version = "0.3.9"
984
+
source = "registry+https://github.com/rust-lang/crates.io-index"
985
+
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
986
+
dependencies = [
987
+
"winapi-i686-pc-windows-gnu",
988
+
"winapi-x86_64-pc-windows-gnu",
989
+
]
990
+
991
+
[[package]]
992
+
name = "winapi-i686-pc-windows-gnu"
993
+
version = "0.4.0"
994
+
source = "registry+https://github.com/rust-lang/crates.io-index"
995
+
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
996
+
997
+
[[package]]
998
+
name = "winapi-x86_64-pc-windows-gnu"
999
+
version = "0.4.0"
1000
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1001
+
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
1002
+
1003
+
[[package]]
1004
+
name = "windows-link"
1005
+
version = "0.1.3"
1006
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1007
+
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
1008
+
1009
+
[[package]]
1010
+
name = "windows-sys"
1011
+
version = "0.59.0"
1012
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1013
+
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
1014
+
dependencies = [
1015
+
"windows-targets 0.52.6",
1016
+
]
1017
+
1018
+
[[package]]
1019
+
name = "windows-sys"
1020
+
version = "0.60.2"
1021
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1022
+
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
1023
+
dependencies = [
1024
+
"windows-targets 0.53.3",
1025
+
]
1026
+
1027
+
[[package]]
1028
+
name = "windows-targets"
1029
+
version = "0.52.6"
1030
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1031
+
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1032
+
dependencies = [
1033
+
"windows_aarch64_gnullvm 0.52.6",
1034
+
"windows_aarch64_msvc 0.52.6",
1035
+
"windows_i686_gnu 0.52.6",
1036
+
"windows_i686_gnullvm 0.52.6",
1037
+
"windows_i686_msvc 0.52.6",
1038
+
"windows_x86_64_gnu 0.52.6",
1039
+
"windows_x86_64_gnullvm 0.52.6",
1040
+
"windows_x86_64_msvc 0.52.6",
1041
+
]
1042
+
1043
+
[[package]]
1044
+
name = "windows-targets"
1045
+
version = "0.53.3"
1046
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1047
+
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
1048
+
dependencies = [
1049
+
"windows-link",
1050
+
"windows_aarch64_gnullvm 0.53.0",
1051
+
"windows_aarch64_msvc 0.53.0",
1052
+
"windows_i686_gnu 0.53.0",
1053
+
"windows_i686_gnullvm 0.53.0",
1054
+
"windows_i686_msvc 0.53.0",
1055
+
"windows_x86_64_gnu 0.53.0",
1056
+
"windows_x86_64_gnullvm 0.53.0",
1057
+
"windows_x86_64_msvc 0.53.0",
1058
+
]
1059
+
1060
+
[[package]]
1061
+
name = "windows_aarch64_gnullvm"
1062
+
version = "0.52.6"
1063
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1064
+
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1065
+
1066
+
[[package]]
1067
+
name = "windows_aarch64_gnullvm"
1068
+
version = "0.53.0"
1069
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1070
+
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
1071
+
1072
+
[[package]]
1073
+
name = "windows_aarch64_msvc"
1074
+
version = "0.52.6"
1075
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1076
+
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1077
+
1078
+
[[package]]
1079
+
name = "windows_aarch64_msvc"
1080
+
version = "0.53.0"
1081
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1082
+
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
1083
+
1084
+
[[package]]
1085
+
name = "windows_i686_gnu"
1086
+
version = "0.52.6"
1087
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1088
+
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1089
+
1090
+
[[package]]
1091
+
name = "windows_i686_gnu"
1092
+
version = "0.53.0"
1093
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1094
+
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
1095
+
1096
+
[[package]]
1097
+
name = "windows_i686_gnullvm"
1098
+
version = "0.52.6"
1099
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1100
+
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1101
+
1102
+
[[package]]
1103
+
name = "windows_i686_gnullvm"
1104
+
version = "0.53.0"
1105
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1106
+
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
1107
+
1108
+
[[package]]
1109
+
name = "windows_i686_msvc"
1110
+
version = "0.52.6"
1111
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1112
+
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1113
+
1114
+
[[package]]
1115
+
name = "windows_i686_msvc"
1116
+
version = "0.53.0"
1117
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1118
+
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
1119
+
1120
+
[[package]]
1121
+
name = "windows_x86_64_gnu"
1122
+
version = "0.52.6"
1123
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1124
+
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1125
+
1126
+
[[package]]
1127
+
name = "windows_x86_64_gnu"
1128
+
version = "0.53.0"
1129
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1130
+
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
1131
+
1132
+
[[package]]
1133
+
name = "windows_x86_64_gnullvm"
1134
+
version = "0.52.6"
1135
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1136
+
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1137
+
1138
+
[[package]]
1139
+
name = "windows_x86_64_gnullvm"
1140
+
version = "0.53.0"
1141
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1142
+
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
1143
+
1144
+
[[package]]
1145
+
name = "windows_x86_64_msvc"
1146
+
version = "0.52.6"
1147
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1148
+
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1149
+
1150
+
[[package]]
1151
+
name = "windows_x86_64_msvc"
1152
+
version = "0.53.0"
1153
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1154
+
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
+28
crates/Cargo.toml
+28
crates/Cargo.toml
···
···
1
+
[package]
2
+
name = "van"
3
+
version = "0.1.0"
4
+
edition = "2021"
5
+
6
+
[dependencies]
7
+
lipgloss = "*"
8
+
bubbletea-rs = "*"
9
+
# Disable default features for bubbletea-widgets (prevents optional clipboard backend)
10
+
bubbletea-widgets = { version = "*", default-features = false }
11
+
serde = { version = "1.0", features = ["derive"] }
12
+
serde_json = "1.0"
13
+
14
+
which = "4.4"
15
+
16
+
# runtime helpers for interactive Program
17
+
once_cell = "1.20"
18
+
futures = "0.3"
19
+
20
+
# Use crossterm version compatible with bubbletea-rs
21
+
crossterm = "0.29"
22
+
23
+
# tokio for async main used in src/main.rs
24
+
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
25
+
26
+
27
+
[dev-dependencies]
28
+
regex = "1.10"
+746
crates/src/acekey.rs
+746
crates/src/acekey.rs
···
···
1
+
use std::collections::{HashMap, HashSet};
2
+
3
+
/// Returns true for characters allowed in ACE keys (alphanumeric or hyphen).
4
+
#[inline]
5
+
pub fn is_ace_rune(c: char) -> bool {
6
+
c.is_alphanumeric() || c == '-'
7
+
}
8
+
9
+
/// Returns true when `s` is a single-character string and that character is an ACE rune.
10
+
#[inline]
11
+
pub fn is_single_ace_rune(s: &str) -> bool {
12
+
if let Some(ch) = s.chars().next() {
13
+
s.chars().count() == 1 && is_ace_rune(ch)
14
+
} else {
15
+
false
16
+
}
17
+
}
18
+
19
+
#[derive(Debug, Clone, PartialEq, Eq)]
20
+
pub struct Assignment {
21
+
pub index: usize,
22
+
pub prefix: String,
23
+
}
24
+
25
+
#[derive(Clone, Debug)]
26
+
struct ElemInfo {
27
+
index: usize,
28
+
_orig: String,
29
+
clean: String,
30
+
lower: String,
31
+
lu: String,
32
+
rune_count: usize,
33
+
}
34
+
35
+
fn build_infos(elements: &[String]) -> Vec<ElemInfo> {
36
+
elements
37
+
.iter()
38
+
.enumerate()
39
+
.filter_map(|(i, e)| {
40
+
let c = clean_string(e);
41
+
if c.is_empty() {
42
+
None
43
+
} else {
44
+
let lower = c.to_lowercase();
45
+
let lu = leftmost_unit(&c);
46
+
let rune_count = c.chars().count();
47
+
Some(ElemInfo { index: i, _orig: e.clone(), clean: c, lower, lu, rune_count })
48
+
}
49
+
})
50
+
.collect()
51
+
}
52
+
53
+
fn clean_string(s: &str) -> String {
54
+
s.chars().filter(|&r| is_ace_rune(r)).collect()
55
+
}
56
+
57
+
fn leftmost_unit(clean: &str) -> String {
58
+
if clean.starts_with("--") {
59
+
"--".to_string()
60
+
} else {
61
+
clean.chars().next().map(|c| c.to_string()).unwrap_or_default()
62
+
}
63
+
}
64
+
65
+
fn compute_typed_left_unit(typed_lower: &str) -> String {
66
+
leftmost_unit(typed_lower)
67
+
}
68
+
69
+
fn collapse_leading(match_lower: &str, lu_lower: &str) -> String {
70
+
if lu_lower == "--" || lu_lower.is_empty() {
71
+
return match_lower.to_string();
72
+
}
73
+
let first = lu_lower.chars().next().unwrap();
74
+
let chars = match_lower.chars();
75
+
// count leading occurrences of `first`
76
+
let mut count = 0usize;
77
+
for c in chars {
78
+
if c == first {
79
+
count += 1;
80
+
} else {
81
+
// we've advanced one too far, include this char later
82
+
break;
83
+
}
84
+
}
85
+
if count > 1 {
86
+
let mut res = String::new();
87
+
res.push(first);
88
+
// append the remainder after the run of `first`
89
+
res.extend(match_lower.chars().skip(count));
90
+
res
91
+
} else {
92
+
match_lower.to_string()
93
+
}
94
+
}
95
+
96
+
pub fn assign_initial_candidates(elements: &[String]) -> HashMap<usize, String> {
97
+
elements
98
+
.iter()
99
+
.enumerate()
100
+
.filter_map(|(i, e)| {
101
+
let clean = clean_string(e);
102
+
if clean.is_empty() {
103
+
None
104
+
} else {
105
+
let mut lu = leftmost_unit(&clean);
106
+
if lu == "--" {
107
+
lu = "-".to_string();
108
+
}
109
+
Some((i, lu))
110
+
}
111
+
})
112
+
.collect()
113
+
}
114
+
115
+
// helper: attempt base full key match
116
+
fn attempt_base_full_key_match(infos: &[ElemInfo], typed_left_unit: &str, typed_clean: &str, typed_lower: &str) -> Option<Assignment> {
117
+
if typed_left_unit.is_empty() {
118
+
return None;
119
+
}
120
+
if typed_lower.chars().count() <= typed_left_unit.chars().count() {
121
+
return None;
122
+
}
123
+
124
+
// If typed is exactly left_unit + one rune, prefer the candidate that contains
125
+
// that rune (after the left unit) uniquely among base candidates.
126
+
let extra_len = typed_lower.chars().count() - typed_left_unit.chars().count();
127
+
if extra_len == 1 {
128
+
let extra_ch = typed_lower.chars().nth(typed_left_unit.chars().count()).unwrap();
129
+
let mut matches = Vec::new();
130
+
for it in infos.iter() {
131
+
let lu_lower = it.lu.to_lowercase();
132
+
let match_lu = if typed_left_unit == "-" { lu_lower == "-" || lu_lower == "--" } else { lu_lower == typed_left_unit };
133
+
if match_lu {
134
+
let start_pos = it.lu.chars().count();
135
+
if it.lower.chars().skip(start_pos).any(|r| r == extra_ch) {
136
+
matches.push(it.clone());
137
+
}
138
+
}
139
+
}
140
+
if matches.len() == 1 {
141
+
return Some(Assignment { index: matches[0].index, prefix: String::new() });
142
+
}
143
+
}
144
+
145
+
let mut base_list: Vec<ElemInfo> = infos
146
+
.iter()
147
+
.filter(|it| {
148
+
let lu_lower = it.lu.to_lowercase();
149
+
if typed_left_unit == "-" {
150
+
lu_lower == "-" || lu_lower == "--"
151
+
} else {
152
+
lu_lower == typed_left_unit
153
+
}
154
+
})
155
+
.cloned()
156
+
.collect();
157
+
158
+
base_list.sort_by(|a, b| {
159
+
a.rune_count
160
+
.cmp(&b.rune_count)
161
+
.then(a.index.cmp(&b.index))
162
+
});
163
+
164
+
// original-case allocation
165
+
let mut used_orig = HashSet::new();
166
+
for it in &base_list {
167
+
let start_pos = it.lu.chars().count();
168
+
if let Some(c) = it.clean.chars().skip(start_pos).find(|&r| !used_orig.contains(&r)) {
169
+
used_orig.insert(c);
170
+
let full = format!("{typed_left_unit}{c}");
171
+
if full == typed_clean {
172
+
return Some(Assignment { index: it.index, prefix: String::new() });
173
+
}
174
+
} else if typed_left_unit == typed_clean {
175
+
return Some(Assignment { index: it.index, prefix: String::new() });
176
+
}
177
+
}
178
+
179
+
// fallback lowercased allocation
180
+
let mut used_base = HashSet::new();
181
+
for it in &base_list {
182
+
let start_pos = it.lu.chars().count();
183
+
if let Some(c) = it.lower.chars().skip(start_pos).find(|&r| r != '-' && !used_base.contains(&r)) {
184
+
used_base.insert(c);
185
+
let full = format!("{typed_left_unit}{c}");
186
+
if full == typed_lower {
187
+
return Some(Assignment { index: it.index, prefix: String::new() });
188
+
}
189
+
} else if typed_left_unit == typed_lower {
190
+
return Some(Assignment { index: it.index, prefix: String::new() });
191
+
}
192
+
}
193
+
194
+
None
195
+
}
196
+
197
+
fn filter_candidates(infos: &[ElemInfo], typed_lower: &str, typed_left_unit: &str) -> Vec<ElemInfo> {
198
+
if typed_lower == "-" {
199
+
return infos
200
+
.iter()
201
+
.filter(|it| {
202
+
let lu_lower = it.lu.to_lowercase();
203
+
lu_lower == "-" || lu_lower == "--"
204
+
})
205
+
.cloned()
206
+
.collect();
207
+
}
208
+
209
+
infos
210
+
.iter()
211
+
.filter(|it| {
212
+
let lu_lower = it.lu.to_lowercase();
213
+
if lu_lower != typed_left_unit {
214
+
return false;
215
+
}
216
+
let match_lower = if lu_lower != "--" && !lu_lower.is_empty() {
217
+
collapse_leading(&it.lower, &lu_lower)
218
+
} else {
219
+
it.lower.clone()
220
+
};
221
+
it.lower.starts_with(typed_lower) || match_lower.starts_with(typed_lower)
222
+
})
223
+
.cloned()
224
+
.collect()
225
+
}
226
+
227
+
fn attempt_base_typed_selection_when_no_candidates(infos: &[ElemInfo], typed_lower: &str, typed_left_unit: &str) -> Option<Vec<Assignment>> {
228
+
if typed_left_unit.is_empty() {
229
+
return None;
230
+
}
231
+
if typed_lower.chars().count() <= typed_left_unit.chars().count() {
232
+
return None;
233
+
}
234
+
235
+
let mut base_candidates: Vec<ElemInfo> = infos
236
+
.iter()
237
+
.filter(|it| {
238
+
let lu_lower = it.lu.to_lowercase();
239
+
if typed_left_unit == "-" {
240
+
lu_lower == "-" || lu_lower == "--"
241
+
} else {
242
+
lu_lower == typed_left_unit
243
+
}
244
+
}).filter(|&it| {
245
+
let lu_lower = it.lu.to_lowercase();
246
+
let match_lower = if lu_lower != "--" && !lu_lower.is_empty() {
247
+
collapse_leading(&it.lower, &lu_lower)
248
+
} else {
249
+
it.lower.clone()
250
+
};
251
+
it.lower.starts_with(typed_left_unit) || match_lower.starts_with(typed_left_unit)
252
+
}).cloned()
253
+
.collect();
254
+
255
+
if base_candidates.is_empty() {
256
+
return None;
257
+
}
258
+
259
+
base_candidates.sort_by(|a, b| a.rune_count.cmp(&b.rune_count).then(a.index.cmp(&b.index)));
260
+
261
+
let mut used = HashSet::new();
262
+
let len_base = typed_left_unit.chars().count();
263
+
for bc in &base_candidates {
264
+
if let Some(r) = bc.lower.chars().skip(len_base).find(|&r| r != '-' && !used.contains(&r)) {
265
+
used.insert(r);
266
+
let full = format!("{typed_left_unit}{r}");
267
+
if full == typed_lower {
268
+
return Some(vec![Assignment { index: bc.index, prefix: String::new() }]);
269
+
}
270
+
}
271
+
}
272
+
273
+
None
274
+
}
275
+
276
+
fn exact_case_precedence(candidates: &[ElemInfo], typed_clean: &str) -> Option<Assignment> {
277
+
if typed_clean.is_empty() {
278
+
return None;
279
+
}
280
+
let exact: Vec<&ElemInfo> = candidates.iter().filter(|it| it.clean.starts_with(typed_clean)).collect();
281
+
if exact.len() == 1 {
282
+
let it = exact[0];
283
+
return Some(Assignment { index: it.index, prefix: String::new() });
284
+
}
285
+
None
286
+
}
287
+
288
+
fn filter_exact_matches(candidates: &[ElemInfo], typed_lower: &str) -> Vec<ElemInfo> {
289
+
if typed_lower.is_empty() {
290
+
return candidates.to_vec();
291
+
}
292
+
293
+
let exact_matches: Vec<ElemInfo> = candidates
294
+
.iter()
295
+
.filter_map(|it| {
296
+
let lu_lower = it.lu.to_lowercase();
297
+
let match_lower = if lu_lower != "--" && !lu_lower.is_empty() {
298
+
collapse_leading(&it.lower, &lu_lower)
299
+
} else {
300
+
it.lower.clone()
301
+
};
302
+
if it.lower == typed_lower || (match_lower == typed_lower && it.lower.chars().count() == match_lower.chars().count()) {
303
+
Some(it.clone())
304
+
} else {
305
+
None
306
+
}
307
+
})
308
+
.collect();
309
+
310
+
if exact_matches.is_empty() {
311
+
return candidates.to_vec();
312
+
}
313
+
314
+
let other_starts = candidates.iter().any(|it| it.lower != typed_lower && it.lower.starts_with(typed_lower));
315
+
if !other_starts {
316
+
exact_matches
317
+
} else {
318
+
candidates.to_vec()
319
+
}
320
+
}
321
+
322
+
fn allocate_disambiguators(candidates: &[ElemInfo], typed_lower: &str, elements_count: usize) -> Vec<Assignment> {
323
+
let mut used = HashSet::new();
324
+
let mut assigned: Vec<Option<Assignment>> = vec![None; elements_count];
325
+
let mut order: Vec<ElemInfo> = candidates.to_vec();
326
+
order.sort_by(|a, b| a.rune_count.cmp(&b.rune_count).then(a.index.cmp(&b.index)));
327
+
328
+
// compute the typed left unit (the ace-character to fall back to)
329
+
let typed_left_unit = compute_typed_left_unit(typed_lower);
330
+
331
+
for cand in order {
332
+
let lu_lower = cand.lu.to_lowercase();
333
+
let start_pos = lu_lower.chars().count();
334
+
if let Some(ar) = cand.clean.chars().skip(start_pos).find(|&r| r != '-' && !used.contains(&r)) {
335
+
used.insert(ar);
336
+
assigned[cand.index] = Some(Assignment { index: cand.index, prefix: ar.to_string() });
337
+
} else if lu_lower == "--" && typed_lower == "-" {
338
+
assigned[cand.index] = Some(Assignment { index: cand.index, prefix: "-".to_string() });
339
+
} else {
340
+
// Always use the typed left unit as the prefix when no other disambiguator
341
+
// exists. This ensures every candidate remains selectable.
342
+
if !typed_left_unit.is_empty() {
343
+
assigned[cand.index] = Some(Assignment { index: cand.index, prefix: typed_left_unit.clone() });
344
+
} else {
345
+
// fallback to empty prefix only if there is absolutely nothing sensible to use
346
+
assigned[cand.index] = Some(Assignment { index: cand.index, prefix: String::new() });
347
+
}
348
+
}
349
+
}
350
+
351
+
assigned.into_iter().flatten().collect()
352
+
}
353
+
354
+
// Build collapsed-match strings, start positions and max length for an ordered list
355
+
fn build_ms_maps(order: &[ElemInfo]) -> (HashMap<usize, String>, HashMap<usize, usize>, usize) {
356
+
let mut ms_map: HashMap<usize, String> = HashMap::new();
357
+
let mut start_pos_map: HashMap<usize, usize> = HashMap::new();
358
+
let mut max_len = 0usize;
359
+
for it in order {
360
+
let lu_lower = it.lu.to_lowercase();
361
+
let ms = if lu_lower != "--" && !lu_lower.is_empty() {
362
+
collapse_leading(&it.lower, &lu_lower)
363
+
} else {
364
+
it.lower.clone()
365
+
};
366
+
max_len = max_len.max(ms.chars().count());
367
+
ms_map.insert(it.index, ms);
368
+
start_pos_map.insert(it.index, lu_lower.chars().count());
369
+
}
370
+
(ms_map, start_pos_map, max_len)
371
+
}
372
+
373
+
// Offset-based assignment pass: assign unique characters at each offset among remaining candidates
374
+
fn offset_assignment_pass(
375
+
order: &[ElemInfo],
376
+
ms_map: &HashMap<usize, String>,
377
+
start_pos_map: &HashMap<usize, usize>,
378
+
max_len: usize,
379
+
typed_left_unit: &str,
380
+
assigned: &mut Vec<Option<Assignment>>,
381
+
used: &mut HashSet<char>,
382
+
remaining: &mut Vec<usize>,
383
+
) {
384
+
for offset in 0..max_len {
385
+
if remaining.is_empty() { break; }
386
+
let mut freq: HashMap<char, usize> = HashMap::new();
387
+
for &idx in remaining.iter() {
388
+
if let Some(ms) = ms_map.get(&idx) {
389
+
let start_pos = *start_pos_map.get(&idx).unwrap_or(&0);
390
+
let pos = start_pos + offset;
391
+
if let Some(ch) = ms.chars().nth(pos) {
392
+
if ch != '-' && !used.contains(&ch) {
393
+
if typed_left_unit.is_empty() || ch.to_string() != typed_left_unit {
394
+
*freq.entry(ch).or_insert(0) += 1;
395
+
}
396
+
}
397
+
}
398
+
}
399
+
}
400
+
401
+
let mut newly_assigned: Vec<usize> = Vec::new();
402
+
for it in order {
403
+
let idx = it.index;
404
+
if !remaining.contains(&idx) { continue; }
405
+
if let Some(ms) = ms_map.get(&idx) {
406
+
let start_pos = *start_pos_map.get(&idx).unwrap_or(&0);
407
+
let pos = start_pos + offset;
408
+
if let Some(ch) = ms.chars().nth(pos) {
409
+
if ch != '-' && !used.contains(&ch) && (typed_left_unit.is_empty() || ch.to_string() != typed_left_unit) {
410
+
if let Some(&count) = freq.get(&ch) {
411
+
if count == 1 {
412
+
// prefer original-case char when possible
413
+
if let Some(orig_it) = order.iter().find(|o| o.index == idx) {
414
+
if let Some(orig_ch) = orig_it.clean.chars().nth(pos) {
415
+
if orig_ch != '-' {
416
+
if orig_ch.to_ascii_lowercase() == ch {
417
+
assigned[idx] = Some(Assignment { index: idx, prefix: orig_ch.to_string() });
418
+
} else {
419
+
assigned[idx] = Some(Assignment { index: idx, prefix: ch.to_string() });
420
+
}
421
+
} else {
422
+
assigned[idx] = Some(Assignment { index: idx, prefix: ch.to_string() });
423
+
}
424
+
} else {
425
+
assigned[idx] = Some(Assignment { index: idx, prefix: ch.to_string() });
426
+
}
427
+
} else {
428
+
assigned[idx] = Some(Assignment { index: idx, prefix: ch.to_string() });
429
+
}
430
+
used.insert(ch);
431
+
newly_assigned.push(idx);
432
+
}
433
+
}
434
+
}
435
+
}
436
+
}
437
+
}
438
+
if !newly_assigned.is_empty() {
439
+
remaining.retain(|r| !newly_assigned.contains(r));
440
+
}
441
+
}
442
+
}
443
+
444
+
// Per-candidate left-to-right contiguous pass for remaining candidates
445
+
fn per_candidate_pass(
446
+
order: &[ElemInfo],
447
+
ms_map: &HashMap<usize, String>,
448
+
start_pos_map: &HashMap<usize, usize>,
449
+
typed_left_unit: &str,
450
+
assigned: &mut Vec<Option<Assignment>>,
451
+
used: &mut HashSet<char>,
452
+
remaining: &mut Vec<usize>,
453
+
) {
454
+
let mut newly_assigned_pl: Vec<usize> = Vec::new();
455
+
for it in order {
456
+
let idx = it.index;
457
+
if !remaining.contains(&idx) { continue; }
458
+
if let Some(ms) = ms_map.get(&idx) {
459
+
let start_pos = *start_pos_map.get(&idx).unwrap_or(&0);
460
+
let total = ms.chars().count();
461
+
for pos in start_pos..total {
462
+
if let Some(ch) = ms.chars().nth(pos) {
463
+
if ch == '-' { continue; }
464
+
if !used.contains(&ch) && (typed_left_unit.is_empty() || ch.to_string() != typed_left_unit) {
465
+
if let Some(orig_ch) = it.clean.chars().nth(pos) {
466
+
if orig_ch != '-' {
467
+
if orig_ch.to_ascii_lowercase() == ch {
468
+
assigned[idx] = Some(Assignment { index: idx, prefix: orig_ch.to_string() });
469
+
} else {
470
+
assigned[idx] = Some(Assignment { index: idx, prefix: ch.to_string() });
471
+
}
472
+
} else {
473
+
assigned[idx] = Some(Assignment { index: idx, prefix: ch.to_string() });
474
+
}
475
+
} else {
476
+
assigned[idx] = Some(Assignment { index: idx, prefix: ch.to_string() });
477
+
}
478
+
used.insert(ch);
479
+
newly_assigned_pl.push(idx);
480
+
break;
481
+
}
482
+
}
483
+
}
484
+
}
485
+
}
486
+
if !newly_assigned_pl.is_empty() {
487
+
remaining.retain(|r| !newly_assigned_pl.contains(r));
488
+
}
489
+
}
490
+
491
+
// Last-resort fallback assignment for any remaining candidates
492
+
fn last_resort_assign(
493
+
order: &[ElemInfo],
494
+
ms_map: &HashMap<usize, String>,
495
+
typed_left_unit: &str,
496
+
assigned: &mut Vec<Option<Assignment>>,
497
+
remaining: &Vec<usize>,
498
+
) {
499
+
for idx in remaining.iter() {
500
+
if let Some(ms) = ms_map.get(idx) {
501
+
let mut chosen: Option<String> = None;
502
+
if let Some(ch) = ms.chars().rev().find(|&r| r != '-') {
503
+
if typed_left_unit.is_empty() || ch.to_string() != typed_left_unit {
504
+
let total_chars = ms.chars().count();
505
+
if let Some(pos_rev) = ms.chars().rev().position(|r| r == ch) {
506
+
let pos = total_chars.saturating_sub(1 + pos_rev);
507
+
if let Some(orig_it) = order.iter().find(|o| o.index == *idx) {
508
+
if let Some(orig_ch) = orig_it.clean.chars().nth(pos) {
509
+
if orig_ch != '-' && orig_ch.to_ascii_lowercase() == ch {
510
+
chosen = Some(orig_ch.to_string());
511
+
}
512
+
}
513
+
}
514
+
}
515
+
if chosen.is_none() {
516
+
chosen = Some(ch.to_string());
517
+
}
518
+
} else {
519
+
if let Some(ch2) = ms.chars().rev().find(|&r| r != '-' && (typed_left_unit.is_empty() || r.to_string() != typed_left_unit)) {
520
+
chosen = Some(ch2.to_string());
521
+
} else {
522
+
chosen = Some(ch.to_string());
523
+
}
524
+
}
525
+
}
526
+
527
+
if let Some(pref) = chosen {
528
+
assigned[*idx] = Some(Assignment { index: *idx, prefix: pref });
529
+
} else {
530
+
let lu = order.iter().find(|o| o.index == *idx).map(|o| o.lu.clone()).unwrap_or_default();
531
+
let use_pref = if lu == "--" { "-".to_string() } else { lu };
532
+
assigned[*idx] = Some(Assignment { index: *idx, prefix: use_pref });
533
+
}
534
+
}
535
+
}
536
+
}
537
+
538
+
// Replace the original allocate_disambiguators_filtered body with calls into the helpers
539
+
fn allocate_disambiguators_filtered(candidates: &[ElemInfo], typed_lower: &str, elements_count: usize) -> Vec<Assignment> {
540
+
// Filtered allocator following vic/acekey.md contiguous-right semantics.
541
+
let typed_left_unit = compute_typed_left_unit(typed_lower);
542
+
543
+
// deterministic order
544
+
let mut order: Vec<ElemInfo> = candidates.to_vec();
545
+
order.sort_by(|a, b| a.rune_count.cmp(&b.rune_count).then(a.index.cmp(&b.index)));
546
+
547
+
// build collapsed lowercase match strings and start positions
548
+
let (ms_map, start_pos_map, max_len) = build_ms_maps(&order);
549
+
550
+
let mut assigned: Vec<Option<Assignment>> = vec![None; elements_count];
551
+
let mut used: HashSet<char> = HashSet::new();
552
+
let mut remaining: Vec<usize> = order.iter().map(|o| o.index).collect();
553
+
554
+
// Offset loop
555
+
offset_assignment_pass(&order, &ms_map, &start_pos_map, max_len, &typed_left_unit, &mut assigned, &mut used, &mut remaining);
556
+
557
+
// Per-candidate left-to-right contiguous pass
558
+
if !remaining.is_empty() {
559
+
per_candidate_pass(&order, &ms_map, &start_pos_map, &typed_left_unit, &mut assigned, &mut used, &mut remaining);
560
+
}
561
+
562
+
// Last-resort fallback
563
+
if !remaining.is_empty() {
564
+
last_resort_assign(&order, &ms_map, &typed_left_unit, &mut assigned, &remaining);
565
+
}
566
+
567
+
assigned.into_iter().flatten().collect()
568
+
}
569
+
570
+
pub fn assign_ace_keys(elements: &[String], typed: &str) -> Option<Vec<Assignment>> {
571
+
let infos = build_infos(elements);
572
+
let typed_clean = clean_string(typed);
573
+
let typed_lower = typed_clean.to_lowercase();
574
+
575
+
// compute left unit early
576
+
let typed_left_unit = compute_typed_left_unit(&typed_lower);
577
+
578
+
// If nothing is typed, return initial prefixes (e.g., flags get "-" for "--long").
579
+
if typed_lower.is_empty() {
580
+
let initial_map = assign_initial_candidates(elements);
581
+
let mut res: Vec<Assignment> = Vec::new();
582
+
for (idx, pref) in initial_map.into_iter() {
583
+
res.push(Assignment { index: idx, prefix: pref });
584
+
}
585
+
return Some(res);
586
+
}
587
+
588
+
// quick attempt: try base full-key match only when typed is not a left-unit followed by
589
+
// extra AceKey tokens. If typed is left-unit + extra, prefer the tokenized iterative
590
+
// resolution path below to avoid premature selection.
591
+
if !( !typed_left_unit.is_empty() && typed_lower.starts_with(&typed_left_unit) && typed_lower.chars().count() > typed_left_unit.chars().count() ) {
592
+
if let Some(a) = attempt_base_full_key_match(&infos, &typed_left_unit, &typed_clean, &typed_lower) {
593
+
return Some(vec![a]);
594
+
}
595
+
}
596
+
597
+
// fast path: direct match on clean/typed
598
+
if let Some(idx) = infos.iter().position(|it| it.clean == typed_clean) {
599
+
if !(typed_lower == typed_left_unit && infos.iter().filter(|it| it.lu.to_lowercase() == typed_left_unit).count() > 1) {
600
+
return Some(vec![Assignment { index: idx, prefix: String::new() }]);
601
+
}
602
+
}
603
+
604
+
// Step 1: exact case match precedence
605
+
if let Some(exact) = exact_case_precedence(&infos, &typed_clean) {
606
+
return Some(vec![exact]);
607
+
}
608
+
609
+
// Step 2: candidate filtering from infos based on typed and left unit
610
+
let mut maybe_candidates: Option<Vec<ElemInfo>> = None;
611
+
if !typed_left_unit.is_empty() && typed_lower.starts_with(&typed_left_unit)
612
+
&& typed_lower.chars().count() > typed_left_unit.chars().count()
613
+
{
614
+
// treat the extra runes after the left-unit as a sequence of AceKey tokens
615
+
// and iteratively recompute disambiguators narrowing the candidate set per token.
616
+
let base_list: Vec<ElemInfo> = infos
617
+
.iter()
618
+
.filter(|it| {
619
+
let lu_lower = it.lu.to_lowercase();
620
+
if typed_left_unit == "-" {
621
+
lu_lower == "-" || lu_lower == "--"
622
+
} else {
623
+
lu_lower == typed_left_unit
624
+
}
625
+
})
626
+
.cloned()
627
+
.collect();
628
+
629
+
if !base_list.is_empty() {
630
+
// collect token chars (each rune typed after the left-unit)
631
+
let extra_chars: Vec<char> = typed_lower.chars().skip(typed_left_unit.chars().count()).collect();
632
+
// iteratively consume tokens
633
+
let mut narrowed = base_list;
634
+
let mut consumed_any = false;
635
+
// compute the base snapshot assignments once (this reflects what the UI shows)
636
+
let base_assigns = allocate_disambiguators_filtered(&narrowed, &typed_left_unit, elements.len());
637
+
for (i, token) in extra_chars.iter().enumerate() {
638
+
if narrowed.len() <= 1 {
639
+
break;
640
+
}
641
+
// use base snapshot for the first token so typing the shown disambiguator
642
+
// selects from the items that were assigned that rune at the initial stage.
643
+
let assigns = if i == 0 {
644
+
base_assigns.clone()
645
+
} else {
646
+
allocate_disambiguators_filtered(&narrowed, &typed_left_unit, elements.len())
647
+
};
648
+
let token_str = token.to_string();
649
+
650
+
// find indices assigned this token
651
+
let matching_idxs: Vec<usize> = assigns
652
+
.into_iter()
653
+
.filter(|a| a.prefix.to_lowercase() == token_str)
654
+
.map(|a| a.index)
655
+
.collect();
656
+
657
+
if matching_idxs.is_empty() {
658
+
// token did not match any assigned disambiguator in this narrowed set;
659
+
// stop iterative token resolution and fall back to standard filtering
660
+
break;
661
+
}
662
+
663
+
consumed_any = true;
664
+
if matching_idxs.len() == 1 {
665
+
// unique selection reached
666
+
return Some(vec![Assignment { index: matching_idxs[0], prefix: String::new() }]);
667
+
}
668
+
669
+
// narrow the candidate list to those matching indices and continue
670
+
let set: std::collections::HashSet<usize> = matching_idxs.into_iter().collect();
671
+
narrowed.retain(|it| set.contains(&it.index));
672
+
}
673
+
674
+
if consumed_any {
675
+
// if we consumed at least one token but didn't resolve to a single item,
676
+
// use the narrowed candidate set for the later allocation steps
677
+
maybe_candidates = Some(narrowed);
678
+
}
679
+
}
680
+
}
681
+
682
+
// remember whether we arrived here after tokenized narrowing so we can
683
+
// choose a matching allocator later without relying on maybe_candidates
684
+
// which will be consumed by the candidate selection below.
685
+
let used_token_narrowing = maybe_candidates.is_some();
686
+
let mut candidates: Vec<ElemInfo> = if let Some(v) = maybe_candidates { v } else { filter_candidates(&infos, &typed_lower, &typed_left_unit) };
687
+
688
+
if candidates.is_empty() {
689
+
if let Some(res) = attempt_base_typed_selection_when_no_candidates(&infos, &typed_lower, &typed_left_unit) {
690
+
return Some(res);
691
+
}
692
+
return None;
693
+
}
694
+
695
+
// Step 3: exact-case precedence among filtered candidates
696
+
if let Some(a) = exact_case_precedence(&candidates, &typed_clean) {
697
+
return Some(vec![a]);
698
+
}
699
+
700
+
// Step 4: filtered recomputation when typed equals left-unit
701
+
if typed_lower == typed_left_unit && candidates.len() > 1 {
702
+
let res = allocate_disambiguators_filtered(&candidates, &typed_lower, elements.len());
703
+
return Some(res);
704
+
}
705
+
706
+
// Step 5: filter exact matches and possibly select single
707
+
candidates = filter_exact_matches(&candidates, &typed_lower);
708
+
if candidates.len() == 1 && !typed_lower.is_empty() {
709
+
return Some(vec![Assignment { index: candidates[0].index, prefix: String::new() }]);
710
+
}
711
+
712
+
// Step 6: default disambiguator allocation
713
+
let final_res = if used_token_narrowing {
714
+
allocate_disambiguators_filtered(&candidates, &typed_left_unit, elements.len())
715
+
} else {
716
+
allocate_disambiguators(&candidates, &typed_lower, elements.len())
717
+
};
718
+
Some(final_res)
719
+
}
720
+
721
+
#[cfg(test)]
722
+
mod acekey_tests {
723
+
use super::*;
724
+
use std::collections::HashSet;
725
+
726
+
#[test]
727
+
fn test_contiguous_unique_ch_examples() {
728
+
let els = ["chcpu", "chpasswd", "chsh"];
729
+
let elems: Vec<String> = els.iter().map(|s| s.to_string()).collect();
730
+
let res = assign_ace_keys(&elems, "c");
731
+
assert!(res.is_some(), "expected assign_ace_keys to return assignments");
732
+
let v = res.unwrap();
733
+
// Expect one assignment per element
734
+
assert_eq!(v.len(), 3);
735
+
// None of the returned assignments should reuse the left-unit 'c'
736
+
for a in v.iter() {
737
+
assert!(!a.prefix.is_empty(), "expected non-empty prefix for index {}", a.index);
738
+
assert_ne!(a.prefix, "c", "left-unit reuse not allowed for index {}", a.index);
739
+
}
740
+
// All assigned prefixes should be unique
741
+
let mut seen = HashSet::new();
742
+
for a in v.iter() {
743
+
assert!(seen.insert(a.prefix.clone()), "duplicate prefix {}", a.prefix);
744
+
}
745
+
}
746
+
}
+397
crates/src/ast.rs
+397
crates/src/ast.rs
···
···
1
+
use serde::Deserialize;
2
+
3
+
#[derive(Debug, Clone, Deserialize)]
4
+
#[serde(rename_all = "PascalCase")]
5
+
pub struct FlagDef {
6
+
pub longhand: String,
7
+
pub shorthand: String,
8
+
pub usage: String,
9
+
pub requires_value: bool,
10
+
}
11
+
12
+
#[derive(Debug, Clone, Deserialize)]
13
+
#[serde(rename_all = "PascalCase")]
14
+
pub struct CommandDef {
15
+
pub name: String,
16
+
pub short: String,
17
+
pub aliases: Vec<String>,
18
+
pub flags: Vec<FlagDef>,
19
+
pub subcommands: Vec<CommandDef>,
20
+
}
21
+
22
+
#[derive(Debug, Clone)]
23
+
pub struct FlagInstance {
24
+
pub form: String,
25
+
pub value: String,
26
+
}
27
+
28
+
#[derive(Debug, Clone, Default)]
29
+
pub struct CommandNode {
30
+
pub name: String,
31
+
pub flags: Vec<FlagInstance>,
32
+
pub positionals: Vec<String>,
33
+
}
34
+
35
+
#[derive(Debug, Clone)]
36
+
pub struct HistoryOp {
37
+
pub kind: String,
38
+
pub depth: usize,
39
+
}
40
+
41
+
// Story 1.2: Redirection enum
42
+
#[derive(Debug, Clone, PartialEq, Eq)]
43
+
pub enum Redirection {
44
+
Input(String),
45
+
Output { file: String, append: bool },
46
+
}
47
+
48
+
// Story 1.2: Binary operators connecting segments (future use)
49
+
#[derive(Debug, Clone, PartialEq, Eq)]
50
+
pub enum BinaryOp {
51
+
Pipe,
52
+
And,
53
+
Or,
54
+
}
55
+
56
+
// Renamed from AST -> Segment (Story 1.1)
57
+
#[derive(Debug, Clone, Default)]
58
+
pub struct Segment {
59
+
pub root: String,
60
+
pub stack: Vec<CommandNode>,
61
+
pub history: Vec<HistoryOp>,
62
+
pub redirections: Vec<Redirection>, // Story 1.2
63
+
}
64
+
65
+
impl Segment {
66
+
pub fn new_empty(root: &str) -> Self {
67
+
let n = CommandNode {
68
+
name: root.to_string(),
69
+
flags: vec![],
70
+
positionals: vec![],
71
+
};
72
+
Segment {
73
+
root: root.to_string(),
74
+
stack: vec![n],
75
+
history: vec![],
76
+
redirections: vec![],
77
+
}
78
+
}
79
+
80
+
pub fn top(&self) -> Option<&CommandNode> {
81
+
self.stack.last()
82
+
}
83
+
84
+
pub fn push_subcommand(&mut self, name: &str) {
85
+
let n = CommandNode {
86
+
name: name.to_string(),
87
+
flags: vec![],
88
+
positionals: vec![],
89
+
};
90
+
self.stack.push(n);
91
+
self.history.push(HistoryOp {
92
+
kind: "subcmd".to_string(),
93
+
depth: self.stack.len() - 1,
94
+
});
95
+
}
96
+
97
+
pub fn pop(&mut self) {
98
+
if self.stack.len() <= 1 {
99
+
return;
100
+
}
101
+
self.stack.pop();
102
+
}
103
+
104
+
pub fn add_flag_to_depth(&mut self, depth: usize, form: &str, value: &str) {
105
+
if depth >= self.stack.len() {
106
+
return;
107
+
}
108
+
let fi = FlagInstance {
109
+
form: form.to_string(),
110
+
value: value.to_string(),
111
+
};
112
+
self.stack[depth].flags.push(fi);
113
+
self.history.push(HistoryOp {
114
+
kind: "flag".to_string(),
115
+
depth,
116
+
});
117
+
}
118
+
119
+
pub fn add_flag(&mut self, form: &str, value: &str) {
120
+
if let Some(depth) = self.stack.len().checked_sub(1) {
121
+
self.add_flag_to_depth(depth, form, value);
122
+
}
123
+
}
124
+
125
+
pub fn remove_flag_from_depth(&mut self, form: &str, depth: usize) -> bool {
126
+
if depth >= self.stack.len() {
127
+
return false;
128
+
}
129
+
let node = &mut self.stack[depth];
130
+
if let Some(pos) = node.flags.iter().rposition(|f| f.form == form) {
131
+
node.flags.remove(pos);
132
+
return true;
133
+
}
134
+
false
135
+
}
136
+
137
+
pub fn remove_flag(&mut self, form: &str) -> bool {
138
+
if self.stack.is_empty() {
139
+
return false;
140
+
}
141
+
let depth = self.stack.len() - 1;
142
+
self.remove_flag_from_depth(form, depth)
143
+
}
144
+
145
+
pub fn add_positional(&mut self, val: &str) {
146
+
if let Some(node) = self.stack.last_mut() {
147
+
node.positionals.push(val.to_string());
148
+
self.history.push(HistoryOp {
149
+
kind: "pos".to_string(),
150
+
depth: self.stack.len() - 1,
151
+
});
152
+
}
153
+
}
154
+
155
+
pub fn remove_last(&mut self) {
156
+
if self.history.is_empty() {
157
+
if let Some(n) = self.stack.last_mut() {
158
+
if n.flags.pop().is_some() {
159
+
return;
160
+
}
161
+
if n.positionals.pop().is_some() {
162
+
return;
163
+
}
164
+
if self.stack.len() > 1 {
165
+
self.pop();
166
+
}
167
+
}
168
+
return;
169
+
}
170
+
171
+
if let Some(op) = self.history.pop() {
172
+
match op.kind.as_str() {
173
+
"flag" => {
174
+
if op.depth < self.stack.len() {
175
+
let n = &mut self.stack[op.depth];
176
+
n.flags.pop();
177
+
}
178
+
}
179
+
"pos" => {
180
+
if op.depth < self.stack.len() {
181
+
let n = &mut self.stack[op.depth];
182
+
n.positionals.pop();
183
+
}
184
+
}
185
+
"subcmd" => {
186
+
if self.stack.len() > 1 {
187
+
self.pop();
188
+
}
189
+
}
190
+
_ => {}
191
+
}
192
+
}
193
+
}
194
+
195
+
pub fn render_preview(&self) -> String {
196
+
let mut parts: Vec<String> = vec![self.root.clone()];
197
+
198
+
let append_node = |node: &CommandNode, include_name: bool, out: &mut Vec<String>| {
199
+
if include_name {
200
+
out.push(node.name.clone());
201
+
}
202
+
for f in &node.flags {
203
+
out.push(f.form.clone());
204
+
if !f.value.is_empty() {
205
+
out.push(f.value.clone());
206
+
}
207
+
}
208
+
for p in &node.positionals {
209
+
out.push(p.clone());
210
+
}
211
+
};
212
+
213
+
for (i, node) in self.stack.iter().enumerate() {
214
+
if i == 0 {
215
+
append_node(node, false, &mut parts);
216
+
} else {
217
+
append_node(node, true, &mut parts);
218
+
}
219
+
}
220
+
221
+
parts.join(" ")
222
+
}
223
+
}
224
+
225
+
#[derive(Debug, Clone, Default)]
226
+
pub struct CommandLine {
227
+
pub segments: Vec<Segment>,
228
+
pub focused_segment_idx: usize,
229
+
}
230
+
231
+
impl CommandLine {
232
+
pub fn new() -> Self {
233
+
Self {
234
+
segments: vec![Segment::new_empty("")],
235
+
focused_segment_idx: 0,
236
+
}
237
+
}
238
+
239
+
pub fn focused_segment(&self) -> &Segment {
240
+
&self.segments[self.focused_segment_idx]
241
+
}
242
+
243
+
pub fn focused_segment_mut(&mut self) -> &mut Segment {
244
+
&mut self.segments[self.focused_segment_idx]
245
+
}
246
+
247
+
pub fn add_segment(&mut self) {
248
+
self.segments.push(Segment::new_empty(""));
249
+
self.focused_segment_idx = self.segments.len() - 1;
250
+
}
251
+
252
+
pub fn remove_focused_segment(&mut self) {
253
+
if self.segments.len() > 1 && self.focused_segment().root.is_empty() {
254
+
let idx = self.focused_segment_idx;
255
+
self.segments.remove(idx);
256
+
if self.focused_segment_idx > 0 {
257
+
self.focused_segment_idx -= 1;
258
+
}
259
+
}
260
+
}
261
+
262
+
pub fn focus_next(&mut self) {
263
+
if self.focused_segment_idx + 1 < self.segments.len() {
264
+
self.focused_segment_idx += 1;
265
+
}
266
+
}
267
+
268
+
pub fn focus_prev(&mut self) {
269
+
if self.focused_segment_idx > 0 {
270
+
self.focused_segment_idx -= 1;
271
+
}
272
+
}
273
+
274
+
pub fn render_preview(&self) -> String {
275
+
self.segments
276
+
.iter()
277
+
.map(|s| s.render_preview())
278
+
.collect::<Vec<_>>()
279
+
.join(" | ")
280
+
}
281
+
}
282
+
283
+
// Tests for Story 1.2 (written before implementation of Redirection/BinaryOp additions)
284
+
#[cfg(test)]
285
+
mod tests {
286
+
use super::*;
287
+
288
+
#[test]
289
+
fn test_redirection_struct() {
290
+
let r1 = Redirection::Input("in.txt".to_string());
291
+
let r2 = Redirection::Output {
292
+
file: "out.txt".to_string(),
293
+
append: false,
294
+
};
295
+
match r1 {
296
+
Redirection::Input(f) => assert_eq!(f, "in.txt"),
297
+
_ => panic!("expected Input"),
298
+
}
299
+
match r2 {
300
+
Redirection::Output { file, append } => {
301
+
assert_eq!(file, "out.txt");
302
+
assert!(!append);
303
+
}
304
+
_ => panic!("expected Output"),
305
+
}
306
+
}
307
+
308
+
#[test]
309
+
fn test_binary_op_enum() {
310
+
let p = BinaryOp::Pipe;
311
+
let a = BinaryOp::And;
312
+
let o = BinaryOp::Or;
313
+
assert!(matches!(p, BinaryOp::Pipe));
314
+
assert!(matches!(a, BinaryOp::And));
315
+
assert!(matches!(o, BinaryOp::Or));
316
+
}
317
+
318
+
#[test]
319
+
fn test_segment_with_redirections_field_exists() {
320
+
let seg = Segment::new_empty("cmd");
321
+
assert!(seg.redirections.is_empty());
322
+
}
323
+
324
+
#[test]
325
+
fn test_command_line_render_preview_single() {
326
+
let mut cl = CommandLine::new();
327
+
cl.focused_segment_mut().root = "cmd1".into();
328
+
assert_eq!(cl.render_preview(), "cmd1");
329
+
}
330
+
331
+
#[test]
332
+
fn test_command_line_render_preview_pipe() {
333
+
let mut cl = CommandLine::new();
334
+
cl.focused_segment_mut().root = "cmd1".into();
335
+
cl.add_segment();
336
+
cl.focused_segment_mut().root = "cmd2".into();
337
+
assert_eq!(cl.render_preview(), "cmd1 | cmd2");
338
+
}
339
+
340
+
// pending
341
+
#[test]
342
+
fn test_command_line_render_preview_with_redirection() {
343
+
// pending
344
+
// let mut cl = CommandLine::new();
345
+
// cl.focused_segment_mut().root = "cmd1".into();
346
+
// cl.focused_segment_mut().redirections.push(Redirection::Output {
347
+
// file: "out.txt".into(),
348
+
// append: false,
349
+
// });
350
+
// cl.add_segment();
351
+
// cl.focused_segment_mut().root = "cmd2".into();
352
+
// cl.focused_segment_mut().redirections.push(Redirection::Input("in.txt".into()));
353
+
// let preview = cl.render_preview();
354
+
// assert!(preview.contains("> out.txt"));
355
+
// assert!(preview.contains("cmd1"));
356
+
// assert!(preview.contains("cmd2"));
357
+
// assert!(preview.contains("< in.txt"));
358
+
}
359
+
360
+
#[test]
361
+
fn test_command_line_focus_management() {
362
+
let mut cl = CommandLine::new();
363
+
cl.add_segment();
364
+
assert_eq!(cl.focused_segment_idx, 1);
365
+
cl.focus_prev();
366
+
assert_eq!(cl.focused_segment_idx, 0);
367
+
cl.focus_prev();
368
+
assert_eq!(cl.focused_segment_idx, 0);
369
+
cl.focus_next();
370
+
assert_eq!(cl.focused_segment_idx, 1);
371
+
cl.focus_next();
372
+
assert_eq!(cl.focused_segment_idx, 1);
373
+
}
374
+
375
+
#[test]
376
+
fn test_command_line_add_segment() {
377
+
let mut cl = CommandLine::new();
378
+
cl.focused_segment_mut().root = "cmd1".into();
379
+
cl.add_segment();
380
+
assert_eq!(cl.segments.len(), 2);
381
+
assert_eq!(cl.focused_segment_idx, 1);
382
+
}
383
+
384
+
#[test]
385
+
fn test_command_line_remove_segment() {
386
+
let mut cl = CommandLine::new();
387
+
cl.focused_segment_mut().root = "cmd1".into();
388
+
cl.add_segment();
389
+
// second segment empty -> removable
390
+
cl.remove_focused_segment();
391
+
assert_eq!(cl.segments.len(), 1);
392
+
assert_eq!(cl.focused_segment_idx, 0);
393
+
// cannot remove non-empty first
394
+
cl.remove_focused_segment();
395
+
assert_eq!(cl.segments.len(), 1);
396
+
}
397
+
}
+129
crates/src/carapace.rs
+129
crates/src/carapace.rs
···
···
1
+
use crate::ast::{CommandDef, FlagDef};
2
+
use std::process::Command;
3
+
4
+
fn run_carapace_cmd(args: &[&str]) -> Result<String, String> {
5
+
let mut cmd = Command::new("carapace");
6
+
for a in args {
7
+
cmd.arg(a);
8
+
}
9
+
let out = cmd
10
+
.output()
11
+
.map_err(|e| format!("carapace {args:?} failed to run: {e}"))?;
12
+
if !out.status.success() {
13
+
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
14
+
return Err(format!("carapace {:?} failed: {}", args, stderr.trim()));
15
+
}
16
+
Ok(String::from_utf8_lossy(&out.stdout).to_string())
17
+
}
18
+
19
+
pub fn list() -> Result<Vec<String>, String> {
20
+
let s = run_carapace_cmd(&["--list"])?;
21
+
Ok(s.lines()
22
+
.map(|l| l.trim())
23
+
.filter(|l| !l.is_empty())
24
+
.filter_map(|l| l.split_whitespace().next())
25
+
.filter(|name| which::which(name).is_ok())
26
+
.map(|s| s.to_string())
27
+
.collect())
28
+
}
29
+
30
+
pub fn list_with_desc() -> Result<Vec<(String, String)>, String> {
31
+
let s = run_carapace_cmd(&["--list"])?;
32
+
let out: Vec<(String, String)> = s
33
+
.lines()
34
+
.map(|l| l.trim())
35
+
.filter(|l| !l.is_empty())
36
+
.filter_map(|line| {
37
+
line.split_whitespace()
38
+
.next()
39
+
.and_then(|name| {
40
+
if which::which(name).is_ok() {
41
+
let short = if line.len() > name.len() {
42
+
line[name.len()..].trim().to_string()
43
+
} else {
44
+
String::new()
45
+
};
46
+
Some((name.to_string(), short))
47
+
} else {
48
+
None
49
+
}
50
+
})
51
+
})
52
+
.collect();
53
+
Ok(out)
54
+
}
55
+
56
+
pub fn export(cmd_name: &str) -> Result<CommandDef, String> {
57
+
if cmd_name.trim().is_empty() {
58
+
return Err("empty command name".to_string());
59
+
}
60
+
let s = run_carapace_cmd(&[cmd_name, "export"])?;
61
+
62
+
let r: serde_json::Value = serde_json::from_str(&s)
63
+
.map_err(|e| format!("failed to parse carapace export JSON: {e}"))?;
64
+
65
+
fn map_raw(r: &serde_json::Value) -> CommandDef {
66
+
let name = r
67
+
.get("Name")
68
+
.and_then(|v| v.as_str())
69
+
.unwrap_or("")
70
+
.to_string();
71
+
let short = r
72
+
.get("Short")
73
+
.and_then(|v| v.as_str())
74
+
.unwrap_or("")
75
+
.to_string();
76
+
let aliases = r
77
+
.get("Aliases")
78
+
.and_then(|v| v.as_array())
79
+
.map(|arr| {
80
+
arr.iter()
81
+
.filter_map(|x| x.as_str().map(|s| s.to_string()))
82
+
.collect()
83
+
})
84
+
.unwrap_or_default();
85
+
let mut flags = Vec::new();
86
+
if let Some(local) = r.get("LocalFlags").and_then(|v| v.as_array()) {
87
+
for f in local {
88
+
let long = f
89
+
.get("Longhand")
90
+
.and_then(|v| v.as_str())
91
+
.unwrap_or("")
92
+
.to_string();
93
+
let shortf = f
94
+
.get("Shorthand")
95
+
.and_then(|v| v.as_str())
96
+
.unwrap_or("")
97
+
.to_string();
98
+
let usage = f
99
+
.get("Usage")
100
+
.and_then(|v| v.as_str())
101
+
.unwrap_or("")
102
+
.to_string();
103
+
let typ = f.get("Type").and_then(|v| v.as_str()).unwrap_or("bool");
104
+
let fd = FlagDef {
105
+
longhand: long,
106
+
shorthand: shortf,
107
+
usage,
108
+
requires_value: typ != "bool",
109
+
};
110
+
flags.push(fd);
111
+
}
112
+
}
113
+
let mut subs = Vec::new();
114
+
if let Some(cmds) = r.get("Commands").and_then(|v| v.as_array()) {
115
+
for c in cmds {
116
+
subs.push(map_raw(c));
117
+
}
118
+
}
119
+
CommandDef {
120
+
name,
121
+
short,
122
+
aliases,
123
+
flags,
124
+
subcommands: subs,
125
+
}
126
+
}
127
+
128
+
Ok(map_raw(&r))
129
+
}
+18
crates/src/lib.rs
+18
crates/src/lib.rs
···
···
1
+
//! van - interactive command completion preview tool
2
+
//!
3
+
//! Library crate exposing the small components used by the binary.
4
+
//!
5
+
//! Tests live close to the modules they exercise as unit tests.
6
+
7
+
pub mod acekey;
8
+
pub mod ast;
9
+
pub mod carapace;
10
+
11
+
pub mod ui;
12
+
13
+
// Keep crate root minimal; tests moved into module files.
14
+
15
+
#[cfg(test)]
16
+
mod _root_tests {
17
+
// intentionally empty
18
+
}
+522
crates/src/main.rs
+522
crates/src/main.rs
···
···
1
+
// Entry point: program main
2
+
// Handles --hook, --exe, --help, and runs the TUI
3
+
//
4
+
// TUI Docs: https://github.com/whit3rabbit/bubbletea-rs look for related crates there and examples on each of them.
5
+
6
+
use std::env;
7
+
use std::fs;
8
+
use std::path::Path;
9
+
use std::process::{self, Command, Stdio};
10
+
use van::ui::{Model as UiModel, initial_model, run as noninteractive_run};
11
+
12
+
use bubbletea_rs::{
13
+
Program, event::KeyMsg, event::WindowSizeMsg, model::Model as TeaModel, window_size,
14
+
};
15
+
use crossterm::event::{KeyCode, KeyModifiers};
16
+
17
+
// Adapter type implementing bubbletea-rs Model trait by delegating to our UiModel
18
+
struct TeaAdapter {
19
+
inner: UiModel,
20
+
}
21
+
22
+
impl TeaModel for TeaAdapter {
23
+
fn init() -> (Self, Option<bubbletea_rs::command::Cmd>) {
24
+
// preload carapace --list with descriptions so interactive UI shows top-level commands immediately
25
+
let entries = van::carapace::list_with_desc().unwrap_or_default();
26
+
let mut adapter = TeaAdapter {
27
+
inner: initial_model(entries),
28
+
};
29
+
let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
30
+
adapter.inner.update(van::ui::Msg::WindowSize {
31
+
width: width as usize,
32
+
height: height as usize,
33
+
});
34
+
let cmd = window_size();
35
+
(adapter, Some(cmd))
36
+
}
37
+
38
+
fn update(&mut self, msg: bubbletea_rs::event::Msg) -> Option<bubbletea_rs::command::Cmd> {
39
+
// Map bubbletea-rs Msg types to our ui::Msg and call update
40
+
if let Some(km) = msg.downcast_ref::<KeyMsg>() {
41
+
// Structured handling using crossterm types (KeyCode, KeyModifiers)
42
+
match &km.key {
43
+
KeyCode::Enter => {
44
+
// Enter -> perform ExecProcess semantics
45
+
self.inner.update(van::ui::Msg::KeyEnter);
46
+
let preview = &self.inner.exit_preview;
47
+
if preview.is_empty() {
48
+
return None;
49
+
}
50
+
let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
51
+
let mut cmd = Command::new(shell);
52
+
cmd.arg("-c")
53
+
.arg(preview)
54
+
.stdin(Stdio::inherit())
55
+
.stdout(Stdio::inherit())
56
+
.stderr(Stdio::inherit());
57
+
match cmd.status() {
58
+
Ok(status) => {
59
+
if let Some(code) = status.code() {
60
+
process::exit(code);
61
+
} else {
62
+
process::exit(0);
63
+
}
64
+
}
65
+
Err(e) => {
66
+
eprintln!("failed to execute command: {e}");
67
+
process::exit(1);
68
+
}
69
+
}
70
+
}
71
+
KeyCode::Backspace => {
72
+
self.inner.update(van::ui::Msg::KeyBackspace);
73
+
}
74
+
KeyCode::Esc => {
75
+
// Quit immediately unless we're in value-input mode
76
+
if !self.inner.in_value_mode {
77
+
return Some(bubbletea_rs::quit());
78
+
}
79
+
self.inner.update(van::ui::Msg::KeyEsc);
80
+
}
81
+
KeyCode::Up => {
82
+
self.inner.update(van::ui::Msg::KeyUp);
83
+
}
84
+
KeyCode::Down => {
85
+
self.inner.update(van::ui::Msg::KeyDown);
86
+
}
87
+
KeyCode::Char(ch) => {
88
+
// Control-key handling
89
+
if km.modifiers.contains(KeyModifiers::CONTROL) {
90
+
match ch {
91
+
'n' | 'N' => {
92
+
self.inner.update(van::ui::Msg::KeyDown);
93
+
}
94
+
'p' | 'P' => {
95
+
self.inner.update(van::ui::Msg::KeyUp);
96
+
}
97
+
'c' | 'C' => {
98
+
return Some(bubbletea_rs::quit());
99
+
}
100
+
_ => {}
101
+
}
102
+
} else if *ch == ' ' {
103
+
self.inner.update(van::ui::Msg::KeySpace);
104
+
} else {
105
+
self.inner.update(van::ui::Msg::Rune(*ch));
106
+
}
107
+
}
108
+
_ => { /* ignore other keys */ }
109
+
}
110
+
111
+
return None;
112
+
}
113
+
if let Some(ws) = msg.downcast_ref::<WindowSizeMsg>() {
114
+
self.inner.update(van::ui::Msg::WindowSize {
115
+
width: ws.width as usize,
116
+
height: ws.height as usize,
117
+
});
118
+
return None;
119
+
}
120
+
None
121
+
}
122
+
123
+
fn view(&self) -> String {
124
+
// delegate to UiModel's styled renderer
125
+
self.inner.render_full()
126
+
}
127
+
}
128
+
129
+
fn print_help() {
130
+
println!("van - interactive command completion preview tool");
131
+
println!();
132
+
println!("Usage:");
133
+
println!(" van [<command> [args...]]");
134
+
println!();
135
+
println!("Options:");
136
+
println!(
137
+
" --hook <shell> Output shell hook code for <shell>. Supported: bash, zsh, fish, nushell. If <shell> omitted, auto-detects from $SHELL and falls back to bash."
138
+
);
139
+
println!(
140
+
" --exe <cmd> Optional: override the executable string to embed in the hook (e.g. './target/debug/van')."
141
+
);
142
+
println!(" --help Show this help message.");
143
+
println!();
144
+
println!("Description:");
145
+
println!(
146
+
" When the hook is installed in your shell, your shell will invoke \"<exe> <command line>\" to produce completion candidates for the currently typed command line. For example, if you type 'jj commit' and press TAB, the shell will call '<exe> jj commit' to obtain completion items."
147
+
);
148
+
println!();
149
+
println!("Installation example (bash):");
150
+
println!(" van --hook bash > ~/.van_hook.sh");
151
+
println!(" source ~/.van_hook.sh");
152
+
}
153
+
154
+
// shell_single_quote safely single-quotes s for embedding in POSIX shells.
155
+
fn shell_single_quote(s: &str) -> String {
156
+
if s.is_empty() {
157
+
return "''".to_string();
158
+
}
159
+
let escaped = s.replace('\'', "'\\''");
160
+
format!("'{escaped}'")
161
+
}
162
+
163
+
// parse_run_from_parts tries to find a '<exe> run' invocation in parts and reconstruct the run command string
164
+
fn parse_run_from_parts(parts: &[String]) -> Option<String> {
165
+
// look for a pair where the second token is "run" and then collect valid run args after it
166
+
parts.windows(2).enumerate().find_map(|(i, pair)| {
167
+
if pair[1] != "run" {
168
+
return None;
169
+
}
170
+
let run_args: Vec<String> = parts
171
+
.iter()
172
+
.skip(i + 2)
173
+
.take_while(|t| {
174
+
if t.is_empty() || t.starts_with('-') {
175
+
return false;
176
+
}
177
+
if Path::new(t).exists() {
178
+
return true;
179
+
}
180
+
// Treat explicit paths or relative paths as run arguments
181
+
if t.contains('/') || t.starts_with("./") || t.starts_with("../") {
182
+
return true;
183
+
}
184
+
false
185
+
})
186
+
.cloned()
187
+
.collect();
188
+
189
+
if run_args.is_empty() {
190
+
None
191
+
} else {
192
+
Some(format!("run {}", run_args.join(" ")))
193
+
}
194
+
})
195
+
}
196
+
197
+
// detect_exec_from_parent: attempts to determine the original executable string used to invoke this program.
198
+
fn detect_exec_from_parent() -> String {
199
+
// default to argv[0]
200
+
let default_exe = env::args().next().unwrap_or_default();
201
+
202
+
// try to determine parent pid via ps -p <pid> -o ppid=
203
+
let pid = process::id();
204
+
let ppid_out = Command::new("ps")
205
+
.arg("-p")
206
+
.arg(pid.to_string())
207
+
.arg("-o")
208
+
.arg("ppid=")
209
+
.output();
210
+
if let Ok(out) = ppid_out {
211
+
if out.status.success() {
212
+
if let Ok(ppid_str) = String::from_utf8(out.stdout) {
213
+
if let Ok(ppid) = ppid_str.trim().parse::<u32>() {
214
+
// platform-specific detection
215
+
if cfg!(target_os = "linux") {
216
+
// linux: try reading /proc/<ppid>/cmdline
217
+
let proc_cmd = format!("/proc/{ppid}/cmdline");
218
+
if let Ok(data) = fs::read(&proc_cmd) {
219
+
// cmdline is NUL-separated; convert bytes to UTF-8 and split on NULs
220
+
if let Ok(raw) = String::from_utf8(data) {
221
+
let parts: Vec<String> = raw
222
+
.split('\0')
223
+
.filter(|s| !s.is_empty())
224
+
.map(|s| s.to_string())
225
+
.collect();
226
+
if let Some(r) = parse_run_from_parts(&parts) {
227
+
return r;
228
+
}
229
+
}
230
+
}
231
+
// fallback: use ps -p <ppid> -o command=
232
+
if let Some(cmdline) = get_ps_command(ppid) {
233
+
let cmdline = cmdline.trim();
234
+
if let Some(idx) = cmdline.find("run ") {
235
+
let rest = cmdline[idx + "run ".len()..].trim();
236
+
if !rest.is_empty() {
237
+
return format!("run {rest}");
238
+
}
239
+
}
240
+
}
241
+
} else if cfg!(target_os = "macos") {
242
+
if let Some(cmdline) = get_ps_command(ppid) {
243
+
let cmdline = cmdline.trim();
244
+
if let Some(idx) = cmdline.find("run ") {
245
+
let rest = cmdline[idx + "run ".len()..].trim();
246
+
if !rest.is_empty() {
247
+
return format!("run {rest}");
248
+
}
249
+
}
250
+
}
251
+
} else if cfg!(target_os = "windows") {
252
+
// windows: query via PowerShell
253
+
let ps_cmd = format!(
254
+
"Get-CimInstance Win32_Process -Filter \"ProcessId={ppid}\" | Select-Object -ExpandProperty CommandLine"
255
+
);
256
+
let out = Command::new("powershell")
257
+
.arg("-NoProfile")
258
+
.arg("-Command")
259
+
.arg(ps_cmd)
260
+
.output();
261
+
if let Ok(o2) = out {
262
+
if o2.status.success() {
263
+
if let Ok(cmdline) = String::from_utf8(o2.stdout) {
264
+
let cmdline = cmdline.trim();
265
+
if !cmdline.is_empty() {
266
+
if let Some(idx) = cmdline.to_lowercase().find("run ") {
267
+
// preserve original-case remainder
268
+
let rest = cmdline[idx + "run ".len()..].trim();
269
+
if !rest.is_empty() {
270
+
return format!("run {rest}");
271
+
}
272
+
}
273
+
}
274
+
}
275
+
}
276
+
}
277
+
} else if let Some(cmdline) = get_ps_command(ppid) {
278
+
let cmdline = cmdline.trim();
279
+
if let Some(idx) = cmdline.find("run ") {
280
+
let rest = cmdline[idx + "run ".len()..].trim();
281
+
if !rest.is_empty() {
282
+
return format!("run {rest}");
283
+
}
284
+
}
285
+
}
286
+
}
287
+
}
288
+
}
289
+
}
290
+
291
+
// If we didn't detect a wrapper like 'run', return the invocation actually used.
292
+
default_exe
293
+
}
294
+
295
+
// hook_script returns a shell-specific hook that will invoke exec_cmd to obtain completion items.
296
+
fn hook_script(shell: &str, exec_cmd: &str) -> String {
297
+
let s = shell.to_lowercase();
298
+
// single-quoted exec_cmd for safe embedding
299
+
let esc = shell_single_quote(exec_cmd);
300
+
// Use template placeholders {{EXEC}} then replace to avoid Rust format! interpreting shell braces
301
+
match s.as_str() {
302
+
"bash" => {
303
+
let tpl = r#"# van bash hook
304
+
EXEC_CMD={{EXEC}}
305
+
_van_completion() {
306
+
local cur compword i
307
+
cur="${COMP_WORDS[COMP_CWORD]}"
308
+
# build args: skip the command itself
309
+
local args=()
310
+
for ((i=1;i<${#COMP_WORDS[@]};i++)); do
311
+
args+=("${COMP_WORDS[i]}")
312
+
done
313
+
local IFS=$'\n'
314
+
local out
315
+
out=$(eval "$EXEC_CMD \"${args[@]}\"") || return
316
+
COMPREPLY=($(compgen -W "$out" -- "$cur"))
317
+
}
318
+
# Register _van_completion for all commands found in PATH (may be slow on very large PATHs)
319
+
for cmd in $(compgen -c); do
320
+
complete -F _van_completion -o default "$cmd" 2>/dev/null || true
321
+
done
322
+
"#;
323
+
tpl.replace("{{EXEC}}", &esc)
324
+
}
325
+
"zsh" => {
326
+
let tpl = r#"# van zsh hook
327
+
EXEC_CMD={{EXEC}}
328
+
_van_completion() {
329
+
# words array contains all words; remove the command itself
330
+
local -a reply
331
+
reply=("${(@f)$(eval "$EXEC_CMD ${words[1,-1]}")}")
332
+
if [[ -n ${reply} ]]; then
333
+
compadd -- "${reply[@]}"
334
+
fi
335
+
}
336
+
# Register for all commands available in this shell
337
+
for cmd in ${(k)commands}; do
338
+
compdef _van_completion $cmd 2>/dev/null || true
339
+
done
340
+
"#;
341
+
tpl.replace("{{EXEC}}", &esc)
342
+
}
343
+
"fish" => {
344
+
let tpl = r#"# van fish hook
345
+
set -l VAN_EXEC {{EXEC}}
346
+
function __van_completion
347
+
# get full commandline
348
+
set -l cmdline (commandline -cp)
349
+
# split into tokens by space (basic split)
350
+
set -l tokens (string split ' ' -- $cmdline)
351
+
# drop the leading command name
352
+
set -e tokens[1]
353
+
# call $VAN_EXEC with remaining tokens and print each candidate on its own line
354
+
for item in (eval "$VAN_EXEC $tokens")
355
+
printf "%s\n" "$item"
356
+
end
357
+
end
358
+
# Register completion for every executable in $PATH (may be slow)
359
+
for p in (string split : $PATH)
360
+
for cmd in (ls $p 2>/dev/null)
361
+
complete -c $cmd -f -a '(__van_completion)'
362
+
end
363
+
end
364
+
"#;
365
+
tpl.replace("{{EXEC}}", &esc)
366
+
}
367
+
"nushell" | "nu" => {
368
+
let tpl = r#"# van nushell hook
369
+
# Nushell custom completion support varies by version. The following provides a simple helper function
370
+
# you can call from your nushell config to get completions for the current command line.
371
+
# Example (in your config):
372
+
# def van-complete [] { {{EXEC_RAW}} ($nu.env.CMDLINE | split ' ' | skip 1) }
373
+
# Consult nushell docs for registering completion functions in your version.
374
+
"#;
375
+
// nushell example uses unquoted raw exec_cmd; provide raw (not shell-single-quoted) replacement
376
+
tpl.replace("{{EXEC_RAW}}", exec_cmd)
377
+
}
378
+
_ => {
379
+
let tpl = r#"# van (default=bash) hook
380
+
EXEC_CMD={{EXEC}}
381
+
_van_completion() {
382
+
local cur compword i
383
+
cur="${COMP_WORDS[COMP_CWORD]}"
384
+
# build args: skip the command itself
385
+
local args=()
386
+
for ((i=1;i<${#COMP_WORDS[@]};i++)); do
387
+
args+=("${COMP_WORDS[i]}")
388
+
done
389
+
local IFS=$'\n'
390
+
local out
391
+
out=$(eval "$EXEC_CMD \"${args[@]}\"") || return
392
+
COMPREPLY=($(compgen -W "$out" -- "$cur"))
393
+
}
394
+
# Register _van_completion for all commands found in PATH (may be slow on very large PATHs)
395
+
for cmd in $(compgen -c); do
396
+
complete -F _van_completion -o default "$cmd" 2>/dev/null || true
397
+
done
398
+
"#;
399
+
tpl.replace("{{EXEC}}", &esc)
400
+
}
401
+
}
402
+
}
403
+
404
+
fn detect_shell_from_env() -> String {
405
+
env::var("SHELL")
406
+
.ok()
407
+
.and_then(|p| {
408
+
Path::new(&p)
409
+
.file_name()
410
+
.and_then(|s| s.to_str().map(|s| s.to_string()))
411
+
})
412
+
.filter(|s| !s.is_empty())
413
+
.unwrap_or_else(|| "bash".to_string())
414
+
}
415
+
416
+
fn get_ps_command(ppid: u32) -> Option<String> {
417
+
let out = Command::new("ps")
418
+
.arg("-p")
419
+
.arg(ppid.to_string())
420
+
.arg("-o")
421
+
.arg("command=")
422
+
.output()
423
+
.ok()?;
424
+
if !out.status.success() {
425
+
return None;
426
+
}
427
+
String::from_utf8(out.stdout)
428
+
.ok()
429
+
.map(|s| s.trim().to_string())
430
+
}
431
+
432
+
#[tokio::main]
433
+
async fn main() {
434
+
let args: Vec<String> = env::args().skip(1).collect();
435
+
// simple flag handling for --help and --hook
436
+
if !args.is_empty() {
437
+
if args[0] == "--help" || args[0] == "-h" {
438
+
print_help();
439
+
return;
440
+
}
441
+
// support: --hook [shell] and optional --exe <cmd> (can appear before or after)
442
+
let mut hook_idx: isize = -1;
443
+
let mut exe_val = String::new();
444
+
let mut i = 0usize;
445
+
while i < args.len() {
446
+
if args[i] == "--hook" {
447
+
hook_idx = i as isize;
448
+
// if next arg exists and doesn't start with '-', treat as shell token
449
+
if i + 1 < args.len() && !args[i + 1].starts_with('-') {
450
+
i += 1;
451
+
}
452
+
i += 1;
453
+
continue;
454
+
}
455
+
if args[i] == "--exe" && i + 1 < args.len() {
456
+
exe_val = args[i + 1].to_owned();
457
+
i += 2;
458
+
continue;
459
+
}
460
+
i += 1;
461
+
}
462
+
if hook_idx != -1 {
463
+
// determine shell param if provided
464
+
let shell = if (hook_idx as usize) + 1 < args.len()
465
+
&& !args[(hook_idx as usize) + 1].starts_with('-')
466
+
{
467
+
args[(hook_idx as usize) + 1].to_owned()
468
+
} else {
469
+
detect_shell_from_env()
470
+
};
471
+
let mut exe_cmd = exe_val;
472
+
if exe_cmd.is_empty() {
473
+
exe_cmd = detect_exec_from_parent();
474
+
}
475
+
if exe_cmd.is_empty() {
476
+
exe_cmd = Path::new(&env::args().next().unwrap_or_default())
477
+
.file_name()
478
+
.and_then(|s| s.to_str())
479
+
.unwrap_or("")
480
+
.to_string();
481
+
}
482
+
print!("{}", hook_script(&shell, &exe_cmd));
483
+
return;
484
+
}
485
+
}
486
+
487
+
// If args provided, use non-interactive parsing similar to tooling (<cmd> args), else run interactive TUI
488
+
if !args.is_empty() {
489
+
match noninteractive_run(args) {
490
+
Ok(out) => {
491
+
if !out.is_empty() {
492
+
println!("{out}");
493
+
}
494
+
process::exit(0);
495
+
}
496
+
Err(e) => {
497
+
eprintln!("{e}");
498
+
process::exit(2);
499
+
}
500
+
}
501
+
}
502
+
503
+
// Run interactive program
504
+
let builder = Program::<TeaAdapter>::builder();
505
+
let program = match builder.build() {
506
+
Ok(p) => p,
507
+
Err(e) => {
508
+
eprintln!("failed to build program: {e:?}");
509
+
process::exit(2);
510
+
}
511
+
};
512
+
match program.run().await {
513
+
Ok(_final_model) => {
514
+
// Interactive run does not print preview; simply exit successfully
515
+
process::exit(0);
516
+
}
517
+
Err(e) => {
518
+
eprintln!("program error: {e:?}");
519
+
process::exit(2);
520
+
}
521
+
}
522
+
}
+27
crates/src/ui.rs
+27
crates/src/ui.rs
···
···
1
+
// UI module root: split implementation into focused submodules under `ui/`
2
+
3
+
pub mod model;
4
+
pub mod render;
5
+
pub mod run;
6
+
pub mod update;
7
+
8
+
// Re-export commonly used symbols so existing call sites keep working (e.g. `crate::ui::initial_model`).
9
+
pub use model::{ChooseItem, Model, initial_model, sort_items};
10
+
pub use render::{
11
+
render_full, render_main_content, render_modeline, render_modeline_padded, render_preview_block,
12
+
};
13
+
pub use run::run;
14
+
pub use update::handle_update;
15
+
16
+
// Messages used by the update logic
17
+
#[derive(Clone, Debug, PartialEq, Eq)]
18
+
pub enum Msg {
19
+
WindowSize { width: usize, height: usize },
20
+
KeyBackspace,
21
+
KeyEnter,
22
+
KeyEsc,
23
+
KeySpace,
24
+
Rune(char),
25
+
KeyUp,
26
+
KeyDown,
27
+
}
+1158
crates/src/ui/model.rs
+1158
crates/src/ui/model.rs
···
···
1
+
use crate::ast;
2
+
use bubbletea_widgets::Viewport;
3
+
use std::collections::HashMap;
4
+
5
+
// small constants reused by rendering code
6
+
pub const PREVIEW_BLOCK_LINES: usize = 3;
7
+
pub const MODELINE_LINES: usize = 1;
8
+
pub const RESERVED_LINES: usize = PREVIEW_BLOCK_LINES + MODELINE_LINES;
9
+
pub const DEFAULT_WIDTH: usize = 80;
10
+
11
+
// Represent a choose item (flag or command)
12
+
#[derive(Clone, Debug)]
13
+
pub struct ChooseItem {
14
+
pub kind: String, // "cmd" or "flag"
15
+
pub label: String,
16
+
pub forms: Vec<String>,
17
+
pub flag_def: Option<ast::FlagDef>,
18
+
pub cmd_def: Option<ast::CommandDef>,
19
+
pub short: String,
20
+
pub depth: usize,
21
+
}
22
+
23
+
#[derive(Clone, Debug, Default)]
24
+
pub struct Model {
25
+
pub items: Vec<ChooseItem>,
26
+
pub typed: String,
27
+
pub typed_raw: String,
28
+
pub ast: ast::Segment,
29
+
pub current: Option<ast::CommandDef>,
30
+
// simplified text input state
31
+
pub in_value_mode: bool,
32
+
pub pending_flag: Option<ast::FlagDef>,
33
+
pub pending_form: String,
34
+
pub pending_pos: bool,
35
+
pub pending_depth: usize,
36
+
pub pending_value: String,
37
+
pub err: String,
38
+
pub exit_preview: String,
39
+
pub def_cache: HashMap<String, ast::CommandDef>,
40
+
// pagination
41
+
pub page: usize,
42
+
pub per_page: usize,
43
+
pub screen_width: usize,
44
+
// viewport using bubbletea widgets
45
+
pub vp: Viewport,
46
+
// numeric mode baseline snapshot (indices into items) used by update/render logic
47
+
pub numeric_baseline: Option<Vec<usize>>,
48
+
}
49
+
50
+
// derive(Default) provides the default implementation
51
+
52
+
pub fn initial_model(entries: Vec<(String, String)>) -> Model {
53
+
let mut m = Model::default();
54
+
if !entries.is_empty() {
55
+
let items: Vec<ChooseItem> = entries
56
+
.into_iter()
57
+
.map(|(name, short)| {
58
+
let label = name.clone();
59
+
let forms = vec![label.clone()];
60
+
ChooseItem {
61
+
kind: "cmd".to_string(),
62
+
label: label.clone(),
63
+
forms,
64
+
flag_def: None,
65
+
cmd_def: None,
66
+
short,
67
+
depth: 0,
68
+
}
69
+
})
70
+
.collect();
71
+
m.items = sort_items(items);
72
+
}
73
+
m
74
+
}
75
+
76
+
impl Model {
77
+
// wrapper update that delegates to the update module
78
+
pub fn update(&mut self, msg: crate::ui::Msg) {
79
+
crate::ui::update::handle_update(self, msg);
80
+
}
81
+
82
+
pub fn mode(&self) -> String {
83
+
if !self.typed.is_empty() {
84
+
return format!("Typed: {}", self.typed);
85
+
}
86
+
if self.ast.stack.is_empty() {
87
+
return "van".to_string();
88
+
}
89
+
self.last_stack_name().unwrap_or_else(|| "van".to_string())
90
+
}
91
+
92
+
// helper: return last non-empty stack name if any
93
+
fn last_stack_name(&self) -> Option<String> {
94
+
self.ast
95
+
.stack
96
+
.iter()
97
+
.rev()
98
+
.find_map(|n| {
99
+
let name = n.name.trim();
100
+
if !name.is_empty() {
101
+
Some(name.to_string())
102
+
} else {
103
+
None
104
+
}
105
+
})
106
+
}
107
+
108
+
pub fn get_def_for_depth(&self, depth: usize) -> Option<ast::CommandDef> {
109
+
if depth >= self.ast.stack.len() {
110
+
return None;
111
+
}
112
+
let root_name = &self.ast.stack[0].name;
113
+
if root_name.is_empty() {
114
+
return None;
115
+
}
116
+
if let Some(root_def) = self.def_cache.get(root_name) {
117
+
if depth == 0 {
118
+
return Some(root_def.clone());
119
+
}
120
+
return self.find_subdef_from_root(root_def.clone(), depth);
121
+
}
122
+
// fallback: if current is set and depth == top, return current
123
+
if let Some(cur) = &self.current {
124
+
if depth == self.ast.stack.len().saturating_sub(1) {
125
+
return Some(cur.clone());
126
+
}
127
+
}
128
+
None
129
+
}
130
+
131
+
// helper: traverse subcommands from a root def to the requested depth
132
+
fn find_subdef_from_root(&self, mut cur: ast::CommandDef, depth: usize) -> Option<ast::CommandDef> {
133
+
for i in 1..=depth {
134
+
let name = &self.ast.stack[i].name;
135
+
if let Some(found) = cur
136
+
.subcommands
137
+
.iter()
138
+
.find(|sc| sc.name == *name || sc.aliases.iter().any(|a| a == name))
139
+
{
140
+
cur = found.clone();
141
+
} else {
142
+
return None;
143
+
}
144
+
}
145
+
Some(cur)
146
+
}
147
+
148
+
pub fn build_items_from_command(&mut self, cmd: &ast::CommandDef) {
149
+
// Preserve early-exit behavior
150
+
let mut items: Vec<ChooseItem> = vec![];
151
+
if cmd.name.is_empty() {
152
+
self.items = items;
153
+
return;
154
+
}
155
+
156
+
let top_depth = self.ast.stack.len().saturating_sub(1);
157
+
items.extend(self.collect_flag_items(top_depth));
158
+
items.extend(self.collect_subcommand_items(cmd, top_depth));
159
+
160
+
self.items = sort_items(items);
161
+
self.page = 0;
162
+
}
163
+
164
+
// helper: collect flags for every depth up to top_depth
165
+
fn collect_flag_items(&self, top_depth: usize) -> Vec<ChooseItem> {
166
+
let mut items: Vec<ChooseItem> = vec![];
167
+
for d in 0..=top_depth {
168
+
if let Some(def) = self.get_def_for_depth(d) {
169
+
for f in def.flags.iter() {
170
+
let mut forms = vec![];
171
+
let mut label_parts = vec![];
172
+
if !f.longhand.is_empty() {
173
+
forms.push(format!("--{}", f.longhand));
174
+
label_parts.push(format!("--{}", f.longhand));
175
+
}
176
+
if !f.shorthand.is_empty() {
177
+
forms.push(format!("-{}", f.shorthand));
178
+
label_parts.push(format!("-{}", f.shorthand));
179
+
}
180
+
let mut label = label_parts.join(", ");
181
+
if d < top_depth {
182
+
label = format!("{}: {}", def.name, label);
183
+
}
184
+
items.push(ChooseItem {
185
+
kind: "flag".to_string(),
186
+
label,
187
+
forms,
188
+
flag_def: Some(f.clone()),
189
+
cmd_def: None,
190
+
short: String::new(),
191
+
depth: d,
192
+
});
193
+
}
194
+
}
195
+
}
196
+
items
197
+
}
198
+
199
+
// helper: collect subcommands for the provided cmd at top_depth
200
+
fn collect_subcommand_items(&self, cmd: &ast::CommandDef, top_depth: usize) -> Vec<ChooseItem> {
201
+
let mut items: Vec<ChooseItem> = vec![];
202
+
for sc in cmd.subcommands.iter() {
203
+
let mut forms = vec![sc.name.clone()];
204
+
for a in sc.aliases.iter() {
205
+
if !a.is_empty() {
206
+
forms.push(a.clone());
207
+
}
208
+
}
209
+
items.push(ChooseItem {
210
+
kind: "cmd".to_string(),
211
+
label: sc.name.clone(),
212
+
forms,
213
+
flag_def: None,
214
+
cmd_def: Some(sc.clone()),
215
+
short: sc.short.clone(),
216
+
depth: top_depth,
217
+
});
218
+
}
219
+
items
220
+
}
221
+
222
+
// Render helper wrappers that forward to the render module to keep this file focused on state.
223
+
pub fn assigned_map(&self) -> HashMap<String, String> {
224
+
crate::ui::render::assigned_map(self)
225
+
}
226
+
pub fn render_visible_items(&self) -> Vec<ChooseItem> {
227
+
crate::ui::render::render_visible_items(self)
228
+
}
229
+
pub fn render_list_content(&self, visible: &[ChooseItem]) -> String {
230
+
crate::ui::render::render_list_content(self, visible)
231
+
}
232
+
pub fn render_preview(&self) -> String {
233
+
crate::ui::render::render_preview(self)
234
+
}
235
+
pub fn render_preview_block(&self) -> Vec<String> {
236
+
crate::ui::render::render_preview_block(self)
237
+
}
238
+
pub fn render_main_content(&self) -> String {
239
+
crate::ui::render::render_main_content(self)
240
+
}
241
+
pub fn render_full(&self) -> String {
242
+
crate::ui::render::render_full(self)
243
+
}
244
+
245
+
// New helper to get labels of current items (replaces stored `root_list`)
246
+
pub fn items_labels(&self) -> impl Iterator<Item = &str> {
247
+
self.items.iter().map(|it| it.label.as_str())
248
+
}
249
+
}
250
+
251
+
pub fn sort_items(items: Vec<ChooseItem>) -> Vec<ChooseItem> {
252
+
let mut flags: Vec<ChooseItem> = items
253
+
.iter()
254
+
.filter(|it| it.kind == "flag")
255
+
.cloned()
256
+
.collect();
257
+
let mut cmds: Vec<ChooseItem> = items.into_iter().filter(|it| it.kind == "cmd").collect();
258
+
flags.sort_by(|a, b| {
259
+
a.label
260
+
.len()
261
+
.cmp(&b.label.len())
262
+
.then(a.label.cmp(&b.label))
263
+
});
264
+
cmds.sort_by(|a, b| {
265
+
a.label
266
+
.len()
267
+
.cmp(&b.label.len())
268
+
.then(a.label.cmp(&b.label))
269
+
});
270
+
flags.extend(cmds);
271
+
flags
272
+
}
273
+
274
+
pub fn leading_hyphen_count(s: &str) -> usize {
275
+
s.chars().take_while(|&r| r == '-').count()
276
+
}
277
+
278
+
#[cfg(test)]
279
+
mod tests {
280
+
use super::*;
281
+
use crate::ast::{Segment, CommandDef, FlagDef};
282
+
283
+
// Revert to direct Segment::new_empty usage where needed.
284
+
// (Full test bodies retained elsewhere in file; only type rename matters.)
285
+
#[test]
286
+
fn test_mode_and_initial_model() {
287
+
let entries = vec![("git".to_string(), "git client".to_string())];
288
+
let mut m = initial_model(entries);
289
+
let labels: Vec<&str> = m.items_labels().collect();
290
+
assert_eq!(labels.len(), 1);
291
+
assert_eq!(labels[0], "git");
292
+
assert_eq!(m.mode(), "van");
293
+
m.typed = "abcd".to_string();
294
+
assert_eq!(m.mode(), "Typed: abcd");
295
+
}
296
+
297
+
#[test]
298
+
fn test_space_enters_value_mode_and_esc_cancels() {
299
+
let mut m = initial_model(vec![]);
300
+
assert!(!m.in_value_mode);
301
+
m.update(crate::ui::Msg::KeySpace);
302
+
assert!(m.in_value_mode);
303
+
assert!(m.pending_pos);
304
+
m.update(crate::ui::Msg::KeyEsc);
305
+
assert!(!m.in_value_mode);
306
+
assert!(!m.pending_pos);
307
+
}
308
+
309
+
#[test]
310
+
fn test_backspace_trims_typed() {
311
+
let mut m = initial_model(vec![]);
312
+
m.typed = "ab".to_string();
313
+
m.typed_raw = "ab".to_string();
314
+
m.update(crate::ui::Msg::KeyBackspace);
315
+
assert_eq!(m.typed, "a");
316
+
assert_eq!(m.typed_raw, "a");
317
+
}
318
+
319
+
#[test]
320
+
fn test_assigned_map_initial_prefixes() {
321
+
let mut m = initial_model(vec![]);
322
+
m.items = vec![
323
+
ChooseItem {
324
+
kind: "flag".to_string(),
325
+
label: "--long".to_string(),
326
+
forms: vec!["--long".to_string()],
327
+
flag_def: None,
328
+
cmd_def: None,
329
+
short: String::new(),
330
+
depth: 0,
331
+
},
332
+
ChooseItem {
333
+
kind: "flag".to_string(),
334
+
label: "-s".to_string(),
335
+
forms: vec!["-s".to_string()],
336
+
flag_def: None,
337
+
cmd_def: None,
338
+
short: String::new(),
339
+
depth: 0,
340
+
},
341
+
ChooseItem {
342
+
kind: "cmd".to_string(),
343
+
label: "cmd".to_string(),
344
+
forms: vec!["cmd".to_string()],
345
+
flag_def: None,
346
+
cmd_def: None,
347
+
short: String::new(),
348
+
depth: 0,
349
+
},
350
+
];
351
+
m.typed_raw = "".to_string();
352
+
let assigned = m.assigned_map();
353
+
assert_eq!(assigned.get("--long").cloned().unwrap_or_default(), "-");
354
+
assert_eq!(assigned.get("-s").cloned().unwrap_or_default(), "-");
355
+
assert!(assigned.get("cmd").cloned().unwrap_or_default() != "");
356
+
}
357
+
358
+
#[test]
359
+
fn test_sort_items_ordering() {
360
+
let items = vec![
361
+
ChooseItem {
362
+
kind: "cmd".to_string(),
363
+
label: "zzz".to_string(),
364
+
forms: vec![],
365
+
flag_def: None,
366
+
cmd_def: None,
367
+
short: String::new(),
368
+
depth: 0,
369
+
},
370
+
ChooseItem {
371
+
kind: "flag".to_string(),
372
+
label: "a".to_string(),
373
+
forms: vec![],
374
+
flag_def: None,
375
+
cmd_def: None,
376
+
short: String::new(),
377
+
depth: 0,
378
+
},
379
+
ChooseItem {
380
+
kind: "flag".to_string(),
381
+
label: "bb".to_string(),
382
+
forms: vec![],
383
+
flag_def: None,
384
+
cmd_def: None,
385
+
short: String::new(),
386
+
depth: 0,
387
+
},
388
+
ChooseItem {
389
+
kind: "cmd".to_string(),
390
+
label: "x".to_string(),
391
+
forms: vec![],
392
+
flag_def: None,
393
+
cmd_def: None,
394
+
short: String::new(),
395
+
depth: 0,
396
+
},
397
+
];
398
+
let s = sort_items(items);
399
+
assert_eq!(s.len(), 4);
400
+
assert_eq!(s[0].kind, "flag");
401
+
assert_eq!(s[1].kind, "flag");
402
+
assert_eq!(s[0].label, "a");
403
+
assert_eq!(s[1].label, "bb");
404
+
}
405
+
406
+
#[test]
407
+
fn test_build_items_from_command_includes_flags_and_subcommands() {
408
+
let mut m = initial_model(vec![]);
409
+
let def = CommandDef {
410
+
name: "root".to_string(),
411
+
short: "rootcmd".to_string(),
412
+
aliases: vec![],
413
+
flags: vec![FlagDef {
414
+
longhand: "verbose".to_string(),
415
+
shorthand: "v".to_string(),
416
+
usage: "v".to_string(),
417
+
requires_value: false,
418
+
}],
419
+
subcommands: vec![CommandDef {
420
+
name: "sub".to_string(),
421
+
short: "subcmd".to_string(),
422
+
aliases: vec![],
423
+
flags: vec![],
424
+
subcommands: vec![],
425
+
}],
426
+
};
427
+
m.ast = Segment::new_empty("root");
428
+
m.current = Some(def.clone());
429
+
m.build_items_from_command(&def);
430
+
assert!(m.items.len() >= 2);
431
+
let mut has_flag = false;
432
+
let mut has_cmd = false;
433
+
for it in &m.items {
434
+
if it.kind == "flag" {
435
+
has_flag = true
436
+
}
437
+
if it.kind == "cmd" {
438
+
has_cmd = true
439
+
}
440
+
}
441
+
assert!(has_flag && has_cmd);
442
+
}
443
+
444
+
#[test]
445
+
fn test_flag_add_remove_toggle_and_render() {
446
+
let mut m = initial_model(vec![]);
447
+
let def = CommandDef {
448
+
name: "root".to_string(),
449
+
short: "rootcmd".to_string(),
450
+
aliases: vec![],
451
+
flags: vec![
452
+
FlagDef {
453
+
longhand: "message".to_string(),
454
+
shorthand: "m".to_string(),
455
+
usage: "msg".to_string(),
456
+
requires_value: true,
457
+
},
458
+
FlagDef {
459
+
longhand: "verbose".to_string(),
460
+
shorthand: "v".to_string(),
461
+
usage: "v".to_string(),
462
+
requires_value: false,
463
+
},
464
+
],
465
+
subcommands: vec![],
466
+
};
467
+
m.ast = Segment::new_empty("root");
468
+
m.current = Some(def.clone());
469
+
m.build_items_from_command(&def);
470
+
m.ast.add_flag_to_depth(0, "--verbose", "");
471
+
assert_eq!(m.ast.render_preview(), "root --verbose");
472
+
let removed = m.ast.remove_flag_from_depth("--verbose", 0);
473
+
assert!(removed);
474
+
assert_eq!(m.ast.render_preview(), "root");
475
+
m.ast.add_flag_to_depth(0, "--message", "hello");
476
+
assert_eq!(m.ast.render_preview(), "root --message hello");
477
+
assert!(m.ast.remove_flag_from_depth("--message", 0));
478
+
assert_eq!(m.ast.render_preview(), "root");
479
+
}
480
+
481
+
#[test]
482
+
fn test_add_positionals_and_undo_to_root() {
483
+
let mut m = initial_model(vec![]);
484
+
m.ast = Segment::new_empty("root");
485
+
m.ast.push_subcommand("sub");
486
+
m.ast.add_flag_to_depth(0, "--rootflag", "");
487
+
m.ast.add_positional("a");
488
+
m.ast.add_positional("b");
489
+
assert_eq!(m.ast.render_preview(), "root --rootflag sub a b");
490
+
m.ast.remove_last();
491
+
assert_eq!(m.ast.render_preview(), "root --rootflag sub a");
492
+
m.ast.remove_last();
493
+
assert_eq!(m.ast.render_preview(), "root --rootflag sub");
494
+
m.ast.remove_last();
495
+
assert_eq!(m.ast.render_preview(), "root sub");
496
+
m.ast.remove_last();
497
+
assert_eq!(m.ast.render_preview(), "root");
498
+
}
499
+
500
+
#[test]
501
+
fn test_parent_and_subcommand_flags_preview_and_undo() {
502
+
let mut m = initial_model(vec![]);
503
+
m.ast = Segment::new_empty("root");
504
+
m.ast.push_subcommand("sub");
505
+
m.ast.add_flag_to_depth(0, "--rootflag", "");
506
+
m.ast.add_flag_to_depth(1, "--subflag", "");
507
+
assert_eq!(m.ast.render_preview(), "root --rootflag sub --subflag");
508
+
m.ast.remove_last();
509
+
assert_eq!(m.ast.render_preview(), "root --rootflag sub");
510
+
m.ast.remove_last();
511
+
assert_eq!(m.ast.render_preview(), "root sub");
512
+
m.ast.remove_last();
513
+
assert_eq!(m.ast.render_preview(), "root");
514
+
}
515
+
516
+
#[test]
517
+
fn test_acekey_selection_pushes_subcommand_and_flag_requires_value() {
518
+
let mut m = initial_model(vec![]);
519
+
m.ast = Segment::new_empty("root");
520
+
m.ast.root = "root".to_string();
521
+
m.ast.stack[0].name = "root".to_string();
522
+
let subdef = CommandDef {
523
+
name: "sub".to_string(),
524
+
short: "subcmd".to_string(),
525
+
aliases: vec![],
526
+
flags: vec![],
527
+
subcommands: vec![],
528
+
};
529
+
m.items = vec![ChooseItem {
530
+
kind: "cmd".to_string(),
531
+
label: "sub".to_string(),
532
+
forms: vec!["sub".to_string()],
533
+
flag_def: None,
534
+
cmd_def: Some(subdef.clone()),
535
+
short: String::new(),
536
+
depth: 0,
537
+
}];
538
+
m.update(crate::ui::Msg::Rune('s'));
539
+
assert!(m.ast.top().is_some() && m.ast.top().unwrap().name == "sub");
540
+
541
+
// flag requiring value case
542
+
let mut m2 = initial_model(vec![]);
543
+
m2.ast = Segment::new_empty("root");
544
+
m2.ast.root = "root".to_string();
545
+
m2.ast.stack[0].name = "root".to_string();
546
+
let fd = FlagDef {
547
+
longhand: "message".to_string(),
548
+
shorthand: "m".to_string(),
549
+
usage: String::new(),
550
+
requires_value: true,
551
+
};
552
+
m2.items = vec![ChooseItem {
553
+
kind: "flag".to_string(),
554
+
label: "--message".to_string(),
555
+
forms: vec!["--message".to_string()],
556
+
flag_def: Some(fd.clone()),
557
+
cmd_def: None,
558
+
short: String::new(),
559
+
depth: 0,
560
+
}];
561
+
m2.update(crate::ui::Msg::Rune('-'));
562
+
m2.update(crate::ui::Msg::Rune('m'));
563
+
assert!(
564
+
m2.in_value_mode
565
+
&& m2.pending_flag.is_some()
566
+
&& m2.pending_flag.as_ref().unwrap().longhand == "message"
567
+
);
568
+
}
569
+
570
+
#[test]
571
+
fn test_acekey_disambiguation_interaction() {
572
+
let mut m = initial_model(vec![]);
573
+
let mut root = CommandDef {
574
+
name: "root".to_string(),
575
+
short: "rootcmd".to_string(),
576
+
aliases: vec![],
577
+
flags: vec![],
578
+
subcommands: vec![],
579
+
};
580
+
let s1 = CommandDef {
581
+
name: "serve".to_string(),
582
+
short: "serve".to_string(),
583
+
aliases: vec![],
584
+
flags: vec![],
585
+
subcommands: vec![],
586
+
};
587
+
let s2 = CommandDef {
588
+
name: "setup".to_string(),
589
+
short: "setup".to_string(),
590
+
aliases: vec![],
591
+
flags: vec![],
592
+
subcommands: vec![],
593
+
};
594
+
root.subcommands = vec![s1.clone(), s2.clone()];
595
+
m.ast = Segment::new_empty("root");
596
+
m.ast.root = "root".to_string();
597
+
m.ast.stack[0].name = "root".to_string();
598
+
m.current = Some(root.clone());
599
+
m.def_cache.insert("root".to_string(), root.clone());
600
+
m.build_items_from_command(&root);
601
+
m.update(crate::ui::Msg::Rune('s'));
602
+
assert!(m.render_visible_items().len() >= 2);
603
+
m.update(crate::ui::Msg::Rune('r'));
604
+
assert!(m.ast.top().is_some() && m.ast.top().unwrap().name == "serve");
605
+
}
606
+
607
+
#[test]
608
+
fn test_command_then_subcommand_then_flags_then_undo_and_subcommand_visible_again() {
609
+
let mut m = initial_model(vec![]);
610
+
let sub = CommandDef {
611
+
name: "sub".to_string(),
612
+
short: "subcmd".to_string(),
613
+
aliases: vec![],
614
+
flags: vec![],
615
+
subcommands: vec![],
616
+
};
617
+
let root = CommandDef {
618
+
name: "root".to_string(),
619
+
short: "rootcmd".to_string(),
620
+
aliases: vec![],
621
+
flags: vec![],
622
+
subcommands: vec![sub.clone()],
623
+
};
624
+
m.ast = Segment::new_empty("root");
625
+
m.ast.root = "root".to_string();
626
+
m.ast.stack[0].name = "root".to_string();
627
+
m.current = Some(root.clone());
628
+
m.def_cache.insert("root".to_string(), root.clone());
629
+
m.build_items_from_command(&root);
630
+
m.update(crate::ui::Msg::Rune('s'));
631
+
assert!(m.ast.top().is_some() && m.ast.top().unwrap().name == "sub");
632
+
m.ast.add_flag_to_depth(0, "--rootflag", "");
633
+
m.ast.add_flag_to_depth(1, "--subflag", "");
634
+
assert_eq!(m.ast.render_preview(), "root --rootflag sub --subflag");
635
+
m.ast.remove_last();
636
+
assert_eq!(m.ast.render_preview(), "root --rootflag sub");
637
+
m.ast.remove_last();
638
+
assert_eq!(m.ast.render_preview(), "root sub");
639
+
m.ast.remove_last();
640
+
assert_eq!(m.ast.render_preview(), "root");
641
+
m.current = Some(root.clone());
642
+
m.build_items_from_command(&root);
643
+
let mut found = false;
644
+
for it in &m.items {
645
+
if it.kind == "cmd" && it.label == "sub" {
646
+
found = true;
647
+
break;
648
+
}
649
+
}
650
+
assert!(found);
651
+
}
652
+
653
+
#[test]
654
+
fn test_undo_from_subcommand_to_root_restores_root_items() {
655
+
let mut m = initial_model(vec![]);
656
+
let init_def = CommandDef {
657
+
name: "init".to_string(),
658
+
short: "init".to_string(),
659
+
aliases: vec![],
660
+
flags: vec![],
661
+
subcommands: vec![],
662
+
};
663
+
let root = CommandDef {
664
+
name: "jj".to_string(),
665
+
short: "jjcmd".to_string(),
666
+
aliases: vec![],
667
+
flags: vec![],
668
+
subcommands: vec![init_def.clone()],
669
+
};
670
+
m.def_cache.insert("jj".to_string(), root.clone());
671
+
m.ast = Segment::new_empty("jj");
672
+
m.ast.root = "jj".to_string();
673
+
m.ast.stack[0].name = "jj".to_string();
674
+
m.current = Some(root.clone());
675
+
m.build_items_from_command(&root);
676
+
m.items = vec![ChooseItem {
677
+
kind: "cmd".to_string(),
678
+
label: "init".to_string(),
679
+
forms: vec!["init".to_string()],
680
+
flag_def: None,
681
+
cmd_def: Some(init_def.clone()),
682
+
short: String::new(),
683
+
depth: 0,
684
+
}];
685
+
m.update(crate::ui::Msg::Rune('i'));
686
+
assert!(m.ast.top().is_some() && m.ast.top().unwrap().name == "init");
687
+
assert!(m.current.is_some() && m.current.as_ref().unwrap().name == "init");
688
+
m.update(crate::ui::Msg::KeyBackspace);
689
+
assert!(m.current.is_some() && m.current.as_ref().unwrap().name == "jj");
690
+
let mut found = false;
691
+
for it in &m.items {
692
+
if it.kind == "cmd" && it.label == "init" {
693
+
found = true;
694
+
break;
695
+
}
696
+
}
697
+
assert!(found);
698
+
}
699
+
700
+
#[test]
701
+
fn test_flag_value_confirm_adds_flag_to_depth() {
702
+
let mut m = initial_model(vec![]);
703
+
m.ast = Segment::new_empty("root");
704
+
m.ast.root = "root".to_string();
705
+
m.ast.stack[0].name = "root".to_string();
706
+
let fd = FlagDef {
707
+
longhand: "message".to_string(),
708
+
shorthand: "m".to_string(),
709
+
usage: String::new(),
710
+
requires_value: true,
711
+
};
712
+
m.items = vec![ChooseItem {
713
+
kind: "flag".to_string(),
714
+
label: "--message".to_string(),
715
+
forms: vec!["--message".to_string()],
716
+
flag_def: Some(fd.clone()),
717
+
cmd_def: None,
718
+
short: String::new(),
719
+
depth: 0,
720
+
}];
721
+
m.update(crate::ui::Msg::Rune('-'));
722
+
m.update(crate::ui::Msg::Rune('m'));
723
+
assert!(m.in_value_mode && m.pending_flag.is_some());
724
+
m.pending_value = "hello".to_string();
725
+
m.update(crate::ui::Msg::KeyEnter);
726
+
let top = &m.ast.stack[0];
727
+
assert!(
728
+
top.flags.len() == 1
729
+
&& top.flags[0].form == "--message"
730
+
&& top.flags[0].value == "hello"
731
+
);
732
+
}
733
+
734
+
#[test]
735
+
fn test_lifo_order_multiple_depths() {
736
+
let mut astree = Segment::new_empty("root");
737
+
astree.push_subcommand("sub");
738
+
astree.add_flag_to_depth(0, "--r", "");
739
+
astree.add_positional("p1");
740
+
astree.add_flag_to_depth(1, "--s", "v");
741
+
astree.remove_last();
742
+
assert_eq!(astree.render_preview(), "root --r sub p1");
743
+
astree.remove_last();
744
+
assert_eq!(astree.render_preview(), "root --r sub");
745
+
astree.remove_last();
746
+
assert_eq!(astree.render_preview(), "root sub");
747
+
astree.remove_last();
748
+
assert_eq!(astree.render_preview(), "root");
749
+
}
750
+
751
+
#[test]
752
+
fn test_build_items_shows_parent_flag_label() {
753
+
let mut m = initial_model(vec![]);
754
+
let mut root = CommandDef {
755
+
name: "root".to_string(),
756
+
short: String::new(),
757
+
aliases: vec![],
758
+
flags: vec![FlagDef {
759
+
longhand: "verbose".to_string(),
760
+
shorthand: "v".to_string(),
761
+
usage: "v".to_string(),
762
+
requires_value: false,
763
+
}],
764
+
subcommands: vec![],
765
+
};
766
+
let sub = CommandDef {
767
+
name: "sub".to_string(),
768
+
short: "subcmd".to_string(),
769
+
aliases: vec![],
770
+
flags: vec![],
771
+
subcommands: vec![],
772
+
};
773
+
root.subcommands = vec![sub.clone()];
774
+
m.ast = Segment::new_empty("root");
775
+
m.ast.root = "root".to_string();
776
+
m.ast.stack[0].name = "root".to_string();
777
+
m.current = Some(sub.clone());
778
+
m.ast.push_subcommand("sub");
779
+
m.def_cache.insert("root".to_string(), root.clone());
780
+
m.build_items_from_command(&sub);
781
+
let mut header_found = false;
782
+
for it in &m.items {
783
+
if it.kind == "flag" && it.depth < m.ast.stack.len() - 1
784
+
&& it.label.starts_with("root:") {
785
+
header_found = true;
786
+
break;
787
+
}
788
+
}
789
+
assert!(header_found);
790
+
}
791
+
792
+
#[test]
793
+
fn test_assign_ace_keys_hyphen_and_collapse_edgecases() {
794
+
{
795
+
let els = ["jjui", "ju"];
796
+
let res = crate::acekey::assign_ace_keys(
797
+
&els.iter().map(|s| s.to_string()).collect::<Vec<String>>(),
798
+
"ju",
799
+
);
800
+
assert!(res.is_some());
801
+
let v = res.unwrap();
802
+
assert_eq!(v.len(), 1);
803
+
assert_eq!(v[0].index, 1);
804
+
assert_eq!(v[0].prefix, "");
805
+
}
806
+
{
807
+
let els = ["--long", "-s"];
808
+
let res = crate::acekey::assign_ace_keys(
809
+
&els.iter().map(|s| s.to_string()).collect::<Vec<String>>(),
810
+
"-",
811
+
);
812
+
assert!(res.is_some());
813
+
let v = res.unwrap();
814
+
assert_eq!(v.len(), 2);
815
+
for a in v.iter() {
816
+
assert!(!a.prefix.is_empty());
817
+
}
818
+
}
819
+
{
820
+
let els = ["a-b", "ab"];
821
+
let res = crate::acekey::assign_ace_keys(
822
+
&els.iter().map(|s| s.to_string()).collect::<Vec<String>>(),
823
+
"a",
824
+
);
825
+
assert!(res.is_some());
826
+
let v = res.unwrap();
827
+
for a in v.iter() {
828
+
assert!(
829
+
!(a.prefix == "-" && a.index < els.len() && els[a.index].chars().count() > 1)
830
+
);
831
+
}
832
+
}
833
+
}
834
+
835
+
#[test]
836
+
fn test_window_size_pagination_and_nav() {
837
+
let mut m = initial_model(vec![]);
838
+
let mut items = vec![];
839
+
for _ in 0..10 {
840
+
items.push(ChooseItem {
841
+
kind: "cmd".to_string(),
842
+
label: "cmd".to_string(),
843
+
forms: vec!["cmd".to_string()],
844
+
flag_def: None,
845
+
cmd_def: None,
846
+
short: String::new(),
847
+
depth: 0,
848
+
});
849
+
}
850
+
m.items = items;
851
+
m.update(crate::ui::Msg::WindowSize {
852
+
width: 80,
853
+
height: 10,
854
+
});
855
+
// per_page should be height minus reserved non-main lines (preview + modeline) = 4
856
+
assert_eq!(m.per_page, (10usize).saturating_sub(4));
857
+
assert_eq!(m.page, 0);
858
+
m.update(crate::ui::Msg::KeyDown);
859
+
assert!(m.page != 0);
860
+
}
861
+
862
+
#[test]
863
+
fn test_mode_various_states() {
864
+
let mut m = initial_model(vec![]);
865
+
assert_eq!(m.mode(), "van");
866
+
m.typed = "x".to_string();
867
+
assert_eq!(m.mode(), "Typed: x");
868
+
m.typed.clear();
869
+
m.ast = Segment::new_empty("root");
870
+
m.ast.stack[0].name = "root".to_string();
871
+
assert_eq!(m.mode(), "root");
872
+
m.ast.push_subcommand("sub");
873
+
m.ast.stack[1].name = "sub".to_string();
874
+
assert_eq!(m.mode(), "sub");
875
+
}
876
+
877
+
#[test]
878
+
fn test_numeric_selection_selects_flag_by_index() {
879
+
let mut m = initial_model(vec![]);
880
+
m.ast = Segment::new_empty("root");
881
+
m.ast.root = "root".to_string();
882
+
m.ast.stack[0].name = "root".to_string();
883
+
let fd = FlagDef {
884
+
longhand: "flag1".to_string(),
885
+
shorthand: "f".to_string(),
886
+
usage: String::new(),
887
+
requires_value: false,
888
+
};
889
+
m.items = vec![ChooseItem {
890
+
kind: "flag".to_string(),
891
+
label: "--flag1".to_string(),
892
+
forms: vec!["--flag1".to_string(), "-f".to_string()],
893
+
flag_def: Some(fd.clone()),
894
+
cmd_def: None,
895
+
short: String::new(),
896
+
depth: 0,
897
+
}];
898
+
m.update(crate::ui::Msg::Rune('1'));
899
+
let top = &m.ast.stack[0];
900
+
assert!(top.flags.len() == 1 && top.flags[0].form == "--flag1");
901
+
}
902
+
903
+
#[test]
904
+
fn test_numeric_selection_selects_command_by_index() {
905
+
let mut m = initial_model(vec![]);
906
+
let sub = CommandDef {
907
+
name: "sub".to_string(),
908
+
short: "subcmd".to_string(),
909
+
aliases: vec![],
910
+
flags: vec![],
911
+
subcommands: vec![],
912
+
};
913
+
let root = CommandDef {
914
+
name: "root".to_string(),
915
+
short: String::new(),
916
+
aliases: vec![],
917
+
flags: vec![],
918
+
subcommands: vec![sub.clone()],
919
+
};
920
+
m.ast = Segment::new_empty("root");
921
+
m.ast.root = "root".to_string();
922
+
m.ast.stack[0].name = "root".to_string();
923
+
m.current = Some(root.clone());
924
+
m.def_cache.insert("root".to_string(), root.clone());
925
+
m.items = vec![ChooseItem {
926
+
kind: "cmd".to_string(),
927
+
label: "sub".to_string(),
928
+
forms: vec!["sub".to_string()],
929
+
flag_def: None,
930
+
cmd_def: Some(sub.clone()),
931
+
short: String::new(),
932
+
depth: 0,
933
+
}];
934
+
m.update(crate::ui::Msg::Rune('1'));
935
+
assert!(m.ast.top().is_some() && m.ast.top().unwrap().name == "sub");
936
+
}
937
+
938
+
#[test]
939
+
fn test_numeric_multi_digit_selects_correct_flag_by_index() {
940
+
let mut m = initial_model(vec![]);
941
+
m.ast = Segment::new_empty("root");
942
+
m.ast.root = "root".to_string();
943
+
m.ast.stack[0].name = "root".to_string();
944
+
let mut items = vec![];
945
+
for i in 0..30 {
946
+
if i == 11 {
947
+
let fd = FlagDef {
948
+
longhand: "f12".to_string(),
949
+
shorthand: String::new(),
950
+
usage: String::new(),
951
+
requires_value: false,
952
+
};
953
+
items.push(ChooseItem {
954
+
kind: "flag".to_string(),
955
+
label: "--f12".to_string(),
956
+
forms: vec!["--f12".to_string()],
957
+
flag_def: Some(fd.clone()),
958
+
cmd_def: None,
959
+
short: String::new(),
960
+
depth: 0,
961
+
});
962
+
} else {
963
+
let s = (i + 1).to_string();
964
+
items.push(ChooseItem {
965
+
kind: "cmd".to_string(),
966
+
label: format!("cmd{s}"),
967
+
forms: vec![format!("cmd{}", s)],
968
+
flag_def: None,
969
+
cmd_def: None,
970
+
short: String::new(),
971
+
depth: 0,
972
+
});
973
+
}
974
+
}
975
+
m.items = items;
976
+
m.update(crate::ui::Msg::Rune('1'));
977
+
m.update(crate::ui::Msg::Rune('2'));
978
+
let top = &m.ast.stack[0];
979
+
assert!(!top.flags.is_empty() && top.flags.iter().any(|f| f.form == "--f12"));
980
+
}
981
+
982
+
#[test]
983
+
fn test_ls_command_shows_flags_and_subcommands_model() {
984
+
// Ensure commands named `ls` produce visible items (flags or subcommands)
985
+
let mut m = initial_model(vec![]);
986
+
let init_sub = CommandDef {
987
+
name: "list".to_string(),
988
+
short: "listsub".to_string(),
989
+
aliases: vec![],
990
+
flags: vec![],
991
+
subcommands: vec![],
992
+
};
993
+
let root = CommandDef {
994
+
name: "ls".to_string(),
995
+
short: "lscmd".to_string(),
996
+
aliases: vec![],
997
+
flags: vec![FlagDef {
998
+
longhand: "all".to_string(),
999
+
shorthand: "a".to_string(),
1000
+
usage: "show all".to_string(),
1001
+
requires_value: false,
1002
+
}],
1003
+
subcommands: vec![init_sub.clone()],
1004
+
};
1005
+
// populate cache and set current
1006
+
m.def_cache.insert("ls".to_string(), root.clone());
1007
+
m.ast = Segment::new_empty("ls");
1008
+
m.ast.root = "ls".to_string();
1009
+
m.ast.stack[0].name = "ls".to_string();
1010
+
m.current = Some(root.clone());
1011
+
m.build_items_from_command(&root);
1012
+
// must contain at least one flag or one subcommand
1013
+
let mut has_flag = false;
1014
+
let mut has_cmd = false;
1015
+
for it in &m.items {
1016
+
if it.kind == "flag" {
1017
+
has_flag = true
1018
+
}
1019
+
if it.kind == "cmd" {
1020
+
has_cmd = true
1021
+
}
1022
+
}
1023
+
assert!(
1024
+
has_flag || has_cmd,
1025
+
"expected ls to expose flags or subcommands but none found"
1026
+
);
1027
+
}
1028
+
1029
+
#[test]
1030
+
fn test_all_ambiguous_choices_selectable_via_acekeys() {
1031
+
let subs = vec!["chcpu", "chgrp", "chroot", "chpasswd"];
1032
+
let mut root = CommandDef {
1033
+
name: "root".to_string(),
1034
+
short: "rootcmd".to_string(),
1035
+
aliases: vec![],
1036
+
flags: vec![],
1037
+
subcommands: vec![],
1038
+
};
1039
+
let mut scs = vec![];
1040
+
for s in &subs {
1041
+
scs.push(CommandDef {
1042
+
name: s.to_string(),
1043
+
short: s.to_string(),
1044
+
aliases: vec![],
1045
+
flags: vec![],
1046
+
subcommands: vec![],
1047
+
});
1048
+
}
1049
+
root.subcommands = scs.clone();
1050
+
1051
+
for target in subs.iter().copied() {
1052
+
let mut m = initial_model(vec![]);
1053
+
m.ast = Segment::new_empty("root");
1054
+
m.ast.root = "root".to_string();
1055
+
m.ast.stack[0].name = "root".to_string();
1056
+
m.current = Some(root.clone());
1057
+
m.def_cache.insert("root".to_string(), root.clone());
1058
+
m.build_items_from_command(&root);
1059
+
1060
+
// type the ambiguous initial rune
1061
+
m.update(crate::ui::Msg::Rune('c'));
1062
+
let visible = m.render_visible_items();
1063
+
assert!(visible.len() >= 2, "expected ambiguity after typing 'c'");
1064
+
1065
+
// find assigned disambiguator for the target form
1066
+
let assigned = m.assigned_map();
1067
+
let assigned_pref = assigned
1068
+
.get(target)
1069
+
.cloned()
1070
+
.unwrap_or_default();
1071
+
assert!(
1072
+
!assigned_pref.is_empty(),
1073
+
"expected assigned disambiguator for {target}"
1074
+
);
1075
+
1076
+
// simulate typing the disambiguator rune(s)
1077
+
if assigned_pref == m.typed_raw {
1078
+
// assigned disambiguator is the same as the left unit; type the
1079
+
// next rune from the form (e.g., 'chpasswd' -> type 'h') to
1080
+
// disambiguate further.
1081
+
let next = target.chars().nth(1).expect("form must have at least 2 chars");
1082
+
m.update(crate::ui::Msg::Rune(next));
1083
+
} else {
1084
+
for ch in assigned_pref.chars() {
1085
+
m.update(crate::ui::Msg::Rune(ch));
1086
+
}
1087
+
}
1088
+
1089
+
// after typing the disambiguator, the target should be selected (pushed as subcommand)
1090
+
assert!(
1091
+
m.ast.top().is_some(),
1092
+
"expected a subcommand selected for {target}"
1093
+
);
1094
+
assert_eq!(m.ast.top().unwrap().name, *target);
1095
+
}
1096
+
}
1097
+
1098
+
#[test]
1099
+
fn test_prompt_disambiguation_progression() {
1100
+
// Use the exact list from vic/prompt.md (includes non-`c` items to ensure filtering occurs)
1101
+
let items = vec!["hello","test","cp","cal","cat","cut","chsh","code","comm","curl","cargo","chcpu","chgrp","chmod","chown","cksum","cfdisk","chroot","csplit","carapace","chpasswd","cargo-fmt","coredumpctl","cargo-clippy"];
1102
+
let forms: Vec<String> = items.iter().map(|s| s.to_string()).collect();
1103
+
1104
+
// Helper that attempts to drive selection of a target by repeatedly applying
1105
+
// assign_ace_keys using assigned prefixes first, then contiguous characters.
1106
+
fn drive_to_target(forms: &[String], target: &str) -> bool {
1107
+
let target_idx = forms.iter().position(|f| f == target).expect("form must exist");
1108
+
let mut typed = String::new();
1109
+
// start by typing the left-unit (first ace-rune)
1110
+
if let Some(first) = target.chars().next() {
1111
+
typed.push(first);
1112
+
}
1113
+
1114
+
eprintln!(">>>> driving to target {target}");
1115
+
1116
+
1117
+
// loop: try assigned prefix first, then contiguous chars of target
1118
+
let max_iters = 32;
1119
+
for _ in 0..max_iters {
1120
+
if let Some(res) = crate::acekey::assign_ace_keys(forms, &typed) {
1121
+
// If target is directly selected (empty prefix), we're done
1122
+
if res.iter().any(|r| r.index == target_idx && r.prefix.is_empty()) {
1123
+
eprintln!("<<<< reached target {target}");
1124
+
return true;
1125
+
}
1126
+
// If an assigned prefix was produced for the target, append it and retry
1127
+
if let Some(a) = res.iter().find(|r| r.index == target_idx) {
1128
+
if !a.prefix.is_empty() {
1129
+
typed.push_str(&a.prefix);
1130
+
eprintln!(" typing assigned prefix {}, now '{}'", a.prefix, typed);
1131
+
continue;
1132
+
}
1133
+
}
1134
+
}
1135
+
1136
+
// fallback: append next contiguous rune from the target
1137
+
let cur_len = typed.chars().count();
1138
+
if cur_len < target.chars().count() {
1139
+
if let Some(ch) = target.chars().nth(cur_len) {
1140
+
typed.push(ch);
1141
+
eprintln!(" typing contiguous char {}, now '{}'", ch, typed);
1142
+
continue;
1143
+
}
1144
+
}
1145
+
break;
1146
+
}
1147
+
false
1148
+
}
1149
+
1150
+
for item in &items {
1151
+
assert!(drive_to_target(&forms, item), "expected to be able to reach item {item} via disambiguation progression");
1152
+
}
1153
+
1154
+
1155
+
}
1156
+
1157
+
1158
+
}
+15
crates/src/ui/render.rs
+15
crates/src/ui/render.rs
···
···
1
+
// Render module split into focused submodules to reduce file size and compiler warnings.
2
+
3
+
pub mod decorate;
4
+
pub mod full;
5
+
pub mod list;
6
+
pub mod modeline;
7
+
pub mod preview;
8
+
pub mod styles;
9
+
pub mod util;
10
+
11
+
pub use decorate::tested_string;
12
+
pub use full::render_full;
13
+
pub use list::{assigned_map, render_list_content, render_main_content, render_visible_items};
14
+
pub use modeline::{render_modeline, render_modeline_padded};
15
+
pub use preview::{render_preview, render_preview_block};
+143
crates/src/ui/render/decorate.rs
+143
crates/src/ui/render/decorate.rs
···
···
1
+
use crate::ui::render::styles::{STYLE_ACE, STYLE_TYPED};
2
+
use std::collections::HashMap;
3
+
4
+
fn collect_candidate_runes(form: &str) -> (Vec<char>, Vec<usize>) {
5
+
let mut runes = Vec::new();
6
+
let mut positions = Vec::new();
7
+
for (i, ch) in form.char_indices() {
8
+
if crate::acekey::is_ace_rune(ch) {
9
+
runes.push(ch);
10
+
positions.push(i);
11
+
}
12
+
}
13
+
(runes, positions)
14
+
}
15
+
16
+
pub fn decorate_form(form: &str, typed: &str, assigned_seq: String) -> String {
17
+
let (candidate_runes, candidate_pos) = collect_candidate_runes(form);
18
+
19
+
let mut assigned_pos: Vec<usize> = Vec::new();
20
+
if !assigned_seq.is_empty() {
21
+
let mut ci = 0usize;
22
+
let assigned_lower = assigned_seq.to_lowercase();
23
+
for ar_rune in assigned_lower.chars() {
24
+
let mut found: Option<usize> = None;
25
+
// Always start searching from the current candidate index. We want the
26
+
// AceKey positions returned by assign_ace_keys to be respected even
27
+
// when the user has already typed; otherwise the ace-character may
28
+
// be skipped and not highlighted.
29
+
let start = ci;
30
+
for (j, ch) in candidate_runes.iter().enumerate().skip(start) {
31
+
if ch.eq_ignore_ascii_case(&ar_rune) {
32
+
found = Some(j);
33
+
ci = j + 1;
34
+
break;
35
+
}
36
+
}
37
+
if let Some(idx) = found {
38
+
assigned_pos.push(idx);
39
+
} else {
40
+
assigned_pos.clear();
41
+
break;
42
+
}
43
+
}
44
+
}
45
+
46
+
let typed_len = if !typed.is_empty() && !assigned_seq.is_empty() {
47
+
if crate::ui::model::leading_hyphen_count(typed) >= 2
48
+
&& crate::ui::model::leading_hyphen_count(&assigned_seq)
49
+
< crate::ui::model::leading_hyphen_count(typed)
50
+
{
51
+
0usize
52
+
} else {
53
+
let leftmost_unit_runes = if form.starts_with("--") {
54
+
2usize
55
+
} else {
56
+
1usize
57
+
};
58
+
let typed_lower = typed.to_lowercase();
59
+
let typed_no_hyph = typed_lower.trim_start_matches('-');
60
+
let mut tr: Vec<char> = crate::ui::render::tested_string(typed_no_hyph)
61
+
.chars()
62
+
.collect();
63
+
if leftmost_unit_runes > tr.len() {
64
+
tr.clear();
65
+
} else {
66
+
tr = tr.into_iter().skip(leftmost_unit_runes).collect();
67
+
}
68
+
let assigned_lower = assigned_seq.to_lowercase();
69
+
let ar: Vec<char> = crate::ui::render::tested_string(&assigned_lower)
70
+
.chars()
71
+
.collect();
72
+
let mut i = 0usize;
73
+
while i < tr.len() && i < ar.len() && tr[i] == ar[i] {
74
+
i += 1;
75
+
}
76
+
i
77
+
}
78
+
} else {
79
+
0usize
80
+
};
81
+
82
+
let mut out = String::with_capacity(form.len());
83
+
let assigned_index_set: HashMap<usize, usize> = assigned_pos
84
+
.iter()
85
+
.cloned()
86
+
.enumerate()
87
+
.map(|(ord, idx)| (idx, ord))
88
+
.collect();
89
+
90
+
for (byte_idx, ch) in form.char_indices() {
91
+
if crate::acekey::is_ace_rune(ch) {
92
+
let cidx_opt = candidate_pos.iter().position(|&p| p == byte_idx);
93
+
if let Some(cidx) = cidx_opt {
94
+
if let Some(&ord) = assigned_index_set.get(&cidx) {
95
+
if typed.is_empty() {
96
+
if ord == 0 {
97
+
out.push_str(&STYLE_ACE.render(&ch.to_string()));
98
+
} else {
99
+
out.push(ch);
100
+
}
101
+
continue;
102
+
}
103
+
if ord < typed_len {
104
+
out.push_str(&STYLE_TYPED.render(&ch.to_string()));
105
+
continue;
106
+
}
107
+
if ord == typed_len {
108
+
out.push_str(&STYLE_ACE.render(&ch.to_string()));
109
+
continue;
110
+
}
111
+
out.push(ch);
112
+
} else {
113
+
out.push(ch);
114
+
}
115
+
} else {
116
+
out.push(ch);
117
+
}
118
+
} else {
119
+
out.push(ch);
120
+
}
121
+
}
122
+
out
123
+
}
124
+
125
+
pub fn tested_string(s: &str) -> String {
126
+
s.to_string()
127
+
}
128
+
129
+
#[cfg(test)]
130
+
mod tests {
131
+
use super::*;
132
+
133
+
#[test]
134
+
fn acekey_highlight_when_typed_keeps_magenta() {
135
+
// when assigned_seq contains the ace char, decorate_form must render that
136
+
// character using STYLE_ACE, even if the user has already typed it.
137
+
let assigned = "w".to_string();
138
+
let out = decorate_form("w", "w", assigned.clone());
139
+
assert!(out.contains(&crate::ui::render::styles::STYLE_ACE.render("w")));
140
+
let out2 = decorate_form("wc", "w", assigned);
141
+
assert!(out2.contains(&crate::ui::render::styles::STYLE_ACE.render("w")));
142
+
}
143
+
}
+241
crates/src/ui/render/full.rs
+241
crates/src/ui/render/full.rs
···
···
1
+
use crate::ui::model::Model;
2
+
3
+
pub fn render_full(m: &Model) -> String {
4
+
let mut lines = m.render_preview_block();
5
+
lines.extend(m.render_main_content().lines().map(str::to_string));
6
+
let first_line = crate::ui::render::modeline::render_modeline_padded(m)
7
+
.lines()
8
+
.next()
9
+
.unwrap_or("")
10
+
.to_string();
11
+
lines.push(first_line);
12
+
lines.join("\n")
13
+
}
14
+
15
+
#[cfg(test)]
16
+
mod tests {
17
+
use regex::Regex;
18
+
19
+
// helper to strip ANSI CSI sequences from rendered output for assertions
20
+
fn strip_ansi(s: &str) -> String {
21
+
let re = Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").unwrap();
22
+
re.replace_all(s, "").to_string()
23
+
}
24
+
25
+
#[test]
26
+
fn render_full_matches_dimensions() {
27
+
// sample sizes to validate behavior across different terminal shapes
28
+
let sizes = [(80usize, 24usize), (100usize, 10usize), (40usize, 20usize)];
29
+
30
+
for (w, h) in sizes.iter().cloned() {
31
+
// populate 50 entries so the viewport/pagination logic is exercised
32
+
let mut entries: Vec<(String, String)> = Vec::new();
33
+
for i in 0..50 {
34
+
let name = format!("cmd{}", i + 1);
35
+
let desc = format!("description {}", i + 1);
36
+
entries.push((name, desc));
37
+
}
38
+
let mut m = crate::ui::initial_model(entries);
39
+
40
+
// simulate WindowSize message
41
+
m.update(crate::ui::Msg::WindowSize {
42
+
width: w,
43
+
height: h,
44
+
});
45
+
46
+
// render the full view
47
+
let out = m.render_full();
48
+
49
+
// strip ANSI escape sequences so we can measure plain character dimensions
50
+
let stripped = strip_ansi(&out);
51
+
52
+
// collect lines and assert the rendered height matches requested height
53
+
let lines: Vec<&str> = stripped.lines().collect();
54
+
assert_eq!(
55
+
lines.len(),
56
+
h,
57
+
"height mismatch for {}x{}: got {} lines\n<<output>>\n{}",
58
+
w,
59
+
h,
60
+
lines.len(),
61
+
stripped
62
+
);
63
+
64
+
// each line must have exactly `w` characters after stripping ANSI
65
+
for (idx, line) in lines.iter().enumerate() {
66
+
let lw = line.chars().count();
67
+
assert_eq!(
68
+
lw, w,
69
+
"width mismatch at line {idx} for {w}x{h}: got {lw} chars\nline: `{line}`\n<<output>>\n{stripped}"
70
+
);
71
+
}
72
+
}
73
+
}
74
+
75
+
#[test]
76
+
fn modeline_is_last_line_and_exact_width() {
77
+
let (w, h) = (80usize, 24usize);
78
+
let entries: Vec<(String, String)> = Vec::new();
79
+
let mut m = crate::ui::initial_model(entries);
80
+
m.update(crate::ui::Msg::WindowSize {
81
+
width: w,
82
+
height: h,
83
+
});
84
+
let out = m.render_full();
85
+
let stripped = strip_ansi(&out);
86
+
let lines: Vec<&str> = stripped.lines().collect();
87
+
assert!(!lines.is_empty(), "no lines rendered");
88
+
let last = *lines.last().unwrap();
89
+
assert_eq!(
90
+
last.chars().count(),
91
+
w,
92
+
"modeline width mismatch: got {} expected {}\n<<output>>\n{}",
93
+
last.chars().count(),
94
+
w,
95
+
stripped
96
+
);
97
+
let modeline = crate::ui::render_modeline_padded(&m);
98
+
let modeline_stripped = strip_ansi(&modeline);
99
+
let modeline_first = modeline_stripped.lines().next().unwrap_or("");
100
+
assert_eq!(
101
+
last, modeline_first,
102
+
"modeline content mismatch:\n<<output>>\n{stripped}"
103
+
);
104
+
}
105
+
106
+
#[test]
107
+
fn preview_box_first_three_lines() {
108
+
let (w, h) = (80usize, 24usize);
109
+
let entries: Vec<(String, String)> = Vec::new();
110
+
let mut m = crate::ui::initial_model(entries);
111
+
m.update(crate::ui::Msg::WindowSize {
112
+
width: w,
113
+
height: h,
114
+
});
115
+
let out = m.render_full();
116
+
let stripped = strip_ansi(&out);
117
+
let lines: Vec<&str> = stripped.lines().collect();
118
+
assert!(lines.len() >= 3, "not enough lines to contain preview box");
119
+
let preview_block = m.render_preview_block();
120
+
let helper_combined = preview_block.join("\n");
121
+
let helper_stripped = strip_ansi(&helper_combined);
122
+
let helper_lines: Vec<&str> = helper_stripped.lines().collect();
123
+
for i in 0..3 {
124
+
assert_eq!(
125
+
lines[i], helper_lines[i],
126
+
"preview box line {i} mismatch:\n<<output>>\n{stripped}"
127
+
);
128
+
}
129
+
}
130
+
131
+
#[test]
132
+
fn main_content_matches_between_preview_and_modeline() {
133
+
let (w, h) = (80usize, 24usize);
134
+
let entries: Vec<(String, String)> = Vec::new();
135
+
let mut m = crate::ui::initial_model(entries);
136
+
m.update(crate::ui::Msg::WindowSize {
137
+
width: w,
138
+
height: h,
139
+
});
140
+
let full = m.render_full();
141
+
let full_stripped = strip_ansi(&full);
142
+
let mut full_lines: Vec<&str> = full_stripped.lines().collect();
143
+
assert!(
144
+
full_lines.len() >= 4,
145
+
"not enough lines in full render to extract main content"
146
+
);
147
+
let preview_block = m.render_preview_block();
148
+
let preview_combined = preview_block.join("\n");
149
+
let preview_stripped = strip_ansi(&preview_combined);
150
+
let preview_height = preview_stripped.lines().count();
151
+
let middle_from_full = if full_lines.len() > preview_height + 1 {
152
+
full_lines
153
+
.drain(preview_height..full_lines.len() - 1)
154
+
.collect::<Vec<&str>>()
155
+
} else {
156
+
vec![]
157
+
};
158
+
let main = m.render_main_content();
159
+
let main_stripped = strip_ansi(&main);
160
+
let main_lines: Vec<&str> = main_stripped.lines().collect();
161
+
let mut left = middle_from_full;
162
+
while left.last().is_some_and(|s| s.trim().is_empty()) {
163
+
left.pop();
164
+
}
165
+
let mut right = main_lines;
166
+
while right.last().is_some_and(|s| s.trim().is_empty()) {
167
+
right.pop();
168
+
}
169
+
assert_eq!(left.len(), right.len(), "main content line count mismatch");
170
+
for (i, (a, b)) in left.iter().zip(right.iter()).enumerate() {
171
+
assert_eq!(a, b, "main content line {i} mismatch");
172
+
}
173
+
}
174
+
175
+
#[test]
176
+
fn main_content_uses_viewport() {
177
+
let (w, h) = (30usize, 10usize);
178
+
let mut m = crate::ui::initial_model(Vec::new());
179
+
let mut items: Vec<crate::ui::ChooseItem> = Vec::new();
180
+
for i in 0..40 {
181
+
let name = format!("cmd{}", i + 1);
182
+
items.push(crate::ui::ChooseItem {
183
+
kind: "cmd".to_string(),
184
+
label: name.clone(),
185
+
forms: vec![name.clone()],
186
+
flag_def: None,
187
+
cmd_def: None,
188
+
short: String::new(),
189
+
depth: 0,
190
+
});
191
+
}
192
+
m.items = items;
193
+
m.update(crate::ui::Msg::WindowSize {
194
+
width: w,
195
+
height: h,
196
+
});
197
+
let full = m.render_full();
198
+
let stripped = strip_ansi(&full);
199
+
let lines: Vec<&str> = stripped.lines().collect();
200
+
assert_eq!(
201
+
lines.len(),
202
+
h,
203
+
"full render height mismatch: got {} expected {}\n<<output>>\n{}",
204
+
lines.len(),
205
+
h,
206
+
stripped
207
+
);
208
+
for (idx, line) in lines.iter().enumerate() {
209
+
let lw = line.chars().count();
210
+
assert_eq!(
211
+
lw, w,
212
+
"width mismatch at line {idx}: got {lw} expected {w}\nline: `{line}`\n<<output>>\n{stripped}"
213
+
);
214
+
}
215
+
let modeline = crate::ui::render_modeline_padded(&m);
216
+
let modeline_stripped = strip_ansi(&modeline);
217
+
let total_pages = if m.per_page == 0 {
218
+
1
219
+
} else {
220
+
m.items.len().div_ceil(m.per_page)
221
+
};
222
+
let expect_pag = format!("Page 1/{total_pages}");
223
+
assert!(
224
+
modeline_stripped.contains(&expect_pag),
225
+
"modeline does not show pagination\n<<output>>\n{full}"
226
+
);
227
+
let preview_block = m.render_preview_block();
228
+
let preview_height = preview_block.len();
229
+
let middle: Vec<&str> = if lines.len() > preview_height + 1 {
230
+
lines[preview_height..lines.len() - 1].to_vec()
231
+
} else {
232
+
Vec::new()
233
+
};
234
+
let expected_per = m.per_page;
235
+
assert_eq!(middle.len(), expected_per, "main content page size mismatch: got {middle_len} expected {expected_per}\n<<output>>\n{stripped}", middle_len = middle.len());
236
+
for (i, line) in middle.iter().enumerate().take(expected_per) {
237
+
let expect = format!("cmd{}", i + 1);
238
+
assert!(line.contains(&expect), "expected main content line {i} to contain `{expect}` but got `{line}`\n<<output>>\n{stripped}");
239
+
}
240
+
}
241
+
}
+586
crates/src/ui/render/list.rs
+586
crates/src/ui/render/list.rs
···
···
1
+
use crate::acekey::assign_ace_keys;
2
+
use crate::ui::model::leading_hyphen_count;
3
+
use crate::ui::model::{ChooseItem, DEFAULT_WIDTH, Model};
4
+
use crate::ui::render::decorate::decorate_form;
5
+
use crate::ui::render::styles::{STYLE_DESC, STYLE_LABEL, STYLE_LINENUM};
6
+
use crate::ui::render::util::normalize_and_pad;
7
+
use std::collections::{HashMap, HashSet};
8
+
9
+
// Collect forms in baseline order for a numeric baseline subset
10
+
fn baseline_subset_forms(nb: &[usize], items: &[ChooseItem]) -> Vec<String> {
11
+
let mut subset_forms = Vec::new();
12
+
for &idx in nb.iter() {
13
+
if let Some(it) = items.get(idx) {
14
+
for f in &it.forms {
15
+
subset_forms.push(f.clone());
16
+
}
17
+
}
18
+
}
19
+
subset_forms
20
+
}
21
+
22
+
// Given a list of forms and the typed buffer, produce the ace-key assignment map
23
+
fn assign_prefix_map(forms: &[String], typed_raw: &str) -> HashMap<String, String> {
24
+
let assignments = assign_ace_keys(forms, typed_raw);
25
+
let mut assigned: HashMap<String, String> = forms.iter().cloned().map(|f| (f, String::new())).collect();
26
+
if let Some(asg) = assignments {
27
+
for a in asg.iter() {
28
+
if a.index < forms.len() {
29
+
assigned.insert(forms[a.index].clone(), a.prefix.clone());
30
+
}
31
+
}
32
+
}
33
+
assigned
34
+
}
35
+
36
+
pub fn assigned_map(m: &Model) -> HashMap<String, String> {
37
+
// When Numeric mode is active, compute assignments only for the numeric-filtered subset.
38
+
if let Some(nb) = &m.numeric_baseline {
39
+
// Build forms for the baseline subset in the same order as baseline
40
+
let subset_forms = baseline_subset_forms(nb, &m.items);
41
+
return assign_prefix_map(&subset_forms, &m.typed_raw);
42
+
}
43
+
44
+
// Default: use all items
45
+
let forms: Vec<String> = m
46
+
.items
47
+
.iter()
48
+
.flat_map(|it| it.forms.iter().cloned())
49
+
.collect();
50
+
assign_prefix_map(&forms, &m.typed_raw)
51
+
}
52
+
53
+
fn render_visible_items_numeric(nb: &[usize], m: &Model) -> Vec<ChooseItem> {
54
+
// typed_raw should be digits
55
+
if !m.typed_raw.is_empty() && m.typed_raw.chars().all(|c| c.is_ascii_digit()) {
56
+
let matches: Vec<usize> = nb
57
+
.iter()
58
+
.filter_map(|&orig_idx| {
59
+
let num = (orig_idx + 1).to_string();
60
+
if num.starts_with(&m.typed_raw) {
61
+
Some(orig_idx)
62
+
} else {
63
+
None
64
+
}
65
+
})
66
+
.collect();
67
+
matches
68
+
.into_iter()
69
+
.filter_map(|i| m.items.get(i).cloned())
70
+
.collect()
71
+
} else {
72
+
// no typed digits yet: return full baseline items in baseline order
73
+
nb.iter().filter_map(|&i| m.items.get(i).cloned()).collect()
74
+
}
75
+
}
76
+
77
+
fn render_visible_items_alpha(m: &Model) -> Vec<ChooseItem> {
78
+
let forms: Vec<String> = m
79
+
.items
80
+
.iter()
81
+
.flat_map(|it| it.forms.iter().cloned())
82
+
.collect();
83
+
let assignments = assign_ace_keys(&forms, &m.typed_raw);
84
+
let mut visible_forms: HashSet<String> = HashSet::new();
85
+
86
+
if let Some(asg) = assignments {
87
+
for a in asg.iter() {
88
+
if a.index < forms.len() {
89
+
visible_forms.insert(forms[a.index].clone());
90
+
}
91
+
}
92
+
} else if m.typed.is_empty() {
93
+
visible_forms = forms.into_iter().collect();
94
+
}
95
+
96
+
m.items
97
+
.iter()
98
+
.filter(|it| it.forms.iter().any(|f| visible_forms.contains(f)))
99
+
.cloned()
100
+
.collect()
101
+
}
102
+
103
+
pub fn render_visible_items(m: &Model) -> Vec<ChooseItem> {
104
+
if let Some(nb) = &m.numeric_baseline {
105
+
render_visible_items_numeric(nb, m)
106
+
} else {
107
+
render_visible_items_alpha(m)
108
+
}
109
+
}
110
+
111
+
fn compute_gutter_width(total: usize) -> usize {
112
+
if total == 0 {
113
+
return 1;
114
+
}
115
+
let gw = ((total as f64).log10().floor() as usize) + 1;
116
+
usize::max(gw, 3)
117
+
}
118
+
119
+
fn format_num_str(num: usize, gutter_width: usize) -> String {
120
+
format!("{:>1$} │ ", num, gutter_width)
121
+
}
122
+
123
+
// Build baseline numbers and order when numeric baseline is active
124
+
fn build_baseline(m: &Model) -> Option<(Vec<String>, Vec<usize>)> {
125
+
if let Some(nb) = &m.numeric_baseline {
126
+
let v: Vec<String> = nb.iter().map(|&orig_idx| (orig_idx + 1).to_string()).collect();
127
+
if v.is_empty() {
128
+
None
129
+
} else {
130
+
Some((v, nb.clone()))
131
+
}
132
+
} else {
133
+
None
134
+
}
135
+
}
136
+
137
+
// Given a baseline order and typed buffer, produce positions to render (vis_pos, orig_idx)
138
+
fn collect_numeric_positions(nb_order: &[usize], typed: &str) -> Vec<(usize, usize)> {
139
+
let mut positions = Vec::new();
140
+
if !typed.is_empty() && typed.chars().all(|c| c.is_ascii_digit()) {
141
+
for (vis_pos, &orig_idx) in nb_order.iter().enumerate() {
142
+
let num = (orig_idx + 1).to_string();
143
+
if num.starts_with(typed) {
144
+
positions.push((vis_pos, orig_idx));
145
+
}
146
+
}
147
+
} else {
148
+
for (vis_pos, &orig_idx) in nb_order.iter().enumerate() {
149
+
positions.push((vis_pos, orig_idx));
150
+
}
151
+
}
152
+
positions
153
+
}
154
+
155
+
fn build_label(it: &ChooseItem, assigned: &HashMap<String, String>, t_hyph: usize, m: &Model) -> Option<String> {
156
+
let mut parts = Vec::new();
157
+
for f in &it.forms {
158
+
if t_hyph >= 2 && leading_hyphen_count(f) < t_hyph {
159
+
continue;
160
+
}
161
+
parts.push(decorate_form(f, &m.typed_raw, assigned.get(f).cloned().unwrap_or_default()));
162
+
}
163
+
if parts.is_empty() {
164
+
None
165
+
} else {
166
+
Some(parts.join(", "))
167
+
}
168
+
}
169
+
170
+
fn flag_suffix(it: &ChooseItem, m: &Model) -> Vec<String> {
171
+
let mut suffix = Vec::new();
172
+
if let Some(fd) = &it.flag_def {
173
+
if fd.requires_value {
174
+
let mut placeholder = "VALUE".to_string();
175
+
if !fd.longhand.is_empty() {
176
+
placeholder = fd.longhand.to_uppercase();
177
+
} else if !fd.shorthand.is_empty() {
178
+
placeholder = fd.shorthand.to_uppercase();
179
+
}
180
+
suffix.push(STYLE_DESC.render(&format!(" {placeholder}")));
181
+
suffix.push(STYLE_DESC.render(" "));
182
+
} else {
183
+
suffix.push(STYLE_DESC.render(" "));
184
+
}
185
+
if !fd.usage.is_empty() {
186
+
suffix.push(STYLE_DESC.render(&fd.usage));
187
+
}
188
+
let top_depth = m.ast.stack.len().saturating_sub(1);
189
+
if it.depth < top_depth && it.depth < m.ast.stack.len() {
190
+
let origin = &m.ast.stack[it.depth].name;
191
+
if !origin.is_empty() {
192
+
suffix.push(STYLE_DESC.render(&format!(" (from {origin})")));
193
+
}
194
+
}
195
+
}
196
+
suffix
197
+
}
198
+
199
+
fn cmd_suffix(it: &ChooseItem) -> Option<String> {
200
+
let short_ref: &str = if !it.short.is_empty() {
201
+
it.short.as_str()
202
+
} else if let Some(cd) = &it.cmd_def {
203
+
cd.short.as_str()
204
+
} else {
205
+
""
206
+
};
207
+
if short_ref.is_empty() {
208
+
None
209
+
} else {
210
+
Some(STYLE_DESC.render(&format!(" {short_ref}")))
211
+
}
212
+
}
213
+
214
+
// Render a single ChooseItem into a line (without trailing newline). Returns None when nothing should be rendered.
215
+
fn render_item_line(
216
+
it: &ChooseItem,
217
+
assigned: &HashMap<String, String>,
218
+
t_hyph: usize,
219
+
num_str: String,
220
+
m: &Model,
221
+
) -> Option<String> {
222
+
let label = build_label(it, assigned, t_hyph, m)?;
223
+
let mut line_pieces: Vec<String> = vec![STYLE_LINENUM.render(&num_str), STYLE_LABEL.render(&label)];
224
+
line_pieces.extend(flag_suffix(it, m));
225
+
if let Some(s) = cmd_suffix(it) {
226
+
line_pieces.push(s);
227
+
}
228
+
Some(line_pieces.join(""))
229
+
}
230
+
231
+
// Render when numeric baseline is active
232
+
fn render_numeric_content(m: &Model, assigned: &HashMap<String, String>, bs: &Vec<String>, nb_order: &Vec<usize>, t_hyph: usize, gutter_width: usize) -> String {
233
+
let mut b = String::new();
234
+
let positions = collect_numeric_positions(nb_order, &m.typed_raw);
235
+
if positions.is_empty() {
236
+
return b;
237
+
}
238
+
let total_positions = positions.len();
239
+
let per_page = if m.per_page == 0 { total_positions } else { m.per_page };
240
+
let start_pos = m.page.saturating_mul(per_page);
241
+
let end_pos = usize::min(start_pos + per_page, total_positions);
242
+
243
+
for pos_idx in start_pos..end_pos {
244
+
let (vis_pos, orig_idx) = positions[pos_idx];
245
+
if let Some(it) = m.items.get(orig_idx) {
246
+
let num_str = if vis_pos < bs.len() {
247
+
format!("{:>1$} │ ", bs[vis_pos], gutter_width)
248
+
} else {
249
+
format_num_str(orig_idx + 1, gutter_width)
250
+
};
251
+
if let Some(line) = render_item_line(it, assigned, t_hyph, num_str, m) {
252
+
b.push_str(&line);
253
+
b.push('\n');
254
+
}
255
+
}
256
+
}
257
+
b
258
+
}
259
+
260
+
// Default non-numeric render path
261
+
fn render_default_content(m: &Model, visible: &[ChooseItem], baseline_num_strs: &Option<Vec<String>>, assigned: &HashMap<String, String>, t_hyph: usize, gutter_width: usize, start: usize, end: usize) -> String {
262
+
let mut b = String::new();
263
+
for (idx, it) in visible.iter().enumerate().skip(start).take(end.saturating_sub(start)) {
264
+
let num_str = if let Some(bs) = baseline_num_strs {
265
+
if idx < bs.len() {
266
+
format!("{:>1$} │ ", bs[idx], gutter_width)
267
+
} else {
268
+
format_num_str(idx + 1, gutter_width)
269
+
}
270
+
} else {
271
+
format_num_str(idx + 1, gutter_width)
272
+
};
273
+
274
+
if let Some(line) = render_item_line(it, assigned, t_hyph, num_str, m) {
275
+
b.push_str(&line);
276
+
b.push('\n');
277
+
}
278
+
}
279
+
b
280
+
}
281
+
282
+
pub fn render_list_content(m: &Model, visible: &[ChooseItem]) -> String {
283
+
let assigned = m.assigned_map();
284
+
285
+
// If numeric baseline is active, compute total from baseline for gutter width
286
+
let (total, per) = if let Some(nb) = &m.numeric_baseline {
287
+
// total for gutter calculation should reflect the largest original index number
288
+
// use the maximum orig_idx+1 so gutter width does not shrink during numeric filtering
289
+
let max_num = nb.iter().map(|&i| i + 1).max().unwrap_or(0);
290
+
let t = max_num;
291
+
(t, if m.per_page == 0 { t } else { m.per_page })
292
+
} else {
293
+
let t = visible.len();
294
+
(t, if m.per_page == 0 { t } else { m.per_page })
295
+
};
296
+
297
+
if per == 0 {
298
+
return String::new();
299
+
}
300
+
let start = m.page.saturating_mul(per);
301
+
let end = usize::min(start + per, total);
302
+
let t_hyph = leading_hyphen_count(&m.typed_raw);
303
+
let gutter_width = compute_gutter_width(total);
304
+
305
+
let baseline = build_baseline(m);
306
+
307
+
// Numeric baseline path
308
+
if let Some((bs, nb_order)) = baseline.as_ref() {
309
+
return render_numeric_content(m, &assigned, bs, &nb_order, t_hyph, gutter_width);
310
+
}
311
+
312
+
// Default non-numeric path
313
+
render_default_content(m, visible, &baseline.map(|(v, _)| v), &assigned, t_hyph, gutter_width, start, end)
314
+
}
315
+
316
+
pub fn render_main_content(m: &Model) -> String {
317
+
let total_width = if m.screen_width > 0 {
318
+
m.screen_width
319
+
} else {
320
+
DEFAULT_WIDTH
321
+
};
322
+
323
+
if m.in_value_mode {
324
+
let lines: Vec<String> = vec![
325
+
lipgloss::Style::new().bold(true).render("Value input: ") + &m.pending_value,
326
+
lipgloss::Style::new()
327
+
.faint(true)
328
+
.render("Press Enter to confirm, Esc to cancel"),
329
+
];
330
+
let per = if m.per_page == 0 { lines.len() } else { m.per_page };
331
+
return normalize_and_pad(lines, total_width, per);
332
+
}
333
+
334
+
let visible = m.render_visible_items();
335
+
let list_block = m.render_list_content(&visible);
336
+
let lines: Vec<String> = list_block.lines().map(|s| s.to_string()).collect();
337
+
let per = if m.per_page == 0 {
338
+
lines.len()
339
+
} else {
340
+
m.per_page
341
+
};
342
+
// Ensure we return exactly `per` lines each normalized to the terminal width.
343
+
normalize_and_pad(lines, total_width, per)
344
+
}
345
+
346
+
#[cfg(test)]
347
+
mod tests {
348
+
use regex::Regex;
349
+
350
+
fn strip_ansi(s: &str) -> String {
351
+
let re = Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").unwrap();
352
+
re.replace_all(s, "").to_string()
353
+
}
354
+
355
+
#[test]
356
+
fn render_assigned_map_initial_prefixes_shows_labels() {
357
+
let mut m = crate::ui::initial_model(vec![]);
358
+
m.items = vec![
359
+
crate::ui::ChooseItem {
360
+
kind: "flag".to_string(),
361
+
label: "--long".to_string(),
362
+
forms: vec!["--long".to_string()],
363
+
flag_def: None,
364
+
cmd_def: None,
365
+
short: String::new(),
366
+
depth: 0,
367
+
},
368
+
crate::ui::ChooseItem {
369
+
kind: "flag".to_string(),
370
+
label: "-s".to_string(),
371
+
forms: vec!["-s".to_string()],
372
+
flag_def: None,
373
+
cmd_def: None,
374
+
short: String::new(),
375
+
depth: 0,
376
+
},
377
+
crate::ui::ChooseItem {
378
+
kind: "cmd".to_string(),
379
+
label: "cmd".to_string(),
380
+
forms: vec!["cmd".to_string()],
381
+
flag_def: None,
382
+
cmd_def: None,
383
+
short: String::new(),
384
+
depth: 0,
385
+
},
386
+
];
387
+
m.typed_raw = "".to_string();
388
+
let visible = m.render_visible_items();
389
+
let list = m.render_list_content(&visible);
390
+
let stripped = strip_ansi(&list);
391
+
assert!(stripped.contains("--long"));
392
+
assert!(stripped.contains("-s"));
393
+
assert!(stripped.contains("cmd"));
394
+
}
395
+
396
+
#[test]
397
+
fn render_build_items_from_command_includes_flags_and_subcommands() {
398
+
let mut m = crate::ui::initial_model(vec![]);
399
+
let def = crate::ast::CommandDef {
400
+
name: "root".to_string(),
401
+
short: "rootcmd".to_string(),
402
+
aliases: vec![],
403
+
flags: vec![crate::ast::FlagDef {
404
+
longhand: "verbose".to_string(),
405
+
shorthand: "v".to_string(),
406
+
usage: "v".to_string(),
407
+
requires_value: false,
408
+
}],
409
+
subcommands: vec![crate::ast::CommandDef {
410
+
name: "sub".to_string(),
411
+
short: "subcmd".to_string(),
412
+
aliases: vec![],
413
+
flags: vec![],
414
+
subcommands: vec![],
415
+
}],
416
+
};
417
+
m.ast = crate::ast::Segment::new_empty("root");
418
+
m.current = Some(def.clone());
419
+
m.build_items_from_command(&def);
420
+
let visible = m.render_visible_items();
421
+
let list = m.render_list_content(&visible);
422
+
let stripped = strip_ansi(&list);
423
+
assert!(stripped.contains("--verbose") || stripped.contains("-v"));
424
+
assert!(stripped.contains("sub"));
425
+
}
426
+
427
+
#[test]
428
+
fn render_flag_add_remove_toggle_and_render() {
429
+
let mut m = crate::ui::initial_model(vec![]);
430
+
let def = crate::ast::CommandDef {
431
+
name: "root".to_string(),
432
+
short: "rootcmd".to_string(),
433
+
aliases: vec![],
434
+
flags: vec![
435
+
crate::ast::FlagDef {
436
+
longhand: "message".to_string(),
437
+
shorthand: "m".to_string(),
438
+
usage: "msg".to_string(),
439
+
requires_value: true,
440
+
},
441
+
crate::ast::FlagDef {
442
+
longhand: "verbose".to_string(),
443
+
shorthand: "v".to_string(),
444
+
usage: "v".to_string(),
445
+
requires_value: false,
446
+
},
447
+
],
448
+
subcommands: vec![],
449
+
};
450
+
m.ast = crate::ast::Segment::new_empty("root");
451
+
m.current = Some(def.clone());
452
+
m.build_items_from_command(&def);
453
+
m.ast.add_flag_to_depth(0, "--verbose", "");
454
+
let preview = m.render_preview();
455
+
let stripped = strip_ansi(&preview);
456
+
assert!(stripped.contains("--verbose"));
457
+
let removed = m.ast.remove_flag_from_depth("--verbose", 0);
458
+
assert!(removed);
459
+
let preview2 = m.render_preview();
460
+
let stripped2 = strip_ansi(&preview2);
461
+
assert!(!stripped2.contains("--verbose"));
462
+
m.ast.add_flag_to_depth(0, "--message", "hello");
463
+
let preview3 = m.render_preview();
464
+
assert!(strip_ansi(&preview3).contains("--message"));
465
+
}
466
+
467
+
#[test]
468
+
fn render_add_positionals_and_undo_to_root() {
469
+
let mut m = crate::ui::initial_model(vec![]);
470
+
m.ast = crate::ast::Segment::new_empty("root");
471
+
m.ast.push_subcommand("sub");
472
+
m.ast.add_flag_to_depth(0, "--rootflag", "");
473
+
m.ast.add_positional("a");
474
+
m.ast.add_positional("b");
475
+
let p = strip_ansi(&m.render_preview());
476
+
assert_eq!(p, "root --rootflag sub a b");
477
+
m.ast.remove_last();
478
+
assert_eq!(strip_ansi(&m.render_preview()), "root --rootflag sub a");
479
+
m.ast.remove_last();
480
+
assert_eq!(strip_ansi(&m.render_preview()), "root --rootflag sub");
481
+
m.ast.remove_last();
482
+
assert_eq!(strip_ansi(&m.render_preview()), "root sub");
483
+
m.ast.remove_last();
484
+
assert_eq!(strip_ansi(&m.render_preview()).trim(), "root");
485
+
}
486
+
487
+
#[test]
488
+
fn render_parent_and_subcommand_flags_preview_and_undo() {
489
+
let mut m = crate::ui::initial_model(vec![]);
490
+
m.ast = crate::ast::Segment::new_empty("root");
491
+
m.ast.push_subcommand("sub");
492
+
m.ast.add_flag_to_depth(0, "--rootflag", "");
493
+
m.ast.add_flag_to_depth(1, "--subflag", "");
494
+
assert!(
495
+
strip_ansi(&m.render_preview()).contains("--rootflag")
496
+
&& strip_ansi(&m.render_preview()).contains("--subflag")
497
+
);
498
+
m.ast.remove_last();
499
+
assert!(!strip_ansi(&m.render_preview()).contains("--subflag"));
500
+
}
501
+
502
+
#[test]
503
+
fn render_typed_buffer_preserved_and_highlighted_on_ambiguity() {
504
+
let mut m = crate::ui::initial_model(vec![]);
505
+
m.items = vec![
506
+
crate::ui::ChooseItem {
507
+
kind: "cmd".to_string(),
508
+
label: "chcpu".to_string(),
509
+
forms: vec!["chcpu".to_string()],
510
+
flag_def: None,
511
+
cmd_def: None,
512
+
short: String::new(),
513
+
depth: 0,
514
+
},
515
+
crate::ui::ChooseItem {
516
+
kind: "cmd".to_string(),
517
+
label: "chgrp".to_string(),
518
+
forms: vec!["chgrp".to_string()],
519
+
flag_def: None,
520
+
cmd_def: None,
521
+
short: String::new(),
522
+
depth: 0,
523
+
},
524
+
crate::ui::ChooseItem {
525
+
kind: "cmd".to_string(),
526
+
label: "chroot".to_string(),
527
+
forms: vec!["chroot".to_string()],
528
+
flag_def: None,
529
+
cmd_def: None,
530
+
short: String::new(),
531
+
depth: 0,
532
+
},
533
+
crate::ui::ChooseItem {
534
+
kind: "cmd".to_string(),
535
+
label: "chpasswd".to_string(),
536
+
forms: vec!["chpasswd".to_string()],
537
+
flag_def: None,
538
+
cmd_def: None,
539
+
short: String::new(),
540
+
depth: 0,
541
+
},
542
+
];
543
+
m.typed_raw = "c".to_string();
544
+
545
+
// filtered visible items should include multiple candidates (ambiguity)
546
+
let visible = m.render_visible_items();
547
+
assert!(
548
+
visible.len() >= 2,
549
+
"expected at least two visible candidates when typed 'c'"
550
+
);
551
+
552
+
// assigned disambiguators should be present for the visible forms
553
+
let assigned = m.assigned_map();
554
+
for it in &visible {
555
+
for f in &it.forms {
556
+
let pref = assigned.get(f).cloned().unwrap_or_default();
557
+
assert!(!pref.is_empty(), "expected disambiguator for form {f}");
558
+
}
559
+
}
560
+
561
+
// typed buffer should be preserved in the model mode
562
+
// model.mode() reflects the normalized typed buffer (`typed`), ensure it's set
563
+
m.typed = "c".to_string();
564
+
assert_eq!(m.mode(), "Typed: c");
565
+
566
+
// ACE highlight must still be present in the rendered output for at least
567
+
// one of the assigned disambiguators (ANSI-coded). We don't require it
568
+
// to be 'c' specifically because assign_ace_keys may choose a different
569
+
// disambiguator rune in the filtered set.
570
+
let list = m.render_list_content(&visible);
571
+
let mut found_ace = false;
572
+
for (_k, v) in assigned.iter() {
573
+
if !v.is_empty() {
574
+
let styled = crate::ui::render::styles::STYLE_ACE.render(v);
575
+
if list.contains(&styled) {
576
+
found_ace = true;
577
+
break;
578
+
}
579
+
}
580
+
}
581
+
assert!(
582
+
found_ace,
583
+
"expected at least one ACE-styled disambiguator present in rendered list"
584
+
);
585
+
}
586
+
}
+226
crates/src/ui/render/modeline.rs
+226
crates/src/ui/render/modeline.rs
···
···
1
+
use crate::ui::model::{ChooseItem, DEFAULT_WIDTH, Model};
2
+
use crate::ui::render::styles::STYLE_MODELINE;
3
+
use lipgloss::Color;
4
+
5
+
pub fn render_modeline(m: &Model, inner_max: usize, mode: &str, visible: &[ChooseItem]) -> String {
6
+
// Build styled pairs, compute plain widths, and fit pagination into available space.
7
+
let total = visible.len();
8
+
let per = if m.per_page == 0 { total } else { m.per_page };
9
+
let total_pages = if per > 0 { total.div_ceil(per) } else { 1 };
10
+
11
+
// prepare inner styles without padding so spacing is under our control
12
+
let inner_style = STYLE_MODELINE.clone().padding(0, 0, 0, 0);
13
+
let key_style = STYLE_MODELINE
14
+
.clone()
15
+
.foreground(Color::from_rgb(238, 0, 238))
16
+
.bold(true)
17
+
.padding(0, 0, 0, 0);
18
+
let desc_style = STYLE_MODELINE.clone().padding(0, 0, 0, 0);
19
+
let pag_style = STYLE_MODELINE.clone().faint(true).padding(0, 0, 0, 0);
20
+
21
+
// key/description pairs definitions
22
+
let pairs_def: Vec<(&str, &str)> =
23
+
vec![("␣", "arg"), ("⏎", "run"), ("⌫", "undo"), ("⎋", "quit")];
24
+
25
+
// Build rendered pairs and their plain widths in one pass
26
+
let pairs: Vec<(String, usize)> = pairs_def
27
+
.iter()
28
+
.map(|(k, d)| {
29
+
let plain_len = d.chars().count() + 1 + k.chars().count();
30
+
let rendered = format!(
31
+
"{}{}{}",
32
+
desc_style.render(d),
33
+
inner_style.render(":"),
34
+
key_style.render(k)
35
+
);
36
+
(rendered, plain_len)
37
+
})
38
+
.collect();
39
+
40
+
let pair_sep_rendered = inner_style.render(" ");
41
+
let pair_sep_width = 2usize;
42
+
43
+
// build pagination plain and styled
44
+
let mut pag_plain = String::new();
45
+
let mut pag_rendered = String::new();
46
+
if total_pages > 1 {
47
+
pag_plain = format!("Page {}/{} ↑/↓", m.page + 1, total_pages);
48
+
let arrows = format!("{}/{}", key_style.render("↑"), key_style.render("↓"));
49
+
let pag_unstyled = format!("Page {}/{} ", m.page + 1, total_pages);
50
+
pag_rendered = pag_style.render(&format!("{pag_unstyled}{arrows}"));
51
+
}
52
+
let mut pag_width = pag_plain.chars().count();
53
+
54
+
// Start with all pairs and compute left width
55
+
let mut pairs_count = pairs.len();
56
+
let mut left_joined_rendered = if pairs_count > 0 {
57
+
pairs
58
+
.iter()
59
+
.map(|(r, _)| r.clone())
60
+
.collect::<Vec<_>>()
61
+
.join(&pair_sep_rendered)
62
+
} else {
63
+
String::new()
64
+
};
65
+
let mut left_width = if pairs_count > 0 {
66
+
pairs.iter().map(|(_, w)| *w).sum::<usize>() + pair_sep_width * (pairs_count - 1)
67
+
} else {
68
+
0
69
+
};
70
+
71
+
// mode and separator widths (mode has padding of 2 chars in modeStyle)
72
+
let mode_len = mode.chars().count();
73
+
let mode_padding = 2usize; // Padding(0,1) adds 1 left + 1 right
74
+
let mode_w = mode_len + mode_padding;
75
+
let sep_w = " | ".chars().count();
76
+
77
+
let avail = if inner_max > mode_w + sep_w {
78
+
inner_max - mode_w - sep_w
79
+
} else {
80
+
0
81
+
};
82
+
83
+
// drop rightmost pairs until left + pag fits into avail
84
+
while pairs_count > 0 && left_width + pag_width > avail {
85
+
// remove last pair
86
+
pairs_count -= 1;
87
+
if pairs_count > 0 {
88
+
left_width = pairs
89
+
.iter()
90
+
.take(pairs_count)
91
+
.map(|(_, w)| *w)
92
+
.sum::<usize>()
93
+
+ pair_sep_width * (pairs_count - 1);
94
+
left_joined_rendered = pairs
95
+
.iter()
96
+
.take(pairs_count)
97
+
.map(|(r, _)| r.clone())
98
+
.collect::<Vec<_>>()
99
+
.join(&pair_sep_rendered);
100
+
} else {
101
+
left_width = 0;
102
+
left_joined_rendered.clear();
103
+
}
104
+
}
105
+
106
+
// if still doesn't fit and pagination exists, shorten pagination to just "Page X/Y"
107
+
if left_width + pag_width > avail && !pag_plain.is_empty() {
108
+
let short_pag = format!("Page {}/{}", m.page + 1, total_pages);
109
+
pag_width = short_pag.chars().count();
110
+
pag_rendered = pag_style.render(&short_pag);
111
+
}
112
+
113
+
// compute filler width (subtract 2 to keep spacing consistent)
114
+
let pad = if avail > left_width + pag_width + 2 {
115
+
avail - left_width - pag_width - 2
116
+
} else {
117
+
0
118
+
};
119
+
let filler = if pad > 0 {
120
+
STYLE_MODELINE.clone().width(pad as i32).render("")
121
+
} else {
122
+
String::new()
123
+
};
124
+
125
+
let footer_inner = format!("{left_joined_rendered}{filler}{pag_rendered}");
126
+
127
+
let mode_style = STYLE_MODELINE
128
+
.clone()
129
+
.background(Color::from_rgb(101, 101, 101))
130
+
.padding(0, 1, 0, 1)
131
+
.bold(true);
132
+
let mode_styled = mode_style.render(mode);
133
+
134
+
// Indicator: show a dim single-char marker at the far left to indicate
135
+
// filtering mode. When numeric_baseline is present show '1', otherwise 'A'.
136
+
let indicator_char = if m.numeric_baseline.is_some() { "1" } else { "A" };
137
+
let indicator_style = STYLE_MODELINE.clone().faint(true).padding(0, 1, 0, 1);
138
+
let indicator_styled = indicator_style.render(indicator_char);
139
+
140
+
let sep_styled = inner_style.render(" | ");
141
+
let rest_content = format!("{sep_styled}{footer_inner}");
142
+
143
+
let trailing_pad = STYLE_MODELINE.render(" ");
144
+
145
+
// Place the indicator to the far left followed by the mode block.
146
+
format!("{indicator_styled}{mode_styled}{rest_content}{trailing_pad}")
147
+
}
148
+
149
+
pub fn render_modeline_padded(m: &Model) -> String {
150
+
// Compute total width and inner_max the same way render_full used to.
151
+
let total_width = if m.screen_width > 0 {
152
+
m.screen_width
153
+
} else {
154
+
DEFAULT_WIDTH
155
+
};
156
+
let inner_max = if total_width > 0 {
157
+
total_width.saturating_sub(2) - 1
158
+
} else {
159
+
DEFAULT_WIDTH
160
+
};
161
+
let visible = m.render_visible_items();
162
+
let mode = m.mode();
163
+
let modeline = render_modeline(m, inner_max, &mode, &visible);
164
+
let modeline_single = modeline.replace('\n', " ");
165
+
STYLE_MODELINE
166
+
.clone()
167
+
.width(total_width as i32)
168
+
.render(&modeline_single)
169
+
}
170
+
171
+
#[cfg(test)]
172
+
mod tests {
173
+
use regex::Regex;
174
+
175
+
fn strip_ansi(s: &str) -> String {
176
+
let re = Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").unwrap();
177
+
re.replace_all(s, "").to_string()
178
+
}
179
+
180
+
#[test]
181
+
fn modeline_is_last_line_and_exact_width_small() {
182
+
let (w, h) = (80usize, 24usize);
183
+
let entries: Vec<(String, String)> = Vec::new();
184
+
let mut m = crate::ui::initial_model(entries);
185
+
m.update(crate::ui::Msg::WindowSize {
186
+
width: w,
187
+
height: h,
188
+
});
189
+
let modeline = crate::ui::render_modeline_padded(&m);
190
+
let modeline_stripped = strip_ansi(&modeline);
191
+
assert!(
192
+
modeline_stripped
193
+
.lines()
194
+
.next()
195
+
.unwrap_or("")
196
+
.chars()
197
+
.count()
198
+
<= w
199
+
);
200
+
}
201
+
202
+
#[test]
203
+
fn modeline_shows_numeric_indicator_when_numeric_baseline() {
204
+
let (w, h) = (80usize, 24usize);
205
+
let entries: Vec<(String, String)> = Vec::new();
206
+
let mut m = crate::ui::initial_model(entries);
207
+
m.update(crate::ui::Msg::WindowSize { width: w, height: h });
208
+
// simulate numeric mode baseline captured
209
+
m.numeric_baseline = Some(vec![0, 1, 2]);
210
+
let modeline = crate::ui::render_modeline_padded(&m);
211
+
let modeline_stripped = strip_ansi(&modeline);
212
+
assert!(modeline_stripped.trim_start().starts_with('1'));
213
+
}
214
+
215
+
#[test]
216
+
fn modeline_shows_alpha_indicator_when_not_numeric() {
217
+
let (w, h) = (80usize, 24usize);
218
+
let entries: Vec<(String, String)> = Vec::new();
219
+
let mut m = crate::ui::initial_model(entries);
220
+
m.update(crate::ui::Msg::WindowSize { width: w, height: h });
221
+
m.numeric_baseline = None;
222
+
let modeline = crate::ui::render_modeline_padded(&m);
223
+
let modeline_stripped = strip_ansi(&modeline);
224
+
assert!(modeline_stripped.trim_start().starts_with('A'));
225
+
}
226
+
}
+26
crates/src/ui/render/preview.rs
+26
crates/src/ui/render/preview.rs
···
···
1
+
use crate::ui::model::{DEFAULT_WIDTH, Model, PREVIEW_BLOCK_LINES};
2
+
use crate::ui::render::styles::{STYLE_PREVIEW, STYLE_PREVIEW_BOX};
3
+
4
+
pub fn render_preview(m: &Model) -> String {
5
+
STYLE_PREVIEW.render(&m.ast.render_preview())
6
+
}
7
+
8
+
pub fn render_preview_block(m: &Model) -> Vec<String> {
9
+
let preview = m.ast.render_preview();
10
+
let preview_line = format!("> {preview}");
11
+
let box_width = if m.screen_width >= 2 {
12
+
m.screen_width - 2
13
+
} else {
14
+
DEFAULT_WIDTH
15
+
};
16
+
let w_i32: i32 = box_width.try_into().unwrap_or(i32::MAX);
17
+
let inner = STYLE_PREVIEW.render(&preview_line);
18
+
let preview_block = STYLE_PREVIEW_BOX.clone().width(w_i32).render(&inner);
19
+
let mut out: Vec<String> = preview_block.lines().map(|s| s.to_string()).collect();
20
+
// Ensure the preview block occupies exactly PREVIEW_BLOCK_LINES lines by truncating or padding with empty lines.
21
+
out.truncate(PREVIEW_BLOCK_LINES);
22
+
while out.len() < PREVIEW_BLOCK_LINES {
23
+
out.push(String::new());
24
+
}
25
+
out
26
+
}
+31
crates/src/ui/render/styles.rs
+31
crates/src/ui/render/styles.rs
···
···
1
+
use lipgloss::{Color, Style, rounded_border};
2
+
use once_cell::sync::Lazy;
3
+
4
+
// Styles kept local to render module
5
+
pub static STYLE_ACE: Lazy<Style> = Lazy::new(|| {
6
+
Style::new()
7
+
.foreground(Color::from_rgb(238, 0, 238))
8
+
.bold(true)
9
+
});
10
+
pub static STYLE_TYPED: Lazy<Style> = Lazy::new(|| {
11
+
Style::new()
12
+
.foreground(Color::from_rgb(0, 0, 238))
13
+
.bold(true)
14
+
});
15
+
pub static STYLE_PREVIEW: Lazy<Style> = Lazy::new(|| {
16
+
Style::new()
17
+
.foreground(Color::from_rgb(0, 238, 238))
18
+
.bold(true)
19
+
});
20
+
pub static STYLE_LABEL: Lazy<Style> =
21
+
Lazy::new(|| Style::new().foreground(Color::from_rgb(200, 200, 200)));
22
+
pub static STYLE_DESC: Lazy<Style> = Lazy::new(|| Style::new().faint(true));
23
+
pub static STYLE_MODELINE: Lazy<Style> = Lazy::new(|| {
24
+
Style::new()
25
+
.background(Color::from_rgb(95, 95, 95))
26
+
.foreground(Color::from_rgb(255, 255, 255))
27
+
.padding(0, 1, 0, 1)
28
+
});
29
+
pub static STYLE_PREVIEW_BOX: Lazy<Style> =
30
+
Lazy::new(|| Style::new().border(rounded_border()).padding(0, 1, 0, 1));
31
+
pub static STYLE_LINENUM: Lazy<Style> = Lazy::new(|| Style::new().faint(true));
+14
crates/src/ui/render/util.rs
+14
crates/src/ui/render/util.rs
···
···
1
+
use lipgloss::Style;
2
+
3
+
pub fn normalize_and_pad(lines: Vec<String>, total_width: usize, per: usize) -> String {
4
+
let line_style = Style::new().width(total_width as i32);
5
+
let mut normalized: Vec<String> = lines.into_iter().map(|l| line_style.render(&l)).collect();
6
+
if normalized.len() > per {
7
+
normalized.truncate(per);
8
+
} else {
9
+
while normalized.len() < per {
10
+
normalized.push(line_style.render(""));
11
+
}
12
+
}
13
+
normalized.join("\n")
14
+
}
+218
crates/src/ui/run.rs
+218
crates/src/ui/run.rs
···
···
1
+
use crate::carapace;
2
+
use crate::ui::model::Model;
3
+
use crate::ui::model::initial_model;
4
+
use bubbletea_rs::{
5
+
Program, command::Cmd, event::KeyMsg, event::WindowSizeMsg, model::Model as TeaModel,
6
+
};
7
+
use crossterm::event::{KeyCode, KeyModifiers};
8
+
9
+
// helper to build forms for a FlagDef
10
+
fn flag_forms(f: &crate::ast::FlagDef) -> Vec<String> {
11
+
let mut forms = Vec::new();
12
+
if !f.longhand.is_empty() {
13
+
forms.push(format!("--{}", f.longhand));
14
+
}
15
+
if !f.shorthand.is_empty() {
16
+
forms.push(format!("-{}", f.shorthand));
17
+
}
18
+
forms
19
+
}
20
+
21
+
// Keep the interactive runner and the non-interactive parsing behavior here.
22
+
pub fn run(initial_args: Vec<String>) -> Result<String, String> {
23
+
// preload carapace --list with descriptions
24
+
let entries = match carapace::list_with_desc() {
25
+
Ok(e) => e,
26
+
Err(err) => return Err(format!("carapace --list failed: {err}")),
27
+
};
28
+
let mut m = initial_model(entries);
29
+
30
+
if !initial_args.is_empty() {
31
+
// set root
32
+
let root = &initial_args[0];
33
+
match carapace::export(root) {
34
+
Ok(def) => {
35
+
m.ast.root = def.name.clone();
36
+
if !m.ast.stack.is_empty() {
37
+
m.ast.stack[0].name = def.name.clone();
38
+
}
39
+
m.build_items_from_command(&def);
40
+
m.current = Some(def);
41
+
}
42
+
Err(e) => return Err(format!("carapace {root} export failed: {e}")),
43
+
}
44
+
45
+
// parse remaining tokens
46
+
let mut i = 1usize;
47
+
while i < initial_args.len() {
48
+
let tok = &initial_args[i];
49
+
if tok.starts_with('-') {
50
+
// flag form; find exact-form match among current.Flags
51
+
let mut matched = false;
52
+
if let Some(cur) = &m.current {
53
+
for f in &cur.flags {
54
+
for fm in flag_forms(f).iter() {
55
+
if fm == tok {
56
+
// add flag; if requires value and next arg exists and isn't a flag, consume it
57
+
let mut val = String::new();
58
+
if f.requires_value
59
+
&& i + 1 < initial_args.len()
60
+
&& !initial_args[i + 1].starts_with('-')
61
+
{
62
+
val = initial_args[i + 1].clone();
63
+
i += 1;
64
+
}
65
+
m.ast.add_flag(fm, &val);
66
+
matched = true;
67
+
break;
68
+
}
69
+
}
70
+
if matched {
71
+
break;
72
+
}
73
+
}
74
+
}
75
+
if !matched {
76
+
m.ast.add_positional(tok);
77
+
}
78
+
i += 1;
79
+
continue;
80
+
}
81
+
// not a flag: could be subcommand or positional
82
+
let mut found = false;
83
+
if let Some(cur) = m.current.clone() {
84
+
for sc in cur.subcommands.iter() {
85
+
if sc.name == *tok || sc.aliases.iter().any(|a| a == tok) {
86
+
m.ast.push_subcommand(&sc.name);
87
+
m.current = Some(sc.clone());
88
+
m.build_items_from_command(sc);
89
+
found = true;
90
+
break;
91
+
}
92
+
}
93
+
}
94
+
if !found {
95
+
m.ast.add_positional(tok);
96
+
}
97
+
i += 1;
98
+
}
99
+
}
100
+
101
+
// If initial_args were provided we are non-interactive: return the recorded preview (may be empty)
102
+
if !initial_args.is_empty() {
103
+
return Ok(m.exit_preview.clone());
104
+
}
105
+
106
+
// Interactive path: build a TeaAdapter that delegates to our Model and run the bubbletea-rs Program.
107
+
struct TeaAdapter {
108
+
inner: Model,
109
+
}
110
+
111
+
impl TeaModel for TeaAdapter {
112
+
fn init() -> (Self, Option<Cmd>) {
113
+
// Preload entries for interactive session (best-effort)
114
+
let entries = carapace::list_with_desc().unwrap_or_default();
115
+
let model = initial_model(entries);
116
+
(TeaAdapter { inner: model }, None)
117
+
}
118
+
119
+
fn update(&mut self, msg: bubbletea_rs::event::Msg) -> Option<Cmd> {
120
+
// Map bubbletea-rs Msg types to our ui::Msg and call update
121
+
if let Some(km) = msg.downcast_ref::<KeyMsg>() {
122
+
// Normalize and handle global quit keys first for reliability across terminals:
123
+
match &km.key {
124
+
KeyCode::Esc => {
125
+
if !self.inner.in_value_mode {
126
+
return Some(bubbletea_rs::quit());
127
+
}
128
+
self.inner.update(crate::ui::Msg::KeyEsc);
129
+
return None;
130
+
}
131
+
KeyCode::Char(ch) => {
132
+
if *ch == '\u{1b}' {
133
+
if !self.inner.in_value_mode {
134
+
return Some(bubbletea_rs::quit());
135
+
}
136
+
self.inner.update(crate::ui::Msg::KeyEsc);
137
+
return None;
138
+
}
139
+
if *ch == '\u{03}' {
140
+
// Ctrl-C delivered as ETX
141
+
return Some(bubbletea_rs::quit());
142
+
}
143
+
if km.modifiers.contains(KeyModifiers::CONTROL)
144
+
&& (*ch == 'c' || *ch == 'C')
145
+
{
146
+
return Some(bubbletea_rs::quit());
147
+
}
148
+
}
149
+
_ => {}
150
+
}
151
+
152
+
match &km.key {
153
+
KeyCode::Enter => {
154
+
self.inner.update(crate::ui::Msg::KeyEnter);
155
+
if !self.inner.exit_preview.is_empty() {
156
+
return Some(bubbletea_rs::quit());
157
+
}
158
+
}
159
+
KeyCode::Backspace => {
160
+
self.inner.update(crate::ui::Msg::KeyBackspace);
161
+
}
162
+
KeyCode::Esc => { /* handled above */ }
163
+
KeyCode::Up => {
164
+
self.inner.update(crate::ui::Msg::KeyUp);
165
+
}
166
+
KeyCode::Down => {
167
+
self.inner.update(crate::ui::Msg::KeyDown);
168
+
}
169
+
KeyCode::Char(ch) => {
170
+
if km.modifiers.contains(KeyModifiers::CONTROL) {
171
+
match ch {
172
+
'n' | 'N' => {
173
+
self.inner.update(crate::ui::Msg::KeyDown);
174
+
}
175
+
'p' | 'P' => {
176
+
self.inner.update(crate::ui::Msg::KeyUp);
177
+
}
178
+
_ => {}
179
+
}
180
+
} else if *ch == ' ' {
181
+
self.inner.update(crate::ui::Msg::KeySpace);
182
+
} else {
183
+
self.inner.update(crate::ui::Msg::Rune(*ch));
184
+
}
185
+
}
186
+
_ => {}
187
+
}
188
+
return None;
189
+
}
190
+
if let Some(ws) = msg.downcast_ref::<WindowSizeMsg>() {
191
+
self.inner.update(crate::ui::Msg::WindowSize {
192
+
width: ws.width as usize,
193
+
height: ws.height as usize,
194
+
});
195
+
return None;
196
+
}
197
+
None
198
+
}
199
+
200
+
fn view(&self) -> String {
201
+
self.inner.render_full()
202
+
}
203
+
}
204
+
205
+
let builder = Program::<TeaAdapter>::builder()
206
+
.alt_screen(true)
207
+
.signal_handler(true);
208
+
let program = match builder.build() {
209
+
Ok(p) => p,
210
+
Err(e) => return Err(format!("failed to build program: {e:?}")),
211
+
};
212
+
let final_adapter = match futures::executor::block_on(program.run()) {
213
+
Ok(fa) => fa,
214
+
Err(e) => return Err(format!("program error: {e:?}")),
215
+
};
216
+
217
+
Ok(final_adapter.inner.exit_preview.clone())
218
+
}
+816
crates/src/ui/update.rs
+816
crates/src/ui/update.rs
···
···
1
+
use crate::acekey::assign_ace_keys;
2
+
use crate::carapace;
3
+
use crate::ui::model::ChooseItem;
4
+
use crate::ui::model::Model;
5
+
use bubbletea_widgets::Viewport;
6
+
use std::collections::HashMap;
7
+
8
+
pub fn handle_update(m: &mut Model, msg: crate::ui::Msg) {
9
+
match msg {
10
+
crate::ui::Msg::WindowSize { width, height } => handle_window_size(m, width, height),
11
+
crate::ui::Msg::KeyBackspace => handle_key_backspace(m),
12
+
crate::ui::Msg::KeyEnter => handle_key_enter(m),
13
+
crate::ui::Msg::KeySpace => handle_key_space(m),
14
+
crate::ui::Msg::KeyEsc => handle_key_esc(m),
15
+
crate::ui::Msg::KeyDown => handle_key_down(m),
16
+
crate::ui::Msg::KeyUp => handle_key_up(m),
17
+
crate::ui::Msg::Rune(r) => handle_rune(m, r),
18
+
}
19
+
}
20
+
21
+
fn handle_window_size(m: &mut Model, width: usize, height: usize) {
22
+
m.screen_width = width;
23
+
m.per_page = height.saturating_sub(crate::ui::model::RESERVED_LINES);
24
+
m.vp = Viewport::new(m.per_page, m.screen_width);
25
+
let visible = m.render_visible_items();
26
+
let total_pages = if visible.is_empty() {
27
+
1
28
+
} else {
29
+
visible.len().div_ceil(m.per_page)
30
+
};
31
+
if m.page >= total_pages {
32
+
m.page = 0;
33
+
}
34
+
let list_content = m.render_list_content(&visible);
35
+
m.vp.set_content(&list_content);
36
+
if !m.typed.is_empty() {
37
+
m.vp.goto_top();
38
+
}
39
+
}
40
+
41
+
fn handle_key_backspace(m: &mut Model) {
42
+
if !m.typed.is_empty() {
43
+
m.typed.pop();
44
+
m.typed_raw.pop();
45
+
// If typed_raw becomes empty, clear numeric_baseline since numeric mode ended
46
+
if m.typed_raw.is_empty() {
47
+
m.numeric_baseline = None;
48
+
}
49
+
return;
50
+
}
51
+
52
+
if let Some(top) = m.ast.top() {
53
+
if !m.ast.root.is_empty()
54
+
&& m.ast.stack.len() == 1
55
+
&& top.flags.is_empty()
56
+
&& top.positionals.is_empty()
57
+
{
58
+
match carapace::list_with_desc() {
59
+
Ok(entries) => {
60
+
set_items_from_carapace_entries(m, entries);
61
+
return;
62
+
}
63
+
Err(e) => {
64
+
m.err = e;
65
+
return;
66
+
}
67
+
}
68
+
}
69
+
}
70
+
71
+
let before = m.ast.stack.len();
72
+
m.ast.remove_last();
73
+
let after = m.ast.stack.len();
74
+
if after < before {
75
+
restore_current_after_pop(m);
76
+
}
77
+
}
78
+
79
+
fn handle_key_enter(m: &mut Model) {
80
+
if m.in_value_mode {
81
+
if m.pending_pos {
82
+
if !m.pending_value.is_empty() {
83
+
m.ast.add_positional(&m.pending_value);
84
+
}
85
+
m.in_value_mode = false;
86
+
m.pending_pos = false;
87
+
m.pending_value.clear();
88
+
return;
89
+
}
90
+
if let Some(_fd) = &m.pending_flag {
91
+
m.ast
92
+
.add_flag_to_depth(m.pending_depth, &m.pending_form, &m.pending_value);
93
+
m.in_value_mode = false;
94
+
m.pending_flag = None;
95
+
m.pending_form.clear();
96
+
m.pending_value.clear();
97
+
return;
98
+
}
99
+
}
100
+
let preview = m.ast.render_preview();
101
+
if preview.is_empty() {
102
+
return;
103
+
}
104
+
m.exit_preview = preview.clone();
105
+
}
106
+
107
+
fn handle_key_space(m: &mut Model) {
108
+
m.in_value_mode = true;
109
+
m.pending_pos = true;
110
+
}
111
+
112
+
fn handle_key_esc(m: &mut Model) {
113
+
if m.in_value_mode {
114
+
m.in_value_mode = false;
115
+
m.pending_flag = None;
116
+
m.pending_form.clear();
117
+
m.pending_pos = false;
118
+
m.pending_depth = 0;
119
+
m.pending_value.clear();
120
+
}
121
+
}
122
+
123
+
fn handle_key_down(m: &mut Model) {
124
+
let visible = m.render_visible_items();
125
+
let total = visible.len();
126
+
let per = if m.per_page == 0 { total } else { m.per_page };
127
+
if per == 0 {
128
+
return;
129
+
}
130
+
let total_pages = if total == 0 {
131
+
1
132
+
} else {
133
+
total.div_ceil(per)
134
+
};
135
+
if m.page + 1 < total_pages {
136
+
m.page += 1;
137
+
}
138
+
let list_content = m.render_list_content(&visible);
139
+
m.vp.set_content(&list_content);
140
+
m.vp.goto_top();
141
+
}
142
+
143
+
fn handle_key_up(m: &mut Model) {
144
+
if m.page > 0 {
145
+
m.page -= 1;
146
+
}
147
+
let visible = m.render_visible_items();
148
+
let list_content = m.render_list_content(&visible);
149
+
m.vp.set_content(&list_content);
150
+
}
151
+
152
+
fn clear_typed(m: &mut Model) {
153
+
m.typed.clear();
154
+
m.typed_raw.clear();
155
+
}
156
+
157
+
fn handle_command_choice(m: &mut Model, it: &ChooseItem, chosen_form: &str) -> bool {
158
+
let cmd_name = if let Some(cd) = &it.cmd_def {
159
+
cd.name.clone()
160
+
} else {
161
+
chosen_form.to_string()
162
+
};
163
+
164
+
if m.current.is_none() && m.ast.root.is_empty() {
165
+
match carapace::export(&cmd_name) {
166
+
Ok(def) => {
167
+
apply_loaded_command(m, def);
168
+
return true;
169
+
}
170
+
Err(e) => {
171
+
m.err = e;
172
+
return true;
173
+
}
174
+
}
175
+
}
176
+
177
+
m.ast.push_subcommand(&cmd_name);
178
+
179
+
if let Some(subdef) = &it.cmd_def {
180
+
m.current = Some(subdef.clone());
181
+
m.build_items_from_command(subdef);
182
+
clear_typed(m);
183
+
return true;
184
+
}
185
+
186
+
match carapace::export(chosen_form) {
187
+
Ok(def) => {
188
+
m.def_cache.insert(def.name.clone(), def.clone());
189
+
m.current = Some(def.clone());
190
+
m.build_items_from_command(&def);
191
+
clear_typed(m);
192
+
true
193
+
}
194
+
Err(e) => {
195
+
m.err = e;
196
+
true
197
+
}
198
+
}
199
+
}
200
+
201
+
fn handle_flag_choice(
202
+
m: &mut Model,
203
+
fd: &crate::ast::FlagDef,
204
+
chosen_form: &str,
205
+
depth: usize,
206
+
) -> bool {
207
+
if m.ast.remove_flag_from_depth(chosen_form, depth) {
208
+
clear_typed(m);
209
+
return true;
210
+
}
211
+
if fd.requires_value {
212
+
m.in_value_mode = true;
213
+
m.pending_flag = Some(fd.clone());
214
+
m.pending_form = chosen_form.to_string();
215
+
m.pending_depth = depth;
216
+
clear_typed(m);
217
+
return true;
218
+
}
219
+
220
+
m.ast.add_flag_to_depth(depth, chosen_form, "");
221
+
clear_typed(m);
222
+
true
223
+
}
224
+
225
+
fn update_typed_for_rune(m: &mut Model, r: char, was_numeric: bool) {
226
+
// Handles all non-initial-numeric-capture typed updates
227
+
if r.is_ascii_digit() && was_numeric {
228
+
m.typed_raw.push(r);
229
+
m.typed.push(r.to_ascii_lowercase());
230
+
m.page = 0;
231
+
return;
232
+
}
233
+
234
+
if r.is_ascii_alphabetic() && (m.typed_raw.chars().all(|c| c.is_ascii_digit()) && !m.typed_raw.is_empty()) {
235
+
// Transition from Numeric mode to Alpha mode
236
+
m.numeric_baseline = None;
237
+
m.typed_raw.clear();
238
+
m.typed.clear();
239
+
m.typed_raw.push(r);
240
+
m.typed.push(r.to_ascii_lowercase());
241
+
m.page = 0;
242
+
return;
243
+
}
244
+
245
+
// Regular AceKey character handling (alpha or other)
246
+
m.typed_raw.push(r);
247
+
m.typed.push(r.to_ascii_lowercase());
248
+
m.page = 0;
249
+
}
250
+
251
+
fn forms_and_form_map(m: &Model) -> (Vec<String>, HashMap<String, usize>) {
252
+
let forms: Vec<String> = m.items.iter().flat_map(|it| it.forms.iter().cloned()).collect();
253
+
let form_map: HashMap<String, usize> = m
254
+
.items
255
+
.iter()
256
+
.enumerate()
257
+
.flat_map(|(item_idx, it)| it.forms.iter().cloned().map(move |f| (f, item_idx)))
258
+
.collect();
259
+
(forms, form_map)
260
+
}
261
+
262
+
fn simulate_alpha_treatment(m: &Model, r: char, was_numeric: bool) -> bool {
263
+
if !(r.is_ascii_digit() && !was_numeric) {
264
+
return false;
265
+
}
266
+
267
+
let (forms_all, _fm) = forms_and_form_map(m);
268
+
let mut sim_typed = m.typed_raw.clone();
269
+
sim_typed.push(r);
270
+
271
+
if let Some(asg) = assign_ace_keys(&forms_all, &sim_typed) {
272
+
if !asg.is_empty() {
273
+
return true;
274
+
}
275
+
}
276
+
277
+
if m.items.len() == 1 && m.items.iter().any(|it| it.forms.iter().any(|f| f.contains(&r.to_string()))) {
278
+
return true;
279
+
}
280
+
281
+
false
282
+
}
283
+
284
+
fn handle_rune(m: &mut Model, r: char) {
285
+
let s = r.to_string();
286
+
if !crate::acekey::is_single_ace_rune(&s) {
287
+
return;
288
+
}
289
+
290
+
let was_numeric = m.typed_raw.chars().all(|c| c.is_ascii_digit()) && !m.typed_raw.is_empty();
291
+
292
+
// If incoming rune is a digit starting a potential numeric mode, treat it as numeric
293
+
// only when simulate_alpha_treatment returns false. Flattened for readability.
294
+
if r.is_ascii_digit() && !was_numeric && !simulate_alpha_treatment(m, r, was_numeric) {
295
+
capture_numeric_baseline(m, r);
296
+
} else {
297
+
update_typed_for_rune(m, r, was_numeric);
298
+
}
299
+
300
+
let (forms, form_map) = forms_and_form_map(m);
301
+
let assignments = assign_ace_keys(&forms, &m.typed_raw);
302
+
303
+
if process_numeric_selection(m) {
304
+
return;
305
+
}
306
+
307
+
if let Some(asg) = assignments {
308
+
if try_immediate_assignment_selection(m, asg, &forms, &form_map) {
309
+
return;
310
+
}
311
+
}
312
+
313
+
update_viewport_after_typed(m);
314
+
}
315
+
316
+
fn capture_numeric_baseline(m: &mut Model, r: char) {
317
+
let visible_snapshot = m.render_visible_items();
318
+
let mut baseline_indices: Vec<usize> = visible_snapshot
319
+
.iter()
320
+
.filter_map(|vis| {
321
+
m.items
322
+
.iter()
323
+
.position(|it| it.label == vis.label && it.forms == vis.forms)
324
+
})
325
+
.collect();
326
+
327
+
if baseline_indices.is_empty() {
328
+
baseline_indices = (0..m.items.len()).collect();
329
+
}
330
+
331
+
m.numeric_baseline = Some(baseline_indices);
332
+
m.typed_raw.clear();
333
+
m.typed.clear();
334
+
m.typed_raw.push(r);
335
+
m.typed.push(r.to_ascii_lowercase());
336
+
m.page = 0;
337
+
}
338
+
339
+
fn set_items_from_carapace_entries(m: &mut Model, entries: Vec<(String, String)>) {
340
+
let items: Vec<ChooseItem> = entries
341
+
.into_iter()
342
+
.map(|(name, short)| ChooseItem {
343
+
kind: "cmd".to_string(),
344
+
label: name.clone(),
345
+
forms: vec![name.clone()],
346
+
flag_def: None,
347
+
cmd_def: None,
348
+
short,
349
+
depth: 0,
350
+
})
351
+
.collect();
352
+
m.items = crate::ui::model::sort_items(items);
353
+
m.current = None;
354
+
m.ast.root.clear();
355
+
if let Some(n) = m.ast.stack.get_mut(0) {
356
+
n.name.clear();
357
+
}
358
+
let visible = m.render_visible_items();
359
+
let list_content = m.render_list_content(&visible);
360
+
m.vp.set_content(&list_content);
361
+
}
362
+
363
+
fn restore_current_after_pop(m: &mut Model) {
364
+
if !m.ast.root.is_empty() {
365
+
let root_name = m.ast.stack[0].name.clone();
366
+
if let Some(def) = m.def_cache.get(&root_name) {
367
+
let mut cur = def.clone();
368
+
if m.ast.stack.len() > 1 {
369
+
for i in 1..m.ast.stack.len() {
370
+
let name = m.ast.stack[i].name.clone();
371
+
if let Some(found) = cur
372
+
.subcommands
373
+
.iter()
374
+
.find(|sc| sc.name == name || sc.aliases.iter().any(|a| a == &name))
375
+
{
376
+
cur = found.clone();
377
+
} else {
378
+
break;
379
+
}
380
+
}
381
+
}
382
+
m.current = Some(cur.clone());
383
+
m.build_items_from_command(&cur);
384
+
let visible = m.render_visible_items();
385
+
let list_content = m.render_list_content(&visible);
386
+
m.vp.set_content(&list_content);
387
+
} else {
388
+
m.current = None;
389
+
m.items.clear();
390
+
m.vp.set_content("");
391
+
}
392
+
} else {
393
+
m.current = None;
394
+
m.items.clear();
395
+
m.vp.set_content("");
396
+
}
397
+
}
398
+
399
+
fn process_numeric_selection(m: &mut Model) -> bool {
400
+
let is_numeric = !m.typed_raw.is_empty() && m.typed_raw.chars().all(|c| c.is_ascii_digit());
401
+
if !is_numeric { return false; }
402
+
if let Some(baseline) = &m.numeric_baseline {
403
+
let matches: Vec<usize> = baseline
404
+
.iter()
405
+
.filter_map(|&orig_idx| {
406
+
let num = (orig_idx + 1).to_string();
407
+
if num.starts_with(&m.typed_raw) {
408
+
Some(orig_idx)
409
+
} else {
410
+
None
411
+
}
412
+
})
413
+
.collect();
414
+
if matches.len() == 1 {
415
+
let chosen_idx = matches[0];
416
+
let it = m.items[chosen_idx].clone();
417
+
let chosen_form = it.forms.first().cloned().unwrap_or_default();
418
+
419
+
if it.kind == "cmd" {
420
+
if handle_command_choice(m, &it, &chosen_form) {
421
+
m.numeric_baseline = None;
422
+
return true;
423
+
}
424
+
} else if it.kind == "flag" {
425
+
if let Some(fd) = &it.flag_def {
426
+
if handle_flag_choice(m, fd, &chosen_form, it.depth) {
427
+
m.numeric_baseline = None;
428
+
return true;
429
+
}
430
+
}
431
+
}
432
+
}
433
+
} else {
434
+
let matches: Vec<usize> = m
435
+
.items
436
+
.iter()
437
+
.enumerate()
438
+
.filter_map(|(idx, _)| {
439
+
let num = (idx + 1).to_string();
440
+
if num.starts_with(&m.typed_raw) {
441
+
Some(idx)
442
+
} else {
443
+
None
444
+
}
445
+
})
446
+
.collect();
447
+
if matches.len() == 1 {
448
+
let chosen_idx = matches[0];
449
+
let it = m.items[chosen_idx].clone();
450
+
let chosen_form = it.forms.first().cloned().unwrap_or_default();
451
+
if it.kind == "cmd" {
452
+
if handle_command_choice(m, &it, &chosen_form) {
453
+
return true;
454
+
}
455
+
} else if it.kind == "flag" {
456
+
if let Some(fd) = &it.flag_def {
457
+
if handle_flag_choice(m, fd, &chosen_form, it.depth) {
458
+
return true;
459
+
}
460
+
}
461
+
}
462
+
}
463
+
}
464
+
false
465
+
}
466
+
467
+
fn try_immediate_assignment_selection(m: &mut Model, assignments: Vec<crate::acekey::Assignment>, forms: &[String], form_map: &HashMap<String, usize>) -> bool {
468
+
if assignments.len() == 1 && assignments[0].prefix.is_empty() {
469
+
let visible_items = m.render_visible_items();
470
+
if visible_items.len() == 1 {
471
+
let idx = assignments[0].index;
472
+
if idx < forms.len() {
473
+
let chosen_form = forms[idx].clone();
474
+
if let Some(item_idx) = form_map.get(&chosen_form) {
475
+
let it = m.items[*item_idx].clone();
476
+
if it.kind == "cmd" {
477
+
handle_command_choice(m, &it, &chosen_form);
478
+
return true;
479
+
} else if it.kind == "flag" {
480
+
if let Some(fd) = &it.flag_def {
481
+
if handle_flag_choice(m, fd, &chosen_form, it.depth) {
482
+
return true;
483
+
}
484
+
}
485
+
}
486
+
}
487
+
}
488
+
}
489
+
}
490
+
false
491
+
}
492
+
493
+
fn update_viewport_after_typed(m: &mut Model) {
494
+
let visible_now = m.render_visible_items();
495
+
let list_content = m.render_list_content(&visible_now);
496
+
m.vp.set_content(&list_content);
497
+
if !m.typed.is_empty() {
498
+
m.vp.goto_top();
499
+
}
500
+
}
501
+
502
+
fn apply_loaded_command(m: &mut Model, def: crate::ast::CommandDef) {
503
+
m.def_cache.insert(def.name.clone(), def.clone());
504
+
m.ast.root = def.name.clone();
505
+
if m.ast.stack.is_empty() {
506
+
m.ast = crate::ast::Segment::new_empty(&def.name);
507
+
} else {
508
+
m.ast.stack[0].name = def.name.clone();
509
+
}
510
+
m.current = Some(def.clone());
511
+
m.build_items_from_command(&def);
512
+
// update viewport content so the interactive UI shows the newly loaded command items
513
+
let visible = m.render_visible_items();
514
+
let list_content = m.render_list_content(&visible);
515
+
m.vp.set_content(&list_content);
516
+
m.typed.clear();
517
+
m.typed_raw.clear();
518
+
}
519
+
520
+
#[cfg(test)]
521
+
mod tests {
522
+
use crate::ast::{Segment, CommandDef, FlagDef};
523
+
use crate::ui::model::initial_model;
524
+
525
+
#[test]
526
+
fn apply_loaded_command_sets_ast_and_items_and_viewport() {
527
+
let mut m = initial_model(vec![]);
528
+
// ensure model starts with an empty AST stack (simulate initial screen)
529
+
m.ast = Segment::default();
530
+
531
+
let sub = CommandDef {
532
+
name: "list".to_string(),
533
+
short: "listsub".to_string(),
534
+
aliases: vec![],
535
+
flags: vec![],
536
+
subcommands: vec![],
537
+
};
538
+
let def = CommandDef {
539
+
name: "ls".to_string(),
540
+
short: "lscmd".to_string(),
541
+
aliases: vec![],
542
+
flags: vec![FlagDef {
543
+
longhand: "all".to_string(),
544
+
shorthand: "a".to_string(),
545
+
usage: "show all".to_string(),
546
+
requires_value: false,
547
+
}],
548
+
subcommands: vec![sub.clone()],
549
+
};
550
+
551
+
// call the private helper as the interactive path would
552
+
super::apply_loaded_command(&mut m, def.clone());
553
+
554
+
// AST stack should have a root node named `ls`
555
+
assert!(
556
+
!m.ast.stack.is_empty(),
557
+
"expected AST stack to be non-empty"
558
+
);
559
+
assert_eq!(m.ast.stack[0].name, "ls");
560
+
561
+
// current should be set to the loaded def
562
+
assert!(m.current.is_some());
563
+
assert_eq!(m.current.as_ref().unwrap().name, "ls");
564
+
565
+
// items should include at least one flag or subcommand
566
+
let mut has_flag = false;
567
+
let mut has_cmd = false;
568
+
for it in &m.items {
569
+
if it.kind == "flag" {
570
+
has_flag = true
571
+
}
572
+
if it.kind == "cmd" {
573
+
has_cmd = true
574
+
}
575
+
}
576
+
assert!(
577
+
has_flag || has_cmd,
578
+
"expected flags or subcommands after loading command"
579
+
);
580
+
581
+
// typed buffers should be cleared
582
+
assert!(m.typed.is_empty() && m.typed_raw.is_empty());
583
+
584
+
// viewport content should contain something (non-empty)
585
+
// Viewport doesn't expose content directly; ensure render_visible_items produces expected output
586
+
let visible = m.render_visible_items();
587
+
let list = m.render_list_content(&visible);
588
+
assert!(
589
+
!list.is_empty(),
590
+
"expected rendered list content to be non-empty"
591
+
);
592
+
}
593
+
}
594
+
595
+
#[cfg(test)]
596
+
mod numeric_mode_tests {
597
+
use crate::ui::model::{initial_model, ChooseItem};
598
+
use crate::ast::{Segment, FlagDef, CommandDef};
599
+
600
+
#[test]
601
+
fn test_digit_switch_from_alpha_to_numeric_and_selects_unique_index() {
602
+
let mut m = initial_model(vec![]);
603
+
// ensure AST present so flag selection will add to depth
604
+
m.ast = Segment::new_empty("root");
605
+
m.ast.root = "root".to_string();
606
+
m.ast.stack[0].name = "root".to_string();
607
+
608
+
// create three flag items so index 2 uniquely identifies the middle
609
+
m.items = vec![
610
+
ChooseItem {
611
+
kind: "flag".to_string(),
612
+
label: "--flag1".to_string(),
613
+
forms: vec!["--flag1".to_string()],
614
+
flag_def: Some(FlagDef {
615
+
longhand: "flag1".to_string(),
616
+
shorthand: "f".to_string(),
617
+
usage: String::new(),
618
+
requires_value: false,
619
+
}),
620
+
cmd_def: None,
621
+
short: String::new(),
622
+
depth: 0,
623
+
},
624
+
ChooseItem {
625
+
kind: "flag".to_string(),
626
+
label: "--flag2".to_string(),
627
+
forms: vec!["--flag2".to_string()],
628
+
flag_def: Some(FlagDef {
629
+
longhand: "flag2".to_string(),
630
+
shorthand: "g".to_string(),
631
+
usage: String::new(),
632
+
requires_value: false,
633
+
}),
634
+
cmd_def: None,
635
+
short: String::new(),
636
+
depth: 0,
637
+
},
638
+
ChooseItem {
639
+
kind: "flag".to_string(),
640
+
label: "--flag3".to_string(),
641
+
forms: vec!["--flag3".to_string()],
642
+
flag_def: Some(FlagDef {
643
+
longhand: "flag3".to_string(),
644
+
shorthand: "h".to_string(),
645
+
usage: String::new(),
646
+
requires_value: false,
647
+
}),
648
+
cmd_def: None,
649
+
short: String::new(),
650
+
depth: 0,
651
+
},
652
+
];
653
+
654
+
// type digit '2' which should switch to numeric mode, capture baseline and select index 2
655
+
m.update(crate::ui::Msg::Rune('2'));
656
+
// Numeric input of '2' may immediately select the unique matching index.
657
+
let top = &m.ast.stack[0];
658
+
assert!(top.flags.iter().any(|f| f.form == "--flag2"), "expected flag --flag2 to be selected via numeric index");
659
+
}
660
+
661
+
#[test]
662
+
fn test_switch_back_to_alpha_clears_numeric_baseline() {
663
+
let mut m = initial_model(vec![]);
664
+
m.ast = Segment::new_empty("root");
665
+
m.ast.root = "root".to_string();
666
+
m.ast.stack[0].name = "root".to_string();
667
+
668
+
// populate items so baseline capture works
669
+
m.items = vec![
670
+
ChooseItem {
671
+
kind: "cmd".to_string(),
672
+
label: "one".to_string(),
673
+
forms: vec!["one".to_string()],
674
+
flag_def: None,
675
+
cmd_def: None,
676
+
short: String::new(),
677
+
depth: 0,
678
+
},
679
+
ChooseItem {
680
+
kind: "cmd".to_string(),
681
+
label: "two".to_string(),
682
+
forms: vec!["two".to_string()],
683
+
flag_def: None,
684
+
cmd_def: None,
685
+
short: String::new(),
686
+
depth: 0,
687
+
},
688
+
];
689
+
690
+
// create many items so that the single-digit prefix '1' is ambiguous
691
+
for i in 0..12 {
692
+
m.items.push(ChooseItem {
693
+
kind: "cmd".to_string(),
694
+
label: format!("cmd{}", i+1),
695
+
forms: vec![format!("cmd{}", i+1)],
696
+
flag_def: None,
697
+
cmd_def: None,
698
+
short: String::new(),
699
+
depth: 0,
700
+
});
701
+
}
702
+
703
+
// type digit to enter numeric; should capture baseline but not select (ambiguous)
704
+
m.update(crate::ui::Msg::Rune('1'));
705
+
assert!(m.numeric_baseline.is_some());
706
+
assert_eq!(m.typed_raw, "1");
707
+
708
+
// now type an alphabetic character which should clear numeric baseline and switch to alpha
709
+
m.update(crate::ui::Msg::Rune('x'));
710
+
assert!(m.numeric_baseline.is_none(), "expected numeric_baseline cleared after alpha rune");
711
+
assert_eq!(m.typed_raw, "x");
712
+
}
713
+
714
+
#[test]
715
+
fn test_w_wc_who_alpha_list_and_numeric_selects_who() {
716
+
use regex::Regex;
717
+
fn strip_ansi(s: &str) -> String {
718
+
let re = Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").unwrap();
719
+
re.replace_all(s, "").to_string()
720
+
}
721
+
722
+
let mut m = initial_model(vec![]);
723
+
m.ast = Segment::new_empty("root");
724
+
m.ast.root = "root".to_string();
725
+
m.ast.stack[0].name = "root".to_string();
726
+
727
+
let wdef = CommandDef { name: "w".to_string(), short: "w".to_string(), aliases: vec![], flags: vec![], subcommands: vec![] };
728
+
let wcdef = CommandDef { name: "wc".to_string(), short: "wc".to_string(), aliases: vec![], flags: vec![], subcommands: vec![] };
729
+
let whodef = CommandDef { name: "who".to_string(), short: "who".to_string(), aliases: vec![], flags: vec![], subcommands: vec![] };
730
+
731
+
m.items = vec![
732
+
ChooseItem {
733
+
kind: "cmd".to_string(),
734
+
label: "w".to_string(),
735
+
forms: vec!["w".to_string()],
736
+
flag_def: None,
737
+
cmd_def: Some(wdef.clone()),
738
+
short: String::new(),
739
+
depth: 0,
740
+
},
741
+
ChooseItem {
742
+
kind: "cmd".to_string(),
743
+
label: "wc".to_string(),
744
+
forms: vec!["wc".to_string()],
745
+
flag_def: None,
746
+
cmd_def: Some(wcdef.clone()),
747
+
short: String::new(),
748
+
depth: 0,
749
+
},
750
+
ChooseItem {
751
+
kind: "cmd".to_string(),
752
+
label: "who".to_string(),
753
+
forms: vec!["who".to_string()],
754
+
flag_def: None,
755
+
cmd_def: Some(whodef.clone()),
756
+
short: String::new(),
757
+
depth: 0,
758
+
},
759
+
];
760
+
761
+
// Type 'w' (alpha) - should show all three in order
762
+
m.update(crate::ui::Msg::Rune('w'));
763
+
let visible = m.render_visible_items();
764
+
assert_eq!(visible.len(), 3);
765
+
assert_eq!(visible[0].label, "w");
766
+
assert_eq!(visible[1].label, "wc");
767
+
assert_eq!(visible[2].label, "who");
768
+
769
+
let list = m.render_list_content(&visible);
770
+
let stripped = strip_ansi(&list);
771
+
let lines: Vec<&str> = stripped.lines().collect();
772
+
assert!(lines.len() >= 3, "expected at least 3 rendered lines");
773
+
assert!(lines[0].contains(" 1 │ w "), "{}", lines[0]);
774
+
assert!(lines[1].contains(" 2 │ wc "), "second line should show gutter 2 and 'wc'");
775
+
assert!(lines[2].contains(" 3 │ who "), "third line should show gutter 3 and 'who'");
776
+
777
+
// Now type '3' to switch to numeric mode and select who
778
+
m.update(crate::ui::Msg::Rune('3'));
779
+
assert!(m.ast.top().is_some(), "expected a subcommand selected");
780
+
assert_eq!(m.ast.top().unwrap().name, "who");
781
+
}
782
+
}
783
+
784
+
#[cfg(test)]
785
+
mod digit_vs_numeric_tests {
786
+
use crate::ui::model::{initial_model, ChooseItem};
787
+
use crate::ast::Segment;
788
+
789
+
#[test]
790
+
fn digit_present_in_form_treated_as_alpha_not_numeric() {
791
+
let mut m = initial_model(vec![]);
792
+
m.ast = Segment::new_empty("root");
793
+
m.ast.root = "root".to_string();
794
+
m.ast.stack[0].name = "root".to_string();
795
+
796
+
// item form contains digit '2' so typing '2' should be treated as alpha
797
+
m.items = vec![
798
+
ChooseItem {
799
+
kind: "cmd".to_string(),
800
+
label: "a2".to_string(),
801
+
forms: vec!["a2".to_string()],
802
+
flag_def: None,
803
+
cmd_def: None,
804
+
short: String::new(),
805
+
depth: 0,
806
+
},
807
+
];
808
+
809
+
// type digit '2'
810
+
m.update(crate::ui::Msg::Rune('2'));
811
+
// should remain in alpha: numeric_baseline must be None
812
+
assert!(m.numeric_baseline.is_none(), "expected digit in form to be treated as alpha");
813
+
// typed_raw should contain '2' as part of AceKey input
814
+
assert_eq!(m.typed_raw, "2");
815
+
}
816
+
}
+468
flake.lock
+468
flake.lock
···
···
1
+
{
2
+
"nodes": {
3
+
"allfollow": {
4
+
"inputs": {
5
+
"nixpkgs": "nixpkgs",
6
+
"rust-overlay": "rust-overlay",
7
+
"systems": "systems"
8
+
},
9
+
"locked": {
10
+
"lastModified": 1752903850,
11
+
"narHash": "sha256-Q9CVcods7Ftcs0KeplhkZOClQKqZy8zwfay02jvNloQ=",
12
+
"owner": "spikespaz",
13
+
"repo": "allfollow",
14
+
"rev": "5e097ac8c6fb8b9e32a3c590090005abe853cccf",
15
+
"type": "github"
16
+
},
17
+
"original": {
18
+
"owner": "spikespaz",
19
+
"repo": "allfollow",
20
+
"type": "github"
21
+
}
22
+
},
23
+
"cachix": {
24
+
"inputs": {
25
+
"devenv": [
26
+
"devenv"
27
+
],
28
+
"flake-compat": [
29
+
"devenv"
30
+
],
31
+
"git-hooks": [
32
+
"devenv",
33
+
"git-hooks"
34
+
],
35
+
"nixpkgs": [
36
+
"devenv",
37
+
"nixpkgs"
38
+
]
39
+
},
40
+
"locked": {
41
+
"lastModified": 1748883665,
42
+
"narHash": "sha256-R0W7uAg+BLoHjMRMQ8+oiSbTq8nkGz5RDpQ+ZfxxP3A=",
43
+
"owner": "cachix",
44
+
"repo": "cachix",
45
+
"rev": "f707778d902af4d62d8dd92c269f8e70de09acbe",
46
+
"type": "github"
47
+
},
48
+
"original": {
49
+
"owner": "cachix",
50
+
"ref": "latest",
51
+
"repo": "cachix",
52
+
"type": "github"
53
+
}
54
+
},
55
+
"devenv": {
56
+
"inputs": {
57
+
"cachix": "cachix",
58
+
"flake-compat": "flake-compat",
59
+
"git-hooks": "git-hooks",
60
+
"nix": "nix",
61
+
"nixpkgs": "nixpkgs_2"
62
+
},
63
+
"locked": {
64
+
"lastModified": 1756048064,
65
+
"narHash": "sha256-mVgB6qWhLrCW6AciLyFXosDKKZFtBgqvixcA8a07s+g=",
66
+
"owner": "cachix",
67
+
"repo": "devenv",
68
+
"rev": "3fb20c149d329b01a2b519fbb2a9ca3e6e6e1b05",
69
+
"type": "github"
70
+
},
71
+
"original": {
72
+
"owner": "cachix",
73
+
"repo": "devenv",
74
+
"type": "github"
75
+
}
76
+
},
77
+
"devshell": {
78
+
"inputs": {
79
+
"nixpkgs": "nixpkgs_3"
80
+
},
81
+
"locked": {
82
+
"lastModified": 1741473158,
83
+
"narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=",
84
+
"owner": "numtide",
85
+
"repo": "devshell",
86
+
"rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0",
87
+
"type": "github"
88
+
},
89
+
"original": {
90
+
"owner": "numtide",
91
+
"repo": "devshell",
92
+
"type": "github"
93
+
}
94
+
},
95
+
"flake-compat": {
96
+
"flake": false,
97
+
"locked": {
98
+
"lastModified": 1747046372,
99
+
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
100
+
"owner": "edolstra",
101
+
"repo": "flake-compat",
102
+
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
103
+
"type": "github"
104
+
},
105
+
"original": {
106
+
"owner": "edolstra",
107
+
"repo": "flake-compat",
108
+
"type": "github"
109
+
}
110
+
},
111
+
"flake-file": {
112
+
"locked": {
113
+
"lastModified": 1753122811,
114
+
"narHash": "sha256-D2uccKODLVkI91uiWlwwEkg0X7HcYeiKKGHsD24qmyU=",
115
+
"owner": "vic",
116
+
"repo": "flake-file",
117
+
"rev": "745975d82877699e9504fd0e751b4f70166f066c",
118
+
"type": "github"
119
+
},
120
+
"original": {
121
+
"owner": "vic",
122
+
"repo": "flake-file",
123
+
"type": "github"
124
+
}
125
+
},
126
+
"flake-parts": {
127
+
"inputs": {
128
+
"nixpkgs-lib": [
129
+
"devenv",
130
+
"nix",
131
+
"nixpkgs"
132
+
]
133
+
},
134
+
"locked": {
135
+
"lastModified": 1733312601,
136
+
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
137
+
"owner": "hercules-ci",
138
+
"repo": "flake-parts",
139
+
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
140
+
"type": "github"
141
+
},
142
+
"original": {
143
+
"owner": "hercules-ci",
144
+
"repo": "flake-parts",
145
+
"type": "github"
146
+
}
147
+
},
148
+
"flake-parts_2": {
149
+
"inputs": {
150
+
"nixpkgs-lib": "nixpkgs-lib"
151
+
},
152
+
"locked": {
153
+
"lastModified": 1754487366,
154
+
"narHash": "sha256-pHYj8gUBapuUzKV/kN/tR3Zvqc7o6gdFB9XKXIp1SQ8=",
155
+
"owner": "hercules-ci",
156
+
"repo": "flake-parts",
157
+
"rev": "af66ad14b28a127c5c0f3bbb298218fc63528a18",
158
+
"type": "github"
159
+
},
160
+
"original": {
161
+
"owner": "hercules-ci",
162
+
"repo": "flake-parts",
163
+
"type": "github"
164
+
}
165
+
},
166
+
"git-hooks": {
167
+
"inputs": {
168
+
"flake-compat": [
169
+
"devenv",
170
+
"flake-compat"
171
+
],
172
+
"gitignore": "gitignore",
173
+
"nixpkgs": [
174
+
"devenv",
175
+
"nixpkgs"
176
+
]
177
+
},
178
+
"locked": {
179
+
"lastModified": 1750779888,
180
+
"narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=",
181
+
"owner": "cachix",
182
+
"repo": "git-hooks.nix",
183
+
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
184
+
"type": "github"
185
+
},
186
+
"original": {
187
+
"owner": "cachix",
188
+
"repo": "git-hooks.nix",
189
+
"type": "github"
190
+
}
191
+
},
192
+
"gitignore": {
193
+
"inputs": {
194
+
"nixpkgs": [
195
+
"devenv",
196
+
"git-hooks",
197
+
"nixpkgs"
198
+
]
199
+
},
200
+
"locked": {
201
+
"lastModified": 1709087332,
202
+
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
203
+
"owner": "hercules-ci",
204
+
"repo": "gitignore.nix",
205
+
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
206
+
"type": "github"
207
+
},
208
+
"original": {
209
+
"owner": "hercules-ci",
210
+
"repo": "gitignore.nix",
211
+
"type": "github"
212
+
}
213
+
},
214
+
"import-tree": {
215
+
"locked": {
216
+
"lastModified": 1752730890,
217
+
"narHash": "sha256-GES8fapSLGz36MMPRVNkSUWXUTtqvGQNXHjRmRLfJUY=",
218
+
"owner": "vic",
219
+
"repo": "import-tree",
220
+
"rev": "6ebb8cb87987b20264c09296166543fd3761d274",
221
+
"type": "github"
222
+
},
223
+
"original": {
224
+
"owner": "vic",
225
+
"repo": "import-tree",
226
+
"type": "github"
227
+
}
228
+
},
229
+
"nix": {
230
+
"inputs": {
231
+
"flake-compat": [
232
+
"devenv",
233
+
"flake-compat"
234
+
],
235
+
"flake-parts": "flake-parts",
236
+
"git-hooks-nix": [
237
+
"devenv",
238
+
"git-hooks"
239
+
],
240
+
"nixpkgs": [
241
+
"devenv",
242
+
"nixpkgs"
243
+
],
244
+
"nixpkgs-23-11": [
245
+
"devenv"
246
+
],
247
+
"nixpkgs-regression": [
248
+
"devenv"
249
+
]
250
+
},
251
+
"locked": {
252
+
"lastModified": 1755029779,
253
+
"narHash": "sha256-3+GHIYGg4U9XKUN4rg473frIVNn8YD06bjwxKS1IPrU=",
254
+
"owner": "cachix",
255
+
"repo": "nix",
256
+
"rev": "b0972b0eee6726081d10b1199f54de6d2917f861",
257
+
"type": "github"
258
+
},
259
+
"original": {
260
+
"owner": "cachix",
261
+
"ref": "devenv-2.30",
262
+
"repo": "nix",
263
+
"type": "github"
264
+
}
265
+
},
266
+
"nixpkgs": {
267
+
"locked": {
268
+
"lastModified": 1751498133,
269
+
"narHash": "sha256-QWJ+NQbMU+NcU2xiyo7SNox1fAuwksGlQhpzBl76g1I=",
270
+
"owner": "NixOS",
271
+
"repo": "nixpkgs",
272
+
"rev": "d55716bb59b91ae9d1ced4b1ccdea7a442ecbfdb",
273
+
"type": "github"
274
+
},
275
+
"original": {
276
+
"owner": "NixOS",
277
+
"ref": "nixpkgs-unstable",
278
+
"repo": "nixpkgs",
279
+
"type": "github"
280
+
}
281
+
},
282
+
"nixpkgs-lib": {
283
+
"locked": {
284
+
"lastModified": 1753579242,
285
+
"narHash": "sha256-zvaMGVn14/Zz8hnp4VWT9xVnhc8vuL3TStRqwk22biA=",
286
+
"owner": "nix-community",
287
+
"repo": "nixpkgs.lib",
288
+
"rev": "0f36c44e01a6129be94e3ade315a5883f0228a6e",
289
+
"type": "github"
290
+
},
291
+
"original": {
292
+
"owner": "nix-community",
293
+
"repo": "nixpkgs.lib",
294
+
"type": "github"
295
+
}
296
+
},
297
+
"nixpkgs_2": {
298
+
"locked": {
299
+
"lastModified": 1750441195,
300
+
"narHash": "sha256-yke+pm+MdgRb6c0dPt8MgDhv7fcBbdjmv1ZceNTyzKg=",
301
+
"owner": "cachix",
302
+
"repo": "devenv-nixpkgs",
303
+
"rev": "0ceffe312871b443929ff3006960d29b120dc627",
304
+
"type": "github"
305
+
},
306
+
"original": {
307
+
"owner": "cachix",
308
+
"ref": "rolling",
309
+
"repo": "devenv-nixpkgs",
310
+
"type": "github"
311
+
}
312
+
},
313
+
"nixpkgs_3": {
314
+
"locked": {
315
+
"lastModified": 1722073938,
316
+
"narHash": "sha256-OpX0StkL8vpXyWOGUD6G+MA26wAXK6SpT94kLJXo6B4=",
317
+
"owner": "NixOS",
318
+
"repo": "nixpkgs",
319
+
"rev": "e36e9f57337d0ff0cf77aceb58af4c805472bfae",
320
+
"type": "github"
321
+
},
322
+
"original": {
323
+
"owner": "NixOS",
324
+
"ref": "nixpkgs-unstable",
325
+
"repo": "nixpkgs",
326
+
"type": "github"
327
+
}
328
+
},
329
+
"nixpkgs_4": {
330
+
"locked": {
331
+
"lastModified": 1755783167,
332
+
"narHash": "sha256-gj7qvMNz7YvhjYxNq4I370cAYIZEw2PbVs5BSwaLrD4=",
333
+
"owner": "cachix",
334
+
"repo": "devenv-nixpkgs",
335
+
"rev": "4a880fb247d24fbca57269af672e8f78935b0328",
336
+
"type": "github"
337
+
},
338
+
"original": {
339
+
"owner": "cachix",
340
+
"ref": "rolling",
341
+
"repo": "devenv-nixpkgs",
342
+
"type": "github"
343
+
}
344
+
},
345
+
"nixpkgs_5": {
346
+
"locked": {
347
+
"lastModified": 1754340878,
348
+
"narHash": "sha256-lgmUyVQL9tSnvvIvBp7x1euhkkCho7n3TMzgjdvgPoU=",
349
+
"owner": "nixos",
350
+
"repo": "nixpkgs",
351
+
"rev": "cab778239e705082fe97bb4990e0d24c50924c04",
352
+
"type": "github"
353
+
},
354
+
"original": {
355
+
"owner": "nixos",
356
+
"ref": "nixpkgs-unstable",
357
+
"repo": "nixpkgs",
358
+
"type": "github"
359
+
}
360
+
},
361
+
"root": {
362
+
"inputs": {
363
+
"allfollow": "allfollow",
364
+
"devenv": "devenv",
365
+
"devshell": "devshell",
366
+
"flake-file": "flake-file",
367
+
"flake-parts": "flake-parts_2",
368
+
"import-tree": "import-tree",
369
+
"nixpkgs": "nixpkgs_4",
370
+
"rust-overlay": "rust-overlay_2",
371
+
"systems": "systems_2",
372
+
"treefmt-nix": "treefmt-nix"
373
+
}
374
+
},
375
+
"rust-overlay": {
376
+
"inputs": {
377
+
"nixpkgs": [
378
+
"allfollow",
379
+
"nixpkgs"
380
+
]
381
+
},
382
+
"locked": {
383
+
"lastModified": 1751596734,
384
+
"narHash": "sha256-1tQOwmn3jEUQjH0WDJyklC+hR7Bj+iqx6ChtRX2QiPA=",
385
+
"owner": "oxalica",
386
+
"repo": "rust-overlay",
387
+
"rev": "e28ba067a9368286a8bc88b68dc2ca92181a09f0",
388
+
"type": "github"
389
+
},
390
+
"original": {
391
+
"owner": "oxalica",
392
+
"repo": "rust-overlay",
393
+
"type": "github"
394
+
}
395
+
},
396
+
"rust-overlay_2": {
397
+
"inputs": {
398
+
"nixpkgs": [
399
+
"nixpkgs"
400
+
]
401
+
},
402
+
"locked": {
403
+
"lastModified": 1756003222,
404
+
"narHash": "sha256-lmEMhIIbjt8Wp1EYbNqCojuU9ygyDFv8Tu0X1k8qIMc=",
405
+
"owner": "oxalica",
406
+
"repo": "rust-overlay",
407
+
"rev": "88ceedecde53e809b4bf8b5fd10d181889d9bac7",
408
+
"type": "github"
409
+
},
410
+
"original": {
411
+
"owner": "oxalica",
412
+
"repo": "rust-overlay",
413
+
"type": "github"
414
+
}
415
+
},
416
+
"systems": {
417
+
"flake": false,
418
+
"locked": {
419
+
"lastModified": 1681028828,
420
+
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
421
+
"owner": "nix-systems",
422
+
"repo": "default",
423
+
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
424
+
"type": "github"
425
+
},
426
+
"original": {
427
+
"owner": "nix-systems",
428
+
"repo": "default",
429
+
"type": "github"
430
+
}
431
+
},
432
+
"systems_2": {
433
+
"locked": {
434
+
"lastModified": 1681028828,
435
+
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
436
+
"owner": "nix-systems",
437
+
"repo": "default",
438
+
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
439
+
"type": "github"
440
+
},
441
+
"original": {
442
+
"owner": "nix-systems",
443
+
"repo": "default",
444
+
"type": "github"
445
+
}
446
+
},
447
+
"treefmt-nix": {
448
+
"inputs": {
449
+
"nixpkgs": "nixpkgs_5"
450
+
},
451
+
"locked": {
452
+
"lastModified": 1755934250,
453
+
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
454
+
"owner": "numtide",
455
+
"repo": "treefmt-nix",
456
+
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
457
+
"type": "github"
458
+
},
459
+
"original": {
460
+
"owner": "numtide",
461
+
"repo": "treefmt-nix",
462
+
"type": "github"
463
+
}
464
+
}
465
+
},
466
+
"root": "root",
467
+
"version": 7
468
+
}
+50
flake.nix
+50
flake.nix
···
···
1
+
# DO-NOT-EDIT. This file was auto-generated using github:vic/flake-file.
2
+
# Use `nix run .#write-flake` to regenerate it.
3
+
{
4
+
5
+
outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (inputs.import-tree ./nix);
6
+
7
+
nixConfig = {
8
+
extra-substituters = "https://devenv.cachix.org";
9
+
extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
10
+
};
11
+
12
+
inputs = {
13
+
allfollow = {
14
+
url = "github:spikespaz/allfollow";
15
+
};
16
+
devenv = {
17
+
url = "github:cachix/devenv";
18
+
};
19
+
devshell = {
20
+
url = "github:numtide/devshell";
21
+
};
22
+
flake-file = {
23
+
url = "github:vic/flake-file";
24
+
};
25
+
flake-parts = {
26
+
url = "github:hercules-ci/flake-parts";
27
+
};
28
+
import-tree = {
29
+
url = "github:vic/import-tree";
30
+
};
31
+
nixpkgs = {
32
+
url = "github:cachix/devenv-nixpkgs/rolling";
33
+
};
34
+
rust-overlay = {
35
+
inputs = {
36
+
nixpkgs = {
37
+
follows = "nixpkgs";
38
+
};
39
+
};
40
+
url = "github:oxalica/rust-overlay";
41
+
};
42
+
systems = {
43
+
url = "github:nix-systems/default";
44
+
};
45
+
treefmt-nix = {
46
+
url = "github:numtide/treefmt-nix";
47
+
};
48
+
};
49
+
50
+
}
+15
nix/dendritic.nix
+15
nix/dendritic.nix
···
···
1
+
{ inputs, lib, ... }:
2
+
{
3
+
imports = [
4
+
inputs.flake-file.flakeModules.dendritic
5
+
];
6
+
7
+
flake-file.outputs = lib.mkForce ''
8
+
inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (inputs.import-tree ./nix)
9
+
'';
10
+
11
+
perSystem.treefmt = {
12
+
programs.rustfmt.enable = true;
13
+
settings.on-unmatched = "warn";
14
+
};
15
+
}
+40
nix/devenv.nix
+40
nix/devenv.nix
···
···
1
+
{ inputs, ... }:
2
+
{
3
+
flake-file.inputs = {
4
+
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
5
+
devenv.url = "github:cachix/devenv";
6
+
};
7
+
8
+
flake-file.nixConfig = {
9
+
extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
10
+
extra-substituters = "https://devenv.cachix.org";
11
+
};
12
+
13
+
imports = [
14
+
inputs.devenv.flakeModule
15
+
];
16
+
17
+
perSystem =
18
+
{ config, pkgs, ... }:
19
+
let
20
+
app = config.devshells.default.languages.rust.import ./crates { };
21
+
wrapped = pkgs.stdenvNoCC.mkDerivation {
22
+
name = "van-wrapped";
23
+
nativeBuildInputs = [pkgs.makeWrapper];
24
+
phases = ["wrap"];
25
+
wrap = ''
26
+
wrapProgram ${app}/bin/van $out/bin/van --prefix PATH : ${pkgs.lib.makeBinPath [pkgs.carapace]}
27
+
'';
28
+
};
29
+
in
30
+
{
31
+
packages.default = wrapped;
32
+
devenv.shells.default = {
33
+
languages.rust.enable = true;
34
+
packages = [
35
+
pkgs.carapace
36
+
];
37
+
};
38
+
};
39
+
40
+
}