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>
@@ -88,19 +88,30 @@ how to override it. Items marked ✓ are shipped.
|
||||
green inputbar, yellow bottom-border). Remaining: author `.rasi`
|
||||
identities for the other four ported themes if/when they want one (the
|
||||
generated palette theme is the default and looks fine)
|
||||
- **Visual theme picker (preview thumbnails):** replace the plain-text theme
|
||||
list in `nomarchy-menu theme` with a rofi **icon grid of real desktop
|
||||
previews** — each theme shown as a screenshot of its themed desktop (waybar
|
||||
+ a floating terminal), name beneath, the active theme marked, with the
|
||||
`↩ Back` entry. **Workflow:** Bernardo captures the previews on real
|
||||
hardware and commits them as `themes/<slug>/preview.png`; a build-time
|
||||
derivation downscales each to a small thumbnail (imagemagick), and the
|
||||
picker loads it as the rofi element icon — rofi already renders image files,
|
||||
which is how the Papirus menu icons work, so a `.png` path in the icon slot
|
||||
Just Works. Grid layout via a per-invocation `-theme-str` (`listview {
|
||||
columns; }` + a larger `element-icon`). Graceful fallback when a theme has
|
||||
no `preview.png` yet: its wallpaper (`backgrounds/`), an accent swatch, or
|
||||
the plain name — so it degrades cleanly as previews are added incrementally.
|
||||
- ✓ **Visual theme picker (preview thumbnails):** `nomarchy-menu theme` is now
|
||||
a rofi **icon grid of real desktop previews** instead of a plain-text list —
|
||||
each theme a screenshot of its themed desktop (waybar + floating terminal),
|
||||
pretty name beneath, **grouped dark-first then light** in one scrollable grid
|
||||
(the previews make the mode obvious, so no light/dark submenu split), the
|
||||
active theme marked `✓`, ending in `↩ Back`. The grid, the Name→slug map and
|
||||
the active mark are all **generated at eval time** from the preset JSONs in
|
||||
`rofi.nix` (`builtins.readDir` + `fromJSON`); "active" is just `t.slug`, since
|
||||
every switch rebuilds the menu. Grid layout via a per-invocation `-theme-str`
|
||||
(`listview { columns: 3; flow: horizontal; }` so Down scrolls row-by-row, +
|
||||
vertical `element` cards, name centred below). **Sizing gotcha (learned the
|
||||
hard way):** rofi's `element-icon` `size` is a **single value → a square
|
||||
cell** (a two-value `WxH` is silently collapsed), and the icon is *contained*
|
||||
in that square. So a 16:9 preview letterboxes (theme-coloured bands top/
|
||||
bottom), and a cell can't be shorter-than-square without the bands returning.
|
||||
The fix: a build-time imagemagick step **centre-crops each preview to a
|
||||
square** so it fills the cell edge-to-edge; the window width is derived so a
|
||||
column is exactly the icon side (no slack margins). One knob, `themeGridIconW`
|
||||
(240px), drives both icon and window. **Workflow:** Bernardo captures previews
|
||||
on real hardware and commits them as `themes/<slug>/preview.png`, **already
|
||||
downscaled to 480×270** (~2.4 MB total for all 21, vs ~28 MB full-res) — the
|
||||
source stays 16:9 and untouched; only the *displayed* thumb is squared at
|
||||
build, so the crop is reversible. Graceful fallback when a theme has no
|
||||
`preview.png`: a plain-name row, so it degrades cleanly.
|
||||
(A headless VM-render route — `runNixOSTest` + software-GL Hyprland
|
||||
(`LIBGL_ALWAYS_SOFTWARE` on virtio-gpu) + `machine.screenshot()` QMP
|
||||
framebuffer dump — was prototyped 2026-06-19 and **works** (themed waybar
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
themes/catppuccin-latte/preview.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
themes/catppuccin/preview.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
themes/ethereal/preview.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
themes/everforest/preview.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
themes/flexoki-light/preview.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
themes/gruvbox/preview.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
themes/hackerman/preview.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
themes/kanagawa/preview.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
themes/lumon/preview.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
themes/matte-black/preview.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
themes/miasma/preview.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
themes/nord/preview.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
themes/osaka-jade/preview.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
themes/retro-82/preview.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
themes/ristretto/preview.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
themes/rose-pine/preview.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
themes/summer-day/preview.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
themes/summer-night/preview.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
themes/tokyo-night/preview.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
themes/vantablack/preview.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
themes/white/preview.png
Normal file
|
After Width: | Height: | Size: 49 KiB |