command line structured editor in rust (bubbletea-rs TUI)

init

oeiuwq.com 00a9e720

+5
.gitignore
··· 1 + .direnv 2 + .devenv 3 + .vscode 4 + target 5 + result
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + }