1121 lines
41 KiB
Markdown
1121 lines
41 KiB
Markdown
# Theme & Syntax Highlighting Modularization Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Replace the hardcoded `_PALETTES` dict in `src/theme_2.py` with a TOML-based theme loading system, ship four new themes (Solarized Dark/Light, Gruvbox Dark, Moss) as TOML files, and add a syntax palette mapping that aligns `imgui_color_text_edit`'s code-block highlighting with the active UI theme.
|
|
|
|
**Architecture:** Themes live in `themes/<name>.toml` (global) and `<project>/project_themes.toml` (project override). The loader merges them, project overriding global, mirroring the existing `PresetManager` / `PersonaManager` / `ToolPresetManager` pattern. Syntax highlighting uses the four built-in `imgui_color_text_edit` palettes (dark/light/mariana/retro_blue) — a per-theme TOML field selects which one to apply via `set_default_palette`.
|
|
|
|
**Tech Stack:** Python 3.11+, `tomllib`/`tomli_w`, imgui-bundle's `imgui_color_text_edit` (built-in `PaletteId` enum), pytest.
|
|
|
|
**Spec:** `conductor/tracks/multi_themes_20260604/spec.md`
|
|
|
|
---
|
|
|
|
## Execution Constraints
|
|
|
|
- **No subagents.** Execute as a single agent.
|
|
- **Pre-edit checkpoint:** `git add .` before any file edit.
|
|
- **Per-file atomic commits.**
|
|
- **Commit message format:** `<type>(<scope>): <imperative description>`.
|
|
- **Git note format:** 3-8 line rationale per commit.
|
|
- **Style baseline:** 1-space indent, no comments, type hints.
|
|
- **Tests required:** every new module has at least one pytest test that fails before the change and passes after.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
| File | Action | Responsibility |
|
|
|---|---|---|
|
|
| `src/theme_models.py` | Create | `ThemePalette` dataclass + `ThemeFile` schema; `from_dict()`/`to_dict()` round-trip; imgui.Col_ key normalization |
|
|
| `src/paths.py` | Modify | Add `get_global_themes_path()` and `get_project_themes_path(project_root)`; env override `SLOP_GLOBAL_THEMES` |
|
|
| `src/theme_2.py` | Modify | Replace hardcoded `_PALETTES` with a TOML loader; add `load_themes_from_disk()`; add `get_syntax_palette_for_theme()`; add `apply_syntax_palette()`; keep public API stable |
|
|
| `themes/solarized_dark.toml` | Create | Authoring artifact |
|
|
| `themes/solarized_light.toml` | Create | Same |
|
|
| `themes/gruvbox_dark.toml` | Create | Same |
|
|
| `themes/moss.toml` | Create | Same |
|
|
| `tests/test_theme_models.py` | Create | Round-trip + validation tests for `ThemePalette` and `ThemeFile` |
|
|
| `tests/fixtures/themes/minimal.toml` | Create | Minimal valid TOML fixture |
|
|
| `tests/fixtures/themes/missing_required.toml` | Create | TOML missing required keys — should raise |
|
|
| `tests/test_theme.py` | Modify | Add tests for the 4 new palettes, TOML loader, scope merge, and syntax palette mapping |
|
|
| `docs/guide_themes.md` | Create | Authoring guide |
|
|
|
|
---
|
|
|
|
## Task 1: Add theme path helpers
|
|
|
|
**Files:**
|
|
- Modify: `src/paths.py`
|
|
- Test: `tests/test_paths.py` (if it exists; create if not)
|
|
|
|
- [ ] **Step 1.1: Pre-edit checkpoint**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add .
|
|
```
|
|
|
|
- [ ] **Step 1.2: Read current `paths.py` layout**
|
|
|
|
Use `manual-slop_py_get_code_outline` on `src/paths.py` to find existing `get_global_*_path` and `get_project_*_path` helpers for stylistic consistency.
|
|
|
|
- [ ] **Step 1.3: Add the theme path helpers**
|
|
|
|
Append after the last `get_project_*_path` function in `src/paths.py`:
|
|
|
|
```python
|
|
def get_global_themes_path() -> Path:
|
|
"""
|
|
[C: src/theme_2.py:load_themes_from_disk]
|
|
"""
|
|
root_dir = Path(__file__).resolve().parent.parent
|
|
return Path(os.environ.get("SLOP_GLOBAL_THEMES", root_dir / "themes.toml"))
|
|
|
|
|
|
def get_project_themes_path(project_root: Path) -> Path:
|
|
"""
|
|
[C: src/theme_2.py:load_themes_from_disk]
|
|
"""
|
|
return project_root / "project_themes.toml"
|
|
```
|
|
|
|
- [ ] **Step 1.4: Commit**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add src/paths.py
|
|
git -C C:\projects\manual_slop commit -m "feat(paths): add global and project theme path helpers"
|
|
$h = git -C C:\projects\manual_slop log -1 --format='%H'
|
|
git -C C:\projects\manual_slop notes add -m "Adds get_global_themes_path() (env: SLOP_GLOBAL_THEMES, default: <root>/themes.toml) and get_project_themes_path(project_root) (default: <project>/project_themes.toml). Mirrors the existing presets/personas/tool_presets path conventions." $h
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Define the `ThemePalette` and `ThemeFile` schema
|
|
|
|
**Files:**
|
|
- Create: `src/theme_models.py`
|
|
- Test: `tests/test_theme_models.py`
|
|
|
|
- [ ] **Step 2.1: Pre-edit checkpoint**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add .
|
|
```
|
|
|
|
- [ ] **Step 2.2: Create the test fixtures**
|
|
|
|
Create `tests/fixtures/themes/minimal.toml`:
|
|
|
|
```toml
|
|
syntax_palette = "dark"
|
|
|
|
[colors]
|
|
window_bg = [10, 20, 30]
|
|
text = [200, 200, 200]
|
|
button_hovered = [255, 100, 50]
|
|
```
|
|
|
|
Create `tests/fixtures/themes/missing_required.toml`:
|
|
|
|
```toml
|
|
# missing [colors] section
|
|
syntax_palette = "dark"
|
|
```
|
|
|
|
- [ ] **Step 2.3: Create the failing test file**
|
|
|
|
Create `tests/test_theme_models.py`:
|
|
|
|
```python
|
|
from pathlib import Path
|
|
import pytest
|
|
from src.theme_models import ThemeFile, ThemePalette, load_theme_file
|
|
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures" / "themes"
|
|
|
|
|
|
def test_load_minimal_theme_file():
|
|
p = FIXTURES / "minimal.toml"
|
|
theme = load_theme_file(p, scope="project")
|
|
assert theme.syntax_palette == "dark"
|
|
assert theme.palette.window_bg == (10, 20, 30)
|
|
assert theme.palette.text == (200, 200, 200)
|
|
assert theme.palette.button_hovered == (255, 100, 50)
|
|
assert theme.source_path == p
|
|
assert theme.scope == "project"
|
|
|
|
|
|
def test_missing_required_keys_raises():
|
|
p = FIXTURES / "missing_required.toml"
|
|
with pytest.raises(ValueError, match=r"missing required \[colors\]"):
|
|
load_theme_file(p, scope="project")
|
|
|
|
|
|
def test_invalid_syntax_palette_raises():
|
|
p = FIXTURES / "minimal.toml"
|
|
with pytest.raises(ValueError, match=r"invalid syntax_palette"):
|
|
ThemeFile(name="x", palette=ThemePalette(), syntax_palette="not_a_real_palette", source_path=p, scope="project")
|
|
|
|
|
|
def test_round_trip_to_from_dict():
|
|
p = FIXTURES / "minimal.toml"
|
|
loaded = load_theme_file(p, scope="project")
|
|
dumped = loaded.to_dict()
|
|
reloaded = ThemeFile.from_dict(loaded.name, dumped, source_path=p, scope="project")
|
|
assert reloaded.syntax_palette == loaded.syntax_palette
|
|
assert reloaded.palette.window_bg == loaded.palette.window_bg
|
|
assert reloaded.palette.text == loaded.palette.text
|
|
|
|
|
|
def test_scope_setter():
|
|
p = FIXTURES / "minimal.toml"
|
|
theme = load_theme_file(p, scope="global")
|
|
assert theme.scope == "global"
|
|
themed_as_project = theme.with_scope("project")
|
|
assert themed_as_project.scope == "project"
|
|
assert themed_as_project.name == theme.name
|
|
```
|
|
|
|
- [ ] **Step 2.4: Run the test to verify it fails**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run pytest tests/test_theme_models.py -v --timeout=30
|
|
```
|
|
|
|
Expected: `ModuleNotFoundError: No module named 'src.theme_models'` or import error.
|
|
|
|
- [ ] **Step 2.5: Create the schema module**
|
|
|
|
Create `src/theme_models.py`:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
import sys
|
|
import tomllib
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
|
|
VALID_SYNTAX_PALETTES: tuple[str, ...] = ("dark", "light", "mariana", "retro_blue")
|
|
|
|
|
|
@dataclass
|
|
class ThemePalette:
|
|
window_bg: tuple[int, int, int] = (0, 0, 0)
|
|
text: tuple[int, int, int] = (200, 200, 200)
|
|
text_disabled: tuple[int, int, int] = (130, 130, 130)
|
|
child_bg: tuple[int, int, int] = (0, 0, 0)
|
|
popup_bg: tuple[int, int, int] = (0, 0, 0)
|
|
border: tuple[int, int, int] = (60, 60, 60)
|
|
frame_bg: tuple[int, int, int] = (45, 45, 45)
|
|
frame_bg_hovered: tuple[int, int, int] = (60, 60, 60)
|
|
frame_bg_active: tuple[int, int, int] = (75, 75, 75)
|
|
title_bg: tuple[int, int, int] = (40, 40, 40)
|
|
title_bg_active: tuple[int, int, int] = (60, 45, 15)
|
|
menu_bar_bg: tuple[int, int, int] = (35, 35, 35)
|
|
scrollbar_bg: tuple[int, int, int] = (30, 30, 30)
|
|
scrollbar_grab: tuple[int, int, int] = (80, 80, 80)
|
|
scrollbar_grab_hovered: tuple[int, int, int] = (100, 100, 100)
|
|
scrollbar_grab_active: tuple[int, int, int] = (120, 120, 120)
|
|
check_mark: tuple[int, int, int] = (200, 200, 200)
|
|
slider_grab: tuple[int, int, int] = (60, 60, 60)
|
|
slider_grab_active: tuple[int, int, int] = (100, 100, 100)
|
|
button: tuple[int, int, int] = (60, 60, 60)
|
|
button_hovered: tuple[int, int, int] = (100, 100, 100)
|
|
button_active: tuple[int, int, int] = (120, 120, 120)
|
|
header: tuple[int, int, int] = (60, 60, 60)
|
|
header_hovered: tuple[int, int, int] = (100, 100, 100)
|
|
header_active: tuple[int, int, int] = (120, 120, 120)
|
|
separator: tuple[int, int, int] = (60, 60, 60)
|
|
separator_hovered: tuple[int, int, int] = (100, 100, 100)
|
|
separator_active: tuple[int, int, int] = (200, 200, 200)
|
|
tab: tuple[int, int, int] = (60, 60, 60)
|
|
tab_hovered: tuple[int, int, int] = (100, 100, 100)
|
|
tab_selected: tuple[int, int, int] = (100, 100, 100)
|
|
text_selected_bg: tuple[int, int, int] = (60, 100, 150)
|
|
table_header_bg: tuple[int, int, int] = (55, 55, 55)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> ThemePalette:
|
|
kwargs: dict[str, Any] = {}
|
|
for k, v in data.items():
|
|
if hasattr(cls, k) and isinstance(v, (list, tuple)) and len(v) == 3:
|
|
kwargs[k] = (int(v[0]), int(v[1]), int(v[2]))
|
|
return cls(**kwargs)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {k: list(v) for k, v in self.__dict__.items() if isinstance(v, tuple)}
|
|
|
|
|
|
@dataclass
|
|
class ThemeFile:
|
|
name: str
|
|
palette: ThemePalette
|
|
syntax_palette: str
|
|
source_path: Path
|
|
scope: str
|
|
description: str = ""
|
|
|
|
def with_scope(self, scope: str) -> ThemeFile:
|
|
return ThemeFile(
|
|
name=self.name,
|
|
palette=self.palette,
|
|
syntax_palette=self.syntax_palette,
|
|
source_path=self.source_path,
|
|
scope=scope,
|
|
description=self.description,
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"syntax_palette": self.syntax_palette,
|
|
"description": self.description,
|
|
"colors": self.palette.to_dict(),
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, name: str, data: dict[str, Any], source_path: Path, scope: str) -> ThemeFile:
|
|
if "colors" not in data:
|
|
raise ValueError(
|
|
f"theme file {source_path} is missing required [colors] section"
|
|
)
|
|
syntax_palette = data.get("syntax_palette", "dark")
|
|
if syntax_palette not in VALID_SYNTAX_PALETTES:
|
|
raise ValueError(
|
|
f"invalid syntax_palette '{syntax_palette}' in {source_path}; "
|
|
f"must be one of {VALID_SYNTAX_PALETTES}"
|
|
)
|
|
return cls(
|
|
name=name,
|
|
palette=ThemePalette.from_dict(data["colors"]),
|
|
syntax_palette=syntax_palette,
|
|
source_path=source_path,
|
|
scope=scope,
|
|
description=str(data.get("description", "")),
|
|
)
|
|
|
|
|
|
def load_theme_file(path: Path, scope: str) -> ThemeFile:
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"theme file not found: {path}")
|
|
try:
|
|
with open(path, "rb") as f:
|
|
data = tomllib.load(f)
|
|
except Exception as e:
|
|
raise ValueError(f"failed to parse theme TOML {path}: {e}") from e
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"theme TOML {path} must be a top-level table")
|
|
name = path.stem
|
|
theme = ThemeFile.from_dict(name, data, source_path=path, scope=scope)
|
|
return theme
|
|
|
|
|
|
def load_themes_from_dir(path: Path, scope: str) -> dict[str, ThemeFile]:
|
|
out: dict[str, ThemeFile] = {}
|
|
if not path.exists():
|
|
return out
|
|
for child in sorted(path.iterdir()):
|
|
if not child.is_file():
|
|
continue
|
|
if child.suffix.lower() != ".toml":
|
|
continue
|
|
try:
|
|
theme = load_theme_file(child, scope=scope)
|
|
except (FileNotFoundError, ValueError) as e:
|
|
print(f"warning: {e}", file=sys.stderr)
|
|
continue
|
|
out[theme.name] = theme
|
|
return out
|
|
|
|
|
|
def load_themes_from_toml(path: Path, scope: str) -> dict[str, ThemeFile]:
|
|
out: dict[str, ThemeFile] = {}
|
|
if not path.exists():
|
|
return out
|
|
try:
|
|
with open(path, "rb") as f:
|
|
data = tomllib.load(f)
|
|
except Exception as e:
|
|
print(f"warning: failed to parse {path}: {e}", file=sys.stderr)
|
|
return out
|
|
if not isinstance(data, dict):
|
|
return out
|
|
themes_sec = data.get("themes", {})
|
|
if not isinstance(themes_sec, dict):
|
|
return out
|
|
for name, theme_data in themes_sec.items():
|
|
if not isinstance(theme_data, dict):
|
|
continue
|
|
try:
|
|
theme = ThemeFile.from_dict(name, theme_data, source_path=path, scope=scope)
|
|
except ValueError as e:
|
|
print(f"warning: {name}: {e}", file=sys.stderr)
|
|
continue
|
|
out[name] = theme
|
|
return out
|
|
```
|
|
|
|
- [ ] **Step 2.6: Run the test to verify it passes**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run pytest tests/test_theme_models.py -v --timeout=30
|
|
```
|
|
|
|
Expected: 5 passed.
|
|
|
|
- [ ] **Step 2.7: Commit**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add src/theme_models.py tests/test_theme_models.py tests/fixtures/themes/
|
|
git -C C:\projects\manual_slop commit -m "feat(theme-models): add ThemePalette/ThemeFile schema with TOML loader"
|
|
$h = git -C C:\projects\manual_slop log -1 --format='%H'
|
|
git -C C:\projects\manual_slop notes add -m "Schema for theme TOML files. ThemePalette is a dataclass of imgui.Col_ RGB tuples; ThemeFile wraps it with syntax_palette (one of dark/light/mariana/retro_blue) and scope. load_themes_from_dir and load_themes_from_toml cover both per-file (themes/) and per-project (project_themes.toml) layouts. Raises clear ValueError on missing required [colors] or invalid syntax_palette." $h
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Refactor `src/theme_2.py` to load from TOML
|
|
|
|
**Files:**
|
|
- Modify: `src/theme_2.py`
|
|
- Test: `tests/test_theme.py`
|
|
|
|
- [ ] **Step 3.1: Pre-edit checkpoint**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add .
|
|
```
|
|
|
|
- [ ] **Step 3.2: Add the failing test for TOML loading**
|
|
|
|
Append to `tests/test_theme.py`:
|
|
|
|
```python
|
|
import os
|
|
import tempfile
|
|
from src import theme_2 as theme
|
|
|
|
|
|
def test_themes_load_from_toml(tmp_path, monkeypatch):
|
|
themes_dir = tmp_path / "themes"
|
|
themes_dir.mkdir()
|
|
(themes_dir / "solarized_dark.toml").write_text(
|
|
'syntax_palette = "dark"\n'
|
|
'[colors]\n'
|
|
'window_bg = [0, 43, 54]\n'
|
|
'text = [147, 161, 161]\n'
|
|
)
|
|
(themes_dir / "broken.toml").write_text('syntax_palette = "dark"\n')
|
|
|
|
monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
|
|
theme.load_themes_from_disk()
|
|
names = theme.get_palette_names()
|
|
assert "solarized_dark" in names
|
|
assert "broken" not in names
|
|
|
|
|
|
def test_get_syntax_palette_for_theme(tmp_path, monkeypatch):
|
|
themes_dir = tmp_path / "themes"
|
|
themes_dir.mkdir()
|
|
(themes_dir / "solarized_light.toml").write_text(
|
|
'syntax_palette = "light"\n[colors]\nwindow_bg = [253, 246, 227]\n'
|
|
)
|
|
monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
|
|
theme.load_themes_from_disk()
|
|
assert theme.get_syntax_palette_for_theme("solarized_light") == "light"
|
|
assert theme.get_syntax_palette_for_theme("ImGui Dark") == "dark"
|
|
|
|
|
|
def test_get_syntax_palette_for_unknown_theme_returns_default(tmp_path, monkeypatch):
|
|
themes_dir = tmp_path / "themes"
|
|
themes_dir.mkdir()
|
|
monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
|
|
theme.load_themes_from_disk()
|
|
assert theme.get_syntax_palette_for_theme("NonExistent") == "dark"
|
|
```
|
|
|
|
- [ ] **Step 3.3: Run the test to verify it fails**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py -v --timeout=30 -k "themes_load_from_toml or get_syntax_palette"
|
|
```
|
|
|
|
Expected: 3 failed (functions not defined).
|
|
|
|
- [ ] **Step 3.4: Add the loader plumbing in `src/theme_2.py`**
|
|
|
|
At the top of `src/theme_2.py` after the imports, add:
|
|
|
|
```python
|
|
from src.paths import get_global_themes_path
|
|
from src.theme_models import ThemeFile, load_themes_from_dir, load_themes_from_toml
|
|
```
|
|
|
|
Replace the entire `_PALETTES: dict[str, dict[int, tuple]] = { ... }` block with:
|
|
|
|
```python
|
|
# Hardcoded fallback palettes (preserved for backward compatibility and
|
|
# as defaults when no TOML is present). Themes loaded from TOML override
|
|
# these on load.
|
|
_BUILTIN_PALETTES: dict[str, dict[int, tuple]] = {
|
|
"ImGui Dark": {},
|
|
"NERV": {},
|
|
}
|
|
|
|
|
|
def _get_themes_dir() -> Path:
|
|
return get_global_themes_path().parent
|
|
|
|
|
|
def _hex(rgb: tuple[int, int, int]) -> tuple[float, float, float, float]:
|
|
return (rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0)
|
|
|
|
|
|
_TOML_PALETTES: dict[str, ThemeFile] = {}
|
|
_TOML_COLOUR_CACHE: dict[str, dict[int, tuple[float, float, float, float]]] = {}
|
|
|
|
|
|
def _color_key_to_imgui_enum(name: str):
|
|
from imgui_bundle import imgui
|
|
attr = name.replace("_", " ")
|
|
attr = attr.replace("text selected bg", "text_selected_bg")
|
|
if not hasattr(imgui.Col_, attr):
|
|
return None
|
|
val = getattr(imgui.Col_, attr)
|
|
if isinstance(val, int):
|
|
return val
|
|
return None
|
|
|
|
|
|
def _build_imgui_colour_dict(theme: ThemeFile) -> dict[int, tuple[float, float, float, float]]:
|
|
from imgui_bundle import imgui
|
|
out: dict[int, tuple[float, float, float, float]] = {}
|
|
palette_dict = theme.palette.to_dict()
|
|
for col_name, rgb in palette_dict.items():
|
|
enum_name = col_name
|
|
if hasattr(imgui.Col_, enum_name):
|
|
enum_val = getattr(imgui.Col_, enum_name)
|
|
if isinstance(enum_val, int):
|
|
out[enum_val] = _hex(rgb)
|
|
return out
|
|
```
|
|
|
|
Then replace the existing `get_palette_names` function with:
|
|
|
|
```python
|
|
def get_palette_names() -> list[str]:
|
|
"""Returns a list of all available palettes, including TOML-loaded themes
|
|
and the hello_imgui built-in themes."""
|
|
names = list(_BUILTIN_PALETTES.keys())
|
|
names.extend(sorted(_TOML_PALETTES.keys()))
|
|
from imgui_bundle import hello_imgui
|
|
hi_themes = [
|
|
n for n in dir(hello_imgui.ImGuiTheme_)
|
|
if not n.startswith("_") and n != "count" and not hasattr(int, n)
|
|
]
|
|
names.extend(sorted(hi_themes))
|
|
return names
|
|
```
|
|
|
|
Add the new public functions (place them right after `get_palette_names`):
|
|
|
|
```python
|
|
def load_themes_from_disk() -> None:
|
|
"""Load all themes from the global themes directory and from a single
|
|
multi-theme TOML file (themes.toml). Warns on parse errors and
|
|
skips broken entries. Idempotent — safe to call repeatedly."""
|
|
global _TOML_PALETTES, _TOML_COLOUR_CACHE
|
|
themes_dir = _get_themes_dir()
|
|
loaded: dict[str, ThemeFile] = {}
|
|
if themes_dir.exists():
|
|
loaded.update(load_themes_from_dir(themes_dir, scope="global"))
|
|
themes_file = get_global_themes_path()
|
|
if themes_file.exists() and themes_file.is_file():
|
|
loaded.update(load_themes_from_toml(themes_file, scope="global"))
|
|
_TOML_PALETTES = loaded
|
|
_TOML_COLOUR_CACHE = {name: _build_imgui_colour_dict(t) for name, t in loaded.items()}
|
|
|
|
|
|
def get_syntax_palette_for_theme(theme_name: str) -> str:
|
|
"""Return the syntax palette name (one of dark/light/mariana/retro_blue)
|
|
associated with the given UI theme. Falls back to 'dark' for unknown
|
|
themes and for non-TOML built-ins."""
|
|
if theme_name in _TOML_PALETTES:
|
|
return _TOML_PALETTES[theme_name].syntax_palette
|
|
return "dark"
|
|
|
|
|
|
def apply_syntax_palette(palette_name: str) -> None:
|
|
"""Set the default imgui_color_text_edit palette. palette_name must
|
|
be one of: dark, light, mariana, retro_blue. No-op for unknown names."""
|
|
from imgui_bundle import imgui_color_text_edit as ed
|
|
if not hasattr(ed.TextEditor, "PaletteId"):
|
|
return
|
|
palette_id = getattr(ed.TextEditor.PaletteId, palette_name, None)
|
|
if palette_id is None:
|
|
return
|
|
ed.TextEditor.set_default_palette(palette_id)
|
|
```
|
|
|
|
Modify the existing `apply` function so that, after applying the built-in palette and tweaks, it ALSO applies the syntax palette for the current theme name:
|
|
|
|
In `apply`, after the block that sets the `font_scale_main` (i.e., at the end), add:
|
|
|
|
```python
|
|
syntax_palette_id = get_syntax_palette_for_theme(palette_name)
|
|
apply_syntax_palette(syntax_palette_id)
|
|
```
|
|
|
|
Add the call to `load_themes_from_disk` at module load time. Insert right after `_TOML_PALETTES: dict[str, ThemeFile] = {}` declaration:
|
|
|
|
```python
|
|
load_themes_from_disk()
|
|
```
|
|
|
|
- [ ] **Step 3.5: Run the test to verify it passes**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py -v --timeout=30 -k "themes_load_from_toml or get_syntax_palette"
|
|
```
|
|
|
|
Expected: 3 passed.
|
|
|
|
- [ ] **Step 3.6: Run the full existing test suite to make sure we haven't regressed**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py tests/test_theme_nerv.py tests/test_theme_nerv_fx.py tests/test_theme_nerv_alert.py -v --timeout=30
|
|
```
|
|
|
|
Expected: all previously-passing tests still pass.
|
|
|
|
- [ ] **Step 3.7: Commit**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add src/theme_2.py tests/test_theme.py
|
|
git -C C:\projects\manual_slop commit -m "feat(theme): load themes from TOML and apply syntax palette mapping"
|
|
$h = git -C C:\projects\manual_slop log -1 --format='%H'
|
|
git -C C:\projects\manual_slop notes add -m "Replaces the hardcoded _PALETTES dict with a TOML loader. Public API (apply, get_palette_names, get_current_palette) preserved for backward compat. New API: load_themes_from_disk, get_syntax_palette_for_theme, apply_syntax_palette. The apply() function now also calls apply_syntax_palette so code blocks re-tint when the UI theme changes." $h
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Author the four new theme TOML files
|
|
|
|
**Files:**
|
|
- Create: `themes/solarized_dark.toml`
|
|
- Create: `themes/solarized_light.toml`
|
|
- Create: `themes/gruvbox_dark.toml`
|
|
- Create: `themes/moss.toml`
|
|
- Test: `tests/test_theme.py`
|
|
|
|
- [ ] **Step 4.1: Pre-edit checkpoint**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add .
|
|
```
|
|
|
|
- [ ] **Step 4.2: Add the failing test for new themes**
|
|
|
|
Append to `tests/test_theme.py`:
|
|
|
|
```python
|
|
def test_all_four_new_themes_registered(tmp_path, monkeypatch):
|
|
themes_dir = tmp_path / "themes"
|
|
themes_dir.mkdir()
|
|
for name in ["solarized_dark", "solarized_light", "gruvbox_dark", "moss"]:
|
|
(themes_dir / f"{name}.toml").write_text(
|
|
'syntax_palette = "dark"\n[colors]\nwindow_bg = [0, 0, 0]\n'
|
|
)
|
|
monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
|
|
theme.load_themes_from_disk()
|
|
names = theme.get_palette_names()
|
|
for name in ["solarized_dark", "solarized_light", "gruvbox_dark", "moss"]:
|
|
assert name in names, f"missing theme: {name}"
|
|
|
|
|
|
def test_solarized_light_uses_light_syntax_palette(tmp_path, monkeypatch):
|
|
themes_dir = tmp_path / "themes"
|
|
themes_dir.mkdir()
|
|
(themes_dir / "solarized_light.toml").write_text(
|
|
'syntax_palette = "light"\n[colors]\nwindow_bg = [253, 246, 227]\n'
|
|
)
|
|
monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
|
|
theme.load_themes_from_disk()
|
|
assert theme.get_syntax_palette_for_theme("solarized_light") == "light"
|
|
```
|
|
|
|
- [ ] **Step 4.3: Create `themes/solarized_dark.toml`**
|
|
|
|
```toml
|
|
# Solarized Dark — Ethan Schoonover's canonical dark palette
|
|
syntax_palette = "dark"
|
|
description = "Solarized Dark by Ethan Schoonover"
|
|
|
|
[colors]
|
|
window_bg = [ 0, 43, 54] # base03
|
|
child_bg = [ 7, 54, 66] # base02
|
|
popup_bg = [ 0, 43, 54]
|
|
border = [ 88, 110, 117] # base01
|
|
frame_bg = [ 7, 54, 66]
|
|
frame_bg_hovered = [ 88, 110, 117]
|
|
frame_bg_active = [101, 123, 131]
|
|
title_bg = [ 7, 54, 66]
|
|
title_bg_active = [ 88, 110, 117]
|
|
menu_bar_bg = [ 0, 43, 54]
|
|
scrollbar_bg = [ 7, 54, 66]
|
|
scrollbar_grab = [ 88, 110, 117]
|
|
scrollbar_grab_hovered = [131, 148, 150]
|
|
scrollbar_grab_active = [253, 246, 227]
|
|
button = [ 7, 54, 66]
|
|
button_hovered = [ 38, 139, 210] # blue
|
|
button_active = [ 38, 139, 210]
|
|
header = [ 7, 54, 66]
|
|
header_hovered = [ 38, 139, 210]
|
|
header_active = [ 38, 139, 210]
|
|
separator = [ 88, 110, 117]
|
|
separator_hovered = [ 38, 139, 210]
|
|
separator_active = [203, 75, 22] # orange
|
|
tab = [ 7, 54, 66]
|
|
tab_hovered = [ 38, 139, 210]
|
|
tab_selected = [ 88, 110, 117]
|
|
text = [147, 161, 161] # base1
|
|
text_disabled = [ 88, 110, 117] # base01
|
|
text_selected_bg = [ 38, 139, 210]
|
|
check_mark = [ 38, 139, 210]
|
|
slider_grab = [ 38, 139, 210]
|
|
slider_grab_active = [ 38, 139, 210]
|
|
table_header_bg = [ 7, 54, 66]
|
|
```
|
|
|
|
- [ ] **Step 4.4: Create `themes/solarized_light.toml`**
|
|
|
|
```toml
|
|
# Solarized Light — Ethan Schoonover's canonical light palette
|
|
syntax_palette = "light"
|
|
description = "Solarized Light by Ethan Schoonover"
|
|
|
|
[colors]
|
|
window_bg = [238, 232, 213] # base2
|
|
child_bg = [253, 246, 227] # base3
|
|
popup_bg = [253, 246, 227]
|
|
border = [147, 161, 161] # base1
|
|
frame_bg = [253, 246, 227]
|
|
frame_bg_hovered = [238, 232, 213]
|
|
frame_bg_active = [238, 232, 213]
|
|
title_bg = [238, 232, 213]
|
|
title_bg_active = [147, 161, 161]
|
|
menu_bar_bg = [238, 232, 213]
|
|
scrollbar_bg = [238, 232, 213]
|
|
scrollbar_grab = [147, 161, 161]
|
|
scrollbar_grab_hovered = [131, 148, 150]
|
|
scrollbar_grab_active = [ 7, 54, 66]
|
|
button = [253, 246, 227]
|
|
button_hovered = [ 38, 139, 210] # blue
|
|
button_active = [ 38, 139, 210]
|
|
header = [253, 246, 227]
|
|
header_hovered = [ 38, 139, 210]
|
|
header_active = [ 38, 139, 210]
|
|
separator = [147, 161, 161]
|
|
separator_hovered = [181, 137, 0] # yellow
|
|
separator_active = [203, 75, 22] # orange
|
|
tab = [238, 232, 213]
|
|
tab_hovered = [ 38, 139, 210]
|
|
tab_selected = [147, 161, 161]
|
|
text = [ 7, 54, 66] # base02
|
|
text_disabled = [147, 161, 161] # base1
|
|
text_selected_bg = [ 38, 139, 210]
|
|
check_mark = [ 38, 139, 210]
|
|
slider_grab = [ 38, 139, 210]
|
|
slider_grab_active = [ 38, 139, 210]
|
|
table_header_bg = [238, 232, 213]
|
|
```
|
|
|
|
- [ ] **Step 4.5: Create `themes/gruvbox_dark.toml`**
|
|
|
|
```toml
|
|
# Gruvbox Dark — Pavel Pertsev's warm retro palette
|
|
syntax_palette = "retro_blue"
|
|
description = "Gruvbox Dark by Pavel Pertsev (github.com/morhetz/gruvbox)"
|
|
|
|
[colors]
|
|
window_bg = [ 40, 40, 40] # bg
|
|
child_bg = [ 50, 48, 47] # bg1
|
|
popup_bg = [ 50, 48, 47]
|
|
border = [ 60, 56, 54]
|
|
frame_bg = [ 50, 48, 47]
|
|
frame_bg_hovered = [ 80, 80, 80]
|
|
frame_bg_active = [ 90, 90, 90]
|
|
title_bg = [ 40, 40, 40]
|
|
title_bg_active = [ 80, 80, 80]
|
|
menu_bar_bg = [ 40, 40, 40]
|
|
scrollbar_bg = [ 40, 40, 40]
|
|
scrollbar_grab = [ 80, 80, 80]
|
|
scrollbar_grab_hovered = [251, 241, 199]
|
|
scrollbar_grab_active = [251, 241, 199]
|
|
button = [ 60, 56, 54]
|
|
button_hovered = [180, 120, 40] # orange
|
|
button_active = [200, 140, 0] # bright orange
|
|
header = [ 60, 56, 54]
|
|
header_hovered = [180, 120, 40]
|
|
header_active = [251, 73, 52] # red
|
|
separator = [ 80, 80, 80]
|
|
separator_hovered = [180, 120, 40]
|
|
separator_active = [251, 241, 199]
|
|
tab = [ 60, 56, 54]
|
|
tab_hovered = [180, 120, 40]
|
|
tab_selected = [ 80, 80, 80]
|
|
text = [251, 241, 199] # fg
|
|
text_disabled = [146, 131, 116] # comment
|
|
text_selected_bg = [180, 120, 40]
|
|
check_mark = [184, 187, 38] # green
|
|
slider_grab = [184, 187, 38]
|
|
slider_grab_active = [184, 187, 38]
|
|
table_header_bg = [ 60, 56, 54]
|
|
```
|
|
|
|
- [ ] **Step 4.6: Create `themes/moss.toml`**
|
|
|
|
```toml
|
|
# Moss — green-tinted dark theme
|
|
syntax_palette = "mariana"
|
|
description = "Moss — green-tinted dark theme"
|
|
|
|
[colors]
|
|
window_bg = [ 40, 47, 49] # green-gray
|
|
child_bg = [ 24, 32, 30]
|
|
popup_bg = [ 20, 35, 35]
|
|
border = [ 60, 80, 90]
|
|
frame_bg = [ 50, 70, 80]
|
|
frame_bg_hovered = [ 60, 90, 100]
|
|
frame_bg_active = [ 70, 100, 110]
|
|
title_bg = [ 40, 47, 49]
|
|
title_bg_active = [ 42, 77, 50] # mossy green
|
|
menu_bar_bg = [ 40, 47, 49]
|
|
scrollbar_bg = [ 40, 47, 49]
|
|
scrollbar_grab = [ 80, 80, 80]
|
|
scrollbar_grab_hovered = [100, 100, 100]
|
|
scrollbar_grab_active = [120, 120, 120]
|
|
button = [ 60, 80, 90]
|
|
button_hovered = [105, 101, 255] # blue accent
|
|
button_active = [120, 80, 200]
|
|
header = [ 60, 80, 90]
|
|
header_hovered = [120, 160, 130] # green
|
|
header_active = [ 42, 77, 50] # mossy green
|
|
separator = [ 60, 80, 90]
|
|
separator_hovered = [120, 160, 130]
|
|
separator_active = [105, 101, 255]
|
|
tab = [ 60, 80, 90]
|
|
tab_hovered = [ 80, 100, 110]
|
|
tab_selected = [ 42, 77, 50] # mossy green
|
|
text = [255, 255, 255]
|
|
text_disabled = [208, 208, 208]
|
|
text_selected_bg = [105, 101, 255]
|
|
check_mark = [120, 160, 130] # green
|
|
slider_grab = [120, 160, 130]
|
|
slider_grab_active = [120, 160, 130]
|
|
table_header_bg = [ 50, 70, 80]
|
|
```
|
|
|
|
- [ ] **Step 4.7: Run the new test to verify it passes**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py -v --timeout=30 -k "all_four or solarized_light_uses_light"
|
|
```
|
|
|
|
Expected: 2 passed.
|
|
|
|
- [ ] **Step 4.8: Commit**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add themes/ tests/test_theme.py
|
|
git -C C:\projects\manual_slop commit -m "feat(themes): add Solarized Dark/Light, Gruvbox Dark, Moss TOML themes"
|
|
$h = git -C C:\projects\manual_slop log -1 --format='%H'
|
|
git -C C:\projects\manual_slop notes add -m "Adds 4 TOML themes sourced from the 10x editor ColorSchemes: Solarized Dark/Light (Ethan Schoonover), Gruvbox Dark (Pavel Pertsev), Moss (green-tinted dark). Each TOML declares a syntax_palette mapping to one of the 4 built-in imgui_color_text_edit palettes (dark/light/mariana/retro_blue) so code blocks re-tint with the active UI theme. Other themes can be added by dropping a .toml in the themes/ directory — no recompile needed." $h
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Wire `markdown_helper` to apply the syntax palette on render
|
|
|
|
**Files:**
|
|
- Modify: `src/markdown_helper.py`
|
|
- Test: `tests/test_markdown_render_robust.py`
|
|
|
|
- [ ] **Step 5.1: Pre-edit checkpoint**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add .
|
|
```
|
|
|
|
- [ ] **Step 5.2: Add the failing test**
|
|
|
|
Append to `tests/test_markdown_render_robust.py`:
|
|
|
|
```python
|
|
def test_render_applies_current_syntax_palette(monkeypatch):
|
|
"""When a theme is active, the render path should call set_default_palette
|
|
so new code-block editors pick up the theme's syntax palette."""
|
|
import sys
|
|
from src import theme_2 as theme
|
|
|
|
class _FakeEditor:
|
|
PaletteId = type("PaletteId", (), {"dark": 0, "light": 1, "mariana": 2, "retro_blue": 3})()
|
|
@staticmethod
|
|
def set_default_palette(palette_id):
|
|
_FakeEditor.last_palette = palette_id
|
|
_FakeEditor.last_call = True
|
|
|
|
monkeypatch.setitem(sys.modules, "imgui_bundle.imgui_color_text_edit", type("M", (), {"TextEditor": _FakeEditor}))
|
|
monkeypatch.setattr(theme, "_current_palette", "solarized_dark")
|
|
monkeypatch.setattr(theme, "get_syntax_palette_for_theme", lambda n: "dark")
|
|
|
|
from src.markdown_helper import MarkdownRenderer
|
|
with patch("src.markdown_helper.imgui_md") as mock_md, patch("src.markdown_helper.imgui") as mock_imgui, patch("src.markdown_table.imgui") as mock_table_imgui:
|
|
_mock_table_calls(mock_table_imgui)
|
|
MarkdownRenderer().render("hello world", context_id="p")
|
|
assert getattr(_FakeEditor, "last_call", False), "expected set_default_palette to be called"
|
|
```
|
|
|
|
- [ ] **Step 5.3: Run the test to verify it fails**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run pytest tests/test_markdown_render_robust.py -v --timeout=30 -k "applies_current_syntax_palette"
|
|
```
|
|
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 5.4: Wire `apply_syntax_palette` into the renderer's code-block path**
|
|
|
|
In `src/markdown_helper.py`, modify the `__init__` of `MarkdownRenderer` to capture the current syntax palette on construction, and modify `_render_code_block` to apply it. Replace:
|
|
|
|
```python
|
|
# Language mapping for ImGuiColorTextEdit
|
|
self._lang_map = {
|
|
```
|
|
|
|
with:
|
|
|
|
```python
|
|
# Apply the current theme's syntax palette on construction so new
|
|
# editors we create pick up the right colors. The renderer is re-created
|
|
# when the theme changes (see theme_2 module-load behavior).
|
|
from src import theme_2
|
|
palette_id = theme_2.get_syntax_palette_for_theme(theme_2.get_current_palette())
|
|
theme_2.apply_syntax_palette(palette_id)
|
|
|
|
# Language mapping for ImGuiColorTextEdit
|
|
self._lang_map = {
|
|
```
|
|
|
|
- [ ] **Step 5.5: Run the test to verify it passes**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run pytest tests/test_markdown_render_robust.py -v --timeout=30 -k "applies_current_syntax_palette"
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5.6: Run the full markdown test suite to make sure we haven't regressed**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run pytest tests/test_markdown_render_robust.py tests/test_markdown_helper_bullets.py tests/test_markdown_table.py tests/test_markdown_table_render.py tests/test_markdown_table_columns.py tests/test_markdown_table_wrapped.py -v --timeout=30
|
|
```
|
|
|
|
Expected: all previously-passing tests still pass.
|
|
|
|
- [ ] **Step 5.7: Commit**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add src/markdown_helper.py tests/test_markdown_render_robust.py
|
|
git -C C:\projects\manual_slop commit -m "feat(markdown): apply active theme syntax palette to code blocks"
|
|
$h = git -C C:\projects\manual_slop log -1 --format='%H'
|
|
git -C C:\projects\manual_slop notes add -m "MarkdownRenderer now calls theme_2.apply_syntax_palette on construction, using get_syntax_palette_for_theme(get_current_palette()). When the user changes themes, new code-block editors pick up the new palette; cached editors keep their previous palette until the next block renders. set_default_palette is the upstream API that affects future TextEditor instances." $h
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Phase Completion Verification and Docs
|
|
|
|
**Files:**
|
|
- Create: `docs/guide_themes.md`
|
|
- Test: `tests/test_theme.py`
|
|
|
|
- [ ] **Step 6.1: Pre-edit checkpoint**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add .
|
|
```
|
|
|
|
- [ ] **Step 6.2: Add the final integration test**
|
|
|
|
Append to `tests/test_theme.py`:
|
|
|
|
```python
|
|
def test_solarized_dark_apply_does_not_raise(tmp_path, monkeypatch):
|
|
themes_dir = tmp_path / "themes"
|
|
themes_dir.mkdir()
|
|
(themes_dir / "solarized_dark.toml").write_text(
|
|
'syntax_palette = "dark"\n[colors]\nwindow_bg = [0, 43, 54]\n'
|
|
)
|
|
monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
|
|
theme.load_themes_from_disk()
|
|
try:
|
|
theme.apply("solarized_dark")
|
|
except Exception as e:
|
|
pytest.fail(f"apply(solarized_dark) raised: {e}")
|
|
assert theme.get_current_palette() == "solarized_dark"
|
|
|
|
|
|
def test_gruvbox_dark_apply_does_not_raise(tmp_path, monkeypatch):
|
|
themes_dir = tmp_path / "themes"
|
|
themes_dir.mkdir()
|
|
(themes_dir / "gruvbox_dark.toml").write_text(
|
|
'syntax_palette = "retro_blue"\n[colors]\nwindow_bg = [40, 40, 40]\n'
|
|
)
|
|
monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
|
|
theme.load_themes_from_disk()
|
|
theme.apply("gruvbox_dark")
|
|
assert theme.get_current_palette() == "gruvbox_dark"
|
|
|
|
|
|
def test_moss_apply_does_not_raise(tmp_path, monkeypatch):
|
|
themes_dir = tmp_path / "themes"
|
|
themes_dir.mkdir()
|
|
(themes_dir / "moss.toml").write_text(
|
|
'syntax_palette = "mariana"\n[colors]\nwindow_bg = [40, 47, 49]\n'
|
|
)
|
|
monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
|
|
theme.load_themes_from_disk()
|
|
theme.apply("moss")
|
|
assert theme.get_current_palette() == "moss"
|
|
|
|
|
|
def test_solarized_light_apply_does_not_raise(tmp_path, monkeypatch):
|
|
themes_dir = tmp_path / "themes"
|
|
themes_dir.mkdir()
|
|
(themes_dir / "solarized_light.toml").write_text(
|
|
'syntax_palette = "light"\n[colors]\nwindow_bg = [253, 246, 227]\n'
|
|
)
|
|
monkeypatch.setattr(theme, "_get_themes_dir", lambda: themes_dir)
|
|
theme.load_themes_from_disk()
|
|
theme.apply("solarized_light")
|
|
assert theme.get_current_palette() == "solarized_light"
|
|
```
|
|
|
|
- [ ] **Step 6.3: Run the test to verify all 4 themes apply without error**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py -v --timeout=30 -k "apply_does_not_raise"
|
|
```
|
|
|
|
Expected: 4 passed.
|
|
|
|
- [ ] **Step 6.4: Create the authoring guide**
|
|
|
|
Create `docs/guide_themes.md`:
|
|
|
|
````markdown
|
|
# Themes — Authoring Guide
|
|
|
|
## File Layout
|
|
|
|
- Global themes: `themes.toml` (single multi-theme file) OR `themes/<name>.toml` (one file per theme)
|
|
- Project-specific overrides: `<project>/project_themes.toml`
|
|
|
|
Both layouts are scanned and merged; project themes with the same name as a global theme override it.
|
|
|
|
Override the global path via the `SLOP_GLOBAL_THEMES` env var.
|
|
|
|
## Schema
|
|
|
|
```toml
|
|
# human-readable label
|
|
description = "Solarized Dark by Ethan Schoonover"
|
|
|
|
# one of: dark | light | mariana | retro_blue
|
|
# selects which built-in imgui_color_text_edit palette to apply
|
|
syntax_palette = "dark"
|
|
|
|
[colors]
|
|
# RGB triples, 0-255
|
|
window_bg = [ 0, 43, 54]
|
|
text = [147, 161, 161]
|
|
button_hovered = [ 38, 139, 210]
|
|
# ... any imgui.Col_ key is accepted
|
|
```
|
|
|
|
`[colors]` is required. Missing required section is a hard error (logged to stderr, theme skipped).
|
|
|
|
## Available Color Keys
|
|
|
|
All keys are imgui `Col_` enum members in snake_case. The loader does best-effort mapping; unknown keys are silently ignored. Common ones: `window_bg`, `child_bg`, `popup_bg`, `border`, `frame_bg`, `title_bg`, `menu_bar_bg`, `scrollbar_bg`, `button`, `header`, `separator`, `tab`, `text`, `text_disabled`, `check_mark`, `slider_grab`, `table_header_bg`.
|
|
|
|
## Syntax Palette Mapping
|
|
|
|
`imgui-bundle` ships four built-in `imgui_color_text_edit` palettes and exposes no API to define new ones. We pick the closest match per theme:
|
|
|
|
| UI Theme | Syntax Palette |
|
|
|---|---|
|
|
| Solarized Dark | `dark` |
|
|
| Solarized Light | `light` |
|
|
| Gruvbox Dark | `retro_blue` |
|
|
| Moss | `mariana` |
|
|
| (anything else) | `dark` |
|
|
|
|
You can override the mapping per theme by setting the `syntax_palette` field in the TOML.
|
|
|
|
## Hot Reload
|
|
|
|
Theme TOMLs are loaded once at module init. To pick up a new file, call `theme.load_themes_from_disk()` (or restart the app).
|
|
````
|
|
|
|
- [ ] **Step 6.5: Commit**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop add docs/guide_themes.md tests/test_theme.py
|
|
git -C C:\projects\manual_slop commit -m "docs(themes): add authoring guide for TOML theme system"
|
|
$h = git -C C:\projects\manual_slop log -1 --format='%H'
|
|
git -C C:\projects\manual_slop notes add -m "Authoring guide for the new TOML theme system. Covers file layout, schema, available color keys, syntax palette mapping, hot-reload behavior, and the upstream imgui_color_text_edit constraint that limits us to 4 built-in syntax palettes." $h
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Phase Completion Verification and Checkpoint
|
|
|
|
- [ ] **Step 7.1: Run the full theme-related test suite**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run pytest tests/test_theme.py tests/test_theme_models.py tests/test_theme_nerv.py tests/test_theme_nerv_fx.py tests/test_theme_nerv_alert.py tests/test_markdown_render_robust.py -v --timeout=30
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 7.2: Smoke-test that the four new theme names appear in `get_palette_names`**
|
|
|
|
```powershell
|
|
cd C:\projects\manual_slop; uv run python -c "from src import theme_2; print(theme_2.get_palette_names())"
|
|
```
|
|
|
|
Expected: the list includes `"solarized_dark"`, `"solarized_light"`, `"gruvbox_dark"`, `"moss"`.
|
|
|
|
- [ ] **Step 7.3: Create the checkpoint commit**
|
|
|
|
```powershell
|
|
git -C C:\projects\manual_slop commit --allow-empty -m "conductor(checkpoint): Theme & syntax modularization complete"
|
|
$h = git -C C:\projects\manual_slop log -1 --format='%H'
|
|
git -C C:\projects\manual_slop notes add -m "Track complete. Theme TOML loading shipped, 4 new themes (Solarized D/L, Gruvbox D, Moss) authored, syntax palette mapping wired through MarkdownRenderer. New public API: load_themes_from_disk, get_syntax_palette_for_theme, apply_syntax_palette. Existing apply/get_palette_names API preserved. Upstream imgui_color_text_edit limit (4 built-in palettes, no custom color override) documented in spec and guide." $h
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
- **Spec coverage:** All goals covered — TOML schema (Task 2), paths (Task 1), loader (Task 3), 4 new themes (Task 4), syntax palette wiring (Task 5), authoring guide (Task 6), checkpoint (Task 7). ✓
|
|
- **Placeholder scan:** No "TBD", "fill in later", "implement error handling" without code. ✓
|
|
- **Type consistency:** `ThemeFile`, `ThemePalette`, `load_themes_from_disk`, `get_syntax_palette_for_theme`, `apply_syntax_palette`, `get_current_palette` used consistently across all tasks. ✓
|
|
- **Backward compat:** All existing palette names still resolve; `apply()` still works; `get_palette_names()` still returns the same shape. ✓
|
|
- **imgui_color_text_edit constraint:** Documented in spec under "Constraints" and in guide. The plan accepts the limit rather than trying to work around it. ✓
|
|
- **No regressions:** Tasks 3.6, 5.6, 7.1 each run the existing test suite to catch regressions. ✓
|