diff --git a/kde/fastfetch/build.py b/kde/fastfetch/build.py index 4088248..f3ce189 100755 --- a/kde/fastfetch/build.py +++ b/kde/fastfetch/build.py @@ -30,7 +30,7 @@ COLS, ROWS = 20, 10 KONSOLE_CELL = 8, 17 -def encode(png, dst): +def encode(png: bytes, dst: Path) -> None: data = base64.b64encode(png) chunks = [data[i : i + 4096] for i in range(0, len(data), 4096)] out = bytearray() @@ -44,7 +44,7 @@ def encode(png, dst): dst.write_bytes(out) -def resize(width, height): +def resize(width: int, height: int) -> bytes: with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "out.png" subprocess.run( diff --git a/kde/spectacle/apply.py b/kde/spectacle/apply.py index 7ff74c4..71cc33d 100755 --- a/kde/spectacle/apply.py +++ b/kde/spectacle/apply.py @@ -7,6 +7,7 @@ import os from pathlib import Path import re import subprocess +from typing import Any HERE = Path(__file__).resolve().parent @@ -14,11 +15,11 @@ COMPONENT = "org.kde.spectacle.desktop" ACCEL = "/component/org_kde_spectacle_desktop" -def run(*args, **kwargs): +def run(*args: str, **kwargs: Any) -> subprocess.CompletedProcess[str]: return subprocess.run(args, check=True, text=True, **kwargs) -def accel(path, method, *args): +def accel(path: str, method: str, *args: str) -> str: return run( "gdbus", "call", "--session", "--dest", "org.kde.kglobalaccel", "--object-path", path, "--method", method, *args, @@ -26,10 +27,10 @@ def accel(path, method, *args): ).stdout -def main(): +def main() -> None: for name in ("spectaclerc", "klipperrc"): config = configparser.ConfigParser(interpolation=None) - config.optionxform = str + config.optionxform = str # type: ignore[method-assign,assignment] config.read(HERE / "settings" / name) for group in config.sections(): for key, value in config[group].items(): @@ -38,7 +39,7 @@ def main(): # T3's locale and XDG_DATA_DIRS can differ from the running KDE session. # Build the cache KDE actually reads, rather than an app-specific cache. - session = dict( + session: dict[str, str] = dict( line.split("=", 1) for line in run("systemctl", "--user", "show-environment", capture_output=True) .stdout.splitlines() if "=" in line @@ -61,7 +62,7 @@ def main(): try: output = accel(ACCEL, "org.kde.kglobalaccel.Component.allShortcutInfos") except subprocess.CalledProcessError: - shortcuts = [] # Component has not been registered yet. + shortcuts: list[Any] = [] # Component has not been registered yet. else: shortcuts = ast.literal_eval(re.sub(r"@a[is] ", "", output))[0] accel(ACCEL, "org.kde.kglobalaccel.Component.cleanUp") diff --git a/kde/theme/build.py b/kde/theme/build.py index 32323c1..8e1fc5d 100755 --- a/kde/theme/build.py +++ b/kde/theme/build.py @@ -18,6 +18,7 @@ import re import struct import zipfile import zlib +from collections.abc import Callable, Mapping, Sequence from pathlib import Path HERE = Path(__file__).resolve().parent @@ -40,6 +41,11 @@ VSCODE_UI = VSCODE / "base/2026-dark.json" # 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. @@ -53,27 +59,27 @@ YELLOW = (255, 255, 0) INACTIVE_TAB_MIX = 0.12 -def load(): +def load() -> ColorFn: cp = configparser.ConfigParser(interpolation=None, strict=False) - cp.optionxform = str + cp.optionxform = str # type: ignore[method-assign,assignment] cp.read(SCHEME) - def color(group, key): + 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, b, t): +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): +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): +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 @@ -81,15 +87,15 @@ def kwin_compensate(c): with the inverse makes Helium land on the same values as native apps. """ - def ch(v): - lin = (v / 255) ** 2.2 + 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 helium(color): +def helium(color: ColorFn) -> dict[str, list[int]]: bar = color("Header", "BackgroundNormal") text = color("Window", "ForegroundNormal") view = color("View", "BackgroundNormal") @@ -123,7 +129,7 @@ def helium(color): return {k: kwin_compensate(v) for k, v in colors.items()} -def ansi(color): +def ansi(color: ColorFn) -> list[Color]: """The 16 colors for the VS Code terminal.""" bg = color("View", "BackgroundNormal") fg = color("View", "ForegroundNormal") @@ -145,7 +151,7 @@ def ansi(color): return normal + bright -def t3(color): +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") @@ -227,7 +233,7 @@ def t3(color): return {k: hexc(kwin_compensate(v)) for k, v in colors.items()} -def telegram(color): +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") @@ -360,25 +366,25 @@ def telegram(color): } -def luma(c): +def luma(c: Sequence[int]) -> float: return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] -def interp(points, x): +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): +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): +def telegram_theme(color: ColorFn) -> str: """The night theme, recolored onto the scheme. Night tints everything blue. Its tinted grays become scheme grays of @@ -399,7 +405,7 @@ def telegram_theme(color): }) accent_h, _, accent_s = colorsys.rgb_to_hls(*(v / 255 for v in color("Selection", "BackgroundNormal"))) - def recolor(c): + 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)) @@ -411,7 +417,7 @@ def telegram_theme(color): return (*(round(v * 255) for v in colorsys.hls_to_rgb(accent_h, l, accent_s)), *alpha) pinned = telegram(color) - seen = set() + 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) @@ -429,20 +435,21 @@ def telegram_theme(color): return "\n".join(out) + "\n" -def vscode_base_colors(): +def vscode_base_colors() -> dict[str, Color]: """Dark 2026's colors with its include chain resolved.""" - chain, path = [], VSCODE_UI + 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 = {} + 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): +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 @@ -475,11 +482,11 @@ def vscode(color): }) assert all(a[1] <= b[1] for a, b in zip(grays, grays[1:])), grays - def hls(c): + 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 = [ + families: list[tuple[tuple[int, int], Color]] = [ ((0, 15), negative), ((15, 70), neutral), ((70, 170), positive), @@ -488,7 +495,7 @@ def vscode(color): ((330, 360), negative), ] - def recolor(c): + def recolor(c: Color) -> Color: rgb, alpha = c[:3], c[3:] if max(rgb) - min(rgb) <= 10: g = round(interp(grays, luma(rgb))) @@ -497,7 +504,7 @@ def vscode(color): 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 = { + pins: dict[str, Color] = { "editor.background": view, "editor.foreground": view_text, "titleBar.inactiveBackground": color("Header][Inactive", "BackgroundNormal"), @@ -527,7 +534,7 @@ def vscode(color): return {k: hexc(kwin_compensate(c[:3]) + list(c[3:])) for k, c in sorted(colors.items())} -def vscode_files(color): +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 @@ -560,7 +567,7 @@ def vscode_files(color): } -def vsix(files): +def vsix(files: Mapping[str, str]) -> bytes: """Pack the extension the way vsce does, for code --install-extension.""" manifest = """ @@ -592,10 +599,10 @@ def vsix(files): }) -def solid_png(c): +def solid_png(c: Sequence[int]) -> bytes: """A 1x1 PNG; Telegram stretches it over the chat as a plain background.""" - def chunk(kind, data): + def chunk(kind: bytes, data: bytes) -> bytes: return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) return ( @@ -606,7 +613,7 @@ def solid_png(c): ) -def zip_bytes(files): +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: @@ -618,7 +625,7 @@ def zip_bytes(files): return buf.getvalue() -def write(path, data): +def write(path: Path, data: str | bytes) -> bool: path.parent.mkdir(parents=True, exist_ok=True) if isinstance(data, str): data = data.encode() @@ -629,7 +636,7 @@ def write(path, data): return True -def main(): +def main() -> None: color = load() manifest = { "manifest_version": 3,