Config Editor Design
Built-in settings editor for Fresh, replacing the plugin-based config_editor.ts
Research Summary
Zed Editor Settings UI
- Layout: Separate window with categories on left, controls on right
- Philosophy: "Files as the organizing principle" - settings.json always available for power users
- Controls: Form-based structure with strongly-typed settings
- Navigation: Tab groups for keyboard navigability, focus management
- State: Direct UI tree state (similar to React hooks)
- Source: How We Rebuilt Settings in Zed
IntelliJ IDEA Settings
- Layout: Hierarchical tree navigation on left, settings panel on right
- Search: Prominent search field at top-left for filtering settings
- Indicators: Icon marks project-specific vs IDE-wide settings
- Actions: OK (apply + close), Apply (apply + stay open), Cancel
- Theme: New "Islands" theme (2025) with rounded corners, balanced spacing
- Source: IntelliJ Settings Documentation
VS Code Settings
- Indicators: Colored bar on left shows modified settings (like editor gutter)
- Search: Powerful Bing-powered search with extension discovery
- Controls: Type-aware inputs (checkboxes for bools, dropdowns for enums)
- Context Menu: Gear icon for reset to default, copy setting ID
- Validation: Inline validation errors
- Source: VS Code Settings Editor UI
Design Goals
- Terminal-Native: Designed for TUI, not a web GUI port
- Keyboard-First: Full navigation without mouse
- Discoverable: Users can browse and find settings without reading docs
- Efficient: Common settings are quick to change
- Safe: Changes can be previewed and reverted
- JSON Accessible: Power users can still edit config.json directly
UI Layout
The config editor opens as a full-screen modal (like the file picker), not a separate buffer.
Main Screen Structure
┌─ 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:
- Left Panel: Category tree (collapsible sections)
- Right Panel: Settings for selected category
- Status Bar: Keyboard shortcuts cheat sheet
- Modified Indicator: Dot (●) next to changed settings
Category Organization
Hierarchy
├── 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 UpdatesControl Types
Boolean Toggle
┌──────────────────────────────────────────────────────────────┐
│ Line Numbers [✓] ●│
│ Show line numbers in the gutter │
│ │
│ Default: On │
└──────────────────────────────────────────────────────────────┘[✓]= enabled,[ ]= disabled- Press
SpaceorEnterto toggle ●indicates modified from default
Number Input
┌──────────────────────────────────────────────────────────────┐
│ Tab Size [4 ] │
│ Number of spaces per tab character │
│ │
│ Default: 4 | Valid range: 1-16 │
└──────────────────────────────────────────────────────────────┘- Shows current value in editable field
- Press
Enterto edit, type number,Enterto confirm - Validation shown inline
Dropdown / Enum
┌──────────────────────────────────────────────────────────────┐
│ Active Keybinding Map [▼ default ] │
│ Choose your preferred keybinding style │
│ ┌─────────────────────────────────────┐ │
│ │ ● default │ │
│ │ emacs │ │
│ │ vscode │ │
│ │ custom-vim (user defined) │ │
│ └─────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘- Press
Enterto open dropdown - Arrow keys to navigate,
Enterto select - Shows current selection in button
Theme Selector (with Preview)
┌──────────────────────────────────────────────────────────────┐
│ Theme [▼ high-contrast ] │
│ Color theme for the editor │
│ ┌─────────────────────────────────────┐ ┌──────────────────┐ │
│ │ ● high-contrast │ │ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ │ │
│ │ monokai │ │ █ fn main() { █ │ │
│ │ solarized-dark │ │ █ println!(); █ │ │
│ │ solarized-light │ │ █ } █ │ │
│ │ dracula │ │ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ │
│ └─────────────────────────────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────┘- Live preview of theme colors in mini code sample
- Preview updates as you navigate options
String Input
┌──────────────────────────────────────────────────────────────┐
│ Custom Ignore Patterns │
│ Patterns to ignore in file explorer (in addition to gitignore)
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ *.log │ │
│ │ node_modules/ │ │
│ │ target/ │ │
│ │ [+ Add pattern...] │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘- List of strings with add/remove capability
- Press
Enteron item to edit,Deleteto remove
LSP Server Configuration
┌──────────────────────────────────────────────────────────────┐
│ ▾ rust [Default]│
├──────────────────────────────────────────────────────────────┤
│ Command [rust-analyzer ] │
│ Args [--log-file, /tmp/ra.log ] │
│ Enabled [✓] │
│ Auto Start [ ] │
│ │
│ [Test Connection] [Reset to Default] │
└──────────────────────────────────────────────────────────────┘- Collapsible sections for each language
- "Default" badge when using built-in config
- Test button to verify LSP server is reachable
Search Interface
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:
- Fuzzy matching on setting names and descriptions
- Results show breadcrumb path (Editor > Appearance)
- Left panel highlights matching categories
- Press
Enterto jump to setting Escclears search and returns to browse mode
Modified Settings Tracking
Visual indicators for changes:
┌──────────────────────────────────────────────────────────────┐
│ Line Numbers [✓] ●│ ← Modified
│ Relative Line Numbers [ ] │ ← Default
│ Tab Size [2 ]●│ ← Modified
└──────────────────────────────────────────────────────────────┘The header shows pending changes:
┌─ Settings ──────────────────────────────────────────── 3 unsaved changes ──┐Actions Bar
At the bottom of the settings panel, contextual actions:
┌──────────────────────────────────────────────────────────────┐
│ │
│ [Reset to Default] [Revert All Changes] [Save] │
│ │
└──────────────────────────────────────────────────────────────┘- Reset: Reset current setting to default
- Save: Save changes and close dialog
- Cancel: Discard changes and close dialog (shows confirmation if unsaved changes exist)
Keyboard Navigation
| 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 |
Category Tree Navigation
| Key | Action |
|---|---|
Enter | Expand/collapse section |
→ | Expand and enter section |
← | Collapse or go to parent |
Edit Mode for Complex Values
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) │
└────────────────────────────────────────────────────────────────────────────┘- Full JSON editor with syntax highlighting
- Validation before save
- Schema tooltips on hover (if mouse enabled)
Help Overlay
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] │
└────────────────────────────────────────────────────────────────────────────┘Confirmation Dialogs
Unsaved Changes on Exit
┌─ 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 Confirmation
┌─ Reset to Default ──────────────────────────────────────────┐
│ │
│ Reset "tab_size" to its default value? │
│ │
│ Current: 2 │
│ Default: 4 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [Reset] [Cancel] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘LSP Server Management
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] │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘Responsive Layout
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 │
└────────────────────────────────────────────────────┘- Category shown as breadcrumb at top
←/Backspacereturns to category list- Full-width settings panel
Architectural Integration
Current Fresh Rendering Patterns
Fresh uses a consistent architecture across all UI components:
- Renderer Pattern: Static structs with
render()methods (e.g.,FileBrowserRenderer,MenuRenderer,SplitRenderer) - State Separation: State structs are separate from renderer structs (e.g.,
FileOpenStatevsFileBrowserRenderer) - Layout Structs: Renderers return layout info for mouse hit testing (e.g.,
FileBrowserLayout) - Direct Ratatui Usage: No custom Widget trait - renders directly with ratatui primitives
- Input Context Routing:
KeyContextenum determines which component handles input
Existing Reusable Components
Fresh 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 |
Integration Strategy
The settings editor will follow Fresh's established patterns while introducing reusable form controls.
Module Structure
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 buttonNew KeyContext
Add KeyContext::Settings to the input routing system, with priority between Prompt and Normal.
Control Abstraction
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, pressed
Render functions - Static functions that render controls:
- Take
frame,area,state,themeparameters - Return layout info for hit testing
- Follow the
render_scrollbar()pattern
ControlLayout structs - Enable mouse interaction:
- Hit testing regions
- Click-to-action mapping
Reuse Opportunities
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 |
State Management Design
SettingsState Structure
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 positions
Change Detection
- Compare
working_configfield values againstoriginal_config - Track which settings have
pending_changesfor the●modified indicator - On save: write
working_configto disk, updateoriginal_config - On cancel: restore from
original_config
JSON Schema-Based Config Integration
Use 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.
Research: How Zed Does It
Zed's settings UI uses a field accessor pattern (source):
SettingField<T>with function pointers for pick/write operations- Type-based renderer registry mapping
TypeIdto control renderers - Manual
SettingItemdefinitions with title, description, field accessors
Key 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."
Our Approach: JSON Schema
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:
- Standard format: Well-known, tooling exists, self-documenting
- Already generated: We have
schemarsderive, just need proper generation - Obvious mapping: Schema types → UI controls is mechanical
- External tooling: LSP can validate config files, editors get autocomplete
- Less code: No
SettingField<T>, nofield!macro, no registry
Schema → 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 |
Read/Write via JSON Pointers
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:
- Schema validation (structure matches)
- Serde deserialization (types match)
Category Structure (from schema)
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:
- Top-level properties → Top-level settings
$refto object types → Collapsible sections- Nested properties → Settings within sections
Input Handling Integration
New Actions
Add 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 default
Keybinding Defaults
Register 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 overlay
Rendering Integration
Entry Point
Add settings rendering in Editor.render() after popups but before menu:
- Check if
settings_stateisSome - Call
SettingsRenderer::render()with full-screen area - Settings modal overlays everything except menu bar
Two-Panel Layout
The renderer calculates layout based on terminal width:
- Wide mode (≥80 cols): Side-by-side panels
- Narrow mode (<80 cols): Stacked panels with breadcrumb
Render Order
Within the settings modal:
- Clear background
- Render border and title with change count
- Render category tree (left panel)
- Render settings list (right panel)
- Render active control overlay (dropdown menu, etc.)
- Render footer with keyboard shortcuts
- Render search overlay if active
- Render help overlay if active
Mouse Support
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 layouts
HoverTarget variants (add to existing enum):
SettingsCategory(CategoryPath)SettingRow(SettingPath)SettingsControl(SettingPath, ControlRegion)SettingsScrollbar
Migration from Plugin ✅ COMPLETE
The 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).
What was done:
- ✅ Implemented native settings view in Rust
- ✅ Added
OpenSettingsaction to command palette - ✅ Added
Ctrl+,keybinding to open Settings - ✅ Added "Settings..." to Edit menu
- ✅ Removed
config_editor.tsplugin and its e2e tests - ✅ Removed
sandCtrl+Skeybindings (previously used by plugin) - ✅ Settings dialog now consumes all keyboard input (global shortcuts don't leak through)
- ✅ Theme and keybinding changes are applied immediately after saving
Advantages of Native Implementation
- Faster startup (no JavaScript evaluation)
- Direct access to Config struct and schema
- Better keyboard handling (consumes all input when modal is open)
- Consistent with other native UI components
- No TextEncoder/TextDecoder polyfill issues
- Runtime state updates (theme, keybindings) applied immediately
Implementation Plan
Status Summary
| 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 |
Current Implementation Details
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.
Known Bugs (found during testing)
Critical UX Gaps (blocking intuitive use)
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.tomldirectly 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 Table
| 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. |
Phase 1: Core Controls Module ✅
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:
- Each control is self-contained with state, render function, and layout
- Controls follow the scrollbar pattern: state struct + render function + layout struct
- Theme-aware: all colors from Theme struct
- Keyboard and mouse support in layouts
- No dependencies on settings-specific code
Phase 2: Robust Schema Generation
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.
Current Problem
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 maintainSolution: Schema Generation Binary
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 > plugins/config-schema.jsonCI 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 plugins/config-schema.json /tmp/config-schema.jsonBenefits
- Standard tooling: Uses schemars as intended, no custom parsing
- Correct by construction: Schema always matches types exactly
- Maintainable: Deleted ~620 lines from build.rs, replaced with 5-line binary
- CI verified: Schema drift is caught in CI
- No restructuring needed: Config types stay in main crate
Phase 3: Settings UI with Schema
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,SettingsPagefrom schemasrc/view/settings/state.rs- SettingsState, change trackingsrc/view/settings/render.rs- SettingsRenderer
Implementation approach:
- Load schema at startup (from embedded file or runtime generation)
- Walk schema
$defsandpropertiesto build UI structure - Map JSON Schema types to controls:
type: "boolean"→ Toggletype: "integer"/type: "number"→ NumberInputtype: "string"+enum→ Dropdowntype: "string"→ TextInputtype: "object"→ Section (recurse)type: "array"→ TextList
- Use
descriptionfield for setting descriptions - Use
defaultfield for reset functionality - Use JSON pointer paths for read/write (
/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 testing
Modifications:
src/app/mod.rs- Addsettings_state: Option<SettingsState>to Editorsrc/app/render.rs- Render settings modal when activesrc/input/keybindings.rs- AddKeyContext::Settingsand related actionssrc/app/input.rs- Handle settings actions
Phase 4: Search & Polish
Add search functionality and UX polish.
New files:
src/view/settings/search.rs- Fuzzy search over settings
Features:
- Fuzzy matching on setting names and descriptions
- Highlight matching categories in tree
- Search results panel with breadcrumbs
- Help overlay (?)
- Unsaved changes confirmation dialog
Phase 5: Migration & Cleanup ✅
Replace the plugin-based config editor.
Completed steps:
- ✅ Added command palette entry "Settings" →
OpenSettingsaction - ✅ Added "Settings..." to Edit menu
- ✅ Added keyboard shortcut
Ctrl+, - ✅ Removed
config_editor.tsplugin entirely - ✅ Removed
sandCtrl+Skeybindings from default keymap - ✅ Settings dialog consumes all keyboard input (no global shortcut leakage)
- ✅ Runtime state (theme, keybindings) updated immediately on save
Design Decisions
Why Not Extend PopupManager?
The settings editor is too complex for the popup system:
- Needs two-panel layout with independent scrolling
- Requires persistent state across focus changes
- Has multiple layers of controls (dropdown over settings panel)
- Needs full keyboard navigation with context switching
Instead, settings is a modal view like the file picker, but with richer UI.
Why Separate Controls Module?
Form controls are generally useful beyond settings:
- Goto line dialog could use NumberInput
- Rename dialog could use TextInput
- Confirmation dialogs could use Button
- Menu items with toggles could use Toggle
Extracting controls enables gradual adoption without modifying existing code.
Why JSON Schema Over Other Approaches?
Alternatives considered:
Derive macro on Config types:
#[derive(SettingsUI)]generates metadata- Con: Zed tried this and abandoned it - "glued UI concerns into non-UI crates"
- Con: Harder to control ordering, grouping, custom descriptions
Visitor pattern: Config types implement trait to describe themselves
- Con: Still couples UI metadata to data types
- Con: Requires manual trait impls or proc macro
Field accessor pattern (Zed's approach):
- Pro: Type-safe via function pointers
- Con: Requires defining every setting manually in UI code
- Con: More infrastructure to build (
SettingField<T>, registry, etc.)
JSON Schema (chosen):
- Pro: Standard format, well-understood
- Pro: Already generated via
#[derive(JsonSchema)] - Pro: Automatic - new Config fields appear in UI automatically
- Pro: External tooling (LSP validation, editor autocomplete)
- Pro: Less custom code - just walk the schema
- Con: Runtime type safety only (caught by serde on deserialize)
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.
ScrollablePanel Abstraction
The settings panel scrolling logic should be extracted into a reusable ScrollablePanel component. This follows patterns from established UI frameworks.
Inspiration from Other 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 |
Core Design
ScrollState - Pure State Management
/// 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
}
}ScrollItem Trait - Item Size Reporting
/// 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,
}ScrollablePanel - Rendering Coordinator
/// 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)>,
}Handling Oversized Items
When an item is taller than the viewport (e.g., a TextList with 20 rows):
- Show top on first focus: When navigating to an oversized item, show its top
- Sub-focus tracking: Track which internal region is focused
- Scroll within item: Use
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 visibleIntegration with Settings UI
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);
}
}Benefits
- Reusable: Same component for settings, file picker, popup lists, etc.
- Row-based scrolling: Pixel/row precision instead of item-based
- Partial visibility: Natural handling of items at viewport edges
- Sub-item focus: Built-in support for navigating within large items
- Consistent behavior: Same scrolling semantics everywhere
- Testable: Pure state management can be unit tested
Implementation Plan
- Create
src/view/ui/scroll_panel.rswithScrollStateandScrollablePanel - Add
ScrollItemtrait - Implement
ScrollItemforSettingItem - Refactor
render_settings_panelto useScrollablePanel - Add sub-focus support to
SettingsStatefor TextList/Map navigation - Consider adopting for file picker and popup lists
Future Enhancements
- Project Settings: Per-project config overrides (
.fresh/config.jsonin project root) - Settings Profiles: Named setting profiles (e.g., "Writing", "Coding") with quick switching
- Plugin Settings: Plugin-contributed settings sections via the schema
- Import/Export: Share settings snippets as JSON
- Search History: Remember recent searches
- Keyboard Shortcut Editor: Visual keybinding configuration
- Live Preview: Immediate application of non-destructive settings