]> git.lizzy.rs Git - micro.git/blob - cmd/micro/view.go
Add nicer deleting for soft tabs
[micro.git] / cmd / micro / view.go
1 package main
2
3 import (
4         "github.com/atotto/clipboard"
5         "github.com/gdamore/tcell"
6         "io/ioutil"
7         "strconv"
8         "strings"
9         "time"
10 )
11
12 // The View struct stores information about a view into a buffer.
13 // It has a stores information about the cursor, and the viewport
14 // that the user sees the buffer from.
15 type View struct {
16         cursor Cursor
17
18         // The topmost line, used for vertical scrolling
19         topline int
20         // The leftmost column, used for horizontal scrolling
21         leftCol int
22
23         // Percentage of the terminal window that this view takes up (from 0 to 100)
24         widthPercent  int
25         heightPercent int
26
27         // Actual with and height
28         width  int
29         height int
30
31         // How much to offset because of line numbers
32         lineNumOffset int
33
34         // The eventhandler for undo/redo
35         eh *EventHandler
36
37         // The buffer
38         buf *Buffer
39         // The statusline
40         sline Statusline
41
42         // Since tcell doesn't differentiate between a mouse release event
43         // and a mouse move event with no keys pressed, we need to keep
44         // track of whether or not the mouse was pressed (or not released) last event to determine
45         // mouse release events
46         mouseReleased bool
47
48         // This stores when the last click was
49         // This is useful for detecting double and triple clicks
50         lastClickTime time.Time
51
52         // Was the last mouse event actually a double click?
53         // Useful for detecting triple clicks -- if a double click is detected
54         // but the last mouse event was actually a double click, it's a triple click
55         doubleClick bool
56         // Same here, just to keep track for mouse move events
57         tripleClick bool
58
59         // Syntax highlighting matches
60         matches SyntaxMatches
61         // The matches from the last frame
62         lastMatches SyntaxMatches
63 }
64
65 // NewView returns a new fullscreen view
66 func NewView(buf *Buffer) *View {
67         return NewViewWidthHeight(buf, 100, 100)
68 }
69
70 // NewViewWidthHeight returns a new view with the specified width and height percentages
71 // Note that w and h are percentages not actual values
72 func NewViewWidthHeight(buf *Buffer, w, h int) *View {
73         v := new(View)
74
75         v.widthPercent = w
76         v.heightPercent = h
77         v.Resize(screen.Size())
78
79         v.OpenBuffer(buf)
80
81         v.eh = NewEventHandler(v)
82
83         v.sline = Statusline{
84                 view: v,
85         }
86
87         return v
88 }
89
90 // Resize recalculates the actual width and height of the view from the width and height
91 // percentages
92 // This is usually called when the window is resized, or when a split has been added and
93 // the percentages have changed
94 func (v *View) Resize(w, h int) {
95         // Always include 1 line for the command line at the bottom
96         h--
97         v.width = int(float32(w) * float32(v.widthPercent) / 100)
98         // We subtract 1 for the statusline
99         v.height = int(float32(h)*float32(v.heightPercent)/100) - 1
100 }
101
102 // ScrollUp scrolls the view up n lines (if possible)
103 func (v *View) ScrollUp(n int) {
104         // Try to scroll by n but if it would overflow, scroll by 1
105         if v.topline-n >= 0 {
106                 v.topline -= n
107         } else if v.topline > 0 {
108                 v.topline--
109         }
110 }
111
112 // ScrollDown scrolls the view down n lines (if possible)
113 func (v *View) ScrollDown(n int) {
114         // Try to scroll by n but if it would overflow, scroll by 1
115         if v.topline+n <= len(v.buf.lines)-v.height {
116                 v.topline += n
117         } else if v.topline < len(v.buf.lines)-v.height {
118                 v.topline++
119         }
120 }
121
122 // PageUp scrolls the view up a page
123 func (v *View) PageUp() {
124         if v.topline > v.height {
125                 v.ScrollUp(v.height)
126         } else {
127                 v.topline = 0
128         }
129 }
130
131 // PageDown scrolls the view down a page
132 func (v *View) PageDown() {
133         if len(v.buf.lines)-(v.topline+v.height) > v.height {
134                 v.ScrollDown(v.height)
135         } else {
136                 if len(v.buf.lines) >= v.height {
137                         v.topline = len(v.buf.lines) - v.height
138                 }
139         }
140 }
141
142 // HalfPageUp scrolls the view up half a page
143 func (v *View) HalfPageUp() {
144         if v.topline > v.height/2 {
145                 v.ScrollUp(v.height / 2)
146         } else {
147                 v.topline = 0
148         }
149 }
150
151 // HalfPageDown scrolls the view down half a page
152 func (v *View) HalfPageDown() {
153         if len(v.buf.lines)-(v.topline+v.height) > v.height/2 {
154                 v.ScrollDown(v.height / 2)
155         } else {
156                 if len(v.buf.lines) >= v.height {
157                         v.topline = len(v.buf.lines) - v.height
158                 }
159         }
160 }
161
162 // CanClose returns whether or not the view can be closed
163 // If there are unsaved changes, the user will be asked if the view can be closed
164 // causing them to lose the unsaved changes
165 // The message is what to print after saying "You have unsaved changes. "
166 func (v *View) CanClose(msg string) bool {
167         if v.buf.IsDirty() {
168                 quit, canceled := messenger.Prompt("You have unsaved changes. " + msg)
169                 if !canceled {
170                         if strings.ToLower(quit) == "yes" || strings.ToLower(quit) == "y" {
171                                 return true
172                         } else if strings.ToLower(quit) == "save" || strings.ToLower(quit) == "s" {
173                                 v.Save()
174                                 return true
175                         }
176                 }
177         } else {
178                 return true
179         }
180         return false
181 }
182
183 // Save the buffer to disk
184 func (v *View) Save() {
185         // If this is an empty buffer, ask for a filename
186         if v.buf.path == "" {
187                 filename, canceled := messenger.Prompt("Filename: ")
188                 if !canceled {
189                         v.buf.path = filename
190                         v.buf.name = filename
191                 } else {
192                         return
193                 }
194         }
195         err := v.buf.Save()
196         if err != nil {
197                 messenger.Error(err.Error())
198         } else {
199                 messenger.Message("Saved " + v.buf.path)
200         }
201 }
202
203 // Copy the selection to the system clipboard
204 func (v *View) Copy() {
205         if v.cursor.HasSelection() {
206                 if !clipboard.Unsupported {
207                         clipboard.WriteAll(v.cursor.GetSelection())
208                 } else {
209                         messenger.Error("Clipboard is not supported on your system")
210                 }
211         }
212 }
213
214 // Cut the selection to the system clipboard
215 func (v *View) Cut() {
216         if v.cursor.HasSelection() {
217                 if !clipboard.Unsupported {
218                         clipboard.WriteAll(v.cursor.GetSelection())
219                         v.cursor.DeleteSelection()
220                         v.cursor.ResetSelection()
221                 } else {
222                         messenger.Error("Clipboard is not supported on your system")
223                 }
224         }
225 }
226
227 // Paste whatever is in the system clipboard into the buffer
228 // Delete and paste if the user has a selection
229 func (v *View) Paste() {
230         if !clipboard.Unsupported {
231                 if v.cursor.HasSelection() {
232                         v.cursor.DeleteSelection()
233                         v.cursor.ResetSelection()
234                 }
235                 clip, _ := clipboard.ReadAll()
236                 v.eh.Insert(v.cursor.Loc(), clip)
237                 v.cursor.SetLoc(v.cursor.Loc() + Count(clip))
238         } else {
239                 messenger.Error("Clipboard is not supported on your system")
240         }
241 }
242
243 // SelectAll selects the entire buffer
244 func (v *View) SelectAll() {
245         v.cursor.curSelection[1] = 0
246         v.cursor.curSelection[0] = v.buf.Len()
247         // Put the cursor at the beginning
248         v.cursor.x = 0
249         v.cursor.y = 0
250 }
251
252 // OpenBuffer opens a new buffer in this view.
253 // This resets the topline, event handler and cursor.
254 func (v *View) OpenBuffer(buf *Buffer) {
255         v.buf = buf
256         v.topline = 0
257         v.leftCol = 0
258         // Put the cursor at the first spot
259         v.cursor = Cursor{
260                 x: 0,
261                 y: 0,
262                 v: v,
263         }
264         v.cursor.ResetSelection()
265
266         v.eh = NewEventHandler(v)
267         v.matches = Match(v)
268
269         // Set mouseReleased to true because we assume the mouse is not being pressed when
270         // the editor is opened
271         v.mouseReleased = true
272         v.lastClickTime = time.Time{}
273 }
274
275 // OpenFile opens a new file in the current view
276 // It makes sure that the current buffer can be closed first (unsaved changes)
277 func (v *View) OpenFile() {
278         if v.CanClose("Continue? (yes, no, save) ") {
279                 filename, canceled := messenger.Prompt("File to open: ")
280                 if canceled {
281                         return
282                 }
283                 file, err := ioutil.ReadFile(filename)
284
285                 if err != nil {
286                         messenger.Error(err.Error())
287                         return
288                 }
289                 buf := NewBuffer(string(file), filename)
290                 v.OpenBuffer(buf)
291         }
292 }
293
294 // Relocate moves the view window so that the cursor is in view
295 // This is useful if the user has scrolled far away, and then starts typing
296 func (v *View) Relocate() bool {
297         ret := false
298         cy := v.cursor.y
299         if cy < v.topline {
300                 v.topline = cy
301                 ret = true
302         }
303         if cy > v.topline+v.height-1 {
304                 v.topline = cy - v.height + 1
305                 ret = true
306         }
307
308         cx := v.cursor.GetVisualX()
309         if cx < v.leftCol {
310                 v.leftCol = cx
311                 ret = true
312         }
313         if cx+v.lineNumOffset+1 > v.leftCol+v.width {
314                 v.leftCol = cx - v.width + v.lineNumOffset + 1
315                 ret = true
316         }
317         return ret
318 }
319
320 // MoveToMouseClick moves the cursor to location x, y assuming x, y were given
321 // by a mouse click
322 func (v *View) MoveToMouseClick(x, y int) {
323         if y-v.topline > v.height-1 {
324                 v.ScrollDown(1)
325                 y = v.height + v.topline - 1
326         }
327         if y >= len(v.buf.lines) {
328                 y = len(v.buf.lines) - 1
329         }
330         if y < 0 {
331                 y = 0
332         }
333         if x < 0 {
334                 x = 0
335         }
336
337         x = v.cursor.GetCharPosInLine(y, x)
338         if x > Count(v.buf.lines[y]) {
339                 x = Count(v.buf.lines[y])
340         }
341         v.cursor.x = x
342         v.cursor.y = y
343         v.cursor.lastVisualX = v.cursor.GetVisualX()
344 }
345
346 // HandleEvent handles an event passed by the main loop
347 func (v *View) HandleEvent(event tcell.Event) {
348         // This bool determines whether the view is relocated at the end of the function
349         // By default it's true because most events should cause a relocate
350         relocate := true
351
352         switch e := event.(type) {
353         case *tcell.EventResize:
354                 // Window resized
355                 v.Resize(e.Size())
356         case *tcell.EventKey:
357                 switch e.Key() {
358                 case tcell.KeyUp:
359                         // Cursor up
360                         v.cursor.ResetSelection()
361                         v.cursor.Up()
362                 case tcell.KeyDown:
363                         // Cursor down
364                         v.cursor.ResetSelection()
365                         v.cursor.Down()
366                 case tcell.KeyLeft:
367                         // Cursor left
368                         v.cursor.ResetSelection()
369                         v.cursor.Left()
370                 case tcell.KeyRight:
371                         // Cursor right
372                         v.cursor.ResetSelection()
373                         v.cursor.Right()
374                 case tcell.KeyEnter:
375                         // Insert a newline
376                         if v.cursor.HasSelection() {
377                                 v.cursor.DeleteSelection()
378                                 v.cursor.ResetSelection()
379                         }
380
381                         v.eh.Insert(v.cursor.Loc(), "\n")
382                         ws := GetLeadingWhitespace(v.buf.lines[v.cursor.y])
383                         v.cursor.Right()
384
385                         if settings.AutoIndent {
386                                 v.eh.Insert(v.cursor.Loc(), ws)
387                                 for i := 0; i < len(ws); i++ {
388                                         v.cursor.Right()
389                                 }
390                         }
391                         v.cursor.lastVisualX = v.cursor.GetVisualX()
392                 case tcell.KeySpace:
393                         // Insert a space
394                         if v.cursor.HasSelection() {
395                                 v.cursor.DeleteSelection()
396                                 v.cursor.ResetSelection()
397                         }
398                         v.eh.Insert(v.cursor.Loc(), " ")
399                         v.cursor.Right()
400                 case tcell.KeyBackspace2, tcell.KeyBackspace:
401                         // Delete a character
402                         if v.cursor.HasSelection() {
403                                 v.cursor.DeleteSelection()
404                                 v.cursor.ResetSelection()
405                         } else if v.cursor.Loc() > 0 {
406                                 // We have to do something a bit hacky here because we want to
407                                 // delete the line by first moving left and then deleting backwards
408                                 // but the undo redo would place the cursor in the wrong place
409                                 // So instead we move left, save the position, move back, delete
410                                 // and restore the position
411
412                                 // If the user is using spaces instead of tabs and they are deleting
413                                 // whitespace at the start of the line, we should delete as if its a
414                                 // tab (tabSize number of spaces)
415                                 lineStart := v.buf.lines[v.cursor.y][:v.cursor.x]
416                                 if settings.TabsToSpaces && IsSpaces(lineStart) && len(lineStart) != 0 && len(lineStart)%settings.TabSize == 0 {
417                                         loc := v.cursor.Loc()
418                                         v.cursor.SetLoc(loc - settings.TabSize)
419                                         cx, cy := v.cursor.x, v.cursor.y
420                                         v.cursor.SetLoc(loc)
421                                         v.eh.Remove(loc-settings.TabSize, loc)
422                                         v.cursor.x, v.cursor.y = cx, cy
423                                 } else {
424                                         v.cursor.Left()
425                                         cx, cy := v.cursor.x, v.cursor.y
426                                         v.cursor.Right()
427                                         loc := v.cursor.Loc()
428                                         v.eh.Remove(loc-1, loc)
429                                         v.cursor.x, v.cursor.y = cx, cy
430                                 }
431                         }
432                         v.cursor.lastVisualX = v.cursor.GetVisualX()
433                 case tcell.KeyTab:
434                         // Insert a tab
435                         if v.cursor.HasSelection() {
436                                 v.cursor.DeleteSelection()
437                                 v.cursor.ResetSelection()
438                         }
439                         if settings.TabsToSpaces {
440                                 v.eh.Insert(v.cursor.Loc(), Spaces(settings.TabSize))
441                                 for i := 0; i < settings.TabSize; i++ {
442                                         v.cursor.Right()
443                                 }
444                         } else {
445                                 v.eh.Insert(v.cursor.Loc(), "\t")
446                                 v.cursor.Right()
447                         }
448                 case tcell.KeyCtrlS:
449                         v.Save()
450                 case tcell.KeyCtrlF:
451                         if v.cursor.HasSelection() {
452                                 searchStart = v.cursor.curSelection[1]
453                         } else {
454                                 searchStart = ToCharPos(v.cursor.x, v.cursor.y, v.buf)
455                         }
456                         BeginSearch()
457                 case tcell.KeyCtrlN:
458                         if v.cursor.HasSelection() {
459                                 searchStart = v.cursor.curSelection[1]
460                         } else {
461                                 searchStart = ToCharPos(v.cursor.x, v.cursor.y, v.buf)
462                         }
463                         messenger.Message("Find: " + lastSearch)
464                         Search(lastSearch, v, true)
465                 case tcell.KeyCtrlP:
466                         if v.cursor.HasSelection() {
467                                 searchStart = v.cursor.curSelection[0]
468                         } else {
469                                 searchStart = ToCharPos(v.cursor.x, v.cursor.y, v.buf)
470                         }
471                         messenger.Message("Find: " + lastSearch)
472                         Search(lastSearch, v, false)
473                 case tcell.KeyCtrlZ:
474                         v.eh.Undo()
475                 case tcell.KeyCtrlY:
476                         v.eh.Redo()
477                 case tcell.KeyCtrlC:
478                         v.Copy()
479                 case tcell.KeyCtrlX:
480                         v.Cut()
481                 case tcell.KeyCtrlV:
482                         v.Paste()
483                 case tcell.KeyCtrlA:
484                         v.SelectAll()
485                 case tcell.KeyCtrlO:
486                         v.OpenFile()
487                 case tcell.KeyHome:
488                         v.topline = 0
489                         relocate = false
490                 case tcell.KeyEnd:
491                         if v.height > len(v.buf.lines) {
492                                 v.topline = 0
493                         } else {
494                                 v.topline = len(v.buf.lines) - v.height
495                         }
496                         relocate = false
497                 case tcell.KeyPgUp:
498                         v.PageUp()
499                         relocate = false
500                 case tcell.KeyPgDn:
501                         v.PageDown()
502                         relocate = false
503                 case tcell.KeyCtrlU:
504                         v.HalfPageUp()
505                         relocate = false
506                 case tcell.KeyCtrlD:
507                         v.HalfPageDown()
508                         relocate = false
509                 case tcell.KeyRune:
510                         // Insert a character
511                         if v.cursor.HasSelection() {
512                                 v.cursor.DeleteSelection()
513                                 v.cursor.ResetSelection()
514                         }
515                         v.eh.Insert(v.cursor.Loc(), string(e.Rune()))
516                         v.cursor.Right()
517                 }
518         case *tcell.EventMouse:
519                 x, y := e.Position()
520                 x -= v.lineNumOffset - v.leftCol
521                 y += v.topline
522
523                 button := e.Buttons()
524
525                 switch button {
526                 case tcell.Button1:
527                         // Left click
528                         origX, origY := v.cursor.x, v.cursor.y
529                         v.MoveToMouseClick(x, y)
530
531                         if v.mouseReleased {
532                                 if (time.Since(v.lastClickTime)/time.Millisecond < doubleClickThreshold) &&
533                                         (origX == v.cursor.x && origY == v.cursor.y) {
534                                         if v.doubleClick {
535                                                 // Triple click
536                                                 v.lastClickTime = time.Now()
537
538                                                 v.tripleClick = true
539                                                 v.doubleClick = false
540
541                                                 v.cursor.SelectLine()
542                                         } else {
543                                                 // Double click
544                                                 v.lastClickTime = time.Now()
545
546                                                 v.doubleClick = true
547                                                 v.tripleClick = false
548
549                                                 v.cursor.SelectWord()
550                                         }
551                                 } else {
552                                         v.doubleClick = false
553                                         v.tripleClick = false
554                                         v.lastClickTime = time.Now()
555
556                                         loc := v.cursor.Loc()
557                                         v.cursor.curSelection[0] = loc
558                                         v.cursor.curSelection[1] = loc
559                                 }
560                         } else {
561                                 if v.tripleClick {
562                                         v.cursor.AddLineToSelection()
563                                 } else if v.doubleClick {
564                                         v.cursor.AddWordToSelection()
565                                 } else {
566                                         v.cursor.curSelection[1] = v.cursor.Loc()
567                                 }
568                         }
569                         v.mouseReleased = false
570                 case tcell.ButtonNone:
571                         // Mouse event with no click
572                         if !v.mouseReleased {
573                                 // Mouse was just released
574
575                                 // Relocating here isn't really necessary because the cursor will
576                                 // be in the right place from the last mouse event
577                                 // However, if we are running in a terminal that doesn't support mouse motion
578                                 // events, this still allows the user to make selections, except only after they
579                                 // release the mouse
580
581                                 if !v.doubleClick && !v.tripleClick {
582                                         v.MoveToMouseClick(x, y)
583                                         v.cursor.curSelection[1] = v.cursor.Loc()
584                                 }
585                                 v.mouseReleased = true
586                         }
587                         // We don't want to relocate because otherwise the view will be relocated
588                         // every time the user moves the cursor
589                         relocate = false
590                 case tcell.WheelUp:
591                         // Scroll up two lines
592                         v.ScrollUp(2)
593                         // We don't want to relocate if the user is scrolling
594                         relocate = false
595                 case tcell.WheelDown:
596                         // Scroll down two lines
597                         v.ScrollDown(2)
598                         // We don't want to relocate if the user is scrolling
599                         relocate = false
600                 }
601         }
602
603         if relocate {
604                 v.Relocate()
605         }
606         if settings.Syntax {
607                 v.matches = Match(v)
608         }
609 }
610
611 // DisplayView renders the view to the screen
612 func (v *View) DisplayView() {
613         // The character number of the character in the top left of the screen
614         charNum := ToCharPos(0, v.topline, v.buf)
615
616         // Convert the length of buffer to a string, and get the length of the string
617         // We are going to have to offset by that amount
618         maxLineLength := len(strconv.Itoa(len(v.buf.lines)))
619         // + 1 for the little space after the line number
620         v.lineNumOffset = maxLineLength + 1
621
622         var highlightStyle tcell.Style
623
624         for lineN := 0; lineN < v.height; lineN++ {
625                 var x int
626                 // If the buffer is smaller than the view height
627                 // and we went too far, break
628                 if lineN+v.topline >= len(v.buf.lines) {
629                         break
630                 }
631                 line := v.buf.lines[lineN+v.topline]
632
633                 // Write the line number
634                 lineNumStyle := defStyle
635                 if style, ok := colorscheme["line-number"]; ok {
636                         lineNumStyle = style
637                 }
638                 // Write the spaces before the line number if necessary
639                 lineNum := strconv.Itoa(lineN + v.topline + 1)
640                 for i := 0; i < maxLineLength-len(lineNum); i++ {
641                         screen.SetContent(x, lineN, ' ', nil, lineNumStyle)
642                         x++
643                 }
644                 // Write the actual line number
645                 for _, ch := range lineNum {
646                         screen.SetContent(x, lineN, ch, nil, lineNumStyle)
647                         x++
648                 }
649                 // Write the extra space
650                 screen.SetContent(x, lineN, ' ', nil, lineNumStyle)
651                 x++
652
653                 // Write the line
654                 tabchars := 0
655                 runes := []rune(line)
656                 for colN := v.leftCol; colN < v.leftCol+v.width; colN++ {
657                         if colN >= len(runes) {
658                                 break
659                         }
660                         ch := runes[colN]
661                         var lineStyle tcell.Style
662                         if settings.Syntax {
663                                 // Syntax highlighting is enabled
664                                 highlightStyle = v.matches[lineN][colN]
665                         }
666
667                         if v.cursor.HasSelection() &&
668                                 (charNum >= v.cursor.curSelection[0] && charNum < v.cursor.curSelection[1] ||
669                                         charNum < v.cursor.curSelection[0] && charNum >= v.cursor.curSelection[1]) {
670
671                                 lineStyle = defStyle.Reverse(true)
672
673                                 if style, ok := colorscheme["selection"]; ok {
674                                         lineStyle = style
675                                 }
676                         } else {
677                                 lineStyle = highlightStyle
678                         }
679
680                         if ch == '\t' {
681                                 screen.SetContent(x+tabchars, lineN, ' ', nil, lineStyle)
682                                 tabSize := settings.TabSize
683                                 for i := 0; i < tabSize-1; i++ {
684                                         tabchars++
685                                         if x-v.leftCol+tabchars >= v.lineNumOffset {
686                                                 screen.SetContent(x-v.leftCol+tabchars, lineN, ' ', nil, lineStyle)
687                                         }
688                                 }
689                         } else {
690                                 if x-v.leftCol+tabchars >= v.lineNumOffset {
691                                         screen.SetContent(x-v.leftCol+tabchars, lineN, ch, nil, lineStyle)
692                                 }
693                         }
694                         charNum++
695                         x++
696                 }
697                 // Here we are at a newline
698
699                 // The newline may be selected, in which case we should draw the selection style
700                 // with a space to represent it
701                 if v.cursor.HasSelection() &&
702                         (charNum >= v.cursor.curSelection[0] && charNum < v.cursor.curSelection[1] ||
703                                 charNum < v.cursor.curSelection[0] && charNum >= v.cursor.curSelection[1]) {
704
705                         selectStyle := defStyle.Reverse(true)
706
707                         if style, ok := colorscheme["selection"]; ok {
708                                 selectStyle = style
709                         }
710                         screen.SetContent(x-v.leftCol+tabchars, lineN, ' ', nil, selectStyle)
711                 }
712
713                 charNum++
714         }
715         // v.lastMatches = matches
716 }
717
718 // Display renders the view, the cursor, and statusline
719 func (v *View) Display() {
720         v.DisplayView()
721         v.cursor.Display()
722         v.sline.Display()
723 }