Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

widgets/path.rs

//! The `path` element (`docs/adr/0040-a-path-is-a-mask-in-the-atlas.md`):
//! any outline — SVG's `d`, or the `Path` builder — filled with `bg` by
//! its rule and stroked by its own stroke, placed like a `line` (a float
//! in its parent's box space sized to its own bounding box). Four things
//! an eight-point `polygon` could not draw: a pie whose wedges are round
//! and meet without a seam, lighting up under the pointer; a donut gauge
//! whose arc is a sector with an inner radius; an icon from SVG path
//! data, filled and stroked; and an even-odd ring whose hole the press
//! falls through. Every paint is one glyph-mask quad from the atlas,
//! rasterized once per shape and scale and re-tinted for free, so a hover
//! or a colour tween costs nothing a text's does not.
//!
//! A path is hit by its outline under its fill rule (ADR 0026): the
//! wedges are `hoverable` themselves, and a pointer in one wedge's
//! bounding box but past its arc is over the canvas, not it.
//!
//! Run: cargo run -p kui-native --example path [-- --headless]

use kui_devtools::{Drive, Example};
use kui_native::{App, Color, Core, FillRule, NodeSpec, Path, QuadKind, Stroke, TextStyle, Ui};

/// A heart, as an icon editor would export it: SVG path data, in a 24 px
/// box, drawn at any scale by the one parser the core owns.
const HEART: &str =
    "M12 21 C12 21 3 14.5 3 8.5 A4.5 4.5 0 0 1 12 6 A4.5 4.5 0 0 1 21 8.5 C21 14.5 12 21 12 21 Z";

struct Demo {
    slices: Vec<(&'static str, f32, Color)>,
    gauge: f32,
}

impl App for Demo {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        ui.with(NodeSpec::column().fill().bg(t.bg).pad(24.0).gap(16.0), |ui| {
            let muted = TextStyle::new(12.0).color(t.muted);
            ui.text(
                "Outlines of any shape, one mask quad each: a pie, a gauge, an icon, a ring. Hover a wedge.",
                muted,
            );
            ui.with(NodeSpec::row().gap(24.0), |ui| {
                // The pie: each wedge is a sector with a round arc, hit by
                // its outline, and its fill eases toward the accent. Two
                // wedges share each radial edge and meet without a seam,
                // because a fill bleeds half a pixel (ADR 0040, decision 5).
                let pie = NodeSpec::column()
                    .size(220.0, 220.0)
                    .bg(t.surface)
                    .border(1.0, t.border)
                    .radius(12.0);
                ui.with_keyed("pie", pie, |ui| {
                    let mut from = 0.0;
                    for (i, &(label, frac, color)) in self.slices.iter().enumerate() {
                        let key = ui.child_key(&format!("wedge{i}"));
                        let lit = ui.is_hovered(key);
                        ui.path_keyed(
                            &format!("wedge{i}"),
                            &Path::sector(110.0, 110.0, 80.0, 0.0, from, frac),
                            NodeSpec::column()
                                .bg(if lit { t.accent } else { color })
                                .transition(160.0)
                                .hoverable()
                                .label(label),
                        );
                        from += frac;
                    }
                });

                ui.with(NodeSpec::column().gap(16.0), |ui| {
                    // The gauge: a track ring, and over it a sector whose
                    // sweep is the value. Both are one node each.
                    let card = NodeSpec::column()
                        .size(300.0, 102.0)
                        .bg(t.surface)
                        .border(1.0, t.border)
                        .radius(12.0);
                    ui.with(card, |ui| {
                        let (cx, cy) = (60.0, 70.0);
                        ui.path(
                            &Path::sector(cx, cy, 44.0, 32.0, 0.5, 0.5),
                            NodeSpec::column().bg(t.raised),
                        );
                        ui.path_keyed(
                            "gauge",
                            &Path::sector(cx, cy, 44.0, 32.0, 0.5, 0.5 * self.gauge),
                            NodeSpec::column().bg(t.accent).transition(300.0),
                        );
                        ui.with(
                            NodeSpec::column()
                                .float(kui_native::FloatConfig::parent().offset(130.0, 30.0))
                                .gap(4.0),
                            |ui| {
                                ui.text(
                                    &format!("{:.0}%", self.gauge * 100.0),
                                    TextStyle::new(22.0).color(t.fg),
                                );
                                ui.text("of the frame budget", muted);
                            },
                        );
                    });

                    // The icon: SVG path data, filled and stroked, at twice
                    // and four times its 24 px box — the same `d`, two
                    // masks of different sizes in the atlas.
                    let strip = NodeSpec::column()
                        .size(300.0, 102.0)
                        .bg(t.surface)
                        .border(1.0, t.border)
                        .radius(12.0);
                    ui.with(strip, |ui| {
                        let heart = Path::parse(HEART).expect("the icon parses");
                        for (i, (scale, x)) in [(2.0, 24.0), (4.0, 110.0)].into_iter().enumerate() {
                            let ops: Vec<kui_native::PathOp> = heart
                                .ops()
                                .iter()
                                .map(|op| scaled(*op, scale, x, 3.0 + (4.0 - scale) * 12.0))
                                .collect();
                            ui.path_keyed(
                                &format!("heart{i}"),
                                &Path::from_ops(ops).stroked(Stroke::new(2.0, t.fg)),
                                NodeSpec::column().bg(t.warning),
                            );
                        }
                    });
                });

                // The ring: two contours wound the same way, filled
                // even-odd, so the hole is a hole — to the eye and to the
                // pointer, which hovers the ring and not its middle.
                let card = NodeSpec::column()
                    .size(120.0, 220.0)
                    .bg(t.surface)
                    .border(1.0, t.border)
                    .radius(12.0);
                ui.with(card, |ui| {
                    let key = ui.child_key("ring");
                    let lit = ui.is_hovered(key);
                    ui.path_keyed(
                        "ring",
                        &Path::parse("M60 60 m-46 0 a46 46 0 1 0 92 0 a46 46 0 1 0 -92 0 M60 60 m-20 0 a20 20 0 1 0 40 0 a20 20 0 1 0 -40 0")
                            .expect("the ring parses")
                            .fill_rule(FillRule::EvenOdd),
                        NodeSpec::column()
                            .bg(if lit { t.accent } else { t.accent_soft })
                            .transition(160.0)
                            .hoverable()
                            .label("ring"),
                    );
                    ui.with(
                        NodeSpec::column()
                            .float(kui_native::FloatConfig::parent().offset(10.0, 130.0))
                            .size(100.0, 80.0),
                        |ui| ui.text("even-odd: the hole is the card's", muted),
                    );
                });
            });
        });
    }
}

/// `op` scaled by `s` and moved to `(x, y)`.
fn scaled(op: kui_native::PathOp, s: f32, x: f32, y: f32) -> kui_native::PathOp {
    use kui_native::{PathOp, Vec2};
    let at = |p: Vec2| Vec2::new(p.x * s + x, p.y * s + y);
    match op {
        PathOp::MoveTo(p) => PathOp::MoveTo(at(p)),
        PathOp::LineTo(p) => PathOp::LineTo(at(p)),
        PathOp::QuadTo(c, p) => PathOp::QuadTo(at(c), at(p)),
        PathOp::CubicTo(a, b, p) => PathOp::CubicTo(at(a), at(b), at(p)),
        PathOp::ArcTo {
            rx,
            ry,
            rotation,
            large,
            sweep,
            to,
        } => PathOp::ArcTo {
            rx: rx * s,
            ry: ry * s,
            rotation,
            large,
            sweep,
            to: at(to),
        },
        PathOp::Close => PathOp::Close,
    }
}

impl Example for Demo {
    fn window(&self) -> kui_devtools::Window {
        kui_devtools::Window::default().size(760.0, 320.0)
    }

    /// Every paint is one glyph-mask quad and no fragment; a wedge is hit
    /// by its arc, so a pointer inside it lights it and one in its box past
    /// the arc does not; the ring's hole is not the ring's.
    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        let mut d = Drive::new(core, 760.0, 320.0);
        d.frame(self);
        let count = |core: &mut Core, kind: QuadKind| {
            core.output()
                .0
                .quads
                .iter()
                .filter(|q| q.kind == kind)
                .count()
        };
        // 4 wedges + the track and the gauge + 2 icons × 2 paints + the ring.
        let masks = count(d.core, QuadKind::GlyphMask);
        d.check(masks >= 11, "every paint is one mask quad")?;
        let fragments = count(d.core, QuadKind::Fragment);
        d.check(fragments == 0, "and none is a fragment")?;
        let warned = d.warnings();
        d.check(warned.is_empty(), "nothing warned")?;
        let w1 = d.key_of("wedge1").ok_or("no second wedge")?;
        let r = d.rect_of(w1).ok_or("the wedge has no region")?;
        // The pie's centre on the page: the wedge's region is its outline's
        // bounding box a pixel out, and the outline is what `sector`
        // computes, so the panel's origin follows from the two.
        let (from, frac) = (self.slices[0].1, self.slices[1].1);
        let mut outline = Vec::new();
        kui_native::path::flatten(
            Path::sector(110.0, 110.0, 80.0, 0.0, from, frac).ops(),
            &mut outline,
        );
        let b = kui_native::path::bounds(&outline).ok_or("no bounds")?;
        let c = kui_native::Vec2::new(r.x - (b.x - 2.0) + 110.0, r.y - (b.y - 2.0) + 110.0);
        let mid = (from + frac * 0.5) * std::f32::consts::TAU;
        let inside = kui_native::Vec2::new(c.x + 50.0 * mid.cos(), c.y + 50.0 * mid.sin());
        let outside = kui_native::Vec2::new(c.x + 84.0 * mid.cos(), c.y + 84.0 * mid.sin());
        d.input(self, kui_native::InputEvent::CursorMoved(inside));
        d.check(
            d.core.is_hovered(w1),
            "a pointer inside the wedge hovers it",
        )?;
        d.input(self, kui_native::InputEvent::CursorMoved(outside));
        d.check(
            !d.core.is_hovered(w1),
            "a pointer in its box past its arc does not",
        )?;
        // Back inside: the fill eases to the accent, from the same slot.
        d.input(self, kui_native::InputEvent::CursorMoved(inside));
        d.frame(self);
        d.advance(0.5);
        d.frame(self);
        let accent = d.core.theme().accent;
        let lit = d
            .core
            .output()
            .0
            .quads
            .iter()
            .any(|q| q.kind == QuadKind::GlyphMask && q.color == accent);
        d.check(lit, "a hovered wedge's fill is the accent")?;
        // The ring: its band hovers, its hole does not.
        let ring = d.key_of("ring").ok_or("no ring")?;
        let rr = d.rect_of(ring).ok_or("the ring has no region")?;
        let centre = kui_native::Vec2::new(rr.x + rr.w * 0.5, rr.y + rr.h * 0.5);
        d.input(self, kui_native::InputEvent::CursorMoved(centre));
        d.check(
            !d.core.is_hovered(ring),
            "the ring's hole is not the ring's",
        )?;
        d.input(
            self,
            kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(centre.x + 33.0, centre.y)),
        );
        d.check(d.core.is_hovered(ring), "its band is")?;
        Ok(())
    }
}

kui_devtools::main!(Demo {
    slices: vec![
        ("layout", 0.34, Color::hex(0x7f9cf5ff)),
        ("paint", 0.26, Color::hex(0xd8863bff)),
        ("input", 0.22, Color::hex(0x9ad9a0ff)),
        ("text", 0.18, Color::hex(0xe07a8aff)),
    ],
    gauge: 0.62,
});