├── .github └── workflows │ ├── check.yml │ └── demo.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── cli ├── Cargo.toml └── src │ ├── lib.rs │ ├── main.rs │ └── unix │ ├── filer.rs │ ├── input.rs │ ├── mod.rs │ └── output.rs ├── core ├── Cargo.toml └── src │ ├── ansi_escape.rs │ ├── decode.rs │ ├── document.rs │ ├── editor.rs │ ├── error.rs │ ├── languages.rs │ ├── lib.rs │ └── traits.rs ├── example.gif ├── renovate.json └── web ├── .gitignore ├── Cargo.toml ├── README.md ├── js ├── index.ts └── worker.ts ├── package-lock.json ├── package.json ├── src ├── filer.rs ├── input.rs ├── lib.rs ├── output.rs └── xterm.rs ├── static └── index.html ├── tsconfig.json └── webpack.config.js /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | check: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Setup Rust 19 | uses: dtolnay/rust-toolchain@stable 20 | 21 | - name: Cache cargo 22 | uses: actions/cache@v4 23 | with: 24 | path: | 25 | ~/.cargo/registry 26 | ~/.cargo/git 27 | target 28 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 29 | 30 | - run: rustup show 31 | - run: cargo check 32 | 33 | web: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | 38 | - name: Setup Rust 39 | uses: dtolnay/rust-toolchain@stable 40 | 41 | - name: Setup Node 42 | uses: actions/setup-node@v4 43 | with: 44 | node-version: "lts/*" 45 | cache: "npm" 46 | cache-dependency-path: "web/package-lock.json" 47 | 48 | - name: Cache cargo 49 | uses: actions/cache@v4 50 | with: 51 | path: | 52 | ~/.cargo/registry 53 | ~/.cargo/git 54 | target 55 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 56 | 57 | - name: Install wasm-pack 58 | run: cargo install wasm-pack@0.9.1 59 | 60 | - run: cd web && npm ci 61 | - run: cd web && npm run build 62 | - run: cd web && npm run tsc 63 | -------------------------------------------------------------------------------- /.github/workflows/demo.yml: -------------------------------------------------------------------------------- 1 | name: demo 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | demo: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup Rust 18 | uses: dtolnay/rust-toolchain@stable 19 | 20 | - name: Setup Node 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: "lts/*" 24 | cache: "npm" 25 | cache-dependency-path: "web/package-lock.json" 26 | 27 | - name: Cache cargo 28 | uses: actions/cache@v4 29 | with: 30 | path: | 31 | ~/.cargo/registry 32 | ~/.cargo/git 33 | target 34 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 35 | 36 | - name: Install wasm-pack 37 | run: cargo install wasm-pack@0.9.1 38 | 39 | - run: cd web && npm ci 40 | - run: cd web && npm run build 41 | 42 | - name: Deploy 43 | uses: peaceiris/actions-gh-pages@v3 44 | with: 45 | github_token: ${{ secrets.GITHUB_TOKEN }} 46 | publish_dir: ./web/dist 47 | publish_branch: demo 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "autocfg" 7 | version = "1.4.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 10 | 11 | [[package]] 12 | name = "bumpalo" 13 | version = "3.16.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" 16 | 17 | [[package]] 18 | name = "cc" 19 | version = "1.1.23" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "3bbb537bb4a30b90362caddba8f360c0a56bc13d3a5570028e7197204cb54a17" 22 | dependencies = [ 23 | "shlex", 24 | ] 25 | 26 | [[package]] 27 | name = "cfg-if" 28 | version = "0.1.10" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 31 | 32 | [[package]] 33 | name = "cfg-if" 34 | version = "1.0.0" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 37 | 38 | [[package]] 39 | name = "cli" 40 | version = "0.1.0" 41 | dependencies = [ 42 | "core", 43 | "libc", 44 | ] 45 | 46 | [[package]] 47 | name = "console_error_panic_hook" 48 | version = "0.1.7" 49 | source = "registry+https://github.com/rust-lang/crates.io-index" 50 | checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" 51 | dependencies = [ 52 | "cfg-if 1.0.0", 53 | "wasm-bindgen", 54 | ] 55 | 56 | [[package]] 57 | name = "core" 58 | version = "0.1.0" 59 | dependencies = [ 60 | "instant", 61 | "unicode-segmentation", 62 | "unicode-width", 63 | ] 64 | 65 | [[package]] 66 | name = "futures" 67 | version = "0.3.30" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" 70 | dependencies = [ 71 | "futures-channel", 72 | "futures-core", 73 | "futures-executor", 74 | "futures-io", 75 | "futures-sink", 76 | "futures-task", 77 | "futures-util", 78 | ] 79 | 80 | [[package]] 81 | name = "futures-channel" 82 | version = "0.3.30" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" 85 | dependencies = [ 86 | "futures-core", 87 | "futures-sink", 88 | ] 89 | 90 | [[package]] 91 | name = "futures-core" 92 | version = "0.3.30" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" 95 | 96 | [[package]] 97 | name = "futures-executor" 98 | version = "0.3.30" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" 101 | dependencies = [ 102 | "futures-core", 103 | "futures-task", 104 | "futures-util", 105 | ] 106 | 107 | [[package]] 108 | name = "futures-io" 109 | version = "0.3.30" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" 112 | 113 | [[package]] 114 | name = "futures-macro" 115 | version = "0.3.30" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" 118 | dependencies = [ 119 | "proc-macro2", 120 | "quote", 121 | "syn", 122 | ] 123 | 124 | [[package]] 125 | name = "futures-sink" 126 | version = "0.3.30" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" 129 | 130 | [[package]] 131 | name = "futures-task" 132 | version = "0.3.30" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" 135 | 136 | [[package]] 137 | name = "futures-util" 138 | version = "0.3.30" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" 141 | dependencies = [ 142 | "futures-channel", 143 | "futures-core", 144 | "futures-io", 145 | "futures-macro", 146 | "futures-sink", 147 | "futures-task", 148 | "memchr", 149 | "pin-project-lite", 150 | "pin-utils", 151 | "slab", 152 | ] 153 | 154 | [[package]] 155 | name = "instant" 156 | version = "0.1.13" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" 159 | dependencies = [ 160 | "cfg-if 1.0.0", 161 | "js-sys", 162 | "wasm-bindgen", 163 | "web-sys", 164 | ] 165 | 166 | [[package]] 167 | name = "js-sys" 168 | version = "0.3.70" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" 171 | dependencies = [ 172 | "wasm-bindgen", 173 | ] 174 | 175 | [[package]] 176 | name = "libc" 177 | version = "0.2.159" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" 180 | 181 | [[package]] 182 | name = "log" 183 | version = "0.4.22" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 186 | 187 | [[package]] 188 | name = "memchr" 189 | version = "2.7.4" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 192 | 193 | [[package]] 194 | name = "memory_units" 195 | version = "0.4.0" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" 198 | 199 | [[package]] 200 | name = "minicov" 201 | version = "0.3.5" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "5c71e683cd655513b99affab7d317deb690528255a0d5f717f1024093c12b169" 204 | dependencies = [ 205 | "cc", 206 | "walkdir", 207 | ] 208 | 209 | [[package]] 210 | name = "once_cell" 211 | version = "1.20.1" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" 214 | dependencies = [ 215 | "portable-atomic", 216 | ] 217 | 218 | [[package]] 219 | name = "pin-project-lite" 220 | version = "0.2.14" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" 223 | 224 | [[package]] 225 | name = "pin-utils" 226 | version = "0.1.0" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 229 | 230 | [[package]] 231 | name = "portable-atomic" 232 | version = "1.9.0" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" 235 | 236 | [[package]] 237 | name = "proc-macro2" 238 | version = "1.0.86" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 241 | dependencies = [ 242 | "unicode-ident", 243 | ] 244 | 245 | [[package]] 246 | name = "quote" 247 | version = "1.0.37" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 250 | dependencies = [ 251 | "proc-macro2", 252 | ] 253 | 254 | [[package]] 255 | name = "same-file" 256 | version = "1.0.6" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 259 | dependencies = [ 260 | "winapi-util", 261 | ] 262 | 263 | [[package]] 264 | name = "scoped-tls" 265 | version = "1.0.1" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 268 | 269 | [[package]] 270 | name = "shlex" 271 | version = "1.3.0" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 274 | 275 | [[package]] 276 | name = "slab" 277 | version = "0.4.9" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 280 | dependencies = [ 281 | "autocfg", 282 | ] 283 | 284 | [[package]] 285 | name = "syn" 286 | version = "2.0.79" 287 | source = "registry+https://github.com/rust-lang/crates.io-index" 288 | checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" 289 | dependencies = [ 290 | "proc-macro2", 291 | "quote", 292 | "unicode-ident", 293 | ] 294 | 295 | [[package]] 296 | name = "unicode-ident" 297 | version = "1.0.13" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 300 | 301 | [[package]] 302 | name = "unicode-segmentation" 303 | version = "1.12.0" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 306 | 307 | [[package]] 308 | name = "unicode-width" 309 | version = "0.2.0" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 312 | 313 | [[package]] 314 | name = "walkdir" 315 | version = "2.5.0" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 318 | dependencies = [ 319 | "same-file", 320 | "winapi-util", 321 | ] 322 | 323 | [[package]] 324 | name = "wasm-bindgen" 325 | version = "0.2.93" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" 328 | dependencies = [ 329 | "cfg-if 1.0.0", 330 | "once_cell", 331 | "wasm-bindgen-macro", 332 | ] 333 | 334 | [[package]] 335 | name = "wasm-bindgen-backend" 336 | version = "0.2.93" 337 | source = "registry+https://github.com/rust-lang/crates.io-index" 338 | checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" 339 | dependencies = [ 340 | "bumpalo", 341 | "log", 342 | "once_cell", 343 | "proc-macro2", 344 | "quote", 345 | "syn", 346 | "wasm-bindgen-shared", 347 | ] 348 | 349 | [[package]] 350 | name = "wasm-bindgen-futures" 351 | version = "0.4.43" 352 | source = "registry+https://github.com/rust-lang/crates.io-index" 353 | checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" 354 | dependencies = [ 355 | "cfg-if 1.0.0", 356 | "js-sys", 357 | "wasm-bindgen", 358 | "web-sys", 359 | ] 360 | 361 | [[package]] 362 | name = "wasm-bindgen-macro" 363 | version = "0.2.93" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" 366 | dependencies = [ 367 | "quote", 368 | "wasm-bindgen-macro-support", 369 | ] 370 | 371 | [[package]] 372 | name = "wasm-bindgen-macro-support" 373 | version = "0.2.93" 374 | source = "registry+https://github.com/rust-lang/crates.io-index" 375 | checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" 376 | dependencies = [ 377 | "proc-macro2", 378 | "quote", 379 | "syn", 380 | "wasm-bindgen-backend", 381 | "wasm-bindgen-shared", 382 | ] 383 | 384 | [[package]] 385 | name = "wasm-bindgen-shared" 386 | version = "0.2.93" 387 | source = "registry+https://github.com/rust-lang/crates.io-index" 388 | checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" 389 | 390 | [[package]] 391 | name = "wasm-bindgen-test" 392 | version = "0.3.43" 393 | source = "registry+https://github.com/rust-lang/crates.io-index" 394 | checksum = "68497a05fb21143a08a7d24fc81763384a3072ee43c44e86aad1744d6adef9d9" 395 | dependencies = [ 396 | "console_error_panic_hook", 397 | "js-sys", 398 | "minicov", 399 | "scoped-tls", 400 | "wasm-bindgen", 401 | "wasm-bindgen-futures", 402 | "wasm-bindgen-test-macro", 403 | ] 404 | 405 | [[package]] 406 | name = "wasm-bindgen-test-macro" 407 | version = "0.3.43" 408 | source = "registry+https://github.com/rust-lang/crates.io-index" 409 | checksum = "4b8220be1fa9e4c889b30fd207d4906657e7e90b12e0e6b0c8b8d8709f5de021" 410 | dependencies = [ 411 | "proc-macro2", 412 | "quote", 413 | "syn", 414 | ] 415 | 416 | [[package]] 417 | name = "web" 418 | version = "0.1.0" 419 | dependencies = [ 420 | "console_error_panic_hook", 421 | "core", 422 | "futures", 423 | "js-sys", 424 | "wasm-bindgen", 425 | "wasm-bindgen-futures", 426 | "wasm-bindgen-test", 427 | "web-sys", 428 | "wee_alloc", 429 | ] 430 | 431 | [[package]] 432 | name = "web-sys" 433 | version = "0.3.70" 434 | source = "registry+https://github.com/rust-lang/crates.io-index" 435 | checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" 436 | dependencies = [ 437 | "js-sys", 438 | "wasm-bindgen", 439 | ] 440 | 441 | [[package]] 442 | name = "wee_alloc" 443 | version = "0.4.5" 444 | source = "registry+https://github.com/rust-lang/crates.io-index" 445 | checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e" 446 | dependencies = [ 447 | "cfg-if 0.1.10", 448 | "libc", 449 | "memory_units", 450 | "winapi", 451 | ] 452 | 453 | [[package]] 454 | name = "winapi" 455 | version = "0.3.9" 456 | source = "registry+https://github.com/rust-lang/crates.io-index" 457 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 458 | dependencies = [ 459 | "winapi-i686-pc-windows-gnu", 460 | "winapi-x86_64-pc-windows-gnu", 461 | ] 462 | 463 | [[package]] 464 | name = "winapi-i686-pc-windows-gnu" 465 | version = "0.4.0" 466 | source = "registry+https://github.com/rust-lang/crates.io-index" 467 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 468 | 469 | [[package]] 470 | name = "winapi-util" 471 | version = "0.1.9" 472 | source = "registry+https://github.com/rust-lang/crates.io-index" 473 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 474 | dependencies = [ 475 | "windows-sys", 476 | ] 477 | 478 | [[package]] 479 | name = "winapi-x86_64-pc-windows-gnu" 480 | version = "0.4.0" 481 | source = "registry+https://github.com/rust-lang/crates.io-index" 482 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 483 | 484 | [[package]] 485 | name = "windows-sys" 486 | version = "0.59.0" 487 | source = "registry+https://github.com/rust-lang/crates.io-index" 488 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 489 | dependencies = [ 490 | "windows-targets", 491 | ] 492 | 493 | [[package]] 494 | name = "windows-targets" 495 | version = "0.52.6" 496 | source = "registry+https://github.com/rust-lang/crates.io-index" 497 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 498 | dependencies = [ 499 | "windows_aarch64_gnullvm", 500 | "windows_aarch64_msvc", 501 | "windows_i686_gnu", 502 | "windows_i686_gnullvm", 503 | "windows_i686_msvc", 504 | "windows_x86_64_gnu", 505 | "windows_x86_64_gnullvm", 506 | "windows_x86_64_msvc", 507 | ] 508 | 509 | [[package]] 510 | name = "windows_aarch64_gnullvm" 511 | version = "0.52.6" 512 | source = "registry+https://github.com/rust-lang/crates.io-index" 513 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 514 | 515 | [[package]] 516 | name = "windows_aarch64_msvc" 517 | version = "0.52.6" 518 | source = "registry+https://github.com/rust-lang/crates.io-index" 519 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 520 | 521 | [[package]] 522 | name = "windows_i686_gnu" 523 | version = "0.52.6" 524 | source = "registry+https://github.com/rust-lang/crates.io-index" 525 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 526 | 527 | [[package]] 528 | name = "windows_i686_gnullvm" 529 | version = "0.52.6" 530 | source = "registry+https://github.com/rust-lang/crates.io-index" 531 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 532 | 533 | [[package]] 534 | name = "windows_i686_msvc" 535 | version = "0.52.6" 536 | source = "registry+https://github.com/rust-lang/crates.io-index" 537 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 538 | 539 | [[package]] 540 | name = "windows_x86_64_gnu" 541 | version = "0.52.6" 542 | source = "registry+https://github.com/rust-lang/crates.io-index" 543 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 544 | 545 | [[package]] 546 | name = "windows_x86_64_gnullvm" 547 | version = "0.52.6" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 550 | 551 | [[package]] 552 | name = "windows_x86_64_msvc" 553 | version = "0.52.6" 554 | source = "registry+https://github.com/rust-lang/crates.io-index" 555 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 556 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | members = [ 4 | "cli", 5 | "core", 6 | "web", 7 | ] 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 inokawa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rust-editor 2 | 3 | ![deploy](https://github.com/inokawa/rust-editor/workflows/check/badge.svg)[![demo](https://github.com/inokawa/rust-editor/actions/workflows/demo.yml/badge.svg)](https://github.com/inokawa/rust-editor/actions/workflows/demo.yml) 4 | 5 | WIP 6 | 7 | An implementation of text editor with Rust/WebAssembly. 8 | 9 | 10 | 11 | This is a hobby project just for my study, but I'm trying to make it as much as practical. 12 | 13 | This editor is roughly based on [kilo](https://github.com/antirez/kilo), but has some improvements. 14 | 15 | - Support ASCII/UTF-8 encoded texts 16 | - Support Undo/Redo 17 | - Run on terminal in UNIX, and on browser with WebAssembly 18 | 19 | NOTE: Some features are not implemented completely. 20 | 21 | ## Demo 22 | 23 | https://inokawa.github.io/rust-editor/ 24 | 25 | ## Start 26 | 27 | ### CLI 28 | 29 | ```sh 30 | git clone git@github.com:inokawa/rust-editor.git 31 | cd rust-editor 32 | cargo run "path/to/file.txt" 33 | ``` 34 | 35 | | Shortcuts | Action | 36 | | --------- | ------ | 37 | | Ctrl+Z | Undo | 38 | | Ctrl+Y | Redo | 39 | | Ctrl+F | Search | 40 | | Ctrl+S | Save | 41 | | Ctrl+Q | Quit | 42 | 43 | ### Web 44 | 45 | ```sh 46 | git clone git@github.com:inokawa/rust-editor.git 47 | cd rust-editor/web 48 | npm install 49 | npm start 50 | ``` 51 | 52 | ## References 53 | 54 | Thank you for this great tutorial of kilo: 55 | 56 | - https://viewsourcecode.org/snaptoken/kilo/ 57 | 58 | And thank you for other great implementations of kilo: 59 | 60 | - https://github.com/rhysd/kiro-editor 61 | - https://github.com/ilai-deutel/kibi 62 | - https://www.philippflenker.com/hecto/ 63 | - https://github.com/nkon/ked-texteditor 64 | -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cli" 3 | version = "0.1.0" 4 | authors = ["inokawa <48897392+inokawa@users.noreply.github.com>"] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | core = { path = "../core" } 11 | libc = "0.2.154" 12 | -------------------------------------------------------------------------------- /cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod unix; 2 | 3 | pub use unix::*; 4 | -------------------------------------------------------------------------------- /cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use cli::{Fs, StdinRaw, Stdout}; 2 | use core::{Editor, Error, Filer, Input, Output}; 3 | use std::env; 4 | 5 | fn main() -> Result<(), Error> { 6 | let args: Vec = env::args().collect(); 7 | 8 | let mut editor = Editor::new(StdinRaw::new()?, Stdout::new(), Fs::new())?; 9 | if let Some(filename) = args.get(1) { 10 | editor.load(filename)?; 11 | } 12 | loop { 13 | let quit = editor.run()?; 14 | if quit { 15 | break; 16 | } 17 | } 18 | Ok(()) 19 | } 20 | -------------------------------------------------------------------------------- /cli/src/unix/filer.rs: -------------------------------------------------------------------------------- 1 | use core::{Error, Filer}; 2 | use std::{fs, io::Write}; 3 | 4 | pub struct Fs {} 5 | 6 | impl Filer for Fs { 7 | fn new() -> Self { 8 | Fs {} 9 | } 10 | 11 | fn load(&self, filename: &String) -> Result { 12 | let file = fs::read_to_string(&filename)?; 13 | Ok(file) 14 | } 15 | 16 | fn save(&self, filename: &String, contents: Vec) -> Result<(), Error> { 17 | let mut file = fs::File::create(filename)?; 18 | for row in &contents { 19 | file.write_all(row.as_bytes())?; 20 | file.write_all(b"\n")?; 21 | } 22 | Ok(()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cli/src/unix/input.rs: -------------------------------------------------------------------------------- 1 | use core::{Decode, Error, Input, Key, RMCUP, SMCUP}; 2 | use libc::{ 3 | tcgetattr, tcsetattr, termios, BRKINT, CS8, ECHO, ICANON, ICRNL, IEXTEN, INPCK, ISIG, ISTRIP, 4 | IXON, OPOST, STDIN_FILENO, TCSAFLUSH, VMIN, VTIME, 5 | }; 6 | use std::io::{self, Read}; 7 | 8 | #[cfg(target_os = "linux")] 9 | fn init_term() -> termios { 10 | termios { 11 | c_iflag: 0, 12 | c_oflag: 0, 13 | c_cflag: 0, 14 | c_lflag: 0, 15 | c_line: 0, 16 | c_cc: [0u8; 32], 17 | c_ispeed: 0, 18 | c_ospeed: 0, 19 | } 20 | } 21 | #[cfg(target_os = "macos")] 22 | fn init_term() -> termios { 23 | termios { 24 | c_iflag: 0, 25 | c_oflag: 0, 26 | c_cflag: 0, 27 | c_lflag: 0, 28 | c_cc: [0u8; 20], 29 | c_ispeed: 0, 30 | c_ospeed: 0, 31 | } 32 | } 33 | 34 | #[cfg(unix)] 35 | pub struct StdinRaw { 36 | orig: termios, 37 | } 38 | 39 | impl Input for StdinRaw { 40 | fn new() -> Result { 41 | let mut term = init_term(); 42 | unsafe { tcgetattr(STDIN_FILENO, &mut term) }; 43 | 44 | let orig = term; 45 | 46 | // Set terminal raw mode. Disable echo back, canonical mode, signals (SIGINT, SIGTSTP) and Ctrl+V. 47 | term.c_lflag &= !(ECHO | ICANON | ISIG | IEXTEN); 48 | // Disable control flow mode (Ctrl+Q/Ctrl+S) and CR-to-NL translation 49 | term.c_iflag &= !(IXON | ICRNL | BRKINT | INPCK | ISTRIP); 50 | // Disable output processing such as \n to \r\n translation 51 | term.c_oflag &= !OPOST; 52 | // Ensure character size is 8bits 53 | term.c_cflag |= CS8; 54 | // Do not wait for next byte with blocking since reading 0 byte is permitted 55 | term.c_cc[VMIN] = 0; 56 | // Set read timeout to 1/10 second it enables 100ms timeout on read() 57 | term.c_cc[VTIME] = 1; 58 | // Apply terminal configurations 59 | unsafe { tcsetattr(STDIN_FILENO, TCSAFLUSH, &term) }; 60 | 61 | print!("{}", SMCUP); 62 | 63 | Ok(StdinRaw { orig }) 64 | } 65 | 66 | fn wait_for_key(&self) -> Key { 67 | self.decode() 68 | } 69 | } 70 | 71 | impl Decode for StdinRaw { 72 | fn read(&self) -> Option { 73 | if let Some(b) = io::stdin().bytes().next() { 74 | b.map(|b| Some(b)).unwrap_or(None) 75 | } else { 76 | None 77 | } 78 | } 79 | } 80 | 81 | impl Drop for StdinRaw { 82 | fn drop(&mut self) { 83 | print!("{}", RMCUP); 84 | 85 | unsafe { tcsetattr(STDIN_FILENO, TCSAFLUSH, &self.orig) }; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /cli/src/unix/mod.rs: -------------------------------------------------------------------------------- 1 | mod filer; 2 | mod input; 3 | mod output; 4 | 5 | pub use filer::*; 6 | pub use input::*; 7 | pub use output::*; 8 | -------------------------------------------------------------------------------- /cli/src/unix/output.rs: -------------------------------------------------------------------------------- 1 | use core::{Ansi, Error, Output, Position}; 2 | use libc::*; 3 | use std::{ 4 | io::{self, Write}, 5 | mem, 6 | }; 7 | 8 | pub struct Stdout {} 9 | 10 | impl Output for Stdout { 11 | fn new() -> Self { 12 | Stdout {} 13 | } 14 | 15 | fn write(&self, text: &str) { 16 | print!("{}", text); 17 | } 18 | 19 | fn flush(&self) -> Result<(), Error> { 20 | io::stdout().flush()?; 21 | Ok(()) 22 | } 23 | 24 | fn render_screen(&self, rows: Vec, status_bar: &str, message_bar: &str, pos: Position) { 25 | let buf = self.render_screen_wrap(rows, status_bar, message_bar, pos); 26 | self.write(&buf); 27 | } 28 | 29 | fn clear_screen(&self) { 30 | let buf = self.clear_screen_wrap(); 31 | self.write(&buf); 32 | } 33 | 34 | fn get_window_size(&self) -> Option<(usize, usize)> { 35 | let mut ws: winsize = unsafe { mem::zeroed() }; 36 | if unsafe { ioctl(STDOUT_FILENO, TIOCGWINSZ, &mut ws) } == -1 { 37 | None 38 | } else { 39 | Some((ws.ws_row as usize, ws.ws_col as usize)) 40 | } 41 | } 42 | } 43 | 44 | impl Ansi for Stdout {} 45 | -------------------------------------------------------------------------------- /core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "core" 3 | version = "0.1.0" 4 | authors = ["inokawa <48897392+inokawa@users.noreply.github.com>"] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | unicode-segmentation = "1.12.0" 11 | unicode-width = "0.2.0" 12 | instant = { version = "0.1.13", features = [ "wasm-bindgen" ] } 13 | -------------------------------------------------------------------------------- /core/src/ansi_escape.rs: -------------------------------------------------------------------------------- 1 | use super::Position; 2 | 3 | const CLEAR_SCREEN: &str = "\x1b[2J"; 4 | const CLEAR_LINE_RIGHT_OF_CURSOR: &str = "\x1b[K"; 5 | const MOVE_CURSOR_TO_START: &str = "\x1b[H"; 6 | const HIDE_CURSOR: &str = "\x1b[?25l"; 7 | const SHOW_CURSOR: &str = "\x1b[?25h"; 8 | const RESET_FMT: &str = "\x1b[m"; 9 | pub const SMCUP: &str = "\x1b[?1049h"; 10 | pub const RMCUP: &str = "\x1b[?1049l"; 11 | 12 | pub const DEFAULT: &str = "\x1b[0m"; 13 | pub const BOLD: &str = "\x1b[1m"; 14 | pub const UNDERLINE: &str = "\x1b[4m"; 15 | pub const REVERSE_VIDEO: &str = "\x1b[7m"; 16 | pub const COLOR_BLACK: &str = "\x1b[30m"; 17 | pub const COLOR_RED: &str = "\x1b[31m"; 18 | pub const COLOR_GREEN: &str = "\x1b[32m"; 19 | pub const COLOR_YELLOW: &str = "\x1b[33m"; 20 | pub const COLOR_BLUE: &str = "\x1b[34m"; 21 | pub const COLOR_MAGENTA: &str = "\x1b[35m"; 22 | pub const COLOR_CYAN: &str = "\x1b[36m"; 23 | pub const COLOR_GRAY: &str = "\x1b[37m"; 24 | pub const COLOR_DEFAULT: &str = "\x1b[39m"; 25 | pub const BACKGROUND_COLOR_BLACK: &str = "\x1b[40m"; 26 | pub const BACKGROUND_COLOR_RED: &str = "\x1b[41m"; 27 | pub const BACKGROUND_COLOR_GREEN: &str = "\x1b[42m"; 28 | pub const BACKGROUND_COLOR_YELLOW: &str = "\x1b[43m"; 29 | pub const BACKGROUND_COLOR_BLUE: &str = "\x1b[44m"; 30 | pub const BACKGROUND_COLOR_MAGENTA: &str = "\x1b[45m"; 31 | pub const BACKGROUND_COLOR_CYAN: &str = "\x1b[46m"; 32 | pub const BACKGROUND_COLOR_GRAY: &str = "\x1b[47m"; 33 | pub const BACKGROUND_COLOR_DEFAULT: &str = "\x1b[49m"; 34 | 35 | pub trait Ansi { 36 | fn render_screen_wrap( 37 | &self, 38 | rows: Vec, 39 | status_bar: &str, 40 | message_bar: &str, 41 | pos: Position, 42 | ) -> String { 43 | let mut buf = String::new(); 44 | buf.push_str(HIDE_CURSOR); 45 | buf.push_str(MOVE_CURSOR_TO_START); 46 | rows.iter().for_each(|r| { 47 | buf.push_str(&r); 48 | buf.push_str(CLEAR_LINE_RIGHT_OF_CURSOR); 49 | buf.push_str("\r\n"); 50 | }); 51 | buf.push_str(REVERSE_VIDEO); 52 | buf.push_str(status_bar); 53 | buf.push_str(RESET_FMT); 54 | buf.push_str("\r\n"); 55 | buf.push_str(CLEAR_LINE_RIGHT_OF_CURSOR); 56 | buf.push_str(message_bar); 57 | buf.push_str(&format!("\x1b[{};{}H", pos.y, pos.x)); 58 | buf.push_str(SHOW_CURSOR); 59 | buf 60 | } 61 | 62 | fn clear_screen_wrap(&self) -> String { 63 | let mut buf = String::new(); 64 | buf.push_str(CLEAR_SCREEN); 65 | buf.push_str(MOVE_CURSOR_TO_START); 66 | buf 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /core/src/decode.rs: -------------------------------------------------------------------------------- 1 | use super::{Arrow, Command, Key, Page}; 2 | use std::str; 3 | 4 | const fn ctrl(c: char) -> u8 { 5 | (c as u8) & 0b0001_1111 6 | } 7 | 8 | const FIND: u8 = ctrl('f'); 9 | const EXIT: u8 = ctrl('q'); 10 | const SAVE: u8 = ctrl('s'); 11 | const UNDO: u8 = ctrl('z'); 12 | const REDO: u8 = ctrl('y'); 13 | const DELETE: u8 = ctrl('h'); 14 | const REFRESH_SCREEN: u8 = ctrl('l'); 15 | 16 | pub trait Decode { 17 | fn read(&self) -> Option; 18 | 19 | fn decode(&self) -> Key { 20 | if let Some(b) = self.read() { 21 | return match b { 22 | // ASCII 0x00~0x7f 23 | ctrl @ 0x00..=0x1f => match ctrl { 24 | 0x1b => self.decode_escape_sequence(), 25 | b'\t' => Key::Char(ctrl as char), 26 | b'\r' | b'\n' => Key::Enter, 27 | DELETE => Key::Backspace, 28 | REFRESH_SCREEN => Key::Escape, 29 | FIND => Key::Command(Command::Find), 30 | UNDO => Key::Command(Command::Undo), 31 | REDO => Key::Command(Command::Redo), 32 | SAVE => Key::Command(Command::Save), 33 | EXIT => Key::Command(Command::Exit), 34 | _ => Key::Unknown, 35 | }, 36 | 0x20 => Key::Char(b as char), 37 | 0x21..=0x7e => Key::Char(b as char), 38 | 0x7f => Key::Backspace, 39 | // UTF-8 0x80~0xff 40 | 0x80..=0xff => self.decode_utf8(b), 41 | }; 42 | } 43 | Key::Unknown 44 | } 45 | 46 | fn decode_escape_sequence(&self) -> Key { 47 | // TODO ignore unhandled escape sequences 48 | match self.read() { 49 | Some(b'[') => { 50 | match self.read() { 51 | Some(b) => match b { 52 | b'A' => return Key::Arrow(Arrow::Up), 53 | b'B' => return Key::Arrow(Arrow::Down), 54 | b'C' => return Key::Arrow(Arrow::Right), 55 | b'D' => return Key::Arrow(Arrow::Left), 56 | b'H' => return Key::Home, 57 | b'F' => return Key::End, 58 | n @ b'0'..=b'9' => match self.read() { 59 | Some(b'~') => match n { 60 | b'1' | b'7' => return Key::Home, 61 | b'4' | b'8' => return Key::End, 62 | b'3' => return Key::Del, 63 | b'5' => return Key::Page(Page::Up), 64 | b'6' => return Key::Page(Page::Down), 65 | _ => {} 66 | }, 67 | _ => {} 68 | }, 69 | _ => {} 70 | }, 71 | None => {} 72 | } 73 | return Key::Unknown; 74 | } 75 | Some(b'O') => match self.read() { 76 | Some(b'H') => return Key::Home, 77 | Some(b'F') => return Key::End, 78 | _ => {} 79 | }, 80 | _ => {} 81 | } 82 | Key::Escape 83 | } 84 | 85 | fn decode_utf8(&self, b: u8) -> Key { 86 | let mut buf: Vec = vec![b]; 87 | 88 | while buf.len() < 4 { 89 | if let Some(b) = self.read() { 90 | buf.push(b); 91 | } 92 | if let Ok(s) = str::from_utf8(&buf) { 93 | if let Some(c) = s.chars().next() { 94 | return Key::CharUtf8(c); 95 | } 96 | return Key::Unknown; 97 | } 98 | } 99 | Key::Unknown 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /core/src/document.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | ansi_escape::*, 3 | editor::{Position, SearchDirection}, 4 | languages::*, 5 | }; 6 | use std::cmp; 7 | use unicode_segmentation::UnicodeSegmentation; 8 | use unicode_width::UnicodeWidthStr; 9 | 10 | const MAX_UNDO_LENGTH: usize = 1000; 11 | const TAB_STOP: usize = 4; 12 | 13 | #[derive(Clone)] 14 | enum History { 15 | Insert { pos: Position, c: char }, 16 | Delete { pos: Position, c: char }, 17 | InsertRow { y: usize, row: Row }, 18 | DeleteRow { y: usize, row: Row }, 19 | SplitRow { x: usize, y: usize, row: Row }, 20 | JoinRow { x: usize, y: usize, row: Row }, 21 | // TODO keep cursor position 22 | } 23 | 24 | pub struct Document { 25 | filename: Option, 26 | rows: Vec, 27 | dirty: usize, 28 | history_index: usize, 29 | histories: Vec, 30 | pub language: Language, 31 | } 32 | 33 | impl Document { 34 | pub fn new() -> Self { 35 | Document { 36 | filename: None, 37 | rows: Vec::new(), 38 | dirty: 0, 39 | history_index: 0, 40 | histories: Vec::new(), 41 | language: Language::Unknown, 42 | } 43 | } 44 | 45 | pub fn open(filename: String, file: String) -> Self { 46 | let rows: Vec = file 47 | .lines() 48 | .map(|l| Row { 49 | string: l.to_string(), 50 | highlight: Vec::new(), 51 | }) 52 | .collect(); 53 | 54 | let language = Language::detect(&filename); 55 | Document { 56 | filename: Some(filename), 57 | rows, 58 | dirty: 0, 59 | history_index: 0, 60 | histories: Vec::new(), 61 | language, 62 | } 63 | } 64 | 65 | pub fn get_filename(&self) -> Option { 66 | self.filename.clone() 67 | } 68 | 69 | pub fn set_filename(&mut self, filename: Option) { 70 | self.filename = filename.clone(); 71 | self.language = Language::detect(&filename.unwrap_or(String::new())); 72 | } 73 | 74 | pub fn contents(&self) -> Vec { 75 | self.rows.iter().map(|r| r.string.clone()).collect() 76 | } 77 | 78 | pub fn row(&self, y: usize) -> Option<&Row> { 79 | self.rows.get(y) 80 | } 81 | 82 | pub fn render_row(&mut self, y: usize, start: usize, end: usize) -> String { 83 | if let Some(row) = self.rows.get_mut(y) { 84 | row.render(start, end) 85 | } else { 86 | String::new() 87 | } 88 | } 89 | 90 | pub fn update_highlights(&mut self) { 91 | let flags = self.language.flags(); 92 | for row in &mut self.rows { 93 | row.update_highlight(flags); 94 | } 95 | } 96 | 97 | pub fn len(&self) -> usize { 98 | self.rows.len() 99 | } 100 | 101 | pub fn is_dirty(&self) -> bool { 102 | self.dirty > 0 103 | } 104 | 105 | pub fn reset_dirty(&mut self) { 106 | self.dirty = 0; 107 | } 108 | 109 | fn edited(&mut self, action: History) { 110 | self.dirty += 1; 111 | self.histories = self.histories[..(self.histories.len() - self.history_index)].to_vec(); 112 | self.history_index = 0; 113 | 114 | self.histories.push(action); 115 | let len = self.histories.len(); 116 | if len > MAX_UNDO_LENGTH { 117 | self.histories = self.histories[len - MAX_UNDO_LENGTH..].to_vec(); 118 | } 119 | } 120 | 121 | pub fn insert_newline(&mut self, at: &Position) { 122 | if at.y > self.len() { 123 | return; 124 | } 125 | if let Some(row) = self.rows.get_mut(at.y) { 126 | let r = row.split(at.x); 127 | self.rows.insert(at.y + 1, r.clone()); 128 | self.edited(History::SplitRow { 129 | x: at.x, 130 | y: at.y, 131 | row: r, 132 | }); 133 | } else { 134 | let r = Row::new(); 135 | self.rows.push(r.clone()); 136 | self.edited(History::InsertRow { 137 | y: at.y + 1, 138 | row: r, 139 | }); 140 | } 141 | } 142 | 143 | pub fn insert(&mut self, c: char, at: &Position) { 144 | if at.y == self.len() { 145 | let mut row = Row::new(); 146 | row.insert(c, 0); 147 | self.rows.push(row); 148 | self.edited(History::Insert { 149 | pos: Position { x: at.x, y: at.y }, 150 | c, 151 | }); 152 | } else if at.y < self.len() { 153 | if let Some(row) = self.rows.get_mut(at.y) { 154 | row.insert(c, at.x); 155 | self.edited(History::Insert { 156 | pos: Position { x: at.x, y: at.y }, 157 | c, 158 | }); 159 | } 160 | } 161 | } 162 | 163 | pub fn delete(&mut self, at: &Position) { 164 | let len = self.len(); 165 | if at.y >= len { 166 | return; 167 | } 168 | 169 | let row_len = match self.rows.get(at.y) { 170 | Some(row) => row.len(), 171 | None => return, 172 | }; 173 | if at.x == row_len && at.y < len - 1 { 174 | let next_row = self.rows.remove(at.y + 1); 175 | if let Some(row) = self.rows.get_mut(at.y) { 176 | row.append(&next_row); 177 | self.edited(History::JoinRow { 178 | x: at.x, 179 | y: at.y, 180 | row: next_row, 181 | }); 182 | } 183 | } else { 184 | if let Some(row) = self.rows.get_mut(at.y) { 185 | if let Some(deleted) = row.delete(at.x) { 186 | self.edited(History::Delete { 187 | pos: Position { x: at.x, y: at.y }, 188 | c: deleted, 189 | }); 190 | } 191 | } 192 | } 193 | } 194 | 195 | pub fn undo(&mut self) { 196 | let index = self.histories.len() - self.history_index; 197 | if index == 0 { 198 | return; 199 | } 200 | match self.histories.get(index - 1) { 201 | Some(action) => { 202 | match action { 203 | History::Insert { pos, c } => { 204 | if let Some(row) = self.rows.get_mut(pos.y) { 205 | if let Some(_) = row.delete(pos.x) {} 206 | } 207 | } 208 | History::Delete { pos, c } => { 209 | if let Some(row) = self.rows.get_mut(pos.y) { 210 | row.insert(c.clone(), pos.x); 211 | } 212 | } 213 | History::InsertRow { y, row } => { 214 | self.rows.remove(y.clone()); 215 | } 216 | History::DeleteRow { y, row } => { 217 | self.rows.insert(y.clone(), row.clone()); 218 | } 219 | History::SplitRow { x, y, row } => { 220 | if let Some(org_row) = self.rows.get_mut(y.clone()) { 221 | org_row.append(row); 222 | self.rows.remove(y + 1); 223 | } 224 | } 225 | History::JoinRow { x, y, row } => { 226 | if let Some(row) = self.rows.get_mut(y.clone()) { 227 | let rest = row.split(x.clone()); 228 | self.rows.insert(y + 1, rest); 229 | } 230 | } 231 | } 232 | self.history_index += 1; 233 | } 234 | None => {} 235 | } 236 | } 237 | 238 | pub fn redo(&mut self) { 239 | let index = self.histories.len() - self.history_index; 240 | if index == self.histories.len() { 241 | return; 242 | } 243 | match self.histories.get(index) { 244 | Some(action) => { 245 | match action { 246 | History::Insert { pos, c } => { 247 | if let Some(row) = self.rows.get_mut(pos.y) { 248 | row.insert(c.clone(), pos.x); 249 | } 250 | } 251 | History::Delete { pos, c } => { 252 | if let Some(row) = self.rows.get_mut(pos.y) { 253 | if let Some(_) = row.delete(pos.x) {} 254 | } 255 | } 256 | History::InsertRow { y, row } => { 257 | self.rows.insert(y.clone(), row.clone()); 258 | } 259 | History::DeleteRow { y, row } => { 260 | self.rows.remove(y.clone()); 261 | } 262 | History::SplitRow { x, y, row } => { 263 | if let Some(row) = self.rows.get_mut(y.clone()) { 264 | let rest = row.split(x.clone()); 265 | self.rows.insert(y + 1, rest); 266 | } 267 | } 268 | History::JoinRow { x, y, row } => { 269 | if let Some(org_row) = self.rows.get_mut(y.clone()) { 270 | org_row.append(row); 271 | self.rows.remove(y + 1); 272 | } 273 | } 274 | } 275 | self.history_index -= 1; 276 | } 277 | None => {} 278 | } 279 | } 280 | 281 | pub fn find( 282 | &self, 283 | query: &str, 284 | at: &Position, 285 | direction: &SearchDirection, 286 | ) -> Option { 287 | if at.y >= self.rows.len() { 288 | return None; 289 | } 290 | 291 | let (start, end) = match direction { 292 | SearchDirection::Forward => (at.y, self.rows.len()), 293 | SearchDirection::Backward => (0, at.y.saturating_add(1)), 294 | }; 295 | let mut position = Position { x: at.x, y: at.y }; 296 | for _ in start..end { 297 | if let Some(row) = self.rows.get(position.y) { 298 | if let Some(x) = row.find(&query, position.x, direction) { 299 | position.x = x; 300 | return Some(position); 301 | } 302 | match direction { 303 | SearchDirection::Forward => { 304 | position.y = position.y.saturating_add(1); 305 | position.x = 0; 306 | } 307 | SearchDirection::Backward => { 308 | position.y = position.y.saturating_sub(1); 309 | position.x = self.rows[position.y].len(); 310 | } 311 | }; 312 | } 313 | } 314 | None 315 | } 316 | } 317 | 318 | #[derive(Clone)] 319 | pub struct Row { 320 | string: String, 321 | highlight: Vec, 322 | } 323 | 324 | impl Row { 325 | pub fn new() -> Self { 326 | Row { 327 | string: String::new(), 328 | highlight: Vec::new(), 329 | } 330 | } 331 | 332 | pub fn render(&self, start: usize, end: usize) -> String { 333 | if start > end { 334 | return String::new(); 335 | } 336 | let start = cmp::max(0, start); 337 | let end = cmp::min(self.string.len(), end); 338 | let mut highlight = &Highlight::None; 339 | let mut string = self 340 | .string 341 | .get(start..end) 342 | .map(|s| { 343 | s.graphemes(true) 344 | .enumerate() 345 | .map(|(i, c)| match c { 346 | "\t" => " ".repeat(TAB_STOP), 347 | _ => { 348 | let mut hl = String::new(); 349 | let h = self 350 | .highlight 351 | .iter() 352 | .find(|h| h.index == start + i) 353 | .map(|h| &h.highlight) 354 | .unwrap_or(&Highlight::None); 355 | if highlight != h { 356 | highlight = h; 357 | hl.push_str(highlight.color()); 358 | } 359 | format!("{}{}", hl, c) 360 | } 361 | }) 362 | .collect() 363 | }) 364 | .unwrap_or(String::new()); 365 | string.push_str(&COLOR_DEFAULT); 366 | string 367 | } 368 | 369 | pub fn update_highlight(&mut self, flags: &[&Highlight]) { 370 | let mut highlight = Vec::new(); 371 | let mut is_prev_sep = true; 372 | let mut in_string = ""; 373 | let mut prev_highlight = Highlight::None; 374 | let graphemes: Vec<&str> = self.string.graphemes(true).collect(); 375 | let mut index = 0; 376 | while let Some(&s) = graphemes.get(index) { 377 | let ns: &str = graphemes.get(index + 1).unwrap_or(&""); 378 | let i = index; 379 | index += 1; 380 | if in_string == "" && s == "/" && ns == "/" { 381 | for ci in i..graphemes.len() { 382 | highlight.push(Token { 383 | index: ci, 384 | highlight: Highlight::Comment, 385 | }); 386 | } 387 | prev_highlight = Highlight::Comment; 388 | continue; 389 | } 390 | if flags.contains(&&Highlight::String) { 391 | if in_string != "" { 392 | highlight.push(Token { 393 | index: i, 394 | highlight: Highlight::String, 395 | }); 396 | if in_string == s { 397 | in_string = ""; 398 | } 399 | is_prev_sep = true; 400 | prev_highlight = Highlight::String; 401 | continue; 402 | } else { 403 | if s == "\"" || s == "'" { 404 | highlight.push(Token { 405 | index: i, 406 | highlight: Highlight::String, 407 | }); 408 | in_string = s; 409 | prev_highlight = Highlight::String; 410 | continue; 411 | } 412 | } 413 | } 414 | if flags.contains(&&Highlight::Number) { 415 | if (is_digit(s) && (is_prev_sep || prev_highlight == Highlight::Number)) 416 | || s == "." && prev_highlight == Highlight::Number 417 | { 418 | highlight.push(Token { 419 | index: i, 420 | highlight: Highlight::Number, 421 | }); 422 | prev_highlight = Highlight::Number; 423 | is_prev_sep = false; 424 | continue; 425 | } 426 | } 427 | 428 | prev_highlight = Highlight::None; 429 | is_prev_sep = is_separator(s); 430 | } 431 | self.highlight = highlight; 432 | } 433 | 434 | pub fn calc_width(&self, start: usize, end: usize) -> usize { 435 | let start = cmp::max(0, start); 436 | let end = cmp::min(self.string.graphemes(true).count(), end); 437 | self.string 438 | .graphemes(true) 439 | .skip(start) 440 | .take(end - start) 441 | .fold(0, |acc, s| acc + str_to_width(s)) 442 | } 443 | 444 | pub fn calc_x(&self, prev_x: usize, prev_row: &Row) -> usize { 445 | if prev_x == 0 { 446 | return 0; 447 | } 448 | let target_width = prev_row.calc_width(0, prev_x); 449 | let mut x = 0; 450 | let mut w = 0; 451 | for s in self.string.graphemes(true) { 452 | w += str_to_width(s); 453 | x += 1; 454 | if w >= target_width { 455 | if w != target_width { 456 | x -= 1; 457 | } 458 | break; 459 | } 460 | } 461 | x 462 | } 463 | 464 | pub fn len(&self) -> usize { 465 | self.string.graphemes(true).count() 466 | } 467 | 468 | fn insert(&mut self, c: char, at: usize) { 469 | if at >= self.len() { 470 | self.string.push(c); 471 | } else { 472 | let mut first: String = self.string.graphemes(true).take(at).collect(); 473 | let rest: String = self.string.graphemes(true).skip(at).collect(); 474 | first.push(c); 475 | self.string = first + &rest; 476 | } 477 | } 478 | 479 | fn delete(&mut self, at: usize) -> Option { 480 | if at >= self.len() { 481 | return None; 482 | } 483 | let first: String = self.string.graphemes(true).take(at).collect(); 484 | let mut rest = self.string.graphemes(true).skip(at); 485 | let deleted = rest.next().and_then(|s| s.chars().nth(0)); 486 | let rest: String = rest.collect(); 487 | self.string = first + &rest; 488 | deleted 489 | } 490 | 491 | fn append(&mut self, new: &Self) { 492 | self.string.push_str(&new.string); 493 | } 494 | 495 | fn split(&mut self, at: usize) -> Self { 496 | let first = self.string.graphemes(true).take(at).collect(); 497 | let rest = self.string.graphemes(true).skip(at).collect(); 498 | self.string = first; 499 | Row { 500 | string: rest, 501 | highlight: Vec::new(), 502 | } 503 | } 504 | 505 | fn find(&self, query: &str, at: usize, direction: &SearchDirection) -> Option { 506 | if at > self.len() { 507 | return None; 508 | } 509 | let (start, end) = match direction { 510 | SearchDirection::Forward => (at, self.len()), 511 | SearchDirection::Backward => (0, at), 512 | }; 513 | let substring: String = self 514 | .string 515 | .graphemes(true) 516 | .skip(start) 517 | .take(end - start) 518 | .collect(); 519 | let matching_byte_index = match direction { 520 | SearchDirection::Forward => substring.find(query), 521 | SearchDirection::Backward => substring.rfind(query), 522 | }; 523 | if let Some(matching_byte_index) = matching_byte_index { 524 | for (grapheme_index, (byte_index, _)) in substring.grapheme_indices(true).enumerate() { 525 | if matching_byte_index == byte_index { 526 | return Some(start + grapheme_index); 527 | } 528 | } 529 | } 530 | None 531 | } 532 | } 533 | 534 | fn str_to_width(s: &str) -> usize { 535 | match s { 536 | "\t" => 1 * TAB_STOP, 537 | _ => UnicodeWidthStr::width(s), 538 | } 539 | } 540 | -------------------------------------------------------------------------------- /core/src/editor.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | document::Document, 3 | error::Error, 4 | traits::{Filer, Input, Output}, 5 | }; 6 | use instant::Instant; 7 | use std::{cmp, time::Duration}; 8 | 9 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 10 | 11 | pub enum Key { 12 | Escape, 13 | Backspace, 14 | Del, 15 | Enter, 16 | Home, 17 | End, 18 | Command(Command), 19 | Page(Page), 20 | Arrow(Arrow), 21 | Char(char), 22 | CharUtf8(char), 23 | Unknown, 24 | } 25 | 26 | pub enum Command { 27 | Find, 28 | Undo, 29 | Redo, 30 | Save, 31 | Exit, 32 | } 33 | 34 | pub enum Page { 35 | Up, 36 | Down, 37 | } 38 | 39 | pub enum Arrow { 40 | Up, 41 | Down, 42 | Left, 43 | Right, 44 | } 45 | 46 | struct Screen { 47 | rows: usize, 48 | cols: usize, 49 | } 50 | 51 | #[derive(Clone)] 52 | pub struct Position { 53 | pub x: usize, 54 | pub y: usize, 55 | } 56 | 57 | struct Message { 58 | text: String, 59 | time: Instant, 60 | } 61 | 62 | impl Message { 63 | fn new(text: impl Into) -> Message { 64 | Message { 65 | text: text.into(), 66 | time: Instant::now(), 67 | } 68 | } 69 | } 70 | 71 | pub enum SearchDirection { 72 | Forward, 73 | Backward, 74 | } 75 | 76 | enum Mode { 77 | Edit, 78 | Search, 79 | Save, 80 | Exit, 81 | } 82 | 83 | pub struct Editor { 84 | input: I, 85 | output: O, 86 | filer: F, 87 | screen: Screen, 88 | cursor: Position, 89 | row_offset: usize, 90 | col_offset: usize, 91 | document: Document, 92 | message: Option, 93 | confirm: bool, 94 | } 95 | 96 | impl Editor { 97 | pub fn new(input: I, output: O, filer: F) -> Result { 98 | if let Some((screen_rows, screen_cols)) = output.get_window_size() { 99 | Ok(Editor { 100 | input, 101 | output, 102 | filer, 103 | screen: Screen { 104 | rows: screen_rows - 2, 105 | cols: screen_cols, 106 | }, 107 | cursor: Position { x: 0, y: 0 }, 108 | row_offset: 0, 109 | col_offset: 0, 110 | document: Document::new(), 111 | message: Some(Message::new( 112 | "HELP: Ctrl-S = save | Ctrl-Q = quit | Ctrl-F = find", 113 | )), 114 | confirm: false, 115 | }) 116 | } else { 117 | Err(Error::UnknownWindowSize) 118 | } 119 | } 120 | 121 | pub fn load(&mut self, filename: &String) -> Result<&mut Self, Error> { 122 | let file = self.filer.load(&filename)?; 123 | self.document = Document::open(filename.clone(), file); 124 | Ok(self) 125 | } 126 | 127 | pub fn save(&mut self) -> Result<(), Error> { 128 | let filename = self.document.get_filename().unwrap_or(String::new()); 129 | let res = self.filer.save(&filename, self.document.contents()); 130 | if res.is_ok() { 131 | self.document.reset_dirty(); 132 | } 133 | res 134 | } 135 | 136 | pub fn run(&mut self) -> Result { 137 | self.refresh_screen()?; 138 | match self.process_key_press()? { 139 | Mode::Edit => {} 140 | Mode::Search => { 141 | self.search_prompt(); 142 | } 143 | Mode::Save => { 144 | self.save_prompt(); 145 | } 146 | Mode::Exit => { 147 | self.output.clear_screen(); 148 | return Ok(true); 149 | } 150 | } 151 | Ok(false) 152 | } 153 | 154 | fn refresh_screen(&mut self) -> Result<(), Error> { 155 | self.scroll(); 156 | self.document.update_highlights(); 157 | 158 | let rows = self.draw_rows(); 159 | let status_bar = self.draw_status_bar(); 160 | let message_bar = self.draw_message_bar(); 161 | 162 | let x = self 163 | .document 164 | .row(self.cursor.y) 165 | .map(|row| row.calc_width(0, self.cursor.x - self.col_offset)) 166 | .unwrap_or(0); 167 | self.output.render_screen( 168 | rows, 169 | &status_bar, 170 | &message_bar, 171 | Position { 172 | x: (x + 1), 173 | y: (self.cursor.y - self.row_offset) + 1, 174 | }, 175 | ); 176 | self.output.flush()?; 177 | 178 | Ok(()) 179 | } 180 | 181 | fn scroll(&mut self) { 182 | if self.cursor.y < self.row_offset { 183 | self.row_offset = self.cursor.y; 184 | } 185 | if self.cursor.y >= self.row_offset + self.screen.rows { 186 | self.row_offset = self.cursor.y - self.screen.rows + 1; 187 | } 188 | if self.cursor.x < self.col_offset { 189 | self.col_offset = self.cursor.x; 190 | } 191 | if self.cursor.x >= self.col_offset + self.screen.cols { 192 | self.col_offset = self.cursor.x - self.screen.cols + 1; 193 | } 194 | } 195 | 196 | fn process_key_press(&mut self) -> Result { 197 | let mut pressed = true; 198 | match self.input.wait_for_key() { 199 | Key::Escape => {} 200 | Key::Enter => { 201 | self.document.insert_newline(&self.cursor); 202 | self.move_cursor(&Arrow::Right); 203 | } 204 | Key::Backspace => { 205 | if self.cursor.x > 0 || self.cursor.y > 0 { 206 | self.move_cursor(&Arrow::Left); 207 | self.document.delete(&self.cursor); 208 | } 209 | } 210 | Key::Del => { 211 | self.document.delete(&self.cursor); 212 | } 213 | Key::Home => self.cursor.x = 0, 214 | Key::End => { 215 | if let Some(row) = self.document.row(self.cursor.y) { 216 | self.cursor.x = row.len(); 217 | } 218 | } 219 | Key::Page(k) => { 220 | let direction = match k { 221 | Page::Up => { 222 | self.cursor.y = self.row_offset; 223 | Arrow::Up 224 | } 225 | Page::Down => { 226 | self.cursor.y = self.row_offset + self.screen.rows - 1; 227 | if self.cursor.y > self.document.len() { 228 | self.cursor.y = self.document.len(); 229 | } 230 | Arrow::Down 231 | } 232 | }; 233 | let mut times = self.screen.rows; 234 | while times > 0 { 235 | self.move_cursor(&direction); 236 | times -= 1; 237 | } 238 | } 239 | Key::Arrow(k) => { 240 | self.move_cursor(&k); 241 | } 242 | Key::Command(command) => match command { 243 | Command::Find => { 244 | return Ok(Mode::Search); 245 | } 246 | Command::Undo => { 247 | self.document.undo(); 248 | } 249 | Command::Redo => { 250 | self.document.redo(); 251 | } 252 | Command::Save => { 253 | return Ok(Mode::Save); 254 | } 255 | Command::Exit => { 256 | if self.document.is_dirty() && self.confirm == false { 257 | self.confirm = true; 258 | self.message = Some(Message::new( 259 | "WARNING!!! File has unsaved changes. Press Ctrl-Q 1 more times to quit.", 260 | )); 261 | return Ok(Mode::Edit); 262 | } else { 263 | return Ok(Mode::Exit); 264 | } 265 | } 266 | }, 267 | Key::Char(c) | Key::CharUtf8(c) => { 268 | self.document.insert(c, &self.cursor); 269 | self.move_cursor(&Arrow::Right); 270 | } 271 | Key::Unknown => { 272 | pressed = false; 273 | } 274 | } 275 | 276 | if pressed == true && self.confirm == true { 277 | self.confirm = false; 278 | self.message = None; 279 | } 280 | Ok(Mode::Edit) 281 | } 282 | 283 | fn move_cursor(&mut self, key: &Arrow) { 284 | match key { 285 | Arrow::Up if self.cursor.y > 0 => { 286 | if let Some(row) = self.document.row(self.cursor.y) { 287 | if let Some(row_next) = self.document.row(self.cursor.y - 1) { 288 | self.cursor.x = row_next.calc_x(self.cursor.x, row); 289 | self.cursor.y -= 1; 290 | } 291 | } 292 | } 293 | Arrow::Down if self.cursor.y < self.document.len() => { 294 | if let Some(row) = self.document.row(self.cursor.y) { 295 | if let Some(row_next) = self.document.row(self.cursor.y + 1) { 296 | self.cursor.x = row_next.calc_x(self.cursor.x, row); 297 | self.cursor.y += 1; 298 | } 299 | } 300 | } 301 | Arrow::Left => { 302 | if self.cursor.x > 0 { 303 | self.cursor.x -= 1 304 | } else if self.cursor.y > 0 { 305 | self.cursor.y -= 1; 306 | if let Some(row) = self.document.row(self.cursor.y) { 307 | self.cursor.x = row.len(); 308 | } 309 | } 310 | } 311 | Arrow::Right => { 312 | if let Some(row) = self.document.row(self.cursor.y) { 313 | let chars_len = row.len(); 314 | if self.cursor.x < chars_len { 315 | self.cursor.x += 1 316 | } else if self.cursor.x == chars_len { 317 | self.cursor.y += 1; 318 | self.cursor.x = 0; 319 | } 320 | } 321 | } 322 | _ => {} 323 | } 324 | if let Some(r) = self.document.row(self.cursor.y) { 325 | if self.cursor.x > r.len() { 326 | self.cursor.x = r.len(); 327 | } 328 | } 329 | } 330 | 331 | fn save_prompt(&mut self) { 332 | if self.document.get_filename().is_none() { 333 | let filename = self.prompt("Save as", "ESC to cancel", |_, _, _| {}); 334 | if filename.is_none() { 335 | self.message = Some(Message::new("Save aborted")); 336 | return; 337 | } 338 | self.document.set_filename(filename); 339 | } 340 | self.message = match self.save() { 341 | Ok(_) => Some(Message::new("File saved successfully.")), 342 | Err(_) => Some(Message::new("Error writing file!")), 343 | } 344 | } 345 | 346 | fn search_prompt(&mut self) { 347 | let cursor = self.cursor.clone(); 348 | let mut direction = SearchDirection::Forward; 349 | let res = self.prompt("Search", "Use ESC/Arrows/Enter", |editor, key, query| { 350 | let mut moved = false; 351 | match key { 352 | Key::Arrow(Arrow::Left) | Key::Arrow(Arrow::Up) => { 353 | direction = SearchDirection::Backward; 354 | editor.move_cursor(&Arrow::Left); 355 | moved = true; 356 | } 357 | Key::Arrow(Arrow::Right) | Key::Arrow(Arrow::Down) => { 358 | direction = SearchDirection::Forward; 359 | editor.move_cursor(&Arrow::Right); 360 | moved = true; 361 | } 362 | _ => { 363 | direction = SearchDirection::Forward; 364 | } 365 | } 366 | 367 | if let Some(pos) = editor.document.find(&query, &editor.cursor, &direction) { 368 | editor.cursor = pos; 369 | editor.scroll(); 370 | } else if moved == true { 371 | match direction { 372 | SearchDirection::Forward => editor.move_cursor(&Arrow::Left), 373 | SearchDirection::Backward => editor.move_cursor(&Arrow::Right), 374 | } 375 | } 376 | }); 377 | if res.is_none() { 378 | self.cursor = cursor; 379 | self.scroll(); 380 | } 381 | } 382 | 383 | fn prompt(&mut self, desc1: &str, desc2: &str, mut cb: C) -> Option 384 | where 385 | C: FnMut(&mut Self, Key, &String), 386 | { 387 | let mut message = String::new(); 388 | loop { 389 | self.message = Some(Message::new(format!("{}: {} ({})", desc1, message, desc2))); 390 | if self.refresh_screen().is_err() { 391 | return None; 392 | } 393 | let key = self.input.wait_for_key(); 394 | match key { 395 | Key::Escape => { 396 | self.message = None; 397 | return None; 398 | } 399 | Key::Del | Key::Backspace => { 400 | message.pop(); 401 | } 402 | Key::Enter => { 403 | if message.len() != 0 { 404 | self.message = None; 405 | return Some(message); 406 | } 407 | } 408 | Key::Char(c) | Key::CharUtf8(c) => { 409 | message.push(c); 410 | } 411 | _ => {} 412 | } 413 | cb(self, key, &message); 414 | } 415 | } 416 | 417 | fn draw_rows(&mut self) -> Vec { 418 | let mut vec = Vec::new(); 419 | let width = self.screen.cols; 420 | let height = self.screen.rows; 421 | let rows = self.document.len(); 422 | for y in 0..height { 423 | let mut buf = String::new(); 424 | let r_index = y + self.row_offset; 425 | if r_index >= rows { 426 | if rows == 0 && y == height / 3 { 427 | let message = create_welcome_message(width); 428 | buf.push_str(&message); 429 | } else { 430 | buf.push_str("~"); 431 | } 432 | } else { 433 | buf.push_str(&self.document.render_row( 434 | r_index, 435 | self.col_offset, 436 | self.col_offset + width, 437 | )); 438 | } 439 | vec.push(buf); 440 | } 441 | vec 442 | } 443 | 444 | fn draw_status_bar(&self) -> String { 445 | let mut buf = String::new(); 446 | let left = format!( 447 | "{} - {} lines {}", 448 | &self 449 | .document 450 | .get_filename() 451 | .map(|mut n| { 452 | n.truncate(20); 453 | n 454 | }) 455 | .unwrap_or(String::from("[No Name]")), 456 | self.document.len(), 457 | if self.document.is_dirty() { 458 | "(modified)" 459 | } else { 460 | "" 461 | } 462 | ); 463 | let right = format!( 464 | "{} | {}/{}", 465 | self.document.language.name(), 466 | self.cursor.y, 467 | self.document.len() 468 | ); 469 | let rlen = right.len(); 470 | let mut len = cmp::min(left.len(), self.screen.cols); 471 | buf.push_str(&left); 472 | while len < self.screen.cols { 473 | if self.screen.cols - len == rlen { 474 | buf.push_str(&right); 475 | break; 476 | } else { 477 | buf.push_str(" "); 478 | len += 1; 479 | } 480 | } 481 | buf 482 | } 483 | 484 | fn draw_message_bar(&self) -> String { 485 | let mut buf = String::new(); 486 | if let Some(message) = &self.message { 487 | if Instant::now() - message.time < Duration::new(5, 0) { 488 | let mut text = message.text.clone(); 489 | text.truncate(self.screen.cols); 490 | buf.push_str(&text); 491 | } 492 | } 493 | buf 494 | } 495 | } 496 | 497 | fn create_welcome_message(width: usize) -> String { 498 | let message = format!("Kilo editor -- version {}", VERSION); 499 | let padding = width.saturating_sub(message.len()) / 2; 500 | let spaces = " ".repeat(padding.saturating_sub(1)); 501 | let mut message = format!("~{}{}", spaces, message); 502 | message.truncate(width); 503 | message 504 | } 505 | -------------------------------------------------------------------------------- /core/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | #[derive(Debug)] 4 | pub enum Error { 5 | IO(io::Error), 6 | UnknownWindowSize, 7 | } 8 | 9 | impl From for Error { 10 | fn from(e: io::Error) -> Self { 11 | Self::IO(e) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /core/src/languages.rs: -------------------------------------------------------------------------------- 1 | use super::ansi_escape::*; 2 | 3 | #[derive(Clone)] 4 | pub struct Token { 5 | pub index: usize, 6 | pub highlight: Highlight, 7 | } 8 | 9 | #[derive(Clone, Copy, PartialEq)] 10 | pub enum Highlight { 11 | Comment, 12 | String, 13 | Number, 14 | None, 15 | } 16 | 17 | impl Highlight { 18 | pub fn color(&self) -> &str { 19 | match self { 20 | Highlight::Comment => COLOR_CYAN, 21 | Highlight::String => COLOR_MAGENTA, 22 | Highlight::Number => COLOR_RED, 23 | Highlight::None => COLOR_DEFAULT, 24 | } 25 | } 26 | } 27 | 28 | pub fn is_digit(s: &str) -> bool { 29 | match s { 30 | "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" => true, 31 | _ => false, 32 | } 33 | } 34 | 35 | pub fn is_separator(s: &str) -> bool { 36 | s == " " || s == "\0" || ",.()+-/*=~%<>[];".contains(s) 37 | } 38 | 39 | pub enum Language { 40 | C, 41 | Unknown, 42 | } 43 | 44 | impl Language { 45 | pub fn detect(filename: &str) -> Language { 46 | for ext in Language::C.exts() { 47 | if filename.ends_with(ext) { 48 | return Language::C; 49 | } 50 | } 51 | Language::Unknown 52 | } 53 | 54 | pub fn name(&self) -> &str { 55 | match self { 56 | Language::C => "C", 57 | Language::Unknown => "no ft", 58 | } 59 | } 60 | 61 | pub fn exts(&self) -> &'static [&'static str] { 62 | match self { 63 | Language::C => &[".c", ".h", ".cpp"], 64 | Language::Unknown => &[], 65 | } 66 | } 67 | 68 | pub fn flags(&self) -> &'static [&'static Highlight] { 69 | match self { 70 | Language::C => &[&Highlight::String, &Highlight::Number], 71 | Language::Unknown => &[], 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /core/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod ansi_escape; 2 | mod decode; 3 | mod document; 4 | mod editor; 5 | mod error; 6 | mod languages; 7 | mod traits; 8 | 9 | pub use ansi_escape::*; 10 | pub use decode::*; 11 | pub use editor::*; 12 | pub use error::*; 13 | pub use traits::*; 14 | -------------------------------------------------------------------------------- /core/src/traits.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | editor::{Key, Position}, 3 | error::Error, 4 | }; 5 | 6 | pub trait Input { 7 | fn new() -> Result 8 | where 9 | Self: Sized; 10 | fn wait_for_key(&self) -> Key; 11 | } 12 | 13 | pub trait Output { 14 | fn new() -> Self; 15 | fn write(&self, text: &str) -> (); 16 | fn render_screen( 17 | &self, 18 | rows: Vec, 19 | status_bar: &str, 20 | message_bar: &str, 21 | pos: Position, 22 | ) -> (); 23 | fn clear_screen(&self) -> (); 24 | fn flush(&self) -> Result<(), Error>; 25 | fn get_window_size(&self) -> Option<(usize, usize)>; 26 | } 27 | 28 | pub trait Filer { 29 | fn new() -> Self; 30 | fn load(&self, filename: &String) -> Result; 31 | fn save(&self, filename: &String, contents: Vec) -> Result<(), Error>; 32 | } 33 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inokawa/rust-editor/f61e876d810b7d29d94127f94a2ab0ec059692ff/example.gif -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "schedule:monthly", 6 | ":preserveSemverRanges", 7 | ":automergeMinor", 8 | ":maintainLockFilesMonthly" 9 | ], 10 | "timezone": "Asia/Tokyo", 11 | "configMigration": true 12 | } 13 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | /target 4 | /pkg 5 | /wasm-pack.log 6 | -------------------------------------------------------------------------------- /web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "web" 3 | description = "My super awesome Rust, WebAssembly, and Webpack project!" 4 | version = "0.1.0" 5 | authors = ["You "] 6 | categories = ["wasm"] 7 | readme = "README.md" 8 | edition = "2018" 9 | 10 | [lib] 11 | crate-type = ["cdylib"] 12 | 13 | [profile.release] 14 | lto = true 15 | 16 | [features] 17 | #default = ["wee_alloc"] 18 | 19 | [dependencies] 20 | core = { path = "../core" } 21 | js-sys = "0.3.70" 22 | wasm-bindgen = "0.2.93" 23 | wee_alloc = { version = "0.4.5", optional = true } 24 | futures = "0.3.30" 25 | wasm-bindgen-futures = "0.4.43" 26 | 27 | [dependencies.web-sys] 28 | version = "0.3.70" 29 | 30 | [target."cfg(debug_assertions)".dependencies] 31 | console_error_panic_hook = "0.1.7" 32 | 33 | [dev-dependencies] 34 | wasm-bindgen-test = "0.3.43" 35 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | ## How to install 2 | 3 | ```sh 4 | npm install 5 | ``` 6 | 7 | ## How to run in debug mode 8 | 9 | ```sh 10 | # Builds the project and opens it in a new browser tab. Auto-reloads when the project changes. 11 | npm start 12 | ``` 13 | 14 | ## How to build in release mode 15 | 16 | ```sh 17 | # Builds the project and places it into the `dist` folder. 18 | npm run build 19 | ``` 20 | 21 | ## How to run unit tests 22 | 23 | ```sh 24 | # Runs tests in Firefox 25 | npm test -- --firefox 26 | 27 | # Runs tests in Chrome 28 | npm test -- --chrome 29 | 30 | # Runs tests in Safari 31 | npm test -- --safari 32 | ``` 33 | 34 | ## What does each file do? 35 | 36 | * `Cargo.toml` contains the standard Rust metadata. You put your Rust dependencies in here. You must change this file with your details (name, description, version, authors, categories) 37 | 38 | * `package.json` contains the standard npm metadata. You put your JavaScript dependencies in here. You must change this file with your details (author, name, version) 39 | 40 | * `webpack.config.js` contains the Webpack configuration. You shouldn't need to change this, unless you have very special needs. 41 | 42 | * The `js` folder contains your JavaScript code (`index.js` is used to hook everything into Webpack, you don't need to change it). 43 | 44 | * The `src` folder contains your Rust code. 45 | 46 | * The `static` folder contains any files that you want copied as-is into the final build. It contains an `index.html` file which loads the `index.js` file. 47 | 48 | * The `tests` folder contains your Rust unit tests. 49 | -------------------------------------------------------------------------------- /web/js/index.ts: -------------------------------------------------------------------------------- 1 | import { Terminal } from "xterm"; 2 | import "xterm/css/xterm.css"; 3 | import * as Comlink from "comlink"; 4 | import { WasmWorker } from "./worker"; 5 | 6 | const term = new Terminal(); 7 | term.open(document.getElementById("terminal") as HTMLElement); 8 | term.resize(100, 40); 9 | (window as any).term = term; 10 | 11 | const wasm = Comlink.wrap( 12 | new Worker(new URL("./worker.ts", import.meta.url), { name: "wasm" }) 13 | ) as WasmWorker; 14 | 15 | (async () => { 16 | let prevKey = ""; 17 | term.onKey(async (e) => { 18 | prevKey = e.key; 19 | const event = e.domEvent; 20 | if (event.isComposing) return; 21 | await wasm.send_key(event.key, event.ctrlKey); 22 | }); 23 | term.onData(async (data) => { 24 | if (data === prevKey) return; 25 | for (const d of data.split("")) { 26 | await wasm.send_key(d, false); 27 | } 28 | }); 29 | 30 | await wasm.init( 31 | Comlink.proxy((data) => { 32 | term.write(data); 33 | }), 34 | term.cols, 35 | term.rows 36 | ); 37 | })(); 38 | -------------------------------------------------------------------------------- /web/js/worker.ts: -------------------------------------------------------------------------------- 1 | import * as Comlink from "comlink"; 2 | 3 | const keys: [key: string, ctrl: boolean][] = []; 4 | 5 | let wasm: typeof import("../pkg/index.js"); 6 | const worker = { 7 | init: async (write: (str: string) => void, cols: number, rows: number) => { 8 | (self as any).term = { 9 | write, 10 | read_key: () => { 11 | return keys[0]?.[0]; 12 | }, 13 | read_ctrl: () => { 14 | return keys[0]?.[1] ?? false; 15 | }, 16 | read_end: () => { 17 | keys.shift(); 18 | }, 19 | get_col_size: () => cols, 20 | get_row_size: () => rows, 21 | }; 22 | wasm = await import("../pkg/index.js"); 23 | }, 24 | send_key: async (key: string, ctrl: boolean) => { 25 | keys.push([key, ctrl]); 26 | }, 27 | }; 28 | 29 | Comlink.expose(worker); 30 | 31 | export type WasmWorker = typeof worker; 32 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "You ", 3 | "name": "rust-webpack-template", 4 | "version": "0.1.0", 5 | "scripts": { 6 | "build": "rimraf dist pkg && webpack", 7 | "start": "rimraf dist pkg && webpack-cli serve --open", 8 | "tsc": "tsc -p . --noEmit", 9 | "test": "cargo test && wasm-pack test --headless" 10 | }, 11 | "devDependencies": { 12 | "@wasm-tool/wasm-pack-plugin": "^1.7.0", 13 | "copy-webpack-plugin": "^12.0.2", 14 | "css-loader": "^7.1.2", 15 | "rimraf": "^6.0.0", 16 | "style-loader": "^4.0.0", 17 | "ts-loader": "^9.5.1", 18 | "typescript": "^5.3.3", 19 | "webpack": "^5.90.3", 20 | "webpack-cli": "^5.0.0", 21 | "webpack-dev-server": "^5.0.0" 22 | }, 23 | "dependencies": { 24 | "comlink": "4.4.2", 25 | "xterm": "4.19.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /web/src/filer.rs: -------------------------------------------------------------------------------- 1 | use core::{Error, Filer}; 2 | 3 | pub struct WebFile {} 4 | 5 | impl Filer for WebFile { 6 | fn new() -> Self { 7 | WebFile {} 8 | } 9 | 10 | fn load(&self, filename: &String) -> Result { 11 | Ok("TODO".to_string()) 12 | } 13 | 14 | fn save(&self, filename: &String, contents: Vec) -> Result<(), Error> { 15 | Ok(()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web/src/input.rs: -------------------------------------------------------------------------------- 1 | use super::xterm; 2 | use core::{Arrow, Command, Error, Input, Key, Page}; 3 | 4 | pub struct WebInput {} 5 | 6 | impl Input for WebInput { 7 | fn new() -> Result { 8 | Ok(WebInput {}) 9 | } 10 | 11 | fn wait_for_key(&self) -> Key { 12 | let key = xterm::xterm_read(); 13 | match key.0 { 14 | Some(s @ _) => match s.as_str() { 15 | "Escape" => Key::Escape, 16 | "Backspace" => Key::Backspace, 17 | "Delete" => Key::Del, 18 | "Enter" => Key::Enter, 19 | "Home" => Key::Home, 20 | "End" => Key::End, 21 | "PageUp" => Key::Page(Page::Up), 22 | "PageDown" => Key::Page(Page::Down), 23 | "ArrowUp" => Key::Arrow(Arrow::Up), 24 | "ArrowDown" => Key::Arrow(Arrow::Down), 25 | "ArrowLeft" => Key::Arrow(Arrow::Left), 26 | "ArrowRight" => Key::Arrow(Arrow::Right), 27 | "Tab" => Key::Char('\t'), 28 | c @ _ => { 29 | if key.1 == true { 30 | match c { 31 | "f" | "F" => return Key::Command(Command::Find), 32 | "q" | "Q" => return Key::Command(Command::Exit), 33 | "s" | "S" => return Key::Command(Command::Save), 34 | "z" | "Z" => return Key::Command(Command::Undo), 35 | "y" | "Y" => return Key::Command(Command::Redo), 36 | "h" | "H" => return Key::Backspace, 37 | "l" | "L" => return Key::Escape, 38 | _ => {} 39 | } 40 | } 41 | return Key::Char(s.chars().next().unwrap()); 42 | } 43 | }, 44 | None => Key::Unknown, 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod filer; 2 | pub mod input; 3 | pub mod output; 4 | pub mod xterm; 5 | 6 | use core::{Editor, Filer, Input, Output}; 7 | use js_sys::{Error, Promise}; 8 | use wasm_bindgen::prelude::*; 9 | use wasm_bindgen_futures::JsFuture; 10 | 11 | #[cfg(feature = "wee_alloc")] 12 | #[global_allocator] 13 | static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; 14 | 15 | #[wasm_bindgen( 16 | inline_js = "export function timeout() { return new Promise((resolve)=> setTimeout(resolve)); }" 17 | )] 18 | extern "C" { 19 | fn timeout() -> Promise; 20 | } 21 | 22 | #[wasm_bindgen(start)] 23 | pub async fn main_js() -> Result<(), JsValue> { 24 | let input = input::WebInput::new().unwrap(); 25 | let mut editor = Editor::new(input, output::WebOutput::new(), filer::WebFile::new()).unwrap(); 26 | 27 | loop { 28 | JsFuture::from(timeout()).await?; 29 | let quit = editor 30 | .run() 31 | .map_err(|err| JsValue::from(Error::new(&format!("{:?}", err))))?; 32 | if quit { 33 | break; 34 | } 35 | } 36 | Ok(()) 37 | } 38 | -------------------------------------------------------------------------------- /web/src/output.rs: -------------------------------------------------------------------------------- 1 | use super::xterm; 2 | use core::{Ansi, Error, Output, Position}; 3 | 4 | pub struct WebOutput {} 5 | 6 | impl Output for WebOutput { 7 | fn new() -> Self { 8 | WebOutput {} 9 | } 10 | 11 | fn write(&self, text: &str) { 12 | xterm::xterm_write(&text); 13 | } 14 | 15 | fn flush(&self) -> Result<(), Error> { 16 | Ok(()) 17 | } 18 | 19 | fn render_screen(&self, rows: Vec, status_bar: &str, message_bar: &str, pos: Position) { 20 | let buf = self.render_screen_wrap(rows, status_bar, message_bar, pos); 21 | self.write(&buf); 22 | } 23 | 24 | fn clear_screen(&self) { 25 | let buf = self.clear_screen_wrap(); 26 | self.write(&buf); 27 | } 28 | 29 | fn get_window_size(&self) -> Option<(usize, usize)> { 30 | Some(xterm::xterm_get_window_size()) 31 | } 32 | } 33 | 34 | impl Ansi for WebOutput {} 35 | -------------------------------------------------------------------------------- /web/src/xterm.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::prelude::*; 2 | 3 | #[wasm_bindgen] 4 | extern "C" { 5 | #[wasm_bindgen(js_namespace = term)] 6 | fn write(s: &str); 7 | #[wasm_bindgen(js_namespace = term)] 8 | fn read_key() -> Option; 9 | #[wasm_bindgen(js_namespace = term)] 10 | fn read_ctrl() -> bool; 11 | #[wasm_bindgen(js_namespace = term)] 12 | fn read_end(); 13 | #[wasm_bindgen(js_namespace = term)] 14 | fn get_col_size() -> usize; 15 | #[wasm_bindgen(js_namespace = term)] 16 | fn get_row_size() -> usize; 17 | } 18 | 19 | pub fn xterm_write(text: &str) { 20 | write(text); 21 | } 22 | 23 | pub fn xterm_read() -> (Option, bool) { 24 | let res = (read_key(), read_ctrl()); 25 | read_end(); 26 | res 27 | } 28 | 29 | pub fn xterm_get_window_size() -> (usize, usize) { 30 | let col = get_col_size(); 31 | let row = get_row_size(); 32 | (row, col) 33 | } 34 | -------------------------------------------------------------------------------- /web/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | rust-editor 6 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "esnext", 5 | "moduleResolution": "Bundler", 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "strict": true, 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true 15 | }, 16 | "include": ["js"], 17 | "exclude": ["node_modules", "js/**/*.spec.*"] 18 | } 19 | -------------------------------------------------------------------------------- /web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const CopyPlugin = require("copy-webpack-plugin"); 3 | const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin"); 4 | 5 | const dist = path.resolve(__dirname, "dist"); 6 | 7 | module.exports = { 8 | mode: "production", 9 | entry: { 10 | index: "./js/index.ts", 11 | }, 12 | output: { 13 | path: dist, 14 | filename: "[name].js", 15 | }, 16 | devServer: { 17 | static: { 18 | directory: dist, 19 | }, 20 | }, 21 | plugins: [ 22 | new CopyPlugin({ patterns: [path.resolve(__dirname, "static")] }), 23 | new WasmPackPlugin({ 24 | crateDirectory: __dirname, 25 | }), 26 | ], 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.tsx?$/, 31 | use: { 32 | loader: "ts-loader", 33 | options: { 34 | transpileOnly: true, 35 | }, 36 | }, 37 | }, 38 | { 39 | test: /\.css/, 40 | use: ["style-loader", "css-loader"], 41 | }, 42 | ], 43 | }, 44 | resolve: { 45 | extensions: [".ts", ".tsx", ".js", ".json", ".mjs", ".wasm"], 46 | }, 47 | experiments: { 48 | syncWebAssembly: true, 49 | }, 50 | }; 51 | --------------------------------------------------------------------------------