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`
|
green inputbar, yellow bottom-border). Remaining: author `.rasi`
|
||||||
identities for the other four ported themes if/when they want one (the
|
identities for the other four ported themes if/when they want one (the
|
||||||
generated palette theme is the default and looks fine)
|
generated palette theme is the default and looks fine)
|
||||||
- **Visual theme picker (preview thumbnails):** replace the plain-text theme
|
- ✓ **Visual theme picker (preview thumbnails):** `nomarchy-menu theme` is now
|
||||||
list in `nomarchy-menu theme` with a rofi **icon grid of real desktop
|
a rofi **icon grid of real desktop previews** instead of a plain-text list —
|
||||||
previews** — each theme shown as a screenshot of its themed desktop (waybar
|
each theme a screenshot of its themed desktop (waybar + floating terminal),
|
||||||
+ a floating terminal), name beneath, the active theme marked, with the
|
pretty name beneath, **grouped dark-first then light** in one scrollable grid
|
||||||
`↩ Back` entry. **Workflow:** Bernardo captures the previews on real
|
(the previews make the mode obvious, so no light/dark submenu split), the
|
||||||
hardware and commits them as `themes/<slug>/preview.png`; a build-time
|
active theme marked `✓`, ending in `↩ Back`. The grid, the Name→slug map and
|
||||||
derivation downscales each to a small thumbnail (imagemagick), and the
|
the active mark are all **generated at eval time** from the preset JSONs in
|
||||||
picker loads it as the rofi element icon — rofi already renders image files,
|
`rofi.nix` (`builtins.readDir` + `fromJSON`); "active" is just `t.slug`, since
|
||||||
which is how the Papirus menu icons work, so a `.png` path in the icon slot
|
every switch rebuilds the menu. Grid layout via a per-invocation `-theme-str`
|
||||||
Just Works. Grid layout via a per-invocation `-theme-str` (`listview {
|
(`listview { columns: 3; flow: horizontal; }` so Down scrolls row-by-row, +
|
||||||
columns; }` + a larger `element-icon`). Graceful fallback when a theme has
|
vertical `element` cards, name centred below). **Sizing gotcha (learned the
|
||||||
no `preview.png` yet: its wallpaper (`backgrounds/`), an accent swatch, or
|
hard way):** rofi's `element-icon` `size` is a **single value → a square
|
||||||
the plain name — so it degrades cleanly as previews are added incrementally.
|
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
|
(A headless VM-render route — `runNixOSTest` + software-GL Hyprland
|
||||||
(`LIBGL_ALWAYS_SOFTWARE` on virtio-gpu) + `machine.screenshot()` QMP
|
(`LIBGL_ALWAYS_SOFTWARE` on virtio-gpu) + `machine.screenshot()` QMP
|
||||||
framebuffer dump — was prototyped 2026-06-19 and **works** (themed waybar
|
framebuffer dump — was prototyped 2026-06-19 and **works** (themed waybar
|
||||||
|
|||||||
@@ -19,6 +19,92 @@ let
|
|||||||
rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi";
|
rasiOverride = cfg.themesDir + "/${t.slug}/rofi.rasi";
|
||||||
hasRasiOverride = builtins.pathExists rasiOverride;
|
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
|
# Keybindings cheatsheet (SUPER+? → the `keybinds` menu module). Built
|
||||||
# from the SAME ./keybinds.nix that hyprland.nix binds, so it can never
|
# 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
|
# drift from the live shortcuts. "$mod" reads as SUPER; rows are padded
|
||||||
@@ -108,9 +194,20 @@ let
|
|||||||
[ -n "$choice" ] && powerprofilesctl set "$choice" ;;
|
[ -n "$choice" ] && powerprofilesctl set "$choice" ;;
|
||||||
|
|
||||||
theme)
|
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"
|
[ "$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)
|
clipboard)
|
||||||
sel=$( { cliphist list; printf '%s\n' "$BACK"; } | rofi -dmenu -p clip) || exit 0
|
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 |