#!/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: t3/darwin-breeze.json (published to T3 Code by apply.sh) telegram/DarwinBreezeDark.tdesktop-theme (not committed; imported into Telegram by hand) vscode/ (packed into vscode/darwin-breeze.vsix, installed by apply.sh) """ import colorsys import configparser import io import json import re import struct import zipfile import zlib from collections.abc import Callable, Mapping, Sequence from pathlib import Path HERE = Path(__file__).resolve().parent PKG = HERE SCHEME = PKG / ".local/share/color-schemes/DarwinBreezeDark.colors" T3 = HERE / "t3/darwin-breeze.json" # Telegram Desktop's built-in night theme, used as the base for every key the # scheme has no role for: the colors file from telegramdesktop/tdesktop at # 837b256, Telegram/Resources/night.tdesktop-theme. TELEGRAM_BASE = HERE / "telegram/night.tdesktop-palette" TELEGRAM = HERE / "telegram/DarwinBreezeDark.tdesktop-theme" VSCODE = HERE / "vscode" VSCODE_THEME = "themes/DarwinBreezeDark-color-theme.json" # VS Code's default dark theme, Dark 2026 (which includes Dark Modern, Dark+ # and Visual Studio Dark), from Code - OSS 1.138 extensions/theme-defaults. # The workbench colors start from it. VSCODE_UI = VSCODE / "base/2026-dark.json" # Pierre Dark, the syntax theme T3 Code highlights code with (from T3 Code # 0.1.4). The editor uses its token colors as they are. VSCODE_SYNTAX = VSCODE / "base/pierre-dark.json" # An RGB or RGBA color, 0-255 per channel. type Color = tuple[int, ...] # Looks up a color in the scheme by group and key. type ColorFn = Callable[[str, str], Color] 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) def load() -> ColorFn: cp = configparser.ConfigParser(interpolation=None, strict=False) cp.optionxform = str # type: ignore[method-assign,assignment] cp.read(SCHEME) def color(group: str, key: str) -> Color: return tuple(int(v) for v in cp[f"Colors:{group}"][key].split(",")) return color def mix(a: Sequence[int], b: Sequence[int], t: float) -> Color: return tuple(round(x + (y - x) * t) for x, y in zip(a, b)) def hexc(c: Sequence[int]) -> str: """#rrggbb, or #rrggbbaa when c carries an alpha.""" return "#" + "".join(f"{v:02x}" for v in c) def kwin_compensate(c: Sequence[int]) -> list[int]: """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 Chromium apps land on the same values as native apps. """ def ch(v: int) -> int: lin: float = (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 ansi(color: ColorFn) -> list[Color]: """The 16 colors for the VS Code terminal.""" bg = color("View", "BackgroundNormal") fg = color("View", "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), ] return normal + bright def t3(color: ColorFn) -> dict[str, str]: """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 telegram(color: ColorFn) -> dict[str, Color]: """Telegram keys mapped onto a scheme role; the rest come from night.""" 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") title_inactive = color("Header][Inactive", "BackgroundNormal") title_inactive_text = color("Header][Inactive", "ForegroundNormal") tooltip, tooltip_text = color("Tooltip", "BackgroundNormal"), color("Tooltip", "ForegroundNormal") accent, accent_text = color("Selection", "BackgroundNormal"), color("Selection", "ForegroundNormal") accent_hover = color("Selection", "BackgroundAlternate") muted = color("Window", "ForegroundInactive") negative = color("Window", "ForegroundNegative") positive = color("Window", "ForegroundPositive") link = color("Window", "ForegroundLink") hover = mix(window, window_text, 0.115) ripple = mix(window, window_text, 0.18) control = mix(window, window_text, 0.3) msg_in = mix(view, view_text, 0.1) msg_out = mix(view, accent, 0.5) return { "windowBg": window, "windowFg": window_text, "windowBgOver": hover, "windowBgRipple": ripple, "windowFgOver": window_text, "windowSubTextFg": muted, "windowSubTextFgOver": mix(muted, window_text, 0.2), "windowBoldFg": window_text, "windowBoldFgOver": window_text, "windowBgActive": accent, "windowFgActive": accent_text, "windowActiveTextFg": link, "windowShadowFgFallback": window, "activeButtonBg": accent, "activeButtonBgOver": accent_hover, "activeButtonBgRipple": mix(accent, WHITE, 0.2), "activeButtonFg": accent_text, "activeButtonFgOver": accent_text, "activeButtonSecondaryFg": mix(accent, accent_text, 0.75), "activeLineFg": accent, "activeLineFgError": negative, "lightButtonBg": window, "lightButtonBgOver": mix(window, accent, 0.12), "lightButtonBgRipple": mix(window, accent, 0.2), "lightButtonFg": link, "attentionButtonFg": negative, "attentionButtonFgOver": negative, "attentionButtonBgOver": mix(window, negative, 0.12), "attentionButtonBgRipple": mix(window, negative, 0.2), "outlineButtonOutlineFg": accent, "menuBg": window, "menuBgOver": hover, "menuBgRipple": ripple, "menuIconFg": muted, "menuIconFgOver": mix(muted, window_text, 0.3), "menuSubmenuArrowFg": muted, "menuFgDisabled": control, "menuSeparatorFg": hover, "checkboxFg": muted, "sliderBgInactive": control, "sliderBgActive": accent, "inputBorderFg": control, # Breeze draws text fields in the view color with a blue focus ring. "filterInputActiveBg": view, "filterInputInactiveBg": view, "filterInputBorderFg": accent, "tooltipBg": tooltip, "tooltipFg": tooltip_text, "tooltipBorderFg": mix(tooltip, tooltip_text, 0.115), "titleBg": title_inactive, "titleBgActive": header, "titleFg": title_inactive_text, "titleFgActive": header_text, "topBarBg": header, "boxBg": window, "boxTitleFg": window_text, "boxTextFgGood": positive, "boxTextFgError": negative, # Folder strip in the view color, so the selected folder merges into # the chat list next to it. "sideBarBg": view, "sideBarBgActive": window, "sideBarBgRipple": mix(view, window_text, 0.1), "sideBarTextFg": muted, "sideBarTextFgActive": link, "sideBarIconFg": muted, "sideBarIconFgActive": link, "sideBarBadgeBg": accent, "sideBarBadgeBgMuted": control, "sideBarBadgeFg": accent_text, "dialogsBg": window, "dialogsBgOver": hover, "dialogsRippleBg": ripple, "dialogsBgActive": accent, "dialogsRippleBgActive": accent_hover, "dialogsNameFg": window_text, "dialogsTextFg": muted, "dialogsTextFgOver": muted, "dialogsTextFgService": link, "dialogsTextFgServiceOver": link, "dialogsDateFg": muted, "dialogsDateFgOver": muted, "dialogsDraftFg": negative, "dialogsOnlineBadgeFg": positive, "dialogsVerifiedIconBg": link, "dialogsVerifiedIconFg": window, "dialogsUnreadBg": accent, "dialogsUnreadBgOver": accent, "dialogsUnreadFg": accent_text, "dialogsUnreadBgMuted": control, "dialogsUnreadBgMutedOver": mix(window, window_text, 0.35), "dialogsUnreadBgActive": accent_text, "dialogsUnreadFgActive": accent, "dialogsUnreadBgMutedActive": mix(accent, accent_text, 0.55), "historyComposeAreaBg": window, "historyComposeAreaFg": window_text, "historyComposeButtonBgOver": hover, "historyComposeButtonBgRipple": ripple, "historyTextInFg": view_text, "historyTextOutFg": view_text, "historyLinkInFg": link, "msgInBg": msg_in, "msgInBgSelected": mix(msg_in, accent, 0.3), "msgOutBg": msg_out, "msgOutBgSelected": mix(msg_out, accent, 0.3), "msgInDateFg": muted, "msgOutDateFg": mix(msg_out, WHITE, 0.55), "msgSelectOverlay": (*accent, 0x4C), "msgStickerOverlay": (*accent, 0x7F), } def luma(c: Sequence[int]) -> float: return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] def interp(points: Sequence[tuple[float, float]], x: float) -> float: for (x0, y0), (x1, y1) in zip(points, points[1:]): if x <= x1: return y0 + (y1 - y0) * (x - x0) / (x1 - x0) return points[-1][1] def parse_hex(v: str) -> Color: h = v[1:] if len(h) in (3, 4): h = "".join(ch * 2 for ch in h) return tuple(int(h[i:i + 2], 16) for i in range(0, len(h), 2)) def telegram_theme(color: ColorFn) -> str: """The night theme, recolored onto the scheme. Night tints everything blue. Its tinted grays become scheme grays of matching brightness, its blues take the accent's hue and saturation, and other hues (reds, greens, name colors) stay. telegram() then pins every key that has a scheme role. """ base = TELEGRAM_BASE.read_text() line_re = re.compile(r"^(\w+):\s*(#[0-9a-fA-F]+)\s*;") night = {m[1]: parse_hex(m[2]) for m in map(line_re.match, base.splitlines()) if m} window, muted = color("Window", "BackgroundNormal"), color("Window", "ForegroundInactive") grays = sorted({ (0, 0), (luma(night["windowBg"]), window[0]), (luma(night["windowSubTextFg"]), muted[0]), (255, 255), }) accent_h, _, accent_s = colorsys.rgb_to_hls(*(v / 255 for v in color("Selection", "BackgroundNormal"))) def recolor(c: Color) -> Color: rgb, alpha = c[:3], c[3:] chroma = max(rgb) - min(rgb) h, l, _ = colorsys.rgb_to_hls(*(v / 255 for v in rgb)) if chroma <= 4 or not 195 <= h * 360 <= 225: return c if chroma <= 55: g = round(interp(grays, luma(rgb))) return (g, g, g, *alpha) return (*(round(v * 255) for v in colorsys.hls_to_rgb(accent_h, l, accent_s)), *alpha) pinned = telegram(color) seen: set[str] = set() out = [f"// Generated by kde/theme/build.py from {SCHEME.name}"] for line in base.splitlines(): m = line_re.match(line) if m: key = m[1] seen.add(key) c = pinned[key] if key in pinned else recolor(night[key]) line = f"{key}: {hexc(c)};" + line[m.end():] elif re.match(r"^(\w+):", line) and line.split(":")[0] in pinned: key = line.split(":")[0] seen.add(key) line = f"{key}: {hexc(pinned[key])};" out.append(line) out += [f"{k}: {hexc(c)};" for k, c in pinned.items() if k not in seen] return "\n".join(out) + "\n" def vscode_base_colors() -> dict[str, Color]: """Dark 2026's colors with its include chain resolved.""" chain: list[dict[str, str]] = [] path: Path | None = VSCODE_UI while path: theme = json.loads(path.read_text()) chain.append(theme.get("colors", {})) path = path.parent / theme["include"] if "include" in theme else None colors: dict[str, str] = {} for layer in reversed(chain): colors.update(layer) return {k: parse_hex(v) for k, v in colors.items()} def vscode(color: ColorFn) -> dict[str, str]: """Workbench colors: Dark 2026, recolored onto the scheme. Dark 2026 lays out its grays the way the scheme does: the chrome lighter than the editor, widgets lighter still. Each of those grays becomes the scheme gray in the same place, and the grays in between are interpolated. Each hue takes the hue of the matching scheme color but keeps its own lightness and saturation, so a muted dark red background stays one. pins then sets the keys that have an exact scheme role, which is where the full macOS blue shows. """ window, window_text = color("Window", "BackgroundNormal"), color("Window", "ForegroundNormal") view, view_text = color("View", "BackgroundNormal"), color("View", "ForegroundNormal") accent, accent_text = color("Selection", "BackgroundNormal"), color("Selection", "ForegroundNormal") muted = color("Window", "ForegroundInactive") negative = color("View", "ForegroundNegative") neutral = color("View", "ForegroundNeutral") positive = color("View", "ForegroundPositive") link = color("View", "ForegroundLink") base = vscode_base_colors() grays = sorted({ (0, 0), (luma(base["editor.background"]), view[0]), (luma(base["sideBar.background"]), window[0]), (luma(base["editorWidget.background"]), color("Window", "BackgroundAlternate")[0]), (luma(base["sideBar.border"]), mix(window, window_text, 0.115)[0]), (luma(base["descriptionForeground"]), muted[0]), (luma(base["foreground"]), window_text[0]), (255, 255), }) assert all(a[1] <= b[1] for a, b in zip(grays, grays[1:])), grays def hls(c: Sequence[int]) -> tuple[float, float, float]: return colorsys.rgb_to_hls(*(v / 255 for v in c)) # (hue range in degrees, scheme color whose hue it takes) families: list[tuple[tuple[int, int], Color]] = [ ((0, 15), negative), ((15, 70), neutral), ((70, 170), positive), ((170, 250), accent), ((250, 330), color("View", "ForegroundVisited")), ((330, 360), negative), ] def recolor(c: Color) -> Color: rgb, alpha = c[:3], c[3:] if max(rgb) - min(rgb) <= 10: g = round(interp(grays, luma(rgb))) return (g, g, g, *alpha) h, l, sat = hls(rgb) target = next(t for (lo, hi), t in families if lo <= h * 360 < hi) return (*(round(v * 255) for v in colorsys.hls_to_rgb(hls(target)[0], l, sat)), *alpha) pins: dict[str, Color] = { "editor.background": view, "editor.foreground": view_text, "titleBar.inactiveBackground": color("Header][Inactive", "BackgroundNormal"), "titleBar.inactiveForeground": color("Header][Inactive", "ForegroundNormal"), "focusBorder": (*accent, 0xB3), "quickInputList.focusBackground": accent, "button.background": accent, "button.border": accent, "button.hoverBackground": color("Selection", "BackgroundAlternate"), "button.foreground": accent_text, "badge.background": accent, "badge.foreground": accent_text, "activityBarBadge.background": accent, "activityBarBadge.foreground": accent_text, "tab.activeBorderTop": accent, "panelTitle.activeBorder": accent, "textLink.foreground": link, "textLink.activeForeground": link, } names = ["Black", "Red", "Green", "Yellow", "Blue", "Magenta", "Cyan", "White"] for i, c in enumerate(ansi(color)): pins[f"terminal.ansi{'Bright' if i >= 8 else ''}{names[i % 8]}"] = c colors = {k: pins.get(k) or recolor(c) for k, c in base.items()} colors.update(pins) # Electron is Chromium, so it needs the KWin compensation. return {k: hexc(kwin_compensate(c[:3]) + list(c[3:])) for k, c in sorted(colors.items())} def vscode_files(color: ColorFn) -> dict[str, str]: """The extension files, keyed by their path inside it.""" name = "Darwin Breeze Dark" # Syntax colors as T3 Code shows them. T3 Code doesn't compensate them # either, so the two render the same. syntax = json.loads(VSCODE_SYNTAX.read_text()) theme = { "$schema": "vscode://schemas/color-theme", "name": name, "type": "dark", "semanticHighlighting": True, "colors": vscode(color), "tokenColors": syntax["tokenColors"], "semanticTokenColors": syntax["semanticTokenColors"], } package = { "name": "darwin-breeze", "displayName": "Darwin Breeze", "description": f"Generated by kde/theme/build.py from {SCHEME.name}", "publisher": "trivk", "version": "1.0.0", "engines": {"vscode": "^1.70.0"}, "categories": ["Themes"], "contributes": { "themes": [{"label": name, "uiTheme": "vs-dark", "path": f"./{VSCODE_THEME}"}], }, } return { "package.json": json.dumps(package, indent=2) + "\n", VSCODE_THEME: json.dumps(theme, indent=2) + "\n", } def vsix(files: Mapping[str, str]) -> bytes: """Pack the extension the way vsce does, for code --install-extension.""" manifest = """ Darwin Breeze Darwin Breeze Dark color theme Themes """ content_types = """ """ return zip_bytes({ "[Content_Types].xml": content_types, "extension.vsixmanifest": manifest, **{f"extension/{path}": text for path, text in files.items()}, }) def solid_png(c: Sequence[int]) -> bytes: """A 1x1 PNG; Telegram stretches it over the chat as a plain background.""" def chunk(kind: bytes, data: bytes) -> bytes: return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) return ( b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(bytes([0, *c]))) + chunk(b"IEND", b"") ) def zip_bytes(files: Mapping[str, str | bytes]) -> bytes: """A zip with fixed timestamps, so an unchanged theme rebuilds byte-identical.""" buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: for name, data in files.items(): info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o644 << 16 z.writestr(info, data) return buf.getvalue() def write(path: Path, data: str | bytes) -> bool: path.parent.mkdir(parents=True, exist_ok=True) if isinstance(data, str): data = data.encode() if path.exists() and path.read_bytes() == data: return False path.write_bytes(data) print(f"wrote {path.relative_to(PKG.parent)}") return True def main() -> None: color = load() theme = {"version": 1, "name": "Darwin Breeze", "appearance": "dark", "colors": t3(color)} write(T3, json.dumps(theme, indent=2) + "\n") write(TELEGRAM, zip_bytes({ "colors.tdesktop-theme": telegram_theme(color), "background.png": solid_png(color("View", "BackgroundNormal")), })) files = vscode_files(color) for path, text in files.items(): write(VSCODE / path, text) write(VSCODE / "darwin-breeze.vsix", vsix(files)) if __name__ == "__main__": main()