diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-11 23:50:13 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-11 23:50:13 -0500 |
| commit | b137223e76c75217b594bc5e46e7625b10e31b0f (patch) | |
| tree | 67ff4b35bf65b91447e7524bd2315013f8848804 /docs/prototypes/gallery-widget.el | |
| parent | 440877965e8879f3c9567cf923b7c65c0eee227c (diff) | |
| download | archsetup-b137223e76c75217b594bc5e46e7625b10e31b0f.tar.gz archsetup-b137223e76c75217b594bc5e46e7625b10e31b0f.zip | |
feat(gallery): add svg.el renderer proving the Emacs target, fix gauge ticks
gallery-widget.el is the proof-of-concept Emacs renderer: it reads the generated tokens.el and renders the needle gauge (card 10) as SVG via svg.el. Rasterized through librsvg (the renderer Emacs itself uses), it matches the web card side by side: same arc, ticks, glowing amber needle, half-dome hub, and readout from the same tokens. That closes the riskiest unknown in the three-target plan, so the component pattern can now scale widget by widget.
The comparison surfaced a real gallery bug: the web gauge's ticks were invisible. Their transform-origin put the rotation center 40px below the dial, so the rotated ticks landed outside the clipped area. I moved the ticks to the arc's top edge with the origin at the pivot, and all three now show at -60/0/+60.
The parseability test caught a second bug: the mono token's font stack carries double quotes, which broke the font-family XML attribute. The renderer swaps them to single quotes.
Tests are ERT (tests/gallery-widgets/), covering token resolution, angle math (normal/boundary/error), and the rendered document's structure. make test-unit now runs the elisp suites alongside the python ones, and make test-elisp runs them alone.
Diffstat (limited to 'docs/prototypes/gallery-widget.el')
| -rw-r--r-- | docs/prototypes/gallery-widget.el | 166 |
1 files changed, 166 insertions, 0 deletions
diff --git a/docs/prototypes/gallery-widget.el b/docs/prototypes/gallery-widget.el new file mode 100644 index 0000000..61cd78a --- /dev/null +++ b/docs/prototypes/gallery-widget.el @@ -0,0 +1,166 @@ +;;; gallery-widget.el --- svg.el renderers for the panel widget gallery -*- lexical-binding: t; -*- + +;;; Commentary: +;; Proof-of-concept Emacs target for the panel widget gallery. The web +;; gallery (panel-widget-gallery.html) is the visual spec; tokens.json is +;; the shared source of truth, and gen_tokens.py emits tokens.el (the +;; `gallery-tokens' alist this file reads). Same tokens, same geometry, +;; different renderer — that's the reuse model: shared values and shared +;; SVG shapes, per-target code. +;; +;; The first widget is the needle gauge (gallery card 10): a semicircular +;; arc in the wash color, three steel ticks at -60/0/+60 degrees, an amber +;; needle with a soft glow (a real feGaussianBlur underlay, which librsvg — +;; Emacs's own SVG renderer — rasterizes), a gold hub, and a value readout. +;; +;; Usage: +;; (gallery-widget-needle-gauge 72) ; => svg dom object +;; (gallery-widget-svg-string (gallery-widget-needle-gauge 72)) +;; (gallery-widget-write-svg (gallery-widget-needle-gauge 72) "/tmp/g.svg") +;; In a live Emacs buffer: +;; (insert-image (svg-image (gallery-widget-needle-gauge 72))) + +;;; Code: + +(require 'svg) +(require 'dom) + +(load (expand-file-name "tokens.el" + (file-name-directory (or load-file-name buffer-file-name))) + nil t) + +(defun gallery-widget-token (name) + "Return the color/value string for token NAME from `gallery-tokens'. +Signal an error for an unknown NAME rather than silently returning nil — +a typo'd token must fail loudly, not render black." + (or (cdr (assq name gallery-tokens)) + (error "Unknown gallery token: %s" name))) + +;;; --- needle gauge (gallery card 10) --- + +(defconst gallery-widget--gauge-min-deg -60.0 + "Needle angle at value 0, degrees from vertical (negative = left).") +(defconst gallery-widget--gauge-max-deg 60.0 + "Needle angle at value 100.") + +(defun gallery-widget--needle-angle (value) + "Map VALUE (0-100, clamped) onto the gauge's needle angle in degrees." + (unless (numberp value) + (error "Gauge value must be a number, got %S" value)) + (let ((v (min 100.0 (max 0.0 (float value))))) + (+ gallery-widget--gauge-min-deg + (* (/ v 100.0) + (- gallery-widget--gauge-max-deg gallery-widget--gauge-min-deg))))) + +(defun gallery-widget--polar (cx cy radius angle-deg) + "Point at RADIUS from (CX . CY) at ANGLE-DEG from vertical, as (X . Y). +Positive angles swing right, matching the gauge's needle travel." + (let ((rad (* float-pi (/ angle-deg 180.0)))) + (cons (+ cx (* radius (sin rad))) + (- cy (* radius (cos rad)))))) + +(defun gallery-widget--fmt (n) + "Format coordinate N with two decimals for stable, readable SVG output." + (format "%.2f" n)) + +(defun gallery-widget--node (svg tag &rest attrs) + "Append a TAG element with ATTRS plist to SVG and return it." + (let ((node (dom-node tag + (cl-loop for (k v) on attrs by #'cddr + collect (cons (intern (substring (symbol-name k) 1)) + v))))) + (svg--append svg node) + node)) + +(defun gallery-widget-needle-gauge (value) + "Render the gallery's needle gauge at VALUE (0-100) as an svg.el object. +Geometry mirrors the web card: 96px-wide semicircular dial, pivot at its +bottom center, 40px needle sweeping -60..+60 degrees, readout below." + (let* ((w 96) (h 64) (cx 48.0) (cy 48.0) + (angle (gallery-widget--needle-angle value)) + (tip (gallery-widget--polar cx cy 40.0 angle)) + (svg (svg-create w h :viewBox (format "0 0 %d %d" w h)))) + ;; glow filter: the SVG equivalent of the web card's box-shadow bloom + (svg--append + svg + (dom-node 'defs nil + (dom-node 'filter + '((id . "gw-glow") (x . "-75%") (y . "-75%") + (width . "250%") (height . "250%")) + (dom-node 'feGaussianBlur + '((in . "SourceGraphic") + (stdDeviation . "2")))))) + ;; arc: 2px wash stroke, centerline radius 47 (96px circle, 2px border) + (gallery-widget--node svg 'path + :d "M 1 48 A 47 47 0 0 1 95 48" + :fill "none" + :stroke (gallery-widget-token 'wash) + :stroke-width "2") + ;; ticks: 8px steel radials at -60 / 0 / +60 + (dolist (deg '(-60.0 0.0 60.0)) + (let ((outer (gallery-widget--polar cx cy 47.0 deg)) + (inner (gallery-widget--polar cx cy 39.0 deg))) + (gallery-widget--node svg 'line + :class "tick" + :x1 (gallery-widget--fmt (car inner)) + :y1 (gallery-widget--fmt (cdr inner)) + :x2 (gallery-widget--fmt (car outer)) + :y2 (gallery-widget--fmt (cdr outer)) + :stroke (gallery-widget-token 'steel) + :stroke-width "1.5"))) + ;; needle glow underlay (blurred, translucent), then the crisp needle + (gallery-widget--node svg 'line + :class "needle-glow" + :x1 (gallery-widget--fmt cx) + :y1 (gallery-widget--fmt cy) + :x2 (gallery-widget--fmt (car tip)) + :y2 (gallery-widget--fmt (cdr tip)) + :stroke (gallery-widget-token 'gold-hi) + :stroke-width "4" + :stroke-linecap "round" + :opacity "0.55" + :filter "url(#gw-glow)") + (gallery-widget--node svg 'line + :class "needle" + :x1 (gallery-widget--fmt cx) + :y1 (gallery-widget--fmt cy) + :x2 (gallery-widget--fmt (car tip)) + :y2 (gallery-widget--fmt (cdr tip)) + :stroke (gallery-widget-token 'gold-hi) + :stroke-width "2" + :stroke-linecap "round") + ;; hub: half-dome on the dial's bottom edge (the web card clips the + ;; lower half of its 8px circle; here the dome is drawn directly) + (gallery-widget--node svg 'path + :class "hub" + :d "M 44 48 A 4 4 0 0 1 52 48 Z" + :fill (gallery-widget-token 'gold)) + ;; value readout, matching the web card's cream bold figure + (svg-text svg (format "%d%%" (round value)) + :x 48 :y 62 + :fill (gallery-widget-token 'cream) + ;; the token's CSS font stack uses double quotes, which would + ;; break the XML attribute — SVG accepts single-quoted names + :font-family (replace-regexp-in-string + "\"" "'" (gallery-widget-token 'mono)) + :font-size "12" + :font-weight "700" + :text-anchor "middle") + svg)) + +;;; --- output helpers --- + +(defun gallery-widget-svg-string (svg) + "Return SVG (an svg.el dom object) serialized as an XML string." + (with-temp-buffer + (svg-print svg) + (buffer-string))) + +(defun gallery-widget-write-svg (svg file) + "Write SVG to FILE and return FILE." + (with-temp-file file + (svg-print svg)) + file) + +(provide 'gallery-widget) +;;; gallery-widget.el ends here |
