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:
@@ -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 + Name→slug
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user