Files
.dotfiles/darwin-breeze/build.py
T
triandClaude Opus 5.5 75b1383edf feat(darwin-breeze): unify theme colors across KDE, Helium, T3 Code and Ghostty
Move the Darwin Breeze Dark color scheme and global theme into a stow
package. build.py generates the Helium, T3 Code and Ghostty themes from
DarwinBreezeDark.colors, and apply.sh rebuilds and applies them, so the
colors are edited in one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:00:16 +07:00

251 lines
8.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate app themes from the Darwin Breeze KDE color scheme.
.local/share/color-schemes/DarwinBreezeDark.colors is the only place colors
are defined. This script derives from it:
.local/share/helium-themes/darwinbreeze/manifest.json
.config/ghostty/themes/DarwinBreezeDark
t3/darwin-breeze.json (published to T3 Code by apply.sh)
"""
import configparser
import json
from pathlib import Path
PKG = Path(__file__).resolve().parent
SCHEME = PKG / ".local/share/color-schemes/DarwinBreezeDark.colors"
HELIUM = PKG / ".local/share/helium-themes/darwinbreeze"
GHOSTTY = PKG / ".config/ghostty/themes/DarwinBreezeDark"
T3 = PKG / "t3/darwin-breeze.json"
WHITE = (255, 255, 255)
# Apple systemCyan; the KDE scheme has no cyan role.
CYAN = (100, 210, 255)
# Apple systemYellow; search highlights in T3 Code.
YELLOW = (255, 255, 0)
# Breeze draws inactive tabs this far from the window color towards the text
# color (measured in Dolphin: 40 -> 62 with text 222).
INACTIVE_TAB_MIX = 0.12
def load():
cp = configparser.ConfigParser(interpolation=None, strict=False)
cp.optionxform = str
cp.read(SCHEME)
def color(group, key):
return tuple(int(v) for v in cp[f"Colors:{group}"][key].split(","))
return color
def mix(a, b, t):
return tuple(round(x + (y - x) * t) for x, y in zip(a, b))
def hexc(c):
return "#{:02x}{:02x}{:02x}".format(*c)
def kwin_compensate(c):
"""Pre-distort a color for Chromium on KWin.
Chromium tags its surfaces as sRGB, KWin shows them with a gamma 2.2
curve, so dark grays come out lighter (40 -> 44). Encoding the target
with the inverse makes Helium land on the same values as native apps.
"""
def ch(v):
lin = (v / 255) ** 2.2
enc = 12.92 * lin if lin <= 0.0031308 else 1.055 * lin ** (1 / 2.4) - 0.055
return round(enc * 255)
return [ch(v) for v in c]
def helium(color):
bar = color("Header", "BackgroundNormal")
text = color("Window", "ForegroundNormal")
view = color("View", "BackgroundNormal")
view_text = color("View", "ForegroundNormal")
inactive_tab = mix(bar, text, INACTIVE_TAB_MIX)
# In Helium's compact layout the active tab and the toolbar segments on
# either side share the toolbar color, so toolbar and frame must match
# for the strip to read as one bar. Inactive tabs get the lighter Breeze
# tab color, the way Dolphin draws them.
colors = {
"frame": bar,
"frame_inactive": bar,
"frame_incognito": bar,
"frame_incognito_inactive": bar,
"toolbar": bar,
"background_tab": inactive_tab,
"background_tab_inactive": inactive_tab,
"tab_text": text,
"tab_background_text": text,
"tab_background_text_inactive": color("Window", "ForegroundInactive"),
"toolbar_text": text,
"toolbar_button_icon": text,
"bookmark_text": text,
"omnibox_background": view,
"omnibox_text": view_text,
"ntp_background": view,
"ntp_text": view_text,
"ntp_link": color("View", "ForegroundLink"),
}
return {k: kwin_compensate(v) for k, v in colors.items()}
def ghostty(color):
bg = color("View", "BackgroundNormal")
fg = color("View", "ForegroundNormal")
accent = color("Selection", "BackgroundNormal")
accent_text = color("Selection", "ForegroundNormal")
normal = [
mix(bg, fg, 0.15),
color("View", "ForegroundNegative"),
color("View", "ForegroundPositive"),
color("View", "ForegroundNeutral"),
color("View", "ForegroundLink"),
color("View", "ForegroundVisited"),
CYAN,
fg,
]
bright = [
mix(bg, fg, 0.45),
*(mix(c, WHITE, 0.25) for c in normal[1:7]),
mix(fg, WHITE, 0.6),
]
lines = [
f"# Generated by darwin-breeze/build.py from {SCHEME.name}",
f"background = {hexc(bg)}",
f"foreground = {hexc(fg)}",
f"cursor-color = {hexc(accent)}",
f"cursor-text = {hexc(accent_text)}",
f"selection-background = {hexc(accent)}",
f"selection-foreground = {hexc(accent_text)}",
]
lines += [f"palette = {i}={hexc(c)}" for i, c in enumerate(normal + bright)]
return "\n".join(lines) + "\n"
def t3(color):
"""Every color slot of a T3 Code theme, mapped onto a scheme role."""
window, window_text = color("Window", "BackgroundNormal"), color("Window", "ForegroundNormal")
view, view_text = color("View", "BackgroundNormal"), color("View", "ForegroundNormal")
header, header_text = color("Header", "BackgroundNormal"), color("Header", "ForegroundNormal")
button, button_text = color("Button", "BackgroundNormal"), color("Button", "ForegroundNormal")
accent, accent_text = color("Selection", "BackgroundNormal"), color("Selection", "ForegroundNormal")
unfocused = color("Selection][Inactive", "BackgroundNormal")
unfocused_text = color("Selection][Inactive", "ForegroundNormal")
muted = color("View", "ForegroundInactive")
negative = color("View", "ForegroundNegative")
neutral = color("View", "ForegroundNeutral")
link = color("View", "ForegroundLink")
window_line = mix(window, window_text, 0.115)
colors = {
"canvas": view,
"chrome": header,
"toolbar": header,
"toolbarForeground": header_text,
"toolbarBorder": mix(header, header_text, 0.115),
"toolbarControl": button,
"toolbarControlForeground": button_text,
"toolbarControlHover": color("Button", "BackgroundAlternate"),
"surface": view,
"surfaceRaised": window,
"surfaceOverlay": window,
"text": view_text,
"textMuted": muted,
"border": mix(view, view_text, 0.115),
"input": mix(view, view_text, 0.3),
"focus": mix(view, accent, 0.5),
"accent": accent,
"accentForeground": accent_text,
"secondary": button,
"secondaryForeground": button_text,
"muted": window,
"mutedForeground": color("Window", "ForegroundInactive"),
"placeholder": muted,
"secondaryLabel": muted,
"iconMuted": muted,
"error": negative,
"errorForeground": negative,
"errorSurface": mix(view, negative, 0.1),
"warning": neutral,
"warningForeground": neutral,
"warningSurface": mix(view, neutral, 0.1),
"update": link,
"updateForeground": link,
"updateSurface": mix(view, link, 0.12),
"accentSurface": unfocused,
"accentSurfaceForeground": unfocused_text,
"messageSurface": window,
"messageForeground": window_text,
"messageAction": accent,
"messageActionForeground": accent_text,
"messageActionHover": color("Selection", "BackgroundAlternate"),
"codeBackground": window,
"codeForeground": WHITE,
"searchMatchBackground": mix(view, YELLOW, 0.25),
"searchMatchForeground": view_text,
"searchMatchActiveBackground": YELLOW,
"searchMatchActiveForeground": (0, 0, 0),
"sidebar": window,
"sidebarForeground": window_text,
"sidebarMutedForeground": color("Window", "ForegroundInactive"),
"sidebarControlSurface": button,
"sidebarRowHover": window_line,
"sidebarRowActive": unfocused,
"sidebarRowSelected": unfocused,
"sidebarBorder": window_line,
"terminalBackground": view,
"terminalForeground": WHITE,
"terminalCursor": WHITE,
"terminalSelection": mix(view, accent, 0.4),
"terminalScrollbar": mix(view, view_text, 0.3),
"terminalScrollbarHover": muted,
}
# T3 Code runs on Chromium too, so it needs the same KWin compensation.
return {k: hexc(kwin_compensate(v)) for k, v in colors.items()}
def write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists() and path.read_text() == text:
return False
path.write_text(text)
print(f"wrote {path.relative_to(PKG)}")
return True
def main():
color = load()
manifest = {
"manifest_version": 3,
"name": "Darwin Breeze",
"version": "1.0",
"description": f"Generated by darwin-breeze/build.py from {SCHEME.name}",
"theme": {"colors": helium(color)},
}
if write(HELIUM / "manifest.json", json.dumps(manifest, indent=2) + "\n"):
# Helium would keep showing the old colors from its cache.
(HELIUM / "Cached Theme.pak").unlink(missing_ok=True)
write(GHOSTTY, ghostty(color))
theme = {"version": 1, "name": "Darwin Breeze", "appearance": "dark", "colors": t3(color)}
write(T3, json.dumps(theme, indent=2) + "\n")
if __name__ == "__main__":
main()