Skip to main content

kui_core/
fragment.rs

1//! A fragment: WGSL an app registers, validated here so a frame never sees
2//! a source that cannot compile
3//! (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`).
4//!
5//! The app writes one function:
6//!
7//! ```wgsl
8//! fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
9//!     let t = in.local.y / max(in.size.y, 1.0);
10//!     return mix(params[0], params[1], t);
11//! }
12//! ```
13//!
14//! and this module puts [`PRELUDE`] in front of it and [`EPILOGUE`] behind
15//! it. The prelude declares what the function reads; the epilogue is the
16//! entry point that calls it and then does what every other quad gets for
17//! free — the node's rounded box, the inherited clip (rounded when an
18//! ancestor rounds it), the group opacity, and the premultiply the blend
19//! expects. An app never writes a pipeline, a bind group, a clip or a
20//! blend, and cannot get any of them wrong.
21//!
22//! [`module_source`] is the one place that assembles the three, so the
23//! text the core validates at registration is character for character the
24//! text a backend compiles. `kui-wgpu` calls it rather than building its
25//! own.
26//!
27//! Everything this module declares is spelled `kui_` or `KUI_` so an app's
28//! own names cannot collide with it. The two names that are the contract —
29//! `FragmentIn` and `fragment` — are not prefixed, because they are what
30//! the app writes.
31
32/// Where a node's [`Draw`] lives in the frame's [`FragmentList`].
33#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub struct FragmentDrawId(pub u32);
35
36/// What a `fragment` node names: the function, and the image it reads
37/// through `kui_sample` if it declared one (backlog V1, ADR 0025 decision
38/// 7). Every fragment door takes `impl Into<FragmentRef>`, so a bare
39/// [`FragmentId`](crate::resources::FragmentId) is the no-image form and
40/// `id.with_image(img)` the other; nothing else about the node changes.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct FragmentRef {
43    pub id: crate::resources::FragmentId,
44    pub image: Option<crate::resources::ImageId>,
45}
46
47impl From<crate::resources::FragmentId> for FragmentRef {
48    fn from(id: crate::resources::FragmentId) -> Self {
49        FragmentRef { id, image: None }
50    }
51}
52
53impl crate::resources::FragmentId {
54    /// This function reading `image`: what `kui_sample(uv)` returns texels
55    /// of, and `FragmentIn::image` the texel rect of.
56    pub fn with_image(self, image: crate::resources::ImageId) -> FragmentRef {
57        FragmentRef {
58            id: self,
59            image: Some(image),
60        }
61    }
62}
63
64/// One `fragment` node's draw as the builder records it: what the node
65/// declared, before emission resolves the image to an atlas slot or a
66/// texture entry (which is per window, so it cannot happen here) and
67/// writes the wire form, [`crate::display::FragmentDraw`].
68#[derive(Clone, Copy, Debug, PartialEq)]
69pub struct Draw {
70    pub id: crate::resources::FragmentId,
71    pub image: Option<crate::resources::ImageId>,
72    /// The view's `params`, zero-padded to sixteen.
73    pub params: [f32; 16],
74}
75
76/// The frame's fragment draws, one per `fragment` node, indexed by the
77/// [`FragmentDrawId`] the node's `NodeContent` carries.
78///
79/// It is a side list for the same reason `DisplayList::fragments` is: a
80/// handle and sixteen floats is 72 bytes, and `NodeContent` is one entry
81/// per node on a tree that pushes 10,000 of them (C15). Four bytes on the
82/// node, the rest here.
83///
84/// The previous frame's draws are kept while an `exit` needs them, by the
85/// same gated buffer swap `LineStore` and the text list use: a ghost is
86/// built out of the *previous* tree, whose nodes index the list that frame
87/// filled. A frame with no departure pays one `clear`.
88#[derive(Default)]
89pub struct FragmentList {
90    draws: crate::retain::Kept<Draw>,
91}
92
93impl FragmentList {
94    /// Starts a frame. `keep_prev` retains the list just finished so a
95    /// departing fragment can copy its draw out of it.
96    pub(crate) fn begin_frame(&mut self, keep_prev: bool) {
97        self.draws.begin(keep_prev);
98    }
99
100    /// Records a draw and returns where it went.
101    pub(crate) fn push(&mut self, draw: Draw) -> FragmentDrawId {
102        let id = FragmentDrawId(self.draws.len() as u32);
103        self.draws.push(draw);
104        id
105    }
106
107    pub(crate) fn get(&self, id: FragmentDrawId) -> Draw {
108        self.draws[id.0 as usize]
109    }
110
111    /// A draw from the frame before this one — what a ghost copies. Out of
112    /// range when the previous list was not kept, which is a departure the
113    /// swap did not expect; it draws nothing rather than something else's
114    /// picture.
115    pub(crate) fn prev_get(&self, id: FragmentDrawId) -> Option<Draw> {
116        self.draws.prev().get(id.0 as usize).copied()
117    }
118}
119
120/// An app's `params` as the shader takes them: the first sixteen numbers,
121/// zero-padded. The second value is how many were dropped, which the
122/// builder turns into a `fragment-params-truncated` warning.
123pub fn params_of(params: &[f32]) -> ([f32; 16], usize) {
124    let mut out = [0.0f32; 16];
125    let n = params.len().min(16);
126    out[..n].copy_from_slice(&params[..n]);
127    (out, params.len().saturating_sub(16))
128}
129
130/// What kui declares before the app's source: the inputs its `fragment`
131/// function reads, the globals behind them, and the same rounded-box
132/// distance the renderer draws every node with, so a fragment that wants
133/// to draw a shape has the tool the core uses.
134///
135/// The `KuiGlobals` it declares must describe the same bytes as
136/// `kui_wgpu`'s `Globals` struct; that crate's `globals_layout_matches` is
137/// the test that says so.
138pub const PRELUDE: &str = r#"
139// The frame's own numbers, shared with the quad pipeline (group 0).
140struct KuiGlobals {
141    viewport: vec2<f32>,
142    atlas_size: vec2<f32>,
143    time: f32,
144    scale: f32,
145    _pad: vec2<f32>,
146};
147@group(0) @binding(0) var<uniform> kui_globals: KuiGlobals;
148// The atlas — or, for a fragment whose `image` has a texture of its own,
149// that texture bound in the atlas's place with `atlas_size` set to its
150// size, exactly as a texture-backed `image` node is drawn (ADR 0025).
151@group(0) @binding(1) var kui_atlas: texture_2d<f32>;
152@group(0) @binding(2) var kui_sampler: sampler;
153@group(0) @binding(3) var kui_sampler_nearest: sampler;
154
155// The texel rect of the node's `image` in `kui_atlas`, `[x, y, w, h]`;
156// zero with no image. Module-private so `kui_sample` needs no argument
157// for it; the epilogue sets it before calling `fragment`.
158var<private> kui_image_rect: vec4<f32>;
159
160// What an app's `fragment` function is given.
161struct FragmentIn {
162    // The pixel being painted, in the node's own space: physical px from
163    // the node's top-left corner, y down.
164    local: vec2<f32>,
165    // The node's size in physical px.
166    size: vec2<f32>,
167    // The frame clock in seconds, the same one transitions read. Only
168    // moves between frames, so a fragment that uses it wants `animate`.
169    time: f32,
170    // Physical px per logical px.
171    scale: f32,
172    // The quad's colour, straight alpha: white on a `fragment` node, the
173    // `bg` fill on a `polygon` (ADR 0025, decision 6). A fragment that
174    // wants a colour the view chose reads it here rather than spending
175    // four params on one.
176    color: vec4<f32>,
177    // The node's `image` as a texel rect, `[x, y, w, h]`, in whatever
178    // `kui_sample` reads from — the atlas or the image's own texture; the
179    // app never needs to know which. `zw` is the image's size in texels,
180    // which is what a data texture's row and column count are. Zero with
181    // no image.
182    image: vec4<f32>,
183};
184
185// The node's `image` at `uv`, `(0,0)` its top-left and `(1,1)` its
186// bottom-right, bilinear between texels and clamped half a texel in from
187// the rect's edge so the neighbour past it — a glyph, in the atlas —
188// never bleeds in. Transparent black with no image. Straight alpha, as
189// the image was registered.
190fn kui_sample(uv: vec2<f32>) -> vec4<f32> {
191    let r = kui_image_rect;
192    let lo = r.xy + vec2<f32>(0.5, 0.5);
193    let hi = r.xy + r.zw - vec2<f32>(0.5, 0.5);
194    let t = clamp(r.xy + clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)) * r.zw, lo, max(lo, hi));
195    let c = textureSample(kui_atlas, kui_sampler, t / kui_globals.atlas_size);
196    return select(vec4<f32>(0.0), c, r.z > 0.0 && r.w > 0.0);
197}
198
199// `kui_sample` reading the nearest texel instead of blending four — a
200// heatmap cell, a pixel-art sprite, anything whose texels are values.
201fn kui_sample_nearest(uv: vec2<f32>) -> vec4<f32> {
202    let r = kui_image_rect;
203    let lo = r.xy + vec2<f32>(0.5, 0.5);
204    let hi = r.xy + r.zw - vec2<f32>(0.5, 0.5);
205    let t = clamp(r.xy + clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)) * r.zw, lo, max(lo, hi));
206    let c = textureSample(kui_atlas, kui_sampler_nearest, t / kui_globals.atlas_size);
207    return select(vec4<f32>(0.0), c, r.z > 0.0 && r.w > 0.0);
208}
209
210// Half-width of every SDF edge ramp, in physical px. The renderer's `AA`.
211const KUI_AA: f32 = 0.75;
212
213// Signed distance to a box with one radius per corner. `p` is centered
214// (y down), `radii` is tl, tr, br, bl; each is clamped to the half extents
215// so oversized radii degrade to a pill, never a fold.
216fn kui_sd_rounded_box(p: vec2<f32>, half: vec2<f32>, radii: vec4<f32>) -> f32 {
217    let right = p.x > 0.0;
218    let bottom = p.y > 0.0;
219    let top_r = select(radii.x, radii.y, right);
220    let bottom_r = select(radii.w, radii.z, right);
221    let r = select(top_r, bottom_r, bottom);
222    let rr = min(r, min(half.x, half.y));
223    let q = abs(p) - half + vec2<f32>(rr, rr);
224    return length(max(q, vec2<f32>(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - rr;
225}
226"#;
227
228/// What kui declares after it: the parameters uniform and the entry point.
229///
230/// The locations are `VsOut`'s in `kui-wgpu/src/shader.wgsl`, because the
231/// vertex stage of a fragment pipeline is that file's `vs_main`. The
232/// coverage math is `shade`'s, for the solid case: a square node covers a
233/// pixel by the area of it inside the quad, a rounded one by its SDF
234/// ramped over `KUI_AA`, and a rounded clip the same way.
235pub const EPILOGUE: &str = r#"
236// The sixteen params, then the image's texel rect — one slot per draw,
237// laid out as `kui_wgpu`'s `FragmentParams`.
238struct KuiFragmentParams { p: array<vec4<f32>, 4>, image: vec4<f32> };
239@group(1) @binding(0) var<uniform> kui_fragment_params: KuiFragmentParams;
240
241@fragment
242fn kui_fs_fragment(
243    @builtin(position) frag_pos: vec4<f32>,
244    @location(0) local: vec2<f32>,
245    @location(1) size: vec2<f32>,
246    @location(2) color: vec4<f32>,
247    @location(6) clip: vec4<f32>,
248    @location(7) radii: vec4<f32>,
249    @location(8) clip_radii: vec4<f32>,
250) -> @location(0) vec4<f32> {
251    var kui_in: FragmentIn;
252    kui_in.local = local;
253    kui_in.size = size;
254    kui_in.time = kui_globals.time;
255    kui_in.scale = kui_globals.scale;
256    kui_in.color = vec4<f32>(color.rgb, 1.0);
257    kui_in.image = kui_fragment_params.image;
258    kui_image_rect = kui_fragment_params.image;
259    let kui_c = fragment(kui_in, kui_fragment_params.p);
260
261    // The node's own box, exactly as a solid gets it: a square one by the
262    // area of the pixel inside it, so one on whole pixels is solid to its
263    // edge and a stack of them meets without a seam; a rounded one by its
264    // SDF.
265    let kui_half = size * 0.5;
266    let kui_d = kui_sd_rounded_box(local - kui_half, kui_half, radii);
267    let kui_lo = max(local - vec2<f32>(0.5, 0.5), vec2<f32>(0.0, 0.0));
268    let kui_hi = min(local + vec2<f32>(0.5, 0.5), size);
269    let kui_area = clamp(kui_hi - kui_lo, vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0));
270    let kui_cov = select(
271        1.0 - smoothstep(-KUI_AA, KUI_AA, kui_d),
272        kui_area.x * kui_area.y,
273        all(radii <= vec4<f32>(0.0)),
274    );
275
276    // The inherited clip, in framebuffer space, rounded when rounded.
277    let kui_p = frag_pos.xy;
278    var kui_inside: f32;
279    if all(clip_radii <= vec4<f32>(0.0)) {
280        kui_inside = f32(
281            kui_p.x >= clip.x && kui_p.y >= clip.y
282            && kui_p.x <= clip.x + clip.z && kui_p.y <= clip.y + clip.w
283        );
284    } else {
285        let kui_ch = clip.zw * 0.5;
286        let kui_cd = kui_sd_rounded_box(kui_p - (clip.xy + kui_ch), kui_ch, clip_radii);
287        kui_inside = 1.0 - smoothstep(-KUI_AA, KUI_AA, kui_cd);
288    }
289
290    // `color.a` is the group opacity the subtree inherited, times the fill
291    // alpha on a polygon; `rgb` reached the function as `in.color`, and a
292    // fragment that ignores it returns its own colour as it always did.
293    let kui_a = clamp(kui_c.a, 0.0, 1.0) * kui_cov * kui_inside * color.a;
294    return vec4<f32>(clamp(kui_c.rgb, vec3<f32>(0.0), vec3<f32>(1.0)) * kui_a, kui_a);
295}
296"#;
297
298/// The stock fragment a rounded span background is painted with once the
299/// frame has joined it with the ones it meets (backlog F101,
300/// `crate::join`): one piece of the shape per line, its quad as tall as
301/// the line and as wide as the piece and whatever of its neighbours'
302/// reach its corners can fill. The params are physical px from the
303/// quad's left: `params[0]` this piece `[a, b]` and the line above's,
304/// `params[1]` the line below's, the radius and which neighbours there
305/// are (1 above, 2 below); the fill is `in.color`, its alpha the quad's.
306///
307/// A corner is convex where this piece reaches past its neighbour on that
308/// side, a fillet past its end where the neighbour reaches past it
309/// (concave), square where the two end together, and round where there is
310/// no neighbour or it does not overlap. At a join the radius is half the
311/// step at most, and both pieces work it out from the same two ends, so
312/// the convex half above and the fillet below meet. The ends and the arcs
313/// are sampled sixteen times a pixel, and only near them; the pieces meet
314/// on whole pixels because a span's background is on them already.
315/// Registered by the core, as [`POLYGON`] is.
316pub const JOIN: &str = r#"
317fn join_radii(cx: f32, e: f32, has: bool, sx: f32, r: f32, lone: f32) -> vec2<f32> {
318    // A corner's (convex, concave) radii: `e` the neighbour's end on this
319    // side, `sx` outward.
320    if !has {
321        return vec2<f32>(lone, 0.0);
322    }
323    let d = (e - cx) * sx;
324    return vec2<f32>(min(r, max(-d, 0.0) * 0.5), min(r, max(d, 0.0) * 0.5));
325}
326
327fn join_cut(p: vec2<f32>, cx: f32, cy: f32, sx: f32, sy: f32, rc: f32) -> bool {
328    // Inside the piece's box, but outside a convex corner's arc.
329    let near = (cx - p.x) * sx < rc && (cy - p.y) * sy < rc;
330    let c = vec2<f32>(cx - sx * rc, cy - sy * rc);
331    return rc > 0.0 && near && distance(p, c) > rc;
332}
333
334fn join_fillet(p: vec2<f32>, cx: f32, cy: f32, sx: f32, sy: f32, rf: f32) -> bool {
335    // Past the piece's end, inside a concave corner's fillet.
336    let dx = (p.x - cx) * sx;
337    let dy = (cy - p.y) * sy;
338    let c = vec2<f32>(cx + sx * rf, cy - sy * rf);
339    return rf > 0.0 && dx >= 0.0 && dx < rf && dy >= 0.0 && dy < rf && distance(p, c) >= rf;
340}
341
342fn join_inside(p: vec2<f32>, h: f32, a: f32, b: f32, pv: vec2<f32>, hp: bool, nx: vec2<f32>, hn: bool, r: f32) -> bool {
343    let lone = min(r, (b - a) * 0.5);
344    let tl = join_radii(a, pv.x, hp, -1.0, r, lone);
345    let tr = join_radii(b, pv.y, hp, 1.0, r, lone);
346    let bl = join_radii(a, nx.x, hn, -1.0, r, lone);
347    let br = join_radii(b, nx.y, hn, 1.0, r, lone);
348    let in_box = p.x >= a && p.x < b && p.y >= 0.0 && p.y < h;
349    let cut = join_cut(p, a, 0.0, -1.0, -1.0, tl.x) || join_cut(p, b, 0.0, 1.0, -1.0, tr.x)
350        || join_cut(p, a, h, -1.0, 1.0, bl.x) || join_cut(p, b, h, 1.0, 1.0, br.x);
351    let fill = join_fillet(p, a, 0.0, -1.0, -1.0, tl.y) || join_fillet(p, b, 0.0, 1.0, -1.0, tr.y)
352        || join_fillet(p, a, h, -1.0, 1.0, bl.y) || join_fillet(p, b, h, 1.0, 1.0, br.y);
353    return (in_box && !cut) || fill;
354}
355
356fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
357    let a = params[0].x;
358    let b = params[0].y;
359    let pv = params[0].zw;
360    let nx = params[1].xy;
361    let h = in.size.y;
362    let r = min(params[1].z, h * 0.5);
363    let flags = u32(params[1].w + 0.5);
364    // A neighbour shapes the corners only where it overlaps this piece.
365    let hp = (flags & 1u) != 0u && pv.x < b && pv.y > a;
366    let hn = (flags & 2u) != 0u && nx.x < b && nx.y > a;
367    let x = in.local.x;
368    // Past the fillets nothing; away from both ends every pixel.
369    if x < a - r - 1.0 || x > b + r + 1.0 {
370        return vec4<f32>(0.0);
371    }
372    if x > a + r + 1.0 && x < b - r - 1.0 {
373        return vec4<f32>(in.color.rgb, 1.0);
374    }
375    var n = 0.0;
376    for (var i = 0; i < 4; i++) {
377        for (var j = 0; j < 4; j++) {
378            let o = vec2<f32>((f32(i) + 0.5) * 0.25 - 0.5, (f32(j) + 0.5) * 0.25 - 0.5);
379            if join_inside(in.local + o, h, a, b, pv, hp, nx, hn, r) {
380                n += 1.0;
381            }
382        }
383    }
384    return vec4<f32>(in.color.rgb, n / 16.0);
385}
386"#;
387
388/// The entry point [`EPILOGUE`] declares — what a backend names when it
389/// builds the pipeline.
390pub const ENTRY_POINT: &str = "kui_fs_fragment";
391
392/// How many vertices a `polygon` takes: one `vec2` per pair of the
393/// sixteen params.
394pub const POLYGON_MAX_POINTS: usize = 8;
395
396/// The stock fragment a `polygon` node paints with (ADR 0025, decision
397/// 6): up to eight vertices, one per `vec2` of the sixteen params, each
398/// normalised to the node's box — a polygon's box is its own bounding box
399/// inflated by a pixel, so the vertices span it — the last vertex
400/// repeated to pad, filled in `in.color`. The distance is the polygon
401/// SDF whose sign flips at every edge crossing — even-odd, the same rule
402/// the hit test uses — so a concave outline fills correctly and a
403/// self-intersecting one leaves its overlaps unfilled; a padding edge of
404/// zero length is skipped before it can divide by itself. The edge ramps over one
405/// physical pixel, like a box's. Registered by the core itself, once per
406/// session, through the same idempotent `add_fragment` an app's source
407/// takes, so `kui_fragment_source` hands a C host the function kui
408/// validated.
409pub const POLYGON: &str = r#"
410fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
411    var v: array<vec2<f32>, 8>;
412    for (var i = 0; i < 4; i++) {
413        v[i * 2] = params[i].xy * in.size;
414        v[i * 2 + 1] = params[i].zw * in.size;
415    }
416    let p = in.local;
417    var d = dot(p - v[0], p - v[0]);
418    var s = 1.0;
419    var j = 7;
420    for (var i = 0; i < 8; i++) {
421        let e = v[j] - v[i];
422        let w = p - v[i];
423        let ee = dot(e, e);
424        if ee > 0.0 {
425            let b = w - e * clamp(dot(w, e) / ee, 0.0, 1.0);
426            d = min(d, dot(b, b));
427            let c = vec3<bool>((p.y >= v[i].y), (p.y < v[j].y), (e.x * w.y > e.y * w.x));
428            if all(c) || all(!c) {
429                s = -s;
430            }
431        }
432        j = i;
433    }
434    let dist = s * sqrt(d);
435    let cov = clamp(0.5 - dist, 0.0, 1.0);
436    return vec4<f32>(in.color.rgb, cov);
437}
438"#;
439
440/// The whole module for an app's source: prelude, the app, epilogue. What
441/// the core validates and what a backend compiles, from one function so
442/// they cannot differ.
443pub fn module_source(app: &str) -> String {
444    format!("{PRELUDE}\n{app}\n{EPILOGUE}")
445}
446
447/// How far an error's line number has to move to land in the app's own
448/// numbering: the prelude's own lines, plus the blank one
449/// [`module_source`] puts between it and the app.
450fn prelude_lines() -> usize {
451    PRELUDE.lines().count() + 1
452}
453
454/// Parses and validates an app's fragment source between the prelude and
455/// the epilogue. `Err` carries naga's message with line numbers moved into
456/// the app's own numbering, so a caller can print it as-is.
457///
458/// This is 55-73 us for a typical source, which is why `add_fragment` is
459/// idempotent: a view that registers every frame should pay a comparison,
460/// not this.
461pub fn validate(app: &str) -> Result<(), String> {
462    use naga::valid::{Capabilities, ValidationFlags, Validator};
463    let full = module_source(app);
464    let module = naga::front::wgsl::parse_str(&full)
465        .map_err(|e| renumber(&e.emit_to_string(&full), prelude_lines()))?;
466    Validator::new(ValidationFlags::all(), Capabilities::empty())
467        .validate(&module)
468        .map(|_| ())
469        .map_err(|e| renumber(&e.emit_to_string(&full), prelude_lines()))
470}
471
472/// Rewrites `wgsl:N:` line references by subtracting the prelude's length,
473/// so an app sees its own line numbers. A reference inside the prelude or
474/// the epilogue clamps to 1 rather than going negative — an app cannot fix
475/// a line it did not write, and the message text still names what failed.
476fn renumber(msg: &str, offset: usize) -> String {
477    let mut out = String::with_capacity(msg.len());
478    let mut rest = msg;
479    while let Some(i) = rest.find("wgsl:") {
480        out.push_str(&rest[..i + 5]);
481        rest = &rest[i + 5..];
482        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
483        if digits.is_empty() {
484            continue;
485        }
486        let n = digits.parse::<usize>().unwrap_or(offset + 1);
487        out.push_str(&n.saturating_sub(offset).max(1).to_string());
488        rest = &rest[digits.len()..];
489    }
490    out.push_str(rest);
491    out
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    const GRADIENT: &str = "\
499fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
500    let t = in.local.y / max(in.size.y, 1.0);
501    return mix(params[0], params[1], t);
502}";
503
504    #[test]
505    fn a_gradient_validates() {
506        validate(GRADIENT).unwrap();
507    }
508
509    /// The stock polygon is validated like any app source — at build,
510    /// here, rather than at the first `polygon` node of a session.
511    #[test]
512    fn the_stock_polygon_validates() {
513        validate(POLYGON).unwrap();
514    }
515
516    #[test]
517    fn the_stock_join_validates() {
518        validate(JOIN).unwrap();
519    }
520
521    /// `in.color` is what the prelude added for it (ADR 0025).
522    #[test]
523    fn the_quad_colour_is_reachable_from_the_app() {
524        let src = "\
525fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
526    return vec4<f32>(in.color.rgb * params[0].x, 1.0);
527}";
528        validate(src).unwrap();
529    }
530
531    /// `kui_sample`, `kui_sample_nearest` and `in.image` are the image
532    /// input (backlog V1); a source using them validates without an image
533    /// bound, since binding is a per-frame fact.
534    #[test]
535    fn the_image_input_is_reachable_from_the_app() {
536        let src = "\
537fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
538    let uv = in.local / max(in.size, vec2<f32>(1.0));
539    let cells = in.image.zw;
540    return mix(kui_sample(uv), kui_sample_nearest(uv), step(1.0, cells.x)) * params[0];
541}";
542        validate(src).unwrap();
543    }
544
545    #[test]
546    fn the_prelude_is_reachable_from_the_app() {
547        // An app may use `kui_sd_rounded_box`, `KUI_AA`, `time` and `scale`.
548        let src = "\
549fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
550    let d = kui_sd_rounded_box(in.local - in.size * 0.5, in.size * 0.5, vec4<f32>(8.0));
551    let a = 1.0 - smoothstep(-KUI_AA, KUI_AA, d);
552    return vec4<f32>(params[0].rgb, a * (0.5 + 0.5 * sin(in.time)) * in.scale / in.scale);
553}";
554        validate(src).unwrap();
555    }
556
557    #[test]
558    fn a_syntax_error_reports_the_apps_own_line() {
559        // The missing semicolon is on the app's line 2.
560        let src = "\
561fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
562    return vec4<f32>(1.0, 0.0, 0.0, 1.0)
563}";
564        let err = validate(src).unwrap_err();
565        assert!(
566            err.contains("wgsl:2:") || err.contains("wgsl:3:"),
567            "should point into the app's source, got:\n{err}"
568        );
569        assert!(
570            !err.contains(&format!("wgsl:{}:", prelude_lines() + 2)),
571            "line number was not moved out of the prelude:\n{err}"
572        );
573    }
574
575    #[test]
576    fn a_missing_fragment_function_is_refused() {
577        let err = validate("fn other() -> f32 { return 1.0; }").unwrap_err();
578        assert!(err.contains("fragment"), "{err}");
579    }
580
581    #[test]
582    fn the_wrong_signature_is_refused() {
583        let err = validate("fn fragment() -> vec4<f32> { return vec4<f32>(1.0); }").unwrap_err();
584        assert!(
585            !err.is_empty(),
586            "a no-argument `fragment` must not validate"
587        );
588    }
589
590    #[test]
591    fn an_empty_source_is_refused() {
592        assert!(validate("").is_err());
593    }
594
595    #[test]
596    fn the_module_is_the_three_parts_in_order() {
597        let m = module_source(GRADIENT);
598        let (p, a, e) = (
599            m.find("struct FragmentIn").unwrap(),
600            m.find("fn fragment(").unwrap(),
601            m.find(ENTRY_POINT).unwrap(),
602        );
603        assert!(p < a && a < e, "prelude, app, epilogue");
604        assert!(m.contains(ENTRY_POINT));
605    }
606}