402 lines
14 KiB
Python
402 lines
14 KiB
Python
# theme_2.py
|
|
"""
|
|
Theming support for manual_slop GUI — imgui-bundle port.
|
|
|
|
Replaces theme.py (DearPyGui-specific) with imgui-bundle equivalents.
|
|
Palettes are applied via imgui.get_style().set_color_() calls or hello_imgui.apply_theme().
|
|
Font loading uses hello_imgui.load_font().
|
|
Scale uses imgui.get_style().font_scale_main.
|
|
"""
|
|
|
|
from imgui_bundle import imgui, hello_imgui
|
|
from typing import Any, Optional
|
|
import typing
|
|
from contextlib import nullcontext
|
|
from src import imgui_scopes as imscope
|
|
import src.theme_nerv
|
|
from src.theme_nerv import DATA_GREEN
|
|
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
|
|
|
|
# Each palette maps imgui color enum values to (R, G, B, A) floats [0..1].
|
|
# Only keys that differ from the ImGui dark defaults need to be listed.
|
|
# ------------------------------------------------------------------ internal helpers
|
|
|
|
def _c(r: int, g: int, b: int, a: int = 255) -> tuple[float, float, float, float]:
|
|
"""Convert 0-255 RGBA to 0.0-1.0 floats."""
|
|
return (r / 255.0, g / 255.0, b / 255.0, a / 255.0)
|
|
|
|
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)
|
|
|
|
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 _build_semantic_colour_dict(theme: ThemeFile) -> dict[str, tuple[float, float, float, float]]:
|
|
out: dict[str, tuple[float, float, float, float]] = {}
|
|
palette_dict = theme.palette.to_dict()
|
|
from imgui_bundle import imgui
|
|
for col_name, rgb in palette_dict.items():
|
|
if not hasattr(imgui.Col_, col_name):
|
|
out[col_name] = _hex(rgb)
|
|
return out
|
|
|
|
# ------------------------------------------------------------------ state
|
|
|
|
_BUILTIN_PALETTES: dict[str, dict[int, tuple]] = {
|
|
"ImGui Dark": {},
|
|
"NERV": {},
|
|
}
|
|
|
|
_TOML_PALETTES: dict[str, ThemeFile] = {}
|
|
_TOML_COLOUR_CACHE: dict[str, dict[int, tuple[float, float, float, float]]] = {}
|
|
_TOML_SEMANTIC_CACHE: dict[str, dict[str, tuple[float, float, float, float]]] = {}
|
|
|
|
_current_palette: str = "10x Dark"
|
|
_current_font_path: str = "fonts/Inter-Regular.ttf"
|
|
_current_font_size: float = 16.0
|
|
_current_scale: float = 1.0
|
|
_transparency: float = 1.0
|
|
_child_transparency: float = 1.0
|
|
|
|
# Per-palette tone mapping: { "Palette Name": value }
|
|
_brightness: dict[str, float] = {}
|
|
_contrast: dict[str, float] = {}
|
|
_gamma: dict[str, float] = {}
|
|
|
|
def _get_tm(d: dict[str, float], palette: str, default: float) -> float:
|
|
return d.get(palette, default)
|
|
|
|
def get_brightness(palette: str) -> float: return _get_tm(_brightness, palette, 1.0)
|
|
def get_contrast(palette: str) -> float: return _get_tm(_contrast, palette, 1.0)
|
|
def get_gamma(palette: str) -> float: return _get_tm(_gamma, palette, 1.0)
|
|
|
|
def set_brightness(palette: str, val: float) -> None: _brightness[palette] = val; apply(palette)
|
|
def set_contrast(palette: str, val: float) -> None: _contrast[palette] = val; apply(palette)
|
|
def set_gamma(palette: str, val: float) -> None: _gamma[palette] = val; apply(palette)
|
|
|
|
def reset_tone_mapping(palette: str) -> None:
|
|
for d in [_brightness, _contrast, _gamma]:
|
|
if palette in d: del d[palette]
|
|
apply(palette)
|
|
|
|
def _tone_map(rgb: tuple[float, float, float, float], palette: str) -> tuple[float, float, float, float]:
|
|
b, c, g = get_brightness(palette), get_contrast(palette), get_gamma(palette)
|
|
r, g_val, bl, a = rgb
|
|
# 1. Brightness
|
|
r *= b; g_val *= b; bl *= b
|
|
# 2. Contrast
|
|
r = (r - 0.5) * c + 0.5; g_val = (g_val - 0.5) * c + 0.5; bl = (bl - 0.5) * c + 0.5
|
|
# 3. Gamma
|
|
r = max(0, r)**(1.0/g); g_val = max(0, g_val)**(1.0/g); bl = max(0, bl)**(1.0/g)
|
|
return (max(0.0, min(1.0, r)), max(0.0, min(1.0, g_val)), max(0.0, min(1.0, bl)), a)
|
|
|
|
_crt_filter = CRTFilter()
|
|
_alert_pulsing = AlertPulsing()
|
|
_status_flicker = StatusFlicker()
|
|
|
|
# ------------------------------------------------------------------ public API
|
|
|
|
def get_current_palette() -> str:
|
|
return _current_palette
|
|
|
|
def is_nerv_active() -> bool:
|
|
return _current_palette == "NERV"
|
|
|
|
def get_current_font_path() -> str:
|
|
return _current_font_path
|
|
|
|
def get_current_font_size() -> float:
|
|
return _current_font_size
|
|
|
|
def get_current_scale() -> float:
|
|
return _current_scale
|
|
|
|
def get_transparency() -> float:
|
|
return _transparency
|
|
|
|
def set_transparency(val: float) -> None:
|
|
global _transparency
|
|
_transparency = val
|
|
apply(_current_palette)
|
|
|
|
def get_child_transparency() -> float:
|
|
return _child_transparency
|
|
|
|
def set_child_transparency(val: float) -> None:
|
|
global _child_transparency
|
|
_child_transparency = val
|
|
apply(_current_palette)
|
|
|
|
def get_table_color(is_alt: bool = False, alpha: float = 1.0) -> imgui.ImVec4:
|
|
"""Returns a tone-mapped table row background color."""
|
|
key = "table_row_bg_alt" if is_alt else "table_row_bg"
|
|
return get_color(key, alpha=alpha)
|
|
|
|
def get_color(name: str, alpha: float = 1.0) -> imgui.ImVec4:
|
|
"""Return a tone-mapped semantic color from the current palette."""
|
|
palette_name = _current_palette
|
|
|
|
# 1. Check TOML-loaded themes first
|
|
if palette_name in _TOML_PALETTES:
|
|
theme_file = _TOML_PALETTES[palette_name]
|
|
|
|
# A. Check semantic cache (for non-ImGui keys)
|
|
if palette_name in _TOML_SEMANTIC_CACHE:
|
|
d = _TOML_SEMANTIC_CACHE[palette_name]
|
|
if name in d:
|
|
rgba = list(d[name])
|
|
rgba[3] = alpha
|
|
return imgui.ImVec4(*_tone_map(tuple(rgba), palette_name))
|
|
|
|
# B. Check ThemePalette dataclass fields (for basics like 'text')
|
|
if hasattr(theme_file.palette, name):
|
|
rgb = getattr(theme_file.palette, name)
|
|
if isinstance(rgb, tuple) and len(rgb) == 3:
|
|
return imgui.ImVec4(*_tone_map((rgb[0]/255.0, rgb[1]/255.0, rgb[2]/255.0, alpha), palette_name))
|
|
|
|
# 2. Hardcoded fallbacks if not in TOML (matches ThemePalette defaults)
|
|
fallbacks = {
|
|
"text": (200, 200, 200),
|
|
"text_disabled": (130, 130, 130),
|
|
"status_success": (80, 255, 80),
|
|
"status_warning": (255, 152, 48),
|
|
"status_error": (255, 72, 64),
|
|
"status_info": (0, 255, 255),
|
|
"bubble_user": (30, 45, 75),
|
|
"bubble_ai": (35, 65, 45),
|
|
"bubble_vendor": (65, 55, 30),
|
|
"bubble_system": (25, 25, 25),
|
|
"slice_manual": (255, 165, 0),
|
|
"slice_auto": (0, 255, 0),
|
|
"slice_selection": (100, 100, 255),
|
|
"diff_added": (51, 230, 51),
|
|
"diff_removed": (230, 51, 51),
|
|
"diff_header": (77, 178, 255),
|
|
"table_header_text": (255, 255, 255),
|
|
"table_row_bg": (0, 0, 0),
|
|
"table_row_bg_alt": (10, 10, 10),
|
|
}
|
|
if name in fallbacks:
|
|
rgb = fallbacks[name]
|
|
return imgui.ImVec4(*_tone_map((rgb[0]/255.0, rgb[1]/255.0, rgb[2]/255.0, alpha), palette_name))
|
|
|
|
# 3. Last resort
|
|
return imgui.ImVec4(1, 1, 1, alpha)
|
|
|
|
def get_role_tint(role: str) -> imgui.ImVec4:
|
|
"""Returns a subtle background tint color based on the message role."""
|
|
mapping = {
|
|
"User": "bubble_user",
|
|
"AI": "bubble_ai",
|
|
"Vendor API": "bubble_vendor",
|
|
}
|
|
return get_color(mapping.get(role, "bubble_system"), alpha=0.6)
|
|
|
|
def apply(palette_name: str) -> None:
|
|
"""Apply a named palette by setting all ImGui style colors and professional styling."""
|
|
global _current_palette
|
|
_current_palette = palette_name
|
|
if palette_name == 'NERV':
|
|
from src import theme_nerv
|
|
theme_nerv.apply_nerv()
|
|
apply_syntax_palette(get_syntax_palette_for_theme(palette_name))
|
|
return
|
|
|
|
# 1. Apply base colors
|
|
if palette_name in _BUILTIN_PALETTES:
|
|
colours = _BUILTIN_PALETTES[palette_name]
|
|
imgui.style_colors_dark()
|
|
style = imgui.get_style()
|
|
for col_enum, rgba in colours.items():
|
|
style.set_color_(col_enum, imgui.ImVec4(*_tone_map(rgba, palette_name)))
|
|
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(*_tone_map(rgba, palette_name)))
|
|
elif hasattr(hello_imgui.ImGuiTheme_, palette_name):
|
|
theme_enum = getattr(hello_imgui.ImGuiTheme_, palette_name)
|
|
hello_imgui.apply_theme(theme_enum)
|
|
else:
|
|
imgui.style_colors_dark()
|
|
|
|
# 2. Professional tweaks
|
|
style = imgui.get_style()
|
|
style.window_rounding = 6.0
|
|
style.child_rounding = 4.0
|
|
style.frame_rounding = 4.0
|
|
style.popup_rounding = 4.0
|
|
style.scrollbar_rounding = 12.0
|
|
style.grab_rounding = 4.0
|
|
style.tab_rounding = 4.0
|
|
style.window_border_size = 1.0
|
|
style.frame_border_size = 1.0
|
|
style.popup_border_size = 1.0
|
|
|
|
win_bg = style.color_(imgui.Col_.window_bg)
|
|
win_bg.w = _transparency
|
|
style.set_color_(imgui.Col_.window_bg, win_bg)
|
|
|
|
for col_idx in [imgui.Col_.child_bg, imgui.Col_.frame_bg, imgui.Col_.popup_bg]:
|
|
c = style.color_(col_idx)
|
|
c.w = _child_transparency
|
|
style.set_color_(col_idx, c)
|
|
|
|
style.window_padding = imgui.ImVec2(8.0, 8.0)
|
|
style.frame_padding = imgui.ImVec2(8.0, 4.0)
|
|
style.item_spacing = imgui.ImVec2(8.0, 4.0)
|
|
style.item_inner_spacing = imgui.ImVec2(4.0, 4.0)
|
|
style.scrollbar_size = 14.0
|
|
style.anti_aliased_lines = True
|
|
style.anti_aliased_fill = True
|
|
style.anti_aliased_lines_use_tex = True
|
|
|
|
# 3. Sync syntax palette and clear markdown render cache
|
|
apply_syntax_palette(get_syntax_palette_for_theme(palette_name))
|
|
try:
|
|
import src.markdown_helper
|
|
src.markdown_helper.get_renderer().clear_cache()
|
|
except (ImportError, AttributeError):
|
|
pass
|
|
|
|
def apply_current() -> None:
|
|
"""Apply the loaded palette and scale."""
|
|
apply(_current_palette)
|
|
set_scale(_current_scale)
|
|
|
|
def get_palette_names() -> list[str]:
|
|
"""Returns a list of all available palettes."""
|
|
names = list(_BUILTIN_PALETTES.keys())
|
|
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 = [n for n in hi_themes if not hasattr(int, n)]
|
|
names.extend(sorted(hi_themes))
|
|
return names
|
|
|
|
# ------------------------------------------------------------------ persistence
|
|
|
|
def save_to_config(config: dict) -> None:
|
|
"""Persist theme settings into the config dict."""
|
|
config.setdefault("theme", {})
|
|
config["theme"]["palette"] = _current_palette
|
|
config["theme"]["font_path"] = _current_font_path
|
|
config["theme"]["font_size"] = _current_font_size
|
|
config["theme"]["scale"] = _current_scale
|
|
config["theme"]["transparency"] = _transparency
|
|
config["theme"]["child_transparency"] = _child_transparency
|
|
tm = {}
|
|
for p in set(list(_brightness.keys()) + list(_contrast.keys()) + list(_gamma.keys())):
|
|
tm[p] = {
|
|
"brightness": _brightness.get(p, 1.0),
|
|
"contrast": _contrast.get(p, 1.0),
|
|
"gamma": _gamma.get(p, 1.0)
|
|
}
|
|
config["theme"]["tone_mapping"] = tm
|
|
|
|
def load_from_config(config: dict) -> None:
|
|
"""Read [theme] from config."""
|
|
import sys
|
|
global _current_font_path, _current_font_size, _current_scale, _current_palette, _transparency, _child_transparency, _brightness, _contrast, _gamma
|
|
t = config.get("theme", {})
|
|
_current_palette = t.get("palette", "10x Dark")
|
|
if _current_palette in ("", "DPG Default"):
|
|
_current_palette = "10x Dark"
|
|
_current_font_path = t.get("font_path", "fonts/Inter-Regular.ttf")
|
|
_current_font_size = float(t.get("font_size", 16.0))
|
|
_current_scale = float(t.get("scale", 1.0))
|
|
_transparency = float(t.get("transparency", 1.0))
|
|
_child_transparency = float(t.get("child_transparency", 1.0))
|
|
tm = t.get("tone_mapping", {})
|
|
_brightness = {p: float(v.get("brightness", 1.0)) for p, v in tm.items()}
|
|
_contrast = {p: float(v.get("contrast", 1.0)) for p, v in tm.items()}
|
|
_gamma = {p: float(v.get("gamma", 1.0)) for p, v in tm.items()}
|
|
|
|
# ------------------------------------------------------------------ external themes
|
|
|
|
def load_themes_from_disk() -> None:
|
|
"""Load all themes from the global themes directory."""
|
|
global _TOML_PALETTES, _TOML_COLOUR_CACHE, _TOML_SEMANTIC_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()}
|
|
_TOML_SEMANTIC_CACHE = {name: _build_semantic_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)
|
|
|
|
# ------------------------------------------------------------------ font & scaling
|
|
|
|
def set_scale(factor: float) -> None:
|
|
global _current_scale
|
|
_current_scale = factor
|
|
imgui.get_style().font_scale_main = factor
|
|
|
|
def get_font_loading_params() -> tuple[str, float]:
|
|
return _current_font_path, _current_font_size
|
|
|
|
def get_tweaked_theme() -> hello_imgui.ImGuiTweakedTheme:
|
|
tt = hello_imgui.ImGuiTweakedTheme()
|
|
if hasattr(hello_imgui.ImGuiTheme_, _current_palette):
|
|
tt.theme = getattr(hello_imgui.ImGuiTheme_, _current_palette)
|
|
else:
|
|
tt.theme = hello_imgui.ImGuiTheme_.imgui_colors_dark
|
|
tt.tweaks.rounding = 6.0
|
|
return tt
|
|
|
|
# ------------------------------------------------------------------ specialized colors
|
|
|
|
def ai_text_color() -> imgui.ImVec4:
|
|
if is_nerv_active():
|
|
return imgui.ImVec4(*DATA_GREEN)
|
|
return imgui.get_style().color_(imgui.Col_.text)
|
|
|
|
def ai_text_style():
|
|
return imscope.style_color(imgui.Col_.text, ai_text_color())
|
|
|
|
"""Returns a subtle background tint color based on the message role."""
|
|
def render_post_fx(width: float, height: float, ai_status: str, crt_enabled: bool) -> None:
|
|
"""Updates and renders the alert and CRT filters."""
|
|
_alert_pulsing.update(ai_status)
|
|
_alert_pulsing.render(width, height)
|
|
_crt_filter.enabled = crt_enabled
|
|
_crt_filter.render(width, height)
|
|
|
|
# ------------------------------------------------------------------ init
|
|
load_themes_from_disk()
|