docs/internal/config-themes-settings.md
Purpose: explain how Fresh resolves layered configuration, generates and consumes
its JSON Schema, renders the schema-driven Settings UI, preserves user comments
through JSONC read/write, and manages themes, the keybinding editor, and the
init.ts programmable-config surface — distinguishing what is implemented from
what is still planned.
The overlay applies five rungs. Earlier design notes describe a "4-layer" model, but the resolver inserts a platform-specific User variant between Project and User, so the real count is five.
The writable layer enum exposes four targets — System, User, Project, Session — with precedence rising in that order. There is no writable enum variant for the platform rung; it is a read-only variant of the User layer rather than a save target.
A configuration resolver carries directory context and a working directory, and drives resolution. Effective precedence, highest wins:
Session > Project > User-Platform > User > System(defaults)
Resolution starts from Session and merges Project, then the User-platform file (for example a Linux-specific user config), then User, then fills remaining gaps from the hardcoded default config (the System layer).
Layer file locations:
| Layer | Location |
|---|---|
| User | the user config file in the OS config directory |
| User-platform | a platform-suffixed user config file (config_linux.json / config_macos.json / config_windows.json) |
| Project | a .fresh/config.json under the working directory (legacy fallback to a bare config.json); writes always target the .fresh/ form |
| Session | a volatile .fresh/session.json under the working directory |
| System | none; the hardcoded default config |
The config directory is OS-specific: macOS forces ~/.config/fresh;
Linux/Windows use the platform config directory under a fresh subfolder. A
separate legacy search-path API also exists.
Discrepancy. The doc comments on the layer enum and on the older layered-load entry point describe a clean 4-layer "Session > Project > User > System" and omit the UserPlatform rung that resolution actually inserts. The layer-write path handles only User/Project/Session and errors on System — platform is read-only.
Two distinct mechanisms.
(A) Layer merge — the Merge trait. For an optional value, if the receiver
is unset, it takes the other layer's value. Because the higher-precedence layer
is merged first, a value set there wins → scalars: highest layer wins.
languages and plugins). This is what makes
per-language overrides such as languages.python.tab_size compose correctly,
rather than replacing the whole language entry.keybindings and environment detectors merge as
whole optionals, so the higher layer's list replaces rather than
element-merges.settings (untyped JSON): objects deep-merge key-by-key,
non-objects and null replace.(B) Resolve-with-defaults. HashMaps start from a clone of the defaults' map
and overlay user entries, so defaults for un-overridden keys survive; LSP
single-server vs multi-server forms get special handling; resolution ends by
normalizing zero sentinels, treating 0 as "unset" for fields such as the wrap
column, page width, and tab size.
This matches the intended overlay contract: scalars highest-wins, maps deep-merge, lists replace.
Layers store only their difference from the resolved parent, so setting a value back to an inherited or default value prunes the key.
Three save paths exist on the resolver:
The diff core performs a recursive object diff emitting only differing leaves.
Empty objects and nulls are stripped before writing. Lifting a full config to its
partial form prunes default plugins and never serializes a plugin's auto-discovered
path.
Known limitation (implemented, ignored test). The base save path alone cannot prune a value reset to the parent value (covered by an
#[ignore]d test); the baseline and changes paths solve it. Other tests document that the base save path is not a true read-modify-write under external concurrent edits; the pointer-based changes path is what preserves external edits and comments.
A parallel set of Option-wrapped mirrors of every config struct exists, where
None means "inherit". This is the substrate for delta serialization:
serializing a partial naturally omits None keys, and the diff/prune logic
operates on these shapes. Lifting from a full config produces all-Some;
resolving collapses back. The session config is a focused volatile subset (theme,
editor, per-buffer overrides).
The top-level config struct has all fields default-able. Top level: version, theme, locale, update-check. Nested sections cover the editor, file explorer, file browser, clipboard, terminal, warnings, packages, and environment. Keybindings are held as a list plus a map of named keymaps and an active-keymap selector (default/emacs/vscode/macos/macos-gui keymaps are embedded into the binary). Languages are a map plus a default-language selector. LSP has an enable flag plus single- and universal-server maps. Plugins are an auto-discovered map.
The plugin config type lives in fresh-core so the plugin runtime can read it
from the JS bindings; the editor crate re-exports it. Each plugin's free-form
settings is stored as untyped JSON so a malformed plugin schema cannot poison
the rest of the config.
Migration: a current-config-version constant gates a migration chain that runs before deserialization — v0→v1 converts camelCase to snake_case, v1→v2 prepends a remote element to the status bar's left section.
A sequence of changes, landed in order, fixed a class of silent-data-loss bug. The chronology is the design rationale.
Accept JSONC on read. Config files were parsed with a strict JSON parser,
so a single // comment made a layer invalid; in the layered flow the error
was swallowed and the editor silently fell back to defaults. All reads now
route through a JSONC parser that tolerates comments and trailing commas;
empty or comment-only input yields an empty object. This covers the user,
project, and session layers, the read-modify-write save paths, the raw
user-config read, and the explicit --config load. Genuinely malformed input
still errors.
Preserve comments on rewrite. Writes still serialized from a value, which reconstructs the file and drops all comments. Writes now go through the JSONC parser's concrete syntax tree: existing text is parsed, then edited minimally — unchanged properties are left byte-for-byte (writes are skipped when the value is already equal, to keep inline comments), nested objects recurse, removed keys are deleted, and new keys appended. New, unparseable, or non-object-root files fall back to pretty-printing. All save paths funnel through the same clean-write routine, so this covers every layer write.
Never overwrite an unparseable config. A save is a read-modify-write; the read side previously swallowed parse failures and returned an empty object, so one comment plus any save destroyed the whole file. The existing-file read now errors with a parse error when a non-empty file cannot parse, and the base save path repeats the guard inline. Regression tests assert the file is left byte-for-byte intact.
Surfacing save errors. When the no-clobber guard aborts a save, a plain status-bar line was easy to miss. The error now propagates out and the settings save flow raises a save-error popup — a centered, red-bordered modal showing the parse error, the file path and line/column, and a note that the file was left unchanged. Acknowledging the modal opens the offending config file so the user can fix the syntax.
The config-error type distinguishes IO, parse, serialize, and validation errors.
The config schema is generated offline, not at build time: a generator binary
runs schemars over the config struct, drops the large menu default, and the
result is committed alongside the editor crate's bundled plugins. The same binary
emits the theme schema and the package schema. The build script notes that the
schema is no longer build-generated.
The schema is compiled into the binary via an include at the single load point — opening the settings UI — and reused in settings tests.
Custom x- extensions that schemars emits or the struct attributes inject
(present in the committed JSON) include: x-display-field,
x-standalone-category, x-no-add, x-section, x-order, x-enum-from,
x-dual-list-options, x-dual-list-sibling, and a status-bar
dynamically-extendable marker. For example, the plugin config carries a
display-field pointing at its enabled flag and marks path and settings
read-only.
The schema is parsed into a list of setting categories, which are built into settings pages of setting items (each carrying a setting control), rendered and hit-tested by the settings layout. The schema-parse module is the only WASM-compatible settings module; everything else is gated behind the runtime feature.
Schema parsing walks top-level properties. A property becomes a standalone
category when the standalone-category extension is set (maps such as
languages), a category with nested settings when the resolved schema has
properties, else a top-level setting folded into a synthesized "General"
category. Plugin schemas are appended as Plugin: <name> categories rooted at
each plugin's settings subtree.
Control-type selection maps the schema to a setting type:
| Schema | SettingType | Control |
|---|---|---|
boolean | Boolean | Toggle |
integer (+min/max) | Integer | Number |
number | Number | Number (floats stored as scaled integers) |
string | String | Text (Dropdown if enum-from set) |
| inline / enum-values enum | Enum | Dropdown |
array<string|int> | StringArray/IntegerArray | TextList |
array + dual-list-options | DualList | DualList |
array<$ref object> | ObjectArray | ObjectArray (keybindings) |
object + additionalProperties | Map | Map (entry dialogs) |
object + fixed properties | Object | flattened controls or JSON |
| fallback | Complex | uneditable / JSON editor |
It handles the schemars Option<T> shape (an anyOf of a ref and a null type)
and turns a null enum value into an "Auto-detect" option with an empty-string
value.
Discrepancy. The category and page types carry subcategory/subpage fields, but schema parsing never populates subcategories — the left panel is a flat categories-plus-sections tree, not true nested subpages. An inner
Objectis flattened into native child controls only when every child is editable; otherwise the whole object collapses to one JSON editor. So "nested object → subcategory" holds only for top-level properties.
The central state object holds pending changes (pointer→value), pending deletions, per-value layer sources, a target layer (default User), a nested entry-dialog stack, and the original config. Module responsibilities:
The control enum covers: Toggle, Number (integer or float-as-percent), Dropdown (enum/select), Text, TextList (string/int arrays), DualList (an ordered subset with sibling cross-exclusion), Map (key→value dict), an ObjectArray for keybinding lists, a multiline JSON editor for Object/Complex values, and an uneditable Complex control. There is no dedicated color or file-path control — colors fall through to Text/JSON.
"Modified" means the value is defined in the target layer being edited, not "differs from default" (the newer semantics): a value is modified when its layer source equals the target layer. Layer sources come from the resolver and default to System when a value is purely a schema default.
Rendering uses a three-column indicator gutter: a focus marker, a modified dot, and a spacer. Category labels get a dot prefix when the page has pending changes; the title bar shows a modified marker with the layer name; the per-value layer source (user/project/session, none for System) is shown in the description band.
The layer-definition signal and the unsaved-this-session signal are deliberately separate.
The dialog context uses the old definition (modified = differs from schema default) because dialogs have no layer context.
The entry dialog is schema-driven, not per-type hardcoded — there is no special "add language" versus "add LSP" dialog. The language, LSP, and universal-LSP dialogs are the same generic dialog opened over the respective maps. Building from schema injects a synthetic read-only key field plus one item per object property; an array-item variant handles ObjectArray items by index. Nested Map/ObjectArray values open by pushing onto the entry-dialog stack.
Per-field buttons [Reset]/[Inherit]/[Clear]: Reset restores the built-in
default; Inherit/Clear set null. Built-in per-entry defaults come from the bundled
config. Inherited (null) nullable fields are omitted on save so they keep
inheriting.
Control mutators report a value change, which compares against the effective original and records a pending change — toggling a value back to its original clears the pending entry. The target layer cycles User→Project→Session; System is read-only; switching layers clears pending changes and rebuilds pages.
Saving builds the merged config (deletions first, then changes), applies runtime side-effects (theme, locale, plugins, keybindings, LSP, bars, file explorer), persists the delta via the changes-based save path, re-resolves from disk, and clears the settings state so a reopen is fresh. On error the change is not applied and the save-error modal (§2) is raised.
Reset/revert: discarding restores; resetting the current value to default removes it from the target layer (via pending deletions) so it falls back to inherited — it does not write the schema default; separate operations set a nullable item to null or clear a nullable category.
Separate prompt system. A command-palette prompt set (theme, language, encoding, keybinding-map, cursor style, locale, line-ending, ruler) is independent of the modal; it calls the save paths directly against the User layer. Its module doc flags the repetitive start/apply/save triples as awaiting refactor.
Settings search fuzzy-matches name, description, and path (best of three, with a custom scorer) and recurses into composite controls and Map/TextList values. Jumping to a result navigates to the page and item, dives into the deep-match target, auto-expands the category, and cancels search.
Discrepancy. Footer button indices enumerate four (Layer/Reset/Save/Cancel) but comments reference a 5th "Edit" button — worth verifying against the live footer renderer.
Two parallel representations exist:
extends.Conversion runs both directions between the file and runtime forms. Colors are an
untagged enum: an RGB triple serialized as [r,g,b], or a named string
("Default"/"Reset" map to terminal transparency). The theme name is a config
newtype, not part of the theme module, defaulting to a high-contrast theme.
Earlier design notes list theme consolidation as "not yet shipped". It is shipped, and went further than the plan. There are no Rust
Theme::dark()/Theme::light()constructors; all built-ins are JSON.
Built-in themes are embedded JSON. A build script scans the themes directory and generates a built-in-themes table that includes each file's JSON, pulled in at runtime. Eight files ship: dark, light, high-contrast, nostalgia, dracula, nord, solarized-dark, and terminal.
The theme loader deserializes each built-in JSON to the file form and then to the
runtime form, then scans user themes (recursively under the config directory's
themes/) and theme packages, producing a registry. Override-by-name applies:
built-ins are keyed by bare name, user themes by a file:// path; resolution
allows a builtin://NAME form to force the built-in, while a relative name
resolves to a user theme overriding the matching built-in. Inheritance via
extends or auto-base selection (luminance-based light/dark auto-pick) is applied
on top of overrides. The planned plugin API for listing built-in themes exists.
Two preview mechanisms:
theme_editor.ts plugin. A command opens the editor, exposing a set
of scoped commands under a theme-editor context. It loads the registry, edits
fields, accepts hex / [r,g,b] / named colors, renders an in-buffer preview
panel, and saves or deletes themes.Known usability gaps: no full live-apply while editing colors (only the preview panel), no discard confirmation, no way to directly edit an existing theme, and inconsistent navigation. Note that some theme docs reference an obsolete single-file theme module; the code is the split theme module.
init.ts ConfigA third indentation tier provides per-language regex rules in the style of VS Code indentation rules, run via the standard regex crate (RE2-style, no lookarounds) with scope-masking (comment and string bytes blanked before matching) as the anti-glitch mechanism. The authoritative primitive lives in the indent-rules module. The config surface is an optional indent-rules config on the language config, with patterns for increase-indent, decrease-indent, indent-next-line, dedent-next-line, and self-close. A user-rules registry layers overrides over built-in families; unset patterns inherit from the family; config load and reload re-register the overrides. The rollout also reduced the bundled tree-sitter grammars substantially, with a parity CI guard.
init.ts — programmable user config (IMPLEMENTED)The init-script loader loads ~/.config/fresh/init.ts (if present) through the
plugin pipeline as a plugin named init.ts — the same code path as "Load Plugin
from Buffer", so reload and unload are free. Design intent: for decisions that
depend on the runtime environment (host, $TERM, SSH, project path) and for
imperative plugin configuration — not for static preferences (Settings UI),
keybindings (editor), permanent themes (selector), or reusable features (plugin
packages). The starter template is fully commented and spells out this
non-overlap.
--no-init / --safe.State and logic live in the app-side keybinding-editor module (the editor struct, its data model, and conversion helpers); rendering and input live on the view side; persistence glue is a separate actions module. The modal shows a collapsible, plugin-grouped table: Key | Action | Description | Context | Source.
Binding resolution layers sources by precedence into a (key_display, context)
dedup map: the active keymap, then user keybindings (which override the keymap),
then plugin defaults, then Unbound placeholders for every action so all stay
rebindable.
Enter opens the edit dialog. Key capture is a deliberate two-step "special capture": Enter arms capture mode, then the next raw keypress is normalized (uppercase mapped to lowercase plus a SHIFT modifier) and stored. The action field has substring autocomplete. Conflict detection is advisory only — it does not block saving.
Applying the dialog validates the action name and pushes a binding to a pending-adds
list. The final save removes pending removals and adds pending additions to the
keybindings list, reloads the resolver, and writes the keybindings JSON pointer to
the User layer. Deleting a keymap or plugin binding shadows it with a custom
noop override.
Discrepancies. (1) Editing an existing binding pushes a fresh pending-add entry without removing the superseded one — repeated edits accumulate redundant entries. (2) Chords are displayed and resolved but cannot be authored in the editor — applying the dialog always writes an empty keys list and a false chord flag. (3) Conflicts are non-blocking.
The four built-in maps plus user maps live in the keymap map; the active one is
the active-keymap selector (resolution with inheritance). The editor reads only
the active map and shows the map names read-only — it cannot switch the active
map. Switching is a switch_keybinding_map:<name> action, expanded per map, that
persists the choice to the User layer.
A set of editor methods handle boolean/config toggles. Two patterns: global preference toggles mutate runtime config and persist to User via a single-pointer save to the User layer, versus per-split/per-buffer view-state toggles stored only in per-file workspace state.
| Toggle | Setting | Persists |
|---|---|---|
| line numbers | editor.line_numbers | yes |
| line numbers, current buffer | per-buffer override | workspace only |
| line wrap, current buffer | per-buffer wrap | workspace only |
| menu bar | editor.show_menu_bar | yes |
| file-explorer side | file_explorer.side | yes |
| vertical scrollbar | editor.show_vertical_scrollbar | yes |
| horizontal scrollbar | editor.show_horizontal_scrollbar | yes |
| mouse capture | window mouse-enabled | runtime only |
| mouse hover | editor.mouse_hover_enabled | runtime only — bug |
| inlay hints | editor.enable_inlay_hints | runtime only — bug |
Related config operations live here too: dumping the whole config and opening it, reloading config (re-resolving layers and re-applying theme/keybindings/bars/LSP), and reloading themes.
Discrepancy. The mouse-hover and inlay-hints toggles mutate runtime config but never persist it, unlike the sibling
editor.*toggles — so they reset on restart. The tab-bar and status-bar toggles are not here; they live on the window type.
x- extensions carry UI hints.init.ts only for env-dependent or imperative
config — enforced by the starter template and the init check tool.