docs/internal/config-editor-design.md
Built-in settings editor for Fresh, replacing the plugin-based config_editor.ts
The config editor opens as a full-screen modal (like the file picker), not a separate buffer.
┌─ Settings ─────────────────────────────────────────────────────────────────┐
│ [/] Search settings... [?] Help [Esc] Close │
├────────────────────────────────────────────────────────────────────────────┤
│ ▸ Editor │ ╔══════════════════════════════════════════╗│
│ Appearance │ ║ Line Numbers [✓] ●║│
│ Behavior │ ║ Show line numbers in the gutter ║│
│ Performance │ ╠══════════════════════════════════════════╣│
│ ▸ File Explorer │ ║ Relative Line Numbers [ ] ║│
│ ▸ Terminal │ ║ Show line numbers relative to cursor ║│
│ ▸ LSP / Language Servers │ ╠══════════════════════════════════════════╣│
│ ▸ Languages │ ║ Tab Size [4 ] ║│
│ ▸ Keybindings │ ║ Number of spaces per tab character ║│
│ ▸ Theme │ ╠══════════════════════════════════════════╣│
│ ▸ Updates │ ║ Line Wrap [✓] ║│
│ │ ║ Wrap long lines to fit window width ║│
│ │ ╠══════════════════════════════════════════╣│
│ │ ║ Scroll Offset [3 ] ║│
│ │ ║ Minimum lines above/below cursor ║│
│ │ ╚══════════════════════════════════════════╝│
├────────────────────────────────────────────────────────────────────────────┤
│ Tab:Edit ↑↓:Navigate Enter:Toggle/Expand /:Search Ctrl+S:Save Esc:Exit│
└────────────────────────────────────────────────────────────────────────────┘
Key elements:
├── Editor
│ ├── Appearance
│ │ ├── Line Numbers
│ │ ├── Relative Line Numbers
│ │ └── Syntax Highlighting
│ ├── Behavior
│ │ ├── Tab Size
│ │ ├── Auto Indent
│ │ └── Line Wrap
│ ├── Mouse
│ │ ├── Mouse Hover Enabled
│ │ ├── Mouse Hover Delay
│ │ └── Double Click Time
│ └── Performance
│ ├── Highlight Timeout
│ ├── Large File Threshold
│ └── Highlight Context Bytes
│
├── File Explorer
│ ├── Show Hidden Files
│ ├── Show Gitignored Files
│ ├── Respect Gitignore
│ ├── Width
│ └── Custom Ignore Patterns
│
├── Terminal
│ └── Jump to End on Output
│
├── LSP / Language Servers
│ ├── [rust]
│ │ ├── Command
│ │ ├── Args
│ │ ├── Enabled
│ │ └── Auto Start
│ ├── [python]
│ │ └── ...
│ └── [Add Server...]
│
├── Languages
│ ├── [rust]
│ │ ├── Extensions
│ │ ├── Grammar
│ │ ├── Comment Prefix
│ │ ├── Highlighter
│ │ └── TextMate Grammar
│ └── [Add Language...]
│
├── Keybindings
│ ├── Active Map (dropdown: default/emacs/vscode)
│ ├── [View Current Bindings...]
│ └── [Edit keybindings JSON...]
│
├── Theme
│ └── Theme Name (dropdown with preview)
│
├── Recovery
│ ├── Recovery Enabled
│ └── Auto Save Interval
│
└── Updates
└── Check for Updates
┌──────────────────────────────────────────────────────────────┐
│ Line Numbers [✓] ●│
│ Show line numbers in the gutter │
│ │
│ Default: On │
└──────────────────────────────────────────────────────────────┘
[✓] = enabled, [ ] = disabledSpace or Enter to toggle● indicates modified from default┌──────────────────────────────────────────────────────────────┐
│ Tab Size [4 ] │
│ Number of spaces per tab character │
│ │
│ Default: 4 | Valid range: 1-16 │
└──────────────────────────────────────────────────────────────┘
Enter to edit, type number, Enter to confirm┌──────────────────────────────────────────────────────────────┐
│ Active Keybinding Map [▼ default ] │
│ Choose your preferred keybinding style │
│ ┌─────────────────────────────────────┐ │
│ │ ● default │ │
│ │ emacs │ │
│ │ vscode │ │
│ │ custom-vim (user defined) │ │
│ └─────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Enter to open dropdownEnter to select┌──────────────────────────────────────────────────────────────┐
│ Theme [▼ high-contrast ] │
│ Color theme for the editor │
│ ┌─────────────────────────────────────┐ ┌──────────────────┐ │
│ │ ● high-contrast │ │ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ │ │
│ │ monokai │ │ █ fn main() { █ │ │
│ │ solarized-dark │ │ █ println!(); █ │ │
│ │ solarized-light │ │ █ } █ │ │
│ │ dracula │ │ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ │
│ └─────────────────────────────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────���───────────────────────────────────────┐
│ Custom Ignore Patterns │
│ Patterns to ignore in file explorer (in addition to gitignore)
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ *.log │ │
│ │ node_modules/ │ │
│ │ target/ │ │
│ │ [+ Add pattern...] │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Enter on item to edit, Delete to remove┌──────────────────────────────────────────────────────────────┐
│ ▾ rust [Default]│
├──────────────────────────────────────────────────────────────┤
│ Command [rust-analyzer ] │
│ Args [--log-file, /tmp/ra.log ] │
│ Enabled [✓] │
│ Auto Start [ ] │
│ │
│ [Test Connection] [Reset to Default] │
└──────────────────────────────────────────────────────────────┘
When user presses / to search:
┌─ Settings ─────────────────────────────────────────────────────────────────┐
│ [/] line numb█ [?] Help [Esc] Close │
├────────────────────────────────────────────────────────────────────────────┤
│ │ Search Results (2 matches) │
│ ▸ Editor │ ╔══════════════════════════════════════════╗│
│ Appearance ←────────────── │ ║ Line Numbers [✓] ●║│
│ Behavior │ ║ Editor > Appearance ║│
│ Performance │ ║ Show line numbers in the gutter ║│
│ ▸ File Explorer │ ╠══════════════════════════════════════════╣│
│ ▸ Terminal │ ║ Relative Line Numbers [ ] ║│
│ ... │ ║ Editor > Appearance ║│
│ │ ║ Show line numbers relative to cursor ║│
│ │ ╚══════════════════════════════════════════╝│
│ │ │
│ │ [↑↓ Navigate results] [Enter: Jump to] │
├────────────────────────────────────────────────────────────────────────────┤
│ Esc:Clear search Enter:Go to result Tab:Next result │
└────────────────────────────────────────────────────────────────────────────┘
Features:
Enter to jump to settingEsc clears search and returns to browse modeVisual indicators for changes:
┌──────────────────────────────────────────────────────────────┐
│ Line Numbers [✓] ●│ ← Modified
│ Relative Line Numbers [ ] │ ← Default
│ Tab Size [2 ]●│ ← Modified
└──────────────────────────────────────────────────────────────┘
The header shows pending changes:
┌─ Settings ──────────────────────────────────────────── 3 unsaved changes ──┐
At the bottom of the settings panel, contextual actions:
┌──────────────────────────────────────────────────────────────┐
│ │
│ [Reset to Default] [Revert All Changes] [Save] │
│ │
└──────────────────────────────────────────────────────────────┘
| Key | Action |
|---|---|
↑ ↓ | Navigate settings list |
← → | Switch between category tree and settings panel (or increment/decrement values) |
Enter | Toggle bool, open dropdown, edit value, or activate footer button |
Space | Toggle boolean (alternative) |
Tab | Cycle between panels: categories → settings → footer buttons |
Shift+Tab | Cycle panels in reverse |
/ | Focus search field |
Esc | Clear search / close dropdown / exit settings |
? | Show help overlay |
| Key | Action |
|---|---|
Enter | Expand/collapse section |
→ | Expand and enter section |
← | Collapse or go to parent |
For arrays and objects (like keybindings), switch to a JSON editor:
┌─ Editing: keybindings ─────────────────────────────────────────────────────┐
│ │
│ 1│ [ │
│ 2│ { │
│ 3│ "key": "s", │
│ 4│ "modifiers": ["ctrl"], │
│ 5│ "action": "save" │
│ 6│ }, │
│ 7│ { │
│ 8│ "key": "q", │
│ 9│ "modifiers": ["ctrl"], │
│ 10│ "action": "quit" │
│ 11│ } │
│ 12│ ] │
│ │
├────────────────────────────────────────────────────────────────────────────┤
│ Ctrl+Enter:Save and close Esc:Cancel (JSON syntax highlighted) │
└────────────────────────────────────────────────────────────────────────────┘
Press ? to show help:
┌─ Settings Help ────────────────────────────────────────────────────────────┐
│ │
│ NAVIGATION EDITING │
│ ────────── ─────── │
│ ↑/↓ Navigate settings Enter Toggle/edit value │
│ ←/→ Switch panels Space Toggle checkbox │
│ Tab Next field Backsp Clear field │
│ / Search settings Ctrl+Z Undo change │
│ │
│ ACTIONS FILES │
│ ─────── ───── │
│ Ctrl+S Save changes Config: ~/.config/fresh/config.json │
│ Esc Close / Cancel │
│ ? Toggle this help │
│ │
│ Settings are saved to config.json. The JSON file can be edited directly │
│ for advanced configuration. Some settings require restart to take effect. │
│ │
│ [Press any key to close] │
└────────────────────────────────────────────────────────────────────────────┘
┌─ Unsaved Changes ───────────────────────────────────────────┐
│ │
│ You have 3 unsaved changes: │
│ │
│ • editor.tab_size: 4 → 2 │
│ • editor.line_numbers: true → false │
│ • theme: "high-contrast" → "monokai" │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [Save and Exit] [Discard] [Cancel] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
┌─ Reset to Default ──────────────────────────────────────────┐
│ │
│ Reset "tab_size" to its default value? │
│ │
│ Current: 2 │
│ Default: 4 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [Reset] [Cancel] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Special UI for adding/configuring LSP servers:
┌─ Add Language Server ───────────────────────────────────────────────────────┐
│ │
│ Language: [go ] │
│ │
│ Command: [gopls ] │
│ Arguments: [ ] │
│ │
│ ┌─ Common Servers ─────────────────────────────────────────────────────┐ │
│ │ rust → rust-analyzer │ │
│ │ python → pylsp │ │
│ │ typescript → typescript-language-server --stdio │ │
│ │ go → gopls │ │
│ │ c/cpp → clangd │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ [Add Server] [Cancel] │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
For narrow terminals (< 80 columns), use stacked layout:
┌─ Settings ─────────────────────────────────────────┐
│ [/] Search... [?] [Esc] │
├────────────────────────────────────────────────────┤
│ ◀ Editor > Appearance │
├────────────────────────────────────────────────────┤
│ Line Numbers [✓] ● │
│ Show line numbers in the gutter │
├────────────────────────────────────────────────────┤
│ Relative Line Numbers [ ] │
│ Show relative line numbers │
├────────────────────────────────────────────────────┤
│ Tab Size [4 ] │
│ Spaces per tab │
├────────────────────────────────────────────────────┤
│ ↑↓:Nav Enter:Edit ◀:Back Ctrl+S:Save │
└────────────────────────────────────────────────────┘
← / Backspace returns to category listFresh uses a consistent architecture across all UI components:
render() methods (e.g., FileBrowserRenderer, MenuRenderer, SplitRenderer)FileOpenState vs FileBrowserRenderer)FileBrowserLayout)KeyContext enum determines which component handles inputFresh already has these reusable building blocks:
| Component | Location | Purpose |
|---|---|---|
ScrollbarState / render_scrollbar() | view/ui/scrollbar.rs | Scrollbar with state calculation and hit testing |
PopupManager / Popup | view/popup.rs | Stack-based popup system with positioning |
PopupListItem | view/popup.rs | List items with icon, text, detail |
parse_markdown() | view/popup.rs | Markdown → styled lines for terminal |
The settings editor will follow Fresh's established patterns while introducing reusable form controls.
src/
├── bin/
│ └── generate_schema.rs # Schema generation binary (uses schemars)
│
└── view/
├── settings/
│ ├── mod.rs # Public exports, SettingsView coordinator
│ ├── schema.rs # Load and parse JSON Schema, build SettingsTree
│ ├── state.rs # SettingsState, focus, change tracking
│ ├── render.rs # SettingsRenderer (static render method)
│ ├── layout.rs # SettingsLayout for hit testing
│ └── search.rs # Fuzzy search over settings (titles + descriptions)
│
└── controls/ # NEW: Reusable form controls
├── mod.rs # Control enum and common traits
├── toggle.rs # Boolean checkbox control
├── number_input.rs # Numeric input with validation
├── dropdown.rs # Enum/list selector
├── text_input.rs # Single-line text field
├── text_list.rs # List of strings (add/remove)
└── button.rs # Clickable button
Add KeyContext::Settings to the input routing system, with priority between Prompt and Normal.
The controls/ module provides reusable form primitives following the scrollbar pattern:
ControlState structs - Hold mutable state for each control type:
ToggleState: value, focusedNumberInputState: value, editing, cursor_pos, validation_errorDropdownState: selected_index, options, expanded, scroll_offsetTextInputState: value, cursor_pos, selectionTextListState: items, selected_index, editing_indexButtonState: focused, pressedRender functions - Static functions that render controls:
frame, area, state, theme parametersrender_scrollbar() patternControlLayout structs - Enable mouse interaction:
These controls can be reused across Fresh:
| Control | Settings Editor | Other Uses |
|---|---|---|
| Toggle | Boolean settings | View menu checkboxes, confirmation dialogs |
| NumberInput | Numeric settings | Goto line dialog, width/height inputs |
| Dropdown | Enum settings | Theme selector, keybinding map selector |
| TextInput | String settings | Search field, rename dialog |
| TextList | Array settings | Custom ignore patterns |
| Button | Action buttons | Confirmation dialogs, wizard navigation |
original_config: Snapshot of config at open timeworking_config: Current config with pending changespending_changes: Map of setting paths to their changed values (for modified indicators)selected_category: Current category/subcategory pathselected_setting: Index within current categoryfocus: Which panel has focus (Tree, Settings, Search, Control)control_states: Map of setting paths to their control statessearch_query: Current search textsearch_results: Filtered settings matching queryscroll_offsets: Per-category scroll positionsworking_config field values against original_configpending_changes for the ● modified indicatorworking_config to disk, update original_configoriginal_configUse JSON Schema as the standard, well-understood format for describing config structure. The schema is already generated from Rust types via #[derive(JsonSchema)], making it the obvious choice.
Zed's settings UI uses a field accessor pattern (source):
SettingField<T> with function pointers for pick/write operationsTypeId to control renderersSettingItem definitions with title, description, field accessorsKey insight: Zed initially tried a macro-based approach where settings were annotated with UI metadata, but abandoned it because it "glued UI concerns into non-UI crates."
We already have #[derive(JsonSchema)] on Config types. Rather than building custom accessor infrastructure, use the schema directly:
Why JSON Schema is better for Fresh:
schemars derive, just need proper generationSettingField<T>, no field! macro, no registrySchema → UI Mapping:
| JSON Schema | Control Type |
|---|---|
type: "boolean" | Toggle |
type: "integer" | NumberInput |
type: "number" | NumberInput |
type: "string" + enum | Dropdown |
type: "string" | TextInput |
type: "object" | Section (recurse into properties) |
type: "array" | TextList |
$ref | Resolve and recurse |
Information from schema:
| UI Need | Schema Source |
|---|---|
| Label | Property name → Title Case |
| Description | description field |
| Default value | default field |
| Control type | type field |
| Enum options | enum array |
| Categories | Object nesting / $ref structure |
Use serde_json's pointer API for dynamic access:
// Read
let value = serde_json::to_value(&config)?;
let tab_size = value.pointer("/editor/tab_size");
// Write
let mut value = serde_json::to_value(&config)?;
*value.pointer_mut("/editor/tab_size").unwrap() = json!(2);
let config: Config = serde_json::from_value(value)?;
This trades compile-time field access safety for simplicity. Errors are caught at:
Derived automatically from schema's object nesting:
{
"$defs": {
"Config": {
"properties": {
"theme": { "type": "string", "description": "Color theme..." },
"editor": { "$ref": "#/$defs/EditorConfig" }
}
},
"EditorConfig": {
"properties": {
"tab_size": { "type": "integer", "description": "Spaces per tab..." },
"line_numbers": { "type": "boolean", "description": "Show line numbers..." }
}
}
}
}
UI walks this structure to build:
$ref to object types → Collapsible sectionsAdd to the Action enum:
OpenSettings - Open settings modalSettingsClose - Close settings (with unsaved check)SettingsSave - Save and stay openSettingsSearch - Focus search fieldSettingsNavigate(Direction) - Move between settingsSettingsToggle - Toggle current boolean / expand dropdownSettingsEdit - Enter edit mode for current controlSettingsReset - Reset current setting to defaultRegister these in the default keymap for KeyContext::Settings:
Escape → CloseSettings/ → SettingsSearchUp/Down → Navigate settingsLeft/Right → Increment/decrement values, or navigate footer buttonsEnter → Toggle/activate current controlTab → Cycle focus between panels (categories → settings → footer)? → Show help overlayAdd settings rendering in Editor.render() after popups but before menu:
settings_state is SomeSettingsRenderer::render() with full-screen areaThe renderer calculates layout based on terminal width:
Within the settings modal:
Following the FileBrowserLayout pattern:
SettingsLayout tracks hit regions:
tree_area: Category tree panelsettings_area: Settings list panelsearch_area: Search fieldper_setting_areas: Vec of (Rect, SettingPath) for each visible settingcontrol_layouts: Map of active control layoutsHoverTarget variants (add to existing enum):
SettingsCategory(CategoryPath)SettingRow(SettingPath)SettingsControl(SettingPath, ControlRegion)SettingsScrollbarThe plugins/config_editor.ts plugin has been fully removed. The built-in Settings UI is now the only way to edit configuration (besides directly editing config.json).
OpenSettings action to command paletteCtrl+, keybinding to open Settingsconfig_editor.ts plugin and its e2e testss and Ctrl+S keybindings (previously used by plugin)| Phase | Status | Notes |
|---|---|---|
| Phase 1: Controls Module | ✅ DONE | All controls implemented with tests |
| Phase 2: Schema Generation | ✅ DONE | 5-line binary replaces 620-line build.rs |
| Phase 3: Settings UI | ✅ DONE | Basic modal with navigation working |
| Phase 4: Search & Polish | ✅ DONE | Help overlay, confirmation dialog, search UI all working |
| Phase 5: Migration | ✅ DONE | Plugin removed, Settings UI is now the only config editor |
The settings panel now includes:
Description display: Each setting shows its description below the control in a subdued color. Descriptions are truncated with "..." if they exceed the available width.
Smart highlighting: Selection and hover highlights only cover content rows (control + description), not the empty spacing row between items. This provides cleaner visual feedback.
Horizontal padding: Settings panel content has 2-character padding from the vertical separator for better visual separation.
Column alignment: All single-row controls (Toggle, Number, Dropdown, Text) align their labels in columns using a calculated maximum label width.
The following issues make the Settings UI difficult or impossible to use for common tasks:
Complex settings not editable - Keybindings and LSP settings cannot be added/edited through the UI. Users must edit config.toml directly for these.
[+] Add new buttons non-functional - ✅ Fixed: Map controls now show text input when pressing Enter on "[+] Add new".
Search is broken - ✅ Fixed: Search field now displays typed text correctly.
No selection indicators - ✅ Fixed: Selection highlighting now shows for both categories and settings.
Empty Unsaved Changes dialog - ✅ Verified working: Dialog correctly displays pending changes.
| Bug | Severity | Status | Description |
|---|---|---|---|
| Keybindings not editable | High | Open | Shows <Complex - edit in config.toml>. Need table UI for key/modifiers/action entries. |
| Menus not editable | High | Open | Shows <Complex - edit in config.toml>. Need hierarchical tree UI for menu structure. |
| Map entry values not editable | High | Open | Map entries show {N fields} but expanding/editing individual fields not implemented. |
| Dropdown editing doesn't work | High | ✅ Fixed | Enter/arrows on dropdown now work correctly. |
| Number input editing not implemented | High | ✅ Fixed | Enter to edit, type value, Enter to confirm. Left/Right also increment/decrement. |
| No settings item selection indicator | Medium | ✅ Fixed | Selection highlighting now shows for focused settings items. |
| View doesn't scroll to selection | Medium | Open | After search jump, view doesn't scroll to show the selected item. |
| Search text input broken | High | ✅ Fixed | Search field now displays typed text correctly. |
| Confirmation dialog empty | Medium | ✅ Fixed | Dialog height calculation was off by 1, causing changes to overlap with separator. |
| No button selection indicator | Medium | ✅ Fixed | Added > indicator and bold styling for selected button. |
| No panel focus indicator | Low | Open | Can't visually tell if categories or settings panel has focus. Footer panel shows focus (>) but categories/settings don't. |
| Terminal captures input when Settings opens | High | ✅ Fixed | Added Settings to popup/prompt check in input routing. Also added OpenSettings to terminal UI actions. |
| Footer buttons inaccessible via keyboard | High | ✅ Fixed | Added FocusPanel enum with Categories/Settings/Footer states. Tab now cycles through all three panels. Footer buttons now navigable with Left/Right arrows. |
| Global shortcuts leak through Settings | High | ✅ Fixed | Ctrl+P (palette), Ctrl+Q (quit), etc. were not consumed when Settings was open. Fixed by consuming all unhandled keys in Settings context. |
| Search results unrelated to query | Medium | Open | Searching "font" returns 14 results with no "font" matches. Fuzzy matching too aggressive or broken. |
| "●" indicator unexplained | Low | Open | Some categories show ● with no explanation. Users don't know if it means unsaved changes, errors, etc. |
| Left/Right for +/- undiscoverable | Low | Open | Arrow keys increment/decrement number fields but help text only shows "↑↓:Navigate". |
| Cancel in Unsaved Changes dialog closes everything | Medium | Open | "Cancel" should return to Settings, but instead closes the entire dialog like Discard. |
| [+] Add new buttons don't respond | High | ✅ Fixed | Map controls now show text input field when pressing Enter on "[+] Add new". Type key name, press Enter to add entry. |
| Empty Unsaved Changes dialog persists | Medium | ✅ Fixed | Dialog now correctly displays pending changes (e.g., "• /check_for_updates: true"). |
| Dropdown options have no selection indicator | Low | Open | When dropdown is open, no visible highlight shows which option is selected (only preview updates). |
| Escape doesn't close Settings directly | Low | Open | Help text says "Esc:Close" but Escape only triggers unsaved changes flow, doesn't close directly. |
| Theme not applied after save | High | ✅ Fixed | save_settings() now updates runtime state (theme, keybindings) after saving config. |
| Number input appends instead of replaces | Medium | Open | When pressing Enter to edit a number field, typing appends to existing value instead of replacing. User must manually clear with Backspace first. |
| Category selection indicator missing | Medium | ✅ Fixed | Selection highlighting now shows for focused category in the left panel. |
| Language entries not expandable | High | Open | Individual language configurations (extensions, grammar, comment prefix, etc.) cannot be viewed or edited. Only the enabled checkbox is accessible. |
| No LSP settings section visible | Medium | Open | LSP/Language Server settings mentioned in design doc are not visible. No way to configure LSP servers from Settings UI. |
Create reusable form controls that can be used independently of settings.
New files:
src/view/controls/mod.rs - Control types enum and common rendering utilitiessrc/view/controls/toggle.rs - ToggleState, render_toggle(), ToggleLayoutsrc/view/controls/number_input.rs - NumberInputState, render_number_input()src/view/controls/dropdown.rs - DropdownState, render_dropdown()src/view/controls/text_input.rs - TextInputState, render_text_input()src/view/controls/button.rs - ButtonState, render_button()Design principles:
The current build.rs uses ~600 lines of custom Rust parsing to extract config structure. This is fragile and duplicates what schemars already provides. Replace with a proper approach.
build.rs (fragile):
├── Custom regex-like parsing of Rust source
├── Manually extracts structs, fields, doc comments
├── Reimplements serde attribute parsing
├── Breaks on edge cases
└── Hard to maintain
Add a simple binary that uses schemars properly:
src/bin/
└── generate_schema.rs # Uses schemars::schema_for!(Config)
Schema generation is trivial (5 lines):
fn main() {
let schema = schemars::schema_for!(Config);
println!("{}", serde_json::to_string_pretty(&schema).unwrap());
}
Usage:
cargo run --features dev-bins --bin generate_schema > crates/fresh-editor/plugins/config-schema.json
CI verification (in .github/workflows/ci.yml):
- name: Generate schema
run: cargo run --features dev-bins --bin generate_schema > /tmp/config-schema.json
- name: Check schema is up-to-date
run: diff -u crates/fresh-editor/plugins/config-schema.json /tmp/config-schema.json
Build the settings UI that reads from JSON Schema.
New files:
src/view/settings/schema.rs - Parse schema, build category treesrc/view/settings/items.rs - SettingItem, SettingsPage from schemasrc/view/settings/state.rs - SettingsState, change trackingsrc/view/settings/render.rs - SettingsRendererImplementation approach:
$defs and properties to build UI structuretype: "boolean" → Toggletype: "integer" / type: "number" → NumberInputtype: "string" + enum → Dropdowntype: "string" → TextInputtype: "object" → Section (recurse)type: "array" → TextListdescription field for setting descriptionsdefault field for reset functionality/editor/tab_size)Read/write via serde_json:
// Read current value
let config_value = serde_json::to_value(&config)?;
let tab_size = config_value.pointer("/editor/tab_size");
// Write new value
let mut config_value = serde_json::to_value(&config)?;
*config_value.pointer_mut("/editor/tab_size").unwrap() = new_val;
let config: Config = serde_json::from_value(config_value)?;
Additional files:
src/view/settings/layout.rs - SettingsLayout for hit testingModifications:
src/app/mod.rs - Add settings_state: Option<SettingsState> to Editorsrc/app/render.rs - Render settings modal when activesrc/input/keybindings.rs - Add KeyContext::Settings and related actionssrc/app/input.rs - Handle settings actionsAdd search functionality and UX polish.
New files:
src/view/settings/search.rs - Fuzzy search over settingsFeatures:
Replace the plugin-based config editor.
Completed steps:
OpenSettings actionCtrl+,config_editor.ts plugin entirelys and Ctrl+S keybindings from default keymapThe settings editor is too complex for the popup system:
Instead, settings is a modal view like the file picker, but with richer UI.
Form controls are generally useful beyond settings:
Extracting controls enables gradual adoption without modifying existing code.
Alternatives considered:
Derive macro on Config types: #[derive(SettingsUI)] generates metadata
Visitor pattern: Config types implement trait to describe themselves
Field accessor pattern (Zed's approach):
SettingField<T>, registry, etc.)JSON Schema (chosen):
#[derive(JsonSchema)]The key insight: We already have the schema. The current problem is how we generate it (fragile build.rs parsing), not whether to use it. Fix the generation, keep the schema.
The settings panel scrolling logic should be extracted into a reusable ScrollablePanel component. This follows patterns from established UI frameworks.
| Framework | Component | Key Patterns |
|---|---|---|
| Flutter | ListView / Sliver | Items report size, Scrollable.ensureVisible() |
| WPF/WinUI | ScrollViewer | BringIntoView(), pixel-based scrolling |
| Qt | QAbstractScrollArea | Viewport + scroll position, ensureWidgetVisible() |
| Web/React | react-window | Virtual scrolling, dynamic measurement |
/// Pure scroll state - knows nothing about content
pub struct ScrollState {
/// Scroll offset in rows (not items)
pub offset: u16,
/// Viewport height
pub viewport: u16,
/// Total content height
pub content_height: u16,
}
impl ScrollState {
/// Create new scroll state
pub fn new(viewport: u16) -> Self;
/// Update content height (call when items change)
pub fn set_content_height(&mut self, height: u16);
/// Scroll to ensure a region is visible
/// If region is taller than viewport, shows the top
pub fn ensure_visible(&mut self, y: u16, height: u16) {
if y < self.offset {
// Region is above viewport - scroll up
self.offset = y;
} else if y + height > self.offset + self.viewport {
// Region is below viewport - scroll down
if height > self.viewport {
// Oversized item - show top
self.offset = y;
} else {
self.offset = y + height - self.viewport;
}
}
}
/// Scroll by delta rows (positive = down, negative = up)
pub fn scroll_by(&mut self, delta: i16);
/// Scroll to a ratio (0.0 = top, 1.0 = bottom)
pub fn scroll_to_ratio(&mut self, ratio: f32);
/// Get scrollbar thumb ratio (size relative to track)
pub fn thumb_ratio(&self) -> f32 {
self.viewport as f32 / self.content_height as f32
}
/// Get scrollbar thumb position (0.0 to 1.0)
pub fn thumb_position(&self) -> f32 {
self.offset as f32 / (self.content_height - self.viewport) as f32
}
/// Check if scrolling is needed
pub fn needs_scrollbar(&self) -> bool {
self.content_height > self.viewport
}
}
/// Trait for items that can be displayed in a scrollable panel
pub trait ScrollItem {
/// Total height of this item in terminal rows
fn height(&self) -> u16;
/// Optional: sub-focus regions within this item
/// Used for items with internal navigation (e.g., TextList rows)
fn focus_regions(&self) -> &[FocusRegion] {
&[]
}
}
/// A focusable region within an item
pub struct FocusRegion {
/// Identifier for this region
pub id: usize,
/// Y offset within the parent item
pub y_offset: u16,
/// Height of this region
pub height: u16,
}
/// Manages scrolling for a list of items
pub struct ScrollablePanel {
scroll: ScrollState,
}
impl ScrollablePanel {
pub fn new(viewport_height: u16) -> Self;
/// Update scroll state for new viewport size
pub fn set_viewport(&mut self, height: u16);
/// Calculate total content height from items
pub fn update_content_height<I: ScrollItem>(&mut self, items: &[I]) {
let height: u16 = items.iter().map(|i| i.height()).sum();
self.scroll.set_content_height(height);
}
/// Ensure focused item (and optional sub-region) is visible
pub fn ensure_focused_visible<I: ScrollItem>(
&mut self,
items: &[I],
focused_index: usize,
sub_focus: Option<usize>,
) {
// Calculate Y offset of focused item
let item_y: u16 = items[..focused_index].iter().map(|i| i.height()).sum();
let item = &items[focused_index];
// If sub-focus specified, use that region
let (focus_y, focus_h) = if let Some(sub_id) = sub_focus {
if let Some(region) = item.focus_regions().iter().find(|r| r.id == sub_id) {
(item_y + region.y_offset, region.height)
} else {
(item_y, item.height())
}
} else {
(item_y, item.height())
};
self.scroll.ensure_visible(focus_y, focus_h);
}
/// Render visible items and scrollbar
/// Returns layout info for hit testing
pub fn render<I, F, L>(
&self,
frame: &mut Frame,
area: Rect,
items: &[I],
render_item: F,
theme: &Theme,
) -> ScrollablePanelLayout<L>
where
I: ScrollItem,
F: Fn(&mut Frame, Rect, &I, usize) -> L,
{
let scrollbar_width = if self.scroll.needs_scrollbar() { 1 } else { 0 };
let content_area = Rect::new(
area.x,
area.y,
area.width.saturating_sub(scrollbar_width),
area.height,
);
let mut layouts = Vec::new();
let mut current_y = 0u16;
let mut render_y = area.y;
for (idx, item) in items.iter().enumerate() {
let item_h = item.height();
// Skip items before scroll offset
if current_y + item_h <= self.scroll.offset {
current_y += item_h;
continue;
}
// Stop if past viewport
if render_y >= area.y + area.height {
break;
}
// Calculate visible portion of item
let skip_top = self.scroll.offset.saturating_sub(current_y);
let available_h = (area.y + area.height).saturating_sub(render_y);
let visible_h = (item_h - skip_top).min(available_h);
let item_area = Rect::new(content_area.x, render_y, content_area.width, visible_h);
let layout = render_item(frame, item_area, item, idx);
layouts.push((idx, layout));
render_y += visible_h;
current_y += item_h;
}
// Render scrollbar if needed
if self.scroll.needs_scrollbar() {
let scrollbar_area = Rect::new(
area.x + content_area.width,
area.y,
1,
area.height,
);
let scrollbar_state = ScrollbarState::new(
self.scroll.content_height as usize,
self.scroll.viewport as usize,
self.scroll.offset as usize,
);
render_scrollbar(frame, scrollbar_area, &scrollbar_state, &ScrollbarColors::from_theme(theme));
}
ScrollablePanelLayout {
content_area,
scrollbar_area: if self.scroll.needs_scrollbar() {
Some(Rect::new(area.x + content_area.width, area.y, 1, area.height))
} else {
None
},
item_layouts: layouts,
}
}
// Delegate scroll operations
pub fn scroll_up(&mut self, rows: u16) { self.scroll.scroll_by(-(rows as i16)); }
pub fn scroll_down(&mut self, rows: u16) { self.scroll.scroll_by(rows as i16); }
pub fn scroll_to_ratio(&mut self, ratio: f32) { self.scroll.scroll_to_ratio(ratio); }
}
/// Layout info returned by ScrollablePanel::render
pub struct ScrollablePanelLayout<L> {
/// Content area (excluding scrollbar)
pub content_area: Rect,
/// Scrollbar area (if visible)
pub scrollbar_area: Option<Rect>,
/// Per-item layouts with their indices
pub item_layouts: Vec<(usize, L)>,
}
When an item is taller than the viewport (e.g., a TextList with 20 rows):
ensure_visible() with sub-focus region// Example: TextList with 10 visible rows, user focuses row 15
panel.ensure_focused_visible(
&items,
focused_item_idx,
Some(15), // Sub-focus: row 15 within the TextList
);
// This scrolls to show row 15, even if the TextList item is only partially visible
The settings panel would use ScrollablePanel like this:
impl SettingsState {
fn render_settings_panel(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
let page = self.current_page().unwrap();
// Update content height
self.scroll_panel.update_content_height(&page.items);
// Ensure focused item visible
if let Some(selected) = self.selected_item {
self.scroll_panel.ensure_focused_visible(
&page.items,
selected,
self.sub_focus, // For TextList/Map internal focus
);
}
// Render
let layout = self.scroll_panel.render(
frame,
area,
&page.items,
|frame, area, item, idx| render_setting_item(frame, area, item, idx, theme),
theme,
);
// Store layout for hit testing
self.panel_layout = Some(layout);
}
}
src/view/ui/scroll_panel.rs with ScrollState and ScrollablePanelScrollItem traitScrollItem for SettingItemrender_settings_panel to use ScrollablePanelSettingsState for TextList/Map navigation.fresh/config.json in project root)