├── .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 | [](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 |
--------------------------------------------------------------------------------