From fa5b28ea69f3bff0941f8a097a9746b7a67fa900 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 24 Jun 2026 14:44:28 -0400 Subject: feat(theme-studio): nerd-icons gallery as a hue-ordered icon grid The nerd-icons pane is now a grid: one row per color face, the rows ordered by hue so families cluster, distinct icons (deduped within a color) drawn in their color with the icon's nerd-font name beneath. A "preview:" dropdown above the grid picks the glyph size in points, with Left/Right arrows to step it. Single-pane apps show it disabled, naming the preview. This replaces the v1 legend in the pane, whose data is still captured for round-trip. build-nerd-icons-legend.el is now a library. A cj/nerd-icons-write-legend entry point requires nerd-icons only at write time, so the capture logic loads and unit-tests without it. It dedupes icons by name within a face, computes each face's native hue, and orders the groups by hue. Writing the test surfaced a latent bug: face-hsl used (cadr (assoc t spec)), which grabs the first keyword instead of the plist. It only worked because the real faces fall through to the face-foreground branch. I fixed it to a correct t-clause parse. Coverage: 7 ERT capture tests (dedupe, hue order, lightness tiebreak, name sort, skip rules), 4 Python validator edges, and browser gates for the grid and the size dropdown. Locate stays color-level: clicking a color flashes its icons, and clicking an icon flashes its color row. Icons aren't individually editable, so there's nothing per-icon to select. --- scripts/theme-studio/generate.py | 54 +++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 6 deletions(-) (limited to 'scripts/theme-studio/generate.py') diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index 6053aa62f..09c25d804 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -13,15 +13,18 @@ def read_json(name): return json.loads(read_text(name)) NERD_ICONS_LEGEND_FIELDS = ("key", "label", "face", "category", "glyph") +NERD_ICONS_GALLERY_GLYPH_FIELDS = ("glyph", "name") def load_nerd_icons_legend(path=None): """Return the nerd-icons legend rows, or None when the artifact is unusable. The legend is captured by build-nerd-icons-legend.el into nerd-icons-legend.json. - Absent, malformed, empty, or carrying a row without all five string fields - (key/label/face/category/glyph) -> None, with a warning, so the caller falls - back to the generic nerd-icons app instead of erroring. nerd-icons not being - installed at capture time yields an empty/absent file, which lands here as None. + The artifact is a JSON object {legend, gallery}; a legacy bare array is read as + the legend directly (back-compat). Absent, malformed, empty, or carrying a row + without all five string fields (key/label/face/category/glyph) -> None, with a + warning, so the caller falls back to the generic nerd-icons app instead of + erroring. nerd-icons not being installed at capture time yields an empty/absent + file, which lands here as None. """ path = path or os.path.join(HERE, "nerd-icons-legend.json") if not os.path.exists(path): @@ -29,10 +32,11 @@ def load_nerd_icons_legend(path=None): return None try: with open(path) as src: - rows = json.load(src) + data = json.load(src) except (json.JSONDecodeError, OSError) as exc: print(f"WARNING: nerd-icons legend malformed ({path}: {exc}); generic nerd-icons app") return None + rows = data.get("legend") if isinstance(data, dict) else data if not isinstance(rows, list) or not rows: print(f"WARNING: nerd-icons legend empty ({path}); generic nerd-icons app") return None @@ -44,6 +48,44 @@ def load_nerd_icons_legend(path=None): return None return rows +def load_nerd_icons_gallery(path=None): + """Return the nerd-icons gallery groups, or None when absent/unusable. + + The gallery (the full colored catalog) rides nerd-icons-legend.json under the + "gallery" key: a list of {face, hue, glyphs:[{glyph,name}]} groups captured by + build-nerd-icons-legend.el, one group per color face, ordered by hue. A legacy + array-only artifact (legend, no gallery), an absent/malformed file, or a + structurally invalid group -> None, so the caller simply omits the gallery while + the legend data still loads. Never raises. + """ + path = path or os.path.join(HERE, "nerd-icons-legend.json") + if not os.path.exists(path): + print(f"WARNING: nerd-icons gallery absent ({path}); legend without gallery") + return None + try: + with open(path) as src: + data = json.load(src) + except (json.JSONDecodeError, OSError) as exc: + print(f"WARNING: nerd-icons gallery malformed ({path}: {exc}); legend without gallery") + return None + groups = data.get("gallery") if isinstance(data, dict) else None + if not isinstance(groups, list) or not groups: + return None # legacy/array-only artifact: legend present, no gallery — not an error + for group in groups: + if not (isinstance(group, dict) + and isinstance(group.get("face"), str) and group["face"].startswith("nerd-icons-") + and isinstance(group.get("hue"), (int, float)) + and isinstance(group.get("glyphs"), list) and group["glyphs"]): + print(f"WARNING: nerd-icons gallery group invalid ({group!r}); legend without gallery") + return None + for entry in group["glyphs"]: + if not (isinstance(entry, dict) + and all(isinstance(entry.get(f), str) and entry.get(f) + for f in NERD_ICONS_GALLERY_GLYPH_FIELDS)): + print(f"WARNING: nerd-icons gallery glyph invalid ({entry!r}); legend without gallery") + return None + return groups + def strip_exports(src): """Drop ES-module `export`/`import` lines so the body loads as a classic