commit 27513283ff58245efb52718eb5cfc70769975eb2 Author: Owen Qwen Date: Sun Jun 21 20:08:56 2026 -0500 Inital commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ecbd5bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c4788d6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1313 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ignore" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "lime-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "lime-core", + "lime-ui", +] + +[[package]] +name = "lime-core" +version = "0.1.0" +dependencies = [ + "ropey", + "tempfile", + "thiserror", + "unicode-segmentation", +] + +[[package]] +name = "lime-syntax" +version = "0.1.0" +dependencies = [ + "tree-sitter", + "tree-sitter-javascript", + "tree-sitter-json", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-typescript", +] + +[[package]] +name = "lime-ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "dirs", + "ignore", + "lime-core", + "lime-syntax", + "ratatui", + "serde", + "toml", + "unicode-width 0.2.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..711d840 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +[workspace] +members = [ + "crates/lime-core", + "crates/lime-syntax", + "crates/lime-ui", + "crates/lime-cli", +] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "MIT" +repository = "https://github.com/owen/lime" + +[workspace.dependencies] +anyhow = "1" +thiserror = "1" +clap = { version = "4", features = ["derive"] } +ropey = "1" +crossterm = "0.28" +ratatui = "0.29" +unicode-segmentation = "1" +unicode-width = "0.2" +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-typescript = "0.23" +tree-sitter-python = "0.23" +tree-sitter-json = "0.24" +ignore = "0.4" +serde = { version = "1", features = ["derive"] } +toml = "0.8" +dirs = "5" +tempfile = "3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e09d591 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Owen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..a1947f3 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,963 @@ +# Lime Implementation Plan + +Lime is a clean, modern terminal/TUI text editor written in Rust. It should feel approachable like Nano, efficient and clean like Vim, and easy to use like VS Code, while using a standard non-modal cursor editing model. + +This plan intentionally avoids time estimates so it can be handed directly to an implementation agent. + +## 1. Product Direction + +Initial product goal: + +```bash +lime path/to/file.rs +``` + +opens a clean single-file terminal editor with: + +- Standard typing/editing behavior +- Save and quit shortcuts +- Unsaved-changes confirmation +- Syntax highlighting +- Line numbers +- Status bar and help bar +- File search/open popup with `Ctrl-F` +- Recursive file discovery from the current working directory +- Large-file warning before opening very large files +- Good behavior on macOS and Linux + +Lime should not be modal like Vim. It should be keyboard-first, but obvious and friendly. + +## 2. Recommended Technical Stack + +Use a Rust-native terminal stack: + +- `crossterm` for terminal control and input events +- `ratatui` for layout, widgets, status bars, popups, and drawing +- `ropey` for text storage +- `tree-sitter` for syntax highlighting +- `ignore` for recursive file scanning with `.gitignore` support +- `nucleo-matcher` for fuzzy file matching + +Do not build a custom terminal renderer at first. `ratatui + crossterm` gives enough control for a polished TUI while keeping implementation manageable. + +Suggested dependencies: + +```toml +[dependencies] +anyhow = "1" +thiserror = "1" +clap = { version = "4", features = ["derive"] } +ropey = "1" +crossterm = "0.28" +ratatui = "0.29" +unicode-segmentation = "1" +unicode-width = "0.2" +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-typescript = "0.23" +tree-sitter-python = "0.23" +tree-sitter-json = "0.23" +tree-sitter-toml-ng = "0.6" +tree-sitter-md = "0.3" +ignore = "0.4" +nucleo-matcher = "0.3" +notify = "7" +serde = { version = "1", features = ["derive"] } +toml = "0.8" +dirs = "5" +``` + +Optional later: + +```toml +arboard = "3" +similar = "2" +tempfile = "3" +insta = "1" +``` + +## 3. Repository Structure + +Use a Cargo workspace from the beginning: + +```txt +lime/ + Cargo.toml + README.md + LICENSE + PLAN.md + crates/ + lime-core/ + Cargo.toml + src/ + lib.rs + buffer.rs + cursor.rs + selection.rs + edit.rs + history.rs + movement.rs + search.rs + file.rs + command.rs + lime-syntax/ + Cargo.toml + src/ + lib.rs + language.rs + highlighter.rs + theme.rs + lime-ui/ + Cargo.toml + src/ + lib.rs + app.rs + terminal.rs + layout.rs + editor_view.rs + status_bar.rs + command_bar.rs + file_picker.rs + prompt.rs + theme.rs + input.rs + lime-cli/ + Cargo.toml + src/ + main.rs +``` + +Root `Cargo.toml`: + +```toml +[workspace] +members = [ + "crates/lime-core", + "crates/lime-syntax", + "crates/lime-ui", + "crates/lime-cli" +] +resolver = "2" +``` + +The installed binary should be named `lime`. + +## 4. Crate Responsibilities + +### `lime-core` + +Pure editor logic. + +Must not depend on terminal UI, Ratatui, themes, syntax highlighting, or input events. + +Responsibilities: + +- Text buffer +- Cursor state +- Selections, even if selection editing comes later +- Editing operations +- Undo/redo +- File load/save +- Dirty state +- Search inside the current buffer +- Line/column movement +- Large-file metadata and open policy types + +### `lime-syntax` + +Syntax parsing and highlighting. + +Responsibilities: + +- Language detection +- Tree-sitter parser setup +- Highlight spans for visible ranges +- Theme scopes +- Graceful fallback for unknown languages or parser failures + +### `lime-ui` + +Terminal application. + +Responsibilities: + +- Raw mode +- Alternate screen +- Event loop +- Keybindings +- Rendering +- Editor viewport +- Popups +- File picker +- Status bar +- Help bar +- Prompts and warnings + +### `lime-cli` + +CLI entry point. + +Responsibilities: + +- Parse CLI arguments +- Resolve starting path +- Load config +- Start app +- Ensure terminal cleanup on error or panic + +## 5. Core Data Model + +Use `ropey::Rope` for buffer storage. + +```rust +pub struct TextBuffer { + rope: Rope, + path: Option, + dirty: bool, + line_ending: LineEnding, +} +``` + +Track positions as line/column character positions, not byte offsets. + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Position { + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cursor { + pub position: Position, + pub preferred_column: Option, +} +``` + +Use `preferred_column` for vertical movement so up/down preserve the intended visual column across lines of different lengths. + +Also define: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextRange { + pub start: Position, + pub end: Position, +} + +pub enum LineEnding { + Lf, + Crlf, +} +``` + +## 6. Editing Commands + +Create a command abstraction in `lime-core`: + +```rust +pub enum EditorCommand { + InsertChar(char), + InsertText(String), + Newline, + Backspace, + Delete, + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + MoveLineStart, + MoveLineEnd, + MoveFileStart, + MoveFileEnd, + PageUp, + PageDown, + Save, + Undo, + Redo, + Search(String), +} +``` + +The UI should convert terminal key events into app actions or editor commands. The core applies commands and returns effects. + +```rust +pub enum CommandResult { + None, + Modified, + Saved, + CursorMoved, + NeedsPath, +} +``` + +## 7. Undo/Redo + +Implement undo/redo early. + +Represent edits as reversible transactions: + +```rust +pub struct EditTransaction { + pub edits: Vec, + pub before_cursor: Cursor, + pub after_cursor: Cursor, +} + +pub enum TextEdit { + Insert { + at: Position, + text: String, + }, + Delete { + range: TextRange, + deleted_text: String, + }, +} +``` + +Typing consecutive characters should be grouped into one undo transaction until one of these happens: + +- Cursor movement +- Newline +- Backspace/delete +- Save +- Different command type +- Focus changes to a popup/prompt + +## 8. Large File Handling + +Before reading a file, inspect metadata. + +Recommended thresholds: + +```txt +<= 5 MB: open normally +5-25 MB: open with warning prompt +> 25 MB: require explicit confirmation +> 100 MB: refuse by default unless --force +``` + +CLI/TUI warning example: + +```txt +This file is 84 MB. Lime may be slower with very large files. +Open anyway? [y/N] +``` + +Core policy type: + +```rust +pub struct FileOpenPolicy { + pub warn_threshold_bytes: u64, + pub confirm_threshold_bytes: u64, + pub hard_threshold_bytes: u64, + pub force: bool, +} +``` + +The file picker should also use this policy before replacing the current buffer. + +## 9. Terminal UI Layout + +Default layout: + +```txt +┌────────────────────────────────────────────┐ +│ 1 │ fn main() { │ +│ 2 │ println!("hello"); │ +│ 3 │ } │ +│ │ │ +│ │ │ +├────────────────────────────────────────────┤ +│ lime file.rs rust Ln 2, Col 5 modified │ +│ Ctrl-S Save Ctrl-F Files Ctrl-Q Quit │ +└────────────────────────────────────────────┘ +``` + +Main regions: + +1. Editor viewport +2. Status bar +3. Help/action bar +4. Optional popup overlay + +Use `ratatui::Layout` for the main shell. + +## 10. Rendering Strategy + +Render only visible lines. + +UI viewport state: + +```rust +pub struct Viewport { + pub top_line: usize, + pub left_col: usize, + pub height: usize, + pub width: usize, +} +``` + +Editor rendering should: + +- Render visible lines only +- Draw line numbers +- Draw cursor +- Draw selected text later +- Apply syntax spans when available +- Horizontally scroll for long lines +- Vertically scroll when the cursor nears viewport edges +- Avoid allocating the entire file each frame + +Gutter width: + +```rust +let gutter_width = total_lines.to_string().len() + 2; +``` + +## 11. Input Model + +Recommended shortcuts: + +```txt +Ctrl-S Save +Ctrl-Q Quit +Ctrl-F File picker +Ctrl-G Go to line +Ctrl-Z Undo +Ctrl-Y Redo +Ctrl-A Start of line +Ctrl-E End of line +Ctrl-R Search in current file +Esc Close popup/cancel prompt +Arrow keys Move cursor +PageUp Page up +PageDown Page down +Home Start of line +End End of line +Backspace Delete backward +Delete Delete forward +Enter Newline +Tab Insert spaces according to config +``` + +Use an app-level action enum: + +```rust +pub enum AppAction { + Editor(EditorCommand), + Save, + Quit, + OpenFilePicker, + OpenGoToLine, + OpenSearch, + ClosePopup, + Confirm, + Cancel, +} +``` + +Do not hardcode behavior directly inside the event loop. Route input through a mapping layer. + +## 12. File Picker + +`Ctrl-F` opens a centered popup listing files recursively from the current directory. + +Behavior: + +- Scan current working directory or selected workspace root +- Respect `.gitignore` +- Skip hidden files by default +- Skip common bulky directories: + - `.git` + - `target` + - `node_modules` + - `dist` + - `.next` + - `build` +- Fuzzy filter as the user types +- Arrow up/down changes selection +- Enter opens selected file +- Escape closes picker +- Warn if current file has unsaved changes before replacing it +- Warn before opening very large files + +Popup layout: + +```txt +╭─ Open File ─────────────────────────────╮ +│ search: main │ +│ │ +│ > crates/lime-cli/src/main.rs │ +│ crates/lime-core/src/buffer.rs │ +│ README.md │ +╰─────────────────────────────────────────╯ +``` + +State: + +```rust +pub struct FilePickerState { + pub query: String, + pub all_files: Vec, + pub matches: Vec, + pub selected: usize, +} + +pub struct FileMatch { + pub path: PathBuf, + pub score: u32, +} +``` + +## 13. Syntax Highlighting + +Start with these languages: + +- Rust +- JavaScript +- TypeScript +- Python +- JSON +- TOML +- Markdown + +Language detection: + +```rust +pub enum Language { + Rust, + JavaScript, + TypeScript, + Python, + Json, + Toml, + Markdown, + PlainText, +} +``` + +Detection should use file extension first. + +Highlighting should be resilient: + +- Unknown language falls back to plain text +- Parser failure falls back to plain text +- Initial implementation may highlight visible ranges only +- Incremental parsing can come later + +Theme model: + +```rust +pub struct Theme { + pub background: Color, + pub foreground: Color, + pub gutter: Color, + pub cursor: Color, + pub selection: Color, + pub keyword: Color, + pub string: Color, + pub comment: Color, + pub function: Color, + pub type_name: Color, + pub number: Color, + pub error: Color, +} +``` + +Use a clean default dark theme with calm contrast. + +## 14. Save/Quit Behavior + +If there are no unsaved changes, `Ctrl-Q` exits immediately. + +If the buffer is dirty: + +```txt +Unsaved changes. Save before quitting? [s]ave [d]iscard [c]ancel +``` + +If saving an unnamed buffer: + +```txt +Save as: _ +``` + +Save operation should: + +- Write to a temporary file +- Flush data +- Rename over original path +- Preserve line endings where practical +- Clear dirty flag after success +- Show a status message after success or failure + +## 15. Config + +Keep config simple at first. + +Locations: + +```txt +macOS: ~/Library/Application Support/lime/config.toml +Linux: ~/.config/lime/config.toml +``` + +Initial config: + +```toml +theme = "lime-dark" +tab_width = 4 +insert_spaces = true +show_line_numbers = true +confirm_large_files = true +``` + +Rust model: + +```rust +pub struct Config { + pub theme: String, + pub tab_width: usize, + pub insert_spaces: bool, + pub show_line_numbers: bool, + pub confirm_large_files: bool, +} +``` + +If config is missing or invalid, use defaults and show a non-blocking warning. + +## 16. Error Handling + +Use `anyhow` at app/CLI boundaries. + +Use `thiserror` for library errors. + +Example: + +```rust +#[derive(thiserror::Error, Debug)] +pub enum LimeError { + #[error("file is too large: {size} bytes")] + FileTooLarge { size: u64 }, + + #[error("buffer has no file path")] + MissingPath, + + #[error("invalid cursor position")] + InvalidPosition, +} +``` + +Terminal cleanup must always happen. + +Use a guard: + +```rust +pub struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + // disable raw mode + // leave alternate screen + // show cursor + } +} +``` + +Never leave the terminal in raw mode after an error or panic. + +## 17. CLI Design + +Basic usage: + +```bash +lime +lime file.rs +lime . +lime --force huge.log +lime --config path/to/config.toml +``` + +CLI args: + +```rust +#[derive(Parser)] +pub struct Cli { + pub path: Option, + + #[arg(long)] + pub force: bool, + + #[arg(long)] + pub config: Option, +} +``` + +Behavior: + +- No path: open empty unnamed buffer +- File path: open file +- Directory path: open empty buffer with file picker rooted at that directory +- Nonexistent file: create a new buffer with that path +- Huge file: warn/confirm unless `--force` + +## 18. Testing Strategy + +### `lime-core` + +Most tests should live here. + +Test: + +- Insert characters +- Insert text +- Insert newline +- Backspace in middle of line +- Backspace at start of line joins lines +- Delete in middle of line +- Delete at end of line joins lines +- Cursor movement left/right/up/down +- Line start/end +- File start/end +- Undo/redo +- Search +- Save/load roundtrip +- Unicode text +- Tabs +- Long lines +- Empty file behavior + +Example test: + +```rust +#[test] +fn backspace_joins_lines() { + let mut editor = Editor::from_text("hello\nworld"); + editor.set_cursor(Position { line: 1, column: 0 }); + editor.backspace(); + + assert_eq!(editor.text(), "helloworld"); + assert_eq!(editor.cursor().position, Position { line: 0, column: 5 }); +} +``` + +### `lime-syntax` + +Test: + +- Language detection +- Parser setup does not panic +- Highlight spans are valid +- Unknown files fall back to plain text + +### `lime-ui` + +Test pure/non-terminal pieces: + +- Keybinding mapping +- Viewport scrolling +- File picker filtering +- Status string formatting +- Layout calculations +- Prompt state transitions + +Avoid depending heavily on full terminal snapshot tests in the first version. + +## 19. Implementation Order + +### Step 1: Create workspace + +Create all workspace crates and make `cargo test` pass. + +Deliverables: + +- Root `Cargo.toml` +- `lime-core` +- `lime-syntax` +- `lime-ui` +- `lime-cli` +- Minimal compiling binary named `lime` + +### Step 2: Implement `lime-core` + +Implement: + +- `TextBuffer` +- `Position` +- `TextRange` +- `Cursor` +- Load from string +- Load from file +- Save to file +- Insert char/text +- Newline +- Backspace +- Delete +- Cursor movement +- Dirty flag + +Add comprehensive tests. + +### Step 3: Implement undo/redo + +Add: + +- `EditTransaction` +- Undo stack +- Redo stack +- Transaction grouping for typing +- Tests for edit operations and grouped undo behavior + +### Step 4: Implement basic terminal app + +In `lime-ui`, implement: + +- Raw mode +- Alternate screen +- Panic-safe cleanup +- Event loop +- Render empty editor +- Render file content +- Status bar +- Help bar +- Cursor rendering + +At this point: + +```bash +cargo run -p lime-cli -- README.md +``` + +should open a navigable file. + +### Step 5: Wire editing input + +Add keyboard handling for: + +- Text input +- Enter +- Tab +- Backspace +- Delete +- Arrow movement +- Home/end +- Page up/down +- Save +- Quit + +After this step, Lime should be a usable single-file editor. + +### Step 6: Add viewport scrolling + +Implement: + +- Vertical scroll +- Horizontal scroll +- Cursor-follow behavior +- Page up/down +- Long-line handling + +Do not render the whole file. + +### Step 7: Add file open policy + +Before reading files: + +- Check size +- Show warning prompt in TUI +- Support `--force` +- Refuse extreme files by default + +### Step 8: Add file picker + +Implement: + +- Recursive file scan +- Fuzzy matching +- Popup rendering +- Query input +- Selection movement +- Opening selected files +- Dirty-buffer confirmation before replacing current file + +Bind to `Ctrl-F`. + +### Step 9: Add syntax highlighting + +Implement `lime-syntax`. + +Start with: + +- Language detection +- Rust highlighting +- Plain-text fallback + +Then add: + +- JavaScript +- TypeScript +- Python +- JSON +- TOML +- Markdown + +Integrate highlighting into editor rendering. + +### Step 10: Add quality-of-life features + +Add: + +- Go to line popup +- Search in current file +- Unsaved quit prompt +- Save-as prompt +- Status messages +- Error display +- Config loading +- Tab width setting +- Insert spaces setting + +## 20. Minimum Definition of Done + +The first complete version should support: + +```bash +lime file.rs +``` + +and include: + +- Clean terminal UI +- Standard text editing +- Natural cursor movement +- Save with `Ctrl-S` +- Quit with `Ctrl-Q` +- Unsaved changes warning +- Syntax highlighting for known languages +- File picker with `Ctrl-F` +- Recursive current-directory browsing +- Fuzzy file filtering +- Moderately large file handling without freezing +- Warning before opening very large files +- Passing core editing tests +- macOS and Linux support + +## 21. Explicit Non-Goals For First Version + +Do not implement these yet: + +- Plugin system +- Extension marketplace +- LSP +- Multi-pane editor +- Integrated terminal +- Git UI +- Debugger +- AI features +- Remote editing +- Modal Vim emulation + +These can be added later only after the core editor feels excellent. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd3ae6e --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Lime + +Lime is a clean, modern terminal text editor written in Rust. + +It aims to be approachable like Nano, efficient and clean like Vim, and easy to use like VS Code, while keeping a standard non-modal editing model. + +## Status + +This repository contains the first usable implementation: + +- Rust Cargo workspace +- Rope-backed editor core +- Cursor movement and editing +- Undo/redo +- Save and save-as +- Unsaved-change prompts +- Ratatui/Crossterm terminal UI +- Status/help bars +- Recursive file picker with `Ctrl-F` +- Fuzzy file filtering +- Large-file warning policy +- Syntax highlighting for common languages with Tree-sitter/lexical fallback +- Simple TOML config loading + +## Run + +```bash +cargo run -p lime-cli -- path/to/file.rs +``` + +or after installing/building the binary: + +```bash +lime path/to/file.rs +lime . +lime --force huge.log +``` + +## Keyboard shortcuts + +| Shortcut | Action | +| --- | --- | +| `Ctrl-S` | Save | +| `Ctrl-Q` | Quit | +| `Ctrl-F` | Open file picker | +| `Ctrl-R` | Search current file | +| `Ctrl-G` | Go to line | +| `Ctrl-Z` | Undo | +| `Ctrl-Y` | Redo | +| `Ctrl-A` | Start of line | +| `Ctrl-E` | End of line | +| `Esc` | Close popup/cancel prompt | + +## Config + +Lime looks for config at: + +- macOS: `~/Library/Application Support/lime/config.toml` +- Linux: `~/.config/lime/config.toml` + +Example: + +```toml +theme = "lime-dark" +tab_width = 4 +insert_spaces = true +show_line_numbers = true +confirm_large_files = true +``` + +## Development + +```bash +cargo fmt +cargo test +``` + +Workspace crates: + +- `lime-core`: pure editor model and editing operations +- `lime-syntax`: language detection and highlighting +- `lime-ui`: terminal UI, file picker, prompts, keybindings +- `lime-cli`: binary entry point diff --git a/crates/lime-cli/Cargo.toml b/crates/lime-cli/Cargo.toml new file mode 100644 index 0000000..7057a37 --- /dev/null +++ b/crates/lime-cli/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lime-cli" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[[bin]] +name = "lime" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +lime-core = { path = "../lime-core" } +lime-ui = { path = "../lime-ui" } diff --git a/crates/lime-cli/src/main.rs b/crates/lime-cli/src/main.rs new file mode 100644 index 0000000..fedb84a --- /dev/null +++ b/crates/lime-cli/src/main.rs @@ -0,0 +1,26 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::Parser; +use lime_ui::{run_app, AppOptions}; + +#[derive(Debug, Parser)] +#[command(name = "lime")] +#[command(about = "A clean, modern terminal text editor")] +struct Cli { + /// File or directory to open + path: Option, + + /// Open very large files without confirmation/refusal + #[arg(long)] + force: bool, + + /// Path to a Lime config file + #[arg(long)] + config: Option, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + run_app(AppOptions::new(cli.path, cli.force, cli.config)) +} diff --git a/crates/lime-core/Cargo.toml b/crates/lime-core/Cargo.toml new file mode 100644 index 0000000..e7b8d02 --- /dev/null +++ b/crates/lime-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lime-core" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +ropey.workspace = true +thiserror.workspace = true +unicode-segmentation.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/lime-core/src/buffer.rs b/crates/lime-core/src/buffer.rs new file mode 100644 index 0000000..3a5d81f --- /dev/null +++ b/crates/lime-core/src/buffer.rs @@ -0,0 +1,270 @@ +use std::fs::{self, File}; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use ropey::Rope; + +use crate::cursor::Position; +use crate::edit::TextRange; +use crate::error::{LimeError, Result}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineEnding { + Lf, + Crlf, +} + +impl LineEnding { + pub const fn as_str(self) -> &'static str { + match self { + Self::Lf => "\n", + Self::Crlf => "\r\n", + } + } + + pub fn detect(text: &str) -> Self { + if text.contains("\r\n") { + Self::Crlf + } else { + Self::Lf + } + } +} + +#[derive(Debug, Clone)] +pub struct TextBuffer { + rope: Rope, + path: Option, + dirty: bool, + line_ending: LineEnding, +} + +impl Default for TextBuffer { + fn default() -> Self { + Self::from_text("") + } +} + +impl TextBuffer { + pub fn from_text(text: &str) -> Self { + Self { + rope: Rope::from_str(text), + path: None, + dirty: false, + line_ending: LineEnding::detect(text), + } + } + + pub fn from_text_with_path(text: &str, path: impl Into) -> Self { + let mut buffer = Self::from_text(text); + buffer.path = Some(path.into()); + buffer + } + + pub fn from_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let text = fs::read_to_string(path)?; + Ok(Self { + rope: Rope::from_str(&text), + path: Some(path.to_path_buf()), + dirty: false, + line_ending: LineEnding::detect(&text), + }) + } + + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + + pub fn set_path(&mut self, path: impl Into) { + self.path = Some(path.into()); + } + + pub fn is_dirty(&self) -> bool { + self.dirty + } + + pub fn set_dirty(&mut self, dirty: bool) { + self.dirty = dirty; + } + + pub fn line_ending(&self) -> LineEnding { + self.line_ending + } + + pub fn set_line_ending(&mut self, line_ending: LineEnding) { + self.line_ending = line_ending; + } + + pub fn len_chars(&self) -> usize { + self.rope.len_chars() + } + + pub fn len_bytes(&self) -> usize { + self.rope.len_bytes() + } + + pub fn line_count(&self) -> usize { + self.rope.len_lines().max(1) + } + + pub fn text(&self) -> String { + self.rope.to_string() + } + + pub fn char(&self, char_idx: usize) -> Option { + if char_idx < self.len_chars() { + Some(self.rope.char(char_idx)) + } else { + None + } + } + + pub fn line_to_string(&self, line_idx: usize) -> String { + if line_idx >= self.line_count() { + return String::new(); + } + self.rope.line(line_idx).to_string() + } + + pub fn line_text_without_ending(&self, line_idx: usize) -> String { + let mut line = self.line_to_string(line_idx); + if line.ends_with('\n') { + line.pop(); + if line.ends_with('\r') { + line.pop(); + } + } + line + } + + pub fn line_len_chars(&self, line_idx: usize) -> usize { + if line_idx >= self.line_count() { + return 0; + } + + let line = self.rope.line(line_idx); + let mut len = line.len_chars(); + if len > 0 && line.char(len - 1) == '\n' { + len -= 1; + if len > 0 && line.char(len - 1) == '\r' { + len -= 1; + } + } + len + } + + pub fn clamp_position(&self, position: Position) -> Position { + let line = position.line.min(self.line_count().saturating_sub(1)); + let column = position.column.min(self.line_len_chars(line)); + Position { line, column } + } + + pub fn position_to_char(&self, position: Position) -> usize { + let position = self.clamp_position(position); + self.rope.line_to_char(position.line) + position.column + } + + pub fn char_to_position(&self, char_idx: usize) -> Position { + let char_idx = char_idx.min(self.len_chars()); + let line = self.rope.char_to_line(char_idx); + let line_start = self.rope.line_to_char(line); + let column = (char_idx - line_start).min(self.line_len_chars(line)); + Position { line, column } + } + + pub fn range_to_chars(&self, range: TextRange) -> (usize, usize) { + let normalized = range.normalized(); + ( + self.position_to_char(normalized.start), + self.position_to_char(normalized.end), + ) + } + + pub fn slice_range(&self, range: TextRange) -> String { + let (start, end) = self.range_to_chars(range); + self.rope.slice(start..end).to_string() + } + + pub fn insert(&mut self, position: Position, text: &str) -> Position { + let position = self.clamp_position(position); + let char_idx = self.position_to_char(position); + self.rope.insert(char_idx, text); + self.dirty = true; + advance_position(position, text) + } + + pub fn remove(&mut self, range: TextRange) -> String { + let normalized = range.normalized(); + let (start, end) = self.range_to_chars(normalized); + if start >= end { + return String::new(); + } + let deleted = self.rope.slice(start..end).to_string(); + self.rope.remove(start..end); + self.dirty = true; + deleted + } + + pub fn save(&mut self) -> Result<()> { + let path = self.path.clone().ok_or(LimeError::MissingPath)?; + self.save_as(path) + } + + pub fn save_as(&mut self, path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("lime-buffer"); + let temp_path = parent.join(format!(".{}.{}.lime-tmp", file_name, std::process::id())); + + { + let file = File::create(&temp_path)?; + let mut writer = BufWriter::new(file); + for chunk in self.rope.chunks() { + writer.write_all(chunk.as_bytes())?; + } + writer.flush()?; + writer.get_ref().sync_all()?; + } + + fs::rename(&temp_path, path)?; + self.path = Some(path.to_path_buf()); + self.dirty = false; + Ok(()) + } +} + +pub fn advance_position(start: Position, text: &str) -> Position { + let mut line = start.line; + let mut column = start.column; + let mut saw_cr = false; + + for ch in text.chars() { + match ch { + '\r' => saw_cr = true, + '\n' => { + line += 1; + column = 0; + saw_cr = false; + } + _ => { + if saw_cr { + column += 1; + saw_cr = false; + } + column += 1; + } + } + } + + if saw_cr { + column += 1; + } + + Position { line, column } +} diff --git a/crates/lime-core/src/command.rs b/crates/lime-core/src/command.rs new file mode 100644 index 0000000..b955bbd --- /dev/null +++ b/crates/lime-core/src/command.rs @@ -0,0 +1,31 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EditorCommand { + InsertChar(char), + InsertText(String), + Newline, + Backspace, + Delete, + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + MoveLineStart, + MoveLineEnd, + MoveFileStart, + MoveFileEnd, + PageUp, + PageDown, + Save, + Undo, + Redo, + Search(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandResult { + None, + Modified, + Saved, + CursorMoved, + NeedsPath, +} diff --git a/crates/lime-core/src/cursor.rs b/crates/lime-core/src/cursor.rs new file mode 100644 index 0000000..d62d944 --- /dev/null +++ b/crates/lime-core/src/cursor.rs @@ -0,0 +1,35 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub struct Position { + pub line: usize, + pub column: usize, +} + +impl Position { + pub const fn new(line: usize, column: usize) -> Self { + Self { line, column } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Cursor { + pub position: Position, + pub preferred_column: Option, +} + +impl Cursor { + pub const fn new(position: Position) -> Self { + Self { + position, + preferred_column: None, + } + } + + pub fn with_position(mut self, position: Position) -> Self { + self.position = position; + self + } + + pub fn clear_preferred_column(&mut self) { + self.preferred_column = None; + } +} diff --git a/crates/lime-core/src/edit.rs b/crates/lime-core/src/edit.rs new file mode 100644 index 0000000..92bafa9 --- /dev/null +++ b/crates/lime-core/src/edit.rs @@ -0,0 +1,703 @@ +use std::path::Path; + +use crate::buffer::{advance_position, TextBuffer}; +use crate::command::{CommandResult, EditorCommand}; +use crate::cursor::{Cursor, Position}; +use crate::error::{LimeError, Result}; +use crate::history::EditTransaction; +use crate::movement::DEFAULT_PAGE_LINES; +use crate::search::SearchMatch; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextRange { + pub start: Position, + pub end: Position, +} + +impl TextRange { + pub const fn new(start: Position, end: Position) -> Self { + Self { start, end } + } + + pub fn normalized(self) -> Self { + if self.start <= self.end { + self + } else { + Self { + start: self.end, + end: self.start, + } + } + } + + pub fn is_empty(self) -> bool { + self.start == self.end + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TextEdit { + Insert { + at: Position, + text: String, + }, + Delete { + range: TextRange, + deleted_text: String, + }, +} + +#[derive(Debug, Clone)] +pub struct Editor { + buffer: TextBuffer, + cursor: Cursor, + undo_stack: Vec, + redo_stack: Vec, + revision: u64, +} + +impl Default for Editor { + fn default() -> Self { + Self::new() + } +} + +impl Editor { + pub fn new() -> Self { + Self::from_buffer(TextBuffer::default()) + } + + pub fn from_text(text: &str) -> Self { + Self::from_buffer(TextBuffer::from_text(text)) + } + + pub fn from_file(path: impl AsRef) -> Result { + Ok(Self::from_buffer(TextBuffer::from_file(path)?)) + } + + pub fn from_buffer(buffer: TextBuffer) -> Self { + Self { + cursor: Cursor::default(), + buffer, + undo_stack: Vec::new(), + redo_stack: Vec::new(), + revision: 0, + } + } + + pub fn replace_buffer(&mut self, buffer: TextBuffer) { + self.buffer = buffer; + self.cursor = Cursor::default(); + self.undo_stack.clear(); + self.redo_stack.clear(); + self.revision = self.revision.wrapping_add(1); + } + + pub fn buffer(&self) -> &TextBuffer { + &self.buffer + } + + pub fn buffer_mut(&mut self) -> &mut TextBuffer { + &mut self.buffer + } + + pub fn cursor(&self) -> Cursor { + self.cursor + } + + pub fn set_cursor(&mut self, position: Position) { + self.cursor.position = self.buffer.clamp_position(position); + self.cursor.preferred_column = None; + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn is_dirty(&self) -> bool { + self.buffer.is_dirty() + } + + pub fn text(&self) -> String { + self.buffer.text() + } + + pub fn line_count(&self) -> usize { + self.buffer.line_count() + } + + pub fn line_len_chars(&self, line: usize) -> usize { + self.buffer.line_len_chars(line) + } + + pub fn line_text_without_ending(&self, line: usize) -> String { + self.buffer.line_text_without_ending(line) + } + + pub fn apply_command(&mut self, command: EditorCommand) -> Result { + match command { + EditorCommand::InsertChar(ch) => { + self.insert_char(ch); + Ok(CommandResult::Modified) + } + EditorCommand::InsertText(text) => { + self.insert_text(&text); + Ok(CommandResult::Modified) + } + EditorCommand::Newline => { + self.newline(); + Ok(CommandResult::Modified) + } + EditorCommand::Backspace => Ok(if self.backspace() { + CommandResult::Modified + } else { + CommandResult::None + }), + EditorCommand::Delete => Ok(if self.delete() { + CommandResult::Modified + } else { + CommandResult::None + }), + EditorCommand::MoveLeft => Ok(if self.move_left() { + CommandResult::CursorMoved + } else { + CommandResult::None + }), + EditorCommand::MoveRight => Ok(if self.move_right() { + CommandResult::CursorMoved + } else { + CommandResult::None + }), + EditorCommand::MoveUp => Ok(if self.move_up() { + CommandResult::CursorMoved + } else { + CommandResult::None + }), + EditorCommand::MoveDown => Ok(if self.move_down() { + CommandResult::CursorMoved + } else { + CommandResult::None + }), + EditorCommand::MoveLineStart => Ok(if self.move_line_start() { + CommandResult::CursorMoved + } else { + CommandResult::None + }), + EditorCommand::MoveLineEnd => Ok(if self.move_line_end() { + CommandResult::CursorMoved + } else { + CommandResult::None + }), + EditorCommand::MoveFileStart => Ok(if self.move_file_start() { + CommandResult::CursorMoved + } else { + CommandResult::None + }), + EditorCommand::MoveFileEnd => Ok(if self.move_file_end() { + CommandResult::CursorMoved + } else { + CommandResult::None + }), + EditorCommand::PageUp => Ok(if self.page_up(DEFAULT_PAGE_LINES) { + CommandResult::CursorMoved + } else { + CommandResult::None + }), + EditorCommand::PageDown => Ok(if self.page_down(DEFAULT_PAGE_LINES) { + CommandResult::CursorMoved + } else { + CommandResult::None + }), + EditorCommand::Save => match self.save() { + Ok(()) => Ok(CommandResult::Saved), + Err(LimeError::MissingPath) => Ok(CommandResult::NeedsPath), + Err(err) => Err(err), + }, + EditorCommand::Undo => Ok(if self.undo() { + CommandResult::Modified + } else { + CommandResult::None + }), + EditorCommand::Redo => Ok(if self.redo() { + CommandResult::Modified + } else { + CommandResult::None + }), + EditorCommand::Search(_) => Ok(CommandResult::None), + } + } + + pub fn insert_char(&mut self, ch: char) { + let mut buf = [0; 4]; + self.insert_text_grouped(ch.encode_utf8(&mut buf), true); + } + + pub fn insert_text(&mut self, text: &str) { + self.insert_text_grouped(text, false); + } + + fn insert_text_grouped(&mut self, text: &str, allow_grouping: bool) { + if text.is_empty() { + return; + } + + let before_cursor = self.cursor; + let at = self.cursor.position; + let after = self.buffer.insert(at, text); + self.cursor = Cursor::new(self.buffer.clamp_position(after)); + self.revision = self.revision.wrapping_add(1); + + let tx = EditTransaction::new( + vec![TextEdit::Insert { + at, + text: text.to_string(), + }], + before_cursor, + self.cursor, + ); + self.record_transaction(tx, allow_grouping && !text.contains(['\n', '\r'])); + } + + pub fn newline(&mut self) { + let line_ending = self.buffer.line_ending().as_str().to_string(); + self.insert_text_grouped(&line_ending, false); + } + + pub fn backspace(&mut self) -> bool { + let current = self.buffer.position_to_char(self.cursor.position); + if current == 0 { + return false; + } + + let start = self.buffer.char_to_position(current - 1); + self.delete_range_with_cursor( + TextRange::new(start, self.cursor.position), + self.cursor, + start, + ) + } + + pub fn delete(&mut self) -> bool { + let current = self.buffer.position_to_char(self.cursor.position); + if current >= self.buffer.len_chars() { + return false; + } + + let mut end_idx = current + 1; + if self.buffer.char(current) == Some('\r') && self.buffer.char(current + 1) == Some('\n') { + end_idx = current + 2; + } + let end = self.buffer.char_to_position(end_idx); + self.delete_range_with_cursor( + TextRange::new(self.cursor.position, end), + self.cursor, + self.cursor.position, + ) + } + + fn delete_range_with_cursor( + &mut self, + range: TextRange, + before_cursor: Cursor, + after_position: Position, + ) -> bool { + let normalized = range.normalized(); + if normalized.is_empty() { + return false; + } + + let deleted_text = self.buffer.remove(normalized); + if deleted_text.is_empty() { + return false; + } + + self.cursor = Cursor::new(self.buffer.clamp_position(after_position)); + self.revision = self.revision.wrapping_add(1); + let tx = EditTransaction::new( + vec![TextEdit::Delete { + range: normalized, + deleted_text, + }], + before_cursor, + self.cursor, + ); + self.record_transaction(tx, false); + true + } + + pub fn move_left(&mut self) -> bool { + let pos = self.cursor.position; + let next = if pos.column > 0 { + Position::new(pos.line, pos.column - 1) + } else if pos.line > 0 { + let previous_line = pos.line - 1; + Position::new(previous_line, self.buffer.line_len_chars(previous_line)) + } else { + return false; + }; + self.set_cursor(next); + true + } + + pub fn move_right(&mut self) -> bool { + let pos = self.cursor.position; + let line_len = self.buffer.line_len_chars(pos.line); + let next = if pos.column < line_len { + Position::new(pos.line, pos.column + 1) + } else if pos.line + 1 < self.buffer.line_count() { + Position::new(pos.line + 1, 0) + } else { + return false; + }; + self.set_cursor(next); + true + } + + pub fn move_up(&mut self) -> bool { + let pos = self.cursor.position; + if pos.line == 0 { + return false; + } + let preferred = self.cursor.preferred_column.unwrap_or(pos.column); + let target_line = pos.line - 1; + self.cursor.position = Position::new( + target_line, + preferred.min(self.buffer.line_len_chars(target_line)), + ); + self.cursor.preferred_column = Some(preferred); + true + } + + pub fn move_down(&mut self) -> bool { + let pos = self.cursor.position; + if pos.line + 1 >= self.buffer.line_count() { + return false; + } + let preferred = self.cursor.preferred_column.unwrap_or(pos.column); + let target_line = pos.line + 1; + self.cursor.position = Position::new( + target_line, + preferred.min(self.buffer.line_len_chars(target_line)), + ); + self.cursor.preferred_column = Some(preferred); + true + } + + pub fn move_line_start(&mut self) -> bool { + let pos = self.cursor.position; + if pos.column == 0 { + return false; + } + self.set_cursor(Position::new(pos.line, 0)); + true + } + + pub fn move_line_end(&mut self) -> bool { + let pos = self.cursor.position; + let end = self.buffer.line_len_chars(pos.line); + if pos.column == end { + return false; + } + self.set_cursor(Position::new(pos.line, end)); + true + } + + pub fn move_file_start(&mut self) -> bool { + if self.cursor.position == Position::new(0, 0) { + return false; + } + self.set_cursor(Position::new(0, 0)); + true + } + + pub fn move_file_end(&mut self) -> bool { + let last_line = self.buffer.line_count().saturating_sub(1); + let end = Position::new(last_line, self.buffer.line_len_chars(last_line)); + if self.cursor.position == end { + return false; + } + self.set_cursor(end); + true + } + + pub fn page_up(&mut self, lines: usize) -> bool { + let pos = self.cursor.position; + if pos.line == 0 { + return false; + } + let target_line = pos.line.saturating_sub(lines.max(1)); + let preferred = self.cursor.preferred_column.unwrap_or(pos.column); + self.cursor.position = Position::new( + target_line, + preferred.min(self.buffer.line_len_chars(target_line)), + ); + self.cursor.preferred_column = Some(preferred); + true + } + + pub fn page_down(&mut self, lines: usize) -> bool { + let pos = self.cursor.position; + let last_line = self.buffer.line_count().saturating_sub(1); + if pos.line >= last_line { + return false; + } + let target_line = (pos.line + lines.max(1)).min(last_line); + let preferred = self.cursor.preferred_column.unwrap_or(pos.column); + self.cursor.position = Position::new( + target_line, + preferred.min(self.buffer.line_len_chars(target_line)), + ); + self.cursor.preferred_column = Some(preferred); + true + } + + pub fn save(&mut self) -> Result<()> { + self.buffer.save() + } + + pub fn save_as(&mut self, path: impl AsRef) -> Result<()> { + self.buffer.save_as(path) + } + + pub fn undo(&mut self) -> bool { + let Some(tx) = self.undo_stack.pop() else { + return false; + }; + + for edit in tx.edits.iter().rev() { + self.apply_inverse_edit(edit); + } + self.cursor = tx.before_cursor; + self.buffer.set_dirty(true); + self.redo_stack.push(tx); + self.revision = self.revision.wrapping_add(1); + true + } + + pub fn redo(&mut self) -> bool { + let Some(tx) = self.redo_stack.pop() else { + return false; + }; + + for edit in &tx.edits { + self.apply_edit(edit); + } + self.cursor = tx.after_cursor; + self.buffer.set_dirty(true); + self.undo_stack.push(tx); + self.revision = self.revision.wrapping_add(1); + true + } + + fn apply_edit(&mut self, edit: &TextEdit) { + match edit { + TextEdit::Insert { at, text } => { + self.buffer.insert(*at, text); + } + TextEdit::Delete { range, .. } => { + self.buffer.remove(*range); + } + } + } + + fn apply_inverse_edit(&mut self, edit: &TextEdit) { + match edit { + TextEdit::Insert { at, text } => { + let end = advance_position(*at, text); + self.buffer.remove(TextRange::new(*at, end)); + } + TextEdit::Delete { + range, + deleted_text, + } => { + self.buffer.insert(range.start, deleted_text); + } + } + } + + fn record_transaction(&mut self, tx: EditTransaction, allow_grouping: bool) { + self.redo_stack.clear(); + + if allow_grouping { + if let Some(last) = self.undo_stack.last_mut() { + if let ( + [TextEdit::Insert { + at: _, + text: last_text, + }], + [TextEdit::Insert { at: _, text }], + ) = (last.edits.as_mut_slice(), tx.edits.as_slice()) + { + if last.after_cursor == tx.before_cursor + && !last_text.contains(['\n', '\r']) + && !text.contains(['\n', '\r']) + { + last_text.push_str(text); + last.after_cursor = tx.after_cursor; + return; + } + } + } + } + + self.undo_stack.push(tx); + } + + pub fn find_all(&self, query: &str) -> Vec { + if query.is_empty() { + return Vec::new(); + } + + let text = self.buffer.text(); + text.match_indices(query) + .map(|(byte_start, matched)| { + let start_char = text[..byte_start].chars().count(); + let end_char = start_char + matched.chars().count(); + SearchMatch { + range: TextRange::new( + self.buffer.char_to_position(start_char), + self.buffer.char_to_position(end_char), + ), + } + }) + .collect() + } + + pub fn search_next(&self, query: &str) -> Option { + let cursor_char = self.buffer.position_to_char(self.cursor.position); + let matches = self.find_all(query); + matches + .iter() + .find(|m| self.buffer.position_to_char(m.range.start) > cursor_char) + .cloned() + .or_else(|| matches.first().cloned()) + } + + pub fn go_to_line(&mut self, one_based_line: usize) -> bool { + let target = one_based_line.saturating_sub(1); + let target = target.min(self.buffer.line_count().saturating_sub(1)); + self.set_cursor(Position::new(target, 0)); + true + } + + pub fn undo_len(&self) -> usize { + self.undo_stack.len() + } + + pub fn redo_len(&self) -> usize { + self.redo_stack.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn insert_characters_groups_undo() { + let mut editor = Editor::new(); + editor.insert_char('h'); + editor.insert_char('i'); + + assert_eq!(editor.text(), "hi"); + assert_eq!(editor.undo_len(), 1); + assert!(editor.undo()); + assert_eq!(editor.text(), ""); + assert_eq!(editor.cursor().position, Position::new(0, 0)); + } + + #[test] + fn insert_newline() { + let mut editor = Editor::from_text("hello"); + editor.move_file_end(); + editor.newline(); + editor.insert_text("world"); + + assert_eq!(editor.text(), "hello\nworld"); + assert_eq!(editor.cursor().position, Position::new(1, 5)); + } + + #[test] + fn backspace_joins_lines() { + let mut editor = Editor::from_text("hello\nworld"); + editor.set_cursor(Position { line: 1, column: 0 }); + editor.backspace(); + + assert_eq!(editor.text(), "helloworld"); + assert_eq!(editor.cursor().position, Position { line: 0, column: 5 }); + } + + #[test] + fn delete_joins_lines() { + let mut editor = Editor::from_text("hello\nworld"); + editor.set_cursor(Position::new(0, 5)); + editor.delete(); + + assert_eq!(editor.text(), "helloworld"); + assert_eq!(editor.cursor().position, Position::new(0, 5)); + } + + #[test] + fn cursor_moves_across_lines() { + let mut editor = Editor::from_text("a\nbc"); + assert!(editor.move_right()); + assert_eq!(editor.cursor().position, Position::new(0, 1)); + assert!(editor.move_right()); + assert_eq!(editor.cursor().position, Position::new(1, 0)); + assert!(editor.move_left()); + assert_eq!(editor.cursor().position, Position::new(0, 1)); + } + + #[test] + fn vertical_movement_preserves_preferred_column() { + let mut editor = Editor::from_text("abcd\nx\nabcdef"); + editor.set_cursor(Position::new(0, 4)); + editor.move_down(); + assert_eq!(editor.cursor().position, Position::new(1, 1)); + editor.move_down(); + assert_eq!(editor.cursor().position, Position::new(2, 4)); + } + + #[test] + fn undo_redo_delete() { + let mut editor = Editor::from_text("abc"); + editor.set_cursor(Position::new(0, 1)); + editor.delete(); + assert_eq!(editor.text(), "ac"); + assert!(editor.undo()); + assert_eq!(editor.text(), "abc"); + assert!(editor.redo()); + assert_eq!(editor.text(), "ac"); + } + + #[test] + fn search_finds_matches() { + let editor = Editor::from_text("one two one"); + let matches = editor.find_all("one"); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].range.start, Position::new(0, 0)); + assert_eq!(matches[1].range.start, Position::new(0, 8)); + } + + #[test] + fn save_load_roundtrip() { + let dir = tempdir().unwrap(); + let path = dir.path().join("test.txt"); + let mut editor = Editor::from_text("hello\nworld"); + editor.save_as(&path).unwrap(); + assert!(!editor.is_dirty()); + + let loaded = Editor::from_file(&path).unwrap(); + assert_eq!(loaded.text(), "hello\nworld"); + } + + #[test] + fn unicode_text_moves_and_deletes() { + let mut editor = Editor::from_text("åß∂"); + editor.move_file_end(); + assert_eq!(editor.cursor().position, Position::new(0, 3)); + editor.backspace(); + assert_eq!(editor.text(), "åß"); + } +} diff --git a/crates/lime-core/src/error.rs b/crates/lime-core/src/error.rs new file mode 100644 index 0000000..6e76580 --- /dev/null +++ b/crates/lime-core/src/error.rs @@ -0,0 +1,21 @@ +use std::io; + +#[derive(thiserror::Error, Debug)] +pub enum LimeError { + #[error("file is too large: {size} bytes")] + FileTooLarge { size: u64 }, + + #[error("buffer has no file path")] + MissingPath, + + #[error("invalid cursor position")] + InvalidPosition, + + #[error("invalid text range")] + InvalidRange, + + #[error("io error: {0}")] + Io(#[from] io::Error), +} + +pub type Result = std::result::Result; diff --git a/crates/lime-core/src/file.rs b/crates/lime-core/src/file.rs new file mode 100644 index 0000000..ef7a92e --- /dev/null +++ b/crates/lime-core/src/file.rs @@ -0,0 +1,94 @@ +use std::fs; +use std::path::Path; + +use crate::error::Result; + +#[derive(Debug, Clone, Copy)] +pub struct FileOpenPolicy { + pub warn_threshold_bytes: u64, + pub confirm_threshold_bytes: u64, + pub hard_threshold_bytes: u64, + pub force: bool, +} + +impl Default for FileOpenPolicy { + fn default() -> Self { + Self { + warn_threshold_bytes: 5 * 1024 * 1024, + confirm_threshold_bytes: 25 * 1024 * 1024, + hard_threshold_bytes: 100 * 1024 * 1024, + force: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileOpenDecision { + Open { size: u64 }, + Warn { size: u64 }, + Confirm { size: u64 }, + Refuse { size: u64 }, +} + +impl FileOpenPolicy { + pub fn evaluate_size(&self, size: u64) -> FileOpenDecision { + if self.force { + return FileOpenDecision::Open { size }; + } + + if size > self.hard_threshold_bytes { + FileOpenDecision::Refuse { size } + } else if size > self.confirm_threshold_bytes { + FileOpenDecision::Confirm { size } + } else if size > self.warn_threshold_bytes { + FileOpenDecision::Warn { size } + } else { + FileOpenDecision::Open { size } + } + } + + pub fn evaluate_path(&self, path: &Path) -> Result { + let metadata = fs::metadata(path)?; + Ok(self.evaluate_size(metadata.len())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evaluates_thresholds() { + let policy = FileOpenPolicy::default(); + assert_eq!( + policy.evaluate_size(1024), + FileOpenDecision::Open { size: 1024 } + ); + assert!(matches!( + policy.evaluate_size(6 * 1024 * 1024), + FileOpenDecision::Warn { .. } + )); + assert!(matches!( + policy.evaluate_size(30 * 1024 * 1024), + FileOpenDecision::Confirm { .. } + )); + assert!(matches!( + policy.evaluate_size(101 * 1024 * 1024), + FileOpenDecision::Refuse { .. } + )); + } + + #[test] + fn force_opens_any_size() { + let policy = FileOpenPolicy { + force: true, + ..FileOpenPolicy::default() + }; + assert_eq!( + policy.evaluate_size(500 * 1024 * 1024), + FileOpenDecision::Open { + size: 500 * 1024 * 1024 + } + ); + } +} diff --git a/crates/lime-core/src/history.rs b/crates/lime-core/src/history.rs new file mode 100644 index 0000000..0d3460a --- /dev/null +++ b/crates/lime-core/src/history.rs @@ -0,0 +1,19 @@ +use crate::cursor::Cursor; +use crate::edit::TextEdit; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EditTransaction { + pub edits: Vec, + pub before_cursor: Cursor, + pub after_cursor: Cursor, +} + +impl EditTransaction { + pub fn new(edits: Vec, before_cursor: Cursor, after_cursor: Cursor) -> Self { + Self { + edits, + before_cursor, + after_cursor, + } + } +} diff --git a/crates/lime-core/src/lib.rs b/crates/lime-core/src/lib.rs new file mode 100644 index 0000000..8a172d2 --- /dev/null +++ b/crates/lime-core/src/lib.rs @@ -0,0 +1,20 @@ +pub mod buffer; +pub mod command; +pub mod cursor; +pub mod edit; +pub mod error; +pub mod file; +pub mod history; +pub mod movement; +pub mod search; +pub mod selection; + +pub use buffer::{LineEnding, TextBuffer}; +pub use command::{CommandResult, EditorCommand}; +pub use cursor::{Cursor, Position}; +pub use edit::{Editor, TextEdit, TextRange}; +pub use error::{LimeError, Result}; +pub use file::{FileOpenDecision, FileOpenPolicy}; +pub use history::EditTransaction; +pub use search::SearchMatch; +pub use selection::Selection; diff --git a/crates/lime-core/src/movement.rs b/crates/lime-core/src/movement.rs new file mode 100644 index 0000000..b291d10 --- /dev/null +++ b/crates/lime-core/src/movement.rs @@ -0,0 +1 @@ +pub const DEFAULT_PAGE_LINES: usize = 20; diff --git a/crates/lime-core/src/search.rs b/crates/lime-core/src/search.rs new file mode 100644 index 0000000..f0b83e6 --- /dev/null +++ b/crates/lime-core/src/search.rs @@ -0,0 +1,6 @@ +use crate::edit::TextRange; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchMatch { + pub range: TextRange, +} diff --git a/crates/lime-core/src/selection.rs b/crates/lime-core/src/selection.rs new file mode 100644 index 0000000..e76d716 --- /dev/null +++ b/crates/lime-core/src/selection.rs @@ -0,0 +1,12 @@ +use crate::edit::TextRange; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Selection { + pub range: TextRange, +} + +impl Selection { + pub const fn new(range: TextRange) -> Self { + Self { range } + } +} diff --git a/crates/lime-syntax/Cargo.toml b/crates/lime-syntax/Cargo.toml new file mode 100644 index 0000000..630c24b --- /dev/null +++ b/crates/lime-syntax/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "lime-syntax" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +tree-sitter.workspace = true +tree-sitter-rust.workspace = true +tree-sitter-javascript.workspace = true +tree-sitter-typescript.workspace = true +tree-sitter-python.workspace = true +tree-sitter-json.workspace = true diff --git a/crates/lime-syntax/src/highlighter.rs b/crates/lime-syntax/src/highlighter.rs new file mode 100644 index 0000000..158f95b --- /dev/null +++ b/crates/lime-syntax/src/highlighter.rs @@ -0,0 +1,524 @@ +use tree_sitter::{Node, Parser}; + +use crate::language::Language; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HighlightKind { + Normal, + Keyword, + String, + Comment, + Function, + TypeName, + Number, + Error, + Punctuation, + Heading, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HighlightSpan { + pub line_start: usize, + pub column_start: usize, + pub line_end: usize, + pub column_end: usize, + pub kind: HighlightKind, +} + +impl HighlightSpan { + pub const fn new( + line_start: usize, + column_start: usize, + line_end: usize, + column_end: usize, + kind: HighlightKind, + ) -> Self { + Self { + line_start, + column_start, + line_end, + column_end, + kind, + } + } + + pub fn intersects_line(&self, line: usize) -> bool { + self.line_start <= line && line <= self.line_end + } +} + +#[derive(Default)] +pub struct Highlighter { + cache: Option, +} + +#[derive(Clone)] +struct HighlightCache { + revision: u64, + language: Language, + spans: Vec, +} + +impl Highlighter { + pub fn new() -> Self { + Self::default() + } + + pub fn clear(&mut self) { + self.cache = None; + } + + pub fn highlight( + &mut self, + text: &str, + language: Language, + revision: u64, + ) -> Vec { + if let Some(cache) = &self.cache { + if cache.revision == revision && cache.language == language { + return cache.spans.clone(); + } + } + + let mut spans = if text.len() <= 2_000_000 { + self.highlight_with_tree_sitter(text, language) + .unwrap_or_else(|| lexical_highlight(text, language)) + } else { + lexical_highlight(text, language) + }; + + spans.sort_by_key(|span| { + ( + span.line_start, + span.column_start, + span.line_end, + span.column_end, + ) + }); + self.cache = Some(HighlightCache { + revision, + language, + spans: spans.clone(), + }); + spans + } + + fn highlight_with_tree_sitter( + &self, + text: &str, + language: Language, + ) -> Option> { + let mut parser = Parser::new(); + let grammar = grammar_for(language)?; + parser.set_language(&grammar).ok()?; + let tree = parser.parse(text, None)?; + let lines: Vec<&str> = text.lines().collect(); + let mut spans = Vec::new(); + collect_node_spans(tree.root_node(), &lines, &mut spans); + + if spans.is_empty() { + None + } else { + Some(spans) + } + } +} + +fn grammar_for(language: Language) -> Option { + match language { + Language::Rust => Some(tree_sitter_rust::LANGUAGE.into()), + Language::JavaScript => Some(tree_sitter_javascript::LANGUAGE.into()), + Language::TypeScript => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + Language::Python => Some(tree_sitter_python::LANGUAGE.into()), + Language::Json => Some(tree_sitter_json::LANGUAGE.into()), + Language::Toml | Language::Markdown | Language::PlainText => None, + } +} + +fn collect_node_spans(node: Node<'_>, lines: &[&str], spans: &mut Vec) { + if let Some(kind) = kind_for_node(node.kind()) { + if let Some(span) = span_from_node(node, lines, kind) { + spans.push(span); + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_node_spans(child, lines, spans); + } +} + +fn kind_for_node(kind: &str) -> Option { + if kind == "ERROR" { + return Some(HighlightKind::Error); + } + + if kind.contains("comment") { + return Some(HighlightKind::Comment); + } + + if kind.contains("string") || kind == "template_string" || kind == "raw_string_literal" { + return Some(HighlightKind::String); + } + + if kind.contains("number") + || kind.contains("integer") + || kind.contains("float") + || kind == "true" + || kind == "false" + || kind == "null" + || kind == "none" + { + return Some(HighlightKind::Number); + } + + if kind == "type_identifier" + || kind == "primitive_type" + || kind == "scoped_type_identifier" + || kind == "generic_type" + { + return Some(HighlightKind::TypeName); + } + + if is_keyword(kind) { + return Some(HighlightKind::Keyword); + } + + if matches!( + kind, + "(" | ")" | "{" | "}" | "[" | "]" | ":" | ";" | "," | "." | "->" | "=>" + ) { + return Some(HighlightKind::Punctuation); + } + + None +} + +fn is_keyword(kind: &str) -> bool { + matches!( + kind, + "as" | "async" + | "await" + | "break" + | "class" + | "const" + | "continue" + | "def" + | "delete" + | "do" + | "else" + | "enum" + | "export" + | "extends" + | "false" + | "fn" + | "for" + | "from" + | "function" + | "if" + | "impl" + | "import" + | "in" + | "interface" + | "let" + | "loop" + | "match" + | "mod" + | "mut" + | "new" + | "null" + | "pub" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "try" + | "type" + | "use" + | "var" + | "while" + | "yield" + | "where" + ) +} + +fn span_from_node(node: Node<'_>, lines: &[&str], kind: HighlightKind) -> Option { + let start = node.start_position(); + let end = node.end_position(); + if start.row >= lines.len() || end.row >= lines.len().max(1) { + return None; + } + + let start_col = byte_col_to_char_col(lines.get(start.row).copied().unwrap_or(""), start.column); + let end_col = byte_col_to_char_col(lines.get(end.row).copied().unwrap_or(""), end.column); + + if start.row == end.row && start_col == end_col { + return None; + } + + Some(HighlightSpan::new( + start.row, start_col, end.row, end_col, kind, + )) +} + +fn byte_col_to_char_col(line: &str, byte_col: usize) -> usize { + let mut byte = byte_col.min(line.len()); + while byte > 0 && !line.is_char_boundary(byte) { + byte -= 1; + } + line[..byte].chars().count() +} + +fn lexical_highlight(text: &str, language: Language) -> Vec { + let mut spans = Vec::new(); + + for (line_idx, line) in text.lines().enumerate() { + if language == Language::Markdown { + highlight_markdown_line(line_idx, line, &mut spans); + continue; + } + + highlight_comments(line_idx, line, language, &mut spans); + highlight_strings(line_idx, line, &mut spans); + highlight_numbers(line_idx, line, &mut spans); + highlight_keywords(line_idx, line, language, &mut spans); + } + + spans +} + +fn highlight_markdown_line(line_idx: usize, line: &str, spans: &mut Vec) { + let trimmed = line.trim_start(); + let indent = line.len() - trimmed.len(); + if trimmed.starts_with('#') { + spans.push(HighlightSpan::new( + line_idx, + indent, + line_idx, + line.chars().count(), + HighlightKind::Heading, + )); + } else if trimmed.starts_with('>') { + spans.push(HighlightSpan::new( + line_idx, + indent, + line_idx, + line.chars().count(), + HighlightKind::Comment, + )); + } + + highlight_strings(line_idx, line, spans); +} + +fn highlight_comments( + line_idx: usize, + line: &str, + language: Language, + spans: &mut Vec, +) { + let marker = match language { + Language::Rust | Language::JavaScript | Language::TypeScript => Some("//"), + Language::Python | Language::Toml => Some("#"), + _ => None, + }; + + if let Some(marker) = marker { + if let Some(byte_idx) = line.find(marker) { + let col = line[..byte_idx].chars().count(); + spans.push(HighlightSpan::new( + line_idx, + col, + line_idx, + line.chars().count(), + HighlightKind::Comment, + )); + } + } +} + +fn highlight_strings(line_idx: usize, line: &str, spans: &mut Vec) { + let chars: Vec = line.chars().collect(); + let mut i = 0; + while i < chars.len() { + let quote = chars[i]; + if quote != '"' && quote != '\'' && quote != '`' { + i += 1; + continue; + } + + let start = i; + i += 1; + let mut escaped = false; + while i < chars.len() { + let ch = chars[i]; + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == quote { + i += 1; + break; + } + i += 1; + } + + spans.push(HighlightSpan::new( + line_idx, + start, + line_idx, + i.min(chars.len()), + HighlightKind::String, + )); + } +} + +fn highlight_numbers(line_idx: usize, line: &str, spans: &mut Vec) { + let chars: Vec = line.chars().collect(); + let mut i = 0; + while i < chars.len() { + if !chars[i].is_ascii_digit() { + i += 1; + continue; + } + let start = i; + while i < chars.len() + && (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '.') + { + i += 1; + } + spans.push(HighlightSpan::new( + line_idx, + start, + line_idx, + i, + HighlightKind::Number, + )); + } +} + +fn highlight_keywords( + line_idx: usize, + line: &str, + language: Language, + spans: &mut Vec, +) { + let keywords = keywords_for(language); + if keywords.is_empty() { + return; + } + + let chars: Vec = line.chars().collect(); + let mut i = 0; + while i < chars.len() { + if !is_word_start(chars[i]) { + i += 1; + continue; + } + let start = i; + i += 1; + while i < chars.len() && is_word_continue(chars[i]) { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + if keywords.contains(&word.as_str()) { + spans.push(HighlightSpan::new( + line_idx, + start, + line_idx, + i, + HighlightKind::Keyword, + )); + } + } +} + +fn is_word_start(ch: char) -> bool { + ch == '_' || ch.is_ascii_alphabetic() +} + +fn is_word_continue(ch: char) -> bool { + ch == '_' || ch.is_ascii_alphanumeric() +} + +fn keywords_for(language: Language) -> &'static [&'static str] { + match language { + Language::Rust => &[ + "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", + "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", + "move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", + "trait", "true", "type", "unsafe", "use", "where", "while", + ], + Language::JavaScript | Language::TypeScript => &[ + "async", + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "export", + "extends", + "false", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "interface", + "let", + "new", + "null", + "return", + "super", + "switch", + "this", + "throw", + "true", + "try", + "type", + "typeof", + "var", + "void", + "while", + "yield", + ], + Language::Python => &[ + "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", + "elif", "else", "except", "False", "finally", "for", "from", "global", "if", "import", + "in", "is", "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "return", + "True", "try", "while", "with", "yield", + ], + Language::Json | Language::Toml | Language::Markdown | Language::PlainText => &[], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lexical_rust_keywords() { + let spans = lexical_highlight("fn main() { let n = 1; }", Language::Rust); + assert!(spans.iter().any(|span| span.kind == HighlightKind::Keyword)); + assert!(spans.iter().any(|span| span.kind == HighlightKind::Number)); + } + + #[test] + fn detects_markdown_heading() { + let spans = lexical_highlight("# Hello", Language::Markdown); + assert_eq!(spans[0].kind, HighlightKind::Heading); + } +} diff --git a/crates/lime-syntax/src/language.rs b/crates/lime-syntax/src/language.rs new file mode 100644 index 0000000..c23b97c --- /dev/null +++ b/crates/lime-syntax/src/language.rs @@ -0,0 +1,90 @@ +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Language { + Rust, + JavaScript, + TypeScript, + Python, + Json, + Toml, + Markdown, + PlainText, +} + +impl Language { + pub fn detect_path(path: Option<&Path>) -> Self { + let Some(path) = path else { + return Self::PlainText; + }; + + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + match file_name { + "Cargo.toml" | "config.toml" | "lime.toml" => return Self::Toml, + "README" | "README.md" => return Self::Markdown, + _ => {} + } + + match path.extension().and_then(|ext| ext.to_str()).unwrap_or("") { + "rs" => Self::Rust, + "js" | "jsx" | "mjs" | "cjs" => Self::JavaScript, + "ts" | "tsx" | "mts" | "cts" => Self::TypeScript, + "py" | "pyw" => Self::Python, + "json" | "jsonc" => Self::Json, + "toml" => Self::Toml, + "md" | "markdown" => Self::Markdown, + _ => Self::PlainText, + } + } + + pub const fn name(self) -> &'static str { + match self { + Self::Rust => "rust", + Self::JavaScript => "javascript", + Self::TypeScript => "typescript", + Self::Python => "python", + Self::Json => "json", + Self::Toml => "toml", + Self::Markdown => "markdown", + Self::PlainText => "text", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn detects_common_extensions() { + assert_eq!( + Language::detect_path(Some(Path::new("main.rs"))), + Language::Rust + ); + assert_eq!( + Language::detect_path(Some(Path::new("app.ts"))), + Language::TypeScript + ); + assert_eq!( + Language::detect_path(Some(Path::new("script.py"))), + Language::Python + ); + assert_eq!( + Language::detect_path(Some(Path::new("README.md"))), + Language::Markdown + ); + } + + #[test] + fn unknown_is_plain_text() { + assert_eq!( + Language::detect_path(Some(Path::new("unknown.blob"))), + Language::PlainText + ); + assert_eq!(Language::detect_path(None), Language::PlainText); + } +} diff --git a/crates/lime-syntax/src/lib.rs b/crates/lime-syntax/src/lib.rs new file mode 100644 index 0000000..cd1529d --- /dev/null +++ b/crates/lime-syntax/src/lib.rs @@ -0,0 +1,7 @@ +pub mod highlighter; +pub mod language; +pub mod theme; + +pub use highlighter::{HighlightKind, HighlightSpan, Highlighter}; +pub use language::Language; +pub use theme::SyntaxTheme; diff --git a/crates/lime-syntax/src/theme.rs b/crates/lime-syntax/src/theme.rs new file mode 100644 index 0000000..acb1884 --- /dev/null +++ b/crates/lime-syntax/src/theme.rs @@ -0,0 +1,10 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SyntaxTheme { + pub name: &'static str, +} + +impl Default for SyntaxTheme { + fn default() -> Self { + Self { name: "lime-dark" } + } +} diff --git a/crates/lime-ui/Cargo.toml b/crates/lime-ui/Cargo.toml new file mode 100644 index 0000000..733a4f3 --- /dev/null +++ b/crates/lime-ui/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "lime-ui" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +crossterm.workspace = true +ratatui.workspace = true +ignore.workspace = true +serde.workspace = true +toml.workspace = true +dirs.workspace = true +unicode-width.workspace = true +lime-core = { path = "../lime-core" } +lime-syntax = { path = "../lime-syntax" } diff --git a/crates/lime-ui/src/app.rs b/crates/lime-ui/src/app.rs new file mode 100644 index 0000000..affe7af --- /dev/null +++ b/crates/lime-ui/src/app.rs @@ -0,0 +1,756 @@ +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use lime_core::{ + CommandResult, Editor, EditorCommand, FileOpenDecision, FileOpenPolicy, TextBuffer, +}; +use lime_syntax::{HighlightSpan, Highlighter, Language}; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; +use ratatui::Frame; + +use crate::command_bar::render_help_bar; +use crate::config::Config; +use crate::editor_view::{gutter_width, render_editor, Viewport}; +use crate::file_picker::FilePickerState; +use crate::input::{map_editing_key, AppAction}; +use crate::layout::{app_layout, centered_rect}; +use crate::prompt::{ConfirmKind, ConfirmState, PromptKind, PromptState}; +use crate::status_bar::{render_status_bar, StatusInfo}; +use crate::terminal::TerminalSession; +use crate::theme::UiTheme; + +#[derive(Debug, Clone)] +pub struct AppOptions { + pub path: Option, + pub force: bool, + pub config_path: Option, +} + +impl AppOptions { + pub fn new(path: Option, force: bool, config_path: Option) -> Self { + Self { + path, + force, + config_path, + } + } +} + +#[derive(Debug, Clone)] +enum Mode { + Editing, + FilePicker(FilePickerState), + Prompt(PromptState), + Confirm(ConfirmState), +} + +pub struct App { + editor: Editor, + config: Config, + theme: UiTheme, + mode: Mode, + viewport: Viewport, + root: PathBuf, + should_quit: bool, + status_message: Option, + status_at: Option, + file_policy: FileOpenPolicy, + highlighter: Highlighter, + language: Language, + highlights: Vec, + highlighted_revision: Option, + highlighted_language: Language, +} + +impl App { + pub fn new(options: AppOptions) -> Result { + let (config, config_warning) = Config::load(options.config_path.as_deref()); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let mut app = Self { + editor: Editor::new(), + config, + theme: UiTheme::default(), + mode: Mode::Editing, + viewport: Viewport::default(), + root: cwd.clone(), + should_quit: false, + status_message: config_warning, + status_at: Some(Instant::now()), + file_policy: FileOpenPolicy { + force: options.force, + ..FileOpenPolicy::default() + }, + highlighter: Highlighter::new(), + language: Language::PlainText, + highlights: Vec::new(), + highlighted_revision: None, + highlighted_language: Language::PlainText, + }; + + if let Some(path) = options.path { + app.open_initial_path(path, &cwd)?; + } + + app.refresh_language(); + app.refresh_highlights(); + Ok(app) + } + + fn open_initial_path(&mut self, path: PathBuf, cwd: &Path) -> Result<()> { + if path.is_dir() { + self.root = path.canonicalize().unwrap_or(path); + self.mode = Mode::FilePicker(FilePickerState::new(&self.root)); + return Ok(()); + } + + if let Some(parent) = path.parent() { + if parent.exists() { + self.root = cwd.to_path_buf(); + } + } + + if !path.exists() { + let mut buffer = TextBuffer::from_text(""); + buffer.set_path(path); + self.editor.replace_buffer(buffer); + self.set_status("New file"); + return Ok(()); + } + + match self.evaluate_open_path(&path)? { + OpenEvaluation::Open => self.open_file_unchecked(&path), + OpenEvaluation::Prompt(size) => { + self.mode = Mode::Confirm(ConfirmState::new( + ConfirmKind::OpenLarge { path, size }, + format!( + "This file is {}. Lime may be slower with very large files. Open anyway? [y/N]", + format_bytes(size) + ), + )); + Ok(()) + } + OpenEvaluation::Refuse(size) => { + self.set_status(format!( + "Refused to open {} file. Re-run with --force to open it.", + format_bytes(size) + )); + Ok(()) + } + } + } + + fn set_status(&mut self, message: impl Into) { + self.status_message = Some(message.into()); + self.status_at = Some(Instant::now()); + } + + fn maybe_clear_status(&mut self) { + if self + .status_at + .is_some_and(|instant| instant.elapsed() > Duration::from_secs(5)) + { + self.status_message = None; + self.status_at = None; + } + } + + pub fn run(&mut self) -> Result<()> { + let mut terminal = TerminalSession::enter()?; + + while !self.should_quit { + self.maybe_clear_status(); + self.refresh_highlights(); + terminal.terminal_mut().draw(|frame| self.draw(frame))?; + + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => self.handle_key(key)?, + Event::Resize(_, _) => {} + _ => {} + } + } + } + + Ok(()) + } + + fn draw(&mut self, frame: &mut Frame<'_>) { + let area = frame.area(); + let layout = app_layout(area); + let total_lines = self.editor.line_count(); + let gutter = gutter_width(total_lines, self.config.show_line_numbers); + let text_width = layout.editor.width.saturating_sub(gutter) as usize; + self.viewport.ensure_cursor_visible( + self.editor.cursor().position, + total_lines, + layout.editor.height as usize, + text_width, + ); + + render_editor( + frame, + layout.editor, + &self.editor, + &self.viewport, + &self.config, + &self.theme, + &self.highlights, + ); + + let file_name = self.file_label(); + render_status_bar( + frame, + layout.status, + &self.theme, + StatusInfo { + file_name: &file_name, + language: self.language.name(), + position: self.editor.cursor().position, + dirty: self.editor.is_dirty(), + message: self.status_message.as_deref(), + }, + ); + render_help_bar(frame, layout.help, &self.theme); + + match &self.mode { + Mode::Editing => {} + Mode::FilePicker(state) => self.render_file_picker(frame, area, state), + Mode::Prompt(state) => self.render_prompt(frame, area, state), + Mode::Confirm(state) => self.render_confirm(frame, area, state), + } + } + + fn handle_key(&mut self, key: KeyEvent) -> Result<()> { + match &mut self.mode { + Mode::Editing => self.handle_editing_key(key), + Mode::FilePicker(_) => self.handle_file_picker_key(key), + Mode::Prompt(_) => self.handle_prompt_key(key), + Mode::Confirm(_) => self.handle_confirm_key(key), + } + } + + fn handle_editing_key(&mut self, key: KeyEvent) -> Result<()> { + let action = map_editing_key(key); + self.handle_action(action) + } + + fn handle_action(&mut self, action: AppAction) -> Result<()> { + match action { + AppAction::Editor(command) => self.apply_editor_command(command), + AppAction::Save => self.save_current(), + AppAction::Quit => self.request_quit(), + AppAction::OpenFilePicker => { + self.mode = Mode::FilePicker(FilePickerState::new(&self.root)); + Ok(()) + } + AppAction::OpenGoToLine => { + self.mode = Mode::Prompt(PromptState::new(PromptKind::GoToLine, "Go to line")); + Ok(()) + } + AppAction::OpenSearch => { + self.mode = Mode::Prompt(PromptState::new(PromptKind::Search, "Search")); + Ok(()) + } + AppAction::ClosePopup | AppAction::Cancel => { + self.mode = Mode::Editing; + Ok(()) + } + AppAction::Confirm | AppAction::None => Ok(()), + } + } + + fn apply_editor_command(&mut self, command: EditorCommand) -> Result<()> { + let command = match command { + EditorCommand::InsertText(text) if text == "\t" && self.config.insert_spaces => { + EditorCommand::InsertText(" ".repeat(self.config.tab_width)) + } + other => other, + }; + + match self.editor.apply_command(command)? { + CommandResult::Saved => self.set_status("Saved"), + CommandResult::NeedsPath => { + self.mode = Mode::Prompt(PromptState::new(PromptKind::SaveAs, "Save as")); + } + CommandResult::Modified => { + self.refresh_language(); + } + CommandResult::CursorMoved | CommandResult::None => {} + } + Ok(()) + } + + fn save_current(&mut self) -> Result<()> { + match self.editor.save() { + Ok(()) => { + self.set_status("Saved"); + Ok(()) + } + Err(lime_core::LimeError::MissingPath) => { + self.mode = Mode::Prompt(PromptState::new(PromptKind::SaveAs, "Save as")); + Ok(()) + } + Err(err) => { + self.set_status(format!("Save failed: {err}")); + Ok(()) + } + } + } + + fn save_then_quit(&mut self) -> Result<()> { + match self.editor.save() { + Ok(()) => { + self.set_status("Saved"); + self.should_quit = true; + } + Err(lime_core::LimeError::MissingPath) => { + self.mode = Mode::Prompt(PromptState::new(PromptKind::SaveAsThenQuit, "Save as")); + } + Err(err) => self.set_status(format!("Save failed: {err}")), + } + Ok(()) + } + + fn save_then_open(&mut self, path: PathBuf) -> Result<()> { + match self.editor.save() { + Ok(()) => { + self.set_status("Saved"); + self.open_with_policy_or_prompt(path)?; + } + Err(lime_core::LimeError::MissingPath) => { + self.mode = Mode::Prompt(PromptState::new( + PromptKind::SaveAsThenOpen { path }, + "Save as", + )); + } + Err(err) => self.set_status(format!("Save failed: {err}")), + } + Ok(()) + } + + fn request_quit(&mut self) -> Result<()> { + if self.editor.is_dirty() { + self.mode = Mode::Confirm(ConfirmState::new( + ConfirmKind::QuitDirty, + "Unsaved changes. Save before quitting? [s]ave [d]iscard [c]ancel", + )); + } else { + self.should_quit = true; + } + Ok(()) + } + + fn handle_file_picker_key(&mut self, key: KeyEvent) -> Result<()> { + let mut open_path = None; + let mut close = false; + + if let Mode::FilePicker(state) = &mut self.mode { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Esc => close = true, + KeyCode::Char('c') if ctrl => close = true, + KeyCode::Char(ch) if !ctrl => state.input_char(ch), + KeyCode::Backspace => state.backspace(), + KeyCode::Up => state.move_up(), + KeyCode::Down => state.move_down(), + KeyCode::Enter => open_path = state.selected_path(), + _ => {} + } + } + + if close { + self.mode = Mode::Editing; + } + + if let Some(path) = open_path { + self.request_open_file(path)?; + } + + Ok(()) + } + + fn handle_prompt_key(&mut self, key: KeyEvent) -> Result<()> { + let mut submit = false; + let mut cancel = false; + + if let Mode::Prompt(prompt) = &mut self.mode { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Esc => cancel = true, + KeyCode::Char('c') if ctrl => cancel = true, + KeyCode::Enter => submit = true, + KeyCode::Backspace => { + prompt.input.pop(); + } + KeyCode::Char(ch) if !ctrl => prompt.input.push(ch), + _ => {} + } + } + + if cancel { + self.mode = Mode::Editing; + return Ok(()); + } + + if submit { + let prompt = match std::mem::replace(&mut self.mode, Mode::Editing) { + Mode::Prompt(prompt) => prompt, + other => { + self.mode = other; + return Ok(()); + } + }; + self.submit_prompt(prompt)?; + } + + Ok(()) + } + + fn submit_prompt(&mut self, prompt: PromptState) -> Result<()> { + match prompt.kind { + PromptKind::SaveAs => { + self.submit_save_as(&prompt.input)?; + } + PromptKind::SaveAsThenQuit => { + if self.submit_save_as(&prompt.input)? { + self.should_quit = true; + } + } + PromptKind::SaveAsThenOpen { path } => { + if self.submit_save_as(&prompt.input)? { + self.open_with_policy_or_prompt(path)?; + } + } + PromptKind::GoToLine => { + if let Ok(line) = prompt.input.trim().parse::() { + self.editor.go_to_line(line); + } else { + self.set_status("Invalid line number"); + } + } + PromptKind::Search => { + let query = prompt.input; + if let Some(found) = self.editor.search_next(&query) { + self.editor.set_cursor(found.range.start); + self.set_status(format!("Found: {query}")); + } else { + self.set_status(format!("Not found: {query}")); + } + } + } + Ok(()) + } + + fn submit_save_as(&mut self, input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + self.set_status("Save cancelled: empty path"); + return Ok(false); + } + + let path = expand_tilde(input); + match self.editor.save_as(&path) { + Ok(()) => { + self.refresh_language(); + self.set_status(format!("Saved {}", path.display())); + Ok(true) + } + Err(err) => { + self.set_status(format!("Save failed: {err}")); + Ok(false) + } + } + } + + fn handle_confirm_key(&mut self, key: KeyEvent) -> Result<()> { + let confirm = match &self.mode { + Mode::Confirm(confirm) => confirm.clone(), + _ => return Ok(()), + }; + + match confirm.kind { + ConfirmKind::QuitDirty => match key.code { + KeyCode::Char('s') | KeyCode::Char('S') => { + self.mode = Mode::Editing; + self.save_then_quit()?; + } + KeyCode::Char('d') | KeyCode::Char('D') => self.should_quit = true, + KeyCode::Char('c') | KeyCode::Char('C') | KeyCode::Esc => self.mode = Mode::Editing, + _ => {} + }, + ConfirmKind::OpenDirty { path } => match key.code { + KeyCode::Char('s') | KeyCode::Char('S') => { + self.mode = Mode::Editing; + self.save_then_open(path)?; + } + KeyCode::Char('d') | KeyCode::Char('D') => { + self.mode = Mode::Editing; + self.open_with_policy_or_prompt(path)?; + } + KeyCode::Char('c') | KeyCode::Char('C') | KeyCode::Esc => self.mode = Mode::Editing, + _ => {} + }, + ConfirmKind::OpenLarge { path, .. } => match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + self.mode = Mode::Editing; + self.open_file_unchecked(&path)?; + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => self.mode = Mode::Editing, + _ => {} + }, + } + + Ok(()) + } + + fn request_open_file(&mut self, path: PathBuf) -> Result<()> { + if self.editor.is_dirty() { + self.mode = Mode::Confirm(ConfirmState::new( + ConfirmKind::OpenDirty { path }, + "Unsaved changes. Save before opening another file? [s]ave [d]iscard [c]ancel", + )); + } else { + self.open_with_policy_or_prompt(path)?; + } + Ok(()) + } + + fn open_with_policy_or_prompt(&mut self, path: PathBuf) -> Result<()> { + match self.evaluate_open_path(&path)? { + OpenEvaluation::Open => self.open_file_unchecked(&path)?, + OpenEvaluation::Prompt(size) => { + self.mode = Mode::Confirm(ConfirmState::new( + ConfirmKind::OpenLarge { path, size }, + format!( + "This file is {}. Lime may be slower with very large files. Open anyway? [y/N]", + format_bytes(size) + ), + )); + } + OpenEvaluation::Refuse(size) => { + self.mode = Mode::Editing; + self.set_status(format!( + "Refused to open {} file. Re-run with --force to open it.", + format_bytes(size) + )); + } + } + Ok(()) + } + + fn evaluate_open_path(&self, path: &Path) -> Result { + if !path.exists() { + return Ok(OpenEvaluation::Open); + } + + match self.file_policy.evaluate_path(path)? { + FileOpenDecision::Open { .. } => Ok(OpenEvaluation::Open), + FileOpenDecision::Warn { size } | FileOpenDecision::Confirm { size } => { + if self.config.confirm_large_files { + Ok(OpenEvaluation::Prompt(size)) + } else { + Ok(OpenEvaluation::Open) + } + } + FileOpenDecision::Refuse { size } => Ok(OpenEvaluation::Refuse(size)), + } + } + + fn open_file_unchecked(&mut self, path: &Path) -> Result<()> { + let buffer = if path.exists() { + match TextBuffer::from_file(path) { + Ok(buffer) => buffer, + Err(err) => { + self.set_status(format!("Could not open {}: {err}", path.display())); + return Ok(()); + } + } + } else { + TextBuffer::from_text_with_path("", path) + }; + self.editor.replace_buffer(buffer); + self.mode = Mode::Editing; + self.viewport = Viewport::default(); + self.refresh_language(); + self.highlighter.clear(); + self.refresh_highlights(); + self.set_status(format!("Opened {}", path.display())); + Ok(()) + } + + fn refresh_language(&mut self) { + let language = Language::detect_path(self.editor.buffer().path()); + if language != self.language { + self.language = language; + self.highlighted_revision = None; + self.highlighter.clear(); + } + } + + fn refresh_highlights(&mut self) { + let revision = self.editor.revision(); + if self.highlighted_revision == Some(revision) && self.highlighted_language == self.language + { + return; + } + + let text = self.editor.text(); + self.highlights = self.highlighter.highlight(&text, self.language, revision); + self.highlighted_revision = Some(revision); + self.highlighted_language = self.language; + } + + fn file_label(&self) -> String { + self.editor + .buffer() + .path() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .map(str::to_string) + .unwrap_or_else(|| "[untitled]".to_string()) + } + + fn render_file_picker(&self, frame: &mut Frame<'_>, area: Rect, state: &FilePickerState) { + let popup = centered_rect(72, 70, area); + frame.render_widget(Clear, popup); + let block = Block::default() + .title(" Open File ") + .borders(Borders::ALL) + .border_style(Style::default().fg(self.theme.popup_border)) + .style(self.theme.normal().bg(self.theme.popup_bg)); + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(1)]) + .split(inner); + + let search = Paragraph::new(format!("search: {}", state.query)) + .style(self.theme.normal().bg(self.theme.popup_bg)); + frame.render_widget(search, chunks[0]); + + let visible_count = chunks[1].height as usize; + let selected = state.selected.min(state.matches.len().saturating_sub(1)); + let start = selected.saturating_sub(visible_count.saturating_sub(1)); + let items: Vec> = state + .matches + .iter() + .skip(start) + .take(visible_count) + .map(|m| { + ListItem::new(Line::from(vec![ + Span::styled(" ", self.theme.normal().bg(self.theme.popup_bg)), + Span::raw(m.display.clone()), + ])) + .style(self.theme.normal().bg(self.theme.popup_bg)) + }) + .collect(); + + let mut list_state = ListState::default(); + if !items.is_empty() { + list_state.select(Some(selected - start)); + } + + let list = List::new(items) + .highlight_style( + Style::default() + .fg(self.theme.background) + .bg(self.theme.heading) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + frame.render_stateful_widget(list, chunks[1], &mut list_state); + } + + fn render_prompt(&self, frame: &mut Frame<'_>, area: Rect, state: &PromptState) { + let popup = centered_rect(60, 20, area); + frame.render_widget(Clear, popup); + let block = Block::default() + .title(format!(" {} ", state.title)) + .borders(Borders::ALL) + .border_style(Style::default().fg(self.theme.popup_border)) + .style(self.theme.normal().bg(self.theme.popup_bg)); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let label = match state.kind { + PromptKind::SaveAs | PromptKind::SaveAsThenQuit | PromptKind::SaveAsThenOpen { .. } => { + "path" + } + PromptKind::GoToLine => "line", + PromptKind::Search => "query", + }; + let paragraph = Paragraph::new(format!("{label}: {}", state.input)) + .style(self.theme.normal().bg(self.theme.popup_bg)); + frame.render_widget(paragraph, inner); + let cursor_x = inner + .x + .saturating_add(label.len() as u16) + .saturating_add(2) + .saturating_add(state.input.chars().count() as u16) + .min(inner.x.saturating_add(inner.width.saturating_sub(1))); + frame.set_cursor_position((cursor_x, inner.y)); + } + + fn render_confirm(&self, frame: &mut Frame<'_>, area: Rect, state: &ConfirmState) { + let popup = centered_rect(64, 24, area); + frame.render_widget(Clear, popup); + let block = Block::default() + .title(" Confirm ") + .borders(Borders::ALL) + .border_style(Style::default().fg(self.theme.popup_border)) + .style(self.theme.normal().bg(self.theme.popup_bg)); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let paragraph = Paragraph::new(state.message.clone()) + .style(self.theme.normal().bg(self.theme.popup_bg)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, inner); + } +} + +#[derive(Debug, Clone, Copy)] +enum OpenEvaluation { + Open, + Prompt(u64), + Refuse(u64), +} + +pub fn run_app(options: AppOptions) -> Result<()> { + let mut app = App::new(options)?; + app.run() +} + +fn format_bytes(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + let bytes_f = bytes as f64; + if bytes_f >= GB { + format!("{:.1} GB", bytes_f / GB) + } else if bytes_f >= MB { + format!("{:.1} MB", bytes_f / MB) + } else if bytes_f >= KB { + format!("{:.1} KB", bytes_f / KB) + } else { + format!("{bytes} B") + } +} + +fn expand_tilde(input: &str) -> PathBuf { + if input == "~" { + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(input)); + } + if let Some(rest) = input.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + PathBuf::from(input) +} diff --git a/crates/lime-ui/src/command_bar.rs b/crates/lime-ui/src/command_bar.rs new file mode 100644 index 0000000..299ffa4 --- /dev/null +++ b/crates/lime-ui/src/command_bar.rs @@ -0,0 +1,46 @@ +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::{layout::Rect, Frame}; + +use crate::theme::UiTheme; + +pub fn render_help_bar(frame: &mut Frame<'_>, area: Rect, theme: &UiTheme) { + let line = Line::from(vec![ + Span::styled( + " Ctrl-S ", + theme.normal().fg(theme.heading).bg(theme.help_bg), + ), + Span::styled("Save ", theme.normal().fg(theme.help_fg).bg(theme.help_bg)), + Span::styled( + " Ctrl-F ", + theme.normal().fg(theme.heading).bg(theme.help_bg), + ), + Span::styled("Files ", theme.normal().fg(theme.help_fg).bg(theme.help_bg)), + Span::styled( + " Ctrl-R ", + theme.normal().fg(theme.heading).bg(theme.help_bg), + ), + Span::styled( + "Search ", + theme.normal().fg(theme.help_fg).bg(theme.help_bg), + ), + Span::styled( + " Ctrl-G ", + theme.normal().fg(theme.heading).bg(theme.help_bg), + ), + Span::styled( + "Go line ", + theme.normal().fg(theme.help_fg).bg(theme.help_bg), + ), + Span::styled( + " Ctrl-Q ", + theme.normal().fg(theme.heading).bg(theme.help_bg), + ), + Span::styled("Quit", theme.normal().fg(theme.help_fg).bg(theme.help_bg)), + ]); + + frame.render_widget( + Paragraph::new(line).style(theme.normal().bg(theme.help_bg)), + area, + ); +} diff --git a/crates/lime-ui/src/config.rs b/crates/lime-ui/src/config.rs new file mode 100644 index 0000000..cddc303 --- /dev/null +++ b/crates/lime-ui/src/config.rs @@ -0,0 +1,74 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub theme: String, + pub tab_width: usize, + pub insert_spaces: bool, + pub show_line_numbers: bool, + pub confirm_large_files: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + theme: "lime-dark".to_string(), + tab_width: 4, + insert_spaces: true, + show_line_numbers: true, + confirm_large_files: true, + } + } +} + +impl Config { + pub fn load(path: Option<&Path>) -> (Self, Option) { + let Some(path) = path.map(Path::to_path_buf).or_else(default_config_path) else { + return (Self::default(), None); + }; + + if !path.exists() { + return (Self::default(), None); + } + + match fs::read_to_string(&path) { + Ok(text) => match toml::from_str::(&text) { + Ok(config) => (config.normalized(), None), + Err(err) => ( + Self::default(), + Some(format!("Invalid config {}: {err}", path.display())), + ), + }, + Err(err) => ( + Self::default(), + Some(format!("Could not read config {}: {err}", path.display())), + ), + } + } + + fn normalized(mut self) -> Self { + if self.tab_width == 0 || self.tab_width > 16 { + self.tab_width = 4; + } + if self.theme.trim().is_empty() { + self.theme = "lime-dark".to_string(); + } + self + } +} + +pub fn default_config_path() -> Option { + if cfg!(target_os = "macos") { + dirs::home_dir().map(|home| { + home.join("Library") + .join("Application Support") + .join("lime") + .join("config.toml") + }) + } else { + dirs::config_dir().map(|dir| dir.join("lime").join("config.toml")) + } +} diff --git a/crates/lime-ui/src/editor_view.rs b/crates/lime-ui/src/editor_view.rs new file mode 100644 index 0000000..19ab40c --- /dev/null +++ b/crates/lime-ui/src/editor_view.rs @@ -0,0 +1,206 @@ +use lime_core::{Editor, Position}; +use lime_syntax::{HighlightKind, HighlightSpan}; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::config::Config; +use crate::theme::UiTheme; + +#[derive(Debug, Clone, Copy, Default)] +pub struct Viewport { + pub top_line: usize, + pub left_col: usize, + pub height: usize, + pub width: usize, +} + +impl Viewport { + pub fn ensure_cursor_visible( + &mut self, + cursor: Position, + total_lines: usize, + viewport_height: usize, + text_width: usize, + ) { + self.height = viewport_height; + self.width = text_width; + + let margin = 2usize.min(viewport_height.saturating_div(3)); + if cursor.line < self.top_line + margin { + self.top_line = cursor.line.saturating_sub(margin); + } else if cursor.line >= self.top_line + viewport_height.saturating_sub(margin) { + self.top_line = cursor + .line + .saturating_sub(viewport_height.saturating_sub(margin + 1)); + } + + let max_top = total_lines.saturating_sub(viewport_height.max(1)); + self.top_line = self.top_line.min(max_top); + + if cursor.column < self.left_col { + self.left_col = cursor.column; + } else if cursor.column >= self.left_col + text_width.max(1) { + self.left_col = cursor.column.saturating_sub(text_width.saturating_sub(1)); + } + } +} + +pub fn gutter_width(total_lines: usize, show_line_numbers: bool) -> u16 { + if show_line_numbers { + total_lines.max(1).to_string().len() as u16 + 3 + } else { + 0 + } +} + +pub fn render_editor( + frame: &mut Frame<'_>, + area: Rect, + editor: &Editor, + viewport: &Viewport, + config: &Config, + theme: &UiTheme, + highlights: &[HighlightSpan], +) { + let total_lines = editor.line_count(); + let gutter = gutter_width(total_lines, config.show_line_numbers); + let text_width = area.width.saturating_sub(gutter) as usize; + let mut lines = Vec::with_capacity(area.height as usize); + let cursor_line = editor.cursor().position.line; + + for screen_row in 0..area.height as usize { + let line_idx = viewport.top_line + screen_row; + if line_idx < total_lines { + let mut spans = Vec::new(); + if config.show_line_numbers { + let number = format!( + "{:>width$} │ ", + line_idx + 1, + width = (gutter as usize).saturating_sub(3) + ); + let style = if line_idx == cursor_line { + Style::default() + .fg(theme.gutter_active) + .bg(theme.background) + } else { + Style::default().fg(theme.gutter).bg(theme.background) + }; + spans.push(Span::styled(number, style)); + } + + let text = editor.line_text_without_ending(line_idx); + spans.extend(styled_line_spans( + line_idx, + &text, + viewport.left_col, + text_width, + highlights, + theme, + )); + lines.push(Line::from(spans)); + } else { + let blank = if config.show_line_numbers { + " ".repeat(gutter as usize) + } else { + String::new() + }; + lines.push(Line::from(Span::styled(blank, theme.normal()))); + } + } + + frame.render_widget(Paragraph::new(lines).style(theme.normal()), area); + + let cursor = editor.cursor().position; + if cursor.line >= viewport.top_line + && cursor.line < viewport.top_line + area.height as usize + && cursor.column >= viewport.left_col + && cursor.column < viewport.left_col + text_width.max(1) + { + let x = area.x + gutter + (cursor.column - viewport.left_col) as u16; + let y = area.y + (cursor.line - viewport.top_line) as u16; + if x < area.x + area.width && y < area.y + area.height { + frame.set_cursor_position((x, y)); + } + } +} + +fn styled_line_spans( + line_idx: usize, + text: &str, + left_col: usize, + width: usize, + highlights: &[HighlightSpan], + theme: &UiTheme, +) -> Vec> { + let chars: Vec = text.chars().collect(); + if width == 0 { + return Vec::new(); + } + + let mut kinds = vec![HighlightKind::Normal; chars.len()]; + for span in highlights + .iter() + .filter(|span| span.intersects_line(line_idx)) + { + let start = if line_idx == span.line_start { + span.column_start + } else { + 0 + }; + let end = if line_idx == span.line_end { + span.column_end + } else { + chars.len() + }; + let start = start.min(chars.len()); + let end = end.min(chars.len()); + for kind in kinds.iter_mut().take(end).skip(start) { + *kind = span.kind; + } + } + + let start = left_col.min(chars.len()); + let end = (start + width).min(chars.len()); + if start >= end { + return vec![Span::raw("")]; + } + + let mut spans = Vec::new(); + let mut current_kind = kinds[start]; + let mut current = String::new(); + + for idx in start..end { + let kind = kinds[idx]; + if kind != current_kind && !current.is_empty() { + spans.push(Span::styled( + current.clone(), + theme.syntax_style(current_kind), + )); + current.clear(); + current_kind = kind; + } + current.push(chars[idx]); + } + + if !current.is_empty() { + spans.push(Span::styled(current, theme.syntax_style(current_kind))); + } + + spans +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn viewport_tracks_cursor() { + let mut viewport = Viewport::default(); + viewport.ensure_cursor_visible(Position::new(50, 10), 100, 10, 20); + assert!(viewport.top_line <= 50); + assert!(viewport.top_line + 10 > 50); + } +} diff --git a/crates/lime-ui/src/file_picker.rs b/crates/lime-ui/src/file_picker.rs new file mode 100644 index 0000000..6ca21c2 --- /dev/null +++ b/crates/lime-ui/src/file_picker.rs @@ -0,0 +1,181 @@ +use std::path::{Path, PathBuf}; + +use ignore::{DirEntry, WalkBuilder}; + +#[derive(Debug, Clone)] +pub struct FileMatch { + pub path: PathBuf, + pub display: String, + pub score: u32, +} + +#[derive(Debug, Clone)] +pub struct FilePickerState { + pub root: PathBuf, + pub query: String, + pub all_files: Vec, + pub matches: Vec, + pub selected: usize, +} + +impl FilePickerState { + pub fn new(root: impl Into) -> Self { + let root = root.into(); + let all_files = scan_files(&root); + let mut state = Self { + root, + query: String::new(), + all_files, + matches: Vec::new(), + selected: 0, + }; + state.update_matches(); + state + } + + pub fn input_char(&mut self, ch: char) { + self.query.push(ch); + self.update_matches(); + } + + pub fn backspace(&mut self) { + self.query.pop(); + self.update_matches(); + } + + pub fn move_up(&mut self) { + if self.matches.is_empty() { + self.selected = 0; + } else if self.selected == 0 { + self.selected = self.matches.len() - 1; + } else { + self.selected -= 1; + } + } + + pub fn move_down(&mut self) { + if self.matches.is_empty() { + self.selected = 0; + } else { + self.selected = (self.selected + 1) % self.matches.len(); + } + } + + pub fn selected_path(&self) -> Option { + self.matches.get(self.selected).map(|m| m.path.clone()) + } + + pub fn update_matches(&mut self) { + let query = self.query.trim(); + let root = self.root.clone(); + let mut matches: Vec = self + .all_files + .iter() + .filter_map(|path| { + let display = path + .strip_prefix(&root) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + fuzzy_score(query, &display).map(|score| FileMatch { + path: path.clone(), + display, + score, + }) + }) + .collect(); + + matches.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.display.len().cmp(&b.display.len())) + .then_with(|| a.display.cmp(&b.display)) + }); + matches.truncate(500); + self.matches = matches; + self.selected = self.selected.min(self.matches.len().saturating_sub(1)); + } +} + +pub fn scan_files(root: &Path) -> Vec { + let mut builder = WalkBuilder::new(root); + builder + .hidden(true) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .parents(true) + .filter_entry(|entry| !is_ignored_dir(entry)); + + let mut files = Vec::new(); + for entry in builder.build().filter_map(Result::ok) { + if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { + files.push(entry.path().to_path_buf()); + } + } + files.sort(); + files +} + +fn is_ignored_dir(entry: &DirEntry) -> bool { + if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { + return false; + } + + let Some(name) = entry.file_name().to_str() else { + return false; + }; + + matches!( + name, + ".git" | "target" | "node_modules" | "dist" | ".next" | "build" + ) +} + +pub fn fuzzy_score(query: &str, candidate: &str) -> Option { + if query.is_empty() { + return Some(1); + } + + let query = query.to_lowercase(); + let candidate_lower = candidate.to_lowercase(); + let mut candidate_chars = candidate_lower.chars().enumerate(); + let mut last_match: Option = None; + let mut score = 0u32; + + for q in query.chars() { + let mut found = None; + for (idx, ch) in candidate_chars.by_ref() { + if ch == q { + found = Some(idx); + break; + } + } + let idx = found?; + score += 10; + if let Some(last) = last_match { + if idx == last + 1 { + score += 15; + } + } else if idx == 0 { + score += 20; + } + if candidate.as_bytes().get(idx) == Some(&b'/') { + score += 3; + } + last_match = Some(idx); + } + + Some(score.saturating_sub(candidate.len() as u32 / 5)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fuzzy_matches_in_order() { + assert!(fuzzy_score("lcore", "crates/lime-core/src/lib.rs").is_some()); + assert!(fuzzy_score("zz", "crates/lime-core/src/lib.rs").is_none()); + } +} diff --git a/crates/lime-ui/src/input.rs b/crates/lime-ui/src/input.rs new file mode 100644 index 0000000..ed5d5f7 --- /dev/null +++ b/crates/lime-ui/src/input.rs @@ -0,0 +1,80 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use lime_core::EditorCommand; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppAction { + Editor(EditorCommand), + Save, + Quit, + OpenFilePicker, + OpenGoToLine, + OpenSearch, + ClosePopup, + Confirm, + Cancel, + None, +} + +pub fn map_editing_key(key: KeyEvent) -> AppAction { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + + if ctrl { + return match key.code { + KeyCode::Char('s') | KeyCode::Char('S') => AppAction::Save, + KeyCode::Char('q') | KeyCode::Char('Q') => AppAction::Quit, + KeyCode::Char('f') | KeyCode::Char('F') => AppAction::OpenFilePicker, + KeyCode::Char('g') | KeyCode::Char('G') => AppAction::OpenGoToLine, + KeyCode::Char('r') | KeyCode::Char('R') => AppAction::OpenSearch, + KeyCode::Char('z') | KeyCode::Char('Z') => AppAction::Editor(EditorCommand::Undo), + KeyCode::Char('y') | KeyCode::Char('Y') => AppAction::Editor(EditorCommand::Redo), + KeyCode::Char('a') | KeyCode::Char('A') => { + AppAction::Editor(EditorCommand::MoveLineStart) + } + KeyCode::Char('e') | KeyCode::Char('E') => { + AppAction::Editor(EditorCommand::MoveLineEnd) + } + _ => AppAction::None, + }; + } + + if alt { + return AppAction::None; + } + + match key.code { + KeyCode::Esc => AppAction::ClosePopup, + KeyCode::Left => AppAction::Editor(EditorCommand::MoveLeft), + KeyCode::Right => AppAction::Editor(EditorCommand::MoveRight), + KeyCode::Up => AppAction::Editor(EditorCommand::MoveUp), + KeyCode::Down => AppAction::Editor(EditorCommand::MoveDown), + KeyCode::Home => AppAction::Editor(EditorCommand::MoveLineStart), + KeyCode::End => AppAction::Editor(EditorCommand::MoveLineEnd), + KeyCode::PageUp => AppAction::Editor(EditorCommand::PageUp), + KeyCode::PageDown => AppAction::Editor(EditorCommand::PageDown), + KeyCode::Backspace => AppAction::Editor(EditorCommand::Backspace), + KeyCode::Delete => AppAction::Editor(EditorCommand::Delete), + KeyCode::Enter => AppAction::Editor(EditorCommand::Newline), + KeyCode::Tab => AppAction::Editor(EditorCommand::InsertText("\t".to_string())), + KeyCode::Char(ch) => AppAction::Editor(EditorCommand::InsertChar(ch)), + _ => AppAction::None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyEvent; + + #[test] + fn maps_save_shortcut() { + let action = map_editing_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)); + assert_eq!(action, AppAction::Save); + } + + #[test] + fn maps_text_input() { + let action = map_editing_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!(action, AppAction::Editor(EditorCommand::InsertChar('x'))); + } +} diff --git a/crates/lime-ui/src/layout.rs b/crates/lime-ui/src/layout.rs new file mode 100644 index 0000000..c954376 --- /dev/null +++ b/crates/lime-ui/src/layout.rs @@ -0,0 +1,45 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +#[derive(Debug, Clone, Copy)] +pub struct AppLayout { + pub editor: Rect, + pub status: Rect, + pub help: Rect, +} + +pub fn app_layout(area: Rect) -> AppLayout { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + + AppLayout { + editor: chunks[0], + status: chunks[1], + help: chunks[2], + } +} + +pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/crates/lime-ui/src/lib.rs b/crates/lime-ui/src/lib.rs new file mode 100644 index 0000000..fd8e371 --- /dev/null +++ b/crates/lime-ui/src/lib.rs @@ -0,0 +1,14 @@ +pub mod app; +pub mod command_bar; +pub mod config; +pub mod editor_view; +pub mod file_picker; +pub mod input; +pub mod layout; +pub mod prompt; +pub mod status_bar; +pub mod terminal; +pub mod theme; + +pub use app::{run_app, App, AppOptions}; +pub use config::Config; diff --git a/crates/lime-ui/src/prompt.rs b/crates/lime-ui/src/prompt.rs new file mode 100644 index 0000000..bacea06 --- /dev/null +++ b/crates/lime-ui/src/prompt.rs @@ -0,0 +1,49 @@ +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub enum PromptKind { + SaveAs, + SaveAsThenQuit, + SaveAsThenOpen { path: PathBuf }, + GoToLine, + Search, +} + +#[derive(Debug, Clone)] +pub struct PromptState { + pub kind: PromptKind, + pub title: String, + pub input: String, +} + +impl PromptState { + pub fn new(kind: PromptKind, title: impl Into) -> Self { + Self { + kind, + title: title.into(), + input: String::new(), + } + } +} + +#[derive(Debug, Clone)] +pub enum ConfirmKind { + QuitDirty, + OpenDirty { path: PathBuf }, + OpenLarge { path: PathBuf, size: u64 }, +} + +#[derive(Debug, Clone)] +pub struct ConfirmState { + pub kind: ConfirmKind, + pub message: String, +} + +impl ConfirmState { + pub fn new(kind: ConfirmKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} diff --git a/crates/lime-ui/src/status_bar.rs b/crates/lime-ui/src/status_bar.rs new file mode 100644 index 0000000..b35cfa2 --- /dev/null +++ b/crates/lime-ui/src/status_bar.rs @@ -0,0 +1,42 @@ +use lime_core::Position; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::{layout::Rect, Frame}; + +use crate::theme::UiTheme; + +#[derive(Debug, Clone, Copy)] +pub struct StatusInfo<'a> { + pub file_name: &'a str, + pub language: &'a str, + pub position: Position, + pub dirty: bool, + pub message: Option<&'a str>, +} + +pub fn render_status_bar(frame: &mut Frame<'_>, area: Rect, theme: &UiTheme, info: StatusInfo<'_>) { + let dirty_mark = if info.dirty { "modified" } else { "saved" }; + let base = format!( + " lime {} {} Ln {}, Col {} {}", + info.file_name, + info.language, + info.position.line + 1, + info.position.column + 1, + dirty_mark + ); + + let line = if let Some(message) = info.message { + Line::from(vec![ + Span::raw(base), + Span::raw(" │ "), + Span::styled(message.to_string(), theme.normal().fg(theme.status_fg)), + ]) + } else { + Line::from(base) + }; + + frame.render_widget( + Paragraph::new(line).style(theme.normal().fg(theme.status_fg).bg(theme.status_bg)), + area, + ); +} diff --git a/crates/lime-ui/src/terminal.rs b/crates/lime-ui/src/terminal.rs new file mode 100644 index 0000000..46e5ec0 --- /dev/null +++ b/crates/lime-ui/src/terminal.rs @@ -0,0 +1,40 @@ +use std::io::{self, Stdout}; + +use anyhow::Result; +use crossterm::cursor::{Hide, Show}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +pub type LimeTerminal = Terminal>; + +pub struct TerminalSession { + terminal: LimeTerminal, +} + +impl TerminalSession { + pub fn enter() -> Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, Hide)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + Ok(Self { terminal }) + } + + pub fn terminal_mut(&mut self) -> &mut LimeTerminal { + &mut self.terminal + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let mut stdout = io::stdout(); + let _ = execute!(stdout, Show, LeaveAlternateScreen); + } +} diff --git a/crates/lime-ui/src/theme.rs b/crates/lime-ui/src/theme.rs new file mode 100644 index 0000000..a724e72 --- /dev/null +++ b/crates/lime-ui/src/theme.rs @@ -0,0 +1,92 @@ +use lime_syntax::HighlightKind; +use ratatui::style::{Color, Modifier, Style}; + +#[derive(Debug, Clone)] +pub struct UiTheme { + pub background: Color, + pub foreground: Color, + pub dim: Color, + pub gutter: Color, + pub gutter_active: Color, + pub status_bg: Color, + pub status_fg: Color, + pub help_bg: Color, + pub help_fg: Color, + pub popup_bg: Color, + pub popup_border: Color, + pub selection_bg: Color, + pub cursor: Color, + pub keyword: Color, + pub string: Color, + pub comment: Color, + pub function: Color, + pub type_name: Color, + pub number: Color, + pub error: Color, + pub punctuation: Color, + pub heading: Color, +} + +impl Default for UiTheme { + fn default() -> Self { + Self::lime_dark() + } +} + +impl UiTheme { + pub fn lime_dark() -> Self { + Self { + background: Color::Rgb(18, 20, 18), + foreground: Color::Rgb(220, 226, 218), + dim: Color::Rgb(112, 121, 108), + gutter: Color::Rgb(88, 96, 84), + gutter_active: Color::Rgb(174, 210, 118), + status_bg: Color::Rgb(53, 83, 45), + status_fg: Color::Rgb(236, 244, 222), + help_bg: Color::Rgb(28, 32, 27), + help_fg: Color::Rgb(177, 187, 169), + popup_bg: Color::Rgb(22, 25, 22), + popup_border: Color::Rgb(135, 175, 95), + selection_bg: Color::Rgb(57, 72, 54), + cursor: Color::Rgb(203, 248, 111), + keyword: Color::Rgb(145, 205, 255), + string: Color::Rgb(177, 219, 139), + comment: Color::Rgb(107, 122, 100), + function: Color::Rgb(246, 207, 122), + type_name: Color::Rgb(129, 221, 199), + number: Color::Rgb(230, 172, 117), + error: Color::Rgb(255, 111, 111), + punctuation: Color::Rgb(157, 167, 148), + heading: Color::Rgb(203, 248, 111), + } + } + + pub fn normal(&self) -> Style { + Style::default().fg(self.foreground).bg(self.background) + } + + pub fn dim(&self) -> Style { + Style::default().fg(self.dim).bg(self.background) + } + + pub fn syntax_style(&self, kind: HighlightKind) -> Style { + let color = match kind { + HighlightKind::Normal => self.foreground, + HighlightKind::Keyword => self.keyword, + HighlightKind::String => self.string, + HighlightKind::Comment => self.comment, + HighlightKind::Function => self.function, + HighlightKind::TypeName => self.type_name, + HighlightKind::Number => self.number, + HighlightKind::Error => self.error, + HighlightKind::Punctuation => self.punctuation, + HighlightKind::Heading => self.heading, + }; + + let mut style = Style::default().fg(color).bg(self.background); + if matches!(kind, HighlightKind::Heading) { + style = style.add_modifier(Modifier::BOLD); + } + style + } +}