ClaudeCodePanel / theme.py
smlflg's picture
Initial public upload from Projekte/ClaudeCodePanel
bbc65cd verified
Raw History Blame Contribute Delete
14.5 kB
#!/usr/bin/env python3
"""Theme detection + CSS generation for Claude Code Control Panel.
Supports COSMIC Desktop light/dark switching.
Detection: COSMIC is_dark config → GTK Settings fallback.
Palettes: Catppuccin Mocha (dark) / Catppuccin Latte (light).
"""
from pathlib import Path
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gio, GLib, Gtk
# ---------------------------------------------------------------------------
# COSMIC config path
# ---------------------------------------------------------------------------
_COSMIC_IS_DARK = Path.home() / ".config/cosmic/com.system76.CosmicTheme.Mode/v1/is_dark"
# ---------------------------------------------------------------------------
# Palettes
# ---------------------------------------------------------------------------
DARK = {
"name": "dark",
"bg": "#1e1e2e",
"mantle": "#181825",
"card": "#313244",
"border": "#45475a",
"text": "#cdd6f4",
"subtext1": "#bac2de",
"dim": "#6c7086",
"overlay": "#a6adc8",
"accent": "#89b4fa",
"mauve": "#cba6f7",
"lavender": "#b4befe",
"green": "#a6e3a1",
"yellow": "#f9e2af",
"red": "#f38ba8",
"teal": "#94e2d5",
"peach": "#fab387",
# Derived
"accent_hover_bg": "rgba(137,180,250,0.2)",
"mauve_hover_bg": "rgba(203,166,247,0.2)",
"green_hover_bg": "rgba(166,227,161,0.2)",
"yellow_hover_bg": "rgba(249,226,175,0.2)",
"resume_text": "#1e1e2e",
}
LIGHT = {
"name": "light",
"bg": "#eff1f5",
"mantle": "#e6e9ef",
"card": "#dce0e8",
"border": "#ccd0da",
"text": "#4c4f69",
"subtext1": "#5c5f77",
"dim": "#7c7f93",
"overlay": "#8c8fa1",
"accent": "#1e66f5",
"mauve": "#8839ef",
"lavender": "#7287fd",
"green": "#1a7f12",
"yellow": "#c65d06",
"red": "#d20f39",
"teal": "#127a80",
"peach": "#e64d00",
# Derived
"accent_hover_bg": "rgba(30,102,245,0.12)",
"mauve_hover_bg": "rgba(136,57,239,0.12)",
"green_hover_bg": "rgba(64,160,43,0.12)",
"yellow_hover_bg": "rgba(223,142,29,0.12)",
"resume_text": "#eff1f5",
}
# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------
def is_dark_mode() -> bool:
"""Detect dark mode: COSMIC config first, GTK Settings fallback."""
try:
content = _COSMIC_IS_DARK.read_text().strip().lower()
return content == "true"
except (OSError, ValueError):
pass
settings = Gtk.Settings.get_default()
if settings is None:
return True # safe fallback
return settings.get_property("gtk-application-prefer-dark-theme")
def get_palette() -> dict[str, str]:
"""Return the active color palette."""
return DARK if is_dark_mode() else LIGHT
def hex_to_pango_rgb(hex_color: str) -> tuple[int, int, int]:
"""Convert '#rrggbb' to Pango 16-bit (r, g, b) tuple."""
return (
int(hex_color[1:3], 16) * 257,
int(hex_color[3:5], 16) * 257,
int(hex_color[5:7], 16) * 257,
)
# ---------------------------------------------------------------------------
# CSS generation
# ---------------------------------------------------------------------------
def build_css(p: dict[str, str] | None = None) -> bytes:
"""Generate panel CSS from a palette dict. Auto-detects if p is None."""
if p is None:
p = get_palette()
return f"""
window {{
background-color: {p["bg"]};
}}
.panel-header {{
font-size: 18px;
font-weight: bold;
color: {p["text"]};
}}
.stat-value {{
font-size: 22px;
font-weight: bold;
color: {p["accent"]};
}}
.stat-label {{
font-size: 11px;
color: {p["overlay"]};
}}
.shortcut-btn {{
padding: 8px 12px;
border-radius: 8px;
background: {p["card"]};
color: {p["text"]};
border: 1px solid {p["border"]};
min-width: 90px;
}}
.shortcut-btn:hover {{
background: {p["border"]};
}}
.shortcut-service {{ border-color: {p["accent"]}; }}
.shortcut-service:hover {{ border-color: {p["accent"]}; background: {p["accent_hover_bg"]}; }}
.shortcut-docs {{ border-color: {p["yellow"]}; }}
.shortcut-docs:hover {{ border-color: {p["yellow"]}; background: {p["yellow_hover_bg"]}; }}
.shortcut-folder {{ border-color: {p["green"]}; }}
.shortcut-folder:hover {{ border-color: {p["green"]}; background: {p["green_hover_bg"]}; }}
.shortcut-config {{ border-color: {p["mauve"]}; }}
.shortcut-config:hover {{ border-color: {p["mauve"]}; background: {p["mauve_hover_bg"]}; }}
.session-row {{
padding: 8px 10px;
border-radius: 8px;
background: transparent;
border-left: 3px solid {p["mauve"]};
margin: 3px 0;
}}
.session-row:hover {{
background: {p["card"]};
}}
.session-row-working {{
border-left: 3px solid {p["yellow"]};
background: {p["yellow_hover_bg"]};
}}
.session-row-working:hover {{
background: {p["yellow_hover_bg"]};
}}
.session-row-ready {{
border-left: 3px solid {p["green"]};
background: {p["green_hover_bg"]};
}}
.session-row-ready:hover {{
background: {p["green_hover_bg"]};
}}
.session-working-badge {{
background: {p["yellow_hover_bg"]};
color: {p["yellow"]};
border-radius: 12px;
padding: 2px 8px;
font-size: 10px;
font-weight: bold;
}}
.session-ready-badge {{
background: {p["green_hover_bg"]};
color: {p["green"]};
border-radius: 12px;
padding: 2px 8px;
font-size: 10px;
font-weight: bold;
}}
.session-project {{
font-size: 13px;
font-weight: bold;
color: {p["text"]};
}}
.session-preview {{
font-size: 11px;
color: {p["subtext1"]};
}}
.session-meta {{
font-size: 10px;
color: {p["dim"]};
letter-spacing: 0.02em;
}}
.session-resume {{
padding: 4px 14px;
border-radius: 6px;
background: {p["accent"]};
color: {p["resume_text"]};
font-weight: bold;
font-size: 11px;
border: none;
}}
.session-resume:hover {{
background: {p["lavender"]};
}}
.session-stats {{
border-radius: 20px;
background: {p["card"]};
padding: 6px 14px;
color: {p["teal"]};
font-size: 11px;
letter-spacing: 0.02em;
}}
.section-frame {{
border: 1px solid {p["border"]};
border-radius: 8px;
padding: 10px;
}}
.section-title {{
font-weight: bold;
color: {p["text"]};
font-size: 13px;
}}
.monitor-value {{
font-family: monospace;
color: {p["green"]};
}}
.cost-value {{
font-family: monospace;
font-size: 20px;
font-weight: bold;
color: {p["yellow"]};
}}
.status-bar {{
background: {p["mantle"]};
padding: 6px 12px;
}}
.status-saved {{
color: {p["green"]};
font-weight: bold;
}}
.status-error {{
color: {p["red"]};
font-weight: bold;
}}
notebook header {{
background: {p["mantle"]};
}}
notebook tab {{
padding: 8px 16px;
color: {p["overlay"]};
}}
notebook tab:checked {{
color: {p["accent"]};
border-bottom: 2px solid {p["accent"]};
}}
.watcher-dot-critical {{ color: {p["red"]}; font-size: 16px; }}
.watcher-dot-warning {{ color: {p["yellow"]}; font-size: 16px; }}
.watcher-dot-info {{ color: {p["accent"]}; font-size: 16px; }}
.watcher-dot-inactive {{ color: {p["dim"]}; font-size: 16px; }}
.watcher-name {{ font-family: monospace; font-size: 11px; color: {p["subtext1"]}; }}
.phase-badge {{
font-family: monospace;
font-size: 12px;
font-weight: bold;
padding: 4px 10px;
border-radius: 12px;
color: {p["text"]};
}}
.phase-badge-exploration {{ background: rgba(137,180,250,0.25); color: {p["accent"]}; }}
.phase-badge-implementation {{ background: rgba(166,227,161,0.25); color: {p["green"]}; }}
.phase-badge-testing {{ background: rgba(249,226,175,0.25); color: {p["yellow"]}; }}
.phase-badge-debugging {{ background: rgba(243,139,168,0.25); color: {p["red"]}; }}
.signal-card {{
border-radius: 8px;
background: {p["card"]};
border: 1px solid {p["border"]};
padding: 12px;
}}
.cost-value {{
font-family: monospace;
font-size: 20px;
font-weight: bold;
color: {p["yellow"]};
}}
.mon-stat-value {{
font-family: monospace;
font-size: 16px;
font-weight: bold;
color: {p["accent"]};
}}
.stat-sublabel {{
font-size: 11px;
color: {p["subtext1"]};
}}
.sc-card {{
border-radius: 8px;
background: {p["card"]};
border: 1px solid {p["border"]};
padding: 10px;
margin: 4px 0;
}}
.sc-badge-mastered {{
background: {p["green_hover_bg"]};
color: {p["green"]};
border-radius: 12px;
padding: 2px 8px;
font-size: 10px;
font-weight: bold;
}}
.sc-badge-learning {{
background: {p["yellow_hover_bg"]};
color: {p["yellow"]};
border-radius: 12px;
padding: 2px 8px;
font-size: 10px;
font-weight: bold;
}}
.sc-badge-unused {{
background: rgba(243,139,168,0.2);
color: {p["red"]};
border-radius: 12px;
padding: 2px 8px;
font-size: 10px;
font-weight: bold;
}}
.sc-learn-card {{
border-left: 3px solid {p["red"]};
padding: 8px 12px;
border-radius: 6px;
background: {p["card"]};
margin: 3px 0;
}}
.sc-learn-card:hover {{
background: {p["border"]};
}}
/* --- Log Viewer --- */
.log-toolbar {{
background: {p["mantle"]};
border-radius: 8px;
padding: 6px 10px;
}}
.log-card {{
background: {p["card"]};
border-radius: 6px;
border: 1px solid {p["border"]};
padding: 6px 10px;
margin: 1px 0;
}}
.log-card:hover {{
background: {p["border"]};
}}
.log-card-tool {{ border-left: 3px solid {p["green"]}; }}
.log-card-llm {{ border-left: 3px solid {p["accent"]}; }}
.log-card-error {{ border-left: 3px solid {p["red"]}; }}
.log-card-hook {{ border-left: 3px solid {p["mauve"]}; }}
.log-time {{
font-family: monospace;
font-size: 10px;
color: {p["dim"]};
}}
.log-tool-name {{
font-family: monospace;
font-size: 11px;
font-weight: bold;
color: {p["text"]};
}}
.log-detail {{
font-size: 11px;
color: {p["subtext1"]};
}}
.log-cost {{
font-family: monospace;
font-size: 11px;
color: {p["yellow"]};
}}
.log-status-ok {{ color: {p["green"]}; }}
.log-status-error {{ color: {p["red"]}; }}
.log-status-neutral {{ color: {p["dim"]}; }}
.log-stats {{
font-size: 11px;
color: {p["overlay"]};
}}
/* --- Swarm Tab --- */
.swarm-toolbar {{
background: {p["mantle"]};
border-radius: 8px;
padding: 6px 10px;
}}
.swarm-btn {{
background: {p["card"]};
border-radius: 6px;
border: 1px solid {p["border"]};
padding: 4px 12px;
color: {p["text"]};
}}
.swarm-btn:hover {{
background: {p["border"]};
}}
.swarm-status-connected {{
color: {p["green"]};
font-weight: bold;
}}
.swarm-status-disconnected {{
color: {p["red"]};
font-weight: bold;
}}
.swarm-url {{
font-family: monospace;
font-size: 11px;
color: {p["dim"]};
}}
/* --- Swarm Native Tab --- */
.swarm-task-row {{
padding: 4px 8px;
border-radius: 6px;
background: transparent;
margin: 1px 0;
}}
.swarm-task-row:hover {{
background: {p["card"]};
}}
.swarm-badge-progress {{
color: {p["accent"]};
font-weight: bold;
font-size: 11px;
}}
.swarm-badge-completed {{
color: {p["green"]};
font-weight: bold;
font-size: 11px;
}}
.swarm-badge-pending {{
color: {p["dim"]};
font-weight: bold;
font-size: 11px;
}}
.swarm-message {{
padding: 2px 8px;
border-radius: 4px;
background: transparent;
margin: 1px 0;
}}
.swarm-message:hover {{
background: {p["card"]};
}}
.session-snippet {{
font-family: monospace;
font-size: 10px;
color: {p["subtext1"]};
padding: 2px 0;
}}
.session-match-badge {{
background: {p["accent_hover_bg"]};
color: {p["accent"]};
border-radius: 12px;
padding: 2px 8px;
font-size: 10px;
font-weight: bold;
}}
.session-row-search {{
border-left: 3px solid {p["accent"]};
}}
/* --- Monitor Tab Enhancements --- */
.mon-tool-bar {{
min-height: 8px;
}}
.mon-tool-bar trough {{
min-height: 8px;
background: {p["border"]};
border-radius: 4px;
}}
.mon-tool-bar progress {{
min-height: 8px;
background: {p["accent"]};
border-radius: 4px;
}}
.mon-expander {{
color: {p["subtext1"]};
font-size: 11px;
}}
.mon-expander title {{
color: {p["subtext1"]};
}}
""".encode()
# ---------------------------------------------------------------------------
# Theme watcher
# ---------------------------------------------------------------------------
_css_provider: Gtk.CssProvider | None = None
_file_monitor = None # prevent GC
def apply_theme(provider: Gtk.CssProvider | None = None) -> None:
"""(Re-)apply CSS based on current theme. Stores provider for reuse."""
global _css_provider
if provider is not None:
_css_provider = provider
if _css_provider is None:
return
_css_provider.load_from_data(build_css())
def setup_theme_watcher(provider: Gtk.CssProvider) -> None:
"""Watch for COSMIC dark/light changes and re-apply CSS automatically."""
global _css_provider, _file_monitor
_css_provider = provider
# Watch COSMIC config file (or parent dir if file doesn't exist yet)
if _COSMIC_IS_DARK.exists():
gio_file = Gio.File.new_for_path(str(_COSMIC_IS_DARK))
try:
_file_monitor = gio_file.monitor_file(Gio.FileMonitorFlags.NONE, None)
_file_monitor.connect("changed", _on_cosmic_theme_changed)
except Exception:
pass
elif _COSMIC_IS_DARK.parent.exists():
# Watch parent dir so we detect when the file is created
gio_dir = Gio.File.new_for_path(str(_COSMIC_IS_DARK.parent))
try:
_file_monitor = gio_dir.monitor_directory(Gio.FileMonitorFlags.NONE, None)
_file_monitor.connect("changed", _on_cosmic_theme_changed)
except Exception:
pass
# Also watch GTK settings as fallback
settings = Gtk.Settings.get_default()
if settings:
settings.connect("notify::gtk-application-prefer-dark-theme", _on_gtk_theme_changed)
settings.connect("notify::gtk-theme-name", _on_gtk_theme_changed)
def _on_cosmic_theme_changed(_monitor, _file, _other, event_type) -> None:
if event_type in (Gio.FileMonitorEvent.CHANGES_DONE_HINT, Gio.FileMonitorEvent.CREATED):
GLib.idle_add(apply_theme)
def _on_gtk_theme_changed(*_args) -> None:
GLib.idle_add(apply_theme)