/
editor.go
479 lines (387 loc) · 11.4 KB
/
editor.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
package editor
import (
"fmt"
"sync"
"github.com/mattn/go-runewidth"
"github.com/nsf/termbox-go"
)
type EditorConfig struct {
ScrollEnabled bool
}
// Editor represents the editor's skeleton.
// The editor is composed of two components:
// 1. an editable text area; which acts as the primary interactive area.
// 2. a status bar; which displays messages on different events, for example, when an user joins a session, etc.
type Editor struct {
// Text contains the editor's content.
Text []rune
// Cursor represents the cursor position of the editor.
Cursor int
// Width represents the terminal's width in characters.
Width int
// Height represents the terminal's width in characters.
Height int
// ColOff is the number of columns between the start of a line and the left of the editor window
ColOff int
// RowOff is the number of rows between the beginning of the text and the top of the editor window
RowOff int
// ShowMsg acts like a switch for the status bar.
ShowMsg bool
// StatusMsg holds the text to be displayed in the status bar.
StatusMsg string
// StatusChan is used to send and receive status messages.
StatusChan chan string
// StatusMu protects against concurrent reads and writes to status bar info.
StatusMu sync.Mutex
// Users holds the names of all users connected to the server, displayed in the status bar.
Users []string
// ScrollEnabled determines whether or not the user can scroll past the initial editor
// window. It is set by the EditorConfig.
ScrollEnabled bool
// IsConnected shows whether the editor is currently connected to the server.
IsConnected bool
// DrawChan is used to send and receive signals to update the terminal display.
DrawChan chan int
// mu prevents concurrent reads and writes to the editor state.
mu sync.RWMutex
}
var userColors = []termbox.Attribute{
termbox.ColorGreen,
termbox.ColorYellow,
termbox.ColorBlue,
termbox.ColorMagenta,
termbox.ColorCyan,
termbox.ColorLightYellow,
termbox.ColorLightMagenta,
termbox.ColorLightGreen,
termbox.ColorLightRed,
termbox.ColorRed,
}
// NewEditor returns a new instance of the editor.
func NewEditor(conf EditorConfig) *Editor {
return &Editor{
ScrollEnabled: conf.ScrollEnabled,
StatusChan: make(chan string, 100),
DrawChan: make(chan int, 10000),
}
}
// GetText returns the editor's content.
func (e *Editor) GetText() []rune {
e.mu.RLock()
defer e.mu.RUnlock()
return e.Text
}
// SetText sets the given string as the editor's content.
func (e *Editor) SetText(text string) {
e.mu.Lock()
e.Text = []rune(text)
e.mu.Unlock()
}
// GetX returns the X-axis component of the current cursor position.
func (e *Editor) GetX() int {
x, _ := e.calcXY(e.Cursor)
return x
}
// SetX sets the X-axis component of the current cursor position to the specified X position.
func (e *Editor) SetX(x int) {
e.Cursor = x
}
// GetY returns the Y-axis component of the current cursor position.
func (e *Editor) GetY() int {
_, y := e.calcXY(e.Cursor)
return y
}
// GetWidth returns the editor's width (in characters).
func (e *Editor) GetWidth() int {
return e.Width
}
// GetWidth returns the editor's height (in characters).
func (e *Editor) GetHeight() int {
return e.Height
}
// SetSize sets the editor size to the specific width and height.
func (e *Editor) SetSize(w, h int) {
e.Width = w
e.Height = h
}
// GetRowOff returns the vertical offset of the editor window from the start of the text.
func (e *Editor) GetRowOff() int {
return e.RowOff
}
// GetColOff returns the horizontal offset of the editor window from the start of a line.
func (e *Editor) GetColOff() int {
return e.ColOff
}
// IncRowOff increments the vertical offset of the editor window from the start of the
// text by inc.
func (e *Editor) IncRowOff(inc int) {
e.RowOff += inc
}
// IncColOff increments the horizontal offset of the editor window from the start of a
// line by inc.
func (e *Editor) IncColOff(inc int) {
e.ColOff += inc
}
// SendDraw sends a draw signal to the drawLoop. Use this function to
// ensure concurrency safety for rendering the editor.
func (e *Editor) SendDraw() {
e.DrawChan <- 1
}
// Draw updates the UI by setting cells with the editor's content.
func (e *Editor) Draw() {
_ = termbox.Clear(termbox.ColorDefault, termbox.ColorDefault)
e.mu.RLock()
cursor := e.Cursor
e.mu.RUnlock()
cx, cy := e.calcXY(cursor)
// draw cursor x position relative to row offset
if cx-e.GetColOff() > 0 {
cx -= e.GetColOff()
}
// draw cursor y position relative to row offset
if cy-e.GetRowOff() > 0 {
cy -= e.GetRowOff()
}
termbox.SetCursor(cx-1, cy-1)
// find the starting and ending row of the termbox window.
yStart := e.GetRowOff()
yEnd := yStart + e.GetHeight() - 1 // -1 accounts for the status bar
// find the starting ending column of the termbox window.
xStart := e.GetColOff()
x, y := 0, 0
for i := 0; i < len(e.Text) && y < yEnd; i++ {
if e.Text[i] == rune('\n') {
x = 0
y++
} else {
// Set cell content. setX and setY account for the window offset.
setY := y - yStart
setX := x - xStart
termbox.SetCell(setX, setY, e.Text[i], termbox.ColorDefault, termbox.ColorDefault)
// Update x by rune's width.
x = x + runewidth.RuneWidth(e.Text[i])
}
}
e.DrawStatusBar()
// Flush back buffer!
termbox.Flush()
}
// DrawStatusBar shows all status and debug information on the bottom line of the editor.
func (e *Editor) DrawStatusBar() {
e.StatusMu.Lock()
showMsg := e.ShowMsg
e.StatusMu.Unlock()
if showMsg {
e.DrawStatusMsg()
} else {
e.DrawInfoBar()
}
// Render connection indicator
if e.IsConnected {
termbox.SetBg(e.Width-1, e.Height-1, termbox.ColorGreen)
} else {
termbox.SetBg(e.Width-1, e.Height-1, termbox.ColorRed)
}
}
// DrawStatusMsg draws the editor's status message at the bottom of the
// termbox window.
func (e *Editor) DrawStatusMsg() {
e.StatusMu.Lock()
statusMsg := e.StatusMsg
e.StatusMu.Unlock()
for i, r := range []rune(statusMsg) {
termbox.SetCell(i, e.Height-1, r, termbox.ColorDefault, termbox.ColorDefault)
}
}
// DrawInfoBar draws the editor's debug information and the names of the
// active users in the editing session at the bottom of the termbox window.
func (e *Editor) DrawInfoBar() {
e.StatusMu.Lock()
users := e.Users
e.StatusMu.Unlock()
e.mu.RLock()
length := len(e.Text)
e.mu.RUnlock()
x := 0
for i, user := range users {
for _, r := range user {
colorIdx := i % len(userColors)
termbox.SetCell(x, e.Height-1, r, userColors[colorIdx], termbox.ColorDefault)
x++
}
termbox.SetCell(x, e.Height-1, ' ', termbox.ColorDefault, termbox.ColorDefault)
x++
}
e.mu.RLock()
cursor := e.Cursor
e.mu.RUnlock()
cx, cy := e.calcXY(cursor)
debugInfo := fmt.Sprintf(" x=%d, y=%d, cursor=%d, len(text)=%d", cx, cy, e.Cursor, length)
for _, r := range debugInfo {
termbox.SetCell(x, e.Height-1, r, termbox.ColorDefault, termbox.ColorDefault)
x++
}
}
// MoveCursor updates the cursor position horizontally by a given x increment, and
// vertically by one line in the direction indicated by y. The positive directions are
// right and down, respectively.
// This is used by the UI layer, where it updates the cursor position on keypresses.
func (e *Editor) MoveCursor(x, y int) {
if len(e.Text) == 0 && e.Cursor == 0 {
return
}
// Move cursor horizontally.
newCursor := e.Cursor + x
// Move cursor vertically.
if y > 0 {
newCursor = e.calcCursorDown()
}
if y < 0 {
newCursor = e.calcCursorUp()
}
if e.ScrollEnabled {
cx, cy := e.calcXY(newCursor)
// move the window to adjust for the cursor
rowStart := e.GetRowOff()
rowEnd := e.GetRowOff() + e.GetHeight() - 1
if cy <= rowStart { // scroll up
e.IncRowOff(cy - rowStart - 1)
}
if cy > rowEnd { // scroll down
e.IncRowOff(cy - rowEnd)
}
colStart := e.GetColOff()
colEnd := e.GetColOff() + e.GetWidth()
if cx <= colStart { // scroll left
e.IncColOff(cx - (colStart + 1))
}
if cx > colEnd { // scroll right
e.IncColOff(cx - colEnd)
}
}
// Reset to bounds.
if newCursor > len(e.Text) {
newCursor = len(e.Text)
}
if newCursor < 0 {
newCursor = 0
}
e.mu.Lock()
e.Cursor = newCursor
e.mu.Unlock()
}
// For the functions calcCursorUp and calcCursorDown, newline characters are found by iterating backward and forward from the current cursor position.
// These characters are taken as the "start" and "end" of the current line.
// The "offset" from the start of the current line to the cursor is calculated and used to determine the final cursor position on the target line, based on whether the offset is greater than the length of the target line.
// "pos" is used as a placeholder variable for the cursor.
// calcCursorUp calculates and returns the intended cursor position after moving the cursor up one line.
func (e *Editor) calcCursorUp() int {
pos := e.Cursor
offset := 0
// If the initial cursor is out of the bounds of the text or already on a newline, move it.
if pos == len(e.Text) || e.Text[pos] == '\n' {
offset++
pos--
}
if pos < 0 {
pos = 0
}
start, end := pos, pos
// Find the start of the current line.
for start > 0 && e.Text[start] != '\n' {
start--
}
// If the cursor is already on the first line, move to the beginning of the Text.
if start == 0 {
return 0
}
// Find the end of the current line.
for end < len(e.Text) && e.Text[end] != '\n' {
end++
}
// Find the start of the previous line.
prevStart := start - 1
for prevStart >= 0 && e.Text[prevStart] != '\n' {
prevStart--
}
// Calculate the distance from the start of the current line to the cursor.
offset += pos - start
if offset <= start-prevStart {
return prevStart + offset
} else {
return start
}
}
// calcCursorDown calculates and returns the intended cursor position after moving the cursor down one line.
func (e *Editor) calcCursorDown() int {
pos := e.Cursor
offset := 0
// If the initial cursor position is out of the bounds or already on a newline, move it.
if pos == len(e.Text) || e.Text[pos] == '\n' {
offset++
pos--
}
if pos < 0 {
pos = 0
}
start, end := pos, pos
// Find the start of the current line.
for start > 0 && e.Text[start] != '\n' {
start--
}
// This handles the case where the cursor is on the first line. This is necessary because the start of the first line is not a newline character, unlike the other lines in the text.
if start == 0 && e.Text[start] != '\n' {
offset++
}
// Find the end of the current line.
for end < len(e.Text) && e.Text[end] != '\n' {
end++
}
// This handles the case where the cursor is on a newline. end has to be incremented, otherwise start == end.
if e.Text[pos] == '\n' && e.Cursor != 0 {
end++
}
// If the Cursor is already on the last line, move to the end of the text.
if end == len(e.Text) {
return len(e.Text)
}
// Find the end of the next line.
nextEnd := end + 1
for nextEnd < len(e.Text) && e.Text[nextEnd] != '\n' {
nextEnd++
}
// Calculate the distance from the start of the current line to the cursor.
offset += pos - start
if offset < nextEnd-end {
return end + offset
} else {
return nextEnd
}
}
// calcXY returns the x and y coordinates of the cell at the given
// index in the text.
func (e *Editor) calcXY(index int) (int, int) {
x := 1
y := 1
if index < 0 {
return x, y
}
e.mu.RLock()
length := len(e.Text)
e.mu.RUnlock()
if index > length {
index = length
}
for i := 0; i < index; i++ {
e.mu.RLock()
r := e.Text[i]
e.mu.RUnlock()
if r == rune('\n') {
x = 1
y++
} else {
x = x + runewidth.RuneWidth(r)
}
}
return x, y
}