turbo-editors/turbo-corepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

view.go · 615 lines · 18.9 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 5h ago1// Package editor is the text-editing widget: a scrolling, colouring viewport
2// onto one buffer.
3//
4// It is where the pure packages meet the screen — internal/buffer holds the
5// text, internal/syntax colours it, internal/theme says in what, internal/ui
6// draws it — and it is the only place that knows how all four fit together.
7package editor
8
9import (
10 "fmt"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/gdamore/tcell/v2"
16
17 "codeberg.org/turbo-editors/turbo-core/buffer"
18 "codeberg.org/turbo-editors/turbo-core/syntax"
19 "codeberg.org/turbo-editors/turbo-core/theme"
20 "codeberg.org/turbo-editors/turbo-core/ui"
21)
22
23// Clipboard is the text shared between views by cut, copy and paste.
24//
25// The editor keeps its own rather than reaching for the system clipboard: a
26// terminal program cannot read the host's clipboard portably, and a shared
27// one between windows is what Turbo C offered anyway.
28type Clipboard struct {
29 text string
30}
31
32// Text returns what the clipboard holds.
33func (c *Clipboard) Text() string { return c.text }
34
35// SetText replaces what the clipboard holds.
36func (c *Clipboard) SetText(text string) { c.text = text }
37
38// Empty reports whether there is nothing to paste.
39func (c *Clipboard) Empty() bool { return c.text == "" }
40
41// View is a viewport onto a buffer: it scrolls, colours, and turns key and
42// mouse events into edits.
43//
44// view := editor.NewView(buf, clipboard)
45// window := ui.NewWindow("main.go", view)
46type View struct {
47 ui.FocusBox
48
49 buf *buffer.Buffer
50 highlight *syntax.Cache
51 clipboard *Clipboard
52
53 top int // first visible line
54 left int // first visible screen column
55
56 lineNumbers bool
57 // now is the clock two clicks are timed against. It is a field so a test
58 // can drive it, the way app.App does: a double click measured against the
59 // real clock would be a test that passes or fails by how fast the machine
60 // is.
61 now func() time.Time
62 // lastClick is where and when the last press landed, and how many presses
63 // in a row have now landed there.
64 lastClick click
65 // marks are the lines something is wrong with, keyed by line number. The
66 // view is told them; it does not know where they came from.
67 marks map[int]Severity
68 selecting bool // a mouse drag is extending the selection
69
70 // OnChange is called after any edit, so the owner can retitle the window
71 // and tell the language server.
72 OnChange func()
73 // OnCursorMove is called after the cursor moves, so the status bar can
74 // follow it.
75 OnCursorMove func()
76 // OnCompletionRequest is called when the user asks for completion, which
77 // the editor itself knows nothing about.
78 OnCompletionRequest func()
79}
80
81// NewView returns a view onto buf, sharing clipboard with the other views.
82//
83// Colouring is switched on when the buffer holds a file this editor can
84// colour, and off otherwise.
85func NewView(buf *buffer.Buffer, clipboard *Clipboard) *View {
86 view := &View{
87 buf: buf,
88 highlight: syntax.NewCache(syntax.LanguageOf(buf.Path(), buf.Line(0))),
89 clipboard: clipboard,
90 lineNumbers: true,
91 now: time.Now,
92 }
93 // A view holds the focus until a window says otherwise, so a view used on
94 // its own — in a test, or outside a desktop — still shows its cursor.
95 view.SetFocused(true)
96 return view
97}
98
99// Buffer returns the text this view is editing.
100func (v *View) Buffer() *buffer.Buffer { return v.buf }
101
102// LineNumbers reports whether the gutter is shown.
103func (v *View) LineNumbers() bool { return v.lineNumbers }
104
105// SetLineNumbers shows or hides the line-number gutter.
106func (v *View) SetLineNumbers(show bool) { v.lineNumbers = show }
107
108// RefreshSyntax re-decides whether this buffer can be coloured, which saving
109// under a new name may change.
110func (v *View) RefreshSyntax() {
111 v.highlight.SetLanguage(syntax.LanguageOf(v.buf.Path(), v.buf.Line(0)))
112}
113
114// Language returns the language this view is colouring its buffer as, and
115// LanguageNone when nothing claims the file.
116//
117// It is what an editor's own test asks to check that registering its language
118// reached the screen, and what the Snippets menu filters on.
119//
120// if view.Language() == golang.Language {
121// // a Go file is in front
122// }
123func (v *View) Language() syntax.Language { return v.highlight.Language() }
124
125// gutterWidth returns how many columns the line numbers occupy, including the
126// space that separates them from the text.
127func (v *View) gutterWidth() int {
128 if !v.lineNumbers {
129 return 0
130 }
131 return len(strconv.Itoa(v.buf.LineCount())) + 1
132}
133
134// textArea returns the rectangle the text itself is drawn in: the view minus
135// the gutter, the vertical scroll bar and the horizontal one.
136func (v *View) textArea() ui.Rect {
137 b := v.Bounds()
138 return ui.Rect{
139 X: b.X + v.gutterWidth(),
140 Y: b.Y,
141 W: max(b.W-v.gutterWidth()-1, 0),
142 H: max(b.H-1, 0),
143 }
144}
145
146// VisibleLines returns how many lines of text fit in the view.
147func (v *View) VisibleLines() int { return v.textArea().H }
148
149// TopLine returns the first visible line.
150func (v *View) TopLine() int { return v.top }
151
152// ScrollTo puts line at the top of the view, clamped to the buffer.
153func (v *View) ScrollTo(line int) {
154 v.top = min(max(line, 0), max(v.buf.LineCount()-1, 0))
155}
156
157// ScrollBy moves the view by a number of lines.
158func (v *View) ScrollBy(lines int) { v.ScrollTo(v.top + lines) }
159
160// EnsureCursorVisible scrolls the view, if it must, so the cursor is on screen.
161func (v *View) EnsureCursorVisible() {
162 area := v.textArea()
163 if area.H <= 0 || area.W <= 0 {
164 return
165 }
166 cursor := v.buf.Cursor()
167
168 v.top = min(v.top, cursor.Line)
169 if cursor.Line >= v.top+area.H {
170 v.top = cursor.Line - area.H + 1
171 }
172
173 column := v.buf.DisplayColumn(cursor.Line, cursor.Col)
174 v.left = min(v.left, column)
175 if column >= v.left+area.W {
176 v.left = column - area.W + 1
177 }
178 v.left = max(v.left, 0)
179}
180
181// CursorStatus returns the "line:column" text the status bar shows, counting
182// from one as every editor does.
183func (v *View) CursorStatus() string {
184 cursor := v.buf.Cursor()
185 return fmt.Sprintf("%d:%d", cursor.Line+1, cursor.Col+1)
186}
187
188// Draw paints the gutter, the text, the scroll bars and the cursor.
189func (v *View) Draw(p *Painter, th *theme.Theme) {
190 v.highlight.Update(v.buf.Text(), v.buf.Revision())
191 v.EnsureCursorVisible()
192
193 area := p.Size()
194 p.Fill(area, ' ', th.Style(theme.KeyEditorText))
195
196 for row := range max(area.H-1, 0) {
197 v.drawLine(p, th, row)
198 }
199 v.drawScrollBars(p, th)
200 v.placeCursor(p, th)
201}
202
203// Painter is the drawing surface a view paints on. It is an alias so that
204// callers of this package do not have to name internal/ui just to draw.
205type Painter = ui.Painter
206
207// drawLine paints one visible row: its number, then its text.
208func (v *View) drawLine(p *Painter, th *theme.Theme, row int) {
209 line := v.top + row
210 if line >= v.buf.LineCount() {
211 return
212 }
213
214 v.drawLineNumber(p, th, row, line)
215 v.drawMark(p, th, row, line)
216 v.drawLineText(p, th, row, line)
217}
218
219// drawLineNumber paints the gutter entry for a line.
220func (v *View) drawLineNumber(p *Painter, th *theme.Theme, row, line int) {
221 if !v.lineNumbers {
222 return
223 }
224 width := v.gutterWidth()
225 number := strconv.Itoa(line + 1)
226 p.Text(width-1-len(number), row, number, th.Style(theme.KeyEditorLineNumber))
227}
228
229// drawLineText paints the characters of one line, coloured by syntax class and
230// overridden by the selection.
231func (v *View) drawLineText(p *Painter, th *theme.Theme, row, line int) {
232 gutter := v.gutterWidth()
233 width := max(p.Size().W-gutter-1, 0)
234 runes := v.buf.LineRunes(line)
235 selection, hasSelection := v.buf.Selection()
236
237 base := th.Style(theme.KeyEditorText)
238 if line == v.buf.Cursor().Line {
239 base = th.Style(theme.KeyEditorCurrent)
240 p.HLine(gutter, row, width, ' ', base)
241 }
242
243 column := 0 // screen column before the horizontal scroll is taken off
244 for i, r := range runes {
245 style := v.styleFor(th, base, line, i, selection, hasSelection)
246 column = v.drawRune(p, r, gutter, row, column, width, style)
247 }
248
249 // A selection that reaches past the end of a line shows one extra cell, so
250 // a selected line break is visible.
251 if hasSelection && coversLineBreak(selection, line, len(runes)) {
252 v.drawRune(p, ' ', gutter, row, column, width, th.Style(theme.KeyEditorSelection))
253 }
254}
255
256// coversLineBreak reports whether a selection swallows the line break at the
257// end of a line, which is what the one extra highlighted cell stands for.
258func coversLineBreak(selection buffer.Range, line, lineLen int) bool {
259 if line >= selection.End.Line {
260 return false // the selection stops on this line, before its break
261 }
262 return !selection.Start.After(buffer.Position{Line: line, Col: lineLen})
263}
264
265// drawRune paints one character, expanding tabs, and returns the next screen
266// column. Characters scrolled off to the left are measured but not drawn.
267func (v *View) drawRune(p *Painter, r rune, gutter, row, column, width int, style tcell.Style) int {
268 span := 1
269 if r == '\t' {
270 span = v.buf.TabWidth() - column%v.buf.TabWidth()
271 r = ' '
272 }
273
274 for i := range span {
275 x := column + i - v.left
276 if x >= 0 && x < width {
277 p.SetCell(gutter+x, row, r, style)
278 }
279 r = ' ' // only the first cell of a tab could ever carry a character
280 }
281 return column + span
282}
283
284// styleFor returns the style one character is drawn in: its syntax colour,
285// unless the selection covers it.
286func (v *View) styleFor(th *theme.Theme, base tcell.Style, line, col int, selection buffer.Range, hasSelection bool) tcell.Style {
287 if hasSelection && selection.Contains(buffer.Position{Line: line, Col: col}) {
288 return th.Style(theme.KeyEditorSelection)
289 }
290
291 for _, span := range v.highlight.Line(line) {
292 if col >= span.Start && col < span.End {
293 return withBackgroundOf(th.Style(span.Class.StyleKey()), base)
294 }
295 }
296 return base
297}
298
299// withBackgroundOf keeps a syntax colour's foreground but takes its background
300// from the line beneath it, so the current-line highlight shows through the
301// coloured tokens instead of being punched full of holes.
302func withBackgroundOf(style, background tcell.Style) tcell.Style {
303 _, bg, _ := background.Decompose()
304 return style.Background(bg)
305}
306
307// drawScrollBars paints the bars along the right and bottom edges.
308func (v *View) drawScrollBars(p *Painter, th *theme.Theme) {
309 area := p.Size()
310 track, thumb := th.Style(theme.KeyScrollBar), th.Style(theme.KeyScrollBarThumb)
311
312 ui.DrawVScrollBar(p, area.W-1, 0, max(area.H-1, 0),
313 v.top, v.VisibleLines(), v.buf.LineCount(), track, thumb)
314 ui.DrawHScrollBar(p, 0, area.H-1, area.W,
315 v.left, v.textArea().W, v.longestVisibleLine(), track, thumb)
316}
317
318// longestVisibleLine returns the width of the widest line on screen, which is
319// what the horizontal scroll bar measures itself against.
320func (v *View) longestVisibleLine() int {
321 longest := 1
322 for row := range v.VisibleLines() {
323 line := v.top + row
324 if line >= v.buf.LineCount() {
325 break
326 }
327 longest = max(longest, v.buf.DisplayColumn(line, v.buf.LineLen(line)))
328 }
329 return longest
330}
331
332// cursorCell returns where the cursor sits inside the view, in the view's own
333// coordinates.
334func (v *View) cursorCell() (x, y int) {
335 cursor := v.buf.Cursor()
336 return v.gutterWidth() + v.buf.DisplayColumn(cursor.Line, cursor.Col) - v.left,
337 cursor.Line - v.top
338}
339
340// CursorScreenPosition returns where the cursor sits on the terminal, which is
341// what a popup anchored to it needs. The gutter and the horizontal scroll are
342// both accounted for.
343func (v *View) CursorScreenPosition() (x, y int) {
344 localX, localY := v.cursorCell()
345 return v.Bounds().X + localX, v.Bounds().Y + localY
346}
347
348// placeCursor marks where the cursor is, twice over.
349//
350// The terminal's own cursor is put there, and the cell underneath is repainted
351// in the theme's cursor colours. Relying on the terminal alone is not enough:
352// its cursor colour is the user's setting, not the theme's, and a thin bar in
353// a colour chosen for some other palette can be invisible against a dark
354// background. The theme colours are a distinct pair rather than a reversal of
355// the text, so that terminals which draw their cursor by inverting the cell do
356// not invert it straight back into invisibility.
357func (v *View) placeCursor(p *Painter, th *theme.Theme) {
358 if !v.Focused() {
359 return // an inactive window has no cursor to show
360 }
361 x, y := v.cursorCell()
362
363 character, _ := p.CellAt(x, y)
364 p.SetCell(x, y, character, th.Style(theme.KeyEditorCursor))
365 p.ShowCursor(x, y)
366}
367
368// positionAt returns the buffer position a screen cell corresponds to, which
369// is what a mouse click needs.
370func (v *View) positionAt(screenX, screenY int) buffer.Position {
371 bounds := v.Bounds()
372 line := v.top + screenY - bounds.Y
373 column := v.left + screenX - bounds.X - v.gutterWidth()
374
375 line = min(max(line, 0), max(v.buf.LineCount()-1, 0))
376 return buffer.Position{Line: line, Col: v.buf.RuneColumn(line, max(column, 0))}
377}
378
379// notifyChange tells the owner the text changed.
380func (v *View) notifyChange() {
381 if v.OnChange != nil {
382 v.OnChange()
383 }
384 v.notifyCursor()
385}
386
387// notifyCursor tells the owner the cursor moved.
388func (v *View) notifyCursor() {
389 if v.OnCursorMove != nil {
390 v.OnCursorMove()
391 }
392}
393
394// SelectedText returns the selection, or the empty string when there is none.
395func (v *View) SelectedText() string { return v.buf.SelectedText() }
396
397// Copy puts the selection on the clipboard and reports whether there was one.
398func (v *View) Copy() bool {
399 text := v.buf.SelectedText()
400 if text == "" {
401 return false
402 }
403 v.clipboard.SetText(text)
404 return true
405}
406
407// Cut copies the selection and removes it, reporting whether there was one.
408func (v *View) Cut() bool {
409 if !v.Copy() {
410 return false
411 }
412 v.buf.DeleteSelection()
413 v.notifyChange()
414 return true
415}
416
417// Paste inserts the clipboard at the cursor, replacing the selection.
418func (v *View) Paste() bool {
419 if v.clipboard.Empty() {
420 return false
421 }
422 v.buf.Insert(v.clipboard.Text())
423 v.notifyChange()
424 return true
425}
426
427// Undo reverts the last change and reports whether it did anything.
428func (v *View) Undo() bool {
429 if !v.buf.Undo() {
430 return false
431 }
432 v.notifyChange()
433 return true
434}
435
436// InsertLine opens a blank line above the cursor and keeps the cursor on its
437// own text, which is now one line lower.
438//
439// Turbo C's Ctrl-N. It makes room above what you are looking at.
440//
441// view.InsertLine()
442func (v *View) InsertLine() {
443 v.buf.InsertLineAbove()
444 v.EnsureCursorVisible()
445 v.notifyChange()
446 v.notifyCursor()
447}
448
449// DeleteLine removes the line the cursor is on and closes the gap.
450//
451// Turbo C's Ctrl-Y. The cursor stays on the same line number, so holding the
452// key deletes a run of lines.
453//
454// view.DeleteLine()
455func (v *View) DeleteLine() {
456 v.buf.DeleteLine()
457 v.EnsureCursorVisible()
458 v.notifyChange()
459 v.notifyCursor()
460}
461
462// Redo re-applies the last undone change and reports whether it did anything.
463func (v *View) Redo() bool {
464 if !v.buf.Redo() {
465 return false
466 }
467 v.notifyChange()
468 return true
469}
470
471// SelectAll selects the whole buffer.
472func (v *View) SelectAll() {
473 v.buf.SelectAll()
474 v.notifyCursor()
475}
476
477// GoToLine puts the cursor at the start of a line, counting from one, and
478// scrolls it into view.
479func (v *View) GoToLine(line int) {
480 v.buf.SetCursor(buffer.Position{Line: line - 1})
481 v.EnsureCursorVisible()
482 v.notifyCursor()
483}
484
485// WordBeforeCursor returns the identifier being typed just left of the cursor,
486// which is what a completion list filters on.
487func (v *View) WordBeforeCursor() string {
488 cursor := v.buf.Cursor()
489 runes := v.buf.LineRunes(cursor.Line)
490
491 start := min(cursor.Col, len(runes))
492 for start > 0 && buffer.IsWordRune(runes[start-1]) {
493 start--
494 }
495 return string(runes[start:min(cursor.Col, len(runes))])
496}
497
498// ReplaceWordBeforeCursor swaps the identifier being typed for text, which is
499// how a completion is accepted.
500func (v *View) ReplaceWordBeforeCursor(text string) {
501 cursor := v.buf.Cursor()
502 start := buffer.Position{Line: cursor.Line, Col: cursor.Col - len([]rune(v.WordBeforeCursor()))}
503
504 v.buf.ReplaceRange(buffer.Range{Start: start, End: cursor}, text)
505 v.notifyChange()
506}
507
508// InsertSnippet puts a piece of text in at the cursor, indenting the lines
509// after the first to match the line it landed on.
510//
511// A multi-line snippet dropped in verbatim restarts at column zero, which is
512// wrong everywhere except the top level of a file. Taking the current line's
513// own leading whitespace and putting it in front of each following line is what
514// makes the result look like it was typed there.
515//
516// It is one undoable change, and the cursor ends after the text — the two
517// things that make it feel like an insertion rather than a script running.
518//
519// view.InsertSnippet("if err != nil {\n\treturn err\n}")
520func (v *View) InsertSnippet(text string) {
521 if text == "" {
522 return
523 }
524
525 v.buf.Insert(indentContinuationLines(text, leadingWhitespace(v.buf.Line(v.buf.Cursor().Line))))
526 v.notifyChange()
527}
528
529// indentContinuationLines puts indent in front of every line of text but the
530// first, which starts where the cursor already is.
531//
532// A line that is empty is left empty: trailing whitespace on a blank line is
533// something every formatter then removes, and putting it there is noise in the
534// diff of the very next save.
535func indentContinuationLines(text, indent string) string {
536 if indent == "" {
537 return text
538 }
539
540 lines := strings.Split(text, "\n")
541 for i := 1; i < len(lines); i++ {
542 if lines[i] == "" {
543 continue
544 }
545 lines[i] = indent + lines[i]
546 }
547 return strings.Join(lines, "\n")
548}
549
550// leadingWhitespace returns the tabs and spaces a line starts with.
551func leadingWhitespace(line string) string {
552 for i, r := range line {
553 if r != ' ' && r != '\t' {
554 return line[:i]
555 }
556 }
557 return line
558}
559
560// Indent adds one tab to the start of every line the selection touches, or
561// inserts a tab when nothing is selected.
562func (v *View) Indent() {
563 selection, ok := v.buf.Selection()
564 if !ok {
565 v.buf.Insert("\t")
566 v.notifyChange()
567 return
568 }
569 v.reindent(selection, func(line string) string { return "\t" + line })
570}
571
572// Unindent removes one level of leading whitespace from every line the
573// selection touches, or from the current line when nothing is selected.
574func (v *View) Unindent() {
575 selection, ok := v.buf.Selection()
576 if !ok {
577 line := v.buf.Cursor().Line
578 selection = buffer.Range{
579 Start: buffer.Position{Line: line},
580 End: buffer.Position{Line: line, Col: v.buf.LineLen(line)},
581 }
582 }
583 v.reindent(selection, stripOneIndent)
584}
585
586// reindent rewrites every line the range touches through transform, as a
587// single undoable change.
588func (v *View) reindent(selection buffer.Range, transform func(string) string) {
589 lines := make([]string, 0, selection.End.Line-selection.Start.Line+1)
590 for line := selection.Start.Line; line <= selection.End.Line; line++ {
591 lines = append(lines, transform(v.buf.Line(line)))
592 }
593
594 whole := buffer.Range{
595 Start: buffer.Position{Line: selection.Start.Line},
596 End: buffer.Position{Line: selection.End.Line, Col: v.buf.LineLen(selection.End.Line)},
597 }
598 v.buf.ReplaceRange(whole, strings.Join(lines, "\n"))
599 v.notifyChange()
600}
601
602// stripOneIndent removes one tab, or up to one tab width of spaces, from the
603// start of a line.
604func stripOneIndent(line string) string {
605 if strings.HasPrefix(line, "\t") {
606 return line[1:]
607 }
608 for width := buffer.DefaultTabWidth; width > 0; width-- {
609 prefix := strings.Repeat(" ", width)
610 if strings.HasPrefix(line, prefix) {
611 return line[width:]
612 }
613 }
614 return line
615}