feat(menu): visual theme picker — rofi preview grid

Replace the plain-text `nomarchy-menu theme` list with a grid of real
desktop previews. The grid, the Name→slug map and the active `✓` mark
are generated at eval time from the preset JSONs (`readDir` + `fromJSON`
in rofi.nix); "active" is just `t.slug`, since every switch rebuilds the
menu. Grouped dark-first then light in one scrollable grid (the previews
make the mode obvious — no light/dark submenu split), `flow: horizontal`
so Down scrolls row-by-row.

Previews are committed as themes/<slug>/preview.png at 480×270 (~2.4 MB
for all 21). rofi's element-icon `size` is single-value → a *square*
cell that *contains* the icon, so a 16:9 preview would letterbox; a
build-time imagemagick step centre-crops each to a square so it fills
the cell edge-to-edge (source stays 16:9, crop is reversible). The
window width is derived from `themeGridIconW` so a column is exactly the
icon side — no slack margins. Themes without a preview degrade to a
plain-name row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 17:26:39 +01:00
parent 70334e68bb
commit e1cf190dd2
23 changed files with 123 additions and 15 deletions

View File

@@ -19,6 +19,92 @@ let
rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi";
hasRasiOverride = builtins.pathExists rasiOverride;
# ── Visual theme picker ──────────────────────────────────────────────
# Every preset (themes/<slug>.json) is read at eval time to build a grid
# of real desktop previews. Grouped dark-first then light — the previews
# make the mode obvious at a glance, so no light/dark submenu split — and
# the active theme is marked ✓. "Active" is just t.slug: every switch
# rebuilds this script, so the baked-in mark always tracks the live theme.
themeFiles = lib.filterAttrs
(n: ty: ty == "regular" && lib.hasSuffix ".json" n)
(builtins.readDir cfg.themesDir);
themeData = lib.mapAttrsToList (fname: _:
let
j = builtins.fromJSON (builtins.readFile (cfg.themesDir + "/${fname}"));
slug = j.slug or (lib.removeSuffix ".json" fname);
in {
inherit slug;
name = j.name or slug;
mode = j.mode or "dark";
hasPreview = builtins.pathExists (cfg.themesDir + "/${slug}/preview.png");
}) themeFiles;
byName = lib.sort (a: b: a.name < b.name);
orderedThemes =
byName (lib.filter (th: th.mode != "light") themeData) # dark group
++ byName (lib.filter (th: th.mode == "light") themeData); # then light
# rofi's element-icon cells are SQUARE, so a 16:9 preview would letterbox
# (theme-coloured bands top/bottom). Build-time crop each committed 480×270
# preview to a centred square so it fills the cell edge-to-edge. The source
# preview.png stays 480×270 (untouched) — only the displayed thumb is square.
themeThumbs = pkgs.runCommand "nomarchy-theme-thumbs"
{ nativeBuildInputs = [ pkgs.imagemagick ]; } ''
mkdir -p $out
${lib.concatMapStringsSep "\n"
(th: lib.optionalString th.hasPreview ''
magick ${cfg.themesDir + "/${th.slug}/preview.png"} \
-resize 360x360^ -gravity center -extent 360x360 -strip $out/${th.slug}.png
'')
orderedThemes}
'';
# One grid row per theme (square thumb + name, ✓ on the active one), and a
# Name→slug map so the picked label resolves back to the preset that
# nomarchy-theme-sync applies (pretty names ≠ slugs, hence the map). Themes
# with no preview degrade to a plain-name row.
themeRows = lib.concatMapStringsSep "\n" (th:
let label = th.name + lib.optionalString (th.slug == t.slug) " ";
in if th.hasPreview
then "row ${lib.escapeShellArg label} ${themeThumbs}/${th.slug}.png"
else "printf '%s\\n' ${lib.escapeShellArg label}")
orderedThemes;
themeSlugMap = lib.concatMapStringsSep "\n" (th:
" [${lib.escapeShellArg th.name}]=${lib.escapeShellArg th.slug}")
orderedThemes;
# Per-invocation grid layout (cards: a 16:9 preview above a centered name),
# overriding the single-column list theme just for this menu.
# · The icon box is sized 16:9 (rofi 2.0 takes a two-value `size`) to
# match the 480×270 previews exactly — they fill it with no letterbox.
# · The window is sized to the *content* (3 × icon + paddings), not a
# percentage, so a column is no wider than its card — otherwise the
# selection highlight balloons out around the image.
# · flow: horizontal lays cards out row-by-row (rofi's default Vertical
# fills column-by-column, so Down at a column's foot jumped to the top
# of the next column instead of scrolling the page).
# Grid dial — the previews are as big as the icon px allows; screen *height*
# caps it, so showing 2 rows (not 3) lets each card grow a lot. Scrolling
# reaches the rest. Tune themeGridIconW for size; the window resizes to fit.
# rofi's element-icon `size` is a SINGLE value (the manual only documents
# one) — a two-value "WxH" is silently collapsed, which is why the preview
# used to render tiny inside a too-wide column. So: size = the card *width*,
# rofi fits the 16:9 image tight to it (no square letterbox), and the window
# is sized so a column is exactly that width — the preview fills the cell.
themeGridCols = 3;
themeGridLines = 3;
themeGridIconW = 240; # square preview card side (px) — the size knob
themeGridPad = 0; # margin around the preview inside its cell (px)
themeGridGap = 8; # gap between cards (px)
themeGridThemeStr = lib.escapeShellArg (lib.concatStringsSep " " [
# window width = cards + their padding + inter-card gaps + chrome (~20px)
"window { width: ${toString (themeGridCols * (themeGridIconW + 2 * themeGridPad) + (themeGridCols - 1) * themeGridGap + 20)}px; }"
"listview { columns: ${toString themeGridCols}; lines: ${toString themeGridLines}; spacing: ${toString themeGridGap}px; flow: horizontal; }"
"element { orientation: vertical; padding: ${toString themeGridPad}px; spacing: 2px; }"
"element-icon { size: ${toString themeGridIconW}px; }"
"element-text { horizontal-align: 0.5; }"
]);
# Keybindings cheatsheet (SUPER+? → the `keybinds` menu module). Built
# from the SAME ./keybinds.nix that hyprland.nix binds, so it can never
# drift from the live shortcuts. "$mod" reads as SUPER; rows are padded
@@ -108,9 +194,20 @@ let
[ -n "$choice" ] && powerprofilesctl set "$choice" ;;
theme)
choice=$( { nomarchy-theme-sync list; printf '%s\n' "$BACK"; } | rofi -dmenu -p theme) || exit 0
# Visual picker: a grid of real desktop previews (rows + Nameslug
# map generated from the presets at build time). Pick a card apply
# that slug. The grid layout is a per-invocation -theme-str override.
declare -A THEME_SLUG=(
${themeSlugMap}
)
choice=$( {
${themeRows}
back
} | rofi -dmenu -show-icons -p Theme -theme-str ${themeGridThemeStr}) || exit 0
[ "$choice" = "$BACK" ] && exec "$0"
[ -n "$choice" ] && exec nomarchy-theme-sync apply "$choice" ;;
choice="''${choice% }" # drop the active marker if present
slug="''${THEME_SLUG[$choice]:-}"
[ -n "$slug" ] && exec nomarchy-theme-sync apply "$slug" ;;
clipboard)
sel=$( { cliphist list; printf '%s\n' "$BACK"; } | rofi -dmenu -p clip) || exit 0