//! The `fragment` element: a box a WGSL function paints
//! (`docs/adr/0015-a-fragment-element-and-the-painter-it-is-not.md`).
//!
//! Four of them, each one function the app wrote:
//!
//! - a **gradient**, which is the thing ADR 0005 declined to build as props
//! and this is the answer to;
//! - a **ring**, drawn from the prelude's `kui_sd_rounded_box` — a gauge
//! with no geometry and no second pass;
//! - a **shimmer**, which reads `time` and so declares `animate`;
//! - a **card**, a fragment holding a title and a button, to show that
//! children paint over it and take input normally;
//! - a **heatmap**, a fragment reading an `image` the app rewrites every
//! frame with `update_image` — 64×16 values in a texture, one cell per
//! texel through `kui_sample_nearest`, coloured between two theme roles
//! (backlog V1, ADR 0025 decision 7): the "many points" case, where the
//! data is a texture and the nodes are one;
//! - a **ripple**, the same input as an image effect — a registered icon
//! read through `kui_sample` with a time-driven offset, so the pixels
//! come from the atlas the icon lives in and the function never knows.
//!
//! The click target is the card's button, and the fragments themselves are
//! hoverable, so hovering one lifts its ring — a fragment takes input like
//! any box.
//!
//! Run: cargo run -p kui-native --example fragment [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::{
App, Color, Core, FragmentId, ImageId, NodeSpec, TextStyle, Ui, UiEvent, Value, widgets,
};
/// One colour as the four floats a `params` slot is. Fragment parameters
/// are where a theme meets a shader: the WGSL says *what* a gradient or a
/// ring is, and the palette says which colours it is made of — so every
/// fragment below follows the OS without a line of its shader changing
/// (`docs/adr/0019-a-theme-derived-from-appearance-and-accent.md`).
fn rgba(c: Color) -> [f32; 4] {
[c.r, c.g, c.b, c.a]
}
/// The four params a fragment takes, flattened: colours as `rgba`, plain
/// numbers as themselves.
fn params(slots: [[f32; 4]; 4]) -> [f32; 16] {
let mut out = [0.0; 16];
for (i, s) in slots.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(s);
}
out
}
/// A vertical gradient between `params[0]` and `params[1]`.
const GRADIENT: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let t = clamp(in.local.y / max(in.size.y, 1.0), 0.0, 1.0);
return mix(params[0], params[1], t);
}";
/// A progress ring: `params[1].x` of the way round, in `params[0]`, on a
/// track of `params[2]`. The arc is an angle test against the same
/// distance field the renderer draws every rounded box with.
const RING: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let c = in.size * 0.5;
let p = in.local - c;
let r = min(c.x, c.y) - params[3].x;
let d = abs(length(p) - r) - params[3].y;
let cov = 1.0 - smoothstep(-KUI_AA, KUI_AA, d);
// Angle from twelve o'clock, clockwise, in turns.
let turn = fract(atan2(p.x, -p.y) / 6.2831853 + 1.0);
let on = step(turn, clamp(params[1].x, 0.0, 1.0));
let col = mix(params[2].rgb, params[0].rgb, on);
return vec4<f32>(col, cov);
}";
/// A diagonal sheen sliding across a dim base — the skeleton-loading
/// shimmer, as one function of `time`.
const SHIMMER: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let u = (in.local.x + in.local.y) / max(in.size.x + in.size.y, 1.0);
let sweep = fract(in.time * params[2].x);
let d = abs(fract(u - sweep + 0.5) - 0.5);
let band = 1.0 - smoothstep(0.0, params[2].y, d);
return vec4<f32>(mix(params[0].rgb, params[1].rgb, band), 1.0);
}";
/// A soft radial wash for the card to sit on.
const WASH: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let c = in.size * 0.5;
let d = length((in.local - c) / max(c.x, 1.0));
return vec4<f32>(mix(params[0].rgb, params[1].rgb, clamp(d, 0.0, 1.0)), 1.0);
}";
/// One cell per texel of the `image`, its red channel the value, coloured
/// between `params[0]` (cold) and `params[1]` (hot). `in.image.zw` is the
/// grid's size, which is what draws the cell borders without a second
/// input.
const HEATMAP: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let uv = in.local / max(in.size, vec2<f32>(1.0));
let v = kui_sample_nearest(uv).r;
let cell = fract(uv * in.image.zw);
let edge = min(min(cell.x, 1.0 - cell.x), min(cell.y, 1.0 - cell.y));
let border = smoothstep(0.0, 0.08, edge);
return vec4<f32>(mix(params[0].rgb, params[1].rgb, v) * (0.6 + 0.4 * border), 1.0);
}";
/// An image effect: the `image` sampled through a horizontal ripple that
/// travels with `time`, desaturated by `params[0].y`.
const RIPPLE: &str = "\
fn fragment(in: FragmentIn, params: array<vec4<f32>, 4>) -> vec4<f32> {
let uv = in.local / max(in.size, vec2<f32>(1.0));
let wobble = vec2<f32>(sin(uv.y * 18.0 + in.time * 3.0) * params[0].x, 0.0);
let c = kui_sample(uv + wobble);
let grey = dot(c.rgb, vec3<f32>(0.299, 0.587, 0.114));
return vec4<f32>(mix(vec3<f32>(grey), c.rgb, params[0].y), c.a);
}";
const HEAT_W: u32 = 64;
const HEAT_H: u32 = 16;
/// The heatmap's frame: a field of two travelling waves, one value per
/// texel in the red channel — what a sensor grid, a spectrogram column or
/// a 50k-point series would hand back.
fn heat(phase: f32, out: &mut Vec<u8>) {
out.clear();
out.reserve((HEAT_W * HEAT_H * 4) as usize);
for y in 0..HEAT_H {
for x in 0..HEAT_W {
let (u, v) = (x as f32 / HEAT_W as f32, y as f32 / HEAT_H as f32);
let a = ((u * 9.0 + phase).sin() * (v * 5.0 - phase * 0.6).cos() + 1.0) * 0.5;
out.extend_from_slice(&[(a * 255.0) as u8, 0, 0, 0xff]);
}
}
}
/// A 48×48 icon for the ripple: a ring on a disc, opaque in the middle
/// and transparent past the disc, so the effect has an alpha to keep.
fn icon() -> Vec<u8> {
let n = 48u32;
let mut px = Vec::with_capacity((n * n * 4) as usize);
for y in 0..n {
for x in 0..n {
let d = ((x as f32 - 23.5).powi(2) + (y as f32 - 23.5).powi(2)).sqrt();
let (r, g, b, a) = if d > 22.0 {
(0, 0, 0, 0)
} else if (d - 15.0).abs() < 3.0 {
(0xf4, 0xd0, 0x6f, 0xff)
} else if d < 8.0 {
(0x3b, 0x5b, 0xd4, 0xff)
} else {
(0x2a, 0x2e, 0x3c, 0xff)
};
px.extend_from_slice(&[r, g, b, a]);
}
}
px
}
#[derive(Default)]
struct Demo {
shaders: Option<Shaders>,
/// 0..1, what the ring shows. The button nudges it.
progress: f32,
/// The heatmap's data texture: registered once, replaced every frame.
heat: Option<ImageId>,
/// The ripple's icon: registered once, never replaced, so it lives in
/// the atlas and the fragment reads it from there.
icon: Option<ImageId>,
phase: f32,
pixels: Vec<u8>,
}
#[derive(Clone, Copy)]
struct Shaders {
gradient: FragmentId,
ring: FragmentId,
shimmer: FragmentId,
wash: FragmentId,
heatmap: FragmentId,
ripple: FragmentId,
}
fn label(ui: &mut Ui<'_>, text: &str) {
let muted = ui.theme().muted;
ui.text(text, TextStyle::new(12.0).color(muted));
}
/// One labelled tile.
fn tile(ui: &mut Ui<'_>, name: &str, f: impl FnOnce(&mut Ui<'_>)) {
ui.with_keyed(name, NodeSpec::column().gap(6.0), |ui| {
f(ui);
label(ui, name);
});
}
impl App for Demo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// Registration is idempotent by source, so calling it every frame
// costs a comparison. A real app would still do this once.
let s = *self.shaders.get_or_insert_with(|| {
let core = ui.core();
Shaders {
gradient: core.add_fragment(GRADIENT).expect("gradient"),
ring: core.add_fragment(RING).expect("ring"),
shimmer: core.add_fragment(SHIMMER).expect("shimmer"),
wash: core.add_fragment(WASH).expect("wash"),
heatmap: core.add_fragment(HEATMAP).expect("heatmap"),
ripple: core.add_fragment(RIPPLE).expect("ripple"),
}
});
// The data texture: a handle once, then new pixels every frame
// through the same handle — the image is the canvas (ADR 0025),
// and the fragment is what draws it as cells rather than pixels.
let heat_id = *self.heat.get_or_insert_with(|| {
ui.core()
.resources
.add_image(HEAT_W, HEAT_H, vec![0; (HEAT_W * HEAT_H * 4) as usize])
});
self.phase += 0.05;
heat(self.phase, &mut self.pixels);
ui.core()
.update_image(heat_id, HEAT_W, HEAT_H, self.pixels.clone());
let icon_id = *self
.icon
.get_or_insert_with(|| ui.core().resources.add_image(48, 48, icon()));
ui.with(
NodeSpec::column().fill().pad(28.0).gap(24.0).bg(t.bg),
|ui| {
ui.text("fragments", TextStyle::new(22.0).color(t.fg));
ui.with(NodeSpec::row().gap(20.0), |ui| {
tile(ui, "gradient", |ui| {
ui.fragment(
s.gradient,
¶ms([
rgba(t.accent), // top
rgba(t.surface), // bottom
[0.0; 4],
[0.0; 4],
]),
NodeSpec::column().size(150.0, 96.0).radius(10.0),
);
});
tile(ui, "ring", |ui| {
ui.fragment(
s.ring,
¶ms([
rgba(t.accent), // the arc
[self.progress, 0.0, 0.0, 0.0], // how far round
rgba(t.border), // the track
[10.0, 5.0, 0.0, 0.0], // inset, half-width
]),
NodeSpec::column().size(96.0, 96.0),
);
});
tile(ui, "shimmer (animate)", |ui| {
ui.fragment(
s.shimmer,
¶ms([
rgba(t.sunken), // base
rgba(t.border_strong), // sheen
[0.35, 0.22, 0.0, 0.0], // turns per second, width
[0.0; 4],
]),
NodeSpec::column().size(150.0, 96.0).radius(10.0).animate(),
);
});
});
ui.with(NodeSpec::row().gap(20.0), |ui| {
// The image input: a texture the app replaces every
// frame, read as data — one node for a thousand cells.
tile(ui, "heatmap (an image as data)", |ui| {
ui.fragment_keyed(
"heatmap",
s.heatmap.with_image(heat_id),
¶ms([
rgba(t.sunken), // cold
rgba(t.accent), // hot
[0.0; 4],
[0.0; 4],
]),
NodeSpec::column().size(256.0, 64.0).radius(6.0).animate(),
);
});
// The same input as an effect over an atlas-backed icon.
tile(ui, "ripple (an image effect)", |ui| {
ui.fragment_keyed(
"ripple",
s.ripple.with_image(icon_id),
¶ms([
[0.02, 0.35, 0.0, 0.0], // ripple depth, colour kept
[0.0; 4],
[0.0; 4],
[0.0; 4],
]),
NodeSpec::column().size(96.0, 96.0).animate(),
);
});
});
// A fragment with children: they lay out inside it and paint over
// it, and the button takes input exactly as it would anywhere.
tile(ui, "a fragment with children", |ui| {
ui.fragment_with(
s.wash,
¶ms([
rgba(t.surface.mix(t.accent, 0.30)), // centre
rgba(t.surface), // edge
[0.0; 4],
[0.0; 4],
]),
NodeSpec::column()
.width(320.0)
.pad(18.0)
.gap(12.0)
.radius(12.0),
|ui| {
ui.text("on a wash", TextStyle::new(16.0).color(t.fg));
widgets::button(ui, "advance", Value::str("advance"));
},
);
});
},
);
}
fn on_event(&mut self, ev: UiEvent) {
if ev.payload.as_str() == Some("advance") {
self.progress = (self.progress + 0.125) % 1.125;
}
}
}
impl Example for Demo {
/// The image input lands on the right binding: the heatmap's data
/// texture takes the frame's one `textures` entry and its draw names
/// it, the ripple's icon is read from the atlas, no texture *quad* is
/// drawn for either, and the next frame's replacement moves the
/// revision the backend re-uploads on.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
use kui_native::{FragmentImage, QuadKind};
let mut d = Drive::new(core, 900.0, 700.0);
d.frame(self);
let (images, textures, texture_quads, rev) = {
let dl = &d.core.output().0;
(
dl.fragments.iter().map(|f| f.image).collect::<Vec<_>>(),
dl.textures.len(),
dl.quads
.iter()
.filter(|q| q.kind == QuadKind::Texture)
.count(),
dl.texture_pixels.first().map(|p| p.rev),
)
};
d.check(images.len() == 6, "six fragments drawn")?;
d.check(
images
.iter()
.filter(|i| matches!(i, FragmentImage::None))
.count()
== 4,
"four of them read no image",
)?;
d.check(
images.contains(&FragmentImage::Texture {
index: 0,
uv: [0, 0, HEAT_W, HEAT_H],
}),
"the heatmap reads the data texture, whole",
)?;
d.check(
images
.iter()
.any(|i| matches!(i, FragmentImage::Atlas([_, _, 48, 48]))),
"the ripple reads the icon from the atlas",
)?;
d.check(textures == 1, "one texture entry, the heatmap's")?;
d.check(
texture_quads == 0,
"and no texture quad: the fragment draws",
)?;
d.frame(self);
let rev2 = d.core.output().0.texture_pixels.first().map(|p| p.rev);
d.check(
rev2 > rev,
"the next frame's update_image moved the revision",
)?;
Ok(())
}
}
kui_devtools::main!(Demo::default());