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.
- ghostcfg-0.1.0/.github/workflows/publish.yml +45 -0
- ghostcfg-0.1.0/.gitignore +29 -0
- ghostcfg-0.1.0/CLAUDE.md +56 -0
- ghostcfg-0.1.0/LICENSE +21 -0
- ghostcfg-0.1.0/PKG-INFO +105 -0
- ghostcfg-0.1.0/README.md +77 -0
- ghostcfg-0.1.0/assets/demo.gif +0 -0
- ghostcfg-0.1.0/pyproject.toml +45 -0
- ghostcfg-0.1.0/src/ghostcfg/__init__.py +3 -0
- ghostcfg-0.1.0/src/ghostcfg/__main__.py +5 -0
- ghostcfg-0.1.0/src/ghostcfg/app.py +271 -0
- ghostcfg-0.1.0/src/ghostcfg/app.tcss +252 -0
- ghostcfg-0.1.0/src/ghostcfg/config_io.py +146 -0
- ghostcfg-0.1.0/src/ghostcfg/ghostty.py +277 -0
- ghostcfg-0.1.0/src/ghostcfg/schema.py +1249 -0
- ghostcfg-0.1.0/src/ghostcfg/screens/__init__.py +1 -0
- ghostcfg-0.1.0/src/ghostcfg/screens/color_picker.py +204 -0
- ghostcfg-0.1.0/src/ghostcfg/screens/help_screen.py +51 -0
- ghostcfg-0.1.0/src/ghostcfg/widgets/__init__.py +1 -0
- ghostcfg-0.1.0/src/ghostcfg/widgets/config_panel.py +81 -0
- ghostcfg-0.1.0/src/ghostcfg/widgets/option_row.py +236 -0
- ghostcfg-0.1.0/src/ghostcfg/widgets/theme_browser.py +158 -0
- ghostcfg-0.1.0/src/ghostcfg/widgets/theme_preview.py +54 -0
- ghostcfg-0.1.0/tests/__init__.py +0 -0
- ghostcfg-0.1.0/tests/test_config_io.py +94 -0
- ghostcfg-0.1.0/tests/test_save_debug.py +127 -0
- ghostcfg-0.1.0/tests/test_schema.py +67 -0
- ghostcfg-0.1.0/tests/test_ui_logic.py +49 -0
|
@@ -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.*
|
ghostcfg-0.1.0/CLAUDE.md
ADDED
|
@@ -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.
|
ghostcfg-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
ghostcfg-0.1.0/README.md
ADDED
|
@@ -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,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()
|