| 📦 Turbo Go 3d7798b k33g 13h ago | 1 | # Design decisions — explanation |
| 2 | |
| 3 | ## What is this about? |
| 4 | |
| 5 | The choices that shaped Turbo Go, what the alternatives were, and why they were turned down. This is the page to read before changing something that looks arbitrary. |
| 6 | |
| 7 | ## Two dependencies, and no more |
| 8 | |
| 9 | Turbo Go depends on `tcell/v2` and `BurntSushi/toml`. Everything else is the standard library — including the tokeniser, the JSON-RPC client, the LSP framing and the file handling. |
| 10 | |
| 11 | **What was rejected.** `go.lsp.dev/jsonrpc2` would have saved perhaps three hundred lines of turbo-core's `lsp`. `rivo/tview` would have saved rather more of turbo-core's `ui`. A syntax-highlighting library would have brought fifty languages instead of one. |
| 12 | |
| 13 | **Why.** An editor is a program you keep for years and change often. Every dependency is a piece of it you cannot change, cannot fully test, and have to track. The protocol is simple enough to write down, and writing it down put the whole conversation somewhere a reader can follow. Three hundred lines you understand beat three hundred you inherit. |
| 14 | |
| 15 | The exception proves the rule: `tcell` is not a convenience, it is the terminal-compatibility database, and reimplementing that would be neither small nor honest work. |
| 16 | |
| 17 | ## The widget framework is hand-written |
| 18 | |
| 19 | `tview` has widgets. `bubbletea` has an architecture. Neither has what Turbo Vision had: overlapping movable windows with shadows, a menu bar with hot keys, and modal dialogs, all drawn with box characters in sixteen colours. |
| 20 | |
| 21 | The Elm-style architecture that `bubbletea` uses re-renders the whole view on every message. That model is excellent for a form and awkward for a full-screen editor with windows stacked on top of each other and a cursor that has to be in one exact cell. |
| 22 | |
| 23 | Writing the framework cost roughly fifteen hundred lines. In exchange the editor looks like Turbo C rather than like a modern TUI wearing a blue background, and every drawing decision is one file away. |
| 24 | |
| 25 | ## Bounds are absolute screen coordinates |
| 26 | |
| 27 | Every widget's `Bounds()` is where it really is on the terminal, not where it is relative to its parent. Hit-testing a mouse click is then a plain rectangle test, and no event ever needs translating on its way down. |
| 28 | |
| 29 | **The cost** is that containers place their children in screen space. **The alternative** — relative coordinates with a translation at each hop — moves the arithmetic from layout into event handling, where it is done far more often and is far easier to get wrong. Clipping still composes correctly because a painter intersects its parent's clip, so a child with wrong arithmetic draws nothing rather than drawing over its neighbours. |
| 30 | |
| 31 | ## Every change goes through one function |
| 32 | |
| 33 | `buffer.ReplaceRange` is the only place the text is modified. Insert, backspace, delete, indent, paste and undo all funnel through it, and it is the only place the undo history, the modified flag, the revision counter and the cursor are maintained. |
| 34 | |
| 35 | The alternative — each operation maintaining its own bookkeeping — is how undo bugs are born. There is exactly one thing to get right, and it is tested directly. |
| 36 | |
| 37 | ## Windows follow the terminal, they do not scale with it |
| 38 | |
| 39 | A window has a **grow mode**, which names the desktop edges it follows. A document window follows the right and bottom edges: its top-left corner stays where it is, and its far corner moves by exactly as much as the terminal's did. A window that filled the terminal therefore still fills it, and one you had cascaded keeps its offset. |
| 40 | |
| 41 | **The alternative was proportional scaling** — multiply every window's rectangle by the ratio of the old and new sizes. It was rejected because it moves windows the user deliberately placed, and because rounding makes it lossy: shrink and grow again and nothing is where it was. Turbo Vision used grow modes, and they are still the right answer. |
| 42 | |
| 43 | Whatever its grow mode, a window is then held to the desktop's own size. One larger than the desktop that holds it has parts nobody can reach. |
| 44 | |
| 45 | |
| 46 | ## A window's boxes say what they will do, not what the window is |
| 47 | |
| 48 | The frame carries two boxes: `[x]` at the left closes the window, `[■]` at the right fills the desktop. |
| 49 | |
| 50 | The close box used to be `[■]` — Turbo Vision's own — and it had to move. Two boxes on one frame need to be told apart at a glance, and a filled block reads as "fill the screen" far more readily than as "close". `[x]` is what a close button has meant for thirty years; the block went to the job it actually looks like. |
| 51 | |
| 52 | The maximise box **changes with the window's state**: `[■]` while there is room to grow, `[▬]` once the window fills the desktop. The alternative was a fixed symbol, and it makes the button ambiguous exactly when you need it — you can see that the window is large, but not whether pressing the box will make it larger still or put it back. A control that shows its *current state* leaves you to work out the action; one that shows its *action* does not. |
| 53 | |
| 54 | A window with nowhere to maximise into shows **no box at all**, rather than one that does nothing. Only the desktop knows what area a window would fill, so a window that is not on one has nothing to offer. |
| 55 | |
| 56 | **Window ▸ Maximise is the same toggle**, not a one-way action. A menu item and a button that disagreed about what "maximise" means would be a bug people reported rather than a subtlety they appreciated. |
| 57 | |
| 58 | ## Undo merges runs of typing |
| 59 | |
| 60 | Typing `func` and pressing Ctrl-Z removes all four letters. So does a run of backspaces. Moving the cursor ends the run, and typing never merges with deleting. |
| 61 | |
| 62 | Character-by-character undo is what a naive implementation gives you, and it is what Turbo C itself did. It is also what nobody wants any more. |
| 63 | |
| 64 | ## Themes are TOML, with two kinds of inheritance |
| 65 | |
| 66 | **Between files**, `inherits` takes the parent's resolved styles as the starting point. A theme of your own can therefore be five lines. |
| 67 | |
| 68 | **Between keys**, along the dots: `syntax.keyword` falls back to `syntax`, and `syntax` to `default`. This happens twice — once at parse time, so an entry setting only `fg` inherits its `bg`, and once at lookup time, so a theme that never mentions `syntax.keyword` still colours keywords. |
| 69 | |
| 70 | That second one is what makes a partial theme a usable theme, and it is why there is no such thing as a theme that leaves half the screen unpainted. |
| 71 | |
| 72 | **Why TOML rather than JSON.** Comments. A theme is a file people edit by hand and annotate. |
| 73 | |
| 74 | **An unknown colour is an error**, not a silent fallback to the terminal default. A typo that quietly repaints half the screen is much harder to find than one that says so at load. |
| 75 | |
| 76 | ## The language server is optional by construction |
| 77 | |
| 78 | `app.Language` wraps the whole gopls conversation, and when there is no server every method is a no-op rather than an error. Nothing else in the editor asks whether a language server exists. |
| 79 | |
| 80 | The alternative — checking for `nil` at each of the twenty call sites — is twenty chances to forget. Here, forgetting is impossible: there is nothing to check. |
| 81 | |
| 82 | This is why `gopls` is not bundled, not downloaded, and not required. It is looked for on `PATH` and in `GOPATH/bin`, and its absence is reported on the status bar with the one command that fixes it. |
| 83 | |
| 84 | ## Saving is atomic, and byte-faithful |
| 85 | |
| 86 | A save writes to a temporary file in the same directory and renames it over the target, preserving the original's permissions. An interrupted save cannot leave a half-written source file. |
| 87 | |
| 88 | Separately, the line endings a file was read with and its trailing newline — or lack of one — are remembered, so opening and saving an untouched file reproduces it byte for byte. An editor that silently normalises line endings turns a one-line change into a whole-file diff. |
| 89 | |
| 90 | ## The clipboard is the editor's own |
| 91 | |
| 92 | A terminal program cannot read the host clipboard portably. Rather than pretend, Turbo Go shares one clipboard between its own windows, which is what Turbo C did. |
| 93 | |
| 94 | ## The version is a property of the build, not of the source |
| 95 | |
| 96 | The version used to be `const Version = "0.1.0"` in a subdirectory. It was accurate the day it was written and wrong for the fourteen commits after it, because nothing in the process of committing, tagging or installing touches a Go constant. An About box is where somebody looks when they are about to report a bug; a number there that names a release the binary is not is worse than no number, because it is believed. |
| 97 | |
| 98 | So the number is taken from the build. The linker stamps `git describe --tags --dirty` into turbo-core's `version` package from the Makefile and from the installer, which is what makes `make install` produce an editor that names the commit it came from. When nothing stamped it, the binary asks `runtime/debug.ReadBuildInfo()`, which covers the one path that cannot be stamped: `go install rickub.com/turbo-editors/turbo-go@v0.2.0`, where there is no Makefile in the picture and the Go tool knows the module version. Only when both are silent does it say `unknown` — deliberately not a number, because the whole failure being designed against is a plausible-looking version nobody set. |
| 99 | |
| 100 | Two things the build system cannot do explain the rest of the design. **It does not read git tags**, so a plain `go build .` can never report `0.1.0-14-g88a4c38` however clever the code is; it reports `devel` plus the commit, and the documentation says so rather than implying that every build is equal. And what it *does* report for such a build is a **pseudo-version** — `v0.1.1-0.20260831165958-88a4c3859bf3` — which is shown as `devel` instead, because its `0.1.1` is a patch release that does not exist and would be read as one. |
| 101 | |
| 102 | `vcs.time` is deliberately unused. It is the commit's timestamp, and every binary is linked later than the commit it was built from, so labelling it "Built" would be false on all of them. A build date is shown only when a build actually stamped one, which is the same rule the About box follows throughout: **a fact nobody recorded gets no line**, rather than an empty one that reads as a failure to fill it in. |
| 103 | |
| 104 | Rejected: a `make release` target that tags, builds and pushes. Releasing is three git commands, and wrapping them hides which of them failed; the version stamping is the part that could not be done by hand reliably, and that is the part that was automated. |
| 105 | |
| 106 | ## How it relates to the rest |
| 107 | |
| 108 | - What the packages are and how they fit: [Architecture](architecture.md) |
| 109 | - How the colouring and the completion work: [Colouring and completion](colouring-and-completion.md) |