1 | // Copyright (c) 2020 Lars Pontoppidan. All rights reserved. |
2 | // Use of this source code is governed by the MIT license distributed with this software. |
3 | // Don't use this editor for any serious work. |
4 | // A lot of functionality is missing compared to your favourite editor :) |
5 | import strings |
6 | import os |
7 | import math |
8 | import term.ui as tui |
9 | import encoding.utf8 |
10 | import encoding.utf8.east_asian |
11 | |
12 | const ( |
13 | rune_digits = [`0`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`] |
14 | |
15 | zero_width_unicode = [ |
16 | `\u034f`, // U+034F COMBINING GRAPHEME JOINER |
17 | `\u061c`, // U+061C ARABIC LETTER MARK |
18 | `\u17b4`, // U+17B4 KHMER VOWEL INHERENT AQ |
19 | `\u17b5`, // U+17B5 KHMER VOWEL INHERENT AA |
20 | `\u200a`, // U+200A HAIR SPACE |
21 | `\u200b`, // U+200B ZERO WIDTH SPACE |
22 | `\u200c`, // U+200C ZERO WIDTH NON-JOINER |
23 | `\u200d`, // U+200D ZERO WIDTH JOINER |
24 | `\u200e`, // U+200E LEFT-TO-RIGHT MARK |
25 | `\u200f`, // U+200F RIGHT-TO-LEFT MARK |
26 | `\u2060`, // U+2060 WORD JOINER |
27 | `\u2061`, // U+2061 FUNCTION APPLICATION |
28 | `\u2062`, // U+2062 INVISIBLE TIMES |
29 | `\u2063`, // U+2063 INVISIBLE SEPARATOR |
30 | `\u2064`, // U+2064 INVISIBLE PLUS |
31 | `\u206a`, // U+206A INHIBIT SYMMETRIC SWAPPING |
32 | `\u206b`, // U+206B ACTIVATE SYMMETRIC SWAPPING |
33 | `\u206c`, // U+206C INHIBIT ARABIC FORM SHAPING |
34 | `\u206d`, // U+206D ACTIVATE ARABIC FORM SHAPING |
35 | `\u206e`, // U+206E NATIONAL DIGIT SHAPES |
36 | `\u206f`, // U+206F NOMINAL DIGIT SHAPES |
37 | `\ufeff`, // U+FEFF ZERO WIDTH NO-BREAK SPACE |
38 | ] |
39 | ) |
40 | |
41 | enum Movement { |
42 | up |
43 | down |
44 | left |
45 | right |
46 | home |
47 | end |
48 | page_up |
49 | page_down |
50 | } |
51 | |
52 | struct View { |
53 | pub: |
54 | raw string |
55 | cursor Cursor |
56 | } |
57 | |
58 | struct App { |
59 | mut: |
60 | tui &tui.Context = unsafe { nil } |
61 | ed &Buffer = unsafe { nil } |
62 | current_file int |
63 | files []string |
64 | status string |
65 | t int |
66 | magnet_x int |
67 | footer_height int = 2 |
68 | viewport int |
69 | } |
70 | |
71 | fn (mut a App) set_status(msg string, duration_ms int) { |
72 | a.status = msg |
73 | a.t = duration_ms |
74 | } |
75 | |
76 | fn (mut a App) save() { |
77 | if a.cfile().len > 0 { |
78 | b := a.ed |
79 | os.write_file(a.cfile(), b.raw()) or { panic(err) } |
80 | a.set_status('Saved', 2000) |
81 | } else { |
82 | a.set_status('No file loaded', 4000) |
83 | } |
84 | } |
85 | |
86 | fn (mut a App) cfile() string { |
87 | if a.files.len == 0 { |
88 | return '' |
89 | } |
90 | if a.current_file >= a.files.len { |
91 | return '' |
92 | } |
93 | return a.files[a.current_file] |
94 | } |
95 | |
96 | fn (mut a App) visit_prev_file() { |
97 | if a.files.len == 0 { |
98 | a.current_file = 0 |
99 | } else { |
100 | a.current_file = (a.current_file + a.files.len - 1) % a.files.len |
101 | } |
102 | a.init_file() |
103 | } |
104 | |
105 | fn (mut a App) visit_next_file() { |
106 | if a.files.len == 0 { |
107 | a.current_file = 0 |
108 | } else { |
109 | a.current_file = (a.current_file + a.files.len + 1) % a.files.len |
110 | } |
111 | a.init_file() |
112 | } |
113 | |
114 | fn (mut a App) footer() { |
115 | w, h := a.tui.window_width, a.tui.window_height |
116 | mut b := a.ed |
117 | // flat := b.flat() |
118 | // snip := if flat.len > 19 { flat[..20] } else { flat } |
119 | finfo := if a.cfile().len > 0 { ' (' + os.file_name(a.cfile()) + ')' } else { '' } |
120 | mut status := a.status |
121 | a.tui.draw_text(0, h - 1, '─'.repeat(w)) |
122 | footer := '${finfo} Line ${b.cursor.pos_y + 1:4}/${b.lines.len:-4}, Column ${b.cursor.pos_x + 1:3}/${b.cur_line().len:-3} index: ${b.cursor_index():5} (ESC = quit, Ctrl+s = save)' |
123 | if footer.len < w { |
124 | a.tui.draw_text((w - footer.len) / 2, h, footer) |
125 | } else if footer.len == w { |
126 | a.tui.draw_text(0, h, footer) |
127 | } else { |
128 | a.tui.draw_text(0, h, footer[..w]) |
129 | } |
130 | if a.t <= 0 { |
131 | status = '' |
132 | } else { |
133 | a.tui.set_bg_color( |
134 | r: 200 |
135 | g: 200 |
136 | b: 200 |
137 | ) |
138 | a.tui.set_color( |
139 | r: 0 |
140 | g: 0 |
141 | b: 0 |
142 | ) |
143 | a.tui.draw_text((w + 4 - status.len) / 2, h - 1, ' ${status} ') |
144 | a.tui.reset() |
145 | a.t -= 33 |
146 | } |
147 | } |
148 | |
149 | struct Buffer { |
150 | tab_width int = 4 |
151 | pub mut: |
152 | lines []string |
153 | cursor Cursor |
154 | } |
155 | |
156 | fn (b Buffer) flat() string { |
157 | return b.raw().replace_each(['\n', r'\n', '\t', r'\t']) |
158 | } |
159 | |
160 | fn (b Buffer) raw() string { |
161 | return b.lines.join('\n') |
162 | } |
163 | |
164 | fn (b Buffer) view(from int, to int) View { |
165 | l := b.cur_line().runes() |
166 | mut x := 0 |
167 | for i := 0; i < b.cursor.pos_x && i < l.len; i++ { |
168 | if l[i] == `\t` { |
169 | x += b.tab_width |
170 | continue |
171 | } |
172 | x++ |
173 | } |
174 | mut lines := []string{} |
175 | for i, line in b.lines { |
176 | if i >= from && i <= to { |
177 | lines << line |
178 | } |
179 | } |
180 | raw := lines.join('\n') |
181 | return View{ |
182 | raw: raw.replace('\t', strings.repeat(` `, b.tab_width)) |
183 | cursor: Cursor{ |
184 | pos_x: x |
185 | pos_y: b.cursor.pos_y |
186 | } |
187 | } |
188 | } |
189 | |
190 | fn (b Buffer) line(i int) string { |
191 | if i < 0 || i >= b.lines.len { |
192 | return '' |
193 | } |
194 | return b.lines[i] |
195 | } |
196 | |
197 | fn (b Buffer) cur_line() string { |
198 | return b.line(b.cursor.pos_y) |
199 | } |
200 | |
201 | fn (b Buffer) cur_slice() string { |
202 | line := b.line(b.cursor.pos_y).runes() |
203 | if b.cursor.pos_x == 0 || b.cursor.pos_x > line.len { |
204 | return '' |
205 | } |
206 | return line[..b.cursor.pos_x].string() |
207 | } |
208 | |
209 | fn (b Buffer) cursor_index() int { |
210 | mut i := 0 |
211 | for y, line in b.lines { |
212 | if b.cursor.pos_y == y { |
213 | i += b.cursor.pos_x |
214 | break |
215 | } |
216 | i += line.runes().len + 1 |
217 | } |
218 | return i |
219 | } |
220 | |
221 | fn (mut b Buffer) put(s string) { |
222 | has_line_ending := s.contains('\n') |
223 | x, y := b.cursor.xy() |
224 | if b.lines.len == 0 { |
225 | b.lines.prepend('') |
226 | } |
227 | line := b.lines[y].runes() |
228 | l, r := line[..x].string(), line[x..].string() |
229 | if has_line_ending { |
230 | mut lines := s.split('\n') |
231 | lines[0] = l + lines[0] |
232 | lines[lines.len - 1] += r |
233 | b.lines.delete(y) |
234 | b.lines.insert(y, lines) |
235 | last := lines[lines.len - 1].runes() |
236 | b.cursor.set(last.len, y + lines.len - 1) |
237 | if s == '\n' { |
238 | b.cursor.set(0, b.cursor.pos_y) |
239 | } |
240 | } else { |
241 | b.lines[y] = l + s + r |
242 | b.cursor.set(x + s.runes().len, y) |
243 | } |
244 | $if debug { |
245 | flat := s.replace('\n', r'\n') |
246 | eprintln(@MOD + '.' + @STRUCT + '::' + @FN + ' "${flat}"') |
247 | } |
248 | } |
249 | |
250 | fn (mut b Buffer) del(amount int) string { |
251 | if amount == 0 { |
252 | return '' |
253 | } |
254 | x, y := b.cursor.xy() |
255 | if amount < 0 { // don't delete left if we're at 0,0 |
256 | if x == 0 && y == 0 { |
257 | return '' |
258 | } |
259 | } else if x >= b.cur_line().runes().len && y >= b.lines.len - 1 { |
260 | return '' |
261 | } |
262 | mut removed := '' |
263 | if amount < 0 { // backspace (backward) |
264 | i := b.cursor_index() |
265 | raw_runes := b.raw().runes() |
266 | removed = raw_runes[i + amount..i].string() |
267 | mut left := amount * -1 |
268 | for li := y; li >= 0 && left > 0; li-- { |
269 | ln := b.lines[li].runes() |
270 | if left == ln.len + 1 { // All of the line + 1 - since we're going backwards the "+1" is the line break delimiter. |
271 | b.lines.delete(li) |
272 | left = 0 |
273 | if y == 0 { |
274 | return '' |
275 | } |
276 | line_above := b.lines[li - 1].runes() |
277 | b.cursor.pos_x = line_above.len |
278 | b.cursor.pos_y-- |
279 | break |
280 | } else if left > ln.len { |
281 | b.lines.delete(li) |
282 | if ln.len == 0 { // line break delimiter |
283 | left-- |
284 | if y == 0 { |
285 | return '' |
286 | } |
287 | line_above := b.lines[li - 1].runes() |
288 | b.cursor.pos_x = line_above.len |
289 | } else { |
290 | left -= ln.len |
291 | } |
292 | b.cursor.pos_y-- |
293 | } else { |
294 | if x == 0 { |
295 | if y == 0 { |
296 | return '' |
297 | } |
298 | line_above := b.lines[li - 1].runes() |
299 | if ln.len == 0 { // at line break |
300 | b.lines.delete(li) |
301 | b.cursor.pos_y-- |
302 | b.cursor.pos_x = line_above.len |
303 | } else { |
304 | b.lines[li - 1] = line_above.string() + ln.string() |
305 | b.lines.delete(li) |
306 | b.cursor.pos_y-- |
307 | b.cursor.pos_x = line_above.len |
308 | } |
309 | } else if x == 1 { |
310 | runes := b.lines[li].runes() |
311 | b.lines[li] = runes[left..].string() |
312 | b.cursor.pos_x = 0 |
313 | } else { |
314 | b.lines[li] = ln[..x - left].string() + ln[x..].string() |
315 | b.cursor.pos_x -= left |
316 | } |
317 | left = 0 |
318 | break |
319 | } |
320 | } |
321 | } else { // delete (forward) |
322 | i := b.cursor_index() + 1 |
323 | raw_buffer := b.raw().runes() |
324 | from_i := i |
325 | mut to_i := i + amount |
326 | |
327 | if to_i > raw_buffer.len { |
328 | to_i = raw_buffer.len |
329 | } |
330 | removed = raw_buffer[from_i..to_i].string() |
331 | mut left := amount |
332 | for li := y; li >= 0 && left > 0; li++ { |
333 | ln := b.lines[li].runes() |
334 | if x == ln.len { // at line end |
335 | if y + 1 <= b.lines.len { |
336 | b.lines[li] = ln.string() + b.lines[y + 1] |
337 | b.lines.delete(y + 1) |
338 | left-- |
339 | b.del(left) |
340 | } |
341 | } else if left > ln.len { |
342 | b.lines.delete(li) |
343 | left -= ln.len |
344 | } else { |
345 | b.lines[li] = ln[..x].string() + ln[x + left..].string() |
346 | left = 0 |
347 | } |
348 | } |
349 | } |
350 | $if debug { |
351 | flat := removed.replace('\n', r'\n') |
352 | eprintln(@MOD + '.' + @STRUCT + '::' + @FN + ' "${flat}"') |
353 | } |
354 | return removed |
355 | } |
356 | |
357 | fn (mut b Buffer) free() { |
358 | $if debug { |
359 | eprintln(@MOD + '.' + @STRUCT + '::' + @FN) |
360 | } |
361 | for line in b.lines { |
362 | unsafe { line.free() } |
363 | } |
364 | unsafe { b.lines.free() } |
365 | } |
366 | |
367 | fn (mut b Buffer) move_updown(amount int) { |
368 | b.cursor.move(0, amount) |
369 | // Check the move |
370 | line := b.cur_line().runes() |
371 | if b.cursor.pos_x > line.len { |
372 | b.cursor.set(line.len, b.cursor.pos_y) |
373 | } |
374 | } |
375 | |
376 | // move_cursor will navigate the cursor within the buffer bounds |
377 | fn (mut b Buffer) move_cursor(amount int, movement Movement) { |
378 | cur_line := b.cur_line().runes() |
379 | match movement { |
380 | .up { |
381 | if b.cursor.pos_y - amount >= 0 { |
382 | b.move_updown(-amount) |
383 | } |
384 | } |
385 | .down { |
386 | if b.cursor.pos_y + amount < b.lines.len { |
387 | b.move_updown(amount) |
388 | } |
389 | } |
390 | .page_up { |
391 | dlines := math.min(b.cursor.pos_y, amount) |
392 | b.move_updown(-dlines) |
393 | } |
394 | .page_down { |
395 | dlines := math.min(b.lines.len - 1, b.cursor.pos_y + amount) - b.cursor.pos_y |
396 | b.move_updown(dlines) |
397 | } |
398 | .left { |
399 | if b.cursor.pos_x - amount >= 0 { |
400 | b.cursor.move(-amount, 0) |
401 | } else if b.cursor.pos_y > 0 { |
402 | b.cursor.set(b.line(b.cursor.pos_y - 1).runes().len, b.cursor.pos_y - 1) |
403 | } |
404 | } |
405 | .right { |
406 | if b.cursor.pos_x + amount <= cur_line.len { |
407 | b.cursor.move(amount, 0) |
408 | } else if b.cursor.pos_y + 1 < b.lines.len { |
409 | b.cursor.set(0, b.cursor.pos_y + 1) |
410 | } |
411 | } |
412 | .home { |
413 | b.cursor.set(0, b.cursor.pos_y) |
414 | } |
415 | .end { |
416 | b.cursor.set(cur_line.len, b.cursor.pos_y) |
417 | } |
418 | } |
419 | } |
420 | |
421 | fn (mut b Buffer) move_to_word(movement Movement) { |
422 | a := if movement == .left { -1 } else { 1 } |
423 | |
424 | mut line := b.cur_line().runes() |
425 | mut x, mut y := b.cursor.pos_x, b.cursor.pos_y |
426 | if x + a < 0 && y > 0 { |
427 | y-- |
428 | line = b.line(b.cursor.pos_y - 1).runes() |
429 | x = line.len |
430 | } else if x + a >= line.len && y + 1 < b.lines.len { |
431 | y++ |
432 | line = b.line(b.cursor.pos_y + 1).runes() |
433 | x = 0 |
434 | } |
435 | // first, move past all non-`a-zA-Z0-9_` characters |
436 | for x + a >= 0 && x + a < line.len && !(utf8.is_letter(line[x + a]) |
437 | || line[x + a] in rune_digits || line[x + a] == `_`) { |
438 | x += a |
439 | } |
440 | // then, move past all the letters and numbers |
441 | for x + a >= 0 && x + a < line.len && (utf8.is_letter(line[x + a]) |
442 | || line[x + a] in rune_digits || line[x + a] == `_`) { |
443 | x += a |
444 | } |
445 | // if the cursor is out of bounds, move it to the next/previous line |
446 | if x + a >= 0 && x + a <= line.len { |
447 | x += a |
448 | } else if a < 0 && y + 1 > b.lines.len && y - 1 >= 0 { |
449 | y += a |
450 | x = 0 |
451 | } |
452 | b.cursor.set(x, y) |
453 | } |
454 | |
455 | struct Cursor { |
456 | pub mut: |
457 | pos_x int |
458 | pos_y int |
459 | } |
460 | |
461 | fn (mut c Cursor) set(x int, y int) { |
462 | c.pos_x = x |
463 | c.pos_y = y |
464 | } |
465 | |
466 | fn (mut c Cursor) move(x int, y int) { |
467 | c.pos_x += x |
468 | c.pos_y += y |
469 | } |
470 | |
471 | fn (c Cursor) xy() (int, int) { |
472 | return c.pos_x, c.pos_y |
473 | } |
474 | |
475 | // App callbacks |
476 | fn init(mut app App) { |
477 | app.init_file() |
478 | } |
479 | |
480 | fn (mut a App) init_file() { |
481 | a.ed = &Buffer{} |
482 | mut init_y := 0 |
483 | mut init_x := 0 |
484 | if a.files.len > 0 && a.current_file < a.files.len && a.files[a.current_file].len > 0 { |
485 | if !os.is_file(a.files[a.current_file]) && a.files[a.current_file].contains(':') { |
486 | // support the file:line:col: format |
487 | fparts := a.files[a.current_file].split(':') |
488 | if fparts.len > 0 { |
489 | a.files[a.current_file] = fparts[0] |
490 | } |
491 | if fparts.len > 1 { |
492 | init_y = fparts[1].int() - 1 |
493 | } |
494 | if fparts.len > 2 { |
495 | init_x = fparts[2].int() - 1 |
496 | } |
497 | } |
498 | if os.is_file(a.files[a.current_file]) { |
499 | // 'vico: ' + |
500 | a.tui.set_window_title(a.files[a.current_file]) |
501 | mut b := a.ed |
502 | content := os.read_file(a.files[a.current_file]) or { panic(err) } |
503 | b.put(content) |
504 | a.ed.cursor.pos_x = init_x |
505 | a.ed.cursor.pos_y = init_y |
506 | } |
507 | } |
508 | } |
509 | |
510 | fn (a &App) view_height() int { |
511 | return a.tui.window_height - a.footer_height - 1 |
512 | } |
513 | |
514 | // magnet_cursor_x will place the cursor as close to it's last move left or right as possible |
515 | fn (mut a App) magnet_cursor_x() { |
516 | mut buffer := a.ed |
517 | if buffer.cursor.pos_x < a.magnet_x { |
518 | if a.magnet_x < buffer.cur_line().runes().len { |
519 | move_x := a.magnet_x - buffer.cursor.pos_x |
520 | buffer.move_cursor(move_x, .right) |
521 | } |
522 | } |
523 | } |
524 | |
525 | fn frame(mut a App) { |
526 | mut ed := a.ed |
527 | a.tui.clear() |
528 | scroll_limit := a.view_height() |
529 | // scroll down |
530 | if ed.cursor.pos_y > a.viewport + scroll_limit { // scroll down |
531 | a.viewport = ed.cursor.pos_y - scroll_limit |
532 | } else if ed.cursor.pos_y < a.viewport { // scroll up |
533 | a.viewport = ed.cursor.pos_y |
534 | } |
535 | view := ed.view(a.viewport, scroll_limit + a.viewport) |
536 | a.tui.draw_text(0, 0, view.raw) |
537 | a.footer() |
538 | |
539 | // Unicode: Handle correct mapping of cursor X position in terminal. |
540 | mut ch_x := view.cursor.pos_x |
541 | mut sl := ed.cur_slice().replace('\t', ' '.repeat(ed.tab_width)) |
542 | if sl.len > 0 { |
543 | // Strip out any zero-width codepoints. |
544 | sl = sl.runes().filter(it !in zero_width_unicode).string() |
545 | ch_x = east_asian.display_width(sl, 1) |
546 | } |
547 | |
548 | a.tui.set_cursor_position(ch_x + 1, ed.cursor.pos_y + 1 - a.viewport) |
549 | a.tui.flush() |
550 | } |
551 | |
552 | fn event(e &tui.Event, mut a App) { |
553 | mut buffer := a.ed |
554 | if e.typ == .key_down { |
555 | match e.code { |
556 | .escape { |
557 | exit(0) |
558 | } |
559 | .enter { |
560 | buffer.put('\n') |
561 | } |
562 | .backspace { |
563 | buffer.del(-1) |
564 | } |
565 | .delete { |
566 | buffer.del(1) |
567 | } |
568 | .left { |
569 | if e.modifiers == .ctrl { |
570 | buffer.move_to_word(.left) |
571 | } else if e.modifiers.is_empty() { |
572 | buffer.move_cursor(1, .left) |
573 | } |
574 | a.magnet_x = buffer.cursor.pos_x |
575 | } |
576 | .right { |
577 | if e.modifiers == .ctrl { |
578 | buffer.move_to_word(.right) |
579 | } else if e.modifiers.is_empty() { |
580 | buffer.move_cursor(1, .right) |
581 | } |
582 | a.magnet_x = buffer.cursor.pos_x |
583 | } |
584 | .up { |
585 | buffer.move_cursor(1, .up) |
586 | a.magnet_cursor_x() |
587 | } |
588 | .down { |
589 | buffer.move_cursor(1, .down) |
590 | a.magnet_cursor_x() |
591 | } |
592 | .page_up { |
593 | buffer.move_cursor(a.view_height(), .page_up) |
594 | } |
595 | .page_down { |
596 | buffer.move_cursor(a.view_height(), .page_down) |
597 | } |
598 | .home { |
599 | buffer.move_cursor(1, .home) |
600 | } |
601 | .end { |
602 | buffer.move_cursor(1, .end) |
603 | } |
604 | 48...57, 97...122 { // 0-9a-zA-Z |
605 | if e.modifiers == .ctrl { |
606 | if e.code == .s { |
607 | a.save() |
608 | } |
609 | } else if !(e.modifiers.has(.ctrl | .alt) || e.code == .null) { |
610 | buffer.put(e.ascii.ascii_str()) |
611 | } |
612 | } |
613 | else { |
614 | if e.modifiers == .alt { |
615 | if e.code == .comma { |
616 | a.visit_prev_file() |
617 | return |
618 | } |
619 | if e.code == .period { |
620 | a.visit_next_file() |
621 | return |
622 | } |
623 | } |
624 | |
625 | buffer.put(e.utf8) |
626 | } |
627 | } |
628 | } else if e.typ == .mouse_scroll { |
629 | direction := if e.direction == .up { Movement.down } else { Movement.up } |
630 | buffer.move_cursor(1, direction) |
631 | } |
632 | } |
633 | |
634 | fn main() { |
635 | mut files := []string{} |
636 | if os.args.len > 1 { |
637 | files << os.args[1..] |
638 | } |
639 | mut a := &App{ |
640 | files: files |
641 | } |
642 | a.tui = tui.init( |
643 | user_data: a |
644 | init_fn: init |
645 | frame_fn: frame |
646 | event_fn: event |
647 | capture_events: true |
648 | ) |
649 | a.tui.run()! |
650 | } |