;;; 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