turbo-editors/turbo-corepublic Fork 0
v1.0.1
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 v1.0.1 · k33g · 21h ago
README.md · 103 lines · 7.9 KBmarkdown
Blame HistoryOpen raw

editor

The text-editing widget: a scrolling, colouring viewport onto one buffer.

This is where the pure packages meet the screen — buffer holds the text, syntax colours it, theme says in what, ui draws it — and it is the only place that knows how all four fit together.

What a view draws

 1 package main          ▲   ← line-number gutter, then the text, then
 2                       ▓     the vertical scroll bar
 3 import "fmt"          ░
◄▓░░░░░░░░░░░░░░░░░░░░░░░►   ← the horizontal one along the bottom

Each character is drawn in its syntax colour, unless the selection covers it. On the cursor's own line the syntax colours keep their foreground but take the current-line background, so the highlight shows through the coloured tokens instead of being punched full of holes.

Tabs are expanded to the buffer's tab width, and the view scrolls in both directions to keep the cursor visible.

The cursor is marked twice. The theme's editor.cursor background is sent to the terminal as the colour of its own cursor, and the cell underneath is painted in the same style as a fallback for terminals that ignore that. Painting alone is not enough: a terminal draws its cursor over the cell, in whatever colour the user configured for some other palette, so on a dark theme it is very often a dark cursor covering whatever is beneath it. Only the terminal cursor's own colour can win, and only the theme should decide it.

The colours must be a distinct pair, never a reversal of the line — terminals that draw their cursor by inverting the cell would invert it straight back into invisibility. Tests hold every theme to that, and to a minimum distance from the line it sits on. Only the active window shows a cursor: a window tells its content whether it is focused.

The theme-arithmetic tests live here for the same reason the drawing does: this is where colour meets the screen. Two measures, and both are needed. Channel distance answers "can the eye see these are two colours" — right for a cursor, a highlight, a selection. WCAG relative luminance answers "can this be read" — right for prose, and the reason syntax.comment is held to 4.5:1 in every theme this project authors. Turbo Classic's comments cleared the first by a mile and failed the second, and were hard to read for exactly that long.

Keys

Turbo C's bindings where they still make sense, modern ones where they do not.

Arrows, Home, End, PgUp, PgDn Move
Ctrl-← Ctrl-→ By word
Ctrl-Home Ctrl-End Start and end of the file
Shift+any movement Extend the selection
Enter New line, keeping the indent
Tab / Shift-Tab Indent / unindent — the whole selection when there is one
Ctrl-Z Ctrl-Y Undo, redo
Ctrl-C Ctrl-X Ctrl-V Clipboard
Ctrl-Ins Shift-Del Shift-Ins The same, spelled the Turbo C way
Ctrl-A Select all
Ctrl-Space Ask for completion

Typing a . asks for completion too, since that is where the list is most useful. Mouse: click to place the cursor, drag to select, wheel to scroll.

Public API

NewView(buf, clipboard) *View A view onto a buffer, sharing a clipboard with the other views
(*View) Buffer() *buffer.Buffer The text being edited
(*View) Draw, HandleKey, HandleMouse, Bounds, SetBounds The ui.Widget contract
(*View) Cut/Copy/Paste/Undo/Redo/SelectAll() bool What the Edit menu calls
(*View) Indent() / Unindent() Block indentation, one undo step
(*View) InsertSnippet(string) Insert text at the cursor, re-indenting the lines after the first to match the current line; one undo step
(*View) GoToLine(int) Jump, counting from one
(*View) EnsureCursorVisible() Scroll so the cursor is on screen
(*View) CursorStatus() string The line:column the status bar shows
(*View) WordBeforeCursor() string The identifier being typed — what a completion list filters on
(*View) ReplaceWordBeforeCursor(string) Accept a completion
(*View) LineNumbers() / SetLineNumbers(bool) Show or hide the gutter
(*View) RefreshSyntax() Re-decide whether this file can be coloured, after a rename
(*View) SetMarks(map[int]Severity), (*View) Marks() Which lines have something wrong with them
(*View) InsertLine(), (*View) DeleteLine() Turbo C's Ctrl-N and Ctrl-Y
(*View) SetClock(func() time.Time) The clock two clicks are timed against; only a test calls it
Clipboard The text shared between views by cut, copy and paste

Three callbacks let the app follow along without the view knowing what an app is: OnChange, OnCursorMove, OnCompletionRequest.

Why a snippet is re-indented

InsertSnippet copies the current line's leading whitespace onto every line of the text after the first. Inserting a multi-line body verbatim restarts it at column zero, which is wrong everywhere except the top level of a file — and an if err != nil is inserted inside something by definition. Copying the line's own prefix follows whatever the file already uses, tabs or spaces, rather than imposing a choice.

A blank line in the body stays blank: padding it to the indent leaves trailing whitespace that every formatter then strips, which is noise in the very next diff.

Why its own clipboard

A terminal program cannot read the host's clipboard portably. A clipboard shared between the editor's own windows is what Turbo C offered, and it is what this offers.

Tests

make test
go test ./editor/

Marks, and why this package has never heard of a language server

A mark is a character in the gutter beside a line something is wrong with. The severities are editor.Severity — this package's own type — and not the protocol's, because this package colours and draws text and knows nothing about protocols. Whoever holds the diagnostics translates on the way in. The alternative was importing lsp here to draw one character, which would put the protocol underneath the drawing.

The mark goes in the column that separates the line numbers from the text. The gutter is already digits + 1 wide, the +1 being that separator, so a mark costs no layout: not the text's left edge, not the cursor's screen column, not the column a mouse click lands on. The consequence, stated where anyone changing it will read it: hiding the line numbers hides the marks. The alternative — a one-column gutter appearing when the numbers are off — would make the text jump sideways the moment a server reported anything.

The three diagnostic.* theme keys are what they are drawn in. They had been defined in every shipped theme, tested for readability, and drawn nowhere at all.

Counting clicks

A second press on the same cell within 400 ms selects the word under it. Same cell, not merely near: a press one column over is a different word, and selecting the first because the pointer drifted is worse than selecting nothing.

Two details are what make this work rather than nearly work. tcell sends Button1 for every event of a drag as well as for a press, so clicks are counted only when no drag is in progress — otherwise a slow drag becomes a double click halfway through. And the clock is a field, set with SetClock, for the same reason app.App has one: a double click measured against the real clock is a test that passes or fails by how fast the machine is.

After a double click the drag carries on from the word, so holding and moving extends from the start of the word, by character. That is not the word-by-word drag a large editor does; it is the cheap half of it, and it beats the alternative, which is the word collapsing back to a single character the moment the pointer twitches.

The counter keeps going past two, so a triple click can be given a meaning later without reworking any of this.

  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
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
# editor

The text-editing widget: a scrolling, colouring viewport onto one buffer.

This is where the pure packages meet the screen — `buffer` holds the text, `syntax` colours it, `theme` says in what, `ui` draws it — and it is the only place that knows how all four fit together.

## What a view draws

```
 1 package main          ▲   ← line-number gutter, then the text, then
 2                       ▓     the vertical scroll bar
 3 import "fmt"          ░
◄▓░░░░░░░░░░░░░░░░░░░░░░░►   ← the horizontal one along the bottom
```

Each character is drawn in its **syntax colour**, unless the selection covers it. On the cursor's own line the syntax colours keep their foreground but take the current-line background, so the highlight shows *through* the coloured tokens instead of being punched full of holes.

Tabs are expanded to the buffer's tab width, and the view scrolls in both directions to keep the cursor visible.

The **cursor is marked twice**. The theme's `editor.cursor` background is sent to the terminal as the colour of its *own* cursor, and the cell underneath is painted in the same style as a fallback for terminals that ignore that. Painting alone is not enough: a terminal draws its cursor over the cell, in whatever colour the user configured for some other palette, so on a dark theme it is very often a dark cursor covering whatever is beneath it. Only the terminal cursor's own colour can win, and only the theme should decide it.

The colours must be a **distinct pair**, never a reversal of the line — terminals that draw their cursor by inverting the cell would invert it straight back into invisibility. Tests hold every theme to that, and to a minimum distance from the line it sits on. Only the **active** window shows a cursor: a window tells its content whether it is focused.

The theme-arithmetic tests live here for the same reason the drawing does: this is where colour meets the screen. Two measures, and both are needed. **Channel distance** answers "can the eye see these are two colours" — right for a cursor, a highlight, a selection. **WCAG relative luminance** answers "can this be read" — right for prose, and the reason `syntax.comment` is held to 4.5:1 in every theme this project authors. Turbo Classic's comments cleared the first by a mile and failed the second, and were hard to read for exactly that long.

## Keys

Turbo C's bindings where they still make sense, modern ones where they do not.

| | |
| --- | --- |
| Arrows, `Home`, `End`, `PgUp`, `PgDn` | Move |
| `Ctrl-←` `Ctrl-→` | By word |
| `Ctrl-Home` `Ctrl-End` | Start and end of the file |
| `Shift`+any movement | Extend the selection |
| `Enter` | New line, keeping the indent |
| `Tab` / `Shift-Tab` | Indent / unindent — the whole selection when there is one |
| `Ctrl-Z` `Ctrl-Y` | Undo, redo |
| `Ctrl-C` `Ctrl-X` `Ctrl-V` | Clipboard |
| `Ctrl-Ins` `Shift-Del` `Shift-Ins` | The same, spelled the Turbo C way |
| `Ctrl-A` | Select all |
| `Ctrl-Space` | Ask for completion |

Typing a `.` asks for completion too, since that is where the list is most useful. Mouse: click to place the cursor, drag to select, wheel to scroll.

## Public API

| | |
| --- | --- |
| `NewView(buf, clipboard) *View` | A view onto a buffer, sharing a clipboard with the other views |
| `(*View) Buffer() *buffer.Buffer` | The text being edited |
| `(*View) Draw`, `HandleKey`, `HandleMouse`, `Bounds`, `SetBounds` | The `ui.Widget` contract |
| `(*View) Cut/Copy/Paste/Undo/Redo/SelectAll() bool` | What the Edit menu calls |
| `(*View) Indent() / Unindent()` | Block indentation, one undo step |
| `(*View) InsertSnippet(string)` | Insert text at the cursor, re-indenting the lines after the first to match the current line; one undo step |
| `(*View) GoToLine(int)` | Jump, counting from one |
| `(*View) EnsureCursorVisible()` | Scroll so the cursor is on screen |
| `(*View) CursorStatus() string` | The `line:column` the status bar shows |
| `(*View) WordBeforeCursor() string` | The identifier being typed — what a completion list filters on |
| `(*View) ReplaceWordBeforeCursor(string)` | Accept a completion |
| `(*View) LineNumbers() / SetLineNumbers(bool)` | Show or hide the gutter |
| `(*View) RefreshSyntax()` | Re-decide whether this file can be coloured, after a rename |
| `(*View) SetMarks(map[int]Severity)`, `(*View) Marks()` | Which lines have something wrong with them |
| `(*View) InsertLine()`, `(*View) DeleteLine()` | Turbo C's Ctrl-N and Ctrl-Y |
| `(*View) SetClock(func() time.Time)` | The clock two clicks are timed against; only a test calls it |
| `Clipboard` | The text shared between views by cut, copy and paste |

Three callbacks let the app follow along without the view knowing what an app is: `OnChange`, `OnCursorMove`, `OnCompletionRequest`.

## Why a snippet is re-indented

`InsertSnippet` copies the current line's leading whitespace onto every line of the text after the first. Inserting a multi-line body verbatim restarts it at column zero, which is wrong everywhere except the top level of a file — and an `if err != nil` is inserted inside something by definition. Copying the line's *own* prefix follows whatever the file already uses, tabs or spaces, rather than imposing a choice.

A blank line in the body stays blank: padding it to the indent leaves trailing whitespace that every formatter then strips, which is noise in the very next diff.

## Why its own clipboard

A terminal program cannot read the host's clipboard portably. A clipboard shared between the editor's own windows is what Turbo C offered, and it is what this offers.

## Tests

```sh
make test
go test ./editor/
```

## Marks, and why this package has never heard of a language server

A mark is a character in the gutter beside a line something is wrong with. The severities are `editor.Severity` — this package's own type — and not the protocol's, because this package colours and draws text and knows nothing about protocols. Whoever holds the diagnostics translates on the way in. The alternative was importing `lsp` here to draw one character, which would put the protocol underneath the drawing.

The mark goes in the column that **separates the line numbers from the text**. The gutter is already `digits + 1` wide, the +1 being that separator, so a mark costs no layout: not the text's left edge, not the cursor's screen column, not the column a mouse click lands on. The consequence, stated where anyone changing it will read it: **hiding the line numbers hides the marks.** The alternative — a one-column gutter appearing when the numbers are off — would make the text jump sideways the moment a server reported anything.

The three `diagnostic.*` theme keys are what they are drawn in. They had been defined in every shipped theme, tested for readability, and drawn nowhere at all.

## Counting clicks

A second press on the **same cell** within 400 ms selects the word under it. Same cell, not merely near: a press one column over is a different word, and selecting the first because the pointer drifted is worse than selecting nothing.

Two details are what make this work rather than nearly work. tcell sends `Button1` for every event of a **drag** as well as for a press, so clicks are counted only when no drag is in progress — otherwise a slow drag becomes a double click halfway through. And the clock is a **field**, set with `SetClock`, for the same reason `app.App` has one: a double click measured against the real clock is a test that passes or fails by how fast the machine is.

After a double click the drag carries on from the word, so holding and moving extends from the **start of the word**, by character. That is not the word-by-word drag a large editor does; it is the cheap half of it, and it beats the alternative, which is the word collapsing back to a single character the moment the pointer twitches.

The counter keeps going past two, so a triple click can be given a meaning later without reworking any of this.