v / examples / term.ui
Raw file | 650 loc (609 sloc) | 14.38 KB | Latest commit hash 868908b80
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 (
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
41enum Movement {
42 up
43 down
44 left
45 right
46 home
47 end
48 page_up
49 page_down
50}
51
52struct View {
53pub:
54 raw string
55 cursor Cursor
56}
57
58struct App {
59mut:
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
71fn (mut a App) set_status(msg string, duration_ms int) {
72 a.status = msg
73 a.t = duration_ms
74}
75
76fn (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
86fn (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
96fn (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
105fn (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
114fn (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
149struct Buffer {
150 tab_width int = 4
151pub mut:
152 lines []string
153 cursor Cursor
154}
155
156fn (b Buffer) flat() string {
157 return b.raw().replace_each(['\n', r'\n', '\t', r'\t'])
158}
159
160fn (b Buffer) raw() string {
161 return b.lines.join('\n')
162}
163
164fn (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
190fn (b Buffer) line(i int) string {
191 if i < 0 || i >= b.lines.len {
192 return ''
193 }
194 return b.lines[i]
195}
196
197fn (b Buffer) cur_line() string {
198 return b.line(b.cursor.pos_y)
199}
200
201fn (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
209fn (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
221fn (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
250fn (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
357fn (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
367fn (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
377fn (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
421fn (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
455struct Cursor {
456pub mut:
457 pos_x int
458 pos_y int
459}
460
461fn (mut c Cursor) set(x int, y int) {
462 c.pos_x = x
463 c.pos_y = y
464}
465
466fn (mut c Cursor) move(x int, y int) {
467 c.pos_x += x
468 c.pos_y += y
469}
470
471fn (c Cursor) xy() (int, int) {
472 return c.pos_x, c.pos_y
473}
474
475// App callbacks
476fn init(mut app App) {
477 app.init_file()
478}
479
480fn (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
510fn (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
515fn (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
525fn 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
552fn 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
634fn 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}