diff --git a/lib/default.nix b/lib/default.nix index 24a5dd4e..39642d44 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -9,7 +9,7 @@ let ; in { - kanagawa = import ./kanagawa.nix; + kanagawa = import ./kanagawa; mkProgramOption = { diff --git a/lib/kanagawa/default.nix b/lib/kanagawa/default.nix new file mode 100644 index 00000000..fda773ff --- /dev/null +++ b/lib/kanagawa/default.nix @@ -0,0 +1,19 @@ +# Kanagawa palette + per-flavor semantic theme tables. +# +# `lib.kanagawa` spreads the raw name→hex palette at the top level (so existing +# consumers like `lib.kanagawa.sumiInk3` and `nix eval --file palette.nix` keep +# working) and adds `lib.kanagawa.themes.` — the editor/syn/diff/chrome +# role tables consumed by pkgs/yazi-kanagawa-flavor. See themes/mk.nix. +let + palette = import ./palette.nix; + theme = name: import (./themes + "/${name}.nix") { inherit palette; }; +in +palette +// { + themes = { + wave = theme "wave"; + dragon = theme "dragon"; + lotus = theme "lotus"; + kris = theme "kris"; + }; +} diff --git a/lib/kanagawa.nix b/lib/kanagawa/palette.nix similarity index 100% rename from lib/kanagawa.nix rename to lib/kanagawa/palette.nix diff --git a/lib/kanagawa/themes/dragon.nix b/lib/kanagawa/themes/dragon.nix new file mode 100644 index 00000000..0318d4be --- /dev/null +++ b/lib/kanagawa/themes/dragon.nix @@ -0,0 +1,46 @@ +# Canonical Kanagawa "dragon" — transcribed from kanagawa.nvim +# lua/kanagawa/themes.lua (dragon). +{ palette }: +let + p = palette; +in +import ./mk.nix { + ui = { + fg = p.dragonWhite; + fg_dim = p.oldWhite; + nontext = p.dragonBlack6; + bg = p.dragonBlack3; + bg_m1 = p.dragonBlack2; + bg_m3 = p.dragonBlack0; + bg_search = p.waveBlue2; + }; + syn = { + string = p.dragonGreen2; + number = p.dragonPink; + constant = p.dragonOrange; + identifier = p.dragonYellow; + parameter = p.dragonGray; + fun = p.dragonBlue2; + keyword = p.dragonViolet; + operator = p.dragonRed; + type = p.dragonAqua; + regex = p.dragonRed; + deprecated = p.katanaGray; + comment = p.dragonAsh; + punct = p.dragonGray2; + special1 = p.dragonTeal; + special2 = p.dragonRed; + special3 = p.dragonRed; + }; + vcs = { + added = p.autumnGreen; + removed = p.autumnRed; + changed = p.autumnYellow; + }; + diff = { + add = p.winterGreen; + delete = p.winterRed; + change = p.winterBlue; + }; + diag.hint = p.waveAqua1; +} diff --git a/lib/kanagawa/themes/kris.nix b/lib/kanagawa/themes/kris.nix new file mode 100644 index 00000000..52f10413 --- /dev/null +++ b/lib/kanagawa/themes/kris.nix @@ -0,0 +1,70 @@ +# Kanagawa "kris" — the personal flavor. NOT a canonical kanagawa.nvim variant: +# wave editor + wave syntax, but a dragon-family UI chrome (with a couple of +# deliberate cross-family picks, e.g. sumiInk0 for the status bar). Written as +# explicit literals rather than via ./mk.nix so it reproduces the long-standing +# flavor byte-for-byte; the canonical flavors recolor the same yazi layout. +{ palette }: +let + p = palette; +in +{ + editor = { + background = p.sumiInk3; + foreground = p.fujiWhite; + caret = p.oldWhite; + invisibles = p.sumiInk6; + lineHighlight = p.waveBlue2; + selection = p.waveBlue2; + findHighlight = p.waveBlue2; + selectionBorder = p.dragonBlack2; # closest palette match to #222218 + gutterForeground = p.sumiInk6; + }; + + # wave syn table (matches the user's neovim treesitter highlighting). + syn = { + string = p.springGreen; + number = p.sakuraPink; + constant = p.surimiOrange; + identifier = p.carpYellow; + parameter = p.oniViolet2; + fun = p.crystalBlue; + keyword = p.oniViolet; + operator = p.boatYellow2; + type = p.waveAqua2; + regex = p.boatYellow2; + deprecated = p.katanaGray; + comment = p.fujiGray; + punct = p.springViolet2; + special1 = p.springBlue; + special2 = p.waveRed; + special3 = p.peachRed; + variable = p.fujiWhite; + }; + + diff = { + add = p.winterGreen; + delete = p.winterRed; + change = p.winterBlue; + }; + + chrome = { + fg = p.dragonWhite; + on_accent = p.dragonBlack3; + bg_deep = p.dragonBlack0; + status_bg = p.sumiInk0; + accent = p.dragonBlue2; + green = p.dragonGreen2; + red = p.waveRed; + pink = p.dragonPink; + yellow = p.carpYellow; + blue = p.springBlue; + border = p.dragonAqua; + teal = p.waveAqua2; + peach = p.peachRed; + docs = p.waveAqua1; + gray = p.dragonGray; + faint = p.sumiInk6; + orphan = p.dragonRed; + exec = p.autumnGreen; + }; +} diff --git a/lib/kanagawa/themes/lotus.nix b/lib/kanagawa/themes/lotus.nix new file mode 100644 index 00000000..c9cc4930 --- /dev/null +++ b/lib/kanagawa/themes/lotus.nix @@ -0,0 +1,47 @@ +# Canonical Kanagawa "lotus" (LIGHT) — transcribed from kanagawa.nvim +# lua/kanagawa/themes.lua (lotus). Light background; the shared mapper reads +# bg/fg from ui so the tmTheme inverts correctly. +{ palette }: +let + p = palette; +in +import ./mk.nix { + ui = { + fg = p.lotusInk1; + fg_dim = p.lotusInk2; + nontext = p.lotusViolet1; + bg = p.lotusWhite3; + bg_m1 = p.lotusWhite2; + bg_m3 = p.lotusWhite0; + bg_search = p.lotusBlue2; + }; + syn = { + string = p.lotusGreen; + number = p.lotusPink; + constant = p.lotusOrange; + identifier = p.lotusYellow; + parameter = p.lotusBlue5; + fun = p.lotusBlue4; + keyword = p.lotusViolet4; + operator = p.lotusYellow2; + type = p.lotusAqua; + regex = p.lotusYellow2; + deprecated = p.lotusGray3; + comment = p.lotusGray3; + punct = p.lotusTeal1; + special1 = p.lotusTeal2; + special2 = p.lotusRed; + special3 = p.lotusRed; + }; + vcs = { + added = p.lotusGreen2; + removed = p.lotusRed2; + changed = p.lotusYellow3; + }; + diff = { + add = p.lotusGreen3; + delete = p.lotusRed4; + change = p.lotusCyan; + }; + diag.hint = p.lotusAqua2; +} diff --git a/lib/kanagawa/themes/mk.nix b/lib/kanagawa/themes/mk.nix new file mode 100644 index 00000000..ed8a6afe --- /dev/null +++ b/lib/kanagawa/themes/mk.nix @@ -0,0 +1,60 @@ +# Shared mapping from a Kanagawa flavor's semantic role tables (transcribed from +# kanagawa.nvim's lua/kanagawa/themes/*.lua) to the four sections the yazi flavor +# builder consumes: +# +# editor — tmTheme global editor colors (background, caret, selection, …) +# syn — tmTheme per-scope syntax foregrounds +# diff — tmTheme markup.{inserted,deleted,changed} backgrounds +# chrome — yazi UI element accents (mgr/mode/status/pick/which/… in flavor.toml) +# +# `ui`/`syn`/`vcs`/`diff`/`diag` are the kanagawa.nvim role tables for one flavor. +# The chrome mapping (which kanagawa role drives each yazi accent) is defined ONCE +# here and shared by the canonical wave/dragon/lotus flavors; kris overrides it. +{ + ui, + syn, + vcs, + diff, + diag, +}: +{ + editor = { + background = ui.bg; + foreground = ui.fg; + caret = ui.fg_dim; + invisibles = ui.nontext; + lineHighlight = ui.bg_search; + selection = ui.bg_search; + findHighlight = ui.bg_search; + selectionBorder = ui.bg_m1; + gutterForeground = ui.nontext; + }; + + # kanagawa.nvim sets syn.variable = "none" (inherit fg); resolve to ui.fg here. + syn = syn // { + variable = ui.fg; + }; + + diff = { inherit (diff) add delete change; }; + + chrome = { + inherit (ui) fg; # borders' default, fallback file, help footer + on_accent = ui.bg; # text drawn on a colored bg (counts, mode, find) + bg_deep = ui.bg_m3; # deepest bg: alt mode, progress label + status_bg = ui.bg_m3; # status bar / which-key mask + accent = syn.fun; # primary accent: normal mode, dirs, which cand + green = syn.string; # copy/created markers, info + red = syn.special2; # cut/selected markers, archives + pink = syn.number; # marked, select mode, media, tasks + yellow = syn.identifier; # cwd, unset mode, images, warn, read perm + blue = syn.special1; # status bar fg + border = syn.type; # pick/input/completion/tasks borders + teal = syn.type; # exec perm, help "on" + peach = syn.special3; # write perm, error + docs = diag.hint; # document filetype + gray = ui.fg_dim; # which-key separator/rest + faint = ui.nontext; # which-key desc, gutter find line-number + orphan = syn.special2; # broken symlinks + exec = vcs.added; # executable files + }; +} diff --git a/lib/kanagawa/themes/wave.nix b/lib/kanagawa/themes/wave.nix new file mode 100644 index 00000000..9fb37c53 --- /dev/null +++ b/lib/kanagawa/themes/wave.nix @@ -0,0 +1,47 @@ +# Canonical Kanagawa "wave" — transcribed from kanagawa.nvim +# lua/kanagawa/themes.lua (wave). Only the roles the yazi builder consumes are +# included; the shared mapper (./mk.nix) turns them into editor/syn/diff/chrome. +{ palette }: +let + p = palette; +in +import ./mk.nix { + ui = { + fg = p.fujiWhite; + fg_dim = p.oldWhite; + nontext = p.sumiInk6; + bg = p.sumiInk3; + bg_m1 = p.sumiInk2; + bg_m3 = p.sumiInk0; + bg_search = p.waveBlue2; + }; + syn = { + string = p.springGreen; + number = p.sakuraPink; + constant = p.surimiOrange; + identifier = p.carpYellow; + parameter = p.oniViolet2; + fun = p.crystalBlue; + keyword = p.oniViolet; + operator = p.boatYellow2; + type = p.waveAqua2; + regex = p.boatYellow2; + deprecated = p.katanaGray; + comment = p.fujiGray; + punct = p.springViolet2; + special1 = p.springBlue; + special2 = p.waveRed; + special3 = p.peachRed; + }; + vcs = { + added = p.autumnGreen; + removed = p.autumnRed; + changed = p.autumnYellow; + }; + diff = { + add = p.winterGreen; + delete = p.winterRed; + change = p.winterBlue; + }; + diag.hint = p.waveAqua1; +} diff --git a/modules/home-manager/yazi/_palette/kanagawa-dragon.png b/modules/home-manager/yazi/_palette/kanagawa-dragon.png new file mode 100644 index 00000000..1ebe7972 Binary files /dev/null and b/modules/home-manager/yazi/_palette/kanagawa-dragon.png differ diff --git a/modules/home-manager/yazi/_palette/kanagawa-kris.png b/modules/home-manager/yazi/_palette/kanagawa-kris.png new file mode 100644 index 00000000..7f1a6471 Binary files /dev/null and b/modules/home-manager/yazi/_palette/kanagawa-kris.png differ diff --git a/modules/home-manager/yazi/_palette/kanagawa-lotus.png b/modules/home-manager/yazi/_palette/kanagawa-lotus.png new file mode 100644 index 00000000..a317727d Binary files /dev/null and b/modules/home-manager/yazi/_palette/kanagawa-lotus.png differ diff --git a/modules/home-manager/yazi/_palette/kanagawa-wave.png b/modules/home-manager/yazi/_palette/kanagawa-wave.png new file mode 100644 index 00000000..13d0f288 Binary files /dev/null and b/modules/home-manager/yazi/_palette/kanagawa-wave.png differ diff --git a/modules/home-manager/yazi/default.nix b/modules/home-manager/yazi/default.nix index 14fcad93..4bdd8ed5 100644 --- a/modules/home-manager/yazi/default.nix +++ b/modules/home-manager/yazi/default.nix @@ -7,12 +7,39 @@ inputs, ... }: + let + stateDir = "${config.xdg.stateHome}/yazi"; + # Default flavor for a fresh machine; the theme-switcher plugin overwrites + # this file at runtime, and it's only seeded when absent (below), so a + # rebuild never clobbers a saved selection. yazi picks the slot matching the + # terminal's color mode at startup, so `light` is the light-terminal fallback. + defaultThemeToml = pkgs.writeText "yazi-theme-default.toml" '' + [flavor] + dark = "kanagawa-kris" + light = "kanagawa-lotus" + ''; + in { options.kriswill.yazi.enable = lib.mkEnableOption "yazi"; config = lib.mkIf config.kriswill.yazi.enable { # `magick` (ImageMagick 7) is required by the font previewer. home.packages = [ pkgs.imagemagick ]; + # theme.toml is intentionally NOT managed by programs.yazi (so it isn't a + # read-only store symlink): point it at a writable file in yazi's state + # dir that the theme-switcher plugin rewrites. yazi reads it at startup. + xdg.configFile."yazi/theme.toml".source = + config.lib.file.mkOutOfStoreSymlink "${stateDir}/theme.toml"; + + # Seed the default selection once; preserve any runtime choice thereafter. + home.activation.yaziThemeSeed = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + run mkdir -p ${lib.escapeShellArg stateDir} + if [ ! -e ${lib.escapeShellArg "${stateDir}/theme.toml"} ]; then + run cp ${defaultThemeToml} ${lib.escapeShellArg "${stateDir}/theme.toml"} + run chmod u+w ${lib.escapeShellArg "${stateDir}/theme.toml"} + fi + ''; + programs.yazi = { enable = lib.mkDefault true; shellWrapperName = "y"; @@ -29,17 +56,27 @@ # Wired via explicit preloader + previewer rules below — yazi # won't let a user plugin named `font` override the preset. font-dark = ./font-dark.yazi; + # Picks a Kanagawa flavor and writes it to the (writable) theme.toml + # in yazi's state dir; bound to `T` below. + theme-switcher = ./theme-switcher.yazi; }; initLua = '' require("git"):setup() ''; - flavors = { - kanagawa-dragon = import ./_themes/kanagawa-dragon { inherit lib pkgs; }; - }; - theme = { - flavor.dark = "kanagawa-dragon"; - }; + # All four flavors installed simultaneously (flavors/.yazi). The + # active one is selected by theme.toml's [flavor], which is NOT managed + # here — see the writable out-of-store symlink + seed below — so the + # theme-switcher plugin can persist a runtime choice. + flavors = import ../../../pkgs/yazi-kanagawa-flavor/all.nix { inherit lib pkgs; }; + + keymap.mgr.prepend_keymap = [ + { + on = [ "T" ]; + run = "plugin theme-switcher"; + desc = "Switch Kanagawa flavor"; + } + ]; settings = { mgr.ratio = [ 1 diff --git a/modules/home-manager/yazi/theme-switcher.yazi/.luarc.json b/modules/home-manager/yazi/theme-switcher.yazi/.luarc.json new file mode 100644 index 00000000..d0c15f6d --- /dev/null +++ b/modules/home-manager/yazi/theme-switcher.yazi/.luarc.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "runtime.version": "Lua 5.4", + "workspace.library": ["~/.config/yazi/plugins/types.yazi"] +} diff --git a/modules/home-manager/yazi/theme-switcher.yazi/main.lua b/modules/home-manager/yazi/theme-switcher.yazi/main.lua new file mode 100644 index 00000000..ef9bf7c6 --- /dev/null +++ b/modules/home-manager/yazi/theme-switcher.yazi/main.lua @@ -0,0 +1,71 @@ +-- Kanagawa theme switcher. +-- +-- Presents a which-key menu of the dark flavors and persists the choice to +-- yazi's state dir (~/.local/state/yazi/theme.toml, an out-of-store symlink the +-- nix config points $XDG_CONFIG_HOME/yazi/theme.toml at). yazi reads +-- theme.toml's [flavor] only at startup and offers no live flavor reload, so the +-- selection takes effect on the next launch — hence the notify. +-- +-- Only the dark flavors are offered here: the choice fills theme.toml's `dark` +-- slot, while the `light` slot is pinned to the light Lotus flavor. yazi detects +-- the terminal's color mode at startup and picks the matching slot, so Lotus is +-- never presented as a manual option — it only activates on a light terminal. +-- (A "light" flavor can't repaint a dark terminal: yazi paints widget bgs only, +-- so the file-list body shows the terminal background regardless.) + +local THEMES = { + { on = "k", flavor = "kanagawa-kris", desc = "Kanagawa Kris" }, + { on = "w", flavor = "kanagawa-wave", desc = "Kanagawa Wave" }, + { on = "d", flavor = "kanagawa-dragon", desc = "Kanagawa Dragon" }, +} + +-- Resolve ~/.local/state/yazi, honoring XDG_STATE_HOME. os.getenv is pure (no +-- I/O), so it's safe in the async entry context. +local function state_dir() + local base = os.getenv("XDG_STATE_HOME") + if not base or base == "" then + base = (os.getenv("HOME") or "") .. "/.local/state" + end + return base .. "/yazi" +end + +return { + entry = function() + local cands = {} + for i, t in ipairs(THEMES) do + cands[i] = { on = t.on, desc = t.desc } + end + + local idx = ya.which({ cands = cands }) + if not idx then + return + end + + local flavor = THEMES[idx].flavor + local dir = state_dir() + -- Chosen flavor drives the dark slot; light stays pinned to Lotus so yazi + -- auto-selects it only when the terminal is in light mode. + local body = string.format('[flavor]\ndark = "%s"\nlight = "kanagawa-lotus"\n', flavor) + + -- Ensure the state dir exists (it normally does, seeded by activation). + fs.create("dir_all", Url(dir)) + + local ok, err = fs.write(Url(dir .. "/theme.toml"), body) + if not ok then + ya.notify({ + title = "Theme", + content = "Failed to save theme: " .. tostring(err), + timeout = 5, + level = "error", + }) + return + end + + ya.notify({ + title = "Theme", + content = "Set dark flavor to " .. flavor .. ". Restart yazi to apply.", + timeout = 5, + level = "info", + }) + end, +} diff --git a/modules/packages.nix b/modules/packages.nix index 47d30601..99103d7f 100644 --- a/modules/packages.nix +++ b/modules/packages.nix @@ -1,11 +1,19 @@ # Custom package outputs (also surfaced into nix-darwin via ./overlays.nix). { perSystem = - { pkgs, ... }: + { pkgs, lib, ... }: + let + # Yazi Kanagawa flavors as standalone derivations ($out holds flavor.toml + # + tmtheme.xml). Built here so `scripts/render-yazi-palette.ts` can + # document the exact files yazi consumes; the home-manager module installs + # the same derivations from the shared spec. Keyed "kanagawa-". + yaziFlavors = import ../pkgs/yazi-kanagawa-flavor/all.nix { inherit lib pkgs; }; + in { packages = { kitten = pkgs.callPackage ../pkgs/kitten.nix { }; iv = pkgs.callPackage ../pkgs/iv.nix { }; - }; + } + // lib.mapAttrs' (name: drv: lib.nameValuePair "yazi-${name}" drv) yaziFlavors; }; } diff --git a/pkgs/yazi-kanagawa-flavor/all.nix b/pkgs/yazi-kanagawa-flavor/all.nix new file mode 100644 index 00000000..956c0a31 --- /dev/null +++ b/pkgs/yazi-kanagawa-flavor/all.nix @@ -0,0 +1,22 @@ +# Builds all four flavors from ./flavors.nix into an attrset keyed by flavor +# name (e.g. "kanagawa-wave" → derivation). Themes are read straight from +# lib/kanagawa so this works whether or not the caller's `lib` carries the +# `kanagawa` helper. +{ lib, pkgs }: +let + specs = import ./flavors.nix; + inherit ((import ../../lib/kanagawa)) themes; +in +lib.listToAttrs ( + map ( + s: + lib.nameValuePair s.name ( + import ./. { + inherit lib pkgs; + inherit (s) name title uuid; + appearance = s.appearance or "dark"; + theme = themes.${s.theme}; + } + ) + ) specs +) diff --git a/modules/home-manager/yazi/_themes/kanagawa-dragon/default.nix b/pkgs/yazi-kanagawa-flavor/default.nix similarity index 55% rename from modules/home-manager/yazi/_themes/kanagawa-dragon/default.nix rename to pkgs/yazi-kanagawa-flavor/default.nix index 713b51cb..e828162b 100644 --- a/modules/home-manager/yazi/_themes/kanagawa-dragon/default.nix +++ b/pkgs/yazi-kanagawa-flavor/default.nix @@ -1,39 +1,48 @@ -# Kanagawa-Dragon flavor for Yazi, generated from the shared palette. +# Reusable builder for a Yazi Kanagawa flavor. Produces the two files yazi +# expects in a `.yazi/` flavor dir — `flavor.toml` (the UI theme) and +# `tmtheme.xml` (the syntect tmTheme used for syntax/diff previews) — from a +# semantic `theme` table (see lib/kanagawa/themes/*). Every color has a single +# source of truth (lib/kanagawa), so the four flavors share one layout and +# differ only by their palette. # -# Both files yazi expects in a `.yazi/` flavor dir — `flavor.toml` -# (the UI theme) and `tmtheme.xml` (the syntect tmTheme used for syntax/diff -# previews) — are produced here from `lib.kanagawa` so every color has a -# single source of truth (lib/kanagawa.nix). The dragon palette uses the -# `dragon*` entries; a few non-dragon accents (waveRed, carpYellow, …) match -# upstream rebelot/kanagawa.nvim. -# -# Attribution: ported from the MIT-licensed +# Attribution: layout originally ported from the MIT-licensed # https://github.com/marcosvnmelo/kanagawa-dragon.yazi -{ lib, pkgs, ... }: +{ + lib, + pkgs, + name, # flavor dir name, e.g. "kanagawa-kris" + title, # tmTheme display name, e.g. "Kanagawa Kris" + uuid, # stable tmTheme uuid (distinct per flavor) + appearance ? "dark", # "dark" | "light" — tmTheme semanticClass only + theme, # { editor, syn, diff, chrome } from lib.kanagawa.themes. +}: let - k = lib.kanagawa; + c = theme.chrome; + s = theme.syn; + e = theme.editor; + d = theme.diff; flavorAttrs = { mgr = { marker_copied = { - fg = k.dragonGreen2; - bg = k.dragonGreen2; + fg = c.green; + bg = c.green; }; marker_cut = { - fg = k.waveRed; - bg = k.waveRed; + fg = c.red; + bg = c.red; }; marker_marked = { - fg = k.dragonPink; - bg = k.dragonPink; + fg = c.pink; + bg = c.pink; }; marker_selected = { - fg = k.waveRed; - bg = k.waveRed; + fg = c.red; + bg = c.red; }; cwd = { - fg = k.carpYellow; + fg = c.yellow; }; hovered = { reversed = true; @@ -43,54 +52,54 @@ let }; find_keyword = { - fg = k.waveRed; - bg = k.dragonBlack3; + fg = c.red; + bg = c.on_accent; }; find_position = { }; count_copied = { - fg = k.dragonBlack3; - bg = k.dragonGreen2; + fg = c.on_accent; + bg = c.green; }; count_cut = { - fg = k.dragonBlack3; - bg = k.waveRed; + fg = c.on_accent; + bg = c.red; }; count_selected = { - fg = k.dragonBlack3; - bg = k.carpYellow; + fg = c.on_accent; + bg = c.yellow; }; border_symbol = "│"; border_style = { - fg = k.dragonWhite; + inherit (c) fg; }; }; mode = { normal_main = { - fg = k.dragonBlack3; - bg = k.dragonBlue2; + fg = c.on_accent; + bg = c.accent; }; normal_alt = { - fg = k.dragonBlue2; - bg = k.dragonBlack0; + fg = c.accent; + bg = c.bg_deep; }; select_main = { - fg = k.dragonBlack3; - bg = k.dragonPink; + fg = c.on_accent; + bg = c.pink; }; select_alt = { - fg = k.dragonPink; - bg = k.dragonBlack0; + fg = c.pink; + bg = c.bg_deep; }; unset_main = { - fg = k.dragonBlack3; - bg = k.carpYellow; + fg = c.on_accent; + bg = c.yellow; }; unset_alt = { - fg = k.carpYellow; - bg = k.dragonBlack0; + fg = c.yellow; + bg = c.bg_deep; }; }; @@ -104,47 +113,47 @@ let close = ""; }; overall = { - fg = k.springBlue; - bg = k.sumiInk0; + fg = c.blue; + bg = c.status_bg; }; progress_label = { - fg = k.dragonBlue2; - bg = k.dragonBlack0; + fg = c.accent; + bg = c.bg_deep; bold = true; }; progress_normal = { - fg = k.dragonBlack0; - bg = k.dragonBlack3; + fg = c.bg_deep; + bg = c.on_accent; }; progress_error = { - fg = k.dragonBlack0; - bg = k.dragonBlack3; + fg = c.bg_deep; + bg = c.on_accent; }; perm_type = { - fg = k.dragonGreen2; + fg = c.green; }; perm_read = { - fg = k.carpYellow; + fg = c.yellow; }; perm_write = { - fg = k.peachRed; + fg = c.peach; }; perm_exec = { - fg = k.waveAqua2; + fg = c.teal; }; perm_sep = { - fg = k.dragonPink; + fg = c.pink; }; }; pick = { border = { - fg = k.dragonAqua; + fg = c.border; }; active = { - fg = k.dragonPink; + fg = c.pink; bold = true; }; inactive = { }; @@ -152,7 +161,7 @@ let input = { border = { - fg = k.dragonAqua; + fg = c.border; }; title = { }; value = { }; @@ -163,7 +172,7 @@ let completion = { border = { - fg = k.dragonAqua; + fg = c.border; }; active = { reversed = true; @@ -173,11 +182,11 @@ let tasks = { border = { - fg = k.dragonAqua; + fg = c.border; }; title = { }; hovered = { - fg = k.dragonPink; + fg = c.pink; }; }; @@ -185,28 +194,28 @@ let cols = 2; separator = " - "; separator_style = { - fg = k.dragonGray; + fg = c.gray; }; mask = { - bg = k.sumiInk0; + bg = c.status_bg; }; rest = { - fg = k.dragonGray; + fg = c.gray; }; cand = { - fg = k.dragonBlue2; + fg = c.accent; }; desc = { - fg = k.sumiInk6; + fg = c.faint; }; }; help = { on = { - fg = k.waveAqua2; + fg = c.teal; }; run = { - fg = k.dragonPink; + fg = c.pink; }; desc = { }; hovered = { @@ -214,20 +223,20 @@ let bold = true; }; footer = { - fg = k.dragonBlack3; - bg = k.dragonWhite; + fg = c.on_accent; + bg = c.fg; }; }; notify = { title_info = { - fg = k.dragonGreen2; + fg = c.green; }; title_warn = { - fg = k.carpYellow; + fg = c.yellow; }; title_error = { - fg = k.peachRed; + fg = c.peach; }; }; @@ -236,43 +245,43 @@ let # images { mime = "image/*"; - fg = k.carpYellow; + fg = c.yellow; } # media { mime = "{audio,video}/*"; - fg = k.dragonPink; + fg = c.pink; } # archives { mime = "application/{zip,rar,7z*,tar,gzip,xz,zstd,bzip*,lzma,compress,archive,cpio,arj,xar,ms-cab*}"; - fg = k.waveRed; + fg = c.red; } # documents { mime = "application/{pdf,doc,rtf,vnd.*}"; - fg = k.waveAqua1; + fg = c.docs; } # broken links { url = "*"; is = "orphan"; - fg = k.dragonRed; + fg = c.orphan; } # executables { url = "*"; is = "exec"; - fg = k.autumnGreen; + fg = c.exec; } # fallback { url = "*"; - fg = k.dragonWhite; + inherit (c) fg; } { url = "*/"; - fg = k.dragonBlue2; + fg = c.accent; } ]; @@ -284,220 +293,220 @@ let }; }; - # tmTheme (syntect) — global colors plus per-scope rules. The first - # `settings` entry holds the editor-wide colors; the rest are scoped. - # - # Syntax token colors mirror the kanagawa.nvim **wave** `syn` table (the - # theme the user's neovim loads), so yazi's syntect previews match neovim's - # treesitter highlighting rather than the upstream dragon tmtheme's choices. + # tmTheme (syntect) — global editor colors plus per-scope rules. The first + # `settings` entry holds the editor-wide colors; the rest are scoped. Syntax + # token colors come from the flavor's `syn` table so yazi's syntect previews + # match the matching kanagawa.nvim variant. tmthemeAttrs = { - name = "Kanagawa Dragon"; + name = title; settings = [ { settings = { - background = k.sumiInk3; - caret = k.oldWhite; - foreground = k.fujiWhite; # wave ui.fg - invisibles = k.sumiInk6; - lineHighlight = k.waveBlue2; - selection = k.waveBlue2; - findHighlight = k.waveBlue2; - selectionBorder = k.dragonBlack2; # closest palette match to #222218 - gutterForeground = k.sumiInk6; + inherit (e) + background + caret + foreground + invisibles + lineHighlight + selection + findHighlight + selectionBorder + gutterForeground + ; }; } { name = "Comment"; scope = "comment"; - settings.foreground = k.fujiGray; # wave syn.comment + settings.foreground = s.comment; } { name = "String"; scope = "string"; - settings.foreground = k.springGreen; # wave syn.string + settings.foreground = s.string; } { name = "Number"; scope = "constant.numeric"; - settings.foreground = k.sakuraPink; # wave syn.number + settings.foreground = s.number; } { name = "Built-in constant"; scope = "constant.language"; - settings.foreground = k.surimiOrange; # wave syn.constant + settings.foreground = s.constant; } { name = "User-defined constant"; scope = "constant.character, constant.other"; - settings.foreground = k.surimiOrange; # wave syn.constant + settings.foreground = s.constant; } { name = "Variable"; scope = "variable"; - settings.foreground = k.fujiWhite; # wave syn.variable = fg + settings.foreground = s.variable; } { name = "Ruby's @variable"; scope = "variable.other.readwrite.instance"; - settings.foreground = k.fujiWhite; + settings.foreground = s.variable; } { name = "String interpolation"; scope = "constant.character.escaped, constant.character.escape, string source, string source.ruby"; - settings.foreground = k.springBlue; # wave syn.special1 + settings.foreground = s.special1; } { name = "Keyword"; scope = "keyword"; - settings.foreground = k.oniViolet; # wave syn.keyword + settings.foreground = s.keyword; } { name = "Operator"; scope = "keyword.operator"; - settings.foreground = k.boatYellow2; # wave syn.operator + settings.foreground = s.operator; } { name = "Storage"; scope = "storage"; - settings.foreground = k.oniViolet; # wave syn.keyword + settings.foreground = s.keyword; } { name = "Storage type"; scope = "storage.type"; - settings.foreground = k.waveAqua2; # wave syn.type + settings.foreground = s.type; } { name = "Class name"; scope = "entity.name.class"; - settings.foreground = k.waveAqua2; # wave syn.type + settings.foreground = s.type; } { name = "Inherited class"; scope = "entity.other.inherited-class"; - settings.foreground = k.waveAqua2; + settings.foreground = s.type; } { name = "Function name"; scope = "entity.name.function"; - settings.foreground = k.crystalBlue; # wave syn.fun + settings.foreground = s.fun; } { name = "Function argument"; scope = "variable.parameter"; - settings.foreground = k.oniViolet2; # wave syn.parameter + settings.foreground = s.parameter; } { name = "Tag name"; scope = "entity.name.tag"; - settings.foreground = k.springBlue; # wave syn.special1 + settings.foreground = s.special1; } { name = "Tag attribute"; scope = "entity.other.attribute-name"; - settings.foreground = k.carpYellow; # wave syn.identifier + settings.foreground = s.identifier; } { name = "Library function"; scope = "support.function"; - settings.foreground = k.crystalBlue; # wave syn.fun + settings.foreground = s.fun; } { name = "Library constant"; scope = "support.constant"; - settings.foreground = k.surimiOrange; # wave syn.constant + settings.foreground = s.constant; } { name = "Library class/type"; scope = "support.type, support.class"; - settings.foreground = k.waveAqua2; # wave syn.type + settings.foreground = s.type; } { name = "Library variable"; scope = "support.other.variable"; - settings.foreground = k.fujiWhite; # wave syn.variable = fg + settings.foreground = s.variable; } { name = "Invalid"; scope = "invalid"; - settings.foreground = k.peachRed; # wave syn.special3 + settings.foreground = s.special3; } { name = "Invalid deprecated"; scope = "invalid.deprecated"; - settings.foreground = k.katanaGray; # wave syn.deprecated + settings.foreground = s.deprecated; } { name = "JSON key"; scope = "meta.structure.dictionary.json string.quoted.double.json"; - settings.foreground = k.carpYellow; # wave syn.identifier (@property) + settings.foreground = s.identifier; } { name = "diff.header"; scope = "meta.diff, meta.diff.header"; - settings.foreground = k.springBlue; # wave syn.special1 + settings.foreground = s.special1; } { name = "diff.deleted"; scope = "markup.deleted"; - settings.background = k.winterRed; # wave diff.delete + settings.background = d.delete; } { name = "diff.inserted"; scope = "markup.inserted"; - settings.background = k.winterGreen; # wave diff.add + settings.background = d.add; } { name = "diff.changed"; scope = "markup.changed"; - settings.background = k.winterBlue; # wave diff.change + settings.background = d.change; } { scope = "constant.numeric.line-number.find-in-files - match"; - settings.foreground = k.sumiInk6; + settings.foreground = e.gutterForeground; } { scope = "entity.name.filename"; - settings.foreground = k.oldWhite; + settings.foreground = e.caret; } { scope = "message.error"; - settings.foreground = k.peachRed; # wave syn.special3 + settings.foreground = s.special3; } { name = "JSON Punctuation"; scope = "punctuation.definition.string.begin.json - meta.structure.dictionary.value.json, punctuation.definition.string.end.json - meta.structure.dictionary.value.json"; - settings.foreground = k.springViolet2; # wave syn.punct + settings.foreground = s.punct; } { name = "JSON Structure"; scope = "meta.structure.dictionary.json string.quoted.double.json"; - settings.foreground = k.carpYellow; # wave syn.identifier (@property) + settings.foreground = s.identifier; } { name = "JSON value string"; scope = "meta.structure.dictionary.value.json string.quoted.double.json"; - settings.foreground = k.springGreen; # wave syn.string + settings.foreground = s.string; } { name = "Escape Characters"; scope = "constant.character.escape"; - settings.foreground = k.springBlue; # wave syn.special1 + settings.foreground = s.special1; } { name = "Regular Expressions"; scope = "string.regexp"; - settings.foreground = k.boatYellow2; # wave syn.regex + settings.foreground = s.regex; } ]; - uuid = "592FC036-6BB7-4676-A2F5-2894D48C8E33"; + inherit uuid; colorSpaceName = "sRGB"; - semanticClass = "theme.dark.kanagawa-dragon"; + semanticClass = "theme.${appearance}.${name}"; }; tomlFile = (pkgs.formats.toml { }).generate "flavor.toml" flavorAttrs; xmlFile = pkgs.writeText "tmtheme.xml" (lib.generators.toPlist { escape = true; } tmthemeAttrs); in -pkgs.runCommand "yazi-kanagawa-dragon-flavor" { } '' +pkgs.runCommand "yazi-${name}-flavor" { } '' mkdir -p $out cp ${tomlFile} $out/flavor.toml cp ${xmlFile} $out/tmtheme.xml diff --git a/pkgs/yazi-kanagawa-flavor/flavors.nix b/pkgs/yazi-kanagawa-flavor/flavors.nix new file mode 100644 index 00000000..e9b619f5 --- /dev/null +++ b/pkgs/yazi-kanagawa-flavor/flavors.nix @@ -0,0 +1,30 @@ +# The four Yazi Kanagawa flavor specs, shared by modules/packages.nix (which +# exposes them as packages) and the yazi home-manager module (which installs +# them). `theme` names a table in lib.kanagawa.themes; uuids are stable. +[ + { + name = "kanagawa-kris"; + title = "Kanagawa Kris"; + uuid = "592FC036-6BB7-4676-A2F5-2894D48C8E33"; + theme = "kris"; + } + { + name = "kanagawa-wave"; + title = "Kanagawa Wave"; + uuid = "9B8F6F76-5FA0-4B82-957A-C2D3E30C2A67"; + theme = "wave"; + } + { + name = "kanagawa-dragon"; + title = "Kanagawa Dragon"; + uuid = "07CF885C-2FC3-4A0B-8501-BAA5D3093BEA"; + theme = "dragon"; + } + { + name = "kanagawa-lotus"; + title = "Kanagawa Lotus"; + uuid = "CA13E094-E572-410B-979E-F25794DC716B"; + appearance = "light"; + theme = "lotus"; + } +] diff --git a/scripts/render-yazi-palette.ts b/scripts/render-yazi-palette.ts new file mode 100755 index 00000000..d6578439 --- /dev/null +++ b/scripts/render-yazi-palette.ts @@ -0,0 +1,475 @@ +#!/usr/bin/env bun +// render-yazi-palette — regenerate the kanagawa palette documentation image +// from the theme's nix-generated flavor files. +// +// The flavor is now produced entirely by nix (flavor.toml via pkgs.formats.toml, +// tmtheme.xml via lib.generators.toPlist), so this script builds the +// `yazi-kanagawa-flavor` package and reads `flavor.toml` / `tmtheme.xml` from +// its store output — the exact files yazi consumes. +// +// Color *names* come from `lib/kanagawa.nix` (the shared name→hex palette, +// read with `nix eval`); usage comes from the flavor.toml tables and tmtheme +// scopes — so the image stays correct as the theme changes. +// +// Writes: modules/home-manager/yazi/_palette/kanagawa-.png (one each) +// +// Deps: bun, nix, ImageMagick (`magick`), and a SauceCodePro Nerd Font in the +// Nix store. Run from anywhere: `bun scripts/render-yazi-palette.ts`. + +import { spawnSync } from "bun"; +import { join } from "node:path"; + +const REPO = join(import.meta.dir, ".."); +const PALETTE_NIX = join(REPO, "lib/kanagawa/palette.nix"); +// One doc image per flavor, under an import-tree-excluded `_palette/` dir. +const OUT_DIR = join(REPO, "modules/home-manager/yazi/_palette"); +const FLAVORS = ["kris", "wave", "dragon", "lotus"] as const; + +const BG = "#16161d"; +const PANEL = "#181616"; +const FG = "#c5c9c5"; +const MUTED = "#a6a69c"; + +// --- shell helpers ---------------------------------------------------------- + +function sh(cmd: string): string { + const r = spawnSync(["bash", "-lc", cmd]); + return r.success ? r.stdout.toString().trim() : ""; +} + +function magick(args: string[]): void { + const r = spawnSync(["magick", ...args]); + if (!r.success) { + throw new Error(`magick failed: ${args.join(" ")}\n${r.stderr.toString()}`); + } +} + +function montage(args: string[]): void { + const r = spawnSync(["montage", ...args]); + if (!r.success) { + throw new Error(`montage failed: ${args.join(" ")}\n${r.stderr.toString()}`); + } +} + +function identifyWidth(path: string): number { + return Number(sh(`magick identify -format '%w' ${JSON.stringify(path)}`)); +} + +// Build a flavor derivation and return its store path ($out holds flavor.toml +// + tmtheme.xml). Errors out loudly — an empty result here would otherwise +// produce a blank image. +function buildFlavor(variant: string): string { + const out = sh( + `nix build ${JSON.stringify(REPO)}#yazi-kanagawa-${variant} --no-link --print-out-paths`, + ); + const path = out.split("\n").filter(Boolean).pop(); + if (!path) { + throw new Error( + `nix build .#yazi-kanagawa-${variant} produced no output path — is the flake evaluable?`, + ); + } + return path; +} + +// hex(lowercase) → kanagawa color name, from the shared palette. On the rare +// hex shared by several names, the alphabetically-first wins (dragon* sorts +// ahead of lotus*, which suits this dark flavor). +function loadNames(): Map { + const json = sh(`nix eval --json --file ${JSON.stringify(PALETTE_NIX)}`); + if (!json) throw new Error(`nix eval failed for ${PALETTE_NIX}`); + const map = new Map(); + for (const [name, hex] of Object.entries(JSON.parse(json) as Record)) { + const h = hex.toLowerCase(); + if (!map.has(h)) map.set(h, name); + } + return map; +} + +function findFont(style: "Regular" | "Bold"): string { + const env = process.env[style === "Bold" ? "FONT_BOLD" : "FONT_REGULAR"]; + if (env) return env; + const hit = sh( + `ls -d /nix/store/*nerd-fonts-sauce-code-pro*/share/fonts/truetype/NerdFonts/SauceCodePro/SauceCodeProNerdFontMono-${style}.ttf 2>/dev/null | head -1`, + ); + if (!hit) { + throw new Error( + `Could not find SauceCodePro ${style} in /nix/store. ` + + `Set FONT_${style === "Bold" ? "BOLD" : "REGULAR"} to a .ttf path.`, + ); + } + return hit; +} + +const FREG = findFont("Regular"); +const FBOLD = findFont("Bold"); + +// --- color / text utilities ------------------------------------------------- + +function luminance(hex: string): number { + const h = hex.replace("#", "").slice(0, 6); + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return (299 * r + 587 * g + 114 * b) / 1000; +} + +// Text colors that read on a given swatch background. +function textColors(bg: string): { main: string; sub: string } { + return luminance(bg) > 140 + ? { main: "#0d0c0c", sub: "#16161d" } + : { main: FG, sub: MUTED }; +} + +function truncate(s: string, n: number): string { + return s.length <= n ? s : s.slice(0, n - 1) + "…"; +} + +// --- swatch + layout primitives --------------------------------------------- + +const SWATCH_W = 560; +const SWATCH_H = 120; +const COLS = 3; +let tmpCounter = 0; +const TMP = sh("mktemp -d"); +process.on("exit", () => sh(`rm -rf ${JSON.stringify(TMP)}`)); + +interface Swatch { + bg: string; + name: string; + hex: string; + sub: string; +} + +function renderSwatch(s: Swatch): string { + const { main, sub } = textColors(s.bg); + const path = join(TMP, `s${tmpCounter++}.png`); + magick([ + "-size", + `${SWATCH_W}x${SWATCH_H}`, + `xc:${s.bg}`, + "-font", + FBOLD, + "-fill", + main, + "-gravity", + "West", + "-pointsize", + "30", + "-annotate", + "+24-28", + truncate(s.name, 28), + "-font", + FREG, + "-pointsize", + "26", + "-annotate", + "+24+2", + s.hex, + "-fill", + sub, + "-pointsize", + "16", + "-annotate", + "+24+34", + truncate(s.sub, 54), + path, + ]); + return path; +} + +function renderGrid(swatches: Swatch[]): string { + const paths = swatches.map(renderSwatch); + const path = join(TMP, `grid${tmpCounter++}.png`); + // `-font` is required even though tiles carry no labels: montage initializes + // a default font and aborts when fontconfig has no usable default. + montage([ + "-font", + FREG, + ...paths, + "-tile", + `${COLS}x`, + "-geometry", + "+8+8", + "-background", + BG, + path, + ]); + return path; +} + +function header(text: string, width: number, sub = ""): string { + const path = join(TMP, `h${tmpCounter++}.png`); + const args = [ + "-size", + `${width}x${sub ? 80 : 60}`, + `xc:${PANEL}`, + "-font", + FBOLD, + "-fill", + FG, + "-gravity", + "West", + "-pointsize", + "28", + "-annotate", + `+24${sub ? "-12" : "+0"}`, + text, + ]; + if (sub) { + args.push("-font", FREG, "-fill", MUTED, "-pointsize", "16", "-annotate", "+26+18", sub); + } + args.push(path); + magick(args); + return path; +} + +// --- parse flavor.toml ------------------------------------------------------- + +async function loadFlavor( + flavorPath: string, + names: Map, +): Promise { + const lines = (await Bun.file(flavorPath).text()).split("\n"); + + // Usage: where each hex appears, kept in first-seen order so swatches group + // by table. Labels are the dotted table path (e.g. `mgr.cwd`). + const usage = new Map(); + const add = (hex: string, where: string) => { + hex = hex.toLowerCase(); + const arr = usage.get(hex) ?? []; + if (!arr.includes(where)) arr.push(where); + usage.set(hex, arr); + }; + + // A `[[filetype.rules]]` block spreads its fg/bg and its discriminator + // (mime/is/url) across separate lines, so accumulate per block and flush on + // the next header / EOF. + let section = ""; + let ruleHexes: string[] = []; + let ruleDisc = ""; + const flushRule = () => { + for (const h of ruleHexes) { + add(h, `filetype.rules${ruleDisc ? `:${truncate(ruleDisc, 16)}` : ""}`); + } + ruleHexes = []; + ruleDisc = ""; + }; + + for (const raw of lines) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + + const header = line.match(/^\[\[?\s*([\w.]+)\s*\]\]?/); + if (header) { + flushRule(); + section = header[1]; + continue; + } + + const hexes = [...line.matchAll(/#[0-9a-fA-F]{3,8}/g)].map((m) => m[0]); + + if (section === "filetype.rules") { + ruleHexes.push(...hexes); + // Prefer the most specific discriminator in the block (mime/is over url). + const d = line.match(/^(mime|is|url)\s*=\s*"([^"]+)"/); + if (d && (!ruleDisc || d[1] !== "url")) ruleDisc = d[2]; + continue; + } + + for (const h of hexes) add(h, section); + } + flushRule(); + + // One swatch per used hex, named from the shared palette. + return [...usage.entries()].map(([hex, used]) => ({ + bg: hex, + name: names.get(hex) ?? "(unnamed)", + hex, + sub: + used.slice(0, 3).join(", ") + + (used.length > 3 ? ` +${used.length - 3}` : ""), + })); +} + +// --- parse tmtheme.xml (minimal plist) -------------------------------------- + +type PVal = string | PVal[] | { [k: string]: PVal }; + +function parsePlist(xml: string): PVal { + const decode = (s: string) => + s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + // Note the self-closing forms (``, ``, …): tmtheme uses + // `` for empty fontStyle values, and missing them desyncs the + // token stream. + const re = + /<\/?(?:dict|array)>|<(?:dict|array)\/>|[\s\S]*?<\/key>|<(?:string|integer|real)>[\s\S]*?<\/(?:string|integer|real)>|<(?:string|integer|real)\/>|<(?:true|false)\/>/g; + type Tok = + | { t: "open" | "close"; k: string } + | { t: "key" | "scalar"; v: string }; + const toks: Tok[] = []; + for (const m of xml.matchAll(re)) { + const s = m[0]; + if (s === "") toks.push({ t: "open", k: "dict" }); + else if (s === "") toks.push({ t: "close", k: "dict" }); + else if (s === "") toks.push({ t: "open", k: "array" }); + else if (s === "") toks.push({ t: "close", k: "array" }); + else if (s === "") toks.push({ t: "open", k: "dict" }, { t: "close", k: "dict" }); + else if (s === "") toks.push({ t: "open", k: "array" }, { t: "close", k: "array" }); + else if (s.startsWith("")) toks.push({ t: "key", v: decode(s.slice(5, -6)) }); + else if (/^<(?:string|integer|real)\/>$/.test(s)) toks.push({ t: "scalar", v: "" }); + else if (s === "" || s === "") toks.push({ t: "scalar", v: s.slice(1, -2) }); + else toks.push({ t: "scalar", v: decode(s.replace(/^<\w+>/, "").replace(/<\/\w+>$/, "")) }); + } + let p = 0; + const value = (): PVal => { + const tok = toks[p]; + if (tok.t === "open" && tok.k === "dict") { + p++; + const o: { [k: string]: PVal } = {}; + while (!(toks[p].t === "close" && (toks[p] as any).k === "dict")) { + const key = (toks[p++] as any).v as string; + o[key] = value(); + } + p++; + return o; + } + if (tok.t === "open" && tok.k === "array") { + p++; + const a: PVal[] = []; + while (!(toks[p].t === "close" && (toks[p] as any).k === "array")) { + a.push(value()); + } + p++; + return a; + } + p++; + return (tok as any).v as string; + }; + return value(); +} + +async function loadTmtheme( + tmthemePath: string, +): Promise<{ global: Swatch[]; syntax: Swatch[] }> { + const xml = await Bun.file(tmthemePath).text(); + const root = parsePlist(xml) as { [k: string]: PVal }; + const entries = root.settings as { [k: string]: PVal }[]; + + // First entry (no scope) holds the global editor colors. + const global: Swatch[] = []; + const editor = (entries[0]?.settings as { [k: string]: string }) ?? {}; + const globalByHex = new Map(); + for (const [key, hex] of Object.entries(editor)) { + if (typeof hex !== "string" || !hex.startsWith("#")) continue; + const arr = globalByHex.get(hex) ?? []; + arr.push(key); + globalByHex.set(hex, arr); + } + for (const [hex, keys] of globalByHex) { + global.push({ bg: hex, name: keys[0], hex, sub: keys.join(" · ") }); + } + + // Remaining entries are scoped syntax styles; dedup by foreground color. + const synByHex = new Map< + string, + { names: string[]; style: string } + >(); + for (const e of entries.slice(1)) { + const s = (e.settings as { [k: string]: string }) ?? {}; + const hex = s.foreground ?? s.background; + if (!hex || !hex.startsWith("#")) continue; + const cur = synByHex.get(hex) ?? { names: [], style: "" }; + const nm = (e.name as string) ?? (e.scope as string) ?? "?"; + if (!cur.names.includes(nm)) cur.names.push(nm); + if (s.fontStyle) cur.style = s.fontStyle; + synByHex.set(hex, cur); + } + const syntax: Swatch[] = [...synByHex.entries()].map(([hex, v]) => { + const tag = v.style ? `[${v.style}] ` : ""; + return { + bg: hex, + name: v.names[0], + hex, + sub: tag + v.names.slice(1).join(", "), + }; + }); + return { global, syntax }; +} + +// --- assemble ---------------------------------------------------------------- + +function titleCard(variant: string, width: number): string { + const path = join(TMP, `title-${variant}.png`); + magick([ + "-size", + `${width}x110`, + `xc:${BG}`, + "-font", + FBOLD, + "-fill", + FG, + "-gravity", + "West", + "-pointsize", + "42", + "-annotate", + "+28-12", + `yazi theme — kanagawa-${variant}`, + "-font", + FREG, + "-fill", + MUTED, + "-pointsize", + "18", + "-annotate", + "+30+26", + "regenerate with scripts/render-yazi-palette.ts", + path, + ]); + return path; +} + +async function renderFlavor(variant: string, names: Map) { + const store = buildFlavor(variant); + const flavor = await loadFlavor(join(store, "flavor.toml"), names); + const { global, syntax } = await loadTmtheme(join(store, "tmtheme.xml")); + + const flavorGrid = renderGrid(flavor); + const W = identifyWidth(flavorGrid); + const pieces = [ + titleCard(variant, W), + header(`UI theme · flavor.toml (${flavor.length} colors)`, W), + flavorGrid, + header( + `Syntax · tmtheme.xml — editor (${global.length})`, + W, + "background, foreground, caret, selection & friends", + ), + renderGrid(global), + header(`Syntax · tmtheme.xml — scopes (${syntax.length} unique colors)`, W), + renderGrid(syntax), + ]; + + const out = join(OUT_DIR, `kanagawa-${variant}.png`); + magick([ + "-background", + BG, + ...pieces, + "-append", + "-bordercolor", + BG, + "-border", + "16", + out, + ]); + const dims = sh(`magick identify -format '%wx%h' ${JSON.stringify(out)}`); + console.log( + `wrote ${out} (${dims}) — flavor: ${flavor.length} · editor: ${global.length} · scopes: ${syntax.length}`, + ); +} + +sh(`mkdir -p ${JSON.stringify(OUT_DIR)}`); +const names = loadNames(); +for (const variant of FLAVORS) { + await renderFlavor(variant, names); +}