Skip to content

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_pairs in 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:

  1. The whole file name (Dockerfile, cargo.toml), lowercased.
  2. The longest extension that matches: component.test.ts tries test.ts before ts.
  3. The language Textchum decided the file is — which is also how a language set by hand in File Properties reaches the icon.
  4. 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.toml or pyproject.toml files 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 .venv per 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.