// Package editor is the text-editing widget: a scrolling, colouring viewport // onto one buffer. // // It is where the pure packages meet the screen — internal/buffer holds the // text, internal/syntax colours it, internal/theme says in what, internal/ui // draws it — and it is the only place that knows how all four fit together. package editor import ( "fmt" "strconv" "strings" "time" "github.com/gdamore/tcell/v2" "rickub.com/turbo-editors/turbo-core/buffer" "rickub.com/turbo-editors/turbo-core/syntax" "rickub.com/turbo-editors/turbo-core/theme" "rickub.com/turbo-editors/turbo-core/ui" ) // Clipboard is the text shared between views by cut, copy and paste. // // The editor keeps its own rather than reaching for the system clipboard: a // terminal program cannot read the host's clipboard portably, and a shared // one between windows is what Turbo C offered anyway. type Clipboard struct { text string } // Text returns what the clipboard holds. func (c *Clipboard) Text() string { return c.text } // SetText replaces what the clipboard holds. func (c *Clipboard) SetText(text string) { c.text = text } // Empty reports whether there is nothing to paste. func (c *Clipboard) Empty() bool { return c.text == "" } // View is a viewport onto a buffer: it scrolls, colours, and turns key and // mouse events into edits. // // view := editor.NewView(buf, clipboard) // window := ui.NewWindow("main.go", view) type View struct { ui.FocusBox buf *buffer.Buffer highlight *syntax.Cache clipboard *Clipboard top int // first visible line left int // first visible screen column lineNumbers bool // now is the clock two clicks are timed against. It is a field so a test // can drive it, the way app.App does: a double click measured against the // real clock would be a test that passes or fails by how fast the machine // is. now func() time.Time // lastClick is where and when the last press landed, and how many presses // in a row have now landed there. lastClick click // marks are the lines something is wrong with, keyed by line number. The // view is told them; it does not know where they came from. marks map[int]Severity selecting bool // a mouse drag is extending the selection // OnChange is called after any edit, so the owner can retitle the window // and tell the language server. OnChange func() // OnCursorMove is called after the cursor moves, so the status bar can // follow it. OnCursorMove func() // OnCompletionRequest is called when the user asks for completion, which // the editor itself knows nothing about. OnCompletionRequest func() } // NewView returns a view onto buf, sharing clipboard with the other views. // // Colouring is switched on when the buffer holds a file this editor can // colour, and off otherwise. func NewView(buf *buffer.Buffer, clipboard *Clipboard) *View { view := &View{ buf: buf, highlight: syntax.NewCache(syntax.LanguageOf(buf.Path(), buf.Line(0))), clipboard: clipboard, lineNumbers: true, now: time.Now, } // A view holds the focus until a window says otherwise, so a view used on // its own — in a test, or outside a desktop — still shows its cursor. view.SetFocused(true) return view } // Buffer returns the text this view is editing. func (v *View) Buffer() *buffer.Buffer { return v.buf } // LineNumbers reports whether the gutter is shown. func (v *View) LineNumbers() bool { return v.lineNumbers } // SetLineNumbers shows or hides the line-number gutter. func (v *View) SetLineNumbers(show bool) { v.lineNumbers = show } // RefreshSyntax re-decides whether this buffer can be coloured, which saving // under a new name may change. func (v *View) RefreshSyntax() { v.highlight.SetLanguage(syntax.LanguageOf(v.buf.Path(), v.buf.Line(0))) } // Language returns the language this view is colouring its buffer as, and // LanguageNone when nothing claims the file. // // It is what an editor's own test asks to check that registering its language // reached the screen, and what the Snippets menu filters on. // // if view.Language() == golang.Language { // // a Go file is in front // } func (v *View) Language() syntax.Language { return v.highlight.Language() } // gutterWidth returns how many columns the line numbers occupy, including the // space that separates them from the text. func (v *View) gutterWidth() int { if !v.lineNumbers { return 0 } return len(strconv.Itoa(v.buf.LineCount())) + 1 } // textArea returns the rectangle the text itself is drawn in: the view minus // the gutter, the vertical scroll bar and the horizontal one. func (v *View) textArea() ui.Rect { b := v.Bounds() return ui.Rect{ X: b.X + v.gutterWidth(), Y: b.Y, W: max(b.W-v.gutterWidth()-1, 0), H: max(b.H-1, 0), } } // VisibleLines returns how many lines of text fit in the view. func (v *View) VisibleLines() int { return v.textArea().H } // TopLine returns the first visible line. func (v *View) TopLine() int { return v.top } // ScrollTo puts line at the top of the view, clamped to the buffer. func (v *View) ScrollTo(line int) { v.top = min(max(line, 0), max(v.buf.LineCount()-1, 0)) } // ScrollBy moves the view by a number of lines. func (v *View) ScrollBy(lines int) { v.ScrollTo(v.top + lines) } // EnsureCursorVisible scrolls the view, if it must, so the cursor is on screen. func (v *View) EnsureCursorVisible() { area := v.textArea() if area.H <= 0 || area.W <= 0 { return } cursor := v.buf.Cursor() v.top = min(v.top, cursor.Line) if cursor.Line >= v.top+area.H { v.top = cursor.Line - area.H + 1 } column := v.buf.DisplayColumn(cursor.Line, cursor.Col) v.left = min(v.left, column) if column >= v.left+area.W { v.left = column - area.W + 1 } v.left = max(v.left, 0) } // CursorStatus returns the "line:column" text the status bar shows, counting // from one as every editor does. func (v *View) CursorStatus() string { cursor := v.buf.Cursor() return fmt.Sprintf("%d:%d", cursor.Line+1, cursor.Col+1) } // Draw paints the gutter, the text, the scroll bars and the cursor. func (v *View) Draw(p *Painter, th *theme.Theme) { v.highlight.Update(v.buf.Text(), v.buf.Revision()) v.EnsureCursorVisible() area := p.Size() p.Fill(area, ' ', th.Style(theme.KeyEditorText)) for row := range max(area.H-1, 0) { v.drawLine(p, th, row) } v.drawScrollBars(p, th) v.placeCursor(p, th) } // Painter is the drawing surface a view paints on. It is an alias so that // callers of this package do not have to name internal/ui just to draw. type Painter = ui.Painter // drawLine paints one visible row: its number, then its text. func (v *View) drawLine(p *Painter, th *theme.Theme, row int) { line := v.top + row if line >= v.buf.LineCount() { return } v.drawLineNumber(p, th, row, line) v.drawMark(p, th, row, line) v.drawLineText(p, th, row, line) } // drawLineNumber paints the gutter entry for a line. func (v *View) drawLineNumber(p *Painter, th *theme.Theme, row, line int) { if !v.lineNumbers { return } width := v.gutterWidth() number := strconv.Itoa(line + 1) p.Text(width-1-len(number), row, number, th.Style(theme.KeyEditorLineNumber)) } // drawLineText paints the characters of one line, coloured by syntax class and // overridden by the selection. func (v *View) drawLineText(p *Painter, th *theme.Theme, row, line int) { gutter := v.gutterWidth() width := max(p.Size().W-gutter-1, 0) runes := v.buf.LineRunes(line) selection, hasSelection := v.buf.Selection() base := th.Style(theme.KeyEditorText) if line == v.buf.Cursor().Line { base = th.Style(theme.KeyEditorCurrent) p.HLine(gutter, row, width, ' ', base) } column := 0 // screen column before the horizontal scroll is taken off for i, r := range runes { style := v.styleFor(th, base, line, i, selection, hasSelection) column = v.drawRune(p, r, gutter, row, column, width, style) } // A selection that reaches past the end of a line shows one extra cell, so // a selected line break is visible. if hasSelection && coversLineBreak(selection, line, len(runes)) { v.drawRune(p, ' ', gutter, row, column, width, th.Style(theme.KeyEditorSelection)) } } // coversLineBreak reports whether a selection swallows the line break at the // end of a line, which is what the one extra highlighted cell stands for. func coversLineBreak(selection buffer.Range, line, lineLen int) bool { if line >= selection.End.Line { return false // the selection stops on this line, before its break } return !selection.Start.After(buffer.Position{Line: line, Col: lineLen}) } // drawRune paints one character, expanding tabs, and returns the next screen // column. Characters scrolled off to the left are measured but not drawn. func (v *View) drawRune(p *Painter, r rune, gutter, row, column, width int, style tcell.Style) int { span := 1 if r == '\t' { span = v.buf.TabWidth() - column%v.buf.TabWidth() r = ' ' } for i := range span { x := column + i - v.left if x >= 0 && x < width { p.SetCell(gutter+x, row, r, style) } r = ' ' // only the first cell of a tab could ever carry a character } return column + span } // styleFor returns the style one character is drawn in: its syntax colour, // unless the selection covers it. func (v *View) styleFor(th *theme.Theme, base tcell.Style, line, col int, selection buffer.Range, hasSelection bool) tcell.Style { if hasSelection && selection.Contains(buffer.Position{Line: line, Col: col}) { return th.Style(theme.KeyEditorSelection) } for _, span := range v.highlight.Line(line) { if col >= span.Start && col < span.End { return withBackgroundOf(th.Style(span.Class.StyleKey()), base) } } return base } // withBackgroundOf keeps a syntax colour's foreground but takes its background // from the line beneath it, so the current-line highlight shows through the // coloured tokens instead of being punched full of holes. func withBackgroundOf(style, background tcell.Style) tcell.Style { _, bg, _ := background.Decompose() return style.Background(bg) } // drawScrollBars paints the bars along the right and bottom edges. func (v *View) drawScrollBars(p *Painter, th *theme.Theme) { area := p.Size() track, thumb := th.Style(theme.KeyScrollBar), th.Style(theme.KeyScrollBarThumb) ui.DrawVScrollBar(p, area.W-1, 0, max(area.H-1, 0), v.top, v.VisibleLines(), v.buf.LineCount(), track, thumb) ui.DrawHScrollBar(p, 0, area.H-1, area.W, v.left, v.textArea().W, v.longestVisibleLine(), track, thumb) } // longestVisibleLine returns the width of the widest line on screen, which is // what the horizontal scroll bar measures itself against. func (v *View) longestVisibleLine() int { longest := 1 for row := range v.VisibleLines() { line := v.top + row if line >= v.buf.LineCount() { break } longest = max(longest, v.buf.DisplayColumn(line, v.buf.LineLen(line))) } return longest } // cursorCell returns where the cursor sits inside the view, in the view's own // coordinates. func (v *View) cursorCell() (x, y int) { cursor := v.buf.Cursor() return v.gutterWidth() + v.buf.DisplayColumn(cursor.Line, cursor.Col) - v.left, cursor.Line - v.top } // CursorScreenPosition returns where the cursor sits on the terminal, which is // what a popup anchored to it needs. The gutter and the horizontal scroll are // both accounted for. func (v *View) CursorScreenPosition() (x, y int) { localX, localY := v.cursorCell() return v.Bounds().X + localX, v.Bounds().Y + localY } // placeCursor marks where the cursor is, twice over. // // The terminal's own cursor is put there, and the cell underneath is repainted // in the theme's cursor colours. Relying on the terminal alone is not enough: // its cursor colour is the user's setting, not the theme's, and a thin bar in // a colour chosen for some other palette can be invisible against a dark // background. The theme colours are a distinct pair rather than a reversal of // the text, so that terminals which draw their cursor by inverting the cell do // not invert it straight back into invisibility. func (v *View) placeCursor(p *Painter, th *theme.Theme) { if !v.Focused() { return // an inactive window has no cursor to show } x, y := v.cursorCell() character, _ := p.CellAt(x, y) p.SetCell(x, y, character, th.Style(theme.KeyEditorCursor)) p.ShowCursor(x, y) } // positionAt returns the buffer position a screen cell corresponds to, which // is what a mouse click needs. func (v *View) positionAt(screenX, screenY int) buffer.Position { bounds := v.Bounds() line := v.top + screenY - bounds.Y column := v.left + screenX - bounds.X - v.gutterWidth() line = min(max(line, 0), max(v.buf.LineCount()-1, 0)) return buffer.Position{Line: line, Col: v.buf.RuneColumn(line, max(column, 0))} } // notifyChange tells the owner the text changed. func (v *View) notifyChange() { if v.OnChange != nil { v.OnChange() } v.notifyCursor() } // notifyCursor tells the owner the cursor moved. func (v *View) notifyCursor() { if v.OnCursorMove != nil { v.OnCursorMove() } } // SelectedText returns the selection, or the empty string when there is none. func (v *View) SelectedText() string { return v.buf.SelectedText() } // Copy puts the selection on the clipboard and reports whether there was one. func (v *View) Copy() bool { text := v.buf.SelectedText() if text == "" { return false } v.clipboard.SetText(text) return true } // Cut copies the selection and removes it, reporting whether there was one. func (v *View) Cut() bool { if !v.Copy() { return false } v.buf.DeleteSelection() v.notifyChange() return true } // Paste inserts the clipboard at the cursor, replacing the selection. func (v *View) Paste() bool { if v.clipboard.Empty() { return false } v.buf.Insert(v.clipboard.Text()) v.notifyChange() return true } // Undo reverts the last change and reports whether it did anything. func (v *View) Undo() bool { if !v.buf.Undo() { return false } v.notifyChange() return true } // InsertLine opens a blank line above the cursor and keeps the cursor on its // own text, which is now one line lower. // // Turbo C's Ctrl-N. It makes room above what you are looking at. // // view.InsertLine() func (v *View) InsertLine() { v.buf.InsertLineAbove() v.EnsureCursorVisible() v.notifyChange() v.notifyCursor() } // DeleteLine removes the line the cursor is on and closes the gap. // // Turbo C's Ctrl-Y. The cursor stays on the same line number, so holding the // key deletes a run of lines. // // view.DeleteLine() func (v *View) DeleteLine() { v.buf.DeleteLine() v.EnsureCursorVisible() v.notifyChange() v.notifyCursor() } // Redo re-applies the last undone change and reports whether it did anything. func (v *View) Redo() bool { if !v.buf.Redo() { return false } v.notifyChange() return true } // SelectAll selects the whole buffer. func (v *View) SelectAll() { v.buf.SelectAll() v.notifyCursor() } // GoToLine puts the cursor at the start of a line, counting from one, and // scrolls it into view. func (v *View) GoToLine(line int) { v.buf.SetCursor(buffer.Position{Line: line - 1}) v.EnsureCursorVisible() v.notifyCursor() } // WordBeforeCursor returns the identifier being typed just left of the cursor, // which is what a completion list filters on. func (v *View) WordBeforeCursor() string { cursor := v.buf.Cursor() runes := v.buf.LineRunes(cursor.Line) start := min(cursor.Col, len(runes)) for start > 0 && buffer.IsWordRune(runes[start-1]) { start-- } return string(runes[start:min(cursor.Col, len(runes))]) } // ReplaceWordBeforeCursor swaps the identifier being typed for text, which is // how a completion is accepted. func (v *View) ReplaceWordBeforeCursor(text string) { cursor := v.buf.Cursor() start := buffer.Position{Line: cursor.Line, Col: cursor.Col - len([]rune(v.WordBeforeCursor()))} v.buf.ReplaceRange(buffer.Range{Start: start, End: cursor}, text) v.notifyChange() } // InsertSnippet puts a piece of text in at the cursor, indenting the lines // after the first to match the line it landed on. // // A multi-line snippet dropped in verbatim restarts at column zero, which is // wrong everywhere except the top level of a file. Taking the current line's // own leading whitespace and putting it in front of each following line is what // makes the result look like it was typed there. // // It is one undoable change, and the cursor ends after the text — the two // things that make it feel like an insertion rather than a script running. // // view.InsertSnippet("if err != nil {\n\treturn err\n}") func (v *View) InsertSnippet(text string) { if text == "" { return } v.buf.Insert(indentContinuationLines(text, leadingWhitespace(v.buf.Line(v.buf.Cursor().Line)))) v.notifyChange() } // indentContinuationLines puts indent in front of every line of text but the // first, which starts where the cursor already is. // // A line that is empty is left empty: trailing whitespace on a blank line is // something every formatter then removes, and putting it there is noise in the // diff of the very next save. func indentContinuationLines(text, indent string) string { if indent == "" { return text } lines := strings.Split(text, "\n") for i := 1; i < len(lines); i++ { if lines[i] == "" { continue } lines[i] = indent + lines[i] } return strings.Join(lines, "\n") } // leadingWhitespace returns the tabs and spaces a line starts with. func leadingWhitespace(line string) string { for i, r := range line { if r != ' ' && r != '\t' { return line[:i] } } return line } // Indent adds one tab to the start of every line the selection touches, or // inserts a tab when nothing is selected. func (v *View) Indent() { selection, ok := v.buf.Selection() if !ok { v.buf.Insert("\t") v.notifyChange() return } v.reindent(selection, func(line string) string { return "\t" + line }) } // Unindent removes one level of leading whitespace from every line the // selection touches, or from the current line when nothing is selected. func (v *View) Unindent() { selection, ok := v.buf.Selection() if !ok { line := v.buf.Cursor().Line selection = buffer.Range{ Start: buffer.Position{Line: line}, End: buffer.Position{Line: line, Col: v.buf.LineLen(line)}, } } v.reindent(selection, stripOneIndent) } // reindent rewrites every line the range touches through transform, as a // single undoable change. func (v *View) reindent(selection buffer.Range, transform func(string) string) { lines := make([]string, 0, selection.End.Line-selection.Start.Line+1) for line := selection.Start.Line; line <= selection.End.Line; line++ { lines = append(lines, transform(v.buf.Line(line))) } whole := buffer.Range{ Start: buffer.Position{Line: selection.Start.Line}, End: buffer.Position{Line: selection.End.Line, Col: v.buf.LineLen(selection.End.Line)}, } v.buf.ReplaceRange(whole, strings.Join(lines, "\n")) v.notifyChange() } // stripOneIndent removes one tab, or up to one tab width of spaces, from the // start of a line. func stripOneIndent(line string) string { if strings.HasPrefix(line, "\t") { return line[1:] } for width := buffer.DefaultTabWidth; width > 0; width-- { prefix := strings.Repeat(" ", width) if strings.HasPrefix(line, prefix) { return line[width:] } } return line }