| 🛟 Updated. 28d5985 k33g 18h ago | 1 | # theme |
| 2 | |
| 3 | Turns TOML colour files into the `tcell` styles the editor draws with. |
| 4 | |
| 5 | A theme is a flat map from a **dotted style key** — `syntax.keyword`, `menu.selected`, `editor.text` — to a foreground colour, a background colour and a few attributes. |
| 6 | |
| 7 | ## Where themes come from |
| 8 | |
| 9 | 1. **The user's directory**, passed in as `userDir` — the editor works it out from its profile, which reads `$TURBO_GO_THEME_DIR` (or `$TURBO_RUST_THEME_DIR`) and otherwise falls back to `<slug>/themes` under the platform config directory. Pass `""` to offer the embedded themes alone. |
| 10 | 2. **Embedded in the binary**: `turbo-classic`, `turbo-dark`, `borland-light`, `cappuccino`, `catppuccin-frappe`, `catppuccin-latte`, `cobalt`, `darcula`, `intellij-light`, `monochrome-dark`, `monochrome-light`. |
| 11 | 3. **A name a theme used to answer to**, listed in `retiredNames`: `monochrome` loads `monochrome-dark`, which is what that theme shipped as before `monochrome-light` joined it. |
| 12 | |
| 13 | A user file wins over an embedded theme of the same name, so a shipped theme can be overridden without being replaced. A retired name is resolved *after* the user's directory, so somebody's own `monochrome.toml` still wins there too, and it is not listed by `Available`, so each theme is offered once under the name it has now. The reason the mechanism exists at all is that a theme's name is what somebody wrote in a settings file their whole team shares. |
| 14 | |
| 15 | ## File format |
| 16 | |
| 17 | ```toml |
| 18 | name = "My Theme" |
| 19 | description = "One line, shown in the theme picker." |
| 20 | inherits = "turbo-classic" # optional: fill in every key you do not set |
| 21 | |
| 22 | [colors] |
| 23 | default = { fg = "silver", bg = "navy" } |
| 24 | "syntax.keyword" = { fg = "white", bold = true } |
| 25 | "syntax.string" = { fg = "#a8d76f" } |
| 26 | "editor.text" = { fg = "default", bg = "default" } # leave it to the terminal |
| 27 | ``` |
| 28 | |
| 29 | Colours are ANSI names understood by tcell (`navy`, `aqua`, `silver`, `fuchsia`, …), `#rrggbb` literals, or `default` / `-` / `""` for the terminal's own colour. Attributes: `bold`, `underline`, `italic`, `reverse`, `dim`, `blink` — each a boolean, only ever switched **on**. |
| 30 | |
| 31 | An unknown colour name is an **error at load time**, not a silent fallback, so a typo is pointed at instead of quietly repainting half the screen. |
| 32 | |
| 33 | ## Two kinds of inheritance |
| 34 | |
| 35 | **Between files**, with `inherits`: the parent's resolved styles are the starting point, and the child overrides what it names. Chains are capped at 16 hops, so a loop is reported rather than hanging. |
| 36 | |
| 37 | **Between keys**, along the dots: `Style("syntax.keyword")` tries `syntax.keyword`, then `syntax`, then `default`. This happens twice — once at parse time, so an entry setting only `fg` inherits its `bg` from its parent key, and once at lookup time, so a theme that never mentions `syntax.keyword` still colours keywords. Shallower keys are resolved first, whatever order they appear in the file. |
| 38 | |
| 39 | ## Public API |
| 40 | |
| 41 | | Function | What it does | |
| 42 | | --- | --- | |
| 43 | | `Load(name string) (*Theme, error)` | User directory first, then embedded; `ErrNotFound` if neither has it | |
| 44 | | `LoadFile(path string) (*Theme, error)` | Reads one file, named after it if it sets no `name` | |
| 45 | | `Parse(data []byte) (*Theme, error)` | Reads TOML content | |
| 46 | | `Default() *Theme` | The theme used when the user has not chosen one; never fails | |
| 47 | | `Available() []string` | Every loadable theme name, sorted, user and embedded merged | |
| 48 | | — | This package no longer works out where the user's themes live; `profile.Profile.ThemeDir()` does, and the answer is passed in. | |
| 49 | | `(*Theme) Style(key string) tcell.Style` | The style for a key, with dotted fallback | |
| 50 | | `(*Theme) Name() / Description() string` | What the theme picker shows | |
| 51 | | `(*Theme) Keys() []string` | Every key the theme sets, sorted | |
| 52 | | `(*Theme) Defines(key string) bool` | Whether it sets that exact key, without fallback | |
| 53 | |
| 54 | Constants `Key*` in `keys.go` are the complete vocabulary of style keys. A test checks that every embedded theme sets every one of them, so adding a widget without theming it fails the suite. |
| 55 | |
| 56 | One consequence of dotted fallback is worth stating, because it surprises people writing a theme: fallback runs along the dots and no further. `terminal.text` falls back to `terminal`, then to `default` — never to `editor.text`. The same holds for `tree.*`, which is why the project tree could not simply borrow `list.selected`: that colour is chosen to stand out against a dialog, and on a window body it can be the very colour underneath it. |
| 57 | |
| 58 | ## Tests |
| 59 | |
| 60 | ```sh |
| 61 | make test |
| 62 | go test ./theme/ |
| 63 | ``` |
| 64 | |
| 65 | ## A shipped theme states its whole palette |
| 66 | |
| 67 | `Defines` is satisfied by inheritance, so a theme that omits a key still passes the completeness test — while quietly showing a colour Turbo Classic chose for its navy background. On espresso, on cream or on black that colour can be unreadable, and nothing says so. |
| 68 | |
| 69 | `TestEveryEmbeddedThemeSetsEveryKeyItself` closes that. A theme *you* write may still inherit — that is what `inherits` is for, and the documentation recommends it. The rule is narrower: a theme shipped inside the binary is one the project is answerable for, so it states its palette in full. |
| 70 | |
| 71 | Three more rules, in `editor` where the colour arithmetic already lives: the cursor is at least 64 from its line and never a plain reversal of it, the current line is at least 16 from the page, and **text meant to be read is at least 64 from its background**. That last one exempts the furniture — desktop, shadow, scrollbar trough, inactive frame, disabled entry, line-number gutter — which sits between 20 and 70 in every theme because it exists to recede. |
| 72 | |
| 73 | **Channel distance is the wrong measure for prose.** The rule above compares the strongest channel, which is right for a cursor and wrong for something read word by word: Turbo Classic drew comments in `#808080` on `#000080` — 128 apart, waved straight through, and 4.05:1 to read, below the W3C's floor for body text. `TestEveryThemeWeAuthorKeepsItsCommentsReadable` measures WCAG relative luminance instead and holds `syntax.comment` to **4.5:1**. Comments were the dimmest reading colour in six of the eight shipped themes when it was written. |
| 74 | |
| 75 | It covers comments and nothing else, deliberately. `syntax.punctuation` is quieter still in several themes and stays that way — punctuation is recognised by shape, not read. And the two Catppuccin themes are exempt, because their colours are somebody else's published palette copied faithfully, and Catppuccin puts comments at 2.87:1 and 2.83:1. A second test fails if that exemption ever names a theme this package does not ship, so it cannot quietly outlive its reason. |
| 76 | |
| 77 | And one rule no measurement finds: **two syntax classes a reader meets side by side must not be drawn identically**. `turbo-classic` once painted `syntax.link` the same lime as `syntax.string`, so a Markdown link and an inline code span were the same thing on screen. Classes deliberately alike — string and char, constant and number — are not grouped together, so the test says nothing about them. |