ghostcfg 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: pip install build
17
+ - run: python -m build
18
+ - uses: actions/upload-artifact@v4
19
+ with:
20
+ name: dist
21
+ path: dist/
22
+
23
+ publish:
24
+ needs: build
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ id-token: write
28
+ environment: pypi
29
+ steps:
30
+ - uses: actions/download-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+ - uses: pypa/gh-action-pypi-publish@release/v1
35
+
36
+ github-release:
37
+ needs: publish
38
+ runs-on: ubuntu-latest
39
+ permissions:
40
+ contents: write
41
+ steps:
42
+ - uses: actions/checkout@v4
43
+ - uses: softprops/action-gh-release@v2
44
+ with:
45
+ generate_release_notes: true
@@ -0,0 +1,29 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+
13
+ # IDE
14
+ .idea/
15
+ .vscode/
16
+ *.swp
17
+ *.swo
18
+
19
+ # OS
20
+ .DS_Store
21
+ Thumbs.db
22
+
23
+ # Testing
24
+ .pytest_cache/
25
+ .coverage
26
+ htmlcov/
27
+
28
+ # ghostcfg backups
29
+ *.bak.*
@@ -0,0 +1,56 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What This Is
6
+
7
+ ghostcfg is a Python TUI (Textual) application for interactively editing Ghostty terminal configuration. It provides tabbed category browsing, live theme preview, type-aware input widgets, and hot-reload via SIGUSR2 signaling to a running Ghostty process.
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ pip install -e . # Install in editable mode
13
+ ghostcfg # Run the app (or: gcfg, python -m ghostcfg)
14
+ pytest # Run all tests
15
+ pytest tests/test_schema.py # Run a single test file
16
+ pytest tests/test_schema.py::test_name # Run a single test
17
+ ```
18
+
19
+ No linter or formatter is configured.
20
+
21
+ ## Architecture
22
+
23
+ **Entry point:** `src/ghostcfg/app.py:main()` → `GhostCfg(App).run()`
24
+
25
+ **Data flow:** Ghostty CLI → schema + config parsing → Textual widgets → config write → SIGUSR2 reload
26
+
27
+ ### Core Modules
28
+
29
+ - **schema.py** — Static metadata for 200+ Ghostty options: type (`string`, `boolean`, `enum`, `integer`, `float`, `color`, `path`, `duration`), default value, category, hot_reload flag, platform filter, repeatability. Options are grouped into `CATEGORIES` (Font, Colors, Cursor, Window, Background, Input, Shell, Advanced) which drive the tab layout.
30
+
31
+ - **config_io.py** — Roundtrip-safe config parser. `GhosttyConfig` stores a list of `ConfigEntry` objects (kind: `option`/`comment`/`blank`) preserving original comments, blank lines, and ordering through read→modify→write cycles. Creates `.bak` backups before first write per session.
32
+
33
+ - **ghostty.py** — Ghostty process interface. Detects config path per platform (macOS: `~/Library/Application Support/com.mitchellh.ghostty/config`, Linux: `~/.config/ghostty/config`). Calls `ghostty +list-themes --plain` and `ghostty +show-config --default --docs` for runtime data. Finds Ghostty PIDs with `pgrep -a` (needed since ghostcfg runs inside Ghostty as an ancestor process) and sends SIGUSR2 for hot-reload.
34
+
35
+ - **app.py** — Main Textual App. Composes a `TabbedContent` with a Themes tab and one tab per category. Tracks `_pending_changes` dict that accumulates until Ctrl+S save. Theme changes bypass pending—they write and reload immediately.
36
+
37
+ ### Widgets (`src/ghostcfg/widgets/`)
38
+
39
+ - **ThemeBrowser** — Searchable theme list with dark/light filter. Emits `ThemeHighlighted` (live preview on cursor move), `ThemeSelected` (Enter confirms), `ThemeReverted` (Escape restores original).
40
+
41
+ - **ConfigPanel** — Container for `OptionRow` widgets in a category, with a documentation pane that updates on focus.
42
+
43
+ - **OptionRow** — Single config option. Selects input widget by type: `Switch` for boolean, `Select` for enum, `Input` for string/number/color. Color options show a live swatch. Tracks original vs current value for modification detection.
44
+
45
+ ### Key Patterns
46
+
47
+ - **Message-based communication** — Widgets communicate via Textual `Message` subclasses (e.g., `ValueChanged`, `ThemeHighlighted`, `OptionFocused`), not direct method calls.
48
+ - **Reactive properties** — `reactive[bool]` for `has_unsaved` drives the `[modified]` subtitle indicator.
49
+ - **Theme application removes color overrides** — When a theme is applied, `THEME_COLOR_KEYS` (background, foreground, palette, etc.) are stripped from config so the theme's colors take effect.
50
+ - **Platform filtering** — Schema entries with `"platform": "macos"` or `"linux"` are filtered at runtime via `platform.system()`.
51
+
52
+ ## Dependencies
53
+
54
+ - Python ≥ 3.10
55
+ - textual ≥ 1.0.0
56
+ - Ghostty must be installed and on PATH for runtime data (themes, docs, reload)
ghostcfg-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sam K
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghostcfg
3
+ Version: 0.1.0
4
+ Summary: Interactive TUI for Ghostty terminal configuration
5
+ Project-URL: Homepage, https://github.com/samkleespies/ghostcfg
6
+ Project-URL: Repository, https://github.com/samkleespies/ghostcfg
7
+ Project-URL: Issues, https://github.com/samkleespies/ghostcfg/issues
8
+ Author-email: Sam Kleespies <samkleespies@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: config,ghostty,terminal,textual,tui
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Terminals :: Terminal Emulators/X Terminals
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: textual>=1.0.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ # ghostcfg
30
+
31
+ A TUI for editing your [Ghostty](https://ghostty.org) config without touching the file by hand.
32
+
33
+ Browse 200+ options by category, preview themes live with color swatches, and save — ghostcfg hot-reloads your running Ghostty instance automatically.
34
+
35
+ <p align="center">
36
+ <img src="assets/demo.gif" alt="ghostcfg demo" width="800">
37
+ </p>
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pipx install ghostcfg # recommended
43
+ uv tool install ghostcfg # or with uv
44
+ pip install ghostcfg # or plain pip
45
+ ```
46
+
47
+ On macOS with Homebrew:
48
+
49
+ ```bash
50
+ brew install samkleespies/ghostcfg/ghostcfg
51
+ ```
52
+
53
+ Or from source:
54
+
55
+ ```bash
56
+ git clone https://github.com/samkleespies/ghostcfg.git
57
+ cd ghostcfg
58
+ pip install -e .
59
+ ```
60
+
61
+ Requires Python 3.10+ and [Ghostty](https://ghostty.org) on PATH.
62
+
63
+ ## Usage
64
+
65
+ ```bash
66
+ ghostcfg # or: gcfg
67
+ ```
68
+
69
+ ### Keybindings
70
+
71
+ | Key | Action |
72
+ |-----|--------|
73
+ | `Tab` / `Shift+Tab` | Switch tabs |
74
+ | `Up` / `Down` | Navigate options |
75
+ | `Enter` | Edit / toggle |
76
+ | `Ctrl+S` | Save and reload Ghostty |
77
+ | `Ctrl+D` | Reset option to default |
78
+ | `?` | Help |
79
+ | `q` | Quit |
80
+
81
+ In the theme browser:
82
+
83
+ | Key | Action |
84
+ |-----|--------|
85
+ | `Up` / `Down` | Browse (previews live) |
86
+ | `Enter` | Confirm theme |
87
+ | `Escape` | Revert to original |
88
+ | `d` / `l` / `a` | Dark only / light only / all |
89
+
90
+ ## What it does
91
+
92
+ - Tabbed categories: Font, Colors, Cursor, Window, Background, Input, Shell, Advanced
93
+ - Theme browser with inline color swatch preview, search, and dark/light filtering
94
+ - Type-aware widgets: toggles for booleans, dropdowns for enums, color pickers, validated number fields
95
+ - Saves trigger SIGUSR2 so Ghostty reloads instantly
96
+ - Roundtrip-safe: your comments, blank lines, and ordering are preserved
97
+ - Platform-aware: only shows options for your OS
98
+
99
+ ghostcfg finds your config at:
100
+ - **macOS:** `~/Library/Application Support/com.mitchellh.ghostty/config`
101
+ - **Linux:** `~/.config/ghostty/config`
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,77 @@
1
+ # ghostcfg
2
+
3
+ A TUI for editing your [Ghostty](https://ghostty.org) config without touching the file by hand.
4
+
5
+ Browse 200+ options by category, preview themes live with color swatches, and save — ghostcfg hot-reloads your running Ghostty instance automatically.
6
+
7
+ <p align="center">
8
+ <img src="assets/demo.gif" alt="ghostcfg demo" width="800">
9
+ </p>
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pipx install ghostcfg # recommended
15
+ uv tool install ghostcfg # or with uv
16
+ pip install ghostcfg # or plain pip
17
+ ```
18
+
19
+ On macOS with Homebrew:
20
+
21
+ ```bash
22
+ brew install samkleespies/ghostcfg/ghostcfg
23
+ ```
24
+
25
+ Or from source:
26
+
27
+ ```bash
28
+ git clone https://github.com/samkleespies/ghostcfg.git
29
+ cd ghostcfg
30
+ pip install -e .
31
+ ```
32
+
33
+ Requires Python 3.10+ and [Ghostty](https://ghostty.org) on PATH.
34
+
35
+ ## Usage
36
+
37
+ ```bash
38
+ ghostcfg # or: gcfg
39
+ ```
40
+
41
+ ### Keybindings
42
+
43
+ | Key | Action |
44
+ |-----|--------|
45
+ | `Tab` / `Shift+Tab` | Switch tabs |
46
+ | `Up` / `Down` | Navigate options |
47
+ | `Enter` | Edit / toggle |
48
+ | `Ctrl+S` | Save and reload Ghostty |
49
+ | `Ctrl+D` | Reset option to default |
50
+ | `?` | Help |
51
+ | `q` | Quit |
52
+
53
+ In the theme browser:
54
+
55
+ | Key | Action |
56
+ |-----|--------|
57
+ | `Up` / `Down` | Browse (previews live) |
58
+ | `Enter` | Confirm theme |
59
+ | `Escape` | Revert to original |
60
+ | `d` / `l` / `a` | Dark only / light only / all |
61
+
62
+ ## What it does
63
+
64
+ - Tabbed categories: Font, Colors, Cursor, Window, Background, Input, Shell, Advanced
65
+ - Theme browser with inline color swatch preview, search, and dark/light filtering
66
+ - Type-aware widgets: toggles for booleans, dropdowns for enums, color pickers, validated number fields
67
+ - Saves trigger SIGUSR2 so Ghostty reloads instantly
68
+ - Roundtrip-safe: your comments, blank lines, and ordering are preserved
69
+ - Platform-aware: only shows options for your OS
70
+
71
+ ghostcfg finds your config at:
72
+ - **macOS:** `~/Library/Application Support/com.mitchellh.ghostty/config`
73
+ - **Linux:** `~/.config/ghostty/config`
74
+
75
+ ## License
76
+
77
+ MIT
Binary file
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ghostcfg"
7
+ version = "0.1.0"
8
+ description = "Interactive TUI for Ghostty terminal configuration"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Sam Kleespies", email = "samkleespies@gmail.com" },
14
+ ]
15
+ keywords = ["ghostty", "terminal", "config", "tui", "textual"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: MacOS",
22
+ "Operating System :: POSIX :: Linux",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Terminals :: Terminal Emulators/X Terminals",
29
+ "Topic :: Utilities",
30
+ ]
31
+ dependencies = [
32
+ "textual>=1.0.0",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/samkleespies/ghostcfg"
37
+ Repository = "https://github.com/samkleespies/ghostcfg"
38
+ Issues = "https://github.com/samkleespies/ghostcfg/issues"
39
+
40
+ [project.scripts]
41
+ ghostcfg = "ghostcfg.app:main"
42
+ gcfg = "ghostcfg.app:main"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/ghostcfg"]
@@ -0,0 +1,3 @@
1
+ """ghostcfg — Interactive TUI for Ghostty configuration."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as python -m ghostcfg."""
2
+
3
+ from ghostcfg.app import main
4
+
5
+ main()
@@ -0,0 +1,271 @@
1
+ """Main Textual App for ghostcfg."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import App, ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.reactive import reactive
10
+ from textual.widgets import Footer, Input, TabbedContent, TabPane
11
+
12
+ from ghostcfg.config_io import GhosttyConfig, backup_config, read_config, write_config
13
+ from ghostcfg.ghostty import (
14
+ get_config_path,
15
+ get_config_with_docs,
16
+ list_themes,
17
+ reload_config,
18
+ )
19
+ from ghostcfg.schema import CATEGORIES
20
+ from ghostcfg.screens.color_picker import ColorPickerScreen
21
+ from ghostcfg.screens.help_screen import HelpScreen
22
+ from ghostcfg.widgets.config_panel import ConfigPanel
23
+ from ghostcfg.widgets.option_row import OptionRow
24
+ from ghostcfg.widgets.theme_browser import ThemeBrowser
25
+
26
+ # Color keys that themes control — remove these when applying a theme
27
+ # so the theme's colors take effect instead of being overridden.
28
+ THEME_COLOR_KEYS = {
29
+ "background",
30
+ "foreground",
31
+ "bold-color",
32
+ "cursor-color",
33
+ "cursor-text",
34
+ "selection-foreground",
35
+ "selection-background",
36
+ "split-divider-color",
37
+ "unfocused-split-fill",
38
+ "window-titlebar-background",
39
+ "window-titlebar-foreground",
40
+ }
41
+
42
+
43
+ class GhostCfg(App):
44
+ """Interactive TUI for Ghostty configuration."""
45
+
46
+ TITLE = "ghostcfg"
47
+ CSS_PATH = "app.tcss"
48
+
49
+ BINDINGS = [
50
+ Binding("ctrl+s", "save", "Save", show=True, priority=True),
51
+ Binding("question_mark", "help", "Help", show=True),
52
+ Binding("q", "quit_app", "Quit", show=True),
53
+ Binding("ctrl+d", "reset_default", "Default", show=True),
54
+ ]
55
+
56
+ has_unsaved: reactive[bool] = reactive(False)
57
+
58
+ def __init__(self) -> None:
59
+ super().__init__()
60
+ self._config_path: Path = get_config_path()
61
+ self._config: GhosttyConfig = GhosttyConfig()
62
+ self._themes: list[str] = []
63
+ self._docs: dict[str, str] = {}
64
+ self._config_values: dict[str, str] = {}
65
+ self._original_theme: str = ""
66
+ self._theme_ready = False
67
+
68
+ def compose(self) -> ComposeResult:
69
+ self._load_data()
70
+ with TabbedContent(id="tabs"):
71
+ with TabPane("Themes", id="tab-themes"):
72
+ # Pass empty list; ThemeBrowser is populated in on_mount
73
+ # to avoid reentrancy in its reactive watchers.
74
+ yield ThemeBrowser([], id="theme-browser")
75
+ for category in CATEGORIES:
76
+ tab_id = f"tab-{category.lower()}"
77
+ with TabPane(category, id=tab_id):
78
+ yield ConfigPanel(
79
+ category=category,
80
+ config_values=self._config_values,
81
+ docs=self._docs,
82
+ id=f"panel-{category.lower()}",
83
+ )
84
+ yield Footer()
85
+
86
+ def on_mount(self) -> None:
87
+ try:
88
+ browser = self.query_one("#theme-browser", ThemeBrowser)
89
+ browser.all_themes = self._themes
90
+ browser.set_original_theme(self._original_theme)
91
+ browser._refresh_list()
92
+ except Exception:
93
+ pass
94
+ self.call_after_refresh(self._enable_theme_preview)
95
+
96
+ def _enable_theme_preview(self) -> None:
97
+ """Allow theme live-preview after initial UI settlement."""
98
+ self._theme_ready = True
99
+
100
+ def _load_data(self) -> None:
101
+ """Load themes, config, and docs from Ghostty."""
102
+ self._themes = list_themes()
103
+ self._config = read_config(self._config_path)
104
+ cli_data = get_config_with_docs()
105
+
106
+ for name, data in cli_data.items():
107
+ self._docs[name] = data.get("doc", "")
108
+ file_val = self._config.get(name)
109
+ if file_val is not None:
110
+ self._config_values[name] = file_val
111
+ else:
112
+ val = data.get("value", "")
113
+ if isinstance(val, list):
114
+ self._config_values[name] = ", ".join(val)
115
+ else:
116
+ self._config_values[name] = val
117
+
118
+ self._original_theme = self._config_values.get("theme", "")
119
+
120
+ # ── Theme browser handlers ────────────────────────────
121
+
122
+ def on_theme_browser_theme_highlighted(
123
+ self, event: ThemeBrowser.ThemeHighlighted
124
+ ) -> None:
125
+ """Live preview: instantly apply theme on cursor movement."""
126
+ if not self._theme_ready:
127
+ return
128
+ self._apply_theme(event.theme)
129
+
130
+ def on_theme_browser_theme_selected(
131
+ self, event: ThemeBrowser.ThemeSelected
132
+ ) -> None:
133
+ """Confirm theme selection."""
134
+ self._original_theme = event.theme
135
+ self.notify(f"Theme: {event.theme}")
136
+
137
+ def on_theme_browser_theme_reverted(
138
+ self, event: ThemeBrowser.ThemeReverted
139
+ ) -> None:
140
+ """Revert to original theme on Escape."""
141
+ self._apply_theme(self._original_theme)
142
+
143
+ def _apply_theme(self, theme: str) -> None:
144
+ """Apply a theme: set it in config, remove conflicting color overrides, save + reload."""
145
+ backup_config(self._config)
146
+ self._config.set("theme", theme)
147
+ # Remove explicit color keys that would override the theme
148
+ for key in THEME_COLOR_KEYS:
149
+ self._config.remove(key)
150
+ # Also remove palette overrides
151
+ self._config.remove("palette")
152
+ try:
153
+ write_config(self._config)
154
+ reload_config()
155
+ except Exception as e:
156
+ self.notify(f"Theme apply failed: {e}", severity="error")
157
+
158
+ # ── Config editor handlers ────────────────────────────
159
+
160
+ def on_option_row_value_changed(self, event: OptionRow.ValueChanged) -> None:
161
+ """Update unsaved indicator when any option changes."""
162
+ self.has_unsaved = bool(self._collect_changes())
163
+
164
+ def on_option_row_color_picker_requested(
165
+ self, event: OptionRow.ColorPickerRequested
166
+ ) -> None:
167
+ """Open the color picker modal for a color option."""
168
+ # Extract values immediately - event may not persist properly
169
+ key = event.key
170
+ current_color = event.current_value
171
+
172
+ def on_color_selected(color: str) -> None:
173
+ if color:
174
+ try:
175
+ input_widget = self.query_one(f"#opt-{key}", Input)
176
+ input_widget.value = color
177
+ except Exception:
178
+ pass
179
+ self.push_screen(ColorPickerScreen(current_color), callback=on_color_selected)
180
+
181
+ # ── Persistence ───────────────────────────────────────
182
+
183
+ def _collect_changes(self) -> dict[str, str]:
184
+ """Collect all modified values directly from widgets."""
185
+ changes: dict[str, str] = {}
186
+ for panel in self.query(ConfigPanel):
187
+ changes.update(panel.get_modified_values())
188
+ return changes
189
+
190
+ def action_save(self) -> None:
191
+ """Save all pending changes to config file and reload."""
192
+ changes = self._collect_changes()
193
+ if not changes:
194
+ self.notify("No changes to save.")
195
+ return
196
+
197
+ backup_config(self._config)
198
+
199
+ for key, value in changes.items():
200
+ if value:
201
+ self._config.set(key, value)
202
+ else:
203
+ self._config.remove(key)
204
+
205
+ try:
206
+ write_config(self._config)
207
+ reloaded = reload_config()
208
+
209
+ for key, value in changes.items():
210
+ self._config_values[key] = value
211
+ try:
212
+ row = self.query_one(f"#row-{key}", OptionRow)
213
+ row.update_original_value(value)
214
+ except Exception:
215
+ pass
216
+
217
+ self.has_unsaved = False
218
+
219
+ if reloaded:
220
+ self.notify("Config saved and reloaded!")
221
+ else:
222
+ self.notify(
223
+ "Saved, but Ghostty reload failed (PID not found).",
224
+ severity="warning",
225
+ )
226
+
227
+ except Exception as e:
228
+ self.notify(f"Save failed: {e}", severity="error")
229
+
230
+ # ── Actions ───────────────────────────────────────────
231
+
232
+ def action_help(self) -> None:
233
+ self.push_screen(HelpScreen())
234
+
235
+ def action_quit_app(self) -> None:
236
+ if self.has_unsaved:
237
+ self.notify(
238
+ "Unsaved changes! Press Ctrl+S to save, or q again to force quit."
239
+ )
240
+ self._force_quit_pending = getattr(self, "_force_quit_pending", False)
241
+ if self._force_quit_pending:
242
+ self.exit()
243
+ else:
244
+ self._force_quit_pending = True
245
+ else:
246
+ self.exit()
247
+
248
+ def action_reset_default(self) -> None:
249
+ """Reset the currently focused option to its default value."""
250
+ focused = self.focused
251
+ if focused is None:
252
+ return
253
+ widget = focused
254
+ while widget is not None and not isinstance(widget, OptionRow):
255
+ widget = widget.parent
256
+ if isinstance(widget, OptionRow):
257
+ widget.reset_to_default()
258
+ self.notify(f"Reset {widget.key} to default")
259
+
260
+ def watch_has_unsaved(self, value: bool) -> None:
261
+ """Update header to show modified indicator."""
262
+ self.sub_title = "[modified]" if value else ""
263
+
264
+
265
+ def main() -> None:
266
+ app = GhostCfg()
267
+ app.run()
268
+
269
+
270
+ if __name__ == "__main__":
271
+ main()