feat(theme): load themes from TOML and apply syntax palette mapping
This commit is contained in:
+4166
File diff suppressed because one or more lines are too long
+74
-3
@@ -15,6 +15,8 @@ from src import imgui_scopes as imscope
|
|||||||
import src.theme_nerv
|
import src.theme_nerv
|
||||||
from src.theme_nerv import DATA_GREEN
|
from src.theme_nerv import DATA_GREEN
|
||||||
from src.theme_nerv_fx import CRTFilter, AlertPulsing, StatusFlicker
|
from src.theme_nerv_fx import CRTFilter, AlertPulsing, StatusFlicker
|
||||||
|
from src.paths import get_global_themes_path
|
||||||
|
from src.theme_models import ThemeFile, load_themes_from_dir, load_themes_from_toml
|
||||||
|
|
||||||
# ------------------------------------------------------------------ palettes
|
# ------------------------------------------------------------------ palettes
|
||||||
|
|
||||||
@@ -225,12 +227,33 @@ _PALETTES: dict[str, dict[int, tuple]] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 _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():
|
||||||
|
if hasattr(imgui.Col_, col_name):
|
||||||
|
enum_val = getattr(imgui.Col_, col_name)
|
||||||
|
if isinstance(enum_val, int):
|
||||||
|
out[enum_val] = _hex(rgb)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def get_palette_names() -> list[str]:
|
def get_palette_names() -> list[str]:
|
||||||
"""Returns a list of all available palettes, including hello_imgui built-ins."""
|
"""Returns a list of all available palettes, including hello_imgui built-ins
|
||||||
|
and TOML-loaded themes."""
|
||||||
names = list(_PALETTES.keys())
|
names = list(_PALETTES.keys())
|
||||||
# Add hello_imgui themes
|
names.extend(sorted(_TOML_PALETTES.keys()))
|
||||||
hi_themes = [name for name in dir(hello_imgui.ImGuiTheme_) if not name.startswith('_') and name != 'count']
|
hi_themes = [name for name in dir(hello_imgui.ImGuiTheme_) if not name.startswith('_') and name != 'count']
|
||||||
# Filter out int methods that leaked into dir() if any
|
|
||||||
hi_themes = [n for n in hi_themes if not hasattr(int, n)]
|
hi_themes = [n for n in hi_themes if not hasattr(int, n)]
|
||||||
names.extend(sorted(hi_themes))
|
names.extend(sorted(hi_themes))
|
||||||
return names
|
return names
|
||||||
@@ -292,6 +315,7 @@ def apply(palette_name: str) -> None:
|
|||||||
_current_palette = palette_name
|
_current_palette = palette_name
|
||||||
if palette_name == 'NERV':
|
if palette_name == 'NERV':
|
||||||
src.theme_nerv.apply_nerv()
|
src.theme_nerv.apply_nerv()
|
||||||
|
apply_syntax_palette(get_syntax_palette_for_theme(palette_name))
|
||||||
return
|
return
|
||||||
|
|
||||||
# 1. Apply base colors
|
# 1. Apply base colors
|
||||||
@@ -301,6 +325,16 @@ def apply(palette_name: str) -> None:
|
|||||||
style = imgui.get_style()
|
style = imgui.get_style()
|
||||||
for col_enum, rgba in colours.items():
|
for col_enum, rgba in colours.items():
|
||||||
style.set_color_(col_enum, imgui.ImVec4(*rgba))
|
style.set_color_(col_enum, imgui.ImVec4(*rgba))
|
||||||
|
elif palette_name in _TOML_PALETTES:
|
||||||
|
colours = _TOML_COLOUR_CACHE.get(palette_name, {})
|
||||||
|
if not colours:
|
||||||
|
theme = _TOML_PALETTES[palette_name]
|
||||||
|
colours = _build_imgui_colour_dict(theme)
|
||||||
|
_TOML_COLOUR_CACHE[palette_name] = colours
|
||||||
|
imgui.style_colors_dark()
|
||||||
|
style = imgui.get_style()
|
||||||
|
for colenum, rgba in colours.items():
|
||||||
|
style.set_color_(colenum, imgui.ImVec4(*rgba))
|
||||||
elif hasattr(hello_imgui.ImGuiTheme_, palette_name):
|
elif hasattr(hello_imgui.ImGuiTheme_, palette_name):
|
||||||
theme_enum = getattr(hello_imgui.ImGuiTheme_, palette_name)
|
theme_enum = getattr(hello_imgui.ImGuiTheme_, palette_name)
|
||||||
hello_imgui.apply_theme(theme_enum)
|
hello_imgui.apply_theme(theme_enum)
|
||||||
@@ -392,6 +426,43 @@ def apply_current() -> None:
|
|||||||
apply(_current_palette)
|
apply(_current_palette)
|
||||||
set_scale(_current_scale)
|
set_scale(_current_scale)
|
||||||
|
|
||||||
|
|
||||||
|
def load_themes_from_disk() -> None:
|
||||||
|
"""Load all themes from the global themes directory. Each *.toml file
|
||||||
|
in the directory is one theme. Idempotent - safe to call repeatedly.
|
||||||
|
Broken entries are logged and skipped."""
|
||||||
|
global _TOML_PALETTES, _TOML_COLOUR_CACHE
|
||||||
|
themes_dir = get_global_themes_path()
|
||||||
|
loaded: dict[str, ThemeFile] = {}
|
||||||
|
if themes_dir.exists() and themes_dir.is_dir():
|
||||||
|
loaded.update(load_themes_from_dir(themes_dir, 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)
|
||||||
|
|
||||||
|
|
||||||
|
load_themes_from_disk()
|
||||||
|
|
||||||
def get_font_loading_params() -> tuple[str, float]:
|
def get_font_loading_params() -> tuple[str, float]:
|
||||||
"""Return (font_path, font_size) for use during hello_imgui font loading callback."""
|
"""Return (font_path, font_size) for use during hello_imgui font loading callback."""
|
||||||
return _current_font_path, _current_font_size
|
return _current_font_path, _current_font_size
|
||||||
|
|||||||
+43
-5
@@ -1,19 +1,18 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from src import theme_2 as theme
|
from src import theme_2 as theme
|
||||||
|
from src import paths as paths_mod
|
||||||
|
|
||||||
|
|
||||||
def test_theme_apply_sets_rounding_and_padding(monkeypatch):
|
def test_theme_apply_sets_rounding_and_padding(monkeypatch):
|
||||||
# Mock imgui
|
|
||||||
mock_style = MagicMock()
|
mock_style = MagicMock()
|
||||||
mock_imgui = MagicMock()
|
mock_imgui = MagicMock()
|
||||||
mock_imgui.get_style.return_value = mock_style
|
mock_imgui.get_style.return_value = mock_style
|
||||||
mock_imgui.ImVec2.side_effect = lambda x, y: (x, y)
|
mock_imgui.ImVec2.side_effect = lambda x, y: (x, y)
|
||||||
monkeypatch.setattr(theme, "imgui", mock_imgui)
|
monkeypatch.setattr(theme, "imgui", mock_imgui)
|
||||||
|
|
||||||
# Call apply with the default palette
|
|
||||||
theme.apply("ImGui Dark")
|
theme.apply("ImGui Dark")
|
||||||
|
|
||||||
# Verify subtle rounding styles
|
|
||||||
assert mock_style.window_rounding == 6.0
|
assert mock_style.window_rounding == 6.0
|
||||||
assert mock_style.child_rounding == 4.0
|
assert mock_style.child_rounding == 4.0
|
||||||
assert mock_style.frame_rounding == 4.0
|
assert mock_style.frame_rounding == 4.0
|
||||||
@@ -22,14 +21,53 @@ def test_theme_apply_sets_rounding_and_padding(monkeypatch):
|
|||||||
assert mock_style.grab_rounding == 4.0
|
assert mock_style.grab_rounding == 4.0
|
||||||
assert mock_style.tab_rounding == 4.0
|
assert mock_style.tab_rounding == 4.0
|
||||||
|
|
||||||
# Verify borders
|
|
||||||
assert mock_style.window_border_size == 1.0
|
assert mock_style.window_border_size == 1.0
|
||||||
assert mock_style.frame_border_size == 1.0
|
assert mock_style.frame_border_size == 1.0
|
||||||
assert mock_style.popup_border_size == 1.0
|
assert mock_style.popup_border_size == 1.0
|
||||||
|
|
||||||
# Verify padding/spacing
|
|
||||||
assert mock_style.window_padding == (8.0, 8.0)
|
assert mock_style.window_padding == (8.0, 8.0)
|
||||||
assert mock_style.frame_padding == (8.0, 4.0)
|
assert mock_style.frame_padding == (8.0, 4.0)
|
||||||
assert mock_style.item_spacing == (8.0, 4.0)
|
assert mock_style.item_spacing == (8.0, 4.0)
|
||||||
assert mock_style.item_inner_spacing == (4.0, 4.0)
|
assert mock_style.item_inner_spacing == (4.0, 4.0)
|
||||||
assert mock_style.scrollbar_size == 14.0
|
assert mock_style.scrollbar_size == 14.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_themes_load_from_toml(tmp_path, monkeypatch):
|
||||||
|
from src import paths as paths_mod
|
||||||
|
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_global_themes_path", 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):
|
||||||
|
from src import paths as paths_mod
|
||||||
|
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_global_themes_path", 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):
|
||||||
|
from src import paths as paths_mod
|
||||||
|
themes_dir = tmp_path / "themes"
|
||||||
|
themes_dir.mkdir()
|
||||||
|
monkeypatch.setattr(theme, "get_global_themes_path", lambda: themes_dir)
|
||||||
|
theme.load_themes_from_disk()
|
||||||
|
assert theme.get_syntax_palette_for_theme("NonExistent") == "dark"
|
||||||
|
|||||||
Reference in New Issue
Block a user