| 🛟 Updated. 28d5985 k33g 15h ago | 1 | // 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. |
| 7 | package editor |
| 8 | |
| 9 | import ( |
| 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. |
| 28 | type Clipboard struct { |
| 29 | text string |
| 30 | } |
| 31 | |
| 32 | // Text returns what the clipboard holds. |
| 33 | func (c *Clipboard) Text() string { return c.text } |
| 34 | |
| 35 | // SetText replaces what the clipboard holds. |
| 36 | func (c *Clipboard) SetText(text string) { c.text = text } |
| 37 | |
| 38 | // Empty reports whether there is nothing to paste. |
| 39 | func (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) |
| 46 | type 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. |
| 85 | func 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. |
| 100 | func (v *View) Buffer() *buffer.Buffer { return v.buf } |
| 101 | |
| 102 | // LineNumbers reports whether the gutter is shown. |
| 103 | func (v *View) LineNumbers() bool { return v.lineNumbers } |
| 104 | |
| 105 | // SetLineNumbers shows or hides the line-number gutter. |
| 106 | func (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. |
| 110 | func (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 | // } |
| 123 | func (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. |
| 127 | func (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. |
| 136 | func (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. |
| 147 | func (v *View) VisibleLines() int { return v.textArea().H } |
| 148 | |
| 149 | // TopLine returns the first visible line. |
| 150 | func (v *View) TopLine() int { return v.top } |
| 151 | |
| 152 | // ScrollTo puts line at the top of the view, clamped to the buffer. |
| 153 | func (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. |
| 158 | func (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. |
| 161 | func (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. |
| 183 | func (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. |
| 189 | func (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. |
| 205 | type Painter = ui.Painter |
| 206 | |
| 207 | // drawLine paints one visible row: its number, then its text. |
| 208 | func (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. |
| 220 | func (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. |
| 231 | func (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. |
| 258 | func 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. |
| 267 | func (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. |
| 286 | func (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. |
| 302 | func 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. |
| 308 | func (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. |
| 320 | func (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. |
| 334 | func (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. |
| 343 | func (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. |
| 357 | func (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. |
| 370 | func (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. |
| 380 | func (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. |
| 388 | func (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. |
| 395 | func (v *View) SelectedText() string { return v.buf.SelectedText() } |
| 396 | |
| 397 | // Copy puts the selection on the clipboard and reports whether there was one. |
| 398 | func (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. |
| 408 | func (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. |
| 418 | func (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. |
| 428 | func (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() |
| 442 | func (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() |
| 455 | func (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. |
| 463 | func (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. |
| 472 | func (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. |
| 479 | func (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. |
| 487 | func (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. |
| 500 | func (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}") |
| 520 | func (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. |
| 535 | func 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. |
| 551 | func 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. |
| 562 | func (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. |
| 574 | func (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. |
| 588 | func (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. |
| 604 | func 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 | } |