4 "github.com/atotto/clipboard"
5 "github.com/gdamore/tcell"
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.
18 // The topmost line, used for vertical scrolling
20 // The leftmost column, used for horizontal scrolling
23 // Percentage of the terminal window that this view takes up (from 0 to 100)
27 // Actual with and height
31 // How much to offset because of line numbers
34 // The eventhandler for undo/redo
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
48 // This stores when the last click was
49 // This is useful for detecting double and triple clicks
50 lastClickTime time.Time
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
56 // Same here, just to keep track for mouse move events
59 // Syntax highlighting matches
61 // The matches from the last frame
62 lastMatches SyntaxMatches
65 // NewView returns a new fullscreen view
66 func NewView(buf *Buffer) *View {
67 return NewViewWidthHeight(buf, 100, 100)
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 {
77 v.Resize(screen.Size())
81 v.eh = NewEventHandler(v)
90 // Resize recalculates the actual width and height of the view from the width and height
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
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
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 {
107 } else if v.topline > 0 {
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 {
117 } else if v.topline < len(v.buf.lines)-v.height {
122 // PageUp scrolls the view up a page
123 func (v *View) PageUp() {
124 if v.topline > v.height {
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)
136 if len(v.buf.lines) >= v.height {
137 v.topline = len(v.buf.lines) - v.height
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)
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)
156 if len(v.buf.lines) >= v.height {
157 v.topline = len(v.buf.lines) - v.height
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 {
168 quit, canceled := messenger.Prompt("You have unsaved changes. " + msg)
170 if strings.ToLower(quit) == "yes" || strings.ToLower(quit) == "y" {
172 } else if strings.ToLower(quit) == "save" || strings.ToLower(quit) == "s" {
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: ")
189 v.buf.path = filename
190 v.buf.name = filename
197 messenger.Error(err.Error())
199 messenger.Message("Saved " + v.buf.path)
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())
209 messenger.Error("Clipboard is not supported on your system")
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()
222 messenger.Error("Clipboard is not supported on your system")
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()
235 clip, _ := clipboard.ReadAll()
236 v.eh.Insert(v.cursor.Loc(), clip)
237 v.cursor.SetLoc(v.cursor.Loc() + Count(clip))
239 messenger.Error("Clipboard is not supported on your system")
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
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) {
258 // Put the cursor at the first spot
264 v.cursor.ResetSelection()
266 v.eh = NewEventHandler(v)
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{}
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: ")
283 file, err := ioutil.ReadFile(filename)
286 messenger.Error(err.Error())
289 buf := NewBuffer(string(file), filename)
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 {
303 if cy > v.topline+v.height-1 {
304 v.topline = cy - v.height + 1
308 cx := v.cursor.GetVisualX()
313 if cx+v.lineNumOffset+1 > v.leftCol+v.width {
314 v.leftCol = cx - v.width + v.lineNumOffset + 1
320 // MoveToMouseClick moves the cursor to location x, y assuming x, y were given
322 func (v *View) MoveToMouseClick(x, y int) {
323 if y-v.topline > v.height-1 {
325 y = v.height + v.topline - 1
327 if y >= len(v.buf.lines) {
328 y = len(v.buf.lines) - 1
337 x = v.cursor.GetCharPosInLine(y, x)
338 if x > Count(v.buf.lines[y]) {
339 x = Count(v.buf.lines[y])
343 v.cursor.lastVisualX = v.cursor.GetVisualX()
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
352 switch e := event.(type) {
353 case *tcell.EventResize:
356 case *tcell.EventKey:
360 v.cursor.ResetSelection()
364 v.cursor.ResetSelection()
368 v.cursor.ResetSelection()
372 v.cursor.ResetSelection()
376 if v.cursor.HasSelection() {
377 v.cursor.DeleteSelection()
378 v.cursor.ResetSelection()
381 v.eh.Insert(v.cursor.Loc(), "\n")
382 ws := GetLeadingWhitespace(v.buf.lines[v.cursor.y])
385 if settings.AutoIndent {
386 v.eh.Insert(v.cursor.Loc(), ws)
387 for i := 0; i < len(ws); i++ {
391 v.cursor.lastVisualX = v.cursor.GetVisualX()
394 if v.cursor.HasSelection() {
395 v.cursor.DeleteSelection()
396 v.cursor.ResetSelection()
398 v.eh.Insert(v.cursor.Loc(), " ")
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
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
421 v.eh.Remove(loc-settings.TabSize, loc)
422 v.cursor.x, v.cursor.y = cx, cy
425 cx, cy := v.cursor.x, v.cursor.y
427 loc := v.cursor.Loc()
428 v.eh.Remove(loc-1, loc)
429 v.cursor.x, v.cursor.y = cx, cy
432 v.cursor.lastVisualX = v.cursor.GetVisualX()
435 if v.cursor.HasSelection() {
436 v.cursor.DeleteSelection()
437 v.cursor.ResetSelection()
439 if settings.TabsToSpaces {
440 v.eh.Insert(v.cursor.Loc(), Spaces(settings.TabSize))
441 for i := 0; i < settings.TabSize; i++ {
445 v.eh.Insert(v.cursor.Loc(), "\t")
451 if v.cursor.HasSelection() {
452 searchStart = v.cursor.curSelection[1]
454 searchStart = ToCharPos(v.cursor.x, v.cursor.y, v.buf)
458 if v.cursor.HasSelection() {
459 searchStart = v.cursor.curSelection[1]
461 searchStart = ToCharPos(v.cursor.x, v.cursor.y, v.buf)
463 messenger.Message("Find: " + lastSearch)
464 Search(lastSearch, v, true)
466 if v.cursor.HasSelection() {
467 searchStart = v.cursor.curSelection[0]
469 searchStart = ToCharPos(v.cursor.x, v.cursor.y, v.buf)
471 messenger.Message("Find: " + lastSearch)
472 Search(lastSearch, v, false)
491 if v.height > len(v.buf.lines) {
494 v.topline = len(v.buf.lines) - v.height
510 // Insert a character
511 if v.cursor.HasSelection() {
512 v.cursor.DeleteSelection()
513 v.cursor.ResetSelection()
515 v.eh.Insert(v.cursor.Loc(), string(e.Rune()))
518 case *tcell.EventMouse:
520 x -= v.lineNumOffset - v.leftCol
523 button := e.Buttons()
528 origX, origY := v.cursor.x, v.cursor.y
529 v.MoveToMouseClick(x, y)
532 if (time.Since(v.lastClickTime)/time.Millisecond < doubleClickThreshold) &&
533 (origX == v.cursor.x && origY == v.cursor.y) {
536 v.lastClickTime = time.Now()
539 v.doubleClick = false
541 v.cursor.SelectLine()
544 v.lastClickTime = time.Now()
547 v.tripleClick = false
549 v.cursor.SelectWord()
552 v.doubleClick = false
553 v.tripleClick = false
554 v.lastClickTime = time.Now()
556 loc := v.cursor.Loc()
557 v.cursor.curSelection[0] = loc
558 v.cursor.curSelection[1] = loc
562 v.cursor.AddLineToSelection()
563 } else if v.doubleClick {
564 v.cursor.AddWordToSelection()
566 v.cursor.curSelection[1] = v.cursor.Loc()
569 v.mouseReleased = false
570 case tcell.ButtonNone:
571 // Mouse event with no click
572 if !v.mouseReleased {
573 // Mouse was just released
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
581 if !v.doubleClick && !v.tripleClick {
582 v.MoveToMouseClick(x, y)
583 v.cursor.curSelection[1] = v.cursor.Loc()
585 v.mouseReleased = true
587 // We don't want to relocate because otherwise the view will be relocated
588 // every time the user moves the cursor
591 // Scroll up two lines
593 // We don't want to relocate if the user is scrolling
595 case tcell.WheelDown:
596 // Scroll down two lines
598 // We don't want to relocate if the user is scrolling
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)
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
622 var highlightStyle tcell.Style
624 for lineN := 0; lineN < v.height; lineN++ {
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) {
631 line := v.buf.lines[lineN+v.topline]
633 // Write the line number
634 lineNumStyle := defStyle
635 if style, ok := colorscheme["line-number"]; ok {
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)
644 // Write the actual line number
645 for _, ch := range lineNum {
646 screen.SetContent(x, lineN, ch, nil, lineNumStyle)
649 // Write the extra space
650 screen.SetContent(x, lineN, ' ', nil, lineNumStyle)
655 runes := []rune(line)
656 for colN := v.leftCol; colN < v.leftCol+v.width; colN++ {
657 if colN >= len(runes) {
661 var lineStyle tcell.Style
663 // Syntax highlighting is enabled
664 highlightStyle = v.matches[lineN][colN]
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]) {
671 lineStyle = defStyle.Reverse(true)
673 if style, ok := colorscheme["selection"]; ok {
677 lineStyle = highlightStyle
681 screen.SetContent(x+tabchars, lineN, ' ', nil, lineStyle)
682 tabSize := settings.TabSize
683 for i := 0; i < tabSize-1; i++ {
685 if x-v.leftCol+tabchars >= v.lineNumOffset {
686 screen.SetContent(x-v.leftCol+tabchars, lineN, ' ', nil, lineStyle)
690 if x-v.leftCol+tabchars >= v.lineNumOffset {
691 screen.SetContent(x-v.leftCol+tabchars, lineN, ch, nil, lineStyle)
697 // Here we are at a newline
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]) {
705 selectStyle := defStyle.Reverse(true)
707 if style, ok := colorscheme["selection"]; ok {
710 screen.SetContent(x-v.leftCol+tabchars, lineN, ' ', nil, selectStyle)
715 // v.lastMatches = matches
718 // Display renders the view, the cursor, and statusline
719 func (v *View) Display() {