diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-29 12:29:38 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-29 12:29:38 +0200 |
| commit | 0ca89376ea0a04ad88ae80b9b602033f3beb8eb6 (patch) | |
| tree | 1c20a959647fb31689e97b343f1cabd11084c3ce | |
| parent | 5b133dbd86aaf54b0c728128f304c8dd8ebea9d1 (diff) | |
| download | unified-desktop-theme-0ca89376ea0a04ad88ae80b9b602033f3beb8eb6.tar.gz unified-desktop-theme-0ca89376ea0a04ad88ae80b9b602033f3beb8eb6.zip | |
feat(palette): theme btop, Joplin and Feishin
btop gets a generated udt.theme and only the color_theme line of btop.conf
is rewritten. Joplin gets userchrome.css overriding the --joplin-* custom
properties most of its UI is built on, with !important since Joplin's own
:root block is injected later; a userchrome.css udt did not write is left
alone. Feishin 1.17 watches a Themes folder for JSON themes, so UDT.json
extends its built-in Catppuccin Mocha with the scheme's roles and reaches
a running Feishin live once selected.
All three use existing roles, so no scheme file changes. udt-appthemes
installs them next to Obsidian and Typora; the recap hint names btop and
Joplin among the apps that need a restart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | AGENTS.md | 26 | ||||
| -rwxr-xr-x | bin/udt-appthemes | 67 | ||||
| -rwxr-xr-x | bin/udt-palette | 151 | ||||
| -rwxr-xr-x | install.sh | 2 |
5 files changed, 241 insertions, 8 deletions
@@ -26,3 +26,6 @@ __pycache__/ /templates/homepage/custom.css /templates/sublime/udt.sublime-color-scheme /templates/sublime/udt.sublime-theme +/templates/btop/udt.theme +/templates/joplin/userchrome.css +/templates/feishin/UDT.json @@ -7,7 +7,7 @@ Guidance for agents working in this repository. One visual identity for a Hyprland desktop: Catppuccin Macchiato, Noto Sans for UI text, Inconsolata Nerd Font Mono for monospace. It covers rofi, Hyprland window borders, conky, Qt5/Qt6, GTK3/GTK4, waybar, kitty, Sublime Text, -hyprlock and neovim. +hyprlock, neovim, btop, Joplin and Feishin. The repo holds the canonical copies. `./install.sh` symlinks them into place, so the working config cannot drift from what is committed. Everything outside @@ -75,8 +75,9 @@ The generated set is `rofi/udt/palette.rasi`, `templates/sublime/udt.sublime-color-scheme`, `templates/sublime/udt.sublime-theme`, `templates/gtk/gtk{3,4}.css`, `templates/gtk/index.theme`, `templates/gtk/assets/*.svg`, -`templates/hyprlock/hyprlock-palette.conf`, `templates/nvim/udt.lua` and -`bin/udt_colors.py`. +`templates/hyprlock/hyprlock-palette.conf`, `templates/nvim/udt.lua`, +`templates/btop/udt.theme`, `templates/joplin/userchrome.css`, +`templates/feishin/UDT.json` and `bin/udt_colors.py`. Sublime is two outputs, not one. The **colour scheme** is the editor pane and tracks the wallpaper accent. The **theme** is the UI chrome, and is an override @@ -123,15 +124,26 @@ alone left kcalc white. `install.sh` installs a generated `udt.colors` into **Qt apps only read a theme at startup**, so a running one keeps the old colours until restarted. `install.sh` cannot signal them and does not try. -Obsidian and Typora are installed by `bin/udt-appthemes`, not by a copy. +Obsidian, Typora, btop, Joplin and Feishin are installed by +`bin/udt-appthemes`, not by a plain copy. Obsidian gets a **snippet**, not a theme, so it layers over whatever theme a vault uses; enabling it means appending to `enabledCssSnippets` in that vault's `appearance.json`, never rewriting the list, or the vault loses the snippets it -already had. Every vault under `~/Documents/Obsidian` is found by looking for -`.obsidian` directories, nested ones included. Typora's theme is derived from +already had. Vaults come from Obsidian's own registry, `obsidian.json`, not a +directory scan, which found abandoned vaults. Typora's theme is derived from its Dracula theme, whose 350 rules all route through `:root` variables, so only that block is templated. +btop gets `~/.config/btop/themes/udt.theme` and only the `color_theme` line of +`btop.conf` is rewritten; btop writes that file back on exit, so restart a +running one after a switch. Joplin gets `userchrome.css`, which overrides the +`--joplin-*` custom properties its UI is built on, with `!important` because +Joplin's own `:root` block lands after it; parts still styled from its JS theme +object keep the base theme, so that must stay a dark one. A `userchrome.css` +udt did not write is left alone. Feishin gets `Themes/UDT.json`, extending its +built-in Catppuccin Mocha; the folder is watched, so after selecting UDT once +in its settings, a scheme switch reaches a running Feishin live. + Signal, Discord and Spotify cannot be consumers: they ship closed bundles with no user-CSS hook, and patching a signed app bundle breaks on every update. @@ -332,3 +344,5 @@ The table is what it does, and what to run if you change a config by hand. | conky | restart it; no hot-reload | | neovim | `:colorscheme udt`, or restart it | | Qt / GTK apps | restart the app | +| btop, Joplin | restart the app | +| Feishin | nothing; it watches its Themes folder | diff --git a/bin/udt-appthemes b/bin/udt-appthemes index 37d3e63..5504427 100755 --- a/bin/udt-appthemes +++ b/bin/udt-appthemes @@ -2,7 +2,8 @@ # udt-appthemes: install the generated app themes that need more than a copy. # Copyright (C) 2026 Danilo M. <danix@danix.xyz> # Licensed under the GNU General Public License v2 only. -"""Install the Obsidian snippet into every vault, and the Typora theme. +"""Install the app themes that need more than a copy: Obsidian, Typora, +btop, Joplin and Feishin. Separate from install.sh because enabling an Obsidian snippet means editing JSON that the user also owns: appearance.json lists every enabled snippet, so @@ -19,6 +20,9 @@ REPO = Path(__file__).resolve().parent.parent SNIPPET = "udt" OBSIDIAN_REGISTRY = Path.home() / ".config" / "obsidian" / "obsidian.json" TYPORA_THEMES = Path.home() / ".config" / "Typora" / "themes" +BTOP = Path.home() / ".config" / "btop" +JOPLIN = Path.home() / ".config" / "joplin-desktop" +FEISHIN = Path.home() / ".config" / "feishin" def vaults(): @@ -80,6 +84,54 @@ def install_typora(source): return True +def install_btop(source): + """Install the theme and point btop.conf's color_theme at it. + + Only that one line is rewritten: btop.conf is btop's own, and it writes + the file back on exit, so a btop left running across a scheme switch + restores its old color_theme. Restart it after installing. + """ + if not BTOP.is_dir(): + return False + (BTOP / "themes").mkdir(exist_ok=True) + shutil.copyfile(source, BTOP / "themes" / "udt.theme") + conf = BTOP / "btop.conf" + try: + lines = conf.read_text().splitlines() + except OSError: + lines = [] + want = 'color_theme = "udt"' + lines = [want if line.startswith("color_theme") else line for line in lines] + if want not in lines: + lines.append(want) + conf.write_text("\n".join(lines) + "\n") + return True + + +def install_joplin(source): + """Write userchrome.css, unless the user already keeps their own there. + + Joplin has one userchrome.css per profile and no include, so a file this + script did not write is left alone rather than replaced. + """ + if not JOPLIN.is_dir(): + return None + dest = JOPLIN / "userchrome.css" + if dest.exists() and "Generated by udt-palette" not in dest.read_text(): + return False + shutil.copyfile(source, dest) + return True + + +def install_feishin(source): + """Drop the theme into Feishin's Themes folder, which it watches live.""" + if not FEISHIN.is_dir(): + return False + (FEISHIN / "Themes").mkdir(exist_ok=True) + shutil.copyfile(source, FEISHIN / "Themes" / "UDT.json") + return True + + def main(): obsidian = REPO / "templates" / "obsidian" / "udt.css" typora = REPO / "templates" / "typora" / "udt.css" @@ -95,6 +147,19 @@ def main(): if install_typora(typora): print("typora: udt.css installed (select it in Themes)") + templates = REPO / "templates" + if install_btop(templates / "btop" / "udt.theme"): + print("btop: udt theme set (restart btop)") + + joplin = install_joplin(templates / "joplin" / "userchrome.css") + if joplin: + print("joplin: userchrome.css installed (restart Joplin)") + elif joplin is False: + print("joplin: userchrome.css is your own, left alone") + + if install_feishin(templates / "feishin" / "UDT.json"): + print("feishin: UDT theme installed (select it once in Settings, it then follows live)") + return 0 diff --git a/bin/udt-palette b/bin/udt-palette index 00e3092..cdeb571 100755 --- a/bin/udt-palette +++ b/bin/udt-palette @@ -13,6 +13,7 @@ what each is for, and everything else here is generated. Editing a generated file is pointless: the next install.sh overwrites it. """ +import json import re import sys from pathlib import Path @@ -795,6 +796,153 @@ def load(selector_path, scheme=None): # What gets written where. Templates are read from the repo, everything else is # generated whole. +def gen_btop(res, scheme, palette): + """btop: a .theme file, installed as ~/.config/btop/themes/udt.theme. + + btop has no include or variables, so every key is written. The graphs are + three-stop gradients; they run through the state colours so a meter reads + the same way a waybar pill does, calm to critical. + """ + def c(role): + return hex6(res[role]) + + def grad(name, start, mid, end): + return (f'theme[{name}_start]="{c(start)}"\n' + f'theme[{name}_mid]="{c(mid)}"\n' + f'theme[{name}_end]="{c(end)}"\n') + + return (f"# {BANNER.format(scheme=scheme)}\n\n" + f'theme[main_bg]="{c("bg")}"\n' + f'theme[main_fg]="{c("fg")}"\n' + f'theme[title]="{c("fg")}"\n' + f'theme[hi_fg]="{c("accent")}"\n' + f'theme[selected_bg]="{c("surface_alt")}"\n' + f'theme[selected_fg]="{c("fg")}"\n' + f'theme[inactive_fg]="{c("fg_faint")}"\n' + f'theme[graph_text]="{c("fg_dim")}"\n' + f'theme[meter_bg]="{c("surface")}"\n' + f'theme[proc_misc]="{c("info")}"\n' + f'theme[cpu_box]="{c("accent")}"\n' + f'theme[mem_box]="{c("success")}"\n' + f'theme[net_box]="{c("peach")}"\n' + f'theme[proc_box]="{c("info")}"\n' + f'theme[div_line]="{c("border")}"\n' + + grad("temp", "success", "warning", "critical") + + grad("cpu", "success", "warning", "critical") + + grad("free", "surface_alt", "success", "success") + + grad("cached", "surface_alt", "info", "info") + + grad("available", "surface_alt", "peach", "peach") + + grad("used", "surface_alt", "critical", "critical") + + grad("download", "surface_alt", "info", "accent") + + grad("upload", "surface_alt", "peach", "warning") + + grad("process", "info", "accent", "critical")) + + +def gen_joplin(res, scheme, palette): + """Joplin: userchrome.css, overriding the --joplin-* theme variables. + + Joplin turns its theme object into :root custom properties and most of the + desktop UI reads those, so overriding them recolours it without restyling + a single component. !important because Joplin's own :root block is + injected after this file. Parts still styled from the JS theme object keep + the base theme's colours, so the base theme should stay a dark one. + """ + def c(role): + return hex6(res[role]) + + pairs = [ + ("background-color", c("bg")), + ("background-color-transparent", hex8((*res["bg"][:3], 90))), + ("odd-background-color", c("bg_alt")), + ("color", c("fg")), + ("color-error", c("critical")), + ("color-correct", c("success")), + ("color-warn", c("warning")), + ("color-warn-url", c("warning")), + ("color-faded", c("fg_faint")), + ("divider-color", c("border")), + ("selected-color", c("surface_alt")), + ("url-color", c("info")), + ("color-error-selected", c("critical")), + ("background-color2", c("bg_deep")), + ("color2", c("fg")), + ("selected-color2", c("surface_alt")), + ("color-error2", c("critical")), + ("color-warn2", c("warning")), + ("color-warn3", c("warning")), + ("background-color3", c("bg_alt")), + ("background-color-hover3", c("surface")), + ("background-color-active3", c("surface_alt")), + ("color3", c("fg")), + ("background-color4", c("bg")), + ("color4", c("accent")), + ("background-color5", c("surface")), + ("color5", c("fg")), + ("background-color-hover2", c("surface")), + ("color-hover2", c("fg")), + ("color-active2", c("fg")), + ("color-faded2", c("fg_dim")), + ("raised-background-color", c("surface")), + ("raised-color", c("fg")), + ("warning-background-color", c("surface_alt")), + ("destructive-color", c("critical")), + ("table-background-color", c("bg_alt")), + ("code-background-color", c("bg_deep")), + ("code-border-color", c("border")), + ("code-color", c("fg")), + ("header-background-color", c("bg_alt")), + ("text-selection-color", c("accent")), + ("search-marker-background-color", c("warning")), + ("search-marker-color", c("bg")), + ("focus-outline-color", c("accent")), + ("focus-outline-color-dimmed", hex8((*res["accent"][:3], 50))), + ("border-color4", c("border")), + ("scrollbar-thumb-color", c("fg_faint")), + ("scrollbar-thumb-color-hover", c("fg_dim")), + ("scrollbar-thumb-color2", c("fg_faint")), + ("scrollbar-thumb-color-hover2", c("fg_dim")), + ] + body = "\n".join(f" --joplin-{k}: {v} !important;" for k, v in pairs) + return f"/* {BANNER.format(scheme=scheme)} */\n\n:root {{\n{body}\n}}\n" + + +def gen_feishin(res, scheme, palette): + """Feishin: a custom theme JSON for its watched Themes folder. + + It extends the built-in Catppuccin Mocha, so everything not restated here + (spacing, radii, the Mantine mapping) comes from a dark theme Feishin + already ships. Feishin validates every colour and drops what it cannot + parse, so plain hex only. + """ + def c(role): + return hex6(res[role]) + + r, g, b, _ = res["bg"] + theme = { + "extends": "catppuccinMocha", + "mode": "dark", + "app": { + "overlay-header": f"linear-gradient(transparent 0%, rgb({r} {g} {b} / 85%) 100%), var(--theme-background-noise)", + "overlay-subheader": f"linear-gradient(180deg, rgb({r} {g} {b} / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)", + }, + "colors": { + "background": c("bg"), + "background-alternate": c("bg_deep"), + "foreground": c("fg"), + "foreground-muted": c("fg_dim"), + "primary": c("accent"), + "state-error": c("critical"), + "state-info": c("info"), + "state-success": c("success"), + "state-warning": c("warning"), + "surface": c("bg_alt"), + "surface-foreground": c("fg"), + }, + } + # JSON has no comments, so the banner goes in a key Feishin ignores. + return json.dumps({"_comment": BANNER.format(scheme=scheme), **theme}, indent=4) + "\n" + + TARGETS = [ ("rofi/udt/palette.rasi", gen_rofi, None), ("templates/waybar/theme.css", gen_waybar, None), @@ -817,6 +965,9 @@ TARGETS = [ ("templates/gtk/gtk3.css", gen_gtk, "templates/gtk/gtk3.css.in"), ("templates/gtk/gtk4.css", gen_gtk, "templates/gtk/gtk4.css.in"), ("templates/gtk/index.theme", gen_gtk, "templates/gtk/index.theme.in"), + ("templates/btop/udt.theme", gen_btop, None), + ("templates/joplin/userchrome.css", gen_joplin, None), + ("templates/feishin/UDT.json", gen_feishin, None), ] # The GTK widget assets, one target each. Discovered rather than listed: there @@ -374,4 +374,4 @@ echo echo [ -n "$reloaded" ] && echo "reloaded:$reloaded" -echo "Qt and GTK apps only reread a theme when they restart; neovim needs :colorscheme udt." +echo "Qt, GTK, btop and Joplin only reread a theme when they restart; neovim needs :colorscheme udt." |
