Skip to main content

ling_ui/
widgets.rs

1//! Professional vector UI toolkit — HUD overlays, meters/gauges, interface
2//! controls and game UI, all emitted as resolved vector primitives so a single
3//! widget can be multi-colour and the host can rasterize (AA fill + AA stroke)
4//! however it likes. Pure, allocation-light, immediate-mode: call a function
5//! each frame with the live params and draw the returned [`Draw`].
6//!
7//! Conventions: screen space, x→right, **y→down**, origin top-left. Colours are
8//! packed `0x00RRGGBB` (the Ling framebuffer format). Interactive state
9//! (hover/active/value) is passed in by the host; these functions are pure
10//! geometry and never read input themselves.
11
12use core::f32::consts::PI;
13use crate::holo;
14
15/// Packed `0x00RRGGBB` colour.
16pub type Rgba = u32;
17
18/// Resolved vector output: filled polygons + stroked polylines, each with its
19/// own colour so one widget can mix theme slots (track vs fill vs accent).
20#[derive(Default, Clone)]
21pub struct Draw {
22    pub fills:   Vec<(Rgba, Vec<[f32; 2]>)>,
23    pub strokes: Vec<(Rgba, Vec<[f32; 2]>)>,
24}
25
26impl Draw {
27    fn new() -> Self { Self::default() }
28    fn fill(&mut self, c: Rgba, poly: Vec<[f32; 2]>) { self.fills.push((c, poly)); }
29    fn stroke(&mut self, c: Rgba, pl: Vec<[f32; 2]>) { self.strokes.push((c, pl)); }
30    /// Stroke a list of disjoint segments `[x0,y0,x1,y1]` (e.g. from `holo`).
31    fn segs(&mut self, c: Rgba, segs: &[[f32; 4]]) {
32        for s in segs { self.strokes.push((c, vec![[s[0], s[1]], [s[2], s[3]]])); }
33    }
34    fn rect_outline(&mut self, c: Rgba, x: f32, y: f32, w: f32, h: f32) {
35        self.stroke(c, vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h], [x, y]]);
36    }
37    fn rect_fill(&mut self, c: Rgba, x: f32, y: f32, w: f32, h: f32) {
38        self.fill(c, vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h], [x, y]]);
39    }
40}
41
42// ── small helpers ─────────────────────────────────────────────────────────────
43
44#[inline] fn clamp01(v: f32) -> f32 { v.max(0.0).min(1.0) }
45
46/// Mix two packed colours by `t∈[0,1]`.
47pub fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba {
48    let t = clamp01(t);
49    let lerp = |x: u32, y: u32| (x as f32 + (y as f32 - x as f32) * t) as u32 & 0xFF;
50    let (ar, ag, ab) = (a >> 16 & 0xFF, a >> 8 & 0xFF, a & 0xFF);
51    let (br, bg, bb) = (b >> 16 & 0xFF, b >> 8 & 0xFF, b & 0xFF);
52    (lerp(ar, br) << 16) | (lerp(ag, bg) << 8) | lerp(ab, bb)
53}
54
55/// Scale a colour's brightness by `k` (clamped).
56pub fn shade(c: Rgba, k: f32) -> Rgba {
57    let f = |x: u32| ((x as f32 * k).max(0.0).min(255.0)) as u32;
58    (f(c >> 16 & 0xFF) << 16) | (f(c >> 8 & 0xFF) << 8) | f(c & 0xFF)
59}
60
61/// Arc polyline from `a0` to `a1` radians (y-down screen space), `n` segments.
62fn arc(cx: f32, cy: f32, r: f32, a0: f32, a1: f32, n: usize) -> Vec<[f32; 2]> {
63    let n = n.max(1);
64    (0..=n).map(|i| {
65        let a = a0 + (a1 - a0) * i as f32 / n as f32;
66        [cx + a.cos() * r, cy + a.sin() * r]
67    }).collect()
68}
69
70/// Beveled (cut-corner) panel polygon — the holographic panel silhouette.
71fn bevel_poly(x: f32, y: f32, w: f32, h: f32, b: f32) -> Vec<[f32; 2]> {
72    let b = b.min(w * 0.5).min(h * 0.5);
73    let (x1, y1) = (x + w, y + h);
74    vec![[x + b, y], [x1 - b, y], [x1, y + b], [x1, y1 - b],
75         [x1 - b, y1], [x + b, y1], [x, y1 - b], [x, y + b], [x + b, y]]
76}
77
78// ════════════════════════════════════════════════════════════════════════════
79// HUD / sci-fi overlays
80// ════════════════════════════════════════════════════════════════════════════
81
82/// Circular radar with grid rings, cross axes and a sweeping wedge at `sweep`
83/// radians. `blip` ∈ [0,1] fraction along the sweep where a contact pings.
84pub fn radar(cx: f32, cy: f32, r: f32, sweep: f32, primary: Rgba, accent: Rgba, track: Rgba) -> Draw {
85    let mut d = Draw::new();
86    for k in 1..=3 { d.stroke(track, arc(cx, cy, r * k as f32 / 3.0, 0.0, PI * 2.0, 48)); }
87    d.stroke(track, vec![[cx - r, cy], [cx + r, cy]]);
88    d.stroke(track, vec![[cx, cy - r], [cx, cy + r]]);
89    // sweep wedge
90    let sw = 0.32;
91    let mut wedge = vec![[cx, cy]];
92    wedge.extend(arc(cx, cy, r, sweep - sw, sweep, 10));
93    wedge.push([cx, cy]);
94    d.fill(shade(accent, 0.5), wedge);
95    d.stroke(accent, vec![[cx, cy], [cx + sweep.cos() * r, cy + sweep.sin() * r]]);
96    // contact ping
97    let pr = r * 0.62;
98    let pa = sweep - 0.15;
99    let (bx, by) = (cx + pa.cos() * pr, cy + pa.sin() * pr);
100    d.fill(primary, arc(bx, by, 3.5, 0.0, PI * 2.0, 10));
101    d
102}
103
104/// Horizontal compass strip centred on `heading` (degrees). Width `w`, ticks
105/// every 15°, cardinal marks taller.
106pub fn compass(x: f32, y: f32, w: f32, h: f32, heading: f32, primary: Rgba, track: Rgba) -> Draw {
107    let mut d = Draw::new();
108    d.rect_outline(track, x, y, w, h);
109    let cx = x + w * 0.5;
110    let deg_per_px = 90.0 / w; // show ~90° across
111    let start = (heading - w * 0.5 * deg_per_px).floor() as i32;
112    let end = (heading + w * 0.5 * deg_per_px).ceil() as i32;
113    let mut deg = start - (start.rem_euclid(15));
114    while deg <= end {
115        let px = cx + (deg as f32 - heading) / deg_per_px;
116        if px >= x && px <= x + w {
117            let card = deg.rem_euclid(90) == 0;
118            let th = if card { h * 0.6 } else { h * 0.3 };
119            d.stroke(if card { primary } else { track }, vec![[px, y + h], [px, y + h - th]]);
120        }
121        deg += 15;
122    }
123    // centre marker
124    d.stroke(primary, vec![[cx, y - 4.0], [cx - 5.0, y - 12.0], [cx + 5.0, y - 12.0], [cx, y - 4.0]]);
125    d
126}
127
128/// Aiming reticle: ring + tick gaps + centre dot. `spread` ∈ [0,1] opens the gaps.
129pub fn reticle(cx: f32, cy: f32, r: f32, spread: f32, primary: Rgba) -> Draw {
130    let mut d = Draw::new();
131    let g = 0.25 + spread * 0.5;
132    for q in 0..4 {
133        let base = q as f32 * PI * 0.5 + PI * 0.25;
134        d.stroke(primary, arc(cx, cy, r, base + g * 0.5, base + PI * 0.5 - g * 0.5, 10));
135    }
136    let inner = r * 0.45 + spread * r * 0.4;
137    for k in 0..4 {
138        let a = k as f32 * PI * 0.5;
139        d.stroke(primary, vec![[cx + a.cos() * inner, cy + a.sin() * inner],
140                               [cx + a.cos() * (inner + 8.0), cy + a.sin() * (inner + 8.0)]]);
141    }
142    d.fill(primary, arc(cx, cy, 1.8, 0.0, PI * 2.0, 8));
143    d
144}
145
146/// Animated target brackets that close in as `lock` → 1 (0 = wide, 1 = snug).
147pub fn target(x: f32, y: f32, w: f32, h: f32, lock: f32, primary: Rgba, accent: Rgba) -> Draw {
148    let mut d = Draw::new();
149    let off = (1.0 - clamp01(lock)) * 14.0;
150    let col = mix(primary, accent, clamp01(lock));
151    let l = (w.min(h)) * 0.28;
152    let (xx, yy, ww, hh) = (x - off, y - off, w + off * 2.0, h + off * 2.0);
153    for seg in holo::corner_brackets(xx, yy, ww, hh, l) {
154        d.stroke(col, vec![[seg[0], seg[1]], [seg[2], seg[3]]]);
155    }
156    if lock > 0.5 { d.stroke(col, vec![[x + w * 0.5, y - off - 6.0], [x + w * 0.5, y]]); }
157    d
158}
159
160/// Sci-fi panel: beveled outline + faint fill + a header tab notch.
161pub fn panel(x: f32, y: f32, w: f32, h: f32, bevel: f32, primary: Rgba, bg: Rgba) -> Draw {
162    let mut d = Draw::new();
163    d.fill(bg, bevel_poly(x, y, w, h, bevel));
164    d.stroke(primary, bevel_poly(x, y, w, h, bevel));
165    // header bar
166    let hb = 16.0_f32.min(h * 0.25);
167    d.stroke(primary, vec![[x + bevel, y + hb], [x + w - bevel, y + hb]]);
168    d.fill(shade(primary, 0.35), vec![[x + bevel, y], [x + w * 0.4, y], [x + w * 0.4 - 6.0, y + hb], [x + bevel, y + hb]]);
169    d
170}
171
172/// Horizontal scanline overlay inside a rect (`density` lines).
173pub fn scanlines(x: f32, y: f32, w: f32, h: f32, density: usize, line: Rgba) -> Draw {
174    let mut d = Draw::new();
175    let n = density.max(1);
176    for i in 0..n {
177        let py = y + h * i as f32 / n as f32;
178        d.stroke(line, vec![[x, py], [x + w, py]]);
179    }
180    d
181}
182
183// ════════════════════════════════════════════════════════════════════════════
184// Meters & gauges
185// ════════════════════════════════════════════════════════════════════════════
186
187/// Linear bar: track outline + proportional fill (`value/max`).
188pub fn bar(x: f32, y: f32, w: f32, h: f32, frac: f32, fill: Rgba, track: Rgba) -> Draw {
189    let mut d = Draw::new();
190    d.rect_outline(track, x, y, w, h);
191    let fw = w * clamp01(frac);
192    if fw > 0.5 { d.rect_fill(fill, x + 1.0, y + 1.0, (fw - 2.0).max(0.0), h - 2.0); }
193    d
194}
195
196/// Segmented/notched bar — `segs` cells, `frac` lit proportionally.
197pub fn segbar(x: f32, y: f32, w: f32, h: f32, frac: f32, segs: usize, fill: Rgba, track: Rgba) -> Draw {
198    let mut d = Draw::new();
199    let n = segs.max(1);
200    let gap = 2.0;
201    let cw = (w - gap * (n as f32 - 1.0)) / n as f32;
202    let lit = (clamp01(frac) * n as f32).round() as usize;
203    for i in 0..n {
204        let cx = x + i as f32 * (cw + gap);
205        if i < lit { d.rect_fill(fill, cx, y, cw, h); }
206        else { d.rect_outline(track, cx, y, cw, h); }
207    }
208    d
209}
210
211/// Radial dial gauge with tick marks and a needle. Sweeps 225°→ -45° (270° arc).
212pub fn gauge(cx: f32, cy: f32, r: f32, frac: f32, needle: Rgba, accent: Rgba, track: Rgba) -> Draw {
213    let mut d = Draw::new();
214    let a0 = PI * 0.75;          // 135° (down-left)
215    let a1 = PI * 0.25 + PI * 2.0; // wrap to up-right (270° sweep clockwise)
216    let span = a1 - a0;
217    d.stroke(track, arc(cx, cy, r, a0, a1, 60));
218    // colored progress arc
219    d.stroke(accent, arc(cx, cy, r, a0, a0 + span * clamp01(frac), 60));
220    // ticks
221    for i in 0..=10 {
222        let a = a0 + span * i as f32 / 10.0;
223        let (c, s) = (a.cos(), a.sin());
224        d.stroke(track, vec![[cx + c * r, cy + s * r], [cx + c * (r - 7.0), cy + s * (r - 7.0)]]);
225    }
226    // needle
227    let a = a0 + span * clamp01(frac);
228    d.stroke(needle, vec![[cx, cy], [cx + a.cos() * (r - 4.0), cy + a.sin() * (r - 4.0)]]);
229    d.fill(needle, arc(cx, cy, 3.0, 0.0, PI * 2.0, 8));
230    d
231}
232
233/// Ring / arc progress meter (full circle minus a gap at the bottom).
234pub fn ring(cx: f32, cy: f32, r: f32, frac: f32, fill: Rgba, track: Rgba) -> Draw {
235    let mut d = Draw::new();
236    let a0 = PI * 0.5 + 0.4;
237    let a1 = PI * 0.5 - 0.4 + PI * 2.0;
238    let span = a1 - a0;
239    d.stroke(track, arc(cx, cy, r, a0, a1, 64));
240    d.stroke(fill, arc(cx, cy, r, a0, a0 + span * clamp01(frac), 64));
241    d
242}
243
244/// VU / spectrum bars from a list of levels (each 0..1).
245pub fn vu(x: f32, y: f32, w: f32, h: f32, levels: &[f32], fill: Rgba, peak: Rgba) -> Draw {
246    let mut d = Draw::new();
247    let n = levels.len().max(1);
248    let gap = 2.0;
249    let bw = (w - gap * (n as f32 - 1.0)) / n as f32;
250    for (i, &lv) in levels.iter().enumerate() {
251        let bx = x + i as f32 * (bw + gap);
252        let bh = h * clamp01(lv);
253        let c = mix(fill, peak, clamp01(lv));
254        d.rect_fill(c, bx, y + h - bh, bw, bh);
255    }
256    d
257}
258
259/// Sparkline polyline from a list of values (auto-scaled to [min,max]).
260pub fn spark(x: f32, y: f32, w: f32, h: f32, vals: &[f32], line: Rgba) -> Draw {
261    let mut d = Draw::new();
262    if vals.len() < 2 { return d; }
263    let (mut lo, mut hi) = (f32::MAX, f32::MIN);
264    for &v in vals { lo = lo.min(v); hi = hi.max(v); }
265    let rng = (hi - lo).max(1e-6);
266    let pl: Vec<[f32; 2]> = vals.iter().enumerate().map(|(i, &v)| {
267        [x + w * i as f32 / (vals.len() as f32 - 1.0), y + h - (v - lo) / rng * h]
268    }).collect();
269    d.stroke(line, pl);
270    d
271}
272
273/// Battery cell with bezel, terminal nub and proportional charge fill.
274pub fn battery(x: f32, y: f32, w: f32, h: f32, frac: f32, fill: Rgba, track: Rgba, warn: Rgba) -> Draw {
275    let mut d = Draw::new();
276    let bw = w - 4.0;
277    d.rect_outline(track, x, y, bw, h);
278    d.rect_fill(track, x + bw, y + h * 0.3, 4.0, h * 0.4); // terminal
279    let f = clamp01(frac);
280    let c = if f < 0.25 { warn } else { fill };
281    if f > 0.0 { d.rect_fill(c, x + 2.0, y + 2.0, (bw - 4.0) * f, h - 4.0); }
282    d
283}
284
285// ════════════════════════════════════════════════════════════════════════════
286// Interface controls  (host supplies hover/active/value; widget = geometry)
287// ════════════════════════════════════════════════════════════════════════════
288
289/// Beveled button background (label drawn by the host). `hover`/`active` brighten.
290pub fn button(x: f32, y: f32, w: f32, h: f32, hover: bool, active: bool, primary: Rgba, bg: Rgba) -> Draw {
291    let mut d = Draw::new();
292    let glow = if active { 0.55 } else if hover { 0.3 } else { 0.12 };
293    d.fill(shade(primary, glow), bevel_poly(x, y, w, h, 8.0));
294    d.stroke(if hover { mix(primary, 0xFFFFFF, 0.4) } else { primary }, bevel_poly(x, y, w, h, 8.0));
295    let _ = bg;
296    d
297}
298
299/// Toggle switch; `on` slides the knob and recolours the rail.
300pub fn toggle(x: f32, y: f32, w: f32, h: f32, on: bool, on_col: Rgba, track: Rgba) -> Draw {
301    let mut d = Draw::new();
302    let r = h * 0.5;
303    let rail = if on { on_col } else { track };
304    // rounded rail
305    let mut p = arc(x + r, y + r, r, PI * 0.5, PI * 1.5, 12);
306    p.extend(arc(x + w - r, y + r, r, PI * 1.5, PI * 2.5, 12));
307    p.push(p[0]);
308    d.stroke(rail, p);
309    let kx = if on { x + w - r } else { x + r };
310    d.fill(rail, arc(kx, y + r, r - 2.0, 0.0, PI * 2.0, 16));
311    d
312}
313
314/// Horizontal slider: track + filled portion + draggable knob at `frac`.
315pub fn slider(x: f32, y: f32, w: f32, frac: f32, hover: bool, fill: Rgba, track: Rgba) -> Draw {
316    let mut d = Draw::new();
317    d.stroke(track, vec![[x, y], [x + w, y]]);
318    let kx = x + w * clamp01(frac);
319    d.stroke(fill, vec![[x, y], [kx, y]]);
320    let kr = if hover { 8.0 } else { 6.0 };
321    d.fill(fill, arc(kx, y, kr, 0.0, PI * 2.0, 16));
322    d.stroke(mix(fill, 0xFFFFFF, 0.5), arc(kx, y, kr, 0.0, PI * 2.0, 16));
323    d
324}
325
326/// Checkbox; `checked` draws a tick.
327pub fn checkbox(x: f32, y: f32, s: f32, checked: bool, hover: bool, primary: Rgba, track: Rgba) -> Draw {
328    let mut d = Draw::new();
329    d.rect_outline(if hover { mix(primary, 0xFFFFFF, 0.4) } else { track }, x, y, s, s);
330    if checked {
331        d.stroke(primary, vec![[x + s * 0.22, y + s * 0.55], [x + s * 0.42, y + s * 0.75], [x + s * 0.8, y + s * 0.25]]);
332    }
333    d
334}
335
336/// Tab strip: `count` tabs, `active` highlighted. Returns per-tab rects via geometry.
337pub fn tabs(x: f32, y: f32, w: f32, h: f32, count: usize, active: usize, hover: i32, primary: Rgba, track: Rgba) -> Draw {
338    let mut d = Draw::new();
339    let n = count.max(1);
340    let tw = w / n as f32;
341    for i in 0..n {
342        let tx = x + i as f32 * tw;
343        if i == active {
344            d.fill(shade(primary, 0.3), vec![[tx, y], [tx + tw, y], [tx + tw, y + h], [tx, y + h], [tx, y]]);
345            d.stroke(primary, vec![[tx, y + h], [tx, y], [tx + tw, y], [tx + tw, y + h]]);
346        } else {
347            let c = if hover == i as i32 { mix(track, primary, 0.5) } else { track };
348            d.stroke(c, vec![[tx, y + h], [tx + tw, y + h]]);
349        }
350    }
351    d
352}
353
354/// Progress bar with subtle internal chevrons.
355pub fn progress(x: f32, y: f32, w: f32, h: f32, frac: f32, fill: Rgba, track: Rgba) -> Draw {
356    let mut d = Draw::new();
357    d.rect_outline(track, x, y, w, h);
358    let fw = (w - 2.0) * clamp01(frac);
359    if fw > 0.5 { d.rect_fill(fill, x + 1.0, y + 1.0, fw, h - 2.0); }
360    d
361}
362
363/// Tooltip / callout bubble with a pointer (label drawn by host).
364pub fn tooltip(x: f32, y: f32, w: f32, h: f32, primary: Rgba, bg: Rgba) -> Draw {
365    let mut d = Draw::new();
366    d.fill(bg, bevel_poly(x, y, w, h, 6.0));
367    d.stroke(primary, bevel_poly(x, y, w, h, 6.0));
368    d.fill(bg, vec![[x + 12.0, y + h], [x + 22.0, y + h], [x + 14.0, y + h + 8.0]]);
369    d.stroke(primary, vec![[x + 12.0, y + h], [x + 14.0, y + h + 8.0], [x + 22.0, y + h]]);
370    d
371}
372
373/// Stepper: [ - value + ] frame (host draws value). Returns the two button rects' geometry.
374pub fn stepper(x: f32, y: f32, w: f32, h: f32, hover_minus: bool, hover_plus: bool, primary: Rgba, track: Rgba) -> Draw {
375    let mut d = Draw::new();
376    let bw = h;
377    d.fill(shade(primary, if hover_minus { 0.4 } else { 0.12 }), bevel_poly(x, y, bw, h, 5.0));
378    d.stroke(primary, bevel_poly(x, y, bw, h, 5.0));
379    d.stroke(primary, vec![[x + bw * 0.3, y + h * 0.5], [x + bw * 0.7, y + h * 0.5]]);
380    let px = x + w - bw;
381    d.fill(shade(primary, if hover_plus { 0.4 } else { 0.12 }), bevel_poly(px, y, bw, h, 5.0));
382    d.stroke(primary, bevel_poly(px, y, bw, h, 5.0));
383    d.stroke(primary, vec![[px + bw * 0.3, y + h * 0.5], [px + bw * 0.7, y + h * 0.5]]);
384    d.stroke(primary, vec![[px + bw * 0.5, y + h * 0.3], [px + bw * 0.5, y + h * 0.7]]);
385    d.rect_outline(track, x + bw + 2.0, y, w - bw * 2.0 - 4.0, h);
386    d
387}
388
389// ════════════════════════════════════════════════════════════════════════════
390// Game UI
391// ════════════════════════════════════════════════════════════════════════════
392
393/// Health bar: notched fill that shifts colour low→high and can pulse (host
394/// passes a 0..1 `pulse`).
395pub fn healthbar(x: f32, y: f32, w: f32, h: f32, frac: f32, pulse: f32, full: Rgba, low: Rgba, track: Rgba) -> Draw {
396    let mut d = Draw::new();
397    d.rect_outline(track, x, y, w, h);
398    let f = clamp01(frac);
399    let mut c = mix(low, full, f);
400    if f < 0.3 { c = mix(c, 0xFFFFFF, pulse * 0.6); }
401    if f > 0.0 { d.rect_fill(c, x + 1.0, y + 1.0, (w - 2.0) * f, h - 2.0); }
402    for i in 1..10 { let nx = x + w * i as f32 / 10.0; d.stroke(track, vec![[nx, y], [nx, y + h]]); }
403    d
404}
405
406/// Radial cooldown wipe (clockwise from top). `frac` = remaining 1→0.
407pub fn cooldown(cx: f32, cy: f32, r: f32, frac: f32, fill: Rgba, track: Rgba) -> Draw {
408    let mut d = Draw::new();
409    d.stroke(track, arc(cx, cy, r, 0.0, PI * 2.0, 48));
410    let a0 = -PI * 0.5;
411    let mut wedge = vec![[cx, cy]];
412    wedge.extend(arc(cx, cy, r, a0, a0 + PI * 2.0 * clamp01(frac), 48));
413    wedge.push([cx, cy]);
414    d.fill(shade(fill, 0.55), wedge);
415    d
416}
417
418/// 7-segment style vector number for `value` with `digits` places.
419pub fn counter(x: f32, y: f32, dw: f32, dh: f32, value: i64, digits: usize, on: Rgba, off: Rgba) -> Draw {
420    let mut d = Draw::new();
421    let segmap: [u8; 10] = [0b1111110, 0b0110000, 0b1101101, 0b1111001, 0b0110011,
422                            0b1011011, 0b1011111, 0b1110000, 0b1111111, 0b1111011];
423    let gap = dw * 0.35;
424    let v = value.unsigned_abs();
425    for i in 0..digits {
426        let digit = ((v / 10u64.pow((digits - 1 - i) as u32)) % 10) as usize;
427        let mask = segmap[digit];
428        let dx = x + i as f32 * (dw + gap);
429        seven_seg(&mut d, dx, y, dw, dh, mask, on, off);
430    }
431    d
432}
433
434fn seven_seg(d: &mut Draw, x: f32, y: f32, w: f32, h: f32, mask: u8, on: Rgba, off: Rgba) {
435    let t = w * 0.12; // segment thickness
436    let mid = y + h * 0.5;
437    // segments: a(top) b(tr) c(br) d(bottom) e(bl) f(tl) g(mid) — bit6..bit0
438    let bars = [
439        (6, [x + t, y, w - 2.0 * t, t]),                  // a
440        (5, [x + w - t, y + t, t, h * 0.5 - 1.5 * t]),    // b
441        (4, [x + w - t, mid + 0.5 * t, t, h * 0.5 - 1.5 * t]), // c
442        (3, [x + t, y + h - t, w - 2.0 * t, t]),          // d
443        (2, [x, mid + 0.5 * t, t, h * 0.5 - 1.5 * t]),    // e
444        (1, [x, y + t, t, h * 0.5 - 1.5 * t]),            // f
445        (0, [x + t, mid - 0.5 * t, w - 2.0 * t, t]),      // g
446    ];
447    for (bit, r) in bars {
448        let c = if mask & (1 << bit) != 0 { on } else { off };
449        d.rect_fill(c, r[0], r[1], r[2], r[3]);
450    }
451}
452
453/// Minimap frame with corner brackets (host plots blips separately).
454pub fn minimap(x: f32, y: f32, w: f32, h: f32, primary: Rgba, bg: Rgba) -> Draw {
455    let mut d = Draw::new();
456    d.fill(bg, vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h], [x, y]]);
457    for seg in holo::corner_brackets(x, y, w, h, 14.0) {
458        d.stroke(primary, vec![[seg[0], seg[1]], [seg[2], seg[3]]]);
459    }
460    d
461}
462
463/// Virtual D-pad / joystick; `dir` 0=none,1=up,2=right,3=down,4=left highlights.
464pub fn dpad(cx: f32, cy: f32, r: f32, dir: i32, primary: Rgba, track: Rgba) -> Draw {
465    let mut d = Draw::new();
466    let arm = r * 0.45;
467    let dirs = [(0.0, -1.0), (1.0, 0.0), (0.0, 1.0), (-1.0, 0.0)];
468    d.stroke(track, arc(cx, cy, r, 0.0, PI * 2.0, 32));
469    for (i, (dx, dy)) in dirs.iter().enumerate() {
470        let on = dir == i as i32 + 1;
471        let c = if on { primary } else { track };
472        let bx = cx + dx * r * 0.55;
473        let by = cy + dy * r * 0.55;
474        let tri = vec![
475            [bx + dx * arm + dy * arm * 0.4, by + dy * arm + dx * arm * 0.4],
476            [bx + dx * arm - dy * arm * 0.4, by + dy * arm - dx * arm * 0.4],
477            [bx - dx * arm * 0.3, by - dy * arm * 0.3],
478            [bx + dx * arm + dy * arm * 0.4, by + dy * arm + dx * arm * 0.4],
479        ];
480        if on { d.fill(c, tri.clone()); }
481        d.stroke(c, tri);
482    }
483    d
484}
485
486/// Item slot grid (`cols`×`rows`), `sel` index highlighted.
487pub fn slotgrid(x: f32, y: f32, cols: usize, rows: usize, cell: f32, sel: i32, primary: Rgba, track: Rgba) -> Draw {
488    let mut d = Draw::new();
489    let gap = 4.0;
490    for r in 0..rows {
491        for c in 0..cols {
492            let idx = (r * cols + c) as i32;
493            let cx = x + c as f32 * (cell + gap);
494            let cy = y + r as f32 * (cell + gap);
495            if idx == sel {
496                d.fill(shade(primary, 0.3), vec![[cx, cy], [cx + cell, cy], [cx + cell, cy + cell], [cx, cy + cell], [cx, cy]]);
497                d.rect_outline(primary, cx, cy, cell, cell);
498            } else {
499                d.rect_outline(track, cx, cy, cell, cell);
500            }
501        }
502    }
503    d
504}
505
506/// Full-screen damage / alert vignette — `intensity` ∈ [0,1] thickens the border glow.
507pub fn vignette(w: f32, h: f32, intensity: f32, col: Rgba) -> Draw {
508    let mut d = Draw::new();
509    let t = clamp01(intensity);
510    let layers = (1.0 + t * 10.0) as usize;
511    for i in 0..layers {
512        let inset = i as f32 * 3.0;
513        let c = shade(col, t * (1.0 - i as f32 / layers as f32));
514        d.rect_outline(c, inset, inset, (w - inset * 2.0).max(0.0), (h - inset * 2.0).max(0.0));
515    }
516    d
517}
518
519// ════════════════════════════════════════════════════════════════════════════
520// Faux-3D in 2D space  (screen-space rotate + perspective divide, no camera)
521// ════════════════════════════════════════════════════════════════════════════
522
523#[inline]
524fn proj3(p: [f32; 3], spin: f32, tilt: f32, cx: f32, cy: f32, scale: f32) -> [f32; 2] {
525    // rotate around Y (spin) then X (tilt), simple perspective divide
526    let (cs, sn) = (spin.cos(), spin.sin());
527    let (ct, st) = (tilt.cos(), tilt.sin());
528    let x1 = p[0] * cs + p[2] * sn;
529    let z1 = -p[0] * sn + p[2] * cs;
530    let y2 = p[1] * ct - z1 * st;
531    let z2 = p[1] * st + z1 * ct;
532    let f = 3.0 / (3.0 + z2);
533    [cx + x1 * scale * f, cy + y2 * scale * f]
534}
535
536/// Spinning wireframe ring gauge — a torus-ish band that rotates in 2D space,
537/// with `frac` lighting a leading arc.
538pub fn gauge3d(cx: f32, cy: f32, r: f32, frac: f32, spin: f32, fill: Rgba, track: Rgba) -> Draw {
539    let mut d = Draw::new();
540    let n = 48;
541    let lit = (clamp01(frac) * n as f32) as usize;
542    let mut prev = proj3([1.0, 0.0, 0.0], spin, 0.9, cx, cy, r);
543    for i in 1..=n {
544        let a = i as f32 / n as f32 * PI * 2.0;
545        let p = proj3([a.cos(), 0.0, a.sin()], spin, 0.9, cx, cy, r);
546        let c = if i <= lit { fill } else { track };
547        d.stroke(c, vec![prev, p]);
548        prev = p;
549    }
550    // spokes for depth cue
551    for k in 0..8 {
552        let a = k as f32 / 8.0 * PI * 2.0;
553        let o = proj3([a.cos(), 0.0, a.sin()], spin, 0.9, cx, cy, r);
554        let inn = proj3([a.cos() * 0.7, 0.0, a.sin() * 0.7], spin, 0.9, cx, cy, r);
555        d.stroke(shade(track, 0.7), vec![inn, o]);
556    }
557    d
558}
559
560/// Extruded / isometric panel — a beveled face lifted off the surface by `depth`.
561pub fn panel3d(x: f32, y: f32, w: f32, h: f32, depth: f32, primary: Rgba, bg: Rgba) -> Draw {
562    let mut d = Draw::new();
563    let dx = depth * 0.7;
564    let dy = -depth * 0.7;
565    let front = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
566    let back: Vec<[f32; 2]> = front.iter().map(|p| [p[0] + dx, p[1] + dy]).collect();
567    // side faces
568    d.fill(shade(primary, 0.18), vec![front[1], back[1], back[2], front[2], front[1]]);
569    d.fill(shade(primary, 0.28), vec![front[0], back[0], back[1], front[1], front[0]]);
570    for i in 0..4 { d.stroke(shade(primary, 0.6), vec![front[i], back[i]]); }
571    d.stroke(primary, back.iter().cloned().chain(std::iter::once(back[0])).collect());
572    d.fill(bg, front.iter().cloned().chain(std::iter::once(front[0])).collect());
573    d.stroke(primary, front.iter().cloned().chain(std::iter::once(front[0])).collect());
574    d
575}
576
577/// Perspective radar dish — concentric rings tilted into the screen, with a sweep.
578pub fn radar3d(cx: f32, cy: f32, r: f32, tilt: f32, sweep: f32, primary: Rgba, track: Rgba) -> Draw {
579    let mut d = Draw::new();
580    for k in 1..=3 {
581        let rr = r * k as f32 / 3.0;
582        let mut pl = Vec::new();
583        for i in 0..=40 {
584            let a = i as f32 / 40.0 * PI * 2.0;
585            pl.push(proj3([a.cos() * rr / r, 0.0, a.sin() * rr / r], 0.0, tilt, cx, cy, r));
586        }
587        d.stroke(track, pl);
588    }
589    let o = proj3([sweep.cos(), 0.0, sweep.sin()], 0.0, tilt, cx, cy, r);
590    let c = proj3([0.0, 0.0, 0.0], 0.0, tilt, cx, cy, r);
591    d.stroke(primary, vec![c, o]);
592    d
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn bar_fill_is_proportional() {
601        let empty = bar(0.0, 0.0, 100.0, 10.0, 0.0, 0xFFFFFF, 0x222222);
602        let half  = bar(0.0, 0.0, 100.0, 10.0, 0.5, 0xFFFFFF, 0x222222);
603        assert!(empty.fills.is_empty());
604        assert_eq!(half.fills.len(), 1);
605        // ~half width (minus the 2px inset)
606        let poly = &half.fills[0].1;
607        let wpx = poly[1][0] - poly[0][0];
608        assert!((wpx - 48.0).abs() < 2.0, "got {wpx}");
609    }
610
611    #[test]
612    fn segbar_lights_cells() {
613        let d = segbar(0.0, 0.0, 100.0, 10.0, 0.5, 10, 0xFFFFFF, 0x222222);
614        assert_eq!(d.fills.len(), 5); // 5 of 10 lit
615    }
616
617    #[test]
618    fn counter_renders_digits() {
619        let d = counter(0.0, 0.0, 12.0, 20.0, 42, 3, 0xFFFFFF, 0x111111);
620        // 3 digits × 7 segments each
621        assert_eq!(d.fills.len(), 21);
622    }
623
624    #[test]
625    fn mix_and_shade() {
626        assert_eq!(mix(0x000000, 0xFFFFFF, 0.5) & 0xFF, 0x7F);
627        assert_eq!(shade(0x808080, 2.0), 0xFFFFFF);
628    }
629}