Configuration
Textchum's settings follow one principle: the GUI is the comfortable way to change them, and a plain JSON file is the always-available escape hatch. There is exactly one store — the file — and the Settings window reads and writes it; nothing lives only inside the app.
The Settings window
Textchum → Settings… (⌘,) edits the recognized settings:
- Appearance — follow the system (switching live when macOS does), or force light or dark.
- Theme — the syntax palette; see Themes below.
- File icons — a VS Code icon pack for the file tree; see File icons below.
- Open files in — tabs of the current window (the default) or separate windows. With separate windows, each window's navigator lists only its own tab group's documents.
- Font — any fixed-pitch family installed on the system, or the platform's monospaced font.
- Font size — 6 to 72 points.
- Tab width — 1 to 16 columns.
- Show line numbers — the gutter, also togglable per session with View → Toggle Line Numbers (⇧⌘L).
- Close brackets and quotes as they are typed — off by default;
editor.auto_close_pairsin the file. See the tour.
Every change is applied to open editor windows immediately and written to disk at the same moment. There is no Apply or Save button to forget.
The file
Settings live in:
~/Library/Application Support/Textchum/config.json
A file edited by hand might look like:
{
"appearance": "dark",
"editor": {
"font_family": "JetBrains Mono",
"font_size": 13,
"tab_width": 4,
"hover": false,
"hover_modifier": "option"
}
}
appearance accepts "system", "light", or "dark"; omitting it (the
default) follows the system. editor.hover switches the mouse-rest
documentation popover off (true, the default, keeps it on);
editor.hover_modifier — "shift", "control", "option" or
"command" — makes it wait for that key as well, and leaving it out
means the mouse alone.
editor.new_files_in places fresh documents in a "tab" of the
frontmost window's group (the default) or a "window" of their own.
editor.mark_occurrences (true by default) marks the other places
the selected word appears; editor.occurrences_case_sensitive and
editor.occurrences_whole_word decide what counts as one, both true
by default.
editor.rainbow_brackets (false by default) colours bracket pairs by
depth; see highlighting.
editor.inlay_hints (true by default) shows the types a language
server inferred, dimmed, after the line they belong to; see
language servers.
editor.context_lines (true by default) pins the first line of each
enclosing construct at the top of the view while a long body scrolls;
false switches the pins off.
editor.git_marks chooses what the gutter's change bar compares
against: "head" (the default) for the last commit, "branch" for the
commit the branch grew from. editor.merge_base_branches is the list
of branch names tried, in order, when git does not name a default
branch — ["main", "master", "trunk", "develop"] unless set. Both keys
also work per project, as editor overrides on a workspace entry.
editor.pairs gives a language its own table of pairs, for Close
brackets and quotes as they are typed and for wrapping a selection:
for a language the built-in rule does not know, or one where it gets a
quote wrong for you. Keys are language names as the status bar shows
them; each entry is the opening half then the closing one:
{
"editor": {
"auto_close_pairs": true,
"pairs": {
"nix": ["()", "[]", "{}", "\"\""],
"rust": ["()", "[]", "{}", "\"\"", "<>"]
}
}
}
A table is the whole answer for its language — nothing from the built-in rule is added to it, so a quote left out stays a plain keystroke. A pair whose two halves are the same character is a quote, never opened right after a word nor before another of itself, and no pair opens into a word, table or not. Not there yet: the Settings window does not edit the table, and plain text cannot have one.
Everything is optional — a missing file, a missing section, or a missing key simply means the default. Writes are atomic (temporary file plus rename), like every write Textchum does.
Two guarantees make hand editing safe:
- Unknown keys survive. The settings window rewrites only the keys it owns. Anything else in the file — your annotations, keys from a newer version — is preserved verbatim on every save.
- Broken files are never clobbered. If the file fails to parse,
Textchum starts with default settings, tells you once at launch, and
leaves the file exactly as you wrote it so you can fix it in any editor —
including Textchum itself. Should you change a setting from the GUI while
the file is broken, the unparseable original is first copied to
config.json.bak, then replaced.
Out-of-range or mistyped values do not count as breakage: a font_size of
4000 is clamped to the valid range, a font_family of 42 is ignored,
and the rest of the file works normally.
Somewhere else, for a run
--data-dir <path> keeps everything Textchum owns under one directory
for that run — the configuration, themes, icon packs, the session and
the language-server log — instead of the usual places:
Textchum --data-dir ~/scratch-profile
That is a whole profile made for the occasion and thrown away
afterwards, with the real one never opened; make playground uses it,
and so does anything else that must not touch your settings. On Linux a
run with its own profile is its own process, since handing the files to
an instance already running would open them in that instance's profile.
--config <path> is the narrower version: it points at one
configuration file, and the session follows it.
Themes
The Theme picker in the General tab selects the syntax palette. Seven ship built in: Textchum (the default), Textchum High Contrast, Graphite (a muted near-monochrome), and the classics — Molokai, Solarized, Dracula, and Gruvbox. Every theme carries a light and a dark palette in one file, so one theme serves both appearance modes (the dark-born classics pair their canonical palette with a contrast-adjusted light one; Solarized and Gruvbox use their genuine light palettes).
User themes are JSON files in:
~/Library/Application Support/Textchum/themes/
selected by file name (without .json); a file named after a built-in
overrides it. Textchum → Open Themes Folder opens (and creates)
this directory. The fastest way to start one is to generate a complete
starter — every styled capture name, filled with the default palette —
and just change colors:
Textchum --emit-theme ~/Library/Application\ Support/Textchum/themes/Mine.json
Entries map tree-sitter capture names to styles:
{
"name": "Mine",
"styles": {
"keyword": {"light": "#AD3DA4", "dark": "#FC5FA3", "bold": true},
"comment": {"light": "#707F8C", "dark": "#7F8C98", "italic": true}
}
}
Colors are #RRGGBB or #RRGGBBAA. Anything omitted — a color, a
flag, a whole capture — keeps the default palette's value, so a theme
only needs to say what it changes. The escape-hatch rules match the
configuration's: a theme that fails to parse falls back to the default
with one warning and is never overwritten, and unknown keys survive.
Theme files are read at launch and when the selection changes.
Importing one from another editor
Textchum → Import Theme brings colours over from VS Code or
TextMate. Pick a theme file, or a folder holding several — a VS Code
extension directory (its package.json says what it contributes) or a
TextMate bundle (its themes live in Themes/). Everything found is
imported, and the first is put on. What was imported, and anything that
did not come across, is said in the notification area at the right of
the status bar; a click there shows the whole of it.
Both editors describe colour by TextMate scope, so importing is a
matter of translating scopes to Textchum's capture names:
entity.name.function becomes function, keyword.control.loop
becomes repeat. Scopes stop where captures keep going — no theme
colours if differently from while — so a capture the source never
named takes its colour from the one it is a special case of, in either
direction: a theme that says keyword colours every kind of keyword,
and one that says only constant.numeric colours the whole constant
family.
Two things are worth knowing before the colours look wrong:
- A theme fills one appearance. Both editors write a theme for a light background or a dark one; Textchum's carry both. An import fills the side the source declares and leaves the other at the default palette, and says which side it filled. Importing a dark theme while the editor is in light appearance changes nothing visible.
- Scopes with nowhere to go are named. Anything the source coloured that no capture answers to is listed after the import. Those colours are unused.
The result is an ordinary theme file in the themes folder, editable like any other.
File icons
The file tree draws an icon per row. Without a pack that is whatever
the desktop offers for the file's type, which knows Python from
Markdown and stops not much further along — and has never heard of a
file called Dockerfile.
Settings → General → File icons takes a VS Code icon pack. The packs already seen are on the list, split between the ones imported here and the ones opened where they lie; System icons is the way back.
Import… copies the pack into Textchum's own folder —
~/Library/Application Support/Textchum/icons/ on macOS,
~/.local/share/textchum/icons/ on Linux — so moving or deleting the
original cannot take the icons away. Open… points at a pack where
it sits and remembers it, which is right for one you maintain
yourself. Either takes the icon theme's JSON file or the extension
folder holding it (its package.json says which file). A pack put
into Textchum's folder by hand is listed as well — an extension folder,
or the theme's JSON file on its own — and the title bar's file icon
follows the pack, as the tree does. Delete
removes an imported pack; a pack opened from elsewhere belongs to
whoever put it there, so it can only be dropped from the list.
The choice is a path in config.json, and the packs opened from
elsewhere are remembered beside it:
{
"icon_pack": "~/packs/material-icon-theme/dist/material-icons.json",
"icon_packs": ["~/packs/material-icon-theme/dist/material-icons.json"]
}
A pack whose folder is gone drops off the list rather than sitting there to fail when chosen. A pack that cannot be read is reported once and the tree keeps the system's icons.
Lookup follows VS Code's, most specific first:
- The whole file name (
Dockerfile,cargo.toml), lowercased. - The longest extension that matches:
component.test.tstriestest.tsbeforets. - The language Textchum decided the file is — which is also how a language set by hand in File Properties reaches the icon.
- The pack's own default.
A pack's light section overrides any of those on a light background,
one lookup at a time, so a pack that only redraws a handful keeps the
rest.
Two things are left out. Folder icons: the tree draws its own.
Font-based icons — the fontCharacter definitions Seti and its
descendants use — need the icon font installed and a text run where an
image goes; a pack with nothing else is refused with that as the
reason, rather than loaded to draw nothing.
Projects
The Projects tab decides where a project starts and ends — the boundary the navigator groups by and the language-server pool keys its instances on. Both switches exist twice: as a default for every project, and per project root. A row added with the path field (which completes directory names as you type, and carries a Browse… button) overrides the defaults for that root only.
- Manifest projects — normally the outermost repository wins:
opening a file anywhere inside a repository makes the repository the
project, however many
Cargo.tomlorpyproject.tomlfiles sit in between. Switching this on splits a root at language manifests again, so nested modules become projects of their own. - Recursive config — makes a root's language-server entries apply to the nested projects inside it, closest ancestor first, and has the root's server look after them: a nested project with no entry of its own is served by the instance running at the root, which sees the whole tree. Useful for monorepos: one configuration, and one server, at the top; many projects underneath. The Language Servers tab shows the same switch beside each project entry, as Nested projects too.
- Separate nested servers — under recursive config, gives each
nested project a server of its own instead: the root's command line,
started in the nested project, with
{project}naming it. For members that each carry their own environment — a.venvper package — where one server at the top would see none of them. A server each, in the Language Servers tab. - Ctags fallback — answers Jump to Definition from a Universal Ctags index when no language server is available; see language servers.
In the file, these live in a workspace section:
{
"workspace": {
"manifest_projects": false,
"recursive_config": false,
"separate_nested_servers": false,
"ctags_fallback": false,
"projects": {
"/Users/you/code/monorepo": {
"manifest_projects": true,
"recursive_config": true
}
}
}
}
Save preprocessors
Formatters and fixers can run automatically before every save, per
language — for every project or for one root, exactly like language
servers. Each entry is a chain: one command per line, run in order,
where every command reads the document on standard input and writes
the whole document back on standard output (the - convention most
formatters follow). If a link in the chain fails — non-zero exit,
empty output, or a hang past ten seconds — nothing is applied, the
error (with the tool's stderr) is shown, and the save asks whether to
proceed unprocessed. That question can be answered once and for all:
When a chain fails, save anyway (Settings ▸ Preprocessors;
"on_failure": "save" in the section below) writes the document as
typed and says so in the status bar for a moment, with the tool's
words in the log — for anyone whose answer is always yes.
{
"preprocessors": {
"defaults": {
"python": ["ruff check --fix -", "black -"],
"go": ["gofmt"]
},
"projects": {
"/work/site": { "javascript": ["prettier --stdin-filepath {filename}"] }
}
}
}
{path} and {filename} anywhere in a command expand to the
document's absolute path and bare name — for tools that read stdin but
infer their behavior from the name, like Prettier's --stdin-filepath.
An untitled document offers Untitled plus its language's extension.
{edition} expands to the Rust edition of the crate the document
belongs to: the nearest Cargo.toml's, or its workspace's when the
package inherits it. rustfmt reading standard input never sees the
manifest and formats as 2015 unless told, so rustfmt --edition
{edition} is how a chain formats the way cargo fmt would. A file
outside any crate gets 2021.
A project entry replaces the default chain for that language, never
appends to it. The Settings window edits the same section under
Language Servers, and Edit ▸ Run Save Preprocessors (⌃⌥⌘F, action
name runPreprocessors) runs the chain on demand without saving —
formatting through your tools instead of the language server's
formatter. The result lands as one edit, so ⌘Z undoes it.
The link @format is not a program: it asks the document's language
server to format, and the save waits up to two seconds for the answer.
It is how a formatter that needs the project's own settings gets them —
rust-analyzer formats with the crate's edition, gopls with its
configured options — and it can sit anywhere in a chain, before or
after commands. A server that cannot format (none running, none that
formats, none that answered in time) does not fail the chain: the text
goes on as it is and the notification area says why.
Known tools
Nobody should have to look up that black wants a trailing dash and prettier a file name. The Known tools menu — beside every chain in Settings ▸ Preprocessors, and in the form that adds one — lists the tools the editor knows the command line of for the language in hand, marks which are installed, and writes the line when one is chosen. A tool that is not installed is still offered, with the command that installs it. Known servers does the same for the server command under Language Servers.
| Tool | Languages | The line it writes |
|---|---|---|
| rustfmt | rust | rustfmt --edition {edition} |
| gofmt | go | gofmt |
| goimports | go | goimports |
| gofumpt | go | gofumpt |
| golines | go | golines |
| ruff format | python | ruff format --stdin-filename {path} - |
| ruff check --fix | python | ruff check --fix --exit-zero --no-cache --stdin-filename {path} - |
| ruff (imports) | python | ruff check --select I --fix --exit-zero --no-cache --stdin-filename {path} - |
| black | python | black --quiet --stdin-filename {path} - |
| isort | python | isort --stdout --filename {path} - |
| autopep8 | python | autopep8 - |
| yapf | python | yapf --quiet |
| autoflake | python | autoflake --remove-all-unused-imports --stdin-display-name {path} - |
| usort | python | usort format - |
| prettier | javascript, typescript, tsx, css, html, json, yaml, markdown | prettier --stdin-filepath {path} |
| prettierd | javascript, typescript, tsx, css, html, json, yaml, markdown | prettierd {path} |
| biome | javascript, typescript, tsx, json, css | biome format --stdin-file-path {path} |
| dprint | javascript, typescript, tsx, json, markdown, toml | dprint fmt --stdin {path} |
| eslint_d | javascript, typescript, tsx | eslint_d --fix-to-stdout --stdin --stdin-filename {path} |
| clang-format | c, cpp, java, csharp | clang-format --assume-filename={path} |
| astyle | c, cpp | astyle |
| shfmt | bash | shfmt -filename {path} |
| beautysh | bash | beautysh - |
| stylua | lua | stylua --search-parent-directories --stdin-filepath {path} - |
| zig fmt | zig | zig fmt --stdin |
| terraform fmt | hcl | terraform fmt - |
| tofu fmt | hcl | tofu fmt - |
| packer fmt | hcl | packer fmt - |
| nomad fmt | hcl | nomad fmt - |
| jq | json | jq . |
| fixjson | json | fixjson |
| taplo | toml | taplo format - |
| yamlfmt | yaml | yamlfmt - |
| mdformat | markdown | mdformat - |
| xmllint | xml | xmllint --format - |
| sql-formatter | sql | sql-formatter |
| sqlfmt | sql | sqlfmt - |
| pg_format | sql | pg_format |
| swiftformat | swift | swiftformat --stdinpath {path} |
| rubocop | ruby | rubocop --auto-correct-all --fail-level fatal --stderr --force-exclusion --stdin {path} |
| standardrb | ruby | standardrb --fix --fail-level fatal --stderr --stdin {path} |
| nixfmt | nix | nixfmt |
| alejandra | nix | alejandra --quiet - |
| nixpkgs-fmt | nix | nixpkgs-fmt |
| ormolu | haskell | ormolu --stdin-input-file {path} |
| fourmolu | haskell | fourmolu --stdin-input-file {path} |
| stylish-haskell | haskell | stylish-haskell |
| ocamlformat | ocaml | ocamlformat --enable-outside-detected-project --name {path} - |
| ocp-indent | ocaml | ocp-indent |
| mix format | elixir | mix format --stdin-filename {path} - |
| scalafmt | scala | scalafmt --stdin |
| google-java-format | java | google-java-format - |
| cmake-format | cmake | cmake-format - |
| gersemi | cmake | gersemi - |
A line is ordinary text once written: add flags to it, reorder the chain, or delete it, as with any other. The table is tested against whichever of these tools the machine running the tests has.
Not there yet: a tool outside this table still has to be typed, and tools that only rewrite files in place, rather than work over standard input, are not listed.
Language presets
A language is rarely one tool, so Settings ▸ Presets offers the common ones as a set: the server, the settings it should run with, and the save-preprocessor chain.
| Preset | Server | Settings | Before every save |
|---|---|---|---|
| Rust | rust-analyzer | checks with clippy | rustfmt --edition {edition} |
| Python | pyright, with ruff beside it | — | ruff check --fix …, then ruff format |
| Go | gopls | staticcheck, its colouring of names, and its inferred types and parameter names as hints | goimports |
| TypeScript and JavaScript | typescript-language-server | — | prettier --stdin-filepath {path} |
| C and C++ | clangd | — | clang-format --assume-filename={path} |
Apply writes ordinary entries — lsp.defaults, lsp.settings and
preprocessors.defaults — and restarts the servers. Nothing remembers
that a preset was used: what it wrote is yours to edit under Language
Servers and Preprocessors, and the button reads Applied for as long
as the configuration still says everything the preset would write.
Each preset lists the programs it runs and whether each was found on
PATH; a missing one shows the command that installs it. The editor
installs nothing itself.
Not there yet: a preset applies to every project (the entries it writes can be moved under a project by hand). And the TypeScript preset does not set eslint up as a second server, since eslint's server needs the project's own configuration to say anything.
Spell checking
Prose gets the system spell checker — the same dictionaries every Mac app shares — scoped to where prose actually lives: comments in code, and the whole document in Markdown, git commit messages, and plain text. Identifiers and string literals are never flagged. Misspellings carry a purple tint, distinct from the red/orange/blue of diagnostics.
Pick the language in Settings ▸ General ▸ "Spell check prose" — Off
(the default), Automatic by content, or a specific dictionary — or set
editor.spell by hand: "auto" or a spelling identifier like
"en_US" or "es". The dictionaries available are the ones enabled
in System Settings ▸ Keyboard ▸ Text Input (macOS ships dozens; add
more there and they appear in the picker).
{ "editor": { "spell": "auto" } }
Several dictionaries can apply at once — name them separated by commas. A word any of them knows is spelled correctly, which is what a document that switches languages mid-paragraph needs:
{ "editor": { "spell": "en_US, es_ES" } }
editor.spell_words is your own list: project names, acronyms, and
everything no dictionary ships with. Right-clicking a misspelling
offers replacements, Add to Dictionary, which writes the word here,
and Ignore, which accepts it until the editor quits. The list is
also editable in Settings.
{ "editor": { "spell_words": ["SBX", "Textchum"] } }
On Linux the same settings ride hunspell: install hunspell plus a
dictionary package (hunspell-es, hunspell-en-us, …) and the marks
appear; "auto" follows $LANG, and the dictionaries hunspell can
find are listed beside the field in Preferences.
Autosave
Off by default. editor.autosave is a number of seconds; the clock
restarts with every keystroke, so the save happens once typing stops
rather than in the middle of a sentence.
{ "editor": { "autosave": 30 } }
Two things it deliberately does not do. It never saves a document that has no name — there is nowhere to put it, and inventing one is not the editor's decision. And it does not run save preprocessors: a formatter reflowing the line you are still writing is not a favour, so explicit saves remain the place for that.
Key shortcuts
Settings ▸ Keyboard has them: a profile, and every command with the shortcut it answers to, editable in place.
Profiles. People arrive from another editor with its shortcuts in
their fingers, so the three those editors are known for ship with the
build — Visual Studio Code, Sublime Text and IntelliJ IDEA. A profile
names the commands it moves and leaves the rest alone, so picking one
changes what that editor is known for and nothing else. keys_profile
holds the choice; empty is Textchum's own bindings.
Changing one shortcut on top of a profile keeps the profile: the change
is an override, and Reset changes drops all of them. Save as
profile turns what is in force into a profile of your own — the way
to modify a bundled preset, since those ship with the build. Saved
profiles live in key_profiles, and one that reuses a bundled name
replaces it.
A key answers to one command. When a profile or an override puts a
command on a key another command has by default, that other command
gives the key up and its field is left blank: a profile that puts
new on cmd+n means Ctrl+N opens a tab, not a window. On Linux,
where cmd and ctrl are the same key, a profile that names both for
one letter — cmd+g for findNext, ctrl+g for goToLine — leaves
the key to the cmd binding, and the other command keeps its own
shortcut. The macOS build does not settle these yet: a key two
commands share there reaches only one of them.
The file spells the overrides as a keys section: an object of action
names to modifiers+key specs, applied over the profile.
{
"keys": {
"openQuickly": "cmd+p",
"goToBlockEnd": "ctrl+alt+down",
"findInProject": "cmd+shift+g"
}
}
Modifiers: cmd, shift, alt, ctrl — cmd is Command on macOS
and Ctrl on Linux, so one profile means the same thing on both. Keys: a
character, f1 to f20, or
up/down/left/right/return/escape/space/tab/delete.
Actions include new, open, openQuickly, save, saveAs, showLocalHistory, close,
undo, redo, find, findAndReplace, findNext, findPrevious,
useSelectionForFind, findInProject, searchEverywhere, runCommand, runAtCaret, runAgain, rerunFailedTests, debugAtCaret, toggleBreakpoint, debugContinue, stepOver, stepInto, stepOut, stopDebugging, toggleDebugPanel, stopRun, nextOutputPlace, previousOutputPlace, toggleRunPanel, jumpToDefinition, goToImplementation, goToTypeDefinition,
findReferences, showUsages, codeActions, renameSymbol, formatDocument, runPreprocessors,
documentOutline,
goBack, goForward,
blameLine, goToLine,
goToBlockStart, goToBlockEnd, expandSelection, shrinkSelection, addNextOccurrence, addCaretAbove, addCaretBelow, toggleNavigator, togglePreview,
toggleLineNumbers, toggleHover, showHover, goToLastEdit, recentFiles, recentPlaces, nextFinding, previousFinding, togglePathDisplay,
redraw, commandPalette, serverStatus, newWithFormat, revealInTree, reopenClosed,
settings — an unknown name is
logged with the full list. And when a shortcut escapes memory entirely,
the Command Palette (⇧⌘P) fuzzy-searches every menu action by name
and runs the selection. Go to Block Start/End (⌃⌥↑/⌃⌥↓ by default) jump over the
innermost multi-line syntax block around the caret, courtesy of the
same tree that powers highlighting. The same tree is behind Expand
Selection (⌥↑, Alt+Up on Linux): each press grows the selection by
one step of the code — the name, the inside of its brackets or quotes,
the brackets with them, the expression, the statement, the block, up to
the whole file — and Shrink Selection (⌥↓, Alt+Down on Linux) walks
back down the same steps. A file no grammar reads grows by word, then
by line, then to the whole. Shrinking only gives back what expanding
took; a selection made with the mouse or the arrows has no steps to
undo.
The chord window
A shortcut of three or four keys has to be remembered and then reached for. The chord window is the other way round: hold two modifier keys, and a small panel lists what can be done, one key beside each command. Press the key and the command runs.
It is off until a pair is chosen in Settings ▸ Keyboard ▸ Chord
window. The file holds the choice as keys_chord:
{ "keys_chord": "ctrl+alt" }
The pairs are ctrl+alt, alt+cmd, ctrl+cmd, ctrl+shift and
alt+shift; anything else leaves the window off. Here cmd is the key
itself — Command on macOS, Super on Linux — because a pair is two keys
held, not a shortcut.
The window opens when the pair has been held on its own for a quarter
of a second, so a shortcut that uses the same two modifiers is neither
delayed nor taken: its key arrives first. Once open it stays open with
the keys let go. Commands are in groups — File, Code, Go,
Search, View — marked with a +; a group's key opens its list,
and the most used commands (the command palette, blame, Preferences)
sit at the top with no group between. A key that is nothing is refused
and the window stays; Escape or a click closes it.
The menu is the same on macOS and Linux, and it is fixed: the keys in it cannot be changed yet, and a command not in it is reached through its shortcut or the command palette. On Linux the desktop may keep a pair for itself — Super on its own often opens the activities view, and Alt+Shift switches keyboard layouts on some setups — in which case the window never hears it; pick another pair.
Live reload
The file is watched while Textchum runs: edit config.json in another
editor and the change applies the moment it lands — appearance, theme,
fonts, key bindings, server table, everything, including the open
Settings window. The app's own saves are recognized and ignored, and a
file that momentarily fails to parse falls back to defaults without
being overwritten, exactly like at launch.
Per-project editor settings
A project root can override the editor's font family, font size, and
tab width for every window inside it — the Projects tab's rows carry
the three fields (empty means "inherit the general value"), and the
file spells it as an editor object on the workspace entry:
{
"workspace": {
"projects": {
"/work/legacy": { "editor": { "tab_width": 8, "font_size": 12 } }
}
}
}
Adding a project
The Projects tab lists the roots of the open documents, so a project is added by picking it rather than by typing its path. Copy settings from starts the new entry from one already configured — its servers, save commands, flags and editor overrides, all of it — which is what a second service in the same layout needs. The same choice sits on each project's own row, for copying onto one that already exists.
An empty override field shows what it inherits, so a blank box says what applies rather than leaving you to go and look.
A configured root whose directory is gone is marked missing, and Remove missing forgets every one of them: nothing will ever match those entries again.
Languages the build does not know
Textchum's colouring comes from tree-sitter grammars compiled into the
build. A language it does not carry can be named in languages, with
the grammar as a compiled library and its highlights query as a file:
{
"languages": {
"kdl": {
"grammar": "~/.local/share/textchum/grammars/libtree-sitter-kdl.dylib",
"highlights": "~/.local/share/textchum/grammars/kdl/highlights.scm",
"extensions": ["kdl"]
}
}
}
aliases, filenames and injections are optional, and so is
symbol: the constructor is tree_sitter_<name> unless it is named,
with dashes and dots turned into underscores. A grammar built for a
different tree-sitter is refused by its ABI number rather than trusted
and crashed on, and a name the build already knows is replaced by the
configured one — which is how a dated built-in grammar gets fixed
without waiting for a release.
Textchum ▸ Install Grammar… writes such an entry for you from a
grammar's repository — a checkout of any tree-sitter grammar, which is
what Neovim, Helix and Zed colour with too. It compiles the parser with
the system's C compiler into the profile's grammars folder, copies
the highlight and injection queries beside it, reads the name and file
types from the repository's tree-sitter.json, and loads the result,
so a file of that type open in a tab is coloured before the dialog
closes. Queries written for another editor are rewritten for this one:
capture names the themes here have no colour for are mapped onto the
ones they do (keyword.repeat becomes repeat, markup.bold becomes
text.strong), a Lua-pattern predicate becomes the regular expression
tree-sitter evaluates, and a predicate the editor cannot evaluate is
named in the report rather than left to match everything in silence.
Without a C compiler the command says what to install; a repository
without src/parser.c needs tree-sitter generate first, and says
so. Not there yet: cloning from a URL — clone it, then choose the
folder — and a list of what is installed, which for now is the
languages section itself.
Building one by hand, from a grammar's own repository:
cc -O2 -fPIC -shared -I src -o libtree-sitter-NAME.dylib src/parser.c src/scanner.c
(.so on Linux, and drop src/scanner.c when the grammar has none.)
An entry that cannot be loaded costs that one language: the editor says
what went wrong and carries on. The section is read again whenever the
file changes, so a grammar added or fixed while the editor runs arrives
without a restart, and the documents already open are parsed with it —
except one whose language you chose by hand, which keeps your choice.
The interface's language
The interface speaks English, Spanish or French; Interface language in Preferences picks one, and System follows the machine. The change applies at the next launch.
The catalogues are gettext, kept in the core so that both shells and
the core itself say the same things in the same words. The source of
truth is core/textchum-core/i18n/<language>.po — the format
translators and their tools speak — and the build compiles each into
the .mo the editor reads. A phrase with no translation reads as what
it says in English rather than as a missing key.
A catalogue of your own goes in the profile, and is read instead of the one the build carries:
msgfmt -o ~/.config/textchum/translations/es.mo my-es.po
~/Library/Application Support/Textchum/translations/<language>.mo~/.config/textchum/translations/<language>.mo
A file named for a language the build does not carry is read the same way, so a fourth language is a catalogue rather than a release.
To work on the translations in the repository, scripts/i18n.sh
extracts the strings and merges them into every catalogue, the way
gettext means it: xgettext finds them, msgmerge carries the
existing translations over and marks as fuzzy the ones whose English
changed, and msgfmt checks the result. make check runs
scripts/i18n.sh --check, so a string added without a translation
fails the build instead of shipping in English.
Project records
A file remembers how it is split, where each view was looking, what is
folded, and what it was told it is when its name does not say. That is
data about the file, so it lives with the project instead of in
config.json: one record per project root, JSON like everything else.
{
"version": 1,
"root": "/work/engine",
"files": {
"src/parser.rs": {
"views": 2,
"dividers": [0.45],
"folds": [[12, 48]],
"language": "rust",
"places": [{"caret": 812, "scroll": 240.0, "top": 790}]
}
}
}
Records are kept in the profile, beside the session and the themes, so
a run pointed at a scratch profile writes its own. Keep each
project's state with the checkout puts the record at
<root>/.tchum instead, for a layout that travels with the clone —
the choice is global, since a per-project answer would have to be
recorded centrally to be found.
The sweep runs at launch on a thread of its own: it forgets the records of projects that are no longer there, and those not written for longer than the keep window (90 days by default; zero keeps them until they are forgotten by hand). Forget records at launch turns it off, and Manage… beside the records folder lists what exists, with what each record is about and when it was last written, to be forgotten one at a time or in a sweep.
Not there yet
- Nothing at the moment — file an itch when one appears.