Files
manual_slop/theme.py
2026-02-21 19:01:00 -05:00

416 lines
16 KiB
Python

# theme.py
"""
Theming support for manual_slop GUI.
Palettes
--------
Each palette is a dict mapping semantic names to (R,G,B) or (R,G,B,A) tuples.
The names correspond to dpg theme colour / style constants.
Font handling
-------------
Call apply_font(path, size) to load a TTF and bind it as the global default.
Call set_scale(factor) to set the global font scale (DPI scaling).
Usage
-----
import theme
theme.apply("10x") # apply a named palette
theme.apply_font("C:/Windows/Fonts/CascadiaCode.ttf", 15)
theme.set_scale(1.25)
"""
import dearpygui.dearpygui as dpg
from pathlib import Path
# ------------------------------------------------------------------ palettes
# Colour key names match the DPG mvThemeCol_* constants (string lookup below).
# Only keys that differ from DPG defaults need to be listed.
_PALETTES: dict[str, dict] = {
"DPG Default": {}, # empty = reset to DPG built-in defaults
"10x Dark": {
# Window / frame chrome
"WindowBg": ( 34, 32, 28),
"ChildBg": ( 30, 28, 24),
"PopupBg": ( 35, 30, 20),
"Border": ( 60, 55, 50),
"BorderShadow": ( 0, 0, 0, 0),
"FrameBg": ( 45, 42, 38),
"FrameBgHovered": ( 60, 56, 50),
"FrameBgActive": ( 75, 70, 62),
# Title bars
"TitleBg": ( 40, 35, 25),
"TitleBgActive": ( 60, 45, 15),
"TitleBgCollapsed": ( 30, 27, 20),
# Menu bar
"MenuBarBg": ( 35, 30, 20),
# Scrollbar
"ScrollbarBg": ( 30, 28, 24),
"ScrollbarGrab": ( 80, 78, 72),
"ScrollbarGrabHovered": (100, 100, 92),
"ScrollbarGrabActive": (120, 118, 110),
# Check marks / radio buttons
"CheckMark": (194, 164, 74),
# Sliders
"SliderGrab": (126, 78, 14),
"SliderGrabActive": (194, 140, 30),
# Buttons
"Button": ( 83, 76, 60),
"ButtonHovered": (126, 78, 14),
"ButtonActive": (115, 90, 70),
# Headers (collapsing headers, selectables, listbox items)
"Header": ( 83, 76, 60),
"HeaderHovered": (126, 78, 14),
"HeaderActive": (115, 90, 70),
# Separator
"Separator": ( 70, 65, 55),
"SeparatorHovered": (126, 78, 14),
"SeparatorActive": (194, 164, 74),
# Resize grip
"ResizeGrip": ( 60, 55, 44),
"ResizeGripHovered": (126, 78, 14),
"ResizeGripActive": (194, 164, 74),
# Tab bar
"Tab": ( 83, 83, 70),
"TabHovered": (126, 77, 25),
"TabActive": (126, 77, 25),
"TabUnfocused": ( 60, 58, 50),
"TabUnfocusedActive": ( 90, 80, 55),
# Docking
"DockingPreview": (126, 78, 14, 180),
"DockingEmptyBg": ( 20, 20, 20),
# Text
"Text": (200, 200, 200),
"TextDisabled": (130, 130, 120),
# Input text cursor / selection
"TextSelectedBg": ( 59, 86, 142, 180),
# Plot / table lines
"TableHeaderBg": ( 55, 50, 38),
"TableBorderStrong": ( 70, 65, 55),
"TableBorderLight": ( 50, 47, 42),
"TableRowBg": ( 0, 0, 0, 0),
"TableRowBgAlt": ( 40, 38, 34, 40),
# Misc
"NavHighlight": (126, 78, 14),
"NavWindowingHighlight":(194, 164, 74, 180),
"NavWindowingDimBg": ( 20, 20, 20, 80),
"ModalWindowDimBg": ( 10, 10, 10, 100),
},
"Nord Dark": {
"WindowBg": ( 36, 41, 49),
"ChildBg": ( 30, 34, 42),
"PopupBg": ( 36, 41, 49),
"Border": ( 59, 66, 82),
"BorderShadow": ( 0, 0, 0, 0),
"FrameBg": ( 46, 52, 64),
"FrameBgHovered": ( 59, 66, 82),
"FrameBgActive": ( 67, 76, 94),
"TitleBg": ( 36, 41, 49),
"TitleBgActive": ( 59, 66, 82),
"TitleBgCollapsed": ( 30, 34, 42),
"MenuBarBg": ( 46, 52, 64),
"ScrollbarBg": ( 30, 34, 42),
"ScrollbarGrab": ( 76, 86, 106),
"ScrollbarGrabHovered": ( 94, 129, 172),
"ScrollbarGrabActive": (129, 161, 193),
"CheckMark": (136, 192, 208),
"SliderGrab": ( 94, 129, 172),
"SliderGrabActive": (129, 161, 193),
"Button": ( 59, 66, 82),
"ButtonHovered": ( 94, 129, 172),
"ButtonActive": (129, 161, 193),
"Header": ( 59, 66, 82),
"HeaderHovered": ( 94, 129, 172),
"HeaderActive": (129, 161, 193),
"Separator": ( 59, 66, 82),
"SeparatorHovered": ( 94, 129, 172),
"SeparatorActive": (136, 192, 208),
"ResizeGrip": ( 59, 66, 82),
"ResizeGripHovered": ( 94, 129, 172),
"ResizeGripActive": (136, 192, 208),
"Tab": ( 46, 52, 64),
"TabHovered": ( 94, 129, 172),
"TabActive": ( 76, 86, 106),
"TabUnfocused": ( 36, 41, 49),
"TabUnfocusedActive": ( 59, 66, 82),
"DockingPreview": ( 94, 129, 172, 180),
"DockingEmptyBg": ( 20, 22, 28),
"Text": (216, 222, 233),
"TextDisabled": (116, 128, 150),
"TextSelectedBg": ( 94, 129, 172, 180),
"TableHeaderBg": ( 59, 66, 82),
"TableBorderStrong": ( 76, 86, 106),
"TableBorderLight": ( 59, 66, 82),
"TableRowBg": ( 0, 0, 0, 0),
"TableRowBgAlt": ( 46, 52, 64, 40),
"NavHighlight": (136, 192, 208),
"ModalWindowDimBg": ( 10, 12, 16, 100),
},
"Monokai": {
"WindowBg": ( 39, 40, 34),
"ChildBg": ( 34, 35, 29),
"PopupBg": ( 39, 40, 34),
"Border": ( 60, 61, 52),
"BorderShadow": ( 0, 0, 0, 0),
"FrameBg": ( 50, 51, 44),
"FrameBgHovered": ( 65, 67, 56),
"FrameBgActive": ( 80, 82, 68),
"TitleBg": ( 39, 40, 34),
"TitleBgActive": ( 73, 72, 62),
"TitleBgCollapsed": ( 30, 31, 26),
"MenuBarBg": ( 50, 51, 44),
"ScrollbarBg": ( 34, 35, 29),
"ScrollbarGrab": ( 80, 80, 72),
"ScrollbarGrabHovered": (102, 217, 39),
"ScrollbarGrabActive": (166, 226, 46),
"CheckMark": (166, 226, 46),
"SliderGrab": (102, 217, 39),
"SliderGrabActive": (166, 226, 46),
"Button": ( 73, 72, 62),
"ButtonHovered": (249, 38, 114),
"ButtonActive": (198, 30, 92),
"Header": ( 73, 72, 62),
"HeaderHovered": (249, 38, 114),
"HeaderActive": (198, 30, 92),
"Separator": ( 60, 61, 52),
"SeparatorHovered": (249, 38, 114),
"SeparatorActive": (166, 226, 46),
"ResizeGrip": ( 73, 72, 62),
"ResizeGripHovered": (249, 38, 114),
"ResizeGripActive": (166, 226, 46),
"Tab": ( 73, 72, 62),
"TabHovered": (249, 38, 114),
"TabActive": (249, 38, 114),
"TabUnfocused": ( 50, 51, 44),
"TabUnfocusedActive": ( 90, 88, 76),
"DockingPreview": (249, 38, 114, 180),
"DockingEmptyBg": ( 20, 20, 18),
"Text": (248, 248, 242),
"TextDisabled": (117, 113, 94),
"TextSelectedBg": (249, 38, 114, 150),
"TableHeaderBg": ( 60, 61, 52),
"TableBorderStrong": ( 73, 72, 62),
"TableBorderLight": ( 55, 56, 48),
"TableRowBg": ( 0, 0, 0, 0),
"TableRowBgAlt": ( 50, 51, 44, 40),
"NavHighlight": (166, 226, 46),
"ModalWindowDimBg": ( 10, 10, 8, 100),
},
}
PALETTE_NAMES: list[str] = list(_PALETTES.keys())
# ------------------------------------------------------------------ colour key -> mvThemeCol_* mapping
# Maps our friendly name -> dpg constant name
_COL_MAP: dict[str, str] = {
"Text": "mvThemeCol_Text",
"TextDisabled": "mvThemeCol_TextDisabled",
"WindowBg": "mvThemeCol_WindowBg",
"ChildBg": "mvThemeCol_ChildBg",
"PopupBg": "mvThemeCol_PopupBg",
"Border": "mvThemeCol_Border",
"BorderShadow": "mvThemeCol_BorderShadow",
"FrameBg": "mvThemeCol_FrameBg",
"FrameBgHovered": "mvThemeCol_FrameBgHovered",
"FrameBgActive": "mvThemeCol_FrameBgActive",
"TitleBg": "mvThemeCol_TitleBg",
"TitleBgActive": "mvThemeCol_TitleBgActive",
"TitleBgCollapsed": "mvThemeCol_TitleBgCollapsed",
"MenuBarBg": "mvThemeCol_MenuBarBg",
"ScrollbarBg": "mvThemeCol_ScrollbarBg",
"ScrollbarGrab": "mvThemeCol_ScrollbarGrab",
"ScrollbarGrabHovered": "mvThemeCol_ScrollbarGrabHovered",
"ScrollbarGrabActive": "mvThemeCol_ScrollbarGrabActive",
"CheckMark": "mvThemeCol_CheckMark",
"SliderGrab": "mvThemeCol_SliderGrab",
"SliderGrabActive": "mvThemeCol_SliderGrabActive",
"Button": "mvThemeCol_Button",
"ButtonHovered": "mvThemeCol_ButtonHovered",
"ButtonActive": "mvThemeCol_ButtonActive",
"Header": "mvThemeCol_Header",
"HeaderHovered": "mvThemeCol_HeaderHovered",
"HeaderActive": "mvThemeCol_HeaderActive",
"Separator": "mvThemeCol_Separator",
"SeparatorHovered": "mvThemeCol_SeparatorHovered",
"SeparatorActive": "mvThemeCol_SeparatorActive",
"ResizeGrip": "mvThemeCol_ResizeGrip",
"ResizeGripHovered": "mvThemeCol_ResizeGripHovered",
"ResizeGripActive": "mvThemeCol_ResizeGripActive",
"Tab": "mvThemeCol_Tab",
"TabHovered": "mvThemeCol_TabHovered",
"TabActive": "mvThemeCol_TabActive",
"TabUnfocused": "mvThemeCol_TabUnfocused",
"TabUnfocusedActive": "mvThemeCol_TabUnfocusedActive",
"DockingPreview": "mvThemeCol_DockingPreview",
"DockingEmptyBg": "mvThemeCol_DockingEmptyBg",
"TextSelectedBg": "mvThemeCol_TextSelectedBg",
"TableHeaderBg": "mvThemeCol_TableHeaderBg",
"TableBorderStrong": "mvThemeCol_TableBorderStrong",
"TableBorderLight": "mvThemeCol_TableBorderLight",
"TableRowBg": "mvThemeCol_TableRowBg",
"TableRowBgAlt": "mvThemeCol_TableRowBgAlt",
"NavHighlight": "mvThemeCol_NavHighlight",
"NavWindowingHighlight": "mvThemeCol_NavWindowingHighlight",
"NavWindowingDimBg": "mvThemeCol_NavWindowingDimBg",
"ModalWindowDimBg": "mvThemeCol_ModalWindowDimBg",
}
# ------------------------------------------------------------------ state
_current_theme_tag: str | None = None
_current_font_tag: str | None = None
_font_registry_tag: str | None = None
_current_palette: str = "DPG Default"
_current_font_path: str = ""
_current_font_size: float = 14.0
_current_scale: float = 1.0
# ------------------------------------------------------------------ public API
def get_palette_names() -> list[str]:
return list(_PALETTES.keys())
def get_current_palette() -> str:
return _current_palette
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_palette_colours(name: str) -> dict:
"""Return a copy of the colour dict for the named palette."""
return dict(_PALETTES.get(name, {}))
def apply(palette_name: str, overrides: dict | None = None):
"""
Build a global DPG theme from the named palette plus optional per-colour
overrides, and bind it as the default theme.
overrides: {colour_key: (R,G,B) or (R,G,B,A)} — merged on top of palette.
"""
global _current_theme_tag, _current_palette
_current_palette = palette_name
colours = dict(_PALETTES.get(palette_name, {}))
if overrides:
colours.update(overrides)
# Delete the old theme if one exists
if _current_theme_tag is not None:
try:
dpg.delete_item(_current_theme_tag)
except Exception:
pass
_current_theme_tag = None
if palette_name == "DPG Default" and not overrides:
# Bind an empty theme to reset to DPG defaults
with dpg.theme() as t:
with dpg.theme_component(dpg.mvAll):
pass
dpg.bind_theme(t)
_current_theme_tag = t
return
with dpg.theme() as t:
with dpg.theme_component(dpg.mvAll):
for name, colour in colours.items():
const_name = _COL_MAP.get(name)
if const_name is None:
continue
const = getattr(dpg, const_name, None)
if const is None:
continue
# Ensure 4-tuple
if len(colour) == 3:
colour = (*colour, 255)
dpg.add_theme_color(const, colour)
dpg.bind_theme(t)
_current_theme_tag = t
def apply_font(font_path: str, size: float = 14.0):
"""
Load the TTF at font_path at the given point size and bind it globally.
Safe to call multiple times. Uses a single persistent font_registry; only
the font *item* tag is tracked. Passing an empty path or a missing file
resets to the DPG built-in font.
"""
global _current_font_tag, _current_font_path, _current_font_size, _font_registry_tag
_current_font_path = font_path
_current_font_size = size
if not font_path or not Path(font_path).exists():
# Reset to default built-in font
dpg.bind_font(0)
_current_font_tag = None
return
# Create the registry once
if _font_registry_tag is None or not dpg.does_item_exist(_font_registry_tag):
with dpg.font_registry() as reg:
_font_registry_tag = reg
# Delete previous custom font item only (not the registry)
if _current_font_tag is not None:
try:
dpg.delete_item(_current_font_tag)
except Exception:
pass
_current_font_tag = None
font = dpg.add_font(font_path, size, parent=_font_registry_tag)
_current_font_tag = font
dpg.bind_font(font)
def set_scale(factor: float):
"""Set the global Dear PyGui font/UI scale factor."""
global _current_scale
_current_scale = factor
dpg.set_global_font_scale(factor)
def save_to_config(config: dict):
"""Persist theme settings into the config dict under [theme]."""
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
def load_from_config(config: dict):
"""Read [theme] from config and apply everything."""
t = config.get("theme", {})
palette = t.get("palette", "DPG Default")
font_path = t.get("font_path", "")
font_size = float(t.get("font_size", 14.0))
scale = float(t.get("scale", 1.0))
apply(palette)
if font_path:
apply_font(font_path, font_size)
set_scale(scale)