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