turbo-editors/turbo-corepublic Fork 0
28d59854361aeda8541d853093e732126f3d7bff
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.

🛟 Updated. 28d5985 · on 28d59854361aeda8541d853093e732126f3d7bff · k33g · 21h ago
README.md · 92 lines · 5.8 KBmarkdown
Blame HistoryOpen raw

buffer

The text of one edited file: its lines, the cursor, the selection and the undo history.

This package knows nothing about terminals, rendering or syntax colouring. Everything here is plain data manipulation, which is why it is fully testable on its own and why every other package can depend on it without dragging a terminal in.

Model

  • Text is a slice of lines, each a slice of runes. A buffer always holds at least one line, so an empty buffer is one empty line rather than none.
  • Positions are counted in runes, never in bytes, so an accented letter is one column like any other character.
  • Range is half-open: Start is included, End is excluded. An empty range is an insertion point.
  • Line endings and the trailing newline of the file that was read are remembered, so saving an untouched buffer reproduces the file byte for byte.

Reloading is refused over unsaved work

Reload re-reads the buffer's file, and returns ErrModified instead when the buffer has unsaved changes. That restriction is the whole safety of the operation: something rewriting a file under an unmodified buffer costs nothing, and under a modified one costs the user's work.

The cursor is kept where it was, clamped into whatever the file now holds — a formatter moves lines about, and putting the cursor back at the top would lose the reader's place. The undo history is discarded, because undoing back past a reload would restore text the file no longer has.

Public API

Construction and content

Function What it does
New() *Buffer An empty buffer: one empty line
NewFromString(text string) *Buffer A buffer holding text, split on line feeds
Open(path string) (*Buffer, error) Reads a file; a missing file yields an empty buffer bound to that path
(*Buffer) SetText(text string) Replaces the whole content
(*Buffer) Text() string The whole content, with the original line endings
(*Buffer) Line(i int) string Line i, or "" if out of range
(*Buffer) LineRunes(i int) []rune A copy of line i
(*Buffer) LineLen(i int) int Rune count of line i
(*Buffer) LineCount() int Number of lines, at least one

File

Function What it does
(*Buffer) Save() error Writes back to Path(); returns ErrNoPath if there is none
(*Buffer) SaveAs(path string) error Writes to path and adopts it
(*Buffer) Reload() (bool, error) Re-reads the file, reporting whether it changed; ErrModified rather than discarding unsaved work
(*Buffer) Path() string / SetPath(string) The file this buffer belongs to
(*Buffer) Modified() bool Whether there are unsaved changes

Saving goes through a temporary file in the same directory followed by a rename, so an interrupted save cannot leave a half-written source file behind. The original file's permissions are preserved.

Cursor

Cursor(), SetCursor(Position), and the movements: MoveLeft, MoveRight, MoveUp, MoveDown, MoveLineStart, MoveLineEnd, MoveBufferStart, MoveBufferEnd, MoveWordLeft, MoveWordRight.

DisplayColumn(line, col) and RuneColumn(line, screenCol) convert between rune columns and screen columns, which differ as soon as a line contains tabs. TabWidth() / SetTabWidth(int) control the expansion.

Selection

StartSelection, ClearSelection, SelectAll, Selection() (Range, bool), SelectedText() string, DeleteSelection() bool.

Extend(move func()) runs any movement while keeping the anchor — that is what holding Shift does, and it is why there is no Shift-flavoured twin of every movement method:

b.Extend(b.MoveWordRight) // Shift-Ctrl-Right
b.Extend(b.MoveDown)      // Shift-Down

SetCursorKeepingSelection(Position) is the same thing for an absolute position, which a mouse drag needs.

Editing

Insert(string), InsertRune(rune), InsertNewlineAndIndent(), Backspace(), Delete(), DeleteRange(Range), ReplaceRange(Range, string) Position.

Every change goes through ReplaceRange, which is the single place where the undo history, the modified flag and the cursor are maintained. Out-of-range and reversed ranges are accepted: they are clamped and ordered.

Undo

Undo() bool, Redo() bool, CanUndo() bool, CanRedo() bool.

A run of typed characters, or a run of backspaces, collapses into a single undo step, so undo removes a word rather than a letter. Moving the cursor ends the run. The history is capped at 1000 entries.

Tests

make test          # the whole repository
go test ./buffer/

Whole-line edits, and one idea of a word

InsertLineAbove and DeleteLine are Turbo C's Ctrl-N and Ctrl-Y. Both go through ReplaceRange — the single mutation path — so each is one entry in the undo history rather than a splice nobody recorded.

Two edges are worth knowing. The last line of a buffer has no newline after it to take, so deleting it takes the newline before it; taking nothing would leave a blank line behind. And a buffer of one line is emptied rather than left with no lines at all, because every other operation here assumes there is always a line to be on. That second case is written out although clamping would reach the same answer without it — the range it would build starts on line −1, and leaning on clamp to pull that back to 0 is something a reader has to work out from two files away.

SelectWord selects the run of runes IsWordRune accepts, which is the same word MoveWordLeft moves by and the same one the completion popup uses to work out what is being typed. One editor, one idea of a word. A position that is not on a word rune selects nothing and only moves the cursor: editors disagree about what a run of punctuation means, and nothing is at least predictable.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# buffer

The text of one edited file: its lines, the cursor, the selection and the undo history.

This package knows nothing about terminals, rendering or syntax colouring. Everything here is plain data manipulation, which is why it is fully testable on its own and why every other package can depend on it without dragging a terminal in.

## Model

- Text is a slice of lines, each a slice of runes. A buffer always holds **at least one line**, so an empty buffer is one empty line rather than none.
- Positions are counted in **runes**, never in bytes, so an accented letter is one column like any other character.
- `Range` is **half-open**: `Start` is included, `End` is excluded. An empty range is an insertion point.
- Line endings and the trailing newline of the file that was read are remembered, so saving an untouched buffer reproduces the file byte for byte.

## Reloading is refused over unsaved work

`Reload` re-reads the buffer's file, and returns `ErrModified` instead when the buffer has unsaved changes. That restriction is the whole safety of the operation: something rewriting a file under an *unmodified* buffer costs nothing, and under a *modified* one costs the user's work.

The cursor is kept where it was, clamped into whatever the file now holds — a formatter moves lines about, and putting the cursor back at the top would lose the reader's place. The undo history is discarded, because undoing back past a reload would restore text the file no longer has.

## Public API

### Construction and content

| Function | What it does |
| --- | --- |
| `New() *Buffer` | An empty buffer: one empty line |
| `NewFromString(text string) *Buffer` | A buffer holding `text`, split on line feeds |
| `Open(path string) (*Buffer, error)` | Reads a file; a missing file yields an empty buffer bound to that path |
| `(*Buffer) SetText(text string)` | Replaces the whole content |
| `(*Buffer) Text() string` | The whole content, with the original line endings |
| `(*Buffer) Line(i int) string` | Line `i`, or `""` if out of range |
| `(*Buffer) LineRunes(i int) []rune` | A **copy** of line `i` |
| `(*Buffer) LineLen(i int) int` | Rune count of line `i` |
| `(*Buffer) LineCount() int` | Number of lines, at least one |

### File

| Function | What it does |
| --- | --- |
| `(*Buffer) Save() error` | Writes back to `Path()`; returns `ErrNoPath` if there is none |
| `(*Buffer) SaveAs(path string) error` | Writes to `path` and adopts it |
| `(*Buffer) Reload() (bool, error)` | Re-reads the file, reporting whether it changed; `ErrModified` rather than discarding unsaved work |
| `(*Buffer) Path() string` / `SetPath(string)` | The file this buffer belongs to |
| `(*Buffer) Modified() bool` | Whether there are unsaved changes |

Saving goes through a temporary file in the same directory followed by a rename, so an interrupted save cannot leave a half-written source file behind. The original file's permissions are preserved.

### Cursor

`Cursor()`, `SetCursor(Position)`, and the movements: `MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown`, `MoveLineStart`, `MoveLineEnd`, `MoveBufferStart`, `MoveBufferEnd`, `MoveWordLeft`, `MoveWordRight`.

`DisplayColumn(line, col)` and `RuneColumn(line, screenCol)` convert between rune columns and screen columns, which differ as soon as a line contains tabs. `TabWidth()` / `SetTabWidth(int)` control the expansion.

### Selection

`StartSelection`, `ClearSelection`, `SelectAll`, `Selection() (Range, bool)`, `SelectedText() string`, `DeleteSelection() bool`.

`Extend(move func())` runs any movement while keeping the anchor — that is what holding Shift does, and it is why there is no Shift-flavoured twin of every movement method:

```go
b.Extend(b.MoveWordRight) // Shift-Ctrl-Right
b.Extend(b.MoveDown)      // Shift-Down
```

`SetCursorKeepingSelection(Position)` is the same thing for an absolute position, which a mouse drag needs.

### Editing

`Insert(string)`, `InsertRune(rune)`, `InsertNewlineAndIndent()`, `Backspace()`, `Delete()`, `DeleteRange(Range)`, `ReplaceRange(Range, string) Position`.

Every change goes through `ReplaceRange`, which is the single place where the undo history, the modified flag and the cursor are maintained. Out-of-range and reversed ranges are accepted: they are clamped and ordered.

### Undo

`Undo() bool`, `Redo() bool`, `CanUndo() bool`, `CanRedo() bool`.

A run of typed characters, or a run of backspaces, collapses into a **single** undo step, so undo removes a word rather than a letter. Moving the cursor ends the run. The history is capped at 1000 entries.

## Tests

```sh
make test          # the whole repository
go test ./buffer/
```

## Whole-line edits, and one idea of a word

`InsertLineAbove` and `DeleteLine` are Turbo C's Ctrl-N and Ctrl-Y. Both go through `ReplaceRange` — the single mutation path — so each is one entry in the undo history rather than a splice nobody recorded.

Two edges are worth knowing. The **last line** of a buffer has no newline after it to take, so deleting it takes the newline *before* it; taking nothing would leave a blank line behind. And a buffer of **one line** is emptied rather than left with no lines at all, because every other operation here assumes there is always a line to be on. That second case is written out although clamping would reach the same answer without it — the range it would build starts on line −1, and leaning on `clamp` to pull that back to 0 is something a reader has to work out from two files away.

`SelectWord` selects the run of runes `IsWordRune` accepts, which is the same word `MoveWordLeft` moves by and the same one the completion popup uses to work out what is being typed. One editor, one idea of a word. A position that is not on a word rune selects nothing and only moves the cursor: editors disagree about what a run of punctuation means, and nothing is at least predictable.