Personal outliner built with Rust.
3

Configure Feed

Select the types of activity you want to include in your feed.

phase 5.5: drop gpui-component, build custom BlockEditor on raw GPUI primitives; full outline keybindings incl. cross-block arrow navigation

graham.systems (Jul 12, 2026, 2:52 PM -0700) 8827cadf fd3e58ee

+1067 -555
+1 -502
Cargo.lock
··· 532 532 ] 533 533 534 534 [[package]] 535 - name = "base62" 536 - version = "2.2.4" 537 - source = "registry+https://github.com/rust-lang/crates.io-index" 538 - checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" 539 - 540 - [[package]] 541 535 name = "base64" 542 536 version = "0.22.1" 543 537 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1764 1758 ] 1765 1759 1766 1760 [[package]] 1767 - name = "enum-iterator" 1768 - version = "2.3.0" 1769 - source = "registry+https://github.com/rust-lang/crates.io-index" 1770 - checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" 1771 - dependencies = [ 1772 - "enum-iterator-derive", 1773 - ] 1774 - 1775 - [[package]] 1776 - name = "enum-iterator-derive" 1777 - version = "1.5.0" 1778 - source = "registry+https://github.com/rust-lang/crates.io-index" 1779 - checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" 1780 - dependencies = [ 1781 - "proc-macro2", 1782 - "quote", 1783 - "syn 2.0.118", 1784 - ] 1785 - 1786 - [[package]] 1787 1761 name = "enum_dispatch" 1788 1762 version = "0.3.13" 1789 1763 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2020 1994 version = "1.0.0" 2021 1995 source = "registry+https://github.com/rust-lang/crates.io-index" 2022 1996 checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" 2023 - 2024 - [[package]] 2025 - name = "fluent-uri" 2026 - version = "0.1.4" 2027 - source = "registry+https://github.com/rust-lang/crates.io-index" 2028 - checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" 2029 - dependencies = [ 2030 - "bitflags 1.3.2", 2031 - ] 2032 1997 2033 1998 [[package]] 2034 1999 name = "flume" ··· 2164 2129 ] 2165 2130 2166 2131 [[package]] 2167 - name = "fsevent-sys" 2168 - version = "4.1.0" 2169 - source = "registry+https://github.com/rust-lang/crates.io-index" 2170 - checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" 2171 - dependencies = [ 2172 - "libc", 2173 - ] 2174 - 2175 - [[package]] 2176 2132 name = "futf" 2177 2133 version = "0.1.5" 2178 2134 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2426 2382 ] 2427 2383 2428 2384 [[package]] 2429 - name = "globwalk" 2430 - version = "0.8.1" 2431 - source = "registry+https://github.com/rust-lang/crates.io-index" 2432 - checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" 2433 - dependencies = [ 2434 - "bitflags 1.3.2", 2435 - "ignore", 2436 - "walkdir", 2437 - ] 2438 - 2439 - [[package]] 2440 2385 name = "gloo-timers" 2441 2386 version = "0.3.0" 2442 2387 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2587 2532 ] 2588 2533 2589 2534 [[package]] 2590 - name = "gpui-component" 2591 - version = "0.5.1" 2592 - source = "registry+https://github.com/rust-lang/crates.io-index" 2593 - checksum = "d021d46b4088d3d93a57ccdf443da85695a77272108caca2f6fe5369f584966a" 2594 - dependencies = [ 2595 - "aho-corasick", 2596 - "anyhow", 2597 - "chrono", 2598 - "core-text", 2599 - "enum-iterator", 2600 - "gpui", 2601 - "gpui-component-macros", 2602 - "gpui-macros", 2603 - "html5ever", 2604 - "itertools 0.13.0", 2605 - "lsp-types", 2606 - "markdown", 2607 - "markup5ever_rcdom", 2608 - "notify", 2609 - "num-traits", 2610 - "once_cell", 2611 - "paste", 2612 - "regex", 2613 - "ropey", 2614 - "rust-i18n", 2615 - "schemars", 2616 - "serde", 2617 - "serde_json", 2618 - "serde_repr", 2619 - "smallvec", 2620 - "smol", 2621 - "tracing", 2622 - "tree-sitter", 2623 - "tree-sitter-json", 2624 - "unicode-segmentation", 2625 - "uuid", 2626 - "zed-sum-tree", 2627 - ] 2628 - 2629 - [[package]] 2630 - name = "gpui-component-macros" 2631 - version = "0.5.1" 2632 - source = "registry+https://github.com/rust-lang/crates.io-index" 2633 - checksum = "86fbc2d84bf91717b171320e6adc600d91ccb3ed259448f3b006787633c1c615" 2634 - dependencies = [ 2635 - "proc-macro2", 2636 - "quote", 2637 - "syn 2.0.118", 2638 - ] 2639 - 2640 - [[package]] 2641 2535 name = "gpui-macros" 2642 2536 version = "0.2.2" 2643 2537 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3001 2895 ] 3002 2896 3003 2897 [[package]] 3004 - name = "html5ever" 3005 - version = "0.27.0" 3006 - source = "registry+https://github.com/rust-lang/crates.io-index" 3007 - checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" 3008 - dependencies = [ 3009 - "log", 3010 - "mac", 3011 - "markup5ever", 3012 - "proc-macro2", 3013 - "quote", 3014 - "syn 2.0.118", 3015 - ] 3016 - 3017 - [[package]] 3018 2898 name = "htmlescape" 3019 2899 version = "0.3.1" 3020 2900 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3276 3156 ] 3277 3157 3278 3158 [[package]] 3279 - name = "ignore" 3280 - version = "0.4.27" 3281 - source = "registry+https://github.com/rust-lang/crates.io-index" 3282 - checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" 3283 - dependencies = [ 3284 - "crossbeam-deque", 3285 - "globset", 3286 - "log", 3287 - "memchr", 3288 - "regex-automata", 3289 - "same-file", 3290 - "walkdir", 3291 - "winapi-util", 3292 - ] 3293 - 3294 - [[package]] 3295 3159 name = "im" 3296 3160 version = "15.1.0" 3297 3161 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3400 3264 ] 3401 3265 3402 3266 [[package]] 3403 - name = "inotify" 3404 - version = "0.10.2" 3405 - source = "registry+https://github.com/rust-lang/crates.io-index" 3406 - checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" 3407 - dependencies = [ 3408 - "bitflags 1.3.2", 3409 - "inotify-sys", 3410 - "libc", 3411 - ] 3412 - 3413 - [[package]] 3414 - name = "inotify-sys" 3415 - version = "0.1.7" 3416 - source = "registry+https://github.com/rust-lang/crates.io-index" 3417 - checksum = "9ea94e891b3606826e9c998be69ddca42247dad8ad50b1649a5cb7e1c9ae06fd" 3418 - dependencies = [ 3419 - "libc", 3420 - ] 3421 - 3422 - [[package]] 3423 3267 name = "inout" 3424 3268 version = "0.1.4" 3425 3269 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3566 3410 dependencies = [ 3567 3411 "libc", 3568 3412 "libloading", 3569 - ] 3570 - 3571 - [[package]] 3572 - name = "kqueue" 3573 - version = "1.2.0" 3574 - source = "registry+https://github.com/rust-lang/crates.io-index" 3575 - checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" 3576 - dependencies = [ 3577 - "kqueue-sys", 3578 - "libc", 3579 - ] 3580 - 3581 - [[package]] 3582 - name = "kqueue-sys" 3583 - version = "1.1.2" 3584 - source = "registry+https://github.com/rust-lang/crates.io-index" 3585 - checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" 3586 - dependencies = [ 3587 - "bitflags 2.13.0", 3588 - "libc", 3589 3413 ] 3590 3414 3591 3415 [[package]] ··· 3916 3740 version = "0.1.2" 3917 3741 source = "registry+https://github.com/rust-lang/crates.io-index" 3918 3742 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 3919 - 3920 - [[package]] 3921 - name = "lsp-types" 3922 - version = "0.97.0" 3923 - source = "registry+https://github.com/rust-lang/crates.io-index" 3924 - checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" 3925 - dependencies = [ 3926 - "bitflags 1.3.2", 3927 - "fluent-uri", 3928 - "serde", 3929 - "serde_json", 3930 - "serde_repr", 3931 - ] 3932 3743 3933 3744 [[package]] 3934 3745 name = "lyon" ··· 4013 3824 ] 4014 3825 4015 3826 [[package]] 4016 - name = "markdown" 4017 - version = "1.0.0" 4018 - source = "registry+https://github.com/rust-lang/crates.io-index" 4019 - checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" 4020 - dependencies = [ 4021 - "unicode-id", 4022 - ] 4023 - 4024 - [[package]] 4025 - name = "markup5ever" 4026 - version = "0.12.1" 4027 - source = "registry+https://github.com/rust-lang/crates.io-index" 4028 - checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" 4029 - dependencies = [ 4030 - "log", 4031 - "phf", 4032 - "phf_codegen", 4033 - "string_cache", 4034 - "string_cache_codegen", 4035 - "tendril", 4036 - ] 4037 - 4038 - [[package]] 4039 - name = "markup5ever_rcdom" 4040 - version = "0.3.0" 4041 - source = "registry+https://github.com/rust-lang/crates.io-index" 4042 - checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" 4043 - dependencies = [ 4044 - "html5ever", 4045 - "markup5ever", 4046 - "tendril", 4047 - "xml5ever", 4048 - ] 4049 - 4050 - [[package]] 4051 3827 name = "matchers" 4052 3828 version = "0.2.0" 4053 3829 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4175 3951 checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" 4176 3952 dependencies = [ 4177 3953 "libc", 4178 - "log", 4179 3954 "wasi", 4180 3955 "windows-sys 0.61.2", 4181 3956 ] ··· 4301 4076 checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" 4302 4077 4303 4078 [[package]] 4304 - name = "normpath" 4305 - version = "1.5.1" 4306 - source = "registry+https://github.com/rust-lang/crates.io-index" 4307 - checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" 4308 - dependencies = [ 4309 - "windows-sys 0.61.2", 4310 - ] 4311 - 4312 - [[package]] 4313 - name = "notify" 4314 - version = "7.0.0" 4315 - source = "registry+https://github.com/rust-lang/crates.io-index" 4316 - checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" 4317 - dependencies = [ 4318 - "bitflags 2.13.0", 4319 - "filetime", 4320 - "fsevent-sys", 4321 - "inotify", 4322 - "kqueue", 4323 - "libc", 4324 - "log", 4325 - "mio", 4326 - "notify-types", 4327 - "walkdir", 4328 - "windows-sys 0.52.0", 4329 - ] 4330 - 4331 - [[package]] 4332 - name = "notify-types" 4333 - version = "1.0.1" 4334 - source = "registry+https://github.com/rust-lang/crates.io-index" 4335 - checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" 4336 - dependencies = [ 4337 - "instant", 4338 - ] 4339 - 4340 - [[package]] 4341 4079 name = "ntapi" 4342 4080 version = "0.4.3" 4343 4081 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4829 4567 ] 4830 4568 4831 4569 [[package]] 4832 - name = "phf" 4833 - version = "0.11.3" 4834 - source = "registry+https://github.com/rust-lang/crates.io-index" 4835 - checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" 4836 - dependencies = [ 4837 - "phf_shared", 4838 - ] 4839 - 4840 - [[package]] 4841 - name = "phf_codegen" 4842 - version = "0.11.3" 4843 - source = "registry+https://github.com/rust-lang/crates.io-index" 4844 - checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" 4845 - dependencies = [ 4846 - "phf_generator", 4847 - "phf_shared", 4848 - ] 4849 - 4850 - [[package]] 4851 - name = "phf_generator" 4852 - version = "0.11.3" 4853 - source = "registry+https://github.com/rust-lang/crates.io-index" 4854 - checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" 4855 - dependencies = [ 4856 - "phf_shared", 4857 - "rand 0.8.6", 4858 - ] 4859 - 4860 - [[package]] 4861 - name = "phf_shared" 4862 - version = "0.11.3" 4863 - source = "registry+https://github.com/rust-lang/crates.io-index" 4864 - checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" 4865 - dependencies = [ 4866 - "siphasher", 4867 - ] 4868 - 4869 - [[package]] 4870 4570 name = "pico-args" 4871 4571 version = "0.5.0" 4872 4572 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5022 4722 dependencies = [ 5023 4723 "zerocopy", 5024 4724 ] 5025 - 5026 - [[package]] 5027 - name = "precomputed-hash" 5028 - version = "0.1.1" 5029 - source = "registry+https://github.com/rust-lang/crates.io-index" 5030 - checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 5031 4725 5032 4726 [[package]] 5033 4727 name = "pretty" ··· 5571 5265 "libc", 5572 5266 "untrusted", 5573 5267 "windows-sys 0.52.0", 5574 - ] 5575 - 5576 - [[package]] 5577 - name = "ropey" 5578 - version = "2.0.0-beta.1" 5579 - source = "registry+https://github.com/rust-lang/crates.io-index" 5580 - checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" 5581 - dependencies = [ 5582 - "str_indices", 5583 5268 ] 5584 5269 5585 5270 [[package]] ··· 5624 5309 ] 5625 5310 5626 5311 [[package]] 5627 - name = "rust-i18n" 5628 - version = "3.1.5" 5629 - source = "registry+https://github.com/rust-lang/crates.io-index" 5630 - checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" 5631 - dependencies = [ 5632 - "globwalk", 5633 - "once_cell", 5634 - "regex", 5635 - "rust-i18n-macro", 5636 - "rust-i18n-support", 5637 - "smallvec", 5638 - ] 5639 - 5640 - [[package]] 5641 - name = "rust-i18n-macro" 5642 - version = "3.1.5" 5643 - source = "registry+https://github.com/rust-lang/crates.io-index" 5644 - checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" 5645 - dependencies = [ 5646 - "glob", 5647 - "once_cell", 5648 - "proc-macro2", 5649 - "quote", 5650 - "rust-i18n-support", 5651 - "serde", 5652 - "serde_json", 5653 - "serde_yaml", 5654 - "syn 2.0.118", 5655 - ] 5656 - 5657 - [[package]] 5658 - name = "rust-i18n-support" 5659 - version = "3.1.5" 5660 - source = "registry+https://github.com/rust-lang/crates.io-index" 5661 - checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" 5662 - dependencies = [ 5663 - "arc-swap", 5664 - "base62", 5665 - "globwalk", 5666 - "itertools 0.11.0", 5667 - "lazy_static", 5668 - "normpath", 5669 - "once_cell", 5670 - "proc-macro2", 5671 - "regex", 5672 - "serde", 5673 - "serde_json", 5674 - "serde_yaml", 5675 - "siphasher", 5676 - "toml 0.8.23", 5677 - "triomphe", 5678 - ] 5679 - 5680 - [[package]] 5681 5312 name = "rust-stemmers" 5682 5313 version = "1.2.0" 5683 5314 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6101 5732 ] 6102 5733 6103 5734 [[package]] 6104 - name = "serde_yaml" 6105 - version = "0.9.34+deprecated" 6106 - source = "registry+https://github.com/rust-lang/crates.io-index" 6107 - checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 6108 - dependencies = [ 6109 - "indexmap 2.14.0", 6110 - "itoa", 6111 - "ryu", 6112 - "serde", 6113 - "unsafe-libyaml", 6114 - ] 6115 - 6116 - [[package]] 6117 5735 name = "sha1_smol" 6118 5736 version = "1.0.1" 6119 5737 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6457 6075 ] 6458 6076 6459 6077 [[package]] 6460 - name = "str_indices" 6461 - version = "0.4.4" 6462 - source = "registry+https://github.com/rust-lang/crates.io-index" 6463 - checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" 6464 - 6465 - [[package]] 6466 - name = "streaming-iterator" 6467 - version = "0.1.9" 6468 - source = "registry+https://github.com/rust-lang/crates.io-index" 6469 - checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" 6470 - 6471 - [[package]] 6472 6078 name = "strict-num" 6473 6079 version = "0.1.1" 6474 6080 source = "registry+https://github.com/rust-lang/crates.io-index" 6475 6081 checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" 6476 6082 dependencies = [ 6477 6083 "float-cmp", 6478 - ] 6479 - 6480 - [[package]] 6481 - name = "string_cache" 6482 - version = "0.8.9" 6483 - source = "registry+https://github.com/rust-lang/crates.io-index" 6484 - checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" 6485 - dependencies = [ 6486 - "new_debug_unreachable", 6487 - "parking_lot", 6488 - "phf_shared", 6489 - "precomputed-hash", 6490 - "serde", 6491 - ] 6492 - 6493 - [[package]] 6494 - name = "string_cache_codegen" 6495 - version = "0.5.4" 6496 - source = "registry+https://github.com/rust-lang/crates.io-index" 6497 - checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" 6498 - dependencies = [ 6499 - "phf_generator", 6500 - "phf_shared", 6501 - "proc-macro2", 6502 - "quote", 6503 6084 ] 6504 6085 6505 6086 [[package]] ··· 7356 6937 version = "0.1.0" 7357 6938 dependencies = [ 7358 6939 "gpui", 7359 - "gpui-component", 7360 6940 "loro", 7361 6941 "pulldown-cmark", 7362 6942 "trawler-core", 6943 + "unicode-segmentation", 7363 6944 ] 7364 6945 7365 6946 [[package]] ··· 7375 6956 ] 7376 6957 7377 6958 [[package]] 7378 - name = "tree-sitter" 7379 - version = "0.25.10" 7380 - source = "registry+https://github.com/rust-lang/crates.io-index" 7381 - checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" 7382 - dependencies = [ 7383 - "cc", 7384 - "regex", 7385 - "regex-syntax", 7386 - "serde_json", 7387 - "streaming-iterator", 7388 - "tree-sitter-language", 7389 - ] 7390 - 7391 - [[package]] 7392 - name = "tree-sitter-json" 7393 - version = "0.24.8" 7394 - source = "registry+https://github.com/rust-lang/crates.io-index" 7395 - checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" 7396 - dependencies = [ 7397 - "cc", 7398 - "tree-sitter-language", 7399 - ] 7400 - 7401 - [[package]] 7402 - name = "tree-sitter-language" 7403 - version = "0.1.7" 7404 - source = "registry+https://github.com/rust-lang/crates.io-index" 7405 - checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" 7406 - 7407 - [[package]] 7408 - name = "triomphe" 7409 - version = "0.1.16" 7410 - source = "registry+https://github.com/rust-lang/crates.io-index" 7411 - checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" 7412 - dependencies = [ 7413 - "arc-swap", 7414 - "serde", 7415 - "stable_deref_trait", 7416 - ] 7417 - 7418 - [[package]] 7419 6959 name = "try-lock" 7420 6960 version = "0.2.5" 7421 6961 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7544 7084 checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" 7545 7085 7546 7086 [[package]] 7547 - name = "unicode-id" 7548 - version = "0.3.6" 7549 - source = "registry+https://github.com/rust-lang/crates.io-index" 7550 - checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" 7551 - 7552 - [[package]] 7553 7087 name = "unicode-ident" 7554 7088 version = "1.0.24" 7555 7089 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7596 7130 version = "0.2.2" 7597 7131 source = "registry+https://github.com/rust-lang/crates.io-index" 7598 7132 checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 7599 - 7600 - [[package]] 7601 - name = "unsafe-libyaml" 7602 - version = "0.2.11" 7603 - source = "registry+https://github.com/rust-lang/crates.io-index" 7604 - checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 7605 7133 7606 7134 [[package]] 7607 7135 name = "untrusted" ··· 8543 8071 checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" 8544 8072 8545 8073 [[package]] 8546 - name = "workspace-hack" 8547 - version = "0.1.0" 8548 - source = "registry+https://github.com/rust-lang/crates.io-index" 8549 - checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" 8550 - 8551 - [[package]] 8552 8074 name = "writeable" 8553 8075 version = "0.6.3" 8554 8076 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 8662 8184 version = "0.2.1" 8663 8185 source = "registry+https://github.com/rust-lang/crates.io-index" 8664 8186 checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" 8665 - 8666 - [[package]] 8667 - name = "xml5ever" 8668 - version = "0.18.1" 8669 - source = "registry+https://github.com/rust-lang/crates.io-index" 8670 - checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" 8671 - dependencies = [ 8672 - "log", 8673 - "mac", 8674 - "markup5ever", 8675 - ] 8676 8187 8677 8188 [[package]] 8678 8189 name = "xmlwriter" ··· 8908 8419 "windows-capture", 8909 8420 "x11", 8910 8421 "xcb", 8911 - ] 8912 - 8913 - [[package]] 8914 - name = "zed-sum-tree" 8915 - version = "0.2.0" 8916 - source = "registry+https://github.com/rust-lang/crates.io-index" 8917 - checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35" 8918 - dependencies = [ 8919 - "arrayvec 0.7.8", 8920 - "log", 8921 - "rayon", 8922 - "workspace-hack", 8923 8422 ] 8924 8423 8925 8424 [[package]]
+1 -1
crates/trawler/Cargo.toml
··· 9 9 10 10 [dependencies] 11 11 gpui = "0.2.2" 12 - gpui-component = "0.5.1" 13 12 loro = "1.13.6" 14 13 pulldown-cmark = { version = "0.13.4", default-features = false } 15 14 trawler-core = { path = "../trawler-core" } 15 + unicode-segmentation = "1.13.3" 16 16 17 17 [dev-dependencies]
+750
crates/trawler/src/editor.rs
··· 1 + //! A minimal multi-line text editor built directly on GPUI's raw 2 + //! `EntityInputHandler`/`Element` primitives — no `gpui-component`. 3 + //! 4 + //! `gpui-component`'s `Input` came with its own opinionated keybindings for 5 + //! Enter/Tab/Backspace baked in, which fought every attempt to give those 6 + //! keys outline semantics (split/indent/merge) instead of textarea 7 + //! semantics, plus a default-width bug that rendered the one-hot editor as 8 + //! a collapsed sliver. This module owns 100% of the block editor's 9 + //! behavior instead, modeled closely on gpui's own `examples/input.rs` 10 + //! (single-line) extended to multiple lines split on `\n` — no soft 11 + //! word-wrap, matching a plain source-text editing surface. 12 + //! 13 + //! `BlockEditor` only edits text and reports high-level outline intents 14 + //! (split/merge/indent/outdent/blur) via `BlockEditorEvent`; it knows 15 + //! nothing about the graph. The caller (`TrawlerApp`) owns what those 16 + //! intents actually do. 17 + 18 + use std::ops::Range; 19 + 20 + use gpui::{ 21 + actions, div, fill, point, px, relative, rgba, size, App, Bounds, ClipboardItem, Context, 22 + CursorStyle, Element, ElementId, ElementInputHandler, Entity, EntityInputHandler, EventEmitter, 23 + FocusHandle, Focusable, GlobalElementId, InteractiveElement, KeyBinding, LayoutId, MouseButton, 24 + MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, Point, Render, 25 + ShapedLine, SharedString, Style, Styled, Subscription, TextRun, UTF16Selection, Window, 26 + }; 27 + use unicode_segmentation::UnicodeSegmentation; 28 + 29 + pub const CONTEXT: &str = "BlockEditor"; 30 + 31 + actions!( 32 + block_editor, 33 + [ 34 + EditorMoveLeft, 35 + EditorMoveRight, 36 + EditorMoveUp, 37 + EditorMoveDown, 38 + EditorSelectLeft, 39 + EditorSelectRight, 40 + EditorSelectAll, 41 + EditorHome, 42 + EditorEnd, 43 + EditorBackspace, 44 + EditorDelete, 45 + EditorEnter, 46 + EditorShiftEnter, 47 + EditorTab, 48 + EditorShiftTab, 49 + EditorMoveBlockUp, 50 + EditorMoveBlockDown, 51 + EditorPaste, 52 + EditorCut, 53 + EditorCopy, 54 + ] 55 + ); 56 + 57 + pub fn init(cx: &mut App) { 58 + cx.bind_keys([ 59 + KeyBinding::new("left", EditorMoveLeft, Some(CONTEXT)), 60 + KeyBinding::new("right", EditorMoveRight, Some(CONTEXT)), 61 + KeyBinding::new("up", EditorMoveUp, Some(CONTEXT)), 62 + KeyBinding::new("down", EditorMoveDown, Some(CONTEXT)), 63 + KeyBinding::new("shift-left", EditorSelectLeft, Some(CONTEXT)), 64 + KeyBinding::new("shift-right", EditorSelectRight, Some(CONTEXT)), 65 + KeyBinding::new("ctrl-a", EditorSelectAll, Some(CONTEXT)), 66 + KeyBinding::new("home", EditorHome, Some(CONTEXT)), 67 + KeyBinding::new("end", EditorEnd, Some(CONTEXT)), 68 + KeyBinding::new("backspace", EditorBackspace, Some(CONTEXT)), 69 + KeyBinding::new("delete", EditorDelete, Some(CONTEXT)), 70 + KeyBinding::new("enter", EditorEnter, Some(CONTEXT)), 71 + KeyBinding::new("shift-enter", EditorShiftEnter, Some(CONTEXT)), 72 + KeyBinding::new("tab", EditorTab, Some(CONTEXT)), 73 + KeyBinding::new("shift-tab", EditorShiftTab, Some(CONTEXT)), 74 + KeyBinding::new("alt-up", EditorMoveBlockUp, Some(CONTEXT)), 75 + KeyBinding::new("alt-down", EditorMoveBlockDown, Some(CONTEXT)), 76 + KeyBinding::new("ctrl-v", EditorPaste, Some(CONTEXT)), 77 + KeyBinding::new("ctrl-c", EditorCopy, Some(CONTEXT)), 78 + KeyBinding::new("ctrl-x", EditorCut, Some(CONTEXT)), 79 + ]); 80 + } 81 + 82 + /// High-level intents `BlockEditor` reports to its owner. The owner (not 83 + /// this module) knows what "split the block" or "merge with previous" 84 + /// means in graph terms. 85 + #[derive(Debug, Clone, Copy)] 86 + pub enum BlockEditorEvent { 87 + Blurred, 88 + /// Plain Enter: split into a new sibling block at the cursor (spec: 89 + /// outline-editor "Enter creates a new sibling block"). 90 + SplitRequested, 91 + /// Backspace with an empty selection at content-start: merge into the 92 + /// previous sibling. 93 + MergeWithPreviousRequested, 94 + Indent, 95 + Outdent, 96 + MoveBlockUp, 97 + MoveBlockDown, 98 + /// Up pressed with the cursor already on the block's first line: hand 99 + /// focus to the previous visible block. 100 + FocusPreviousBlock, 101 + /// Down pressed with the cursor already on the block's last line: hand 102 + /// focus to the next visible block. 103 + FocusNextBlock, 104 + } 105 + 106 + pub struct BlockEditor { 107 + focus_handle: FocusHandle, 108 + content: String, 109 + selected_range: Range<usize>, 110 + selection_reversed: bool, 111 + marked_range: Option<Range<usize>>, 112 + last_layout: Vec<ShapedLine>, 113 + last_bounds: Option<Bounds<Pixels>>, 114 + is_selecting: bool, 115 + _blur_subscription: Subscription, 116 + } 117 + 118 + impl EventEmitter<BlockEditorEvent> for BlockEditor {} 119 + 120 + impl Focusable for BlockEditor { 121 + fn focus_handle(&self, _: &App) -> FocusHandle { 122 + self.focus_handle.clone() 123 + } 124 + } 125 + 126 + impl BlockEditor { 127 + pub fn new(window: &mut Window, cx: &mut Context<Self>, content: String) -> Self { 128 + let len = content.len(); 129 + let focus_handle = cx.focus_handle(); 130 + let blur_subscription = cx.on_blur(&focus_handle, window, |_this, _window, cx| { 131 + cx.emit(BlockEditorEvent::Blurred); 132 + }); 133 + Self { 134 + focus_handle, 135 + content, 136 + selected_range: len..len, 137 + selection_reversed: false, 138 + marked_range: None, 139 + last_layout: Vec::new(), 140 + last_bounds: None, 141 + is_selecting: false, 142 + _blur_subscription: blur_subscription, 143 + } 144 + } 145 + 146 + pub fn value(&self) -> &str { 147 + &self.content 148 + } 149 + 150 + pub fn cursor_offset(&self) -> usize { 151 + if self.selection_reversed { 152 + self.selected_range.start 153 + } else { 154 + self.selected_range.end 155 + } 156 + } 157 + 158 + fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) { 159 + self.selected_range = offset..offset; 160 + cx.notify(); 161 + } 162 + 163 + fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) { 164 + if self.selection_reversed { 165 + self.selected_range.start = offset; 166 + } else { 167 + self.selected_range.end = offset; 168 + } 169 + if self.selected_range.end < self.selected_range.start { 170 + self.selection_reversed = !self.selection_reversed; 171 + self.selected_range = self.selected_range.end..self.selected_range.start; 172 + } 173 + cx.notify(); 174 + } 175 + 176 + fn previous_boundary(&self, offset: usize) -> usize { 177 + self.content 178 + .grapheme_indices(true) 179 + .rev() 180 + .find_map(|(idx, _)| (idx < offset).then_some(idx)) 181 + .unwrap_or(0) 182 + } 183 + 184 + fn next_boundary(&self, offset: usize) -> usize { 185 + self.content 186 + .grapheme_indices(true) 187 + .find_map(|(idx, _)| (idx > offset).then_some(idx)) 188 + .unwrap_or(self.content.len()) 189 + } 190 + 191 + /// Byte range of the line containing `offset` (excluding its `\n`). 192 + fn line_bounds(&self, offset: usize) -> Range<usize> { 193 + let start = self.content[..offset].rfind('\n').map_or(0, |i| i + 1); 194 + let end = self.content[offset..] 195 + .find('\n') 196 + .map_or(self.content.len(), |i| offset + i); 197 + start..end 198 + } 199 + 200 + fn move_left(&mut self, _: &EditorMoveLeft, _: &mut Window, cx: &mut Context<Self>) { 201 + if self.selected_range.is_empty() { 202 + self.move_to(self.previous_boundary(self.cursor_offset()), cx); 203 + } else { 204 + self.move_to(self.selected_range.start, cx); 205 + } 206 + } 207 + 208 + fn move_right(&mut self, _: &EditorMoveRight, _: &mut Window, cx: &mut Context<Self>) { 209 + if self.selected_range.is_empty() { 210 + self.move_to(self.next_boundary(self.cursor_offset()), cx); 211 + } else { 212 + self.move_to(self.selected_range.end, cx); 213 + } 214 + } 215 + 216 + /// Column-preserving move to the previous line (byte-offset-within-line 217 + /// approximation, not pixel-accurate for proportional fonts — a 218 + /// reasonable trade for source-text editing; see module doc). At the 219 + /// first line already, hand off to the previous block instead (spec 220 + /// scenario: "Focus transition commits content" — Up/Down move focus 221 + /// across blocks at a content boundary). 222 + fn move_up(&mut self, _: &EditorMoveUp, _: &mut Window, cx: &mut Context<Self>) { 223 + let cursor = self.cursor_offset(); 224 + let line = self.line_bounds(cursor); 225 + if line.start == 0 { 226 + cx.emit(BlockEditorEvent::FocusPreviousBlock); 227 + return; 228 + } 229 + let column = cursor - line.start; 230 + let prev_line = self.line_bounds(line.start - 1); 231 + let target = prev_line.start + column.min(prev_line.end - prev_line.start); 232 + self.move_to(self.snap_to_char_boundary(target), cx); 233 + } 234 + 235 + fn move_down(&mut self, _: &EditorMoveDown, _: &mut Window, cx: &mut Context<Self>) { 236 + let cursor = self.cursor_offset(); 237 + let line = self.line_bounds(cursor); 238 + if line.end == self.content.len() { 239 + cx.emit(BlockEditorEvent::FocusNextBlock); 240 + return; 241 + } 242 + let column = cursor - line.start; 243 + let next_line = self.line_bounds(line.end + 1); 244 + let target = next_line.start + column.min(next_line.end - next_line.start); 245 + self.move_to(self.snap_to_char_boundary(target), cx); 246 + } 247 + 248 + fn snap_to_char_boundary(&self, offset: usize) -> usize { 249 + let mut offset = offset.min(self.content.len()); 250 + while offset > 0 && !self.content.is_char_boundary(offset) { 251 + offset -= 1; 252 + } 253 + offset 254 + } 255 + 256 + fn select_left(&mut self, _: &EditorSelectLeft, _: &mut Window, cx: &mut Context<Self>) { 257 + self.select_to(self.previous_boundary(self.cursor_offset()), cx); 258 + } 259 + 260 + fn select_right(&mut self, _: &EditorSelectRight, _: &mut Window, cx: &mut Context<Self>) { 261 + self.select_to(self.next_boundary(self.cursor_offset()), cx); 262 + } 263 + 264 + fn select_all(&mut self, _: &EditorSelectAll, _: &mut Window, cx: &mut Context<Self>) { 265 + self.selected_range = 0..self.content.len(); 266 + cx.notify(); 267 + } 268 + 269 + fn home(&mut self, _: &EditorHome, _: &mut Window, cx: &mut Context<Self>) { 270 + let line = self.line_bounds(self.cursor_offset()); 271 + self.move_to(line.start, cx); 272 + } 273 + 274 + fn end(&mut self, _: &EditorEnd, _: &mut Window, cx: &mut Context<Self>) { 275 + let line = self.line_bounds(self.cursor_offset()); 276 + self.move_to(line.end, cx); 277 + } 278 + 279 + fn backspace(&mut self, _: &EditorBackspace, window: &mut Window, cx: &mut Context<Self>) { 280 + if self.selected_range.is_empty() && self.cursor_offset() == 0 { 281 + cx.emit(BlockEditorEvent::MergeWithPreviousRequested); 282 + return; 283 + } 284 + if self.selected_range.is_empty() { 285 + self.select_to(self.previous_boundary(self.cursor_offset()), cx); 286 + } 287 + self.replace_text_in_range(None, "", window, cx); 288 + } 289 + 290 + fn delete(&mut self, _: &EditorDelete, window: &mut Window, cx: &mut Context<Self>) { 291 + if self.selected_range.is_empty() { 292 + self.select_to(self.next_boundary(self.cursor_offset()), cx); 293 + } 294 + self.replace_text_in_range(None, "", window, cx); 295 + } 296 + 297 + fn enter(&mut self, _: &EditorEnter, _: &mut Window, cx: &mut Context<Self>) { 298 + cx.emit(BlockEditorEvent::SplitRequested); 299 + } 300 + 301 + fn shift_enter(&mut self, _: &EditorShiftEnter, window: &mut Window, cx: &mut Context<Self>) { 302 + self.replace_text_in_range(None, "\n", window, cx); 303 + } 304 + 305 + fn tab(&mut self, _: &EditorTab, _: &mut Window, cx: &mut Context<Self>) { 306 + cx.emit(BlockEditorEvent::Indent); 307 + } 308 + 309 + fn shift_tab(&mut self, _: &EditorShiftTab, _: &mut Window, cx: &mut Context<Self>) { 310 + cx.emit(BlockEditorEvent::Outdent); 311 + } 312 + 313 + fn move_block_up(&mut self, _: &EditorMoveBlockUp, _: &mut Window, cx: &mut Context<Self>) { 314 + cx.emit(BlockEditorEvent::MoveBlockUp); 315 + } 316 + 317 + fn move_block_down(&mut self, _: &EditorMoveBlockDown, _: &mut Window, cx: &mut Context<Self>) { 318 + cx.emit(BlockEditorEvent::MoveBlockDown); 319 + } 320 + 321 + fn paste(&mut self, _: &EditorPaste, window: &mut Window, cx: &mut Context<Self>) { 322 + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { 323 + self.replace_text_in_range(None, &text, window, cx); 324 + } 325 + } 326 + 327 + fn copy(&mut self, _: &EditorCopy, _: &mut Window, cx: &mut Context<Self>) { 328 + if !self.selected_range.is_empty() { 329 + cx.write_to_clipboard(ClipboardItem::new_string( 330 + self.content[self.selected_range.clone()].to_string(), 331 + )); 332 + } 333 + } 334 + 335 + fn cut(&mut self, _: &EditorCut, window: &mut Window, cx: &mut Context<Self>) { 336 + if !self.selected_range.is_empty() { 337 + cx.write_to_clipboard(ClipboardItem::new_string( 338 + self.content[self.selected_range.clone()].to_string(), 339 + )); 340 + self.replace_text_in_range(None, "", window, cx); 341 + } 342 + } 343 + 344 + fn on_mouse_down(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context<Self>) { 345 + self.is_selecting = true; 346 + let offset = self.index_for_position(event.position); 347 + if event.modifiers.shift { 348 + self.select_to(offset, cx); 349 + } else { 350 + self.move_to(offset, cx); 351 + } 352 + } 353 + 354 + fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) { 355 + self.is_selecting = false; 356 + } 357 + 358 + fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) { 359 + if self.is_selecting { 360 + let offset = self.index_for_position(event.position); 361 + self.select_to(offset, cx); 362 + } 363 + } 364 + 365 + fn index_for_position(&self, position: Point<Pixels>) -> usize { 366 + let Some(bounds) = self.last_bounds else { 367 + return 0; 368 + }; 369 + if self.last_layout.is_empty() { 370 + return 0; 371 + } 372 + let line_height = bounds_line_height(&self.last_layout); 373 + let relative_y = (position.y - bounds.top()).max(px(0.)); 374 + let line_ix = ((relative_y / line_height) as usize).min(self.last_layout.len() - 1); 375 + let line = &self.last_layout[line_ix]; 376 + let line_start = self.line_start_offset(line_ix); 377 + line_start + line.closest_index_for_x(position.x - bounds.left()) 378 + } 379 + 380 + fn line_start_offset(&self, line_ix: usize) -> usize { 381 + self.content 382 + .split('\n') 383 + .take(line_ix) 384 + .map(|l| l.len() + 1) 385 + .sum() 386 + } 387 + 388 + fn offset_from_utf16(&self, offset: usize) -> usize { 389 + let mut utf8_offset = 0; 390 + let mut utf16_count = 0; 391 + for ch in self.content.chars() { 392 + if utf16_count >= offset { 393 + break; 394 + } 395 + utf16_count += ch.len_utf16(); 396 + utf8_offset += ch.len_utf8(); 397 + } 398 + utf8_offset 399 + } 400 + 401 + fn offset_to_utf16(&self, offset: usize) -> usize { 402 + let mut utf16_offset = 0; 403 + let mut utf8_count = 0; 404 + for ch in self.content.chars() { 405 + if utf8_count >= offset { 406 + break; 407 + } 408 + utf8_count += ch.len_utf8(); 409 + utf16_offset += ch.len_utf16(); 410 + } 411 + utf16_offset 412 + } 413 + 414 + fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> { 415 + self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) 416 + } 417 + 418 + fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> { 419 + self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) 420 + } 421 + } 422 + 423 + fn bounds_line_height(lines: &[ShapedLine]) -> Pixels { 424 + lines 425 + .first() 426 + .map(|l| (l.ascent + l.descent).max(px(1.))) 427 + .unwrap_or(px(20.)) 428 + } 429 + 430 + impl EntityInputHandler for BlockEditor { 431 + fn text_for_range( 432 + &mut self, 433 + range_utf16: Range<usize>, 434 + actual_range: &mut Option<Range<usize>>, 435 + _window: &mut Window, 436 + _cx: &mut Context<Self>, 437 + ) -> Option<String> { 438 + let range = self.range_from_utf16(&range_utf16); 439 + actual_range.replace(self.range_to_utf16(&range)); 440 + Some(self.content[range].to_string()) 441 + } 442 + 443 + fn selected_text_range( 444 + &mut self, 445 + _ignore_disabled_input: bool, 446 + _window: &mut Window, 447 + _cx: &mut Context<Self>, 448 + ) -> Option<UTF16Selection> { 449 + Some(UTF16Selection { 450 + range: self.range_to_utf16(&self.selected_range), 451 + reversed: self.selection_reversed, 452 + }) 453 + } 454 + 455 + fn marked_text_range( 456 + &self, 457 + _window: &mut Window, 458 + _cx: &mut Context<Self>, 459 + ) -> Option<Range<usize>> { 460 + self.marked_range 461 + .as_ref() 462 + .map(|range| self.range_to_utf16(range)) 463 + } 464 + 465 + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) { 466 + self.marked_range = None; 467 + } 468 + 469 + fn replace_text_in_range( 470 + &mut self, 471 + range_utf16: Option<Range<usize>>, 472 + new_text: &str, 473 + _: &mut Window, 474 + cx: &mut Context<Self>, 475 + ) { 476 + let range = range_utf16 477 + .as_ref() 478 + .map(|r| self.range_from_utf16(r)) 479 + .or(self.marked_range.clone()) 480 + .unwrap_or(self.selected_range.clone()); 481 + 482 + self.content = 483 + self.content[0..range.start].to_owned() + new_text + &self.content[range.end..]; 484 + self.selected_range = range.start + new_text.len()..range.start + new_text.len(); 485 + self.marked_range.take(); 486 + cx.notify(); 487 + } 488 + 489 + fn replace_and_mark_text_in_range( 490 + &mut self, 491 + range_utf16: Option<Range<usize>>, 492 + new_text: &str, 493 + new_selected_range_utf16: Option<Range<usize>>, 494 + _window: &mut Window, 495 + cx: &mut Context<Self>, 496 + ) { 497 + let range = range_utf16 498 + .as_ref() 499 + .map(|r| self.range_from_utf16(r)) 500 + .or(self.marked_range.clone()) 501 + .unwrap_or(self.selected_range.clone()); 502 + 503 + self.content = 504 + self.content[0..range.start].to_owned() + new_text + &self.content[range.end..]; 505 + self.marked_range = if new_text.is_empty() { 506 + None 507 + } else { 508 + Some(range.start..range.start + new_text.len()) 509 + }; 510 + self.selected_range = new_selected_range_utf16 511 + .as_ref() 512 + .map(|r| self.range_from_utf16(r)) 513 + .map(|r| r.start + range.start..r.end + range.start) 514 + .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); 515 + cx.notify(); 516 + } 517 + 518 + fn bounds_for_range( 519 + &mut self, 520 + range_utf16: Range<usize>, 521 + bounds: Bounds<Pixels>, 522 + _window: &mut Window, 523 + _cx: &mut Context<Self>, 524 + ) -> Option<Bounds<Pixels>> { 525 + let range = self.range_from_utf16(&range_utf16); 526 + let line_height = bounds_line_height(&self.last_layout); 527 + let line_ix = self.content[..range.start].matches('\n').count(); 528 + let line = self.last_layout.get(line_ix)?; 529 + let line_start = self.line_start_offset(line_ix); 530 + let y = bounds.top() + line_height * line_ix as f32; 531 + Some(Bounds::from_corners( 532 + point( 533 + bounds.left() + line.x_for_index(range.start - line_start), 534 + y, 535 + ), 536 + point( 537 + bounds.left() + line.x_for_index(range.end - line_start), 538 + y + line_height, 539 + ), 540 + )) 541 + } 542 + 543 + fn character_index_for_point( 544 + &mut self, 545 + point: Point<Pixels>, 546 + _window: &mut Window, 547 + _cx: &mut Context<Self>, 548 + ) -> Option<usize> { 549 + Some(self.offset_to_utf16(self.index_for_position(point))) 550 + } 551 + } 552 + 553 + struct BlockTextElement { 554 + editor: Entity<BlockEditor>, 555 + } 556 + 557 + struct PrepaintState { 558 + lines: Vec<ShapedLine>, 559 + line_height: Pixels, 560 + cursor: Option<PaintQuad>, 561 + selections: Vec<PaintQuad>, 562 + } 563 + 564 + impl gpui::IntoElement for BlockTextElement { 565 + type Element = Self; 566 + fn into_element(self) -> Self::Element { 567 + self 568 + } 569 + } 570 + 571 + impl Element for BlockTextElement { 572 + type RequestLayoutState = (); 573 + type PrepaintState = PrepaintState; 574 + 575 + fn id(&self) -> Option<ElementId> { 576 + None 577 + } 578 + 579 + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { 580 + None 581 + } 582 + 583 + fn request_layout( 584 + &mut self, 585 + _id: Option<&GlobalElementId>, 586 + _inspector_id: Option<&gpui::InspectorElementId>, 587 + window: &mut Window, 588 + cx: &mut App, 589 + ) -> (LayoutId, Self::RequestLayoutState) { 590 + let line_count = self.editor.read(cx).content.split('\n').count().max(1); 591 + let mut style = Style::default(); 592 + style.size.width = relative(1.).into(); 593 + style.size.height = (window.line_height() * line_count as f32).into(); 594 + (window.request_layout(style, [], cx), ()) 595 + } 596 + 597 + fn prepaint( 598 + &mut self, 599 + _id: Option<&GlobalElementId>, 600 + _inspector_id: Option<&gpui::InspectorElementId>, 601 + bounds: Bounds<Pixels>, 602 + _request_layout: &mut Self::RequestLayoutState, 603 + window: &mut Window, 604 + cx: &mut App, 605 + ) -> Self::PrepaintState { 606 + let editor = self.editor.read(cx); 607 + let content = editor.content.clone(); 608 + let selected_range = editor.selected_range.clone(); 609 + let cursor = editor.cursor_offset(); 610 + let style = window.text_style(); 611 + let font_size = style.font_size.to_pixels(window.rem_size()); 612 + let line_height = window.line_height(); 613 + 614 + let mut lines = Vec::new(); 615 + let mut byte_offset = 0usize; 616 + for line_text in content.split('\n') { 617 + let run = TextRun { 618 + len: line_text.len(), 619 + font: style.font(), 620 + color: style.color, 621 + background_color: None, 622 + underline: None, 623 + strikethrough: None, 624 + }; 625 + let shaped = window.text_system().shape_line( 626 + SharedString::from(line_text.to_string()), 627 + font_size, 628 + &[run], 629 + None, 630 + ); 631 + lines.push((shaped, byte_offset)); 632 + byte_offset += line_text.len() + 1; 633 + } 634 + 635 + let mut cursor_quad = None; 636 + let mut selection_quads = Vec::new(); 637 + for (line_ix, (line, line_start)) in lines.iter().enumerate() { 638 + let line_end = line_start + line.len(); 639 + let y = bounds.top() + line_height * line_ix as f32; 640 + 641 + if selected_range.is_empty() { 642 + if cursor >= *line_start && cursor <= line_end { 643 + let x = bounds.left() + line.x_for_index(cursor - line_start); 644 + cursor_quad = Some(fill( 645 + Bounds::new(point(x, y), size(px(2.), line_height)), 646 + gpui::blue(), 647 + )); 648 + } 649 + } else if selected_range.start <= line_end && selected_range.end >= *line_start { 650 + let start_x = if selected_range.start <= *line_start { 651 + bounds.left() 652 + } else { 653 + bounds.left() + line.x_for_index(selected_range.start - line_start) 654 + }; 655 + let end_x = if selected_range.end >= line_end { 656 + bounds.left() + line.width 657 + } else { 658 + bounds.left() + line.x_for_index(selected_range.end - line_start) 659 + }; 660 + selection_quads.push(fill( 661 + Bounds::from_corners(point(start_x, y), point(end_x, y + line_height)), 662 + rgba(0x3311ff30), 663 + )); 664 + } 665 + } 666 + 667 + PrepaintState { 668 + lines: lines.into_iter().map(|(l, _)| l).collect(), 669 + line_height, 670 + cursor: cursor_quad, 671 + selections: selection_quads, 672 + } 673 + } 674 + 675 + fn paint( 676 + &mut self, 677 + _id: Option<&GlobalElementId>, 678 + _inspector_id: Option<&gpui::InspectorElementId>, 679 + bounds: Bounds<Pixels>, 680 + _request_layout: &mut Self::RequestLayoutState, 681 + prepaint: &mut Self::PrepaintState, 682 + window: &mut Window, 683 + cx: &mut App, 684 + ) { 685 + let focus_handle = self.editor.read(cx).focus_handle.clone(); 686 + window.handle_input( 687 + &focus_handle, 688 + ElementInputHandler::new(bounds, self.editor.clone()), 689 + cx, 690 + ); 691 + 692 + for selection in prepaint.selections.drain(..) { 693 + window.paint_quad(selection); 694 + } 695 + for (ix, line) in prepaint.lines.iter().enumerate() { 696 + let y = bounds.top() + prepaint.line_height * ix as f32; 697 + line.paint(point(bounds.left(), y), prepaint.line_height, window, cx) 698 + .ok(); 699 + } 700 + if focus_handle.is_focused(window) { 701 + if let Some(cursor) = prepaint.cursor.take() { 702 + window.paint_quad(cursor); 703 + } 704 + } 705 + 706 + let lines = prepaint.lines.clone(); 707 + self.editor.update(cx, |editor, _cx| { 708 + editor.last_layout = lines; 709 + editor.last_bounds = Some(bounds); 710 + }); 711 + } 712 + } 713 + 714 + impl Render for BlockEditor { 715 + fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement { 716 + div() 717 + .flex() 718 + .w_full() 719 + .key_context(CONTEXT) 720 + .track_focus(&self.focus_handle(cx)) 721 + .cursor(CursorStyle::IBeam) 722 + .on_action(cx.listener(Self::move_left)) 723 + .on_action(cx.listener(Self::move_right)) 724 + .on_action(cx.listener(Self::move_up)) 725 + .on_action(cx.listener(Self::move_down)) 726 + .on_action(cx.listener(Self::select_left)) 727 + .on_action(cx.listener(Self::select_right)) 728 + .on_action(cx.listener(Self::select_all)) 729 + .on_action(cx.listener(Self::home)) 730 + .on_action(cx.listener(Self::end)) 731 + .on_action(cx.listener(Self::backspace)) 732 + .on_action(cx.listener(Self::delete)) 733 + .on_action(cx.listener(Self::enter)) 734 + .on_action(cx.listener(Self::shift_enter)) 735 + .on_action(cx.listener(Self::tab)) 736 + .on_action(cx.listener(Self::shift_tab)) 737 + .on_action(cx.listener(Self::move_block_up)) 738 + .on_action(cx.listener(Self::move_block_down)) 739 + .on_action(cx.listener(Self::paste)) 740 + .on_action(cx.listener(Self::copy)) 741 + .on_action(cx.listener(Self::cut)) 742 + .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) 743 + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) 744 + .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up)) 745 + .on_mouse_move(cx.listener(Self::on_mouse_move)) 746 + .child(BlockTextElement { 747 + editor: cx.entity(), 748 + }) 749 + } 750 + }
+314 -51
crates/trawler/src/main.rs
··· 1 1 //! Trawler app shell: window, graph open/create, a virtualized outline 2 - //! view over real block-graph data rendered as Markdown, and a one-hot 3 - //! editor for the focused block. 2 + //! view over real block-graph data rendered as Markdown, a one-hot editor 3 + //! for the focused block, and outline keybindings. 4 4 //! 5 5 //! See openspec/changes/trawler-mvp specs/outline-editor/spec.md and 6 - //! tasks.md 5.1-5.4. Keybindings (5.5) and reference completion (5.6) are 7 - //! not wired up yet — the only way to move focus is clicking another 8 - //! block. 6 + //! tasks.md 5.1-5.5. Reference completion (5.6) is not wired up yet. 7 + //! 8 + //! The one-hot editor is `editor::BlockEditor`, built directly on GPUI's 9 + //! own `EntityInputHandler`/`Element` primitives rather than 10 + //! `gpui-component`'s `Input` — see that module's doc for why. Every 11 + //! outline keybinding (Enter/Tab/Shift+Tab/Backspace-at-start/Alt+Up/ 12 + //! Alt+Down) is owned end-to-end there and reported up as a 13 + //! `BlockEditorEvent`; this module only decides what those intents mean in 14 + //! graph terms. 9 15 16 + mod editor; 10 17 mod markdown; 11 18 19 + use std::collections::HashSet; 12 20 use std::ops::Range; 13 21 use std::path::{Path, PathBuf}; 14 22 23 + use editor::{BlockEditor, BlockEditorEvent}; 15 24 use gpui::{ 16 25 div, px, rgb, size, uniform_list, App, AppContext as _, Application, Bounds, ClickEvent, 17 26 Context, Entity, Focusable as _, FontWeight, InteractiveElement as _, IntoElement, 18 27 ParentElement, Render, StatefulInteractiveElement as _, Styled, Subscription, Window, 19 28 WindowBounds, WindowOptions, 20 29 }; 21 - use gpui_component::input::{Input, InputEvent, InputState}; 22 - use gpui_component::Root; 23 30 use loro::TreeID; 24 31 use markdown::{Block, Inline}; 25 32 use trawler_core::index::GraphIndex; ··· 32 39 id: TreeID, 33 40 depth: usize, 34 41 blocks: Vec<Block>, 42 + has_children: bool, 43 + folded: bool, 35 44 } 36 45 37 46 /// The one live text input, per the one-hot editing model (spec: 38 47 /// "One-hot block editing" — at most one live input at any time). 39 48 struct FocusedEditor { 40 49 block: TreeID, 41 - input: Entity<InputState>, 42 - _blur_subscription: Subscription, 50 + input: Entity<BlockEditor>, 51 + _event_subscription: Subscription, 43 52 } 44 53 45 54 struct TrawlerApp { ··· 47 56 storage: GraphStorage, 48 57 rows: Vec<VisibleRow>, 49 58 editor: Option<FocusedEditor>, 59 + /// Blocks whose descendants are hidden (spec: "Fold hides descendants"). 60 + folded: HashSet<TreeID>, 61 + /// This window's handle, stashed so event-emitter callbacks (which 62 + /// only get `&mut Context<Self>`, no `&mut Window`) can hop back into 63 + /// a window context via `cx.defer` + `AnyWindowHandle::update` when 64 + /// they need one (e.g. focusing a newly-created block after a split). 65 + window_handle: gpui::AnyWindowHandle, 50 66 } 51 67 52 68 impl TrawlerApp { 53 - fn new(graph_dir: PathBuf) -> Self { 69 + fn new(graph_dir: PathBuf, window: &Window) -> Self { 54 70 let is_new = !GraphStorage::exists(&graph_dir); 55 71 let storage = if is_new { 56 72 let storage = GraphStorage::create(&graph_dir).expect("create graph directory"); ··· 60 76 GraphStorage::open(&graph_dir).expect("open graph directory") 61 77 }; 62 78 63 - let rows = visible_rows(&storage); 79 + let folded = HashSet::new(); 80 + let rows = visible_rows(&storage, &folded); 64 81 Self { 65 82 graph_dir, 66 83 storage, 67 84 rows, 68 85 editor: None, 86 + folded, 87 + window_handle: window.window_handle(), 69 88 } 70 89 } 71 90 91 + fn refresh_rows(&mut self) { 92 + self.rows = visible_rows(&self.storage, &self.folded); 93 + } 94 + 95 + fn toggle_fold(&mut self, id: TreeID, cx: &mut Context<Self>) { 96 + if !self.folded.remove(&id) { 97 + self.folded.insert(id); 98 + } 99 + self.refresh_rows(); 100 + cx.notify(); 101 + } 102 + 72 103 /// Write the currently-focused editor's content back into the graph, 73 104 /// persist it, and drop the editor — the "commit" half of one-hot 74 105 /// editing (spec: "Moving focus SHALL commit the current block's ··· 85 116 self.storage 86 117 .persist_update() 87 118 .expect("persist committed edit"); 88 - self.rows = visible_rows(&self.storage); 119 + self.refresh_rows(); 89 120 } 90 121 91 122 /// Commit whatever was focused, then attach a fresh multi-line editor ··· 101 132 let content = Outline::new(self.storage.doc()) 102 133 .content(id) 103 134 .unwrap_or_default(); 104 - let input = cx.new(|cx| { 105 - InputState::new(window, cx) 106 - .multi_line(true) 107 - .auto_grow(1, 20) 108 - }); 109 - input.update(cx, |state, cx| { 110 - state.set_value(content, window, cx); 111 - }); 135 + let input = cx.new(|cx| BlockEditor::new(window, cx, content)); 112 136 113 - let blur_subscription = cx.subscribe(&input, |this: &mut Self, _input, event, cx| { 114 - if matches!(event, InputEvent::Blur) { 115 - this.commit_editor(cx); 116 - } 117 - }); 137 + let event_subscription = 138 + cx.subscribe(&input, |this: &mut Self, _input, event, cx| match event { 139 + BlockEditorEvent::Blurred => this.commit_editor(cx), 140 + BlockEditorEvent::SplitRequested => this.split_focused_block(cx), 141 + BlockEditorEvent::MergeWithPreviousRequested => { 142 + this.merge_focused_block_with_previous(cx); 143 + } 144 + BlockEditorEvent::Indent => this.indent_focused(cx), 145 + BlockEditorEvent::Outdent => this.outdent_focused(cx), 146 + BlockEditorEvent::MoveBlockUp => this.move_focused(-1, cx), 147 + BlockEditorEvent::MoveBlockDown => this.move_focused(1, cx), 148 + BlockEditorEvent::FocusPreviousBlock => this.focus_adjacent_row(-1, cx), 149 + BlockEditorEvent::FocusNextBlock => this.focus_adjacent_row(1, cx), 150 + }); 118 151 119 152 let focus_handle = input.read(cx).focus_handle(cx); 120 153 window.focus(&focus_handle); ··· 122 155 self.editor = Some(FocusedEditor { 123 156 block: id, 124 157 input, 125 - _blur_subscription: blur_subscription, 158 + _event_subscription: event_subscription, 159 + }); 160 + } 161 + 162 + /// Enter was pressed (spec: "Newline within a block vs. new block" — 163 + /// Shift+Enter is handled entirely inside `BlockEditor` and never 164 + /// reaches here). Split the block's content at the cursor via the 165 + /// already-tested `Outline::split_block`. 166 + fn split_focused_block(&mut self, cx: &mut Context<Self>) { 167 + let Some(editor) = self.editor.as_ref() else { 168 + return; 169 + }; 170 + let block = editor.block; 171 + let full_text = editor.input.read(cx).value().to_string(); 172 + let cursor_byte = editor.input.read(cx).cursor_offset(); 173 + let char_offset = full_text[..cursor_byte].chars().count(); 174 + 175 + let outline = Outline::new(self.storage.doc()); 176 + outline 177 + .set_content(block, &full_text) 178 + .expect("focused block still exists"); 179 + let new_block = outline 180 + .split_block(block, char_offset) 181 + .expect("focused block still exists"); 182 + self.storage.persist_update().expect("persist block split"); 183 + // The old editor's content is now stale (superseded by the split 184 + // above); drop it without re-committing through `commit_editor`. 185 + self.editor = None; 186 + self.refresh_rows(); 187 + 188 + // Focusing the new sibling needs a `&mut Window`, which this 189 + // event-emitter callback doesn't have — hop back into one via the 190 + // stashed window handle on the next update cycle. 191 + let entity = cx.entity(); 192 + let handle = self.window_handle; 193 + cx.defer(move |cx| { 194 + let _ = handle.update(cx, move |_, window, cx| { 195 + entity.update(cx, |this, cx| { 196 + this.focus_block(new_block, window, cx); 197 + }); 198 + }); 199 + }); 200 + } 201 + 202 + /// Backspace at the start of a block (no selection): merge it into the 203 + /// end of its previous sibling's content and focus that sibling there 204 + /// (spec: "delete/merge with previous"). No-op if there's no previous 205 + /// sibling or the block has children (matching `Outline::merge_block`'s 206 + /// precondition — merging a block with descendants would orphan them). 207 + /// 208 + /// `BlockEditor` itself already confirmed the cursor was at position 0 209 + /// with no selection before emitting `MergeWithPreviousRequested` — 210 + /// this method doesn't re-check. 211 + fn merge_focused_block_with_previous(&mut self, cx: &mut Context<Self>) { 212 + let Some(editor) = self.editor.as_ref() else { 213 + return; 214 + }; 215 + let block = editor.block; 216 + let live_content = editor.input.read(cx).value().to_string(); 217 + 218 + let outline = Outline::new(self.storage.doc()); 219 + if !outline.children(Some(block)).is_empty() { 220 + return; 221 + } 222 + let parent = outline.parent(block); 223 + let siblings = outline.children(parent); 224 + let Some(pos) = siblings.iter().position(|&s| s == block) else { 225 + return; 226 + }; 227 + let Some(prev) = pos.checked_sub(1).map(|i| siblings[i]) else { 228 + return; 229 + }; 230 + 231 + // `merge_block` reads content from the doc, not the live editor 232 + // buffer — sync first so nothing typed since focus is lost. 233 + outline 234 + .set_content(block, &live_content) 235 + .expect("focused block still exists"); 236 + outline 237 + .merge_block(block, prev) 238 + .expect("merge with previous sibling"); 239 + self.storage.persist_update().expect("persist merge"); 240 + self.editor = None; 241 + self.refresh_rows(); 242 + 243 + let entity = cx.entity(); 244 + let handle = self.window_handle; 245 + cx.defer(move |cx| { 246 + let _ = handle.update(cx, move |_, window, cx| { 247 + entity.update(cx, |this, cx| { 248 + this.focus_block(prev, window, cx); 249 + }); 250 + }); 251 + }); 252 + } 253 + 254 + /// Tab: indent the focused block under its previous sibling (spec 255 + /// scenario: "Indent under previous sibling"). No-op if there is no 256 + /// previous sibling. 257 + fn indent_focused(&mut self, cx: &mut Context<Self>) { 258 + let Some(block) = self.editor.as_ref().map(|e| e.block) else { 259 + return; 260 + }; 261 + let outline = Outline::new(self.storage.doc()); 262 + let parent = outline.parent(block); 263 + let siblings = outline.children(parent); 264 + let Some(pos) = siblings.iter().position(|&s| s == block) else { 265 + return; 266 + }; 267 + let Some(prev_sibling) = pos.checked_sub(1).map(|i| siblings[i]) else { 268 + return; 269 + }; 270 + let new_index = outline.children(Some(prev_sibling)).len(); 271 + outline 272 + .move_subtree(block, Some(prev_sibling), Position::Index(new_index)) 273 + .expect("move under previous sibling"); 274 + self.storage.persist_update().expect("persist indent"); 275 + self.refresh_rows(); 276 + cx.notify(); 277 + } 278 + 279 + /// Shift+Tab: outdent the focused block to become its parent's next 280 + /// sibling. No-op if the block is already at the root. 281 + fn outdent_focused(&mut self, cx: &mut Context<Self>) { 282 + let Some(block) = self.editor.as_ref().map(|e| e.block) else { 283 + return; 284 + }; 285 + let outline = Outline::new(self.storage.doc()); 286 + let Some(parent) = outline.parent(block) else { 287 + return; 288 + }; 289 + let grandparent = outline.parent(parent); 290 + outline 291 + .move_subtree(block, grandparent, Position::After(parent)) 292 + .expect("move to grandparent level"); 293 + self.storage.persist_update().expect("persist outdent"); 294 + self.refresh_rows(); 295 + cx.notify(); 296 + } 297 + 298 + /// Alt+Up / Alt+Down: reorder the focused block among its current 299 + /// siblings. `direction` is -1 for up, +1 for down. 300 + fn move_focused(&mut self, direction: i32, cx: &mut Context<Self>) { 301 + let Some(block) = self.editor.as_ref().map(|e| e.block) else { 302 + return; 303 + }; 304 + let outline = Outline::new(self.storage.doc()); 305 + let parent = outline.parent(block); 306 + let siblings = outline.children(parent); 307 + let Some(pos) = siblings.iter().position(|&s| s == block) else { 308 + return; 309 + }; 310 + let target = pos as i32 + direction; 311 + if target < 0 || target as usize >= siblings.len() { 312 + return; 313 + } 314 + let target_sibling = siblings[target as usize]; 315 + let position = if direction < 0 { 316 + Position::Before(target_sibling) 317 + } else { 318 + Position::After(target_sibling) 319 + }; 320 + outline 321 + .reorder_sibling(block, position) 322 + .expect("reorder sibling"); 323 + self.storage.persist_update().expect("persist reorder"); 324 + self.refresh_rows(); 325 + cx.notify(); 326 + } 327 + 328 + /// Plain Up/Down at a content boundary: move focus to the previous or 329 + /// next block in visible (depth-first, fold-aware) order — the same 330 + /// order `self.rows` is already rendered in (spec scenario: "Focus 331 + /// transition commits content"). `direction` is -1 for up, +1 for down. 332 + fn focus_adjacent_row(&mut self, direction: i32, cx: &mut Context<Self>) { 333 + let Some(block) = self.editor.as_ref().map(|e| e.block) else { 334 + return; 335 + }; 336 + let Some(pos) = self.rows.iter().position(|r| r.id == block) else { 337 + return; 338 + }; 339 + let target = pos as i32 + direction; 340 + if target < 0 || target as usize >= self.rows.len() { 341 + return; 342 + } 343 + let target_block = self.rows[target as usize].id; 344 + 345 + let entity = cx.entity(); 346 + let handle = self.window_handle; 347 + cx.defer(move |cx| { 348 + let _ = handle.update(cx, move |_, window, cx| { 349 + entity.update(cx, |this, cx| { 350 + this.focus_block(target_block, window, cx); 351 + }); 352 + }); 126 353 }); 127 354 } 128 355 } ··· 175 402 storage.persist_update().unwrap(); 176 403 } 177 404 178 - /// Flatten the outline tree into depth-first visible rows. No folding yet 179 - /// (task 5.5) — every block in every page is visible. 180 - fn visible_rows(storage: &GraphStorage) -> Vec<VisibleRow> { 405 + /// Flatten the outline tree into depth-first visible rows, skipping the 406 + /// descendants of any block in `folded` (spec: "Fold hides descendants" — 407 + /// "keyboard navigation skips the hidden blocks" falls out naturally since 408 + /// folded descendants are never turned into rows at all). 409 + fn visible_rows(storage: &GraphStorage, folded: &HashSet<TreeID>) -> Vec<VisibleRow> { 181 410 let outline = Outline::new(storage.doc()); 182 411 let index = GraphIndex::rebuild(storage.doc()); 183 412 184 413 let mut rows = Vec::new(); 185 414 for &page in &index.roots { 186 - push_subtree(&outline, page, 0, &mut rows); 415 + push_subtree(&outline, page, 0, folded, &mut rows); 187 416 } 188 417 rows 189 418 } 190 419 191 - fn push_subtree(outline: &Outline, id: TreeID, depth: usize, rows: &mut Vec<VisibleRow>) { 420 + fn push_subtree( 421 + outline: &Outline, 422 + id: TreeID, 423 + depth: usize, 424 + folded: &HashSet<TreeID>, 425 + rows: &mut Vec<VisibleRow>, 426 + ) { 192 427 let content = outline.content(id).unwrap_or_default(); 428 + let children = outline.children(Some(id)); 193 429 rows.push(VisibleRow { 194 430 id, 195 431 depth, 196 432 blocks: markdown::parse_block(&content), 433 + has_children: !children.is_empty(), 434 + folded: folded.contains(&id), 197 435 }); 198 - for child in outline.children(Some(id)) { 199 - push_subtree(outline, child, depth + 1, rows); 436 + if folded.contains(&id) { 437 + return; 438 + } 439 + for child in children { 440 + push_subtree(outline, child, depth + 1, folded, rows); 200 441 } 201 442 } 202 443 ··· 284 525 /// `'static` and runs on every visible-range change. 285 526 enum RowContent { 286 527 Markdown(Vec<Block>), 287 - Editor(Entity<InputState>), 528 + Editor(Entity<BlockEditor>), 288 529 } 289 530 290 531 impl Render for TrawlerApp { ··· 292 533 let row_count = self.rows.len(); 293 534 let focused_block = self.editor.as_ref().map(|e| e.block); 294 535 let editor_input = self.editor.as_ref().map(|e| e.input.clone()); 295 - let rows: Vec<(TreeID, usize, RowContent)> = self 536 + let rows: Vec<(TreeID, usize, bool, bool, RowContent)> = self 296 537 .rows 297 538 .iter() 298 539 .map(|r| { ··· 301 542 } else { 302 543 RowContent::Markdown(r.blocks.clone()) 303 544 }; 304 - (r.id, r.depth, content) 545 + (r.id, r.depth, r.has_children, r.folded, content) 305 546 }) 306 547 .collect(); 307 548 ··· 330 571 cx.processor(move |_this, range: Range<usize>, _window, cx| { 331 572 range 332 573 .map(|ix| { 333 - let (id, depth, content) = &rows[ix]; 574 + let (id, depth, has_children, folded, content) = &rows[ix]; 334 575 let id = *id; 335 576 let row = div() 336 577 .id(ix) ··· 339 580 .pr_2() 340 581 .py_1() 341 582 .flex() 342 - .flex_col() 583 + .flex_row() 584 + .items_center() 343 585 .gap_1(); 344 - match content { 345 - RowContent::Editor(input) => { 346 - row.child(Input::new(input).w_full()) 347 - } 348 - RowContent::Markdown(blocks) => row 586 + let fold_indicator: gpui::AnyElement = if *has_children { 587 + div() 588 + .id(("fold", ix)) 349 589 .cursor_pointer() 590 + .w(px(14.0)) 591 + .text_color(rgb(MUTED_COLOR)) 350 592 .on_click(cx.listener( 351 - move |this, _event: &ClickEvent, window, cx| { 352 - this.focus_block(id, window, cx); 593 + move |this, _event: &ClickEvent, _window, cx| { 594 + this.toggle_fold(id, cx); 353 595 }, 354 596 )) 355 - .children(blocks.iter().map(render_block)), 597 + .child(if *folded { "▶" } else { "▼" }) 598 + .into_any_element() 599 + } else { 600 + div().w(px(14.0)).into_any_element() 601 + }; 602 + let row = row.child(fold_indicator); 603 + match content { 604 + RowContent::Editor(input) => { 605 + row.child(div().flex_1().child(input.clone())) 606 + } 607 + RowContent::Markdown(blocks) => row.child( 608 + div() 609 + .id(("content", ix)) 610 + .flex_1() 611 + .flex() 612 + .flex_col() 613 + .gap_1() 614 + .cursor_pointer() 615 + .on_click(cx.listener( 616 + move |this, _event: &ClickEvent, window, cx| { 617 + this.focus_block(id, window, cx); 618 + }, 619 + )) 620 + .children(blocks.iter().map(render_block)), 621 + ), 356 622 } 357 623 }) 358 624 .collect::<Vec<_>>() ··· 377 643 fn main() { 378 644 let graph_dir = default_graph_dir(); 379 645 Application::new().run(move |cx: &mut App| { 380 - gpui_component::init(cx); 646 + editor::init(cx); 381 647 let bounds = Bounds::centered(None, size(px(900.0), px(600.0)), cx); 382 648 cx.open_window( 383 649 WindowOptions { 384 650 window_bounds: Some(WindowBounds::Windowed(bounds)), 385 651 ..Default::default() 386 652 }, 387 - move |window, cx| { 388 - let app: Entity<TrawlerApp> = cx.new(|_| TrawlerApp::new(graph_dir.clone())); 389 - cx.new(|cx| Root::new(app, window, cx)) 390 - }, 653 + move |window, cx| cx.new(|_| TrawlerApp::new(graph_dir.clone(), window)), 391 654 ) 392 655 .unwrap(); 393 656 cx.activate(true);
+1 -1
openspec/changes/trawler-mvp/tasks.md
··· 41 41 - [x] 5.2 Virtualized outline view rendering block trees from trawler-core (spec: outline-editor/Large outlines render lazily) 42 42 - [x] 5.3 Markdown renderer for inactive blocks (pulldown-cmark or tree-sitter-markdown): paragraphs, emphasis, code spans/fences, quotes, embedded lists, links, plus node-reference inline extension 43 43 - [x] 5.4 One-hot editor: multi-line focused-block editor, focus model, editor attach/detach, commit-on-blur; measure transition latency from the first working build (spec: outline-editor/Editing latency) 44 - - [ ] 5.5 Outline keybindings: Enter/new block, Shift+Enter/newline within block, split at cursor, Tab/Shift+Tab, move up/down, backspace-merge, fold/unfold 44 + - [x] 5.5 Outline keybindings: Enter/new block, Shift+Enter/newline within block, split at cursor, Tab/Shift+Tab, move up/down, backspace-merge, fold/unfold 45 45 - [ ] 5.6 Reference completion: `[[` and `#` popup with fuzzy matching; date completion for journal refs 46 46 47 47 ## 6. Journal & Navigation (trawler)