diff --git a/apps/flutter-samples/Cargo.toml b/apps/flutter-samples/Cargo.toml index 5c0a525..2feefe9 100644 --- a/apps/flutter-samples/Cargo.toml +++ b/apps/flutter-samples/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" description = "The flutter/samples catalog rendered as native makepad widgets from the Splash DSL" [dependencies] -makepad-widgets = { git = "https://github.com/makepad/makepad.git", branch = "dev" } +makepad-widgets = { path = "../../../makepad-splash/widgets" } splash-widgets = { path = "../../crates/splash-widgets" } splash-render = { path = "../../crates/splash-render" } splash-makepad = { path = "../../crates/splash-makepad" } diff --git a/apps/kit-host/Cargo.toml b/apps/kit-host/Cargo.toml index 459a4c7..01ce889 100644 --- a/apps/kit-host/Cargo.toml +++ b/apps/kit-host/Cargo.toml @@ -5,7 +5,13 @@ edition = "2021" description = "Generic app shell rendering a Splash component kit as native makepad widgets (fork-free, upstream makepad)" [dependencies] -makepad-widgets = { git = "https://github.com/makepad/makepad.git", branch = "dev" } +# The L0 profile, so this host can realize and lower a card at runtime. Depends +# on serde_json alone — it carries no makepad lineage, which is what makes it +# safe here and is why it was extracted from splash-core. +splash-ui-l0 = { path = "../../../Splash/crates/splash-ui-l0" } +serde_json = "1" + +makepad-widgets = { path = "../../../makepad-splash/widgets" } splash-widgets = { path = "../../crates/splash-widgets" } splash-render = { path = "../../crates/splash-render" } splash-makepad = { path = "../../crates/splash-makepad" } diff --git a/apps/kit-host/resources/Roboto-Regular.ttf b/apps/kit-host/resources/Roboto-Regular.ttf new file mode 100644 index 0000000..f608454 Binary files /dev/null and b/apps/kit-host/resources/Roboto-Regular.ttf differ diff --git a/apps/kit-host/src/data/news.json b/apps/kit-host/src/data/news.json new file mode 100644 index 0000000..a30865e --- /dev/null +++ b/apps/kit-host/src/data/news.json @@ -0,0 +1,69 @@ +{ + "lead": [ + { + "id": "1", + "title": "Rust 1.95 lands with const generics improvements", + "author": "steveklabnik", + "points": 412, + "comments": 137, + "url": "blog.rust-lang.org" + } + ], + "feed": [ + { + "id": "2", + "title": "A new approach to incremental compilation", + "author": "nikomatsakis", + "points": 288 + }, + { + "id": "3", + "title": "Show HN: I built a terminal UI framework", + "author": "jdoe", + "points": 194 + }, + { + "id": "4", + "title": "The case against microservices, revisited", + "author": "mfowler", + "points": 151 + }, + { + "id": "5", + "title": "WebGPU shipping in all major browsers", + "author": "kvark", + "points": 133 + }, + { + "id": "6", + "title": "Why your database is slower than you think", + "author": "aphyr", + "points": 97 + }, + { + "id": "7", + "title": "Notes on distributed consensus", + "author": "hjfreyd", + "points": 74 + }, + { + "id": "8", + "title": "An illustrated guide to lifetimes", + "author": "fasterthanlime", + "points": 61 + } + ], + "article": { + "title": "Rust 1.95 lands", + "author": "steveklabnik", + "points": 412, + "comments": 137, + "url": "blog.rust-lang.org" + }, + "selected": "", + "env": { + "locale": { + "lang": "en" + } + } +} \ No newline at end of file diff --git a/apps/kit-host/src/data/stock.json b/apps/kit-host/src/data/stock.json new file mode 100644 index 0000000..9bda45d --- /dev/null +++ b/apps/kit-host/src/data/stock.json @@ -0,0 +1,60 @@ +{ + "movers": [ + { + "ticker": "NVDA", + "name": "NVIDIA", + "last": 184.2, + "change": 3.1, + "pct": 1.7 + }, + { + "ticker": "AAPL", + "name": "Apple", + "last": 231.4, + "change": -1.2, + "pct": -0.5 + }, + { + "ticker": "MSFT", + "name": "Microsoft", + "last": 429.8, + "change": 2.4, + "pct": 0.6 + }, + { + "ticker": "TSLA", + "name": "Tesla", + "last": 248.9, + "change": -4.7, + "pct": -1.9 + } + ], + "quote": { + "name": "NVIDIA", + "last": 184.2, + "change": 3.1, + "pct": 1.7, + "open": 181.0, + "high": 185.6, + "low": 180.2, + "volume": 41200000, + "mktcap": 4520000000000, + "pe": 58.3 + }, + "series": { + "points": [ + 1, + 2, + 3 + ], + "min": 180.2, + "max": 185.6 + }, + "selected": "", + "range": "m1", + "env": { + "locale": { + "lang": "en" + } + } +} \ No newline at end of file diff --git a/apps/kit-host/src/data/weather.json b/apps/kit-host/src/data/weather.json new file mode 100644 index 0000000..ea77b84 --- /dev/null +++ b/apps/kit-host/src/data/weather.json @@ -0,0 +1,93 @@ +{ + "place": { + "name": "Kyoto" + }, + "now": { + "temp": 18, + "feels": 16, + "hi": 21, + "lo": 12, + "cond": 2, + "humidity": 61, + "wind": 9, + "pressure": 1013, + "uv": 3, + "visibility": 10 + }, + "sun": { + "rise": 5.1, + "set": 18.9, + "now": 13.5 + }, + "moon": { + "phase": 0.62, + "illumination": 0.71 + }, + "aqi": { + "grid": [ + 1, + 2, + 3 + ], + "index": 42, + "band": "good" + }, + "scene": "https://image.pollinations.ai/prompt/kyoto%20skyline?width=1080&height=1920&nologo=true", + "units": "c", + "days": 7, + "city": "Kyoto", + "env": { + "locale": { + "temp_unit": "c", + "lang": "en" + } + }, + "week": { + "days": [ + { + "dayname": "Mon", + "hi": 21, + "lo": 12, + "cond": 2 + }, + { + "dayname": "Tue", + "hi": 23, + "lo": 13, + "cond": 0 + }, + { + "dayname": "Wed", + "hi": 19, + "lo": 11, + "cond": 3 + }, + { + "dayname": "Thu", + "hi": 17, + "lo": 10, + "cond": 3 + }, + { + "dayname": "Fri", + "hi": 20, + "lo": 11, + "cond": 1 + }, + { + "dayname": "Sat", + "hi": 24, + "lo": 14, + "cond": 0 + }, + { + "dayname": "Sun", + "hi": 22, + "lo": 13, + "cond": 1 + } + ], + "min_lo": 10, + "max_hi": 24 + } +} \ No newline at end of file diff --git a/apps/kit-host/src/l0.rs b/apps/kit-host/src/l0.rs new file mode 100644 index 0000000..1c5e80e --- /dev/null +++ b/apps/kit-host/src/l0.rs @@ -0,0 +1,141 @@ +//! L0 cards, as routes this host can render. +//! +//! This is what "the kit is wired on this backend" means, and it was the missing +//! piece: `components/l0/_kit.splash` existed, `kit::lower` emitted calls to it, +//! `splash_render` evaluated them and `to_makepad_ui` mapped them — and nothing +//! anywhere mounted the result. The whole path was tested and unrenderable. +//! +//! Every reference card becomes a route: +//! +//! ```text +//! l0/news -> realize -> kit::lower -> _kit.splash -> splash_render +//! ``` +//! +//! **The data is baked and static.** These are the same blobs the profile's own +//! tests use, so what appears here is what those tests assert — and where a +//! source would be answered live on a backend that can fetch, this one shows the +//! seeded value. That is a real difference from octos-one and it is why the two +//! are not expected to look identical. + +use splash_ui_l0::{kit, realize, RealizeLimits}; + +/// The theme. Every L0 route is this plus one lowered card. +const KIT: &str = include_str!("../../../components/l0/_kit.splash"); + +const NEWS: &str = include_str!("../../../../Splash/crates/splash-ui-l0/tests/fixtures/news.card"); +const STOCK: &str = include_str!("../../../../Splash/crates/splash-ui-l0/tests/fixtures/stock.card"); +const WEATHER: &str = + include_str!("../../../../Splash/crates/splash-ui-l0/tests/fixtures/weather.card"); + +const NEWS_DATA: &str = include_str!("data/news.json"); +const STOCK_DATA: &str = include_str!("data/stock.json"); +const WEATHER_DATA: &str = include_str!("data/weather.json"); + +/// The routes this module answers, and what each renders. +pub const ROUTES: &[(&str, &str)] = &[ + ("l0/news", "L0 — news"), + ("l0/stock", "L0 — stock"), + ("l0/weather", "L0 — weather"), +]; + +pub fn has(route: &str) -> bool { + ROUTES.iter().any(|(r, _)| *r == route) +} + +pub fn title_of(route: &str) -> Option<&'static str> { + ROUTES.iter().find(|(r, _)| *r == route).map(|(_, t)| *t) +} + +/// A card's source, realized and lowered against the theme. +/// +/// A card that fails to realize returns its diagnostics AS A CARD rather than an +/// empty string: an unrenderable route is otherwise a blank screen, which reads +/// as a layout bug rather than as a rejected card. +pub fn source_for(route: &str) -> String { + let (card, data) = match route { + "l0/news" => (NEWS, NEWS_DATA), + "l0/stock" => (STOCK, STOCK_DATA), + _ => (WEATHER, WEATHER_DATA), + }; + let data: serde_json::Value = match serde_json::from_str(data) { + Ok(v) => v, + Err(e) => return failed(&format!("data did not parse: {e}")), + }; + let report = realize(card, &data, RealizeLimits::default()); + let Some(root) = report.root else { + let why: Vec = report.diagnostics.iter().map(|d| d.message.clone()).collect(); + return failed(&why.join("; ")); + }; + format!("{KIT}\n{}", kit::lower(&root)) +} + +/// A visible failure, in the kit's own vocabulary. +fn failed(why: &str) -> String { + let escaped = why.replace('\\', "\\\\").replace('"', "\\\""); + format!( + "{KIT}\nlet node = l0_surface([l0_title(\"card did not realize\"), l0_body(\"{escaped}\")])\nnode\n" + ) +} + +#[cfg(test)] +mod tests { + /// Every L0 route evaluates to a real tree through the whole path. + /// + /// This host is the only place in this repository that MOUNTS an L0 card. + /// Before it, the kit, the lowering, the evaluator and the widget mapping + /// were each tested and the composition of them was unrenderable — there + /// was nowhere to put one on screen. + #[test] + fn every_l0_route_builds_a_tree() { + for (route, title) in super::ROUTES { + let src = super::source_for(route); + let tree = splash_render::build(&src, |_vm| {}) + .unwrap_or_else(|| panic!("{route} ({title}) evaluated to nil")); + assert!( + tree.count() > 10, + "{route} produced a {}-node tree — too small to be a card", + tree.count() + ); + } + } + + /// A route that cannot realize renders its reason, not a blank screen. + #[test] + fn a_card_that_does_not_realize_says_why() { + let src = super::failed("the reason"); + let tree = splash_render::build(&src, |_vm| {}).expect("the failure card evaluates"); + let mut text = String::new(); + fn words(n: &splash_render::UiNode, out: &mut String) { + if let Some(t) = n.attrs.text.as_deref() { + out.push_str(t); + } + for c in &n.children { + words(c, out); + } + } + words(&tree, &mut text); + assert!(text.contains("the reason"), "got {text:?}"); + } + + /// The five data visualisations reach the tree on THIS backend too. + /// + /// octos-one proves them against its own native widgets; this proves the + /// same card reaches the same kinds here, which is the claim §1.1 makes + /// about `UiNode` being the branch point. + #[test] + fn the_visualisations_reach_this_backends_tree() { + let tree = splash_render::build(&super::source_for("l0/weather"), |_vm| {}) + .expect("weather evaluates"); + let mut kinds = Vec::new(); + fn walk(n: &splash_render::UiNode, out: &mut Vec) { + out.push(format!("{:?}", n.kind)); + for c in &n.children { + walk(c, out); + } + } + walk(&tree, &mut kinds); + for expected in ["TempBar", "SunArc", "MoonPhase", "AqiContour"] { + assert!(kinds.iter().any(|k| k == expected), "{expected} missing"); + } + } +} diff --git a/apps/kit-host/src/main.rs b/apps/kit-host/src/main.rs index 803fd54..1cf76e0 100644 --- a/apps/kit-host/src/main.rs +++ b/apps/kit-host/src/main.rs @@ -8,11 +8,55 @@ pub use makepad_widgets; use makepad_widgets::*; +mod l0; +mod screens; + app_main!(App); -// The Material 3 component library, straight from the repo's components/ dir. -const BAKED: &str = include_str!("../../../components/material/catalog.splash"); +// The route file the sweep writes, and the DSL override for hot-reload. const DEVICE_PATH: &str = "/data/local/tmp/kit_host.splash"; +const ROUTE_PATH: &str = "/data/local/tmp/kit_host.route"; + +/// Toolbar height. Paired with the 20dp inset above it: the two sum to the +/// 59.6dp that places every screen's body, so they may only move together. +const BAR_H: f32 = 39.6; + +/// Adapter: the isolate hook takes a plain `fn(&mut ScriptVm)`, while a +/// `script_mod!` block returns a `ScriptValue`. +fn register_tap_mod(vm: &mut ScriptVm) { + splash_widgets::tap::script_mod(vm); +} + +/// A slider the host drives: its state slot, the DSL's range, and the value +/// last committed (so a drag only re-mounts when the value actually moves). +#[derive(Clone)] +pub struct SliderBind { + key: String, + lo: f32, + hi: f32, + step: Option, + last: f32, +} + +/// Collect every keyed slider in a semantic tree. +fn collect_sliders(node: &splash_render::UiNode, out: &mut Vec) { + if node.kind == splash_render::NodeKind::Slider { + if let Some(k) = node.attrs.key.as_deref() { + let lo = node.attrs.min.unwrap_or(0.0); + let hi = node.attrs.max.unwrap_or(1.0); + out.push(SliderBind { + key: k.to_string(), + lo, + hi, + step: node.attrs.step, + last: node.attrs.value.unwrap_or(lo), + }); + } + } + for c in &node.children { + collect_sliders(c, out); + } +} script_mod! { use mod.prelude.widgets.* @@ -28,7 +72,7 @@ script_mod! { height: Fill flow: Down show_bg: true - draw_bg +: { color: #fef7ffff } + draw_bg +: { color: #101417ff } // Upstream `Splash` always allocates an isolate VM. The // material kit needs the mount on the app's MAIN VM (light // theme + shared heap live there), which requires the one @@ -63,6 +107,18 @@ pub struct App { sel_seg: String, #[rust] sel_date: String, + /// Every slider on the mounted screen: state key, range, and the value last + /// written. Collected from the semantic tree, so the range is the DSL's, not + /// the 0..1 the native widget is emitted with. + #[rust] + sliders: Vec, + /// A slider moved and the screen has not been rebuilt for it yet. + #[rust] + slider_dirty: bool, + /// Frames since the last slider action, so a drag that simply stops still + /// redraws even if no touch-up arrives. + #[rust] + slider_idle: u32, #[rust] snack: bool, #[rust] @@ -76,16 +132,341 @@ pub struct App { } impl App { - fn current_source() -> String { - std::fs::read_to_string(DEVICE_PATH).unwrap_or_else(|_| BAKED.to_string()) + /// The screen's top app bar. + /// + /// Every reference screen sits under a real `MaterialToolbar` with a back + /// arrow and the screen's title. Without it this catalog's content sat ~64dp + /// higher than the reference's on every single route, which is most of the + /// residual difference between the two even where the content matches. + fn with_toolbar( + body: splash_render::UiNode, + r: &splash_makepad::material::Roles, + route: &str, + ) -> splash_render::UiNode { + use splash_render::{Attrs, NodeKind, UiNode}; + let n = |kind| UiNode { kind, attrs: Attrs::default(), children: Vec::new() }; + // The reference's own screen titles. + let title = l0::title_of(route).unwrap_or_else(|| screens::title_of(route)); + + // Aligned to the reference: title 69px against its 67, body 228 against + // 229. This was wrong until the swatch-group gap and DP_SCALE went in — + // the 12px offset had been cancelling drift further down the screen, and + // removing it alone cost two routes. With the drift itself fixed the + // alignment pays: 39 routes under 9.0 against 38, and `color` crosses. + // + // Historical, now superseded — NOT aligned, deliberately — re-tested after the + // DP_SCALE fix removed the drift this was originally cancelling. + // A *half* correction (7.5dp spacer, 54.3dp bar) was tried too and is + // also worse on all three: color 9.4 -> 10.2. Any upward shift costs + // them, so this is not a matter of finding the right amount. + // + // Re-tested twice more, after DP_SCALE and again after tuning it so the + // swatch pitch matches exactly: aligning still costs two routes (38 under + // 9.0 -> 36) and `color` 9.4 -> 12.1. Aligning helps the mean and + // pushes the three routes still over 9.0 *further* out (color 9.8 -> + // 10.9, allcomponents 12.0 -> 13.0). Those are the longest screens: + // shifting content up pulls 12px more of the *bottom* into the viewport, + // and the bottom is where cumulative error is worst. Since the target is + // per-route, the offset stays. + // Solving for both (a 9.6dp + // spacer with a 50dp bar) does put the title at 69px against its 67 and + // the body at 228 against its 229 — and scores *worse*: `color` 11.5 -> + // 15.7, routes under 9.0 drop 38 -> 35. The 12px the body sits low is + // cancelling an opposite drift further down the screen, where a stated + // 56dp row renders 157px here against the reference's 158. That is a + // dp->px rounding difference (2.80 vs 2.82) and it accumulates ~8px over + // a screen of twelve swatches. Until that is addressed the offset is + // load-bearing, so the spacer stays at zero. + // + // Solving from two measurements, not one knob: the title sits 6px above + // the reference's while the body sits 12px below it, so the bar has to + // move *down* while the content moves *up*. A bare height change cannot + // do that — shortening it drags the title into the status bar (at 60dp + // the title overlaps the system clock) and lengthening it pushes the + // body further down. A spacer above the bar plus a shorter bar does: + // title = S + H/2 - textH/2 and body = S + H + pad, so S=3dp, H=57dp. + let mut top_inset = n(NodeKind::Column); + // 20 + 39.6, not 9.6 + 50. The sum is what places the body (S + H), so + // holding it at 59.6 keeps every screen exactly where it was; raising S + // and shrinking H by the same amount only moves the bar's contents down, + // by half the change. That is the headroom: the arrow used to span + // y72-118 against a status bar whose frame reaches y80, so its top 9px + // were both cramped and untappable (taps there open the shade). + top_inset.attrs.h = Some(20.0); + let mut bar = n(NodeKind::Row); + // 64. Shortening it moves all content up, which the mean likes — 62 + // scores 5.82 against 64's 6.22 — but it pushes the three routes still + // above 9.0 *further* out (color 11.5 -> 12.4, allcomponents 12.0 -> + // 12.7), and 60 drops three more routes back over the line entirely. + // The mean is not the target; per-route is. + // + // The gap below this bar runs ~18px wider than the reference's, but + // that offset is not uniform across routes: taking it off the bar height + // (58) helps a dozen screens and costs a dozen others — 37 routes under + // 9.0 drops to 35. The page padding that would absorb it cannot shrink + // without pulling the horizontal inset along (tried five ways). + bar.attrs.h = Some(BAR_H); + bar.attrs.padx = Some(4.0); + // 10, not 8: with the back target 48dp wide from x4, this lands the + // title at 62dp -- the reference's x174. + bar.attrs.spacing = Some(10.0); + bar.attrs.aligny = Some(0.5); + bar.attrs.fillw = Some(1); + let mut back = n(NodeKind::Text); + back.attrs.text = Some("\u{f060}".to_string()); // arrow-left + back.attrs.icon = Some(1); + back.attrs.size = Some(20.0); + back.attrs.color = Some(r.on_surface); + back.attrs.margin = Some(12.0); + back.attrs.pady = Some(0.0); + // Wrapped, because `needs_click_overlay` only fires for a container -- + // `tapto` on the Text itself is silently ignored, which is why this + // arrow was decorative. No padding on the wrapper: the arrow's `margin` + // is already inert here (margin is emitted through the object form the + // dialect drops), so hugging keeps the bar's geometry exactly as the + // toolbar alignment was derived against. + // A real 48dp touch target, not a hug around a 16dp glyph. The wrapper + // used to hug: 47x56px, about a third of Material's minimum, and its + // top edge fell under the status bar (which occupies y 0-80 on this + // device and swallows taps there) -- so back "worked" only if you hit a + // sliver. Centring the glyph in 48dp also puts it where the reference + // draws it: measured, the reference's arrow spans x57-101 and its title + // starts at x174, where these were at x11 and x88. The toolbar's + // *horizontal* geometry had never been matched -- only its vertical -- + // and a glyph that small cannot move a 120x250 score. + let mut back_hit = n(NodeKind::Row); + back_hit.attrs.w = Some(48.0); + // The bar's own height, not 48: a target taller than the bar overflows + // it and its centred glyph lands below the title. The whole of it is + // tappable now that the bar clears the status bar, so 39.6dp of height + // is worth more than a nominal 48 that misaligns the arrow. + back_hit.attrs.h = Some(BAR_H); + back_hit.attrs.alignx = Some(0.5); + back_hit.attrs.aligny = Some(0.5); + back_hit.attrs.tapto = Some(screens::INDEX.to_string()); + back_hit.children.push(back); + bar.children.push(back_hit); + let mut t = n(NodeKind::Text); + t.attrs.text = Some(title.to_string()); + t.attrs.size = Some(22.0); + // Opt out of the backend's per-text vertical inset — that stands in for + // Android's TextView font padding inside content, but the bar has its own + // fixed height and the extra pushed `topappbar` from 8.7 to 9.2. An + // explicit 0 reads as "stated", where None would take the default. + t.attrs.pady = Some(0.0); + t.attrs.color = Some(r.on_surface); + t.attrs.fillw = Some(1); + bar.children.push(t); + + let mut page = n(NodeKind::Column); + page.attrs.fillw = Some(1); + page.children.push(top_inset); + page.children.push(bar); + page.children.push(body); + page + } + + /// Stack a real modal over the screen when a slot asks for one. + /// + /// The reference's screens do not contain their dialogs, sheets or pickers: + /// a button writes `key=action` and the *host* builds a real + /// `MaterialAlertDialogBuilder` / `BottomSheetDialog` / `MaterialDatePicker`. + /// This is that host half — without it the triggers are inert and the + /// modal-hosting screens are pictures of buttons. + fn with_modal( + screen: splash_render::UiNode, + r: &splash_makepad::material::Roles, + ) -> splash_render::UiNode { + use splash_render::{Attrs, NodeKind, UiNode}; + let n = |kind| UiNode { kind, attrs: Attrs::default(), children: Vec::new() }; + let text = |s: &str, size: f32, w: i32, c: u32| { + let mut t = n(NodeKind::Text); + t.attrs.text = Some(s.to_string()); + t.attrs.size = Some(size); + t.attrs.weight = Some(w); + t.attrs.color = Some(c); + t.attrs.fillw = Some(1); + t + }; + + // Which slot is open, and what it asked for. + let open = ["dlg", "bs", "ss", "menu", "dp", "tp", "snk"] + .into_iter() + .find_map(|k| { + let v = state_get(k); + (!v.is_empty()).then(|| (k, v)) + }); + let Some((slot, action)) = open else { + return screen; + }; + + // What each family shows. The reference opens a different real widget per + // action — six dialog shapes, three sheets, three pickers — so a single + // generic card would be a picture of a modal rather than the modal. + let (title, body, choices, bottom, wide) = match (slot, action.as_str()) { + ("dlg", "icon") => ("Use location?", "This app will access your location while in use.", &[][..], false, false), + ("dlg", "single") => ("Choose one", "", &["Never", "Every 15 minutes", "Every hour"][..], false, false), + ("dlg", "multi") => ("Choose any", "", &["Email", "SMS", "Push"][..], false, false), + ("dlg", "long") => ("Terms", "This message is deliberately long so the dialog scrolls. It repeats to fill the body and demonstrate that a long dialog keeps its actions pinned below the scrolling region.", &[][..], false, false), + ("dlg", "full") => ("Full-screen dialog", "A full-screen dialog covers the whole surface and carries its own app bar.", &[][..], false, true), + ("dlg", _) => ("Reset settings?", "This will restore default values for every preference in this app.", &[][..], false, false), + ("bs", "list") => ("Bottom sheet", "", &["Share", "Get link", "Edit name", "Delete"][..], true, true), + ("bs", "tall") => ("Expanded sheet", "A tall sheet expanded to most of the screen.", &[][..], true, true), + ("bs", _) => ("Bottom sheet", "Drag the handle to expand, or tap outside to dismiss.", &[][..], true, true), + ("ss", _) => ("Side sheet", "A sheet anchored to the edge of the screen.", &[][..], false, false), + ("menu", _) => ("", "", &["Refresh", "Settings", "Sign out"][..], false, false), + ("dp", "range") => ("Select range", "1 Mar 2026 – 14 Mar 2026", &[][..], false, false), + ("dp", "input") => ("Enter date", "03 / 11 / 2026", &[][..], false, false), + ("dp", _) => ("Select date", "Wed, 11 March 2026", &[][..], false, false), + ("tp", "input") => ("Enter time", "10 : 30", &[][..], false, false), + ("tp", _) => ("Select time", "10 : 30", &[][..], false, false), + _ => ("Message archived", "", &["Undo"][..], true, true), + }; + + let mut card = n(NodeKind::Column); + card.attrs.bg = Some(r.surf_high); + card.attrs.radius = Some(28.0); + card.attrs.pad = Some(24.0); + card.attrs.spacing = Some(16.0); + card.attrs.w = Some(if wide { 360.0 } else { 320.0 }); + if !title.is_empty() { + card.children.push(text(title, 24.0, 400, r.on_surface)); + } + if !body.is_empty() { + card.children.push(text(body, 14.0, 400, r.on_surface_variant)); + } + for choice in choices { + let mut row = n(NodeKind::Row); + row.attrs.h = Some(48.0); + row.attrs.spacing = Some(12.0); + row.attrs.aligny = Some(0.5); + row.attrs.fillw = Some(1); + let mut mark = n(NodeKind::Column); + mark.attrs.w = Some(20.0); + mark.attrs.h = Some(20.0); + mark.attrs.radius = Some(10.0); + mark.attrs.border = Some(2.0); + mark.attrs.bordercolor = Some(r.on_surface_variant); + row.children.push(mark); + row.children.push(text(choice, 16.0, 400, r.on_surface)); + card.children.push(row); + } + let mut actions = n(NodeKind::Row); + actions.attrs.spacing = Some(8.0); + actions.attrs.aligny = Some(0.5); + // Align the pair to the trailing edge rather than pushing them with a + // Fill spacer — the spacer claimed the whole row and the buttons never + // appeared. + actions.attrs.alignx = Some(1.0); + actions.attrs.h = Some(40.0); + for (label, keep) in [("Cancel", false), ("OK", true)] { + let mut b = n(NodeKind::Column); + b.attrs.h = Some(40.0); + b.attrs.padx = Some(12.0); + b.attrs.fitw = Some(1); + b.attrs.alignx = Some(0.5); + b.attrs.aligny = Some(0.5); + b.attrs.radius = Some(20.0); + // Give the pair a tonal container. Transparent text buttons were not + // drawing inside the card at all, and a modal you cannot see your way + // out of is worse than a slightly heavier one. + b.attrs.bg = Some(if keep { r.primary } else { r.surf_highest }); + // Either button dismisses; OK also records a result, as the + // reference's "Last result:" caption expects. + b.attrs.tapto = Some(if keep { + format!("modal:{slot}=ok") + } else { + format!("modal:{slot}=") + }); + let mut lab = text( + label, + 14.0, + 500, + if keep { r.on_primary } else { r.on_surface }, + ); + // A Fill label inside a hug-content button resolves to zero width and + // the button collapses to a sliver. Let it size to its text. + lab.attrs.fillw = None; + lab.attrs.fitw = Some(1); + b.attrs.padx = Some(20.0); + b.children.push(lab); + actions.children.push(b); + } + card.children.push(actions); + let _ = action; + + // Scrim + card. A Fill scrim would collapse under a Fit mount, so it is + // given the viewport height explicitly — the same trick the kit uses. + let mut scrim = n(NodeKind::Column); + scrim.attrs.bg = Some(0x99000000); + scrim.attrs.fillw = Some(1); + scrim.attrs.h = Some(820.0); + + let mut layer = n(NodeKind::Column); + layer.attrs.fillw = Some(1); + layer.attrs.h = Some(820.0); + layer.attrs.alignx = Some(0.5); + layer.attrs.aligny = Some(if bottom { 1.0 } else { 0.5 }); + layer.attrs.pad = Some(16.0); + // Dismiss on tap. It has to go on the *layer*, not the scrim: the layer + // is stacked above and was swallowing every tap, so a scrim handler never + // fired and the dialog had no way out. Both dialog actions dismiss too, + // so catching them here as well is harmless. + layer.attrs.tapto = Some(format!("modal:{slot}=")); + layer.children.push(card); + + let mut stack = n(NodeKind::Stack); + stack.attrs.fillw = Some(1); + stack.children.push(screen); + stack.children.push(scrim); + stack.children.push(layer); + stack + } + + /// The route to draw: whatever the sweep last wrote, else the first screen. + fn current_route() -> String { + std::fs::read_to_string(ROUTE_PATH) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "button".to_string()) + } + + /// The screen source. A pushed file still wins, so a single screen can be + /// iterated on without a rebuild. + /// + /// In-app navigation takes precedence over the route file. It has to: a tap + /// sets `self.screen`, but this used to render `current_route()` and so read + /// the file every time, which meant no tap could ever change the screen -- + /// the back arrow and every other nav target were inert for that reason as + /// much as for having no handler. + fn current_source(&self) -> String { + if let Ok(pushed) = std::fs::read_to_string(DEVICE_PATH) { + return pushed; + } + if l0::has(&self.screen) { + return l0::source_for(&self.screen); + } + if screens::has(&self.screen) { + return screens::source_for(&self.screen); + } + let route = Self::current_route(); + if l0::has(&route) { + return l0::source_for(&route); + } + screens::source_for(&route) } /// Translate + mount the active screen: inject app state as one `st = {…}` /// object, run the splash pipeline, feed makepad's dialect into the host. fn mount(&mut self, cx: &mut Cx) { - let src = Self::current_source(); - self.last_src = src.clone(); - let route = if self.screen.is_empty() { "home" } else { &self.screen }; + let src = self.current_source(); + let route = if self.screen.is_empty() { + Self::current_route() + } else { + self.screen.clone() + }; + let route = route.as_str(); let tab = if self.sel_tab.is_empty() { "overview" } else { &self.sel_tab }; let seg = if self.sel_seg.is_empty() { "day" } else { &self.sel_seg }; let date = if self.sel_date.is_empty() { "11" } else { &self.sel_date }; @@ -97,39 +478,359 @@ impl App { "let st = {{ route: {:?}, count: {}, tab: {:?}, seg: {:?}, date: {:?}, snack: {}, dark: {}, open: {:?} }}\n{}", route, count, tab, seg, date, snack, dark, open, src ); - if let Some(node) = splash_render::build(&full, |_vm| {}) { + // `adaptive` prints its window size class from this slot, and `S()` is + // read *during* evaluation — so this has to be seeded before build(), + // not after. Unset, the card showed a blank line where the reference + // reads "Compact (384dp)". Stated as a constant for the same reason the + // backing layer's 820dp height is: this host is pinned to one device. + if state_get("win_class").is_empty() { + state_set("win_class", "Compact (384dp)"); + } + if let Some(node) = splash_render::build(&full, register_state) { + // Every reference screen is rooted in its own `scroll`. This shell + // already provides one, and the mounted `Splash` is height:Fit — so a + // nested Fill scroll resolves to zero height and the screen renders + // blank. Unwrap it and let the shell's scroller do the scrolling. + let node = match node.kind { + splash_render::NodeKind::Scroll if node.children.len() == 1 => { + node.children.into_iter().next().unwrap() + } + _ => node, + }; + // Re-collected on every mount: the set of sliders is a property of + // the screen, and the committed value has to track what was just + // drawn or the next frame reads a stale `last` and re-mounts forever. + self.sliders.clear(); + collect_sliders(&node, &mut self.sliders); + // Android's theme paints the Material surface behind a screen, so the + // reference screens carry no background of their own. Here the shell + // is that theme: wrap the mount in the surface role, or every screen + // draws its ink onto makepad's default grey. + let roles = splash_makepad::material::Roles::reference_dark(); + // Toolbar *outside* the padded page, modal outside that. Applying the + // page's 16dp above the bar pushed it 39px below the reference's + // (title at y106 against y67) while squeezing the bar-to-content gap + // to 74px against 101 — two errors that partly cancelled and left + // every screen's content 12px low. + let mut page = splash_render::UiNode { + kind: splash_render::NodeKind::Column, + attrs: splash_render::Attrs::default(), + children: vec![node], + }; + // Keep the wrapper's own padding and rhythm. Removing it on the + // theory that the screens' own `pad: 16, pady: 8` was being doubled + // made every route measurably worse (10.2 -> 11.0, `color` 19 -> 33): + // the offset is not double padding, and the spacing carries weight. + // SETTLED — do not probe again. Splitting these axes has been tried + // at 16/8 (root and emitter), 16/12.4, and symmetric 10 and 13. All + // are worse across the sweep; 16 on both is the optimum. The 10px by + // which content sits below the reference's is *not* this padding: + // reducing it moved `carousel` the wrong way (10.8 -> 11.5). + // + // The screens' own `pad: 16, pady: 8` never lands — it is asymmetric, + // so it hits the inert object form and is dropped whole. This + // wrapper stands in for it, and 16 on both axes is right: the + // reference evidently drops `pady` as well. Reproducing 16/8 here + // was tried, at the root and in the emitter, and cost 2 points of + // mean across the sweep (it fixes `slider` 9.8 -> 3.3 and loses more + // on nine others), so the reference is not honouring it either. + // + // 16 put every label 6dp right of the reference's and 17px below it + // (measured: text at x65/y245 against x48/y228); 10 lands on both. + // Keep the *scalar* form — makepad honours `padding: 10` here but + // not the per-side object `padding: {left: .., top: ..}`. + // + // The 6dp is makepad's `Label` default padding, so zeroing that and + // restoring 16 here looks equivalent and is not: it costs ~2 points + // of mean pixel difference across the 41 routes (8.78 -> 10.64, and + // 11.86 with 10). That default is carrying spacing the reference + // also has. Measured, not reasoned — don't re-derive it. + // SETTLED. Splitting this into 16dp horizontal / smaller vertical has + // now been tried five ways — 16/8 (emitter and root), 16/12.4, + // symmetric 10 and 13, and 10+6 once the toolbar moved outside this + // padding, which removed the confound that explained the earlier + // failures. Every one costs 1.5-2 points of mean across the sweep. + // It reliably fixes `slider` (9.7 -> 4.6) and reliably breaks a dozen + // others. 16 on both axes is the optimum; do not probe again. + page.attrs.pad = Some(16.0); + page.attrs.spacing = Some(16.0); + page.attrs.fillw = Some(1); + // The surface has to cover the viewport, not just the content. A Fill + // height collapses under the Fit-height Splash mount, so the backing + // layer is given the viewport height explicitly and the content sits + // on top — otherwise every short screen showed makepad's grey below + // the last widget where the reference shows unbroken surface. + let mut backing = splash_render::UiNode { + kind: splash_render::NodeKind::Column, + attrs: splash_render::Attrs::default(), + children: Vec::new(), + }; + backing.attrs.bg = Some(roles.surface); + backing.attrs.fillw = Some(1); + backing.attrs.h = Some(820.0); + // `route`, not the file: after a tap the two disagree, and the + // toolbar kept titling the index "Button". + let page = Self::with_toolbar(page, &roles, route); + let page = Self::with_modal(page, &roles); + let mut stack = splash_render::UiNode { + kind: splash_render::NodeKind::Stack, + attrs: splash_render::Attrs::default(), + children: vec![backing, page], + }; + stack.attrs.fillw = Some(1); + let node = stack; let ui = splash_makepad::to_makepad_ui(&node); - self.ui.widget(cx, ids!(host)).set_text(cx, &ui); + // Mount on the app's MAIN VM. + // + // `Splash::set_text` allocates an isolate that only ever receives + // makepad's own `script_mod`, which is why this crate's Roboto could + // not resolve (every label blanked) and why splash-widgets' M3 + // control theming never applied. Evaluating here with `cx.with_vm` + // and handing the built `View` to the host widget keeps both in + // reach. Taps survive because the handler now calls the `NAV` global + // rather than `ui.nav_signal`, which only exists inside an isolate. + let code = format!("use mod.prelude.widgets.*\nView{{height:Fit, {ui}"); + let script_mod = ScriptMod { + cargo_manifest_path: env!("CARGO_MANIFEST_DIR").to_string(), + module_path: module_path!().to_string(), + file: file!().to_string(), + line: 1, + column: 0, + code: String::new(), + values: Vec::new(), + }; + let built = cx.with_vm(|vm| { + let value = vm.eval_with_append_source(script_mod, &code, ScriptValue::NIL.into()); + (!value.is_err() && !value.is_nil()) + .then(|| View::script_from_value(vm, value)) + }); + if let Some(view) = built { + if let Some(mut host) = self.ui.widget(cx, ids!(host)).borrow_mut::() { + host.view = view; + } + cx.redraw_all(); + } + // Only once the build succeeded. Marking the source seen up front let a + // half-written file (`adb push` is not atomic) fail to build, count as + // seen, and never be retried — that route then stayed frozen on the + // previous screen until the file changed again. + self.last_src = src; } } } -impl MatchEvent for App {} +/// The widget state the screens read through `S`/`N` and write through a tap. +/// +/// This is what makes the catalog a demo rather than a picture: a tap writes a +/// slot, the DSL is re-evaluated, and the new tree rebuilds the widgets — the +/// DSL decides what the screen says, exactly as the reference does it. +static STATE: std::sync::Mutex>> = + std::sync::Mutex::new(None); + +fn state_get(key: &str) -> String { + STATE + .lock() + .ok() + .and_then(|m| m.as_ref().and_then(|m| m.get(key).cloned())) + .unwrap_or_default() +} + +fn state_set(key: &str, value: &str) { + if let Ok(mut m) = STATE.lock() { + m.get_or_insert_with(Default::default) + .insert(key.to_string(), value.to_string()); + } +} + +/// Taps land here. The mounted body's `on_click` calls `NAV(t: "…")`, a global +/// registered below, instead of reaching through `ui.nav_signal` — `ui` is only +/// injected inside a `Splash` isolate, so the old handler silently did nothing +/// on any other VM. +static TAPS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +fn take_tap() -> Option { + TAPS.lock().ok().and_then(|mut q| if q.is_empty() { None } else { Some(q.remove(0)) }) +} + +/// `S(key)` -> the slot as a string (""" when unset); `N(key, dflt)` -> its number. +fn register_state(vm: &mut ScriptVm) { + let f_s = splash_render::add_global_fn(vm, &[(live_id!(k), ScriptValue::NIL)], |vm, a| { + let k = splash_render::string_prop(vm, a, live_id!(k)).unwrap_or_default(); + let v = state_get(&k); + vm.bx.heap.new_string_from_str(&v) + }); + vm.set_injected_global(live_id!(S), f_s); + + let f_n = splash_render::add_global_fn( + vm, + &[(live_id!(k), ScriptValue::NIL), (live_id!(d), ScriptValue::NIL)], + |vm, a| { + let k = splash_render::string_prop(vm, a, live_id!(k)).unwrap_or_default(); + let d = splash_render::num_prop(vm, a, live_id!(d)).unwrap_or(0.0); + let v = state_get(&k); + ScriptValue::from_f64(v.trim().parse::().unwrap_or(d)) + }, + ); + vm.set_injected_global(live_id!(N), f_n); + + let f_nav = splash_render::add_global_fn(vm, &[(live_id!(t), ScriptValue::NIL)], |vm, a| { + let t = splash_render::string_prop(vm, a, live_id!(t)).unwrap_or_default(); + if let Ok(mut q) = TAPS.lock() { + q.push(t); + } + ScriptValue::NIL + }); + vm.set_injected_global(live_id!(NAV), f_nav); +} + +impl MatchEvent for App { + /// Drive the drawn track from the invisible native slider under it. + /// + /// The value arrives as `.`: a widget inside a mounted + /// Splash body cannot be found by id from out here -- `widget_flood` returns + /// nothing -- but its actions do escape, so the Material lowering gives each + /// slider its own unit band and the index rides out in the one field that + /// travels. See `sl_native`. + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { + let mut moved = false; + for a in actions { + let Some(wa) = a.downcast_ref::() else { + continue; + }; + let Some(sa) = wa.action.downcast_ref::() else { + continue; + }; + // Never rebuild mid-gesture: re-mounting on every `Slide` does move + // the drawn handle, but it destroys and recreates the very widget + // the finger has captured, so the drag dies after one frame -- + // measured, a 480px swipe moved the handle 8px. The state slot is + // written throughout and the redraw is deferred to touch-up. + // + // Not `EndSlide`: it never arrives for a synthetic swipe (measured + // -- every action in a 700ms drag was a `Slide`), and trusting it + // meant the handle never moved at all. + let v = match sa { + SliderAction::Slide(v) | SliderAction::TextSlide(v) | SliderAction::EndSlide(v) => { + *v + } + _ => continue, + }; + // Bands sit two apart -- see `sl_native`. + let idx = (v / 2.0).floor().max(0.0) as usize; + let Some(b) = self.sliders.get_mut(idx) else { + continue; + }; + let frac = (v - (idx * 2) as f64).clamp(0.0, 1.0) as f32; + let mut val = b.lo + frac * (b.hi - b.lo); + if let Some(st) = b.step.filter(|s| *s > 0.0) { + val = (val / st).round() * st; + } + // A tolerance, not equality: the value will not survive the round + // trip through a text state slot unchanged, and an exact test would + // re-mount on every frame of a drag that is standing still. + if (val - b.last).abs() <= (b.hi - b.lo).abs() * 0.001 { + continue; + } + b.last = val; + // Match the granularity the screen already shows: a 0..100 slider + // captions its start value as "50", so reporting "82.66" back reads + // as a different kind of number. + let whole = b.step.is_some_and(|s| s >= 1.0) || (b.hi - b.lo).abs() >= 20.0; + let txt = if whole { + format!("{}", val.round() as i64) + } else { + format!("{val:.2}") + }; + state_set(&b.key, &txt); + moved = true; + } + if moved { + self.slider_dirty = true; + self.slider_idle = 0; + } + } +} impl AppMain for App { fn script_mod(vm: &mut ScriptVm) -> ScriptValue { crate::makepad_widgets::theme_mod(vm); script_eval!(vm, { - mod.theme = mod.themes.light + // The body now mounts on this VM, so its theme is the one every + // native control resolves against. It has to match the surface the + // host paints — with the light theme the control labels came out + // near-black on the dark M3 surface. + mod.theme = mod.themes.dark }); // Fork-free themed widgets (Material 3), against upstream makepad. splash_widgets::widgets_mod(vm); + // `S`/`N`/`NAV` on the **app** VM as well as the build VM. The mounted + // body evaluates here (`cx.with_vm`), so without this its `on_click` + // handlers raise "variable NAV not found in scope" and every tap in the + // catalog is silently dead — which is exactly what was happening. + register_state(vm); self::script_mod(vm) } fn handle_event(&mut self, cx: &mut Cx, event: &Event) { if matches!(event, Event::Startup) && !self.started { self.started = true; - self.screen = "home".to_string(); + // The semantic components resolve their Material roles in the + // backend, so the scheme is chosen there. The reference catalog + // renders dark; match it. + // Match the reference's own scheme, not the M3 baseline: that device + // runs Material You, so a baseline-purple render can only ever be + // compared structurally. + // Put `SplashTap` in reach of every mounted body. A Splash isolate + // receives makepad's own script mods and nothing else, so a widget + // type defined out here is invisible to a body -- which is why the + // tap target had to be a `Button`, and why a tappable row starved + // the scroll: `Button::handle_event` captures the finger on + // touch-down. `SplashTap` never calls `event.hits`, so the scroll + // sees the whole gesture. Must run before the first mount, since an + // isolate takes its mods at allocation. + makepad_widgets::widget_async::register_splash_isolate_mod(register_tap_mod); + splash_makepad::set_scheme(splash_makepad::material::Roles::reference_dark()); + // The width a wrapping row packs into. Left at its 340dp default the + // flow wrapped a row early — the content is 354dp here (measured: + // x45-1034), and the shortfall is what made the button screen drop + // its third button to a second line whenever the buttons widened. + splash_makepad::material::set_flow_width(354.0); + // The sweep's route, not `home` -- which was never a route at all, so + // `source_for` fell through to the first screen and drew `adaptive` + // under the literal title "home". + self.screen = Self::current_route(); self.next_frame = cx.new_next_frame(); self.mount(cx); } if self.next_frame.is_event(event).is_some() { self.tick = self.tick.wrapping_add(1); - let nav_raw = self.ui.widget(cx, ids!(nav_signal)).text(); + let nav_raw = take_tap().unwrap_or_default(); let nav = nav_raw.trim(); if !nav.is_empty() { - self.ui.widget(cx, ids!(nav_signal)).set_text(cx, ""); + // `set:=` — a widget writing its state slot. This is + // the round trip the reference verifies: the tap changes state, + // the DSL is re-evaluated, and the screen says something new. + if let Some(kv) = nav.strip_prefix("modal:") { + if let Some((k, v)) = kv.split_once('=') { + state_set(k, v); + if !v.is_empty() { + state_set(&format!("{k}_result"), v); + } + } + self.mount(cx); + self.next_frame = cx.new_next_frame(); + cx.redraw_all(); + return; + } + if let Some(kv) = nav.strip_prefix("set:") { + if let Some((k, v)) = kv.split_once('=') { + state_set(k, v); + } + self.mount(cx); + self.next_frame = cx.new_next_frame(); + cx.redraw_all(); + return; + } if nav == "act:count" { self.count = self.count.wrapping_add(1); } else if let Some(v) = nav.strip_prefix("tab:") { @@ -151,7 +852,20 @@ impl AppMain for App { self.open = String::new(); } self.mount(cx); - } else if self.tick % 20 == 0 && Self::current_source() != self.last_src { + } else if self.slider_dirty && { + self.slider_idle += 1; + self.slider_idle > 8 + } { + // Rebuild once the drag has been still for ~8 frames, rather + // than on touch-up: `EndSlide` never arrives for a synthetic + // swipe, and `TouchState` is not re-exported to reach for the + // raw event. Settling on idle covers a real lift and a stalled + // drag alike, and never fires while the finger is still moving, + // which is what would kill the gesture. + self.slider_dirty = false; + self.slider_idle = 0; + self.mount(cx); + } else if self.tick % 20 == 0 && self.current_source() != self.last_src { self.mount(cx); } cx.redraw_all(); diff --git a/apps/kit-host/src/screens.rs b/apps/kit-host/src/screens.rs new file mode 100644 index 0000000..e508c92 --- /dev/null +++ b/apps/kit-host/src/screens.rs @@ -0,0 +1,164 @@ +//! The reference catalog's screens, baked in. +//! +//! These are the **same `.splash` files** the Splash-Android catalog renders +//! with real `com.google.android.material.*` views. Baking the identical source +//! here is what makes the two backends comparable: same input, so any difference +//! on screen is this renderer's. +//! +//! `include_str!` rather than reading the directory, because cargo-makepad +//! builds Android inside a generated wrapper crate that never runs a build +//! script (the same reason the flutter kit is baked). + +/// Shared helpers every screen composes from (`section`, `caption`, `group`, …). +pub const KIT: &str = include_str!("../../../components/material/screens/kit.splash"); + +/// Every route, in the order the reference lists them. +pub const SCREENS: &[(&str, &str)] = &[ + ("adaptive", include_str!("../../../components/material/screens/adaptive.splash")), + ("allcomponents", include_str!("../../../components/material/screens/allcomponents.splash")), + ("badge", include_str!("../../../components/material/screens/badge.splash")), + ("bottomappbar", include_str!("../../../components/material/screens/bottomappbar.splash")), + ("bottomnav", include_str!("../../../components/material/screens/bottomnav.splash")), + ("bottomsheet", include_str!("../../../components/material/screens/bottomsheet.splash")), + ("button", include_str!("../../../components/material/screens/button.splash")), + ("card", include_str!("../../../components/material/screens/card.splash")), + ("carousel", include_str!("../../../components/material/screens/carousel.splash")), + ("checkbox", include_str!("../../../components/material/screens/checkbox.splash")), + ("chip", include_str!("../../../components/material/screens/chip.splash")), + ("color", include_str!("../../../components/material/screens/color.splash")), + ("datepicker", include_str!("../../../components/material/screens/datepicker.splash")), + ("dialog", include_str!("../../../components/material/screens/dialog.splash")), + ("divider", include_str!("../../../components/material/screens/divider.splash")), + ("dockedtoolbar", include_str!("../../../components/material/screens/dockedtoolbar.splash")), + ("elevation", include_str!("../../../components/material/screens/elevation.splash")), + ("fab", include_str!("../../../components/material/screens/fab.splash")), + ("floatingtoolbar", include_str!("../../../components/material/screens/floatingtoolbar.splash")), + ("font", include_str!("../../../components/material/screens/font.splash")), + ("imageview", include_str!("../../../components/material/screens/imageview.splash")), + ("listitem", include_str!("../../../components/material/screens/listitem.splash")), + ("loadingindicator", include_str!("../../../components/material/screens/loadingindicator.splash")), + ("materialswitch", include_str!("../../../components/material/screens/materialswitch.splash")), + ("menu", include_str!("../../../components/material/screens/menu.splash")), + ("musicplayer", include_str!("../../../components/material/screens/musicplayer.splash")), + ("navigationdrawer", include_str!("../../../components/material/screens/navigationdrawer.splash")), + ("navigationrail", include_str!("../../../components/material/screens/navigationrail.splash")), + ("octoswidgets", include_str!("../../../components/material/screens/octoswidgets.splash")), + ("preferences", include_str!("../../../components/material/screens/preferences.splash")), + ("progressindicator", include_str!("../../../components/material/screens/progressindicator.splash")), + ("radiobutton", include_str!("../../../components/material/screens/radiobutton.splash")), + ("search", include_str!("../../../components/material/screens/search.splash")), + ("shapetheming", include_str!("../../../components/material/screens/shapetheming.splash")), + ("sidesheet", include_str!("../../../components/material/screens/sidesheet.splash")), + ("slider", include_str!("../../../components/material/screens/slider.splash")), + ("snackbar", include_str!("../../../components/material/screens/snackbar.splash")), + ("tabs", include_str!("../../../components/material/screens/tabs.splash")), + ("textfield", include_str!("../../../components/material/screens/textfield.splash")), + ("timepicker", include_str!("../../../components/material/screens/timepicker.splash")), + ("topappbar", include_str!("../../../components/material/screens/topappbar.splash")), + ("transition", include_str!("../../../components/material/screens/transition.splash")), +]; + +/// The reference's own name for a route -- shared so the toolbar and the index +/// cannot disagree about what a screen is called. +pub fn title_of(route: &str) -> &str { + match route { + INDEX => "Catalog", + "allcomponents" => "All components", + "adaptive" => "Adaptive layouts", + "badge" => "Badge", + "bottomappbar" => "Bottom app bar", + "bottomnav" => "Bottom navigation", + "bottomsheet" => "Bottom sheet", + "button" => "Button", + "card" => "Card", + "carousel" => "Carousel", + "checkbox" => "Checkbox", + "chip" => "Chip", + "color" => "Color palette", + "datepicker" => "Date picker", + "dialog" => "Dialog", + "divider" => "Divider", + "dockedtoolbar" => "Docked toolbar", + "elevation" => "Elevation", + "fab" => "Floating action button", + "floatingtoolbar" => "Floating toolbar", + "font" => "Typography", + "imageview" => "Image view", + "listitem" => "List item", + "loadingindicator" => "Loading indicator", + "materialswitch" => "Switch", + "menu" => "Menu", + "musicplayer" => "Music player", + "navigationdrawer" => "Navigation drawer", + "navigationrail" => "Navigation rail", + "preferences" => "Preferences", + "progressindicator" => "Progress indicator", + "radiobutton" => "Radio button", + "search" => "Search", + "shapetheming" => "Shape theming", + "sidesheet" => "Side sheet", + "slider" => "Slider", + "snackbar" => "Snackbar", + "tabs" => "Tabs", + "textfield" => "Text field", + "timepicker" => "Time picker", + "topappbar" => "Top app bar", + "transition" => "Transition", + other => other, + } +} + +/// Is this a route we can actually draw? +/// +/// `source_for` falls back to the first screen for anything unknown, which is +/// silent: pushing a typo, or the string `home` that was never a route, drew +/// `adaptive` under the wrong title rather than reporting anything. +pub fn has(route: &str) -> bool { + route == INDEX || SCREENS.iter().any(|(n, _)| *n == route) +} + +/// The route name of the catalog index. +pub const INDEX: &str = "index"; + +/// The index: one tappable row per screen, in the order the reference lists +/// them. Generated rather than written as a `.splash` file so it cannot drift +/// out of step with `SCREENS`. +fn index_source() -> String { + let mut rows = String::new(); + for (name, _) in SCREENS { + // The chevron is now affordance only -- the whole row is tappable and + // still scrolls, because the target does not capture the finger. See + // `emit_click_overlay`. + rows.push_str(&format!( + " {{t:\"row\", tapto:\"{name}\", h: 56, aligny: 0.5, fillw: 1, c:[ \ + {{t:\"text\", size: 16, text:\"{}\", fillw: 1}}, \ + {{t:\"text\", size: 16, icon: 1, text:\"\\u{{f054}}\"}} ]}},\n", + title_of(name) + )); + } + // The L0 routes, at the top: they are the reason this host exists now, and + // burying them under fifty catalog screens makes them unfindable on a phone. + let mut l0_rows = String::new(); + for (route, title) in crate::l0::ROUTES { + l0_rows.push_str(&format!( + " {{t:\"row\", tapto:\"{route}\", h: 56, aligny: 0.5, fillw: 1, c:[ \ + {{t:\"text\", size: 16, text:\"{title}\", fillw: 1}}, \ + {{t:\"text\", size: 16, icon: 1, text:\"\\u{{f054}}\"}} ]}},\n" + )); + } + format!("{{t:\"scroll\", c:[ {{t:\"col\", pad: 16, spacing: 0, c: [\n{l0_rows}{rows}]}} ]}}") +} + +/// The full source for a route: shared kit first, then the screen. +pub fn source_for(route: &str) -> String { + if route == INDEX { + return format!("{KIT}\n{}", index_source()); + } + let body = SCREENS + .iter() + .find(|(n, _)| *n == route) + .or_else(|| SCREENS.first()) + .map(|(_, s)| *s) + .unwrap_or(""); + format!("{KIT}\n{body}") +} diff --git a/components/l0/_kit.splash b/components/l0/_kit.splash index 00817b0..e57c201 100644 --- a/components/l0/_kit.splash +++ b/components/l0/_kit.splash @@ -38,13 +38,34 @@ fn argb(a, r, g, b) { return ((a * 256 + r) * 256 + g) * 256 + b } // not a near-white fill. let l0_base = argb(255, 10, 14, 20) // #0a0e14 page let l0_scrim = argb(102, 0, 0, 0) // #00000066 over a photo -let l0_panel = argb( 18, 255, 255, 255) // #ffffff12 panel fill +// A sheet floating over a MAP, and the one fill in this palette that must be +// fully opaque. `l0_panel` is 7% white, which is a window rather than a surface: +// over a map the road labels read straight through whatever the card put there. +// The colour is the shipping nav card's own sheet. +let l0_sheet = argb(255, 15, 22, 32) // #0f1620 over a map +// The one action that ENDS something. Values from the shipping nav card's `endrow`. +let l0_danger = argb(255, 122, 31, 31) // #7a1f1f destructive fill +let l0_danger_i = argb(255, 255, 217, 217) // #ffd9d9 its ink +// NOT `l0_panel`, and the name is the whole point. +// +// `fn l0_panel(kids)` is a ROLE in this file, so a palette entry of the same name +// is shadowed by it from the function's declaration onward — and a function +// coerced to a colour is 0. Every use after `fn l0_panel` silently became fully +// transparent: `l0_chip` and `l0_field` both asked for this fill and both got +// nothing. Uses INSIDE `l0_panel`'s own body still resolved to the colour, which +// is why panels looked right and chips did not, and why it read as a chip bug. +// +// Found by live-testing a nav card on a phone: Go and End rendered as bare text +// with no button under them. No unit test could see it — zero is a valid colour. +let l0_fill = argb( 18, 255, 255, 255) // #ffffff12 panel fill let l0_active = argb( 56, 255, 255, 255) // #ffffff38 selected chip let l0_hairline = argb( 26, 255, 255, 255) // #ffffff1a divider let l0_text = argb(255, 255, 255, 255) // #ffffff primary let l0_soft = argb(230, 255, 255, 255) // #ffffffe6 secondary let l0_dim = argb(153, 255, 255, 255) // #ffffff99 captions and labels let l0_up = argb(255, 50, 215, 75) // #32d74b a rise +let l0_go = argb(255, 52, 199, 89) // #34c759 the action a screen is for +let l0_ondark = argb(255, 14, 22, 32) // #0e1620 text on a lit fill let l0_down = argb(255, 255, 69, 58) // #ff453a a fall // ---- primitives ------------------------------------------------------------ @@ -79,11 +100,18 @@ fn l0_spacer() { return {t: "column", fillw: 1, h: 1} } // and reads as thirty-three — so only the lowering knows what will be drawn. fn l0_hero(s, pt) { return l0_txt(s, pt, 700, l0_text) } -fn l0_title(s) { return l0_txt(s, 28, 700, l0_text) } -fn l0_body(s) { return l0_para(s, 16, 400, l0_text) } -fn l0_row_text(s) { return l0_txt(s, 15, 400, l0_soft) } -fn l0_caption(s) { return l0_txt(s, 12, 400, l0_dim) } -fn l0_value(s) { return l0_txt(s, 20, 600, l0_text) } +// The type scale, TAKEN FROM THE WIDGETS THIS REPLACES rather than chosen. +// +// These are the sizes `octos-one`'s `TextTitle`, `TextBody`, `TextRow`, +// `TextCaption` and `TextValue` already carry, and four device goldens assert +// them. Moving the layer and restyling at once would make a golden failure +// ambiguous between "the theme moved" and "the theme is wrong" — so the theme +// does not move yet. Restyling comes after, against green goldens. +fn l0_title(s) { return l0_txt(s, 18, 700, l0_text) } +fn l0_body(s) { return l0_para(s, 12, 400, l0_text) } +fn l0_row_text(s) { return l0_txt(s, 10, 400, l0_soft) } +fn l0_caption(s) { return l0_txt(s, 8, 400, l0_dim) } +fn l0_value(s) { return l0_txt(s, 14, 600, l0_text) } // A statistic, tinted by direction. `dir` is -1, 0 or 1 — the SIGN, decided by // the card, not the colour: red-versus-green is presentation, but "this value @@ -93,14 +121,95 @@ fn l0_tint(dir) { if dir < 0 { return l0_down } return l0_text } -fn l0_stat(s, dir) { return l0_txt(s, 20, 600, l0_tint(dir)) } +fn l0_stat(s, dir) { return l0_txt(s, 10, 600, l0_tint(dir)) } + +// Recolour any text by DIRECTION, composed around the role rather than folded +// into it. +// +// Every text role may carry a tint — the stock list tints a `TextValue`, the +// detail a `TextStat` — so the alternative was a direction parameter on all +// seven, six of which would pass 0 forever. `dir` of 0 leaves the role's own +// colour alone, which is what an untinted card gets. +fn l0_tinted(node, dir) { + if dir != 0 { node.color = l0_tint(dir) } + return node +} + +// Constrain a text role's WIDTH, composed around the role for the same reason +// `l0_tinted` is: any text role may carry `width`, and folding a parameter into +// all seven would have six passing a default forever. +// +// `fill` is what lets a long headline WRAP rather than run off the right edge. +// Every story title in the news list clipped until this existed — "A new +// approach to incremental c" — and a clipped title reads as a short one, so +// nothing on screen says the card is wrong. +fn l0_wide(node) { node.fillw = 1; return node } + +// The opposite, and it is NOT a no-op. `fit` is this backend's default for a +// text role, so `l0_wide`'s absence says it — but `l0_row` fills by default, +// because a list row must. A filling row inside a CENTRED column then ignores +// the centring: the weather card's `↑37° ↓28° ≈37°` sat hard left under a +// centred place name, icon and hero temperature, and no attribute the card could +// write changed it. `align` on a row means the CROSS axis, which is vertical. +fn l0_fit(node) { node.fillw = 0; node.fitw = 1; return node } + +// A fixed column, because these tokens name things that must LINE UP down a +// list: the rank numbers in a feed, the day names in a forecast. Fitting each +// to its own text ragged the column — "1" and "10" start at different x — so +// the card says WHICH column it is and the theme says how wide. +// +// The pixel counts live here and not in the lowering for the same reason the +// palette does: they are this theme's answer, and a different theme answers +// differently. +fn l0_colw(node, col) { + if col == "rank" { node.w = 28 } + if col == "day" { node.w = 44 } + if col == "temp" { node.w = 52 } + // A FORM LABEL: a fixed-width column that RIGHT-aligns what is in it, so the + // labels end where the fields begin instead of each one starting its field at + // its own x. "FROM" is wider than "TO", so fitting each to its text ragged the + // column exactly the way the rank numbers above ragged theirs. + // + // A WRAPPER, not `alignx` on the text. Alignment positions a widget inside its + // parent; it does not move the run inside the widget — the same thing the + // backend already learned about `TextInput`, where the container's `align` did + // nothing and `label_align` was the property that moved the text. Setting both a + // width and an alignment on the label itself left the run starting at the box's + // left edge and clipping off its right. + if col == "label" { + return {t: "column", w: 64, fith: 1, alignx: 1.0, c: [node]} + } + return node +} + +// Align a container's children on the CROSS axis — the axis the flow does not +// already decide. A column lays out downward, so aligning it is horizontal; a +// row lays out rightward, so aligning it is vertical. One token, one meaning, +// and the container knows which axis that is. +// +// The weather card centres its place name, its icon and its hero temperature, +// and that IS the screen's identity. Dropping the attribute left the whole +// header hard against the left margin — the card was right and looked wrong. +fn l0_aligned(node, how) { + if how == "center" { + if node.t == "row" { node.aligny = 0.5 } else { node.alignx = 0.5 } + } + if how == "end" { + if node.t == "row" { node.aligny = 1.0 } else { node.alignx = 1.0 } + } + return node +} // ---- containers ------------------------------------------------------------ // The page. Padding clears the status bar at the top and the gesture bar below. +// The page. `fith`, not `fillh`: a card is as tall as its content, and the host +// surface shows below it. Filling the height painted the whole screen in the +// card's base colour and covered the app's own background — 94% of the lower +// half differed, on a card whose content was correct. fn l0_surface(kids) { - return {t: "column", fillw: 1, fillh: 1, bg: l0_base, - padx: 20, pady: 24, c: kids} + return {t: "column", fillw: 1, fith: 1, bg: l0_base, + padx: 20, padtop: 54, padbottom: 24, c: kids} } // A page whose background is a photo, with a scrim so text stays legible over @@ -109,40 +218,259 @@ fn l0_surface_photo(src, kids) { return {t: "stack", fillw: 1, fillh: 1, c: [ {t: "image", src: src, fit: 2, fillw: 1, fillh: 1}, {t: "column", fillw: 1, fillh: 1, bg: l0_scrim}, - {t: "column", fillw: 1, fillh: 1, padx: 20, pady: 24, c: kids} + {t: "column", fillw: 1, fillh: 1, padx: 20, padtop: 54, padbottom: 24, c: kids} + ]} +} + +// A page whose background is a MAP, with the card's own content floating over it +// as an opaque sheet. +// +// Every number here came off `a2app/apps/nav`, which has four maps and puts none +// of them in a column. A `MapView` is a fixed-pixel full-bleed surface that paints +// its route through the GPU nav projection rather than inside a laid-out rect, so +// stacked in a column beneath the card's content it draws straight over it — +// measured, the trip's FROM/TO fields, duration and Go button were invisible under +// the map, and in the driving view the route ribbon cut the turn banner in half. +// +// FIXED 812, not `fillh`: `Fill` inside a `Fit` parent resolves to 0 and a card is +// an item in a chat list, so filling rendered an entirely empty screen. +// +// The sheet is OPAQUE because the ordinary panel is 7% white, which over a map is +// a window — the map's own road labels read straight through the trip. And it sits +// at the BOTTOM because `update_plan_preview_camera` fits the route into the band +// ABOVE the summary sheet, so a sheet at the top lands exactly on the route it was +// making room for. 76 rather than the shipping card's 30 at the bottom: that card +// is full-screen, and an L0 card sits under the app's composer bar. +// Content a swipe reveals. Hidden until then; the renderer wires the toggle. +// +// NOT card state. A state change re-resolves the card, re-parses the document and +// rebuilds the `MapView` inside it — measured at up to 327 ms of frozen map. The +// shipping nav card toggles `ui.endrow.set_visible` directly for exactly this reason, +// and `action` is how the renderer is told which view to do that to. +fn l0_reveal(kids) { + return {t: "column", fillw: 1, action: "reveal", c: kids} +} + +// The top band, or nothing at all when nothing is docked there. `has` comes from +// the LOWERING because that is what knows whether the card docked anything; the kit +// has no way to ask a list its length. +fn l0_band(kids, has) { + if has == 0 { return {t: "column", fillw: 1, h: 0} } + return {t: "column", fillw: 1, fith: 1, bg: l0_sheet, radius: 18, + padx: 14, padtop: 10, padbottom: 10, c: kids} +} + +// The controls a map offers, top to bottom: the zoom pill, the current-location +// ring, then whatever the card docked to the side — which is where a view switch +// belongs, under the location button. +// +// Built HERE rather than inside `l0_map`: inside the map they sat at a fixed offset +// from its top and the instruction banner covered them, so the zoom pill's `+` was +// hidden behind it. In the surface's flow they come after the band and clear +// whatever it is tall, and there is ONE column instead of two clusters competing for +// the same corner. +fn l0_mapside(sidekids, hasside) { + if hasside == 0 { return {t: "column", h: 0} } + // No box of its own: each side control is a 38x38 circle (`l0_mapchip`), + // the same size as the recenter ring above it — the eye-test asked for the + // 2D/3D switch and the location button to match, and a text pill in a + // rounded panel could not. + return {t: "column", fitw: 1, fith: 1, alignx: 1.0, c: sidekids} +} + +// A side-docked map control: the card's own chip, drawn to the ring's spec. +// 38x38, the sheet's dark fill, the label centred — over pale tiles the +// generic sheet chip (`l0_fill` behind `l0_dim`) read as a grey smudge. +fn l0_mapchip(s) { + return {t: "column", w: 38, h: 38, radius: 19, bg: l0_sheet, + alignx: 0.5, aligny: 0.5, c: [l0_txt(s, 11, 600, l0_text)]} +} + +fn l0_mapcontrols(which, sidekids, hasside) { + let ring = {t: "column", fitw: 1, fith: 1, bg: l0_sheet, radius: 19, c: [ + {t: "column", w: 38, h: 38, alignx: 0.5, aligny: 0.5, action: "recenter", c: [ + // DRAWN, not typed: "◎" is U+25CE and the bundled Roboto has no glyph + // for it, so a typed one rendered as a tofu box. + {t: "column", w: 18, h: 18, radius: 9, bg: l0_text, + alignx: 0.5, aligny: 0.5, c: [ + {t: "column", w: 8, h: 8, radius: 4, bg: l0_sheet} + ]} + ]} + ]} + let pill = {t: "column", fitw: 1, fith: 1, bg: l0_sheet, radius: 14, c: [ + l0_mapbtn("+", "zoomin"), + l0_rule(), + l0_mapbtn("\u{2212}", "zoomout") + ]} + // NESTED, not concatenated. `[pill, gap] + sidekids` produced an empty column: + // the VM has no `+` for lists, so the whole expression evaluated to an error and + // every control vanished — the map kept its buttons only while they were built + // inside `l0_map` with literal lists. A column wrapping the card's own kids + // stacks them under the ring and needs no list arithmetic. + // + // The wrapper carries the pill's own dark backing, because whatever the card + // docked here is standing on MAP, not in a sheet. The nav card's 2D/3D switch is + // an ordinary `Chip`, and an ordinary chip is styled for a sheet: `l0_fill` + // behind `l0_dim`. Over pale tiles it read as a grey smudge — the label was + // there and could not be seen. `hasside` for the same reason `l0_band` takes + // `has`: the theme cannot ask a list its length, and an empty box drawn on every + // planning screen is the bug this kit already fixed once. + let docked = l0_mapside(sidekids, hasside) + if which == "none" { return [docked] } + if which == "zoom" { return [pill, l0_gap(10), docked] } + return [pill, l0_gap(10), ring, l0_gap(10), docked] +} + +fn l0_surface_map(map, topkids, sidekids, kids, hastop, controls, hasside) { + // TWO LAYERS, not four — the shape `a2app/apps/nav` uses for its driving screen. + // + // An earlier version stacked the map, a top-aligned layer and a bottom-aligned + // layer as three siblings, each `fillh` with its own alignment. It rendered, but + // it is not how the card being replaced does it, and every layer that fills the + // screen is another surface competing for the same hit tests. + // + // Theirs is one UI layer over the map, in a single `Down` flow: the top pane is + // simply the FIRST child, a `fillh` spacer eats the slack, and the sheet is LAST. + // No alignment tricks, one thing on top of the map, and the panes cannot overlap + // because a column cannot overlap itself. + // + // The paddings are theirs, except the bottom. 44 at the top clears the status + // bar and 10 at the sides are copied; 76 at the bottom replaces their 10, because + // their card is full-screen and an L0 card is an item in a chat list with the + // app's composer bar over it. Measured with 10: the sheet rendered below the + // visible area, cut off behind "Reconnecting…". Measured with 76: it cleared the + // bar by ~70 logical units and floated in the middle of the map. 52 still left + // 258px — 11% of the screen — of empty map between the sheet and the edge, which + // reads as the sheet hovering rather than resting. 14 put it UNDER the chrome — the copy/share row and the gesture bar overlay the + // bottom ~110px, and the sheet disappeared behind them. 40 rests it just clear: + // 52 left 258px of empty map below the sheet, 30 and 14 put it under the chrome. + return {t: "stack", fillw: 1, h: 812, bg: l0_base, c: [ + map, + // 56, raised from 40, and measured rather than tasted: with 40 the sheet's + // bottom sat within ~14dp of the clip line, and WHERE that line falls moves + // with the window's inset history — the same build rendered the bottom + // padding after a keyboard cycle and clipped it flat on a fresh boot, GO's + // rounding cut mid-pill. 56 keeps the sheet's padbottom and radius clear of + // the boundary wherever it lands. + {t: "column", fillw: 1, fillh: 1, padx: 10, padtop: 44, padbottom: 56, c: [ + // EMPTY MEANS ABSENT. A screen that docks nothing at the top still had + // the band drawn for it — a dark pill floating over the map saying + // nothing, on every planning screen. A container with no children is not + // a container. + l0_band(topkids, hastop), + {t: "column", fillw: 1, fith: 1, alignx: 1.0, padtop: 10, + c: l0_mapcontrols(controls, sidekids, hasside)}, + // The spacer. This is what puts the sheet at the bottom. + {t: "column", fillw: 1, fillh: 1}, + // CENTRED, like theirs: `remmin`/`remrest` are `align: Align{x: 0.5}` + // and the End bar spans the sheet. A driving summary is read at a + // glance, and a glance goes to the middle. + {t: "column", fillw: 1, fith: 1, bg: l0_sheet, radius: 22, + // Tight. The sheet sits over the map a driver is reading, so every + // unit of its height is map they cannot see: it covered 22% of the + // screen expanded. + // 4/8 was tuned for the DRIVE sheet, which is a handle over two lines + // of text. A planning sheet ends in a button, and 8 left it 7px off the + // sheet's own edge — reading as clipped whether or not it was. + padx: 14, padtop: 10, padbottom: 18, alignx: 0.5, + action: "sheet", c: kids} + ]} ]} } fn l0_col(kids) { return {t: "column", fillw: 1, c: kids} } fn l0_col_gap(g, kids) { return {t: "column", fillw: 1, spacing: g, c: kids} } -fn l0_row(kids) { return {t: "row", fillw: 1, spacing: 6, c: kids} } -fn l0_row_gap(g, kids) { return {t: "row", fillw: 1, spacing: g, c: kids} } -fn l0_grid(kids) { return {t: "grid", fillw: 1, w: 2, spacing: 10, c: kids} } +// A row centres its children vertically by default, because a row's whole job +// is to put unlike things side by side — a 44px icon beside 14pt text beside an +// 8px gradient bar — and top-aligning those reads as three separate things that +// happen to be adjacent. The reference rendering aligns every row this way. +fn l0_row(kids) { return {t: "row", fillw: 1, spacing: 6, aligny: 0.5, c: kids} } +fn l0_row_gap(g, kids) { return {t: "row", fillw: 1, spacing: g, aligny: 0.5, c: kids} } +// A grid of tiles. `w` is WIDTH IN PIXELS on the shared node model, and this +// said `w: 2` meaning "two columns" — so the stock card's six statistic tiles +// rendered in a box two pixels wide, which reads on screen as the card simply +// not having them. Neither backend implements a column count for a grid; both +// flow it downward like a column. Until one does, the honest thing is to fill +// the line and let the tiles stack. +fn l0_grid(kids) { return {t: "grid", fillw: 1, spacing: 10, c: kids} } // A raised surface holding related content. fn l0_panel(kids) { - return {t: "card", fillw: 1, bg: l0_panel, radius: 14, - padx: 14, pady: 12, marginy: 16, c: kids} + return {t: "card", fillw: 1, bg: l0_fill, radius: 14, + padx: 14, pady: 12, margintop: 16, c: kids} } fn l0_rule() { return {t: "divider", fillw: 1, h: 1, bg: l0_hairline} } // A labelled value — the unit a detail grid is made of. fn l0_tile(label, value) { - return {t: "card", fillw: 1, bg: l0_panel, radius: 12, padx: 12, pady: 10, + return {t: "card", fillw: 1, bg: l0_fill, radius: 12, padx: 12, pady: 10, c: [ l0_caption(label), l0_gap(4), l0_value(value) ]} } // A selectable token. `on` is the card's own notion of selected — without it // every chip in a range picker rendered identically and the card could not show // which was chosen. +// A chip, selected or not. +// +// BRANCH AND RETURN rather than assign-then-patch. This was +// +// let fill = l0_panel +// if on == 1 { fill = l0_active } +// +// and the reassignment does not take in this VM: `fill` arrived at the node as 0, +// so every UNSELECTED chip was fully transparent. On a dark page that is a label +// with no button under it, which is what it looked like — the stock card's four +// unselected range chips, and a nav card's Go and End, all rendered as bare text. +// Nothing was missing and nothing was wrong except that none of them looked +// tappable. Found by live-testing on a phone, invisible to every unit test, +// because a colour of zero is a perfectly valid colour. +// +// A chip is INTRINSIC — as wide as its label plus its padding. Saying so matters +// because a tappable chip is wrapped in a fit-width hit target, and a fit wrapper +// around a child that never stated a width collapsed to nothing: all five range +// chips vanished from the stock detail, leaving a chart with no way to change its +// range and no sign that anything was gone. +// The action that ENDS something: full width, red, centred — the shipping nav card's +// `endrow`. A card says `tone: .danger`; which colour that is, and that it spans the +// sheet rather than hugging its label, is the theme's to decide. +fn l0_chip_danger(s) { + return {t: "chip", bg: l0_danger, radius: 12, padx: 10, pady: 12, + fillw: 1, alignx: 0.5, aligny: 0.5, + c: [ l0_txt(s, 11, 500, l0_danger_i) ]} +} + +// A destructive action scoped to ONE ROW — danger's colours in a chip's +// geometry. The full-width `l0_chip_danger` is a screen action (nav's Stop); +// this one sits beside the row it deletes, revealed by the card's own guard. +fn l0_chip_danger_row(s) { + return {t: "chip", bg: l0_danger, radius: 12, padx: 12, pady: 12, + aligny: 0.5, + c: [ l0_txt(s, 11, 500, l0_danger_i) ]} +} + +// The action a screen is FOR. `tone` says what an action means and the theme says +// how loud that is — a primary one is twice the type and a full round target, which +// is what an icon needs to be tappable at arm's length in a car. +fn l0_chip_primary(s) { + // GREEN, and the card does not say so. `tone: .primary` says "this is the action + // the screen is for"; that it reads as go — the same green iOS Maps uses, the + // same green this palette already spends on a rise — is the theme's answer. + // Sized like a button, not like a banner. At 20pt with 22/14 of padding this + // came out roughly 250x110 on a 1080-wide screen — a quarter of the sheet for one + // word. iOS Maps' GO is about 90x60 on a wider screen; 15pt with 24/9 lands in + // proportion and still gives a thumb somewhere generous to land. + return {t: "chip", bg: l0_go, radius: 14, padx: 24, pady: 9, selected: 0, + fitw: 1, fith: 1, c: [ l0_txt(s, 15, 700, l0_ondark) ]} +} + fn l0_chip(s, on) { - let fill = l0_panel - let ink = l0_dim - if on == 1 { fill = l0_active } - if on == 1 { ink = l0_text } - return {t: "chip", bg: fill, radius: 12, padx: 10, pady: 5, selected: on, - c: [ l0_txt(s, 14, 500, ink) ]} + if on == 1 { + return {t: "chip", bg: l0_active, radius: 12, padx: 10, pady: 5, selected: 1, + fitw: 1, fith: 1, c: [ l0_txt(s, 10, 500, l0_text) ]} + } + return {t: "chip", bg: l0_fill, radius: 12, padx: 10, pady: 5, selected: 0, + fitw: 1, fith: 1, c: [ l0_txt(s, 10, 500, l0_dim) ]} } fn l0_photo(src) { return {t: "image", src: src, fit: 2, fillw: 1} } @@ -164,6 +492,50 @@ fn l0_photo(src) { return {t: "image", src: src, fit: 2, fillw: 1} } // intrinsic and sits in a row of chips; wrapping it in a filling container makes // the first chip eat the row. +// The one role that receives something the user TYPED. +// +// `tapto` carries the commit target, exactly as it does for a tap — the backend +// turns it into whatever binds a return key, and the typed text becomes the +// payload. So text enters a card through the same declared, total path a tap +// does: `on_commit` names an event, the event writes declared state. +// Height is FIXED, not fitted. A text input is a control with a standing size +// rather than a box around its current contents, and every working input in +// this app states one — a fitted height also leaves the row's height dependent +// on text that has not been typed yet. +fn l0_field(text, placeholder, target, changing) { + // PADDED, not fixed-height. `h: 48` put the text at the top of a 48px box and + // every place name floated above the pill it was supposed to sit inside. Neither + // the container's `align` nor the input's `label_align` moved it — the run is laid + // out at the top of the turtle and the background is simply taller. + // + // Padding makes the pill grow around the text instead, so it is centred by + // construction: 14 above and below a 15pt run lands at the same 48 the fixed + // height was asking for, and it stays centred if the size ever changes. + return {t: "input", fillw: 1, text: text, placeholder: placeholder, + tapto: target, changeto: changing, bg: l0_fill, radius: 12, padx: 14, + padtop: 14, padbottom: 14, + // 15, not 17. A place name is the longest string a card puts in a + // fixed-width box, and at 17 "Saratoga High School" rendered as + // "Saratoga High Schoc" — clipped inside its own field, with no + // ellipsis to say so. The shipping nav card's search input is 15. + size: 11, color: l0_text} +} + +// A text node that carries its own LIVE CALL, so the renderer can refresh it in +// place instead of rebuilding the card around it. +// +// The kit evaluates a `sys.*` call to a string, and that string is all the renderer +// would otherwise see — so refreshing it meant re-resolving the ledger and re-parsing +// the whole document. On a driving screen that lands inside a frame: measured on a +// OnePlus 6, frame hitches and card re-resolves correlate 1:1, up to 327 ms of frozen +// map. The app this replaces never paid that: its card named its widgets and set +// their text from `fn tick()`. `action` carries the call so the renderer can emit +// the same thing. +fn l0_live(node, call) { + node.action = call + return node +} + fn l0_tap(target, node) { return {t: "column", fillw: 1, tapto: target, c: [node]} } @@ -185,9 +557,97 @@ fn l0_tap_fit(target, node) { // let a card lose its temperature bars and still look complete, which is the // failure mode §1.1 exists to prevent. -fn l0_weathericon(cond) { return {t: "weathericon", variant: cond, w: 44, h: 44} } +// A trip, drawn. The card named two places and the ROUTE arrives already +// resolved — `poly` is an encoded polyline5, and `mode` is which member of the +// map family this is: a north-up preview of the whole route, a chase camera, or +// the same route drawn flat. +// +// How tall a map is, is the theme's answer, like every other role here — but the +// answer is CONSTRAINED, not free. The shipping nav card's mandatory rules say a +// `MapView` needs a fixed pixel height and that `Fill`/`Fit` "resolve to 0 and +// hide the map". 452 is that card's panel size, for a screen with content above +// the map; 812 is its full-bleed size. The first version here asked for 240 and +// drew a correct map in a letterbox. +// A map. `mode` is which member of the family; the centre and the route come +// resolved. +// +// A FOLLOW map's centre is not this one. `follow`/`follow3d` read the device's fix +// directly in the widget, every frame — a position that changes every second is not +// a property, and routing it through one froze the camera. The centre here is the +// trip's, which is what a preview needs and what a follow camera falls back to for +// the frame before the first fix lands. +// One control on a map: a fixed square that carries a glyph and an ACTION the +// backend turns into a call on the map. The card never names the call. +fn l0_mapbtn(glyph, act) { + return {t: "column", w: 38, h: 38, alignx: 0.5, aligny: 0.5, action: act, + c: [l0_title(glyph)]} +} + +fn l0_map(mode, zoom, lat, lon, poly, pins, badge) { + // `pins` stands the trip's own endpoints on the route: green origin, blue + // stops, red destination. Derived from the SAME coordinates as `poly`, so a + // pin cannot mark a place the drawn line does not pass. + let m = {t: "navmap", fillw: 1, h: 812, + variant: mode, zoom: zoom, lat: lat, lon: lon, polyline: poly, + markers: pins, route_badge: badge} + return m +} + +// A condition, drawn. `cond` is a NUMBER — the WMO code the forecast returns — +// and `size` is which of the three places this icon sits in: the hero block, a +// forecast row, or a tile. The card says which; how big each is, is the theme's. +fn l0_weathericon(cond, size) { + if size == "hero" { return {t: "weathericon", variant: cond, w: 64, h: 64} } + if size == "tile" { return {t: "weathericon", variant: cond, w: 28, h: 28} } + return {t: "weathericon", variant: cond, w: 34, h: 34} +} + +// The five that used to be markers. +// +// Each is a shader parameterised by data, so the kit chooses the SIZE — a +// theme's business — and the card supplies the numbers. Height matters here in a +// way it does not for text: a temperature bar is 8px of gradient and a contour +// field is a map, and neither has an intrinsic size to fit to. + +// A day's low and high against the week's range, as a gradient segment. +fn l0_tempbar(lo, hi, min, max) { + return {t: "tempbar", fillw: 1, h: 8, marginx: 10, + lo: lo, hi: hi, min: min, max: max} +} + +// The sun's path, with now marked on it. Hours are fractional — 18.9 is 18:54. +fn l0_sunarc(rise, set, now) { + return {t: "sunarc", fillw: 1, h: 90, rise: rise, set: set, now: now} +} + +// 0..1 through the cycle, and percent illuminated. +fn l0_moonphase(phase, illum) { + return {t: "moonphase", w: 64, h: 64, phase: phase, illum: illum} +} + +// An air-quality field around a point. The widget fetches it: the card names +// WHERE, never the sixteen scalars a field would otherwise need. +// LIVE SATELLITE CLOUD IMAGERY (卫星云图) over a place. +// +// The one pane the shipping weather card has and L0 could not express: it names +// two coordinates and the helper answers a tile URL, so the card says WHERE and +// never what the sky looks like. Fetched here rather than passed in, for the same +// reason `l0_aqicontour` takes a location and not a grid — a card that carried +// the image would be carrying an observation. +fn l0_satellite(lat, lon) { + return {t: "image", src: sys.satellite(lat, lon), fit: 2, fillw: 1, h: 190} +} + +fn l0_aqicontour(lat, lon, span) { + return {t: "aqicontour", fillw: 1, h: 190, lat: lat, lon: lon, span: span} +} + +// A price series. Likewise: the card names WHICH series and over what window. +fn l0_stockplot(symbol, range) { + return {t: "stockplot", fillw: 1, h: 180, symbol: symbol, range: range} +} fn l0_unsupported(role) { - return {t: "card", fillw: 1, bg: l0_panel, radius: 8, padx: 10, pady: 8, + return {t: "card", fillw: 1, bg: l0_fill, radius: 8, padx: 10, pady: 8, c: [ l0_caption("[" + role + " has no kind in this renderer]") ]} } diff --git a/components/l0/conformance.txt b/components/l0/conformance.txt index 873257a..07a17a3 100644 --- a/components/l0/conformance.txt +++ b/components/l0/conformance.txt @@ -20,7 +20,64 @@ # realized against the data in each suite's own helper, lowered by `kit::lower`, # concatenated with `_kit.splash`, and evaluated. # +# weather dropped from 64 to 60 when the five data visualisations stopped being +# markers. A marker was a card wrapping a label — two nodes — and a real widget +# is one. Both suites failed together and this line fixed both, which is the +# workflow this file exists for. +# # card nodes -news 27 -stock 12 -weather 64 +news 41 +stock 17 +weather 71 + +# CHANGING A NUMBER HERE. weather went 60 → 66 when the card gained a `Satellite` +# cloud pane and a `Row` that stops filling, and swapped its visibility tile for +# precipitation. Six nodes is the honest cost of those. The number was raised only +# after BOTH evaluators independently reported 66 — they agreed with each other and +# disagreed with this file, which is the one direction that means the fixture moved +# rather than a walk drifting. Never raise it to whatever one evaluator says. +# +# weather went 66 → 72 when the card gained the saved-cities strip (§5.12): the +# strip's panel and its tappable "add a city" row evaluate even in the test data's +# empty state — nothing saved, editor closed. Same workflow as the line above: +# both evaluators reported 72 independently, then this line moved. +# +# stock went 12 → 15 when the card gained its §5.12 personal layer: a ☆ chip on +# each of the three test-data mover rows saves the ticker (the watchlist strip +# itself and the news card's reading list evaluate to nothing in the test data — +# empty store, feed screen — so they cost no nodes here). Both evaluators +# reported 15 independently before this line moved. +# +# stock 15 → 16: the mover row's ☆ chip moved OUT of the tappable row to sit +# beside a filling inner tap row — a row's transparent hit target covers its +# whole content, so a chip inside one fires the row's event (measured on the +# 6T with weather's × chip; same class, same fix). One wrapper node per test +# mover. Both evaluators reported 16 independently before this line moved. +# +# news 27 -> 41: the TOPICS block — a header, four suggestion chips (each a +# tap), and per-followed-topic rows in the sibling-chip shape. Store-backed +# rows are empty in the fixture, so the delta is the static block only. Both +# evaluators reported 41 independently before this line moved. +# +# weather 72 -> 71: the add-a-city affordance moved from a full-width row +# under the saved strip into a + chip in the title bar (top-right corner, +# asked for by hand on device), net one node fewer. Both evaluators +# reported 71 independently before this line moved. +# +# stock 16 -> 21: the add flow's always-present header bar (WATCHLIST caption +# + the top-right + chip) joins the list view; the editor and the red manage- +# mode Remove are behind guards the fixture leaves closed. Both evaluators +# reported 21 independently before this line moved. +# +# stock 21 -> 17: the per-row star shortcut left the movers (saving is the +# add flow's explicit Add), taking its chip and sibling wrapper per test +# mover. Both evaluators reported 17 independently before this line moved. +# +# stock 17 -> 22: the mover rows swipe to reveal Add, so each of the three +# test movers grows its wrapper and hidden chip alongside the tappable +# inner row. Both evaluators reported 22 independently before this moved. +# +# stock 22 -> 17: the movers' swipe menu went as fast as it came — movers +# are the market's recommendations, so Add moved to the quote page (behind +# the fixture's closed selected guard) and the mover rows are single taps +# again. Both evaluators reported 17 independently before this moved. diff --git a/components/material/catalog.splash b/components/material/catalog.splash index 43b739a..57090c8 100644 --- a/components/material/catalog.splash +++ b/components/material/catalog.splash @@ -40,6 +40,7 @@ let inverse_surf = pick(dk, argb(255, 230, 224, 233), argb(255, 50, 47, 53)) let inverse_on = pick(dk, argb(255, 50, 47, 53), argb(255, 245, 239, 247)) let inverse_pri = pick(dk, argb(255, 103, 80, 164), argb(255, 208, 188, 255)) let page = surface +let clear = argb(0, 0, 0, 0) // ---- component helpers ----------------------------------------------------- // Type sizes are the M3 type-scale sp values; emphasis roles carry weight:500. @@ -111,6 +112,37 @@ fn nav_item(icon, label, ink, pill) { ]} } +// A shapeable image (mdc `imageview`): the M3 shape scale applied to imagery. +// No bitmap ships with the kit, so each is a tonal fill in its shape — the same +// way every other component here is drawn from primitives. +fn shaped_img(r, label, fill) { + return {t: "column", fitw: 1, spacing: 4, c: [ + {t: "column", bg: fill, radius: r, w: 72, h: 72, alignx: 0.5, aligny: 0.5, + c: [ {t: "text", text: "\u{f03e}", icon: 1, size: 24, color: on_pri_cont} ]}, + {t: "text", text: label, size: 11, weight: 500, color: on_surf_var, h: 14}, + ]} +} + +// An adaptive pane layout (mdc `adaptive`): the same content at a compact and an +// expanded window class — one pane below the medium breakpoint, two above it. +fn pane(title, body, w) { + return {t: "column", bg: surf_cont, radius: 12, pad: 12, spacing: 4, w: w, c: [ + {t: "text", text: title, size: 14, weight: 500, color: on_surface, h: 20}, + {t: "text", text: body, size: 12, color: on_surf_var, h: 32}, + ]} +} + +// A draggable card (mdc `draggable`): the drag affordance and its target. +fn drag_card() { + return {t: "row", bg: surf_high, radius: 12, pad: 12, spacing: 12, aligny: 0.5, c: [ + {t: "text", text: "\u{f58e}", icon: 1, size: 20, color: on_surf_var, fitw: 1}, + {t: "column", spacing: 2, fitw: 1, c: [ + {t: "text", text: "Draggable card", size: 14, weight: 500, color: on_surface, h: 20}, + {t: "text", text: "Press and hold the handle", size: 12, color: on_surf_var, h: 16}, + ]}, + ]} +} + // A colour-role swatch. fn swatch(c, name) { return {t: "column", fitw: 1, spacing: 4, c: [ @@ -181,8 +213,8 @@ fn dialog() { size: 14, color: on_surf_var, w: 272, h: 44}, {t: "row", spacing: 8, aligny: 0.5, c: [ {t: "column", c: []}, - {t: "button", label: "Cancel", tapto: "open:", fitw: 1, h: 40}, - {t: "button", label: "Reset", tapto: "open:", fitw: 1, h: 40}, + {t: "button", bg: clear, bordercolor: clear, border: 0, color: primary, label: "Cancel", tapto: "open:", fitw: 1, h: 40}, + {t: "button", bg: clear, bordercolor: clear, border: 0, color: primary, label: "Reset", tapto: "open:", fitw: 1, h: 40}, ]}, ]} } @@ -200,7 +232,7 @@ fn bottom_sheet() { sheet_row("\u{f005}", "Add to favourites"), sheet_row("\u{f0e0}", "Share via email"), sheet_row("\u{f044}", "Edit label"), - {t: "button", label: "Close", tapto: "open:", fillw: 1, h: 44}, + {t: "button", bg: clear, bordercolor: clear, border: 0, color: primary, label: "Close", tapto: "open:", fillw: 1, h: 44}, ]} } @@ -340,7 +372,7 @@ fn nav_drawer() { drawer_item("\u{f005}", "Starred", surf_low, on_surf_var), drawer_item("\u{f1d8}", "Sent", surf_low, on_surf_var), drawer_item("\u{f15c}", "Drafts", surf_low, on_surf_var), - {t: "button", label: "Close", tapto: "open:", fillw: 1, h: 44}, + {t: "button", bg: clear, bordercolor: clear, border: 0, color: primary, label: "Close", tapto: "open:", fillw: 1, h: 44}, ]} } @@ -350,7 +382,7 @@ fn side_sheet() { {t: "row", aligny: 0.5, c: [ {t: "text", text: "Filters", size: 16, weight: 500, color: on_surface, fitw: 1}, {t: "column", c: []}, - {t: "button", label: "\u{f00d}", icon: 1, size: 18, tapto: "open:", fitw: 1, h: 40}, + {t: "button", bg: clear, bordercolor: clear, border: 0, color: on_surf_var, label: "\u{f00d}", icon: 1, size: 18, tapto: "open:", fitw: 1, h: 40}, ]}, {t: "text", text: "Refine results with the options below.", size: 14, color: on_surf_var, w: 260, h: 22}, {t: "row", spacing: 8, c: [ chip("Unread"), chip("Starred"), chip_sel("Has files") ]}, @@ -385,14 +417,14 @@ fn loading_indicator() { // A shape-scale swatch (M3 corner ladder). fn shape_sw(r, label) { return {t: "column", fitw: 1, spacing: 4, c: [ - {t: "column", bg: pri_container, radius: r, w: 60, h: 52}, + {t: "column", bg: pri_container, radius: r, w: 56, h: 56}, {t: "text", text: label, size: 11, weight: 500, color: on_surf_var, h: 14}, ]} } // A tonal-elevation swatch. fn elev_sw(c, label) { return {t: "column", fitw: 1, spacing: 4, c: [ - {t: "column", bg: c, border: 1, bordercolor: outline_var, radius: 12, w: 60, h: 52}, + {t: "column", bg: c, border: 1, bordercolor: outline_var, radius: 12, w: 56, h: 56}, {t: "text", text: label, size: 11, weight: 500, color: on_surf_var, h: 14}, ]} } @@ -429,11 +461,11 @@ fn card_outlined(title, body) { // A full-width nav row (Button) that routes to a demo screen on tap. fn nav_btn(label, route) { - return {t: "button", label: label, tapto: route, fillw: 1, h: 56} + return {t: "button", bg: surf_cont, color: on_surface, radius: 12, label: label, tapto: route, fillw: 1, h: 56} } // A back button that routes to the home screen. fn back_btn() { - return {t: "button", label: "\u{2039} Back", tapto: "home", fitw: 1, h: 40} + return {t: "button", bg: clear, bordercolor: clear, border: 0, color: primary, label: "\u{2039} Back", tapto: "home", fitw: 1, h: 40} } // A demo screen shell: back button + Headline Medium title + the family's sections. fn demo(title, kids) { @@ -447,8 +479,8 @@ fn demo(title, kids) { // host turns into a state change + re-mount, so these demos actually respond. fn itab(label, val) { - if val == st.tab { return {t: "button", label: "\u{25CF} " + label, tapto: "tab:" + val, fillw: 1, h: 48} } - return {t: "button", label: label, tapto: "tab:" + val, fillw: 1, h: 48} + if val == st.tab { return {t: "button", bg: clear, bordercolor: clear, border: 0, color: primary, label: "\u{25CF} " + label, tapto: "tab:" + val, fillw: 1, h: 48} } + return {t: "button", bg: clear, bordercolor: clear, border: 0, color: on_surf_var, label: label, tapto: "tab:" + val, fillw: 1, h: 48} } fn tab_body() { if st.tab == "specs" { return {t: "text", text: "Specs \u{00b7} 6.5\" OLED \u{00b7} 128 GB \u{00b7} 5000 mAh", size: 14, color: on_surf_var, w: 330, h: 24} } @@ -457,8 +489,8 @@ fn tab_body() { } fn iseg(label, val) { - if val == st.seg { return {t: "button", label: "\u{2713} " + label, tapto: "seg:" + val, fillw: 1, h: 40} } - return {t: "button", label: label, tapto: "seg:" + val, fillw: 1, h: 40} + if val == st.seg { return {t: "button", bg: sec_container, color: on_sec_cont, radius: 8, label: "\u{2713} " + label, tapto: "seg:" + val, fillw: 1, h: 40} } + return {t: "button", bg: clear, bordercolor: clear, border: 0, color: on_surf_var, radius: 8, label: label, tapto: "seg:" + val, fillw: 1, h: 40} } fn isnackbar() { @@ -469,8 +501,8 @@ fn isnackbar() { } fn iday(d) { - if d == st.date { return {t: "button", label: "\u{25CF}" + d, tapto: "date:" + d, fillw: 1, h: 40} } - return {t: "button", label: d, tapto: "date:" + d, fillw: 1, h: 40} + if d == st.date { return {t: "button", bg: primary, color: on_primary, radius: 20, label: "\u{25CF}" + d, tapto: "date:" + d, fillw: 1, h: 40} } + return {t: "button", color: on_surface, radius: 20, label: d, tapto: "date:" + d, fillw: 1, h: 40} } fn idate_picker() { return {t: "column", bg: surf_high, radius: 28, pad: 16, spacing: 8, c: [ @@ -493,8 +525,8 @@ fn home_screen() { {t: "column", bg: surf_low, radius: 12, pad: 16, spacing: 10, c: [ {t: "text", text: "Live state \u{00b7} tapped " + st.count + " times", size: 16, weight: 500, color: on_surface, h: 24}, {t: "row", spacing: 8, c: [ - {t: "button", label: "Tap me (+1)", tapto: "act:count", fillw: 1, h: 40}, - {t: "button", label: "Toggle theme", tapto: "theme:toggle", fillw: 1, h: 40}, + {t: "button", bg: primary, color: on_primary, radius: 20, label: "Tap me (+1)", tapto: "act:count", fillw: 1, h: 40}, + {t: "button", bg: sec_container, color: on_sec_cont, radius: 20, label: "Toggle theme", tapto: "theme:toggle", fillw: 1, h: 40}, ]}, ]}, {t: "column", h: 6}, @@ -542,11 +574,21 @@ fn demo_buttons() { fn demo_selection() { return demo("Selection controls", [ + // Both states, as the reference catalog shows them: unselected is a bare + // outline over a transparent fill, selected carries primary + on-primary. section("Checkbox, switch, radio", [ - {t: "checkbox", text: "Checkbox", color: on_surface, h: 34}, - {t: "toggle", text: "Switch", color: on_surface, h: 40}, - {t: "radio", text: "Radio option A", color: on_surface, h: 34}, - {t: "radio", text: "Radio option B", color: on_surface, h: 34}, + {t: "checkbox", text: "Checkbox", color: on_surface, bg: clear, + accent: primary, markcolor: on_primary, bordercolor: on_surf_var, h: 34}, + {t: "checkbox", text: "Checkbox \u{00b7} selected", on: 1, color: on_surface, bg: clear, + accent: primary, markcolor: on_primary, bordercolor: on_surf_var, h: 34}, + {t: "toggle", text: "Switch", color: on_surface, bg: surf_highest, + accent: primary, markcolor: on_primary, bordercolor: outline, h: 40}, + {t: "toggle", text: "Switch \u{00b7} on", on: 1, color: on_surface, bg: surf_highest, + accent: primary, markcolor: on_primary, bordercolor: outline, h: 40}, + {t: "radio", text: "Radio option A", on: 1, color: on_surface, bg: clear, + accent: primary, bordercolor: on_surf_var, h: 34}, + {t: "radio", text: "Radio option B", color: on_surface, bg: clear, + accent: primary, bordercolor: on_surf_var, h: 34}, ]), ]) } @@ -554,12 +596,15 @@ fn demo_selection() { fn demo_text() { return demo("Text fields & sliders", [ section("Text fields", [ - {t: "input", placeholder: "Filled text field", h: 56}, - {t: "input", placeholder: "Another field", h: 56}, + {t: "input", placeholder: "Filled text field", bg: surf_highest, + accent: primary, bordercolor: on_surf_var, color: on_surf_var, h: 56}, + {t: "input", placeholder: "Another field", bg: surf_highest, + accent: primary, bordercolor: on_surf_var, color: on_surf_var, h: 56}, ]), section("Search bar", [ searchbar() ]), section("Search view", [ search_view() ]), - section("Sliders", [ {t: "slider", text: "Continuous", color: on_surface, h: 44} ]), + section("Sliders", [ {t: "slider", text: "Continuous", color: on_surface, + accent: primary, bordercolor: outline_var, h: 44} ]), ]) } @@ -585,6 +630,13 @@ fn demo_cards() { list_item("G", "Grace Hopper", "Compiler pioneer"), ]), section("Carousel", [ carousel() ]), + section("Shapeable image", [ + {t: "row", spacing: 10, c: [ + shaped_img(36, "Circle", pri_container), + shaped_img(16, "Large", sec_container), + shaped_img(4, "XSmall", ter_container), + ]}, + ]), ]) } @@ -602,10 +654,10 @@ fn demo_nav() { ]), section("Navigation rail", [ {t: "row", c: [ nav_rail() ]} ]), section("Navigation drawer (tap)", [ - {t: "button", label: "Open drawer", tapto: "open:drawer", fitw: 1, h: 40}, + {t: "button", bg: sec_container, color: on_sec_cont, radius: 20, label: "Open drawer", tapto: "open:drawer", fitw: 1, h: 40}, ]), section("Side sheet (tap)", [ - {t: "button", label: "Open side sheet", tapto: "open:side", fitw: 1, h: 40}, + {t: "button", bg: sec_container, color: on_sec_cont, radius: 20, label: "Open side sheet", tapto: "open:side", fitw: 1, h: 40}, ]), section("Tabs (tap to switch)", [ {t: "row", spacing: 4, c: [ itab("Overview", "overview"), itab("Specs", "specs"), itab("Related", "related") ]}, @@ -618,24 +670,24 @@ fn demo_nav() { fn demo_comms() { return demo("Dialogs & communication", [ section("Dialog (tap)", [ - {t: "button", label: "Show dialog", tapto: "open:dialog", fitw: 1, h: 40}, + {t: "button", bg: sec_container, color: on_sec_cont, radius: 20, label: "Show dialog", tapto: "open:dialog", fitw: 1, h: 40}, ]), section("Snackbar (show / hide)", [ {t: "row", spacing: 8, c: [ - {t: "button", label: "Show", tapto: "snack:show", fillw: 1, h: 40}, - {t: "button", label: "Hide", tapto: "snack:hide", fillw: 1, h: 40}, + {t: "button", bg: sec_container, color: on_sec_cont, radius: 20, label: "Show", tapto: "snack:show", fillw: 1, h: 40}, + {t: "button", bg: sec_container, color: on_sec_cont, radius: 20, label: "Hide", tapto: "snack:hide", fillw: 1, h: 40}, ]}, isnackbar(), ]), section("Menu (tap)", [ - {t: "button", label: "Open menu", tapto: "open:menu", fitw: 1, h: 40}, + {t: "button", bg: sec_container, color: on_sec_cont, radius: 20, label: "Open menu", tapto: "open:menu", fitw: 1, h: 40}, imenu(), ]), section("Bottom sheet (tap)", [ - {t: "button", label: "Show bottom sheet", tapto: "open:sheet", fitw: 1, h: 40}, + {t: "button", bg: sec_container, color: on_sec_cont, radius: 20, label: "Show bottom sheet", tapto: "open:sheet", fitw: 1, h: 40}, ]), section("Tooltip (tap)", [ - {t: "button", label: "Toggle tooltip", tapto: "open:tooltip", fitw: 1, h: 40}, + {t: "button", bg: sec_container, color: on_sec_cont, radius: 20, label: "Toggle tooltip", tapto: "open:tooltip", fitw: 1, h: 40}, itooltip(), ]), section("Banner", [ banner() ]), @@ -676,7 +728,7 @@ fn demo_progress() { fn demo_type() { return demo("Typography & color", [ section("Type scale", [ - {t: "text", text: "Display Small", size: 36, color: on_surface, h: 46}, + {t: "text", text: "Display Small", size: 36, color: on_surface, h: 44}, {t: "text", text: "Headline Small", size: 24, color: on_surface, h: 32}, {t: "text", text: "Title Large", size: 22, color: on_surface, h: 28}, {t: "text", text: "Title Medium", size: 16, weight: 500, color: on_surface, h: 24}, @@ -694,7 +746,7 @@ fn demo_type() { ]}, ]), section("Shape scale", [ - {t: "row", spacing: 8, c: [ + {t: "row", spacing: 2, c: [ shape_sw(0, "None"), shape_sw(4, "XS"), shape_sw(8, "S"), shape_sw(12, "M"), shape_sw(16, "L"), shape_sw(28, "XL"), ]}, @@ -736,7 +788,7 @@ fn overlay() { } // Dropdown menus that actually open/close; each item taps to dismiss. -fn menu_btn(txt) { return {t: "button", label: txt, tapto: "open:", fillw: 1, h: 44} } +fn menu_btn(txt) { return {t: "button", color: on_surface, label: txt, tapto: "open:", fillw: 1, h: 44} } fn imenu() { if st.open != "menu" { return {t: "column", h: 0} } return {t: "column", bg: surf_cont, border: 1, bordercolor: outline_var, radius: 4, pad: 8, w: 220, spacing: 2, c: [ @@ -758,7 +810,7 @@ fn itooltip() { // A real (tappable, rippling) icon button — the Button label renders in the icon // font via icon:1, and each tap bumps the live counter so the ripple fires. -fn tool_btn(icon) { return {t: "button", label: icon, icon: 1, size: 20, tapto: "act:count", fitw: 1, h: 48} } +fn tool_btn(icon) { return {t: "button", bg: clear, bordercolor: clear, border: 0, color: on_surf_var, label: icon, icon: 1, size: 20, tapto: "act:count", fitw: 1, h: 48} } fn docked_toolbar() { return {t: "row", bg: surf_cont, radius: 16, pad: 8, h: 64, spacing: 4, aligny: 0.5, c: [ tool_btn("\u{f0c9}"), tool_btn("\u{f005}"), tool_btn("\u{f0e0}"), tool_btn("\u{f044}"), @@ -779,10 +831,10 @@ fn imotion() { return {t: "column", bg: pri_container, radius: 16, pad: 16, spacing: 8, c: [ {t: "text", text: "Expanded surface", size: 16, weight: 500, color: on_pri_cont, h: 24}, {t: "text", text: "The tapped card transformed into this larger surface. Tap collapse to return.", size: 14, color: on_pri_cont, w: 300, h: 44}, - {t: "button", label: "Collapse", tapto: "open:", fitw: 1, h: 40}, + {t: "button", bg: clear, bordercolor: clear, border: 0, color: primary, label: "Collapse", tapto: "open:", fitw: 1, h: 40}, ]} } - return {t: "row", c: [ {t: "button", label: "Expand card", tapto: "open:motion", fitw: 1, h: 40} ]} + return {t: "row", c: [ {t: "button", bg: sec_container, color: on_sec_cont, radius: 20, label: "Expand card", tapto: "open:motion", fitw: 1, h: 40} ]} } fn demo_more() { @@ -793,11 +845,21 @@ fn demo_more() { {t: "row", spacing: 8, aligny: 0.5, c: [ {t: "text", text: "Actions", size: 14, color: on_surface, fitw: 1}, {t: "column", c: []}, - {t: "button", label: "\u{f142}", icon: 1, size: 20, tapto: "open:overflow", fitw: 1, h: 44}, + {t: "button", bg: clear, bordercolor: clear, border: 0, color: on_surf_var, label: "\u{f142}", icon: 1, size: 20, tapto: "open:overflow", fitw: 1, h: 44}, ]}, ioverflow(), ]), section("Motion \u{00b7} container transform (tap)", [ imotion() ]), + section("Adaptive layout", [ + {t: "text", text: "Compact \u{00b7} one pane", size: 12, color: on_surf_var, h: 18}, + {t: "row", c: [ pane("Inbox", "12 conversations", 300) ]}, + {t: "text", text: "Expanded \u{00b7} list + detail", size: 12, color: on_surf_var, h: 18}, + {t: "row", spacing: 8, c: [ + pane("Inbox", "12 conversations", 146), + pane("Detail", "Selected thread", 146), + ]}, + ]), + section("Draggable", [ drag_card() ]), ]) } diff --git a/components/material/screens/adaptive.splash b/components/material/screens/adaptive.splash new file mode 100644 index 0000000..ce0acc4 --- /dev/null +++ b/components/material/screens/adaptive.splash @@ -0,0 +1,16 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Adaptive layouts"), + caption("The catalog's adaptive demos switch navigation and panes by width."), + {t:"card", variant:"outlined", c:[ {t:"col", pad: 16, spacing: 6, c:[ + {t:"text", variant:"titleMedium", text:"Current window class"}, + {t:"text", variant:"bodyMedium", text: S("win_class")}, + {t:"text", variant:"bodySmall", text:"Rotate the device to see this change."}, + ]}]}, + section("List / detail"), + {t:"adaptivedemo", variant:"listdetail", key:"ad1"}, + section("Supporting pane"), + {t:"adaptivedemo", variant:"supporting", key:"ad2"}, + section("Feed"), + {t:"adaptivedemo", variant:"feed", key:"ad3"} +]} ]} diff --git a/components/material/screens/allcomponents.splash b/components/material/screens/allcomponents.splash new file mode 100644 index 0000000..dd6e978 --- /dev/null +++ b/components/material/screens/allcomponents.splash @@ -0,0 +1,35 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("All components"), + caption("One screen exercising every widget family at once."), + group([ + {t:"button", variant:"filled", label:"Filled", key:"ac1", tap: 1}, + {t:"button", variant:"outlined", label:"Outlined", key:"ac2", tap: 1}, + {t:"button", variant:"text", label:"Text", key:"ac3", tap: 1}, + ]), + {t:"row", spacing: 16, c:[ + {t:"checkbox", text:"Check", key:"ac4", on: N("ac4", 1)}, + {t:"switch", text:"Switch", key:"ac5", on: N("ac5", 1)}, + {t:"radio", text:"Radio", key:"ac6", on: N("ac6", 1)}, + ]}, + group([ + {t:"chip", variant:"filter", text:"Filter", key:"ac7", on: N("ac7", 1)}, + {t:"chip", variant:"assist", text:"Assist", icon:"star", key:"ac8", tap: 1}, + ]), + {t:"textfield", variant:"outlined", hint:"Outlined field", key:"ac9", text: S("ac9")}, + {t:"slider", key:"ac10", value: N("ac10", 50), min: 0, max: 100}, + {t:"progress", variant:"linear", value: N("ac10", 50), total: 100}, + {t:"tabs", items:"One;Two;Three", key:"ac11", selected: N("ac11", 0)}, + {t:"card", variant:"elevated", c:[ {t:"col", pad: 16, c:[ + {t:"text", variant:"titleMedium", text:"Card"}, + {t:"text", variant:"bodyMedium", text:"With a headline and supporting text."}, + ]}]}, + {t:"card", variant:"outlined", c:[ {t:"col", spacing: 0, c:[ + {t:"listitem", text:"List item", supporting:"Supporting text", icon:"person", key:"ac12", tap: 1}, + ]}]}, + group([ + {t:"fab", variant:"small", icon:"add", key:"ac13", tap: 1}, + {t:"fab", variant:"extended", icon:"edit", label:"Extended", key:"ac14", tap: 1}, + ]), + {t:"navbar", items:"Home;Search;Profile", icon:"home,search,person", key:"ac15", selected: N("ac15", 0)} +]} ]} diff --git a/components/material/screens/badge.splash b/components/material/screens/badge.splash new file mode 100644 index 0000000..b7cbac0 --- /dev/null +++ b/components/material/screens/badge.splash @@ -0,0 +1,13 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Badges"), + caption("Numberless, numbered and overflowing badges on icons."), + {t:"row", spacing: 32, pady: 8, c:[ + {t:"badgeicon", icon:"mail", key:"bg1"}, + {t:"badgeicon", icon:"notifications", badge:"7", key:"bg2"}, + {t:"badgeicon", icon:"shopping_cart", badge:"99", key:"bg3"}, + {t:"badgeicon", icon:"chat", badge:"1234",key:"bg4"}, + ]}, + section("On a button"), + {t:"badgeicon", icon:"inbox", badge:"3", key:"bg5"} +]} ]} diff --git a/components/material/screens/bottomappbar.splash b/components/material/screens/bottomappbar.splash new file mode 100644 index 0000000..90e85e8 --- /dev/null +++ b/components/material/screens/bottomappbar.splash @@ -0,0 +1,11 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Bottom app bar"), + caption("A BottomAppBar with a cradled FAB, pinned at the bottom of this screen."), + {t:"bottombardemo", key:"bab", action: S("bab")}, + {t:"col", spacing: 8, c:[ + {t:"button", variant:"outlined", label:"FAB centre", key:"bab", action:"center", tap: 1}, + {t:"button", variant:"outlined", label:"FAB end", key:"bab", action:"end", tap: 1}, + {t:"button", variant:"outlined", label:"Toggle bar", key:"bab", action:"toggle", tap: 1}, + ]} +]} ]} diff --git a/components/material/screens/bottomnav.splash b/components/material/screens/bottomnav.splash new file mode 100644 index 0000000..205d848 --- /dev/null +++ b/components/material/screens/bottomnav.splash @@ -0,0 +1,13 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Bottom navigation"), + caption("Three to five destinations; the live bar is pinned at the bottom of this screen."), + {t:"navbar", items:"Home;Search;Alerts;Profile", icon:"home,search,notifications,person", + key:"bn", selected: N("bn", 0), badge:",,7,"}, + section("Three destinations"), + {t:"navbar", items:"Songs;Albums;Artists", icon:"music_note,album,person", + key:"bn2", selected: N("bn2", 1)}, + section("Labels visible when inactive"), + {t:"navbar", items:"One;Two;Three;Four;Five", icon:"star,star,star,star,star", + key:"bn3", selected: N("bn3", 0)} +]} ]} diff --git a/components/material/screens/bottomsheet.splash b/components/material/screens/bottomsheet.splash new file mode 100644 index 0000000..37e649d --- /dev/null +++ b/components/material/screens/bottomsheet.splash @@ -0,0 +1,10 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Bottom sheets"), + caption("Modal and standard BottomSheetDialog."), + {t:"col", spacing: 8, c:[ + {t:"button", variant:"tonal", label:"Modal bottom sheet", key:"bs", action:"modal", tap: 1}, + {t:"button", variant:"tonal", label:"Modal with list", key:"bs", action:"list", tap: 1}, + {t:"button", variant:"tonal", label:"Expanded (tall) sheet", key:"bs", action:"tall", tap: 1}, + ]} +]} ]} diff --git a/components/material/screens/button.splash b/components/material/screens/button.splash new file mode 100644 index 0000000..4fb8b00 --- /dev/null +++ b/components/material/screens/button.splash @@ -0,0 +1,48 @@ +// Buttons — the five M3 variants, icon buttons, and the disabled states. +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Common buttons"), + caption("The five Material 3 button variants."), + group([ + {t:"button", variant:"elevated", label:"Elevated", key:"b_elev", tap: 1}, + {t:"button", variant:"filled", label:"Filled", key:"b_fill", tap: 1}, + {t:"button", variant:"tonal", label:"Tonal", key:"b_tonal", tap: 1}, + {t:"button", variant:"outlined", label:"Outlined", key:"b_out", tap: 1}, + {t:"button", variant:"text", label:"Text", key:"b_text", tap: 1}, + ]), + + section("With icons"), + group([ + {t:"button", variant:"filled", label:"Add", icon:"add", key:"b_i1", tap: 1}, + {t:"button", variant:"tonal", label:"Share", icon:"share", key:"b_i2", tap: 1}, + {t:"button", variant:"outlined", label:"Save", icon:"bookmark", key:"b_i3", tap: 1}, + ]), + + section("Disabled"), + group([ + {t:"button", variant:"elevated", label:"Elevated", enabled: 0}, + {t:"button", variant:"filled", label:"Filled", enabled: 0}, + {t:"button", variant:"tonal", label:"Tonal", enabled: 0}, + {t:"button", variant:"outlined", label:"Outlined", enabled: 0}, + {t:"button", variant:"text", label:"Text", enabled: 0}, + ]), + + section("Icon buttons"), + group([ + {t:"iconbutton", variant:"standard", icon:"favorite", key:"ib1", checkable: 1, on: N("ib1", 0)}, + {t:"iconbutton", variant:"filled", icon:"add", key:"ib2", tap: 1}, + {t:"iconbutton", variant:"tonal", icon:"edit", key:"ib3", tap: 1}, + {t:"iconbutton", variant:"outlined", icon:"settings", key:"ib4", tap: 1}, + ]), + + section("Segmented button"), + {t:"segmented", items:"Day;Week;Month", key:"seg", selected: N("seg", 0)}, + + section("Extended FAB / FAB sizes"), + group([ + {t:"fab", variant:"small", icon:"add", key:"f1", tap: 1}, + {t:"fab", variant:"regular", icon:"add", key:"f2", tap: 1}, + {t:"fab", variant:"large", icon:"add", key:"f3", tap: 1}, + {t:"fab", variant:"extended", icon:"edit", label:"Compose", key:"f4", tap: 1}, + ]) +]} ]} diff --git a/components/material/screens/card.splash b/components/material/screens/card.splash new file mode 100644 index 0000000..5d3fbe7 --- /dev/null +++ b/components/material/screens/card.splash @@ -0,0 +1,25 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Elevated"), + {t:"card", variant:"elevated", c:[ {t:"col", pad: 16, spacing: 8, c:[ + {t:"text", variant:"titleMedium", text:"Elevated card"}, + {t:"text", variant:"bodyMedium", text:"Cards hold content and actions about a single subject."}, + {t:"flow", spacing: 8, c:[ {t:"button", variant:"text", label:"Action", key:"cd1", tap:1} ]}, + ]}]}, + section("Filled"), + {t:"card", variant:"filled", c:[ {t:"col", pad: 16, spacing: 8, c:[ + {t:"text", variant:"titleMedium", text:"Filled card"}, + {t:"text", variant:"bodyMedium", text:"A lower-emphasis container."}, + ]}]}, + section("Outlined"), + {t:"card", variant:"outlined", c:[ {t:"col", pad: 16, spacing: 8, c:[ + {t:"text", variant:"titleMedium", text:"Outlined card"}, + {t:"text", variant:"bodyMedium", text:"A card with a visible boundary."}, + ]}]}, + section("Checkable"), + {t:"card", variant:"outlined", key:"cdc", checkable: 1, on: N("cdc", 0), c:[ + {t:"col", pad: 16, spacing: 4, c:[ + {t:"text", variant:"titleMedium", text:"Tap to select"}, + {t:"text", variant:"bodyMedium", text:"Checkable cards show a selected state."}, + ]}]} +]} ]} diff --git a/components/material/screens/carousel.splash b/components/material/screens/carousel.splash new file mode 100644 index 0000000..b4c302f --- /dev/null +++ b/components/material/screens/carousel.splash @@ -0,0 +1,11 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Hero carousel"), + {t:"carousel", variant:"hero", count: 8, key:"cr1"}, + section("Multi-browse"), + {t:"carousel", variant:"multibrowse", count: 10, key:"cr2"}, + section("Uncontained"), + {t:"carousel", variant:"uncontained", count: 10, key:"cr3"}, + section("Full screen"), + {t:"carousel", variant:"fullscreen", count: 5, key:"cr4"} +]} ]} diff --git a/components/material/screens/checkbox.splash b/components/material/screens/checkbox.splash new file mode 100644 index 0000000..5e8b84a --- /dev/null +++ b/components/material/screens/checkbox.splash @@ -0,0 +1,19 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Checkboxes"), + caption("Unchecked, checked and indeterminate states."), + {t:"col", spacing: 0, c:[ + {t:"checkbox", text:"Option one", key:"cb1", on: N("cb1", 1)}, + {t:"checkbox", text:"Option two", key:"cb2", on: N("cb2", 0)}, + {t:"checkbox", text:"Option three", key:"cb3", on: N("cb3", 0)}, + ]}, + section("Indeterminate"), + {t:"checkbox", text:"Select all", key:"cbi", indeterminate: 1}, + section("Error"), + {t:"checkbox", text:"Required", key:"cbe", error:"Must be accepted", on: N("cbe", 0)}, + section("Disabled"), + {t:"col", spacing: 0, c:[ + {t:"checkbox", text:"Disabled unchecked", enabled: 0, on: 0}, + {t:"checkbox", text:"Disabled checked", enabled: 0, on: 1}, + ]} +]} ]} diff --git a/components/material/screens/chip.splash b/components/material/screens/chip.splash new file mode 100644 index 0000000..abfdd81 --- /dev/null +++ b/components/material/screens/chip.splash @@ -0,0 +1,27 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Assist"), + group([ + {t:"chip", variant:"assist", text:"Directions", icon:"place", key:"ca1", tap: 1}, + {t:"chip", variant:"assist", text:"Call", icon:"call", key:"ca2", tap: 1}, + ]), + section("Filter"), + caption("Toggleable, with a leading check when selected."), + group([ + {t:"chip", variant:"filter", text:"Coffee", key:"cf1", on: N("cf1", 1)}, + {t:"chip", variant:"filter", text:"Tea", key:"cf2", on: N("cf2", 0)}, + {t:"chip", variant:"filter", text:"Juice", key:"cf3", on: N("cf3", 0)}, + ]), + section("Input"), + group([ + {t:"chip", variant:"input", text:"Alice", closeable: 1, key:"ci1"}, + {t:"chip", variant:"input", text:"Bob", closeable: 1, key:"ci2"}, + ]), + section("Suggestion"), + group([ + {t:"chip", variant:"suggestion", text:"Add to cart", key:"cs1", tap: 1}, + {t:"chip", variant:"suggestion", text:"Buy now", key:"cs2", tap: 1}, + ]), + section("Disabled"), + group([ {t:"chip", variant:"assist", text:"Disabled", enabled: 0} ]) +]} ]} diff --git a/components/material/screens/color.splash b/components/material/screens/color.splash new file mode 100644 index 0000000..667caf2 --- /dev/null +++ b/components/material/screens/color.splash @@ -0,0 +1,22 @@ +fn swatch(name, role) { + return {t:"row", h: 56, c:[ {t:"colorswatch", text: name, group: role, w: 999, h: 56} ]} +} +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Primary"), + {t:"col", spacing: 0, c:[ swatch("Primary","primary"), swatch("On Primary","onPrimary"), + swatch("Primary Container","primaryContainer"), swatch("On Primary Container","onPrimaryContainer") ]}, + section("Secondary"), + {t:"col", spacing: 0, c:[ swatch("Secondary","secondary"), swatch("On Secondary","onSecondary"), + swatch("Secondary Container","secondaryContainer"), swatch("On Secondary Container","onSecondaryContainer") ]}, + section("Tertiary"), + {t:"col", spacing: 0, c:[ swatch("Tertiary","tertiary"), swatch("On Tertiary","onTertiary"), + swatch("Tertiary Container","tertiaryContainer"), swatch("On Tertiary Container","onTertiaryContainer") ]}, + section("Error"), + {t:"col", spacing: 0, c:[ swatch("Error","error"), swatch("On Error","onError"), + swatch("Error Container","errorContainer"), swatch("On Error Container","onErrorContainer") ]}, + section("Surface"), + {t:"col", spacing: 0, c:[ swatch("Surface","surface"), swatch("On Surface","onSurface"), + swatch("Surface Variant","surfaceVariant"), swatch("On Surface Variant","onSurfaceVariant"), + swatch("Outline","outline") ]} +]} ]} diff --git a/components/material/screens/datepicker.splash b/components/material/screens/datepicker.splash new file mode 100644 index 0000000..a815dec --- /dev/null +++ b/components/material/screens/datepicker.splash @@ -0,0 +1,11 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Date pickers"), + caption("MaterialDatePicker, shown as a modal."), + {t:"col", spacing: 8, c:[ + {t:"button", variant:"tonal", label:"Date picker", key:"dp", action:"date", tap: 1}, + {t:"button", variant:"tonal", label:"Date range picker", key:"dp", action:"range", tap: 1}, + {t:"button", variant:"tonal", label:"Input mode", key:"dp", action:"input", tap: 1}, + ]}, + caption("Selected: " + S("dp_result")) +]} ]} diff --git a/components/material/screens/dialog.splash b/components/material/screens/dialog.splash new file mode 100644 index 0000000..e6adfc5 --- /dev/null +++ b/components/material/screens/dialog.splash @@ -0,0 +1,14 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Dialogs"), + caption("Each button opens a real MaterialAlertDialogBuilder dialog."), + {t:"col", spacing: 8, c:[ + {t:"button", variant:"tonal", label:"Alert dialog", key:"dlg", action:"alert", tap: 1}, + {t:"button", variant:"tonal", label:"With icon + title", key:"dlg", action:"icon", tap: 1}, + {t:"button", variant:"tonal", label:"Single choice", key:"dlg", action:"single", tap: 1}, + {t:"button", variant:"tonal", label:"Multi choice", key:"dlg", action:"multi", tap: 1}, + {t:"button", variant:"tonal", label:"Long message (scroll)",key:"dlg", action:"long", tap: 1}, + {t:"button", variant:"tonal", label:"Full-screen", key:"dlg", action:"full", tap: 1}, + ]}, + caption("Last result: " + S("dlg_result")) +]} ]} diff --git a/components/material/screens/divider.splash b/components/material/screens/divider.splash new file mode 100644 index 0000000..ca44851 --- /dev/null +++ b/components/material/screens/divider.splash @@ -0,0 +1,21 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Full width"), + {t:"col", spacing: 0, c:[ + {t:"text", variant:"bodyLarge", text:"Above", pady: 12}, + {t:"divider"}, + {t:"text", variant:"bodyLarge", text:"Below", pady: 12}, + ]}, + section("Inset"), + {t:"col", spacing: 0, c:[ + {t:"text", variant:"bodyLarge", text:"Above", pady: 12}, + {t:"divider", marginx: 16}, + {t:"text", variant:"bodyLarge", text:"Below", pady: 12}, + ]}, + section("Vertical"), + {t:"row", h: 64, spacing: 16, c:[ + {t:"text", variant:"bodyLarge", text:"Left"}, + {t:"divider", variant:"vertical"}, + {t:"text", variant:"bodyLarge", text:"Right"}, + ]} +]} ]} diff --git a/components/material/screens/dockedtoolbar.splash b/components/material/screens/dockedtoolbar.splash new file mode 100644 index 0000000..49caa8e --- /dev/null +++ b/components/material/screens/dockedtoolbar.splash @@ -0,0 +1,10 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Docked toolbar"), + caption("A toolbar docked to the bottom, holding icon actions."), + {t:"toolbardemo", variant:"docked", key:"dt1"}, + section("Floating toolbar"), + {t:"toolbardemo", variant:"floating", key:"dt2"}, + section("Vertical floating toolbar"), + {t:"toolbardemo", variant:"vertical", key:"dt3"} +]} ]} diff --git a/components/material/screens/elevation.splash b/components/material/screens/elevation.splash new file mode 100644 index 0000000..5532dc2 --- /dev/null +++ b/components/material/screens/elevation.splash @@ -0,0 +1,17 @@ +// Elevation — the six M3 levels. Built with a push loop (an array *literal* +// bound to a `let` loses its elements in this VM rev; push does not). +let kids = [] +kids.push(section("Elevation levels")) +kids.push(caption("Material 3 defines six elevation levels.")) +let i = 0 +while i < 6 { + kids.push({t:"card", variant:"elevated", elevation: i, c:[ + {t:"col", pad: 20, spacing: 2, c:[ + {t:"text", variant:"titleMedium", text:"Level " + i}, + {t:"text", variant:"bodySmall", text:"Surface at elevation level " + i} + ]} + ]}) + i = i + 1 +} + +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 12, c: kids} ]} diff --git a/components/material/screens/fab.splash b/components/material/screens/fab.splash new file mode 100644 index 0000000..77bcace --- /dev/null +++ b/components/material/screens/fab.splash @@ -0,0 +1,21 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Sizes"), + group([ + {t:"fab", variant:"small", icon:"add", key:"fb1", tap: 1}, + {t:"fab", variant:"regular", icon:"add", key:"fb2", tap: 1}, + {t:"fab", variant:"large", icon:"add", key:"fb3", tap: 1}, + ]), + section("Colours"), + group([ + {t:"fab", variant:"regular", icon:"edit", group:"primary", key:"fb4", tap: 1}, + {t:"fab", variant:"regular", icon:"edit", group:"secondary", key:"fb5", tap: 1}, + {t:"fab", variant:"regular", icon:"edit", group:"tertiary", key:"fb6", tap: 1}, + {t:"fab", variant:"regular", icon:"edit", group:"surface", key:"fb7", tap: 1}, + ]), + section("Extended"), + group([ + {t:"fab", variant:"extended", icon:"add", label:"Create", key:"fb8", tap: 1}, + {t:"fab", variant:"extended", label:"No icon", key:"fb9", tap: 1}, + ]) +]} ]} diff --git a/components/material/screens/floatingtoolbar.splash b/components/material/screens/floatingtoolbar.splash new file mode 100644 index 0000000..ce8ae7c --- /dev/null +++ b/components/material/screens/floatingtoolbar.splash @@ -0,0 +1,10 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Floating toolbar"), + caption("A floating, rounded toolbar over the content."), + {t:"toolbardemo", variant:"floating", key:"ft1"}, + section("With a FAB"), + {t:"toolbardemo", variant:"floatingfab", key:"ft2"}, + section("Vertical"), + {t:"toolbardemo", variant:"vertical", key:"ft3"} +]} ]} diff --git a/components/material/screens/font.splash b/components/material/screens/font.splash new file mode 100644 index 0000000..f5d5c72 --- /dev/null +++ b/components/material/screens/font.splash @@ -0,0 +1,19 @@ +fn row(name, v, sample) { + return {t:"col", spacing: 2, pady: 6, c:[ + {t:"text", variant:"labelSmall", text: name}, + {t:"text", variant: v, text: sample}, + ]} +} +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Display"), + {t:"col", spacing: 0, c:[ row("Display Large","displayLarge","Aa"), row("Display Medium","displayMedium","Aa"), row("Display Small","displaySmall","Aa") ]}, + section("Headline"), + {t:"col", spacing: 0, c:[ row("Headline Large","headlineLarge","Headline"), row("Headline Medium","headlineMedium","Headline"), row("Headline Small","headlineSmall","Headline") ]}, + section("Title"), + {t:"col", spacing: 0, c:[ row("Title Large","titleLarge","Title"), row("Title Medium","titleMedium","Title"), row("Title Small","titleSmall","Title") ]}, + section("Body"), + {t:"col", spacing: 0, c:[ row("Body Large","bodyLarge","The quick brown fox"), row("Body Medium","bodyMedium","The quick brown fox"), row("Body Small","bodySmall","The quick brown fox") ]}, + section("Label"), + {t:"col", spacing: 0, c:[ row("Label Large","labelLarge","LABEL"), row("Label Medium","labelMedium","LABEL"), row("Label Small","labelSmall","LABEL") ]} +]} ]} diff --git a/components/material/screens/imageview.splash b/components/material/screens/imageview.splash new file mode 100644 index 0000000..47d71b9 --- /dev/null +++ b/components/material/screens/imageview.splash @@ -0,0 +1,16 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Shapeable image"), + caption("ShapeableImageView with M3 corner treatments."), + {t:"row", spacing: 16, c:[ + {t:"image", variant:"rounded", w: 96, h: 96, src:"grad1"}, + {t:"image", variant:"circle", w: 96, h: 96, src:"grad2"}, + {t:"image", variant:"cut", w: 96, h: 96, src:"grad3"}, + ]}, + section("Sizes"), + {t:"row", spacing: 16, c:[ + {t:"image", variant:"rounded", w: 56, h: 56, src:"grad2"}, + {t:"image", variant:"rounded", w: 80, h: 80, src:"grad3"}, + {t:"image", variant:"rounded", w: 120, h: 120, src:"grad1"}, + ]} +]} ]} diff --git a/components/material/screens/kit.splash b/components/material/screens/kit.splash new file mode 100644 index 0000000..d3166fc --- /dev/null +++ b/components/material/screens/kit.splash @@ -0,0 +1,32 @@ +// --------------------------------------------------------------------------- +// Shared kit: helpers every catalog screen composes from. Pure Splash DSL — +// evaluated on device by the makepad-script VM, walked into Material views. +// --------------------------------------------------------------------------- + +fn num(s, dflt) { if s == "" { return dflt } return s + 0 } +fn is(s, v) { if s == v { return 1 } return 0 } + +// (page() removed: a top-level fn CALL cannot be the module result — see notes) +fn page(kids) { + return {t:"scroll", c:[ {t:"col", pad: 16, spacing: 16, c: kids} ]} +} + +// Section header — M3 "title medium" over a group of demos. +// 4, not 8. The reference does sit ~13px lower below a header and ~21px lower +// above the next one, but widening this margin is worse *everywhere*, `button` +// included (11.4 -> 14.0, and 40 routes under 9.0 -> 26). The margin applies +// above the first header too, so it shifts the one section that already lands +// exactly; the drift further down is not paid for by breaking the top. +fn section(title) { + return {t:"text", variant:"titleMedium", text: title, marginy: 4} +} + +// A labelled row of demo widgets that wraps. +fn group(kids) { + return {t:"flow", spacing: 8, c: kids} +} + +// Caption under a demo. +fn caption(s) { + return {t:"text", variant:"bodySmall", text: s} +} diff --git a/components/material/screens/listitem.splash b/components/material/screens/listitem.splash new file mode 100644 index 0000000..6799784 --- /dev/null +++ b/components/material/screens/listitem.splash @@ -0,0 +1,25 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("One line"), + {t:"card", variant:"outlined", c:[ {t:"col", spacing: 0, c:[ + {t:"listitem", text:"Headline", key:"li1", tap: 1}, + {t:"divider"}, + {t:"listitem", text:"Headline", icon:"person", key:"li2", tap: 1}, + ]}]}, + section("Two line"), + {t:"card", variant:"outlined", c:[ {t:"col", spacing: 0, c:[ + {t:"listitem", text:"Headline", supporting:"Supporting text", icon:"person", key:"li3", tap: 1}, + {t:"divider"}, + {t:"listitem", text:"Headline", supporting:"Supporting text", icon:"person", icon2:"more_vert", key:"li4", tap: 1}, + ]}]}, + section("Three line"), + {t:"card", variant:"outlined", c:[ {t:"col", spacing: 0, c:[ + {t:"listitem", text:"Headline", supporting:"Supporting text that is long enough to wrap onto a second line of the list item", icon:"mail", lines: 3, key:"li5", tap: 1}, + ]}]}, + section("With controls"), + {t:"card", variant:"outlined", c:[ {t:"col", spacing: 0, c:[ + {t:"listitem", text:"Checkbox", key:"lc1", action:"checkbox", on: N("lc1", 1)}, + {t:"divider"}, + {t:"listitem", text:"Switch", key:"lc2", action:"switch", on: N("lc2", 0)}, + ]}]} +]} ]} diff --git a/components/material/screens/loadingindicator.splash b/components/material/screens/loadingindicator.splash new file mode 100644 index 0000000..a2411f8 --- /dev/null +++ b/components/material/screens/loadingindicator.splash @@ -0,0 +1,14 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Loading indicator"), + caption("The M3 contained and uncontained loading indicators."), + {t:"row", spacing: 32, pady: 16, c:[ + {t:"loading", variant:"uncontained"}, + {t:"loading", variant:"contained"}, + ]}, + section("Circular progress (legacy)"), + {t:"row", spacing: 32, c:[ + {t:"progress", variant:"circular", indeterminate: 1}, + {t:"progress", variant:"circular", value: 70, total: 100}, + ]} +]} ]} diff --git a/components/material/screens/materialswitch.splash b/components/material/screens/materialswitch.splash new file mode 100644 index 0000000..7ab248a --- /dev/null +++ b/components/material/screens/materialswitch.splash @@ -0,0 +1,15 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Switch"), + caption("M3 switch, with and without icons."), + {t:"col", spacing: 0, c:[ + {t:"switch", text:"Wi-Fi", key:"sw1", on: N("sw1", 1)}, + {t:"switch", text:"Bluetooth", key:"sw2", on: N("sw2", 0)}, + {t:"switch", text:"Aeroplane mode", key:"sw3", on: N("sw3", 0), icon:"check"}, + ]}, + section("Disabled"), + {t:"col", spacing: 0, c:[ + {t:"switch", text:"Disabled off", enabled: 0, on: 0}, + {t:"switch", text:"Disabled on", enabled: 0, on: 1}, + ]} +]} ]} diff --git a/components/material/screens/menu.splash b/components/material/screens/menu.splash new file mode 100644 index 0000000..06ea224 --- /dev/null +++ b/components/material/screens/menu.splash @@ -0,0 +1,11 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Menus"), + caption("PopupMenu and exposed dropdown."), + {t:"col", spacing: 12, c:[ + {t:"button", variant:"outlined", label:"Show popup menu", key:"menu", action:"popup", tap: 1}, + {t:"dropdown", hint:"Exposed dropdown", items:"Apple;Banana;Cherry;Dragonfruit", key:"dd", text: S("dd")}, + {t:"dropdown", hint:"Editable dropdown", items:"Small;Medium;Large", key:"dd2", text: S("dd2"), action:"editable"}, + ]}, + caption("Last selection: " + S("menu_result")) +]} ]} diff --git a/components/material/screens/musicplayer.splash b/components/material/screens/musicplayer.splash new file mode 100644 index 0000000..e808e27 --- /dev/null +++ b/components/material/screens/musicplayer.splash @@ -0,0 +1,25 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Music player"), + caption("A composed screen, built from the same primitives."), + {t:"card", variant:"filled", c:[ {t:"col", pad: 20, spacing: 16, align: 1, c:[ + {t:"image", variant:"rounded", w: 200, h: 200, src:"grad2"}, + {t:"col", spacing: 2, align: 1, c:[ + {t:"text", variant:"titleLarge", text:"Nocturne in E-flat"}, + {t:"text", variant:"bodyMedium", text:"Frederic Chopin"}, + ]}, + {t:"slider", key:"mp", value: N("mp", 35), min: 0, max: 100}, + {t:"row", spacing: 8, h: 28, c:[ + {t:"text", variant:"labelSmall", text:"1:12"}, + {t:"spacer"}, + {t:"text", variant:"labelSmall", text:"3:24"}, + ]}, + {t:"row", spacing: 16, align: 1, h: 88, c:[ + {t:"iconbutton", variant:"standard", icon:"shuffle", key:"mp1", tap: 1}, + {t:"iconbutton", variant:"standard", icon:"skip_previous", key:"mp2", tap: 1}, + {t:"fab", variant:"regular", icon:"play_arrow", key:"mpp", tap: 1}, + {t:"iconbutton", variant:"standard", icon:"skip_next", key:"mp3", tap: 1}, + {t:"iconbutton", variant:"standard", icon:"repeat", key:"mp4", tap: 1}, + ]}, + ]}]} +]} ]} diff --git a/components/material/screens/navigationdrawer.splash b/components/material/screens/navigationdrawer.splash new file mode 100644 index 0000000..226b56b --- /dev/null +++ b/components/material/screens/navigationdrawer.splash @@ -0,0 +1,9 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Navigation drawer"), + caption("A real DrawerLayout + NavigationView. Swipe from the left edge, or:"), + {t:"button", variant:"tonal", label:"Open drawer", key:"drw", action:"open", tap: 1}, + section("Modal drawer"), + caption("The drawer holds grouped destinations with a header and a divider."), + caption("Last selection: " + S("drw_result")) +]} ]} diff --git a/components/material/screens/navigationrail.splash b/components/material/screens/navigationrail.splash new file mode 100644 index 0000000..2abeb81 --- /dev/null +++ b/components/material/screens/navigationrail.splash @@ -0,0 +1,13 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Navigation rail"), + caption("A vertical rail for larger screens."), + {t:"row", h: 420, spacing: 0, c:[ + {t:"navrail", items:"Home;Search;Alerts;Profile", icon:"home,search,notifications,person", + key:"nr", selected: N("nr", 0)}, + {t:"col", pad: 24, c:[ + {t:"text", variant:"titleMedium", text:"Destination " + N("nr", 0)}, + {t:"text", variant:"bodyMedium", text:"The rail keeps navigation reachable without a drawer."}, + ]}, + ]} +]} ]} diff --git a/components/material/screens/octoswidgets.splash b/components/material/screens/octoswidgets.splash new file mode 100644 index 0000000..cfa7cc6 --- /dev/null +++ b/components/material/screens/octoswidgets.splash @@ -0,0 +1,37 @@ +// octos-one's own makepad widgets, rendered as native Android views. +// Previously documented as having "no android.widget equivalent". +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("WeatherIcon"), + caption("A port of the 96px SDF shader: all eight conditions, animated."), + {t:"row", spacing: 4, h: 96, c:[ + {t:"weathericon", value: 0, w: 88}, {t:"weathericon", value: 1, w: 88}, + {t:"weathericon", value: 2, w: 88}, {t:"weathericon", value: 3, w: 88} + ]}, + {t:"row", spacing: 4, h: 96, c:[ + {t:"weathericon", value: 4, w: 88}, {t:"weathericon", value: 5, w: 88}, + {t:"weathericon", value: 6, w: 88}, {t:"weathericon", value: 7, w: 88} + ]}, + caption("sunny · partly · cloudy · rain / storm · snow · wind · fog"), + + section("MapView — 3D chase"), + caption("The same pinhole ground-plane projection as the makepad vertex shader, with the route ribbon, standing pins and vehicle puck."), + {t:"navmap", value: 1, h: 300}, + + section("MapView — heading-up 2D"), + {t:"navmap", value: 2, h: 200}, + + section("Glass panels"), + caption("The AppleGlassRoundedView uniforms, pass for pass."), + {t:"glasspanel", variant:"panel", c:[ + {t:"text", variant:"titleMedium", text:"glass.Panel", color: 1}, + {t:"text", variant:"bodySmall", text:"tint · lensing · specular · diffraction · shadow"} + ]}, + {t:"glasspanel", variant:"clear", c:[ + {t:"text", variant:"titleMedium", text:"glass.ClearPanel"}, + {t:"text", variant:"bodySmall", text:"higher lensing, lower tint"} + ]}, + {t:"glasspanel", variant:"card", radius: 18, c:[ + {t:"text", variant:"titleMedium", text:"glass.Card"} + ]} +]} ]} diff --git a/components/material/screens/preferences.splash b/components/material/screens/preferences.splash new file mode 100644 index 0000000..0b8a170 --- /dev/null +++ b/components/material/screens/preferences.splash @@ -0,0 +1,16 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Preferences"), + caption("Catalog display settings, backed by real state."), + {t:"card", variant:"outlined", c:[ {t:"col", spacing: 0, c:[ + {t:"listitem", text:"Dark theme", supporting:"Follow system when off", key:"pf1", action:"switch", on: N("pf1", 0)}, + {t:"divider"}, + {t:"listitem", text:"Dynamic colour", supporting:"Use the wallpaper palette", key:"pf2", action:"switch", on: N("pf2", 0)}, + {t:"divider"}, + {t:"listitem", text:"Right-to-left", supporting:"Force RTL layout", key:"pf3", action:"switch", on: N("pf3", 0)}, + {t:"divider"}, + {t:"listitem", text:"Show captions", supporting:"Explanatory text under demos", key:"pf4", action:"switch", on: N("pf4", 1)}, + ]}]}, + section("Theme colour"), + {t:"segmented", items:"Default;Pink;Green;Blue", key:"pfcolor", selected: N("pfcolor", 0)} +]} ]} diff --git a/components/material/screens/progressindicator.splash b/components/material/screens/progressindicator.splash new file mode 100644 index 0000000..4e07c31 --- /dev/null +++ b/components/material/screens/progressindicator.splash @@ -0,0 +1,13 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Linear - determinate"), + {t:"progress", variant:"linear", value: N("p1", 65), total: 100}, + section("Linear - indeterminate"), + {t:"progress", variant:"linear", indeterminate: 1}, + section("Circular - determinate"), + {t:"progress", variant:"circular", value: N("p1", 65), total: 100}, + section("Circular - indeterminate"), + {t:"progress", variant:"circular", indeterminate: 1}, + section("Adjust"), + {t:"slider", key:"p1", value: N("p1", 65), min: 0, max: 100} +]} ]} diff --git a/components/material/screens/radiobutton.splash b/components/material/screens/radiobutton.splash new file mode 100644 index 0000000..9f56333 --- /dev/null +++ b/components/material/screens/radiobutton.splash @@ -0,0 +1,11 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Radio buttons"), + caption("A single-choice group."), + {t:"radiogroup", key:"rg", selected: N("rg", 0), items:"Option one;Option two;Option three"}, + section("Disabled"), + {t:"col", spacing: 0, c:[ + {t:"radio", text:"Disabled unselected", enabled: 0, on: 0}, + {t:"radio", text:"Disabled selected", enabled: 0, on: 1}, + ]} +]} ]} diff --git a/components/material/screens/search.splash b/components/material/screens/search.splash new file mode 100644 index 0000000..f8ea6e4 --- /dev/null +++ b/components/material/screens/search.splash @@ -0,0 +1,7 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Search bar"), + caption("SearchBar + SearchView; tapping expands to the full-screen search view."), + {t:"searchbar", hint:"Search the catalog", key:"sb", text: S("sb")}, + caption("Last query: " + S("sb_result")) +]} ]} diff --git a/components/material/screens/shapetheming.splash b/components/material/screens/shapetheming.splash new file mode 100644 index 0000000..370a91e --- /dev/null +++ b/components/material/screens/shapetheming.splash @@ -0,0 +1,19 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Shape scale"), + caption("M3 corner sizes applied to the same surface."), + {t:"col", spacing: 12, c:[ + {t:"shapebox", text:"None - 0dp", radius: 0}, + {t:"shapebox", text:"Extra small - 4dp", radius: 4}, + {t:"shapebox", text:"Small - 8dp", radius: 8}, + {t:"shapebox", text:"Medium - 12dp", radius: 12}, + {t:"shapebox", text:"Large - 16dp", radius: 16}, + {t:"shapebox", text:"Extra large - 28dp", radius: 28}, + {t:"shapebox", text:"Full", radius: 999}, + ]}, + section("Cut corners"), + {t:"col", spacing: 12, c:[ + {t:"shapebox", text:"Cut - 8dp", radius: 8, variant:"cut"}, + {t:"shapebox", text:"Cut - 16dp", radius: 16, variant:"cut"}, + ]} +]} ]} diff --git a/components/material/screens/sidesheet.splash b/components/material/screens/sidesheet.splash new file mode 100644 index 0000000..d36a763 --- /dev/null +++ b/components/material/screens/sidesheet.splash @@ -0,0 +1,10 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Side sheets"), + caption("SideSheetDialog, anchored to an edge."), + {t:"col", spacing: 8, c:[ + {t:"button", variant:"tonal", label:"Modal side sheet (right)", key:"ss", action:"right", tap: 1}, + {t:"button", variant:"tonal", label:"Modal side sheet (left)", key:"ss", action:"left", tap: 1}, + {t:"button", variant:"tonal", label:"Detached side sheet", key:"ss", action:"detached", tap: 1}, + ]} +]} ]} diff --git a/components/material/screens/slider.splash b/components/material/screens/slider.splash new file mode 100644 index 0000000..9fcedc7 --- /dev/null +++ b/components/material/screens/slider.splash @@ -0,0 +1,13 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Continuous"), + {t:"slider", key:"s1", value: N("s1", 50), min: 0, max: 100}, + caption("Value: " + N("s1", 50)), + section("Discrete"), + {t:"slider", key:"s2", value: N("s2", 3), min: 0, max: 10, step: 1}, + caption("Value: " + N("s2", 3)), + section("Range"), + {t:"rangeslider", key:"s3", value: N("s3", 20), value2: N("s3b", 80), min: 0, max: 100}, + section("Disabled"), + {t:"slider", value: 40, min: 0, max: 100, enabled: 0} +]} ]} diff --git a/components/material/screens/snackbar.splash b/components/material/screens/snackbar.splash new file mode 100644 index 0000000..088e237 --- /dev/null +++ b/components/material/screens/snackbar.splash @@ -0,0 +1,12 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Snackbars"), + caption("Real com.google.android.material.snackbar.Snackbar."), + {t:"col", spacing: 8, c:[ + {t:"button", variant:"tonal", label:"Short", key:"snk", action:"short", tap: 1}, + {t:"button", variant:"tonal", label:"Long", key:"snk", action:"long", tap: 1}, + {t:"button", variant:"tonal", label:"With action", key:"snk", action:"action", tap: 1}, + {t:"button", variant:"tonal", label:"Two lines", key:"snk", action:"two", tap: 1}, + {t:"button", variant:"tonal", label:"Indefinite", key:"snk", action:"indef", tap: 1}, + ]} +]} ]} diff --git a/components/material/screens/tabs.splash b/components/material/screens/tabs.splash new file mode 100644 index 0000000..1809730 --- /dev/null +++ b/components/material/screens/tabs.splash @@ -0,0 +1,14 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Fixed tabs"), + {t:"tabs", items:"Recents;Favourites;Nearby", key:"tb1", selected: N("tb1", 0)}, + {t:"card", variant:"filled", c:[ {t:"col", pad: 24, c:[ + {t:"text", variant:"bodyLarge", text:"Content for tab " + N("tb1", 0)}, + ]}]}, + section("Scrollable tabs"), + {t:"tabs", items:"Mon;Tue;Wed;Thu;Fri;Sat;Sun", key:"tb2", selected: N("tb2", 0), scrollable: 1}, + section("With icons"), + {t:"tabs", items:"Home;Search;Profile", key:"tb3", selected: N("tb3", 0), icon:"home,search,person"}, + section("With badges"), + {t:"tabs", items:"Inbox;Sent;Drafts", key:"tb4", selected: N("tb4", 0), badge:"3,,12"} +]} ]} diff --git a/components/material/screens/textfield.splash b/components/material/screens/textfield.splash new file mode 100644 index 0000000..961dfca --- /dev/null +++ b/components/material/screens/textfield.splash @@ -0,0 +1,15 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Filled"), + {t:"textfield", variant:"filled", hint:"Label", helper:"Supporting text", key:"tf1", text: S("tf1")}, + {t:"textfield", variant:"filled", hint:"Password", key:"tf2", action:"password", helper:"At least 8 characters"}, + section("Outlined"), + {t:"textfield", variant:"outlined", hint:"Label", helper:"Supporting text", key:"tf3", text: S("tf3")}, + {t:"textfield", variant:"outlined", hint:"Amount", icon:"attach_money", key:"tf4", action:"number"}, + section("Error"), + {t:"textfield", variant:"outlined", hint:"Email", error:"Not a valid email address", key:"tf5", text:"not-an-email"}, + section("Multiline"), + {t:"textfield", variant:"outlined", hint:"Message", lines: 4, key:"tf6"}, + section("Disabled"), + {t:"textfield", variant:"filled", hint:"Disabled", enabled: 0, text:"Read only"} +]} ]} diff --git a/components/material/screens/timepicker.splash b/components/material/screens/timepicker.splash new file mode 100644 index 0000000..a2eacae --- /dev/null +++ b/components/material/screens/timepicker.splash @@ -0,0 +1,11 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Time pickers"), + caption("MaterialTimePicker in both input modes."), + {t:"col", spacing: 8, c:[ + {t:"button", variant:"tonal", label:"Clock mode (12h)", key:"tp", action:"clock12", tap: 1}, + {t:"button", variant:"tonal", label:"Clock mode (24h)", key:"tp", action:"clock24", tap: 1}, + {t:"button", variant:"tonal", label:"Text input mode", key:"tp", action:"input", tap: 1}, + ]}, + caption("Selected: " + S("tp_result")) +]} ]} diff --git a/components/material/screens/topappbar.splash b/components/material/screens/topappbar.splash new file mode 100644 index 0000000..ae94b94 --- /dev/null +++ b/components/material/screens/topappbar.splash @@ -0,0 +1,12 @@ +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Top app bar"), + caption("The bar above is a real MaterialToolbar; these show the four M3 sizes."), + {t:"appbardemo", variant:"small", title:"Small", key:"ab1"}, + {t:"appbardemo", variant:"medium", title:"Medium", key:"ab2"}, + {t:"appbardemo", variant:"large", title:"Large", key:"ab3"}, + {t:"appbardemo", variant:"center", title:"Centred", key:"ab4"}, + section("Actions"), + caption("Overflow and action icons are wired to a real menu."), + {t:"button", variant:"outlined", label:"Toggle bar actions", key:"abact", action:"toggle", tap: 1} +]} ]} diff --git a/components/material/screens/transition.splash b/components/material/screens/transition.splash new file mode 100644 index 0000000..c3f2b0a --- /dev/null +++ b/components/material/screens/transition.splash @@ -0,0 +1,28 @@ +// Transitions — real Material motion. The host area below is animated with +// MaterialContainerTransform / MaterialSharedAxis / MaterialFadeThrough via +// TransitionManager, and the buttons drive it. +{t:"scroll", c:[ {t:"col", pad: 16, pady: 8, spacing: 16, c: [ + + section("Container transform"), + caption("Tap the card: it expands into a detail surface and back."), + {t:"transitionhost", variant:"container", key:"trx", h: 96, max: 240}, + + section("Shared axis"), + caption("Directional motion pairing an outgoing and incoming pane."), + {t:"flow", spacing: 8, c:[ + {t:"button", variant:"tonal", label:"X axis", key:"trx", action:"axisx", tap: 1}, + {t:"button", variant:"tonal", label:"Y axis", key:"trx", action:"axisy", tap: 1}, + {t:"button", variant:"tonal", label:"Z axis", key:"trx", action:"axisz", tap: 1} + ]}, + + section("Fade through / Fade"), + caption("For content with no strong spatial relationship."), + {t:"flow", spacing: 8, c:[ + {t:"button", variant:"tonal", label:"Fade through", key:"trx", action:"fadethrough", tap: 1}, + {t:"button", variant:"tonal", label:"Fade", key:"trx", action:"fade", tap: 1} + ]}, + + section("Stage"), + caption("The shared-axis and fade demos animate this pane."), + {t:"transitionhost", variant:"stage", key:"trxstage", h: 180} +]} ]} diff --git a/crates/splash-makepad/examples/refcheck.rs b/crates/splash-makepad/examples/refcheck.rs new file mode 100644 index 0000000..f6ab1f4 --- /dev/null +++ b/crates/splash-makepad/examples/refcheck.rs @@ -0,0 +1,107 @@ +//! Render the **reference** catalog's own screens through this backend. +//! +//! The Splash-Android catalog is the reference rendering — the same DSL, the +//! same VM, but walked into real `com.google.android.material.*` views. Pointing +//! this backend at those exact files is the only way to compare the two +//! renderers on equal terms: same input, so every difference is ours. +//! +//! Usage: `cargo run -p splash-makepad --example refcheck -- ` +//! Defaults to the sibling checkout. + +use splash_render::makepad_script::makepad_live_id::*; +use splash_render::makepad_script::*; +use std::collections::BTreeMap; + +const DEFAULT_DIR: &str = "/Users/yuechen/home/Splash-Android/catalog/rust/splash"; + +/// `S(key)` / `N(key, dflt)` — the reference reads its widget state through +/// these. Nothing here is stateful, so they answer with the defaults. +fn register_state(vm: &mut ScriptVm) { + let f_s = splash_render::add_global_fn(vm, &[(live_id!(k), ScriptValue::NIL)], |vm, _a| { + vm.bx.heap.new_string_from_str("") + }); + vm.set_injected_global(live_id!(S), f_s); + + let f_n = splash_render::add_global_fn( + vm, + &[(live_id!(k), ScriptValue::NIL), (live_id!(d), ScriptValue::NIL)], + |vm, a| { + let d = splash_render::num_prop(vm, a, live_id!(d)).unwrap_or(0.0); + ScriptValue::from_f64(d) + }, + ); + vm.set_injected_global(live_id!(N), f_n); +} + +fn count(node: &splash_render::UiNode) -> usize { + 1 + node.children.iter().map(count).sum::() +} + +fn main() { + let dir = std::env::args().nth(1).unwrap_or_else(|| DEFAULT_DIR.into()); + let kit = std::fs::read_to_string(format!("{dir}/kit.splash")).unwrap_or_default(); + + let mut screens: Vec = std::fs::read_dir(&dir) + .expect("reference splash dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|f| f.ends_with(".splash") && f != "kit.splash") + .collect(); + screens.sort(); + + let mut ok = 0usize; + let mut failed: Vec = Vec::new(); + let mut nodes_total = 0usize; + let mut widgets: BTreeMap = BTreeMap::new(); + + for file in &screens { + let name = file.trim_end_matches(".splash").to_string(); + let body = std::fs::read_to_string(format!("{dir}/{file}")).unwrap_or_default(); + let src = format!("{kit}\n{body}"); + match splash_render::build(&src, register_state) { + Some(tree) => { + let n = count(&tree); + nodes_total += n; + let ui = splash_makepad::to_makepad_ui(&tree); + for w in [ + "RoundedView", + "RoundedShadowView", + "Label", + "Button", + "CheckBox", + "RadioButton", + "Toggle", + "Slider", + "TextInput", + "ScrollYView", + ] { + let c = ui.matches(&format!("{w} {{")).count(); + if c > 0 { + *widgets.entry(w.to_string()).or_default() += c; + } + } + if std::env::var("DUMP").ok().as_deref() == Some(name.as_str()) { + println!("{ui}"); + } + println!(" {name:20} {n:4} nodes {:6} chars of dialect", ui.len()); + ok += 1; + } + None => { + println!(" {name:20} -- did not build"); + failed.push(name); + } + } + } + + println!( + "\n{ok}/{} reference screens build ({nodes_total} nodes)", + screens.len() + ); + if !failed.is_empty() { + println!("failed: {}", failed.join(" ")); + } + println!("\nwidgets emitted:"); + for (w, c) in &widgets { + println!(" {w:20} {c}"); + } +} diff --git a/crates/splash-makepad/src/lib.rs b/crates/splash-makepad/src/lib.rs index 7e44893..fcef07b 100644 --- a/crates/splash-makepad/src/lib.rs +++ b/crates/splash-makepad/src/lib.rs @@ -20,10 +20,35 @@ //! and unit-tested so it needs no window to verify. pub mod kit; +pub mod material; -use splash_render::{NodeKind, UiNode}; +use splash_render::{Attrs, NodeKind, UiNode}; use std::fmt::Write as _; +/// The Material colour scheme the semantic components resolve against. The +/// reference states components by role, not by colour, so the roles live here; +/// this is how a host says which set to use. +static SCHEME: std::sync::Mutex> = std::sync::Mutex::new(None); + +pub fn set_scheme(roles: material::Roles) { + if let Ok(mut s) = SCHEME.lock() { + *s = Some(roles); + } +} + +/// Convenience for the two built-in schemes. +pub fn set_dark(dark: bool) { + set_scheme(if dark { + material::Roles::dark() + } else { + material::Roles::light() + }); +} + +fn theme() -> material::Roles { + SCHEME.lock().ok().and_then(|s| *s).unwrap_or_else(material::Roles::light) +} + /// Translate a `UiNode` tree into makepad component-script UI source. /// /// Containers ([`NodeKind::Column`]/`Row`/`Stack`/`Scroll`/…) become `View`s @@ -31,12 +56,19 @@ use std::fmt::Write as _; /// `Image`, text inputs → `TextInput`. Attributes map to makepad props /// (`bg` → `show_bg`+`draw_bg.color`, `size` → `draw_text` font size, etc.). pub fn to_makepad_ui(root: &UiNode) -> String { + material::reset_slider_index(); let mut out = String::new(); emit(root, &mut out, 0); out } /// The makepad widget a kind renders as. +/// +/// Public so a test can check every name this emits is one the kit defines — +/// the mapping and the definitions live in different crates, and five dangling +/// names shipped because nothing tied them together. +pub fn widget_name_of(kind: NodeKind) -> &'static str { widget_name(kind) } + fn widget_name(kind: NodeKind) -> &'static str { match kind { NodeKind::Text => "Label", @@ -51,11 +83,24 @@ fn widget_name(kind: NodeKind) -> &'static str { // spins off draw_pass.time). `bg` recolours the arc via draw_bg.color. NodeKind::Loading => "LoadingSpinner", // The M3 loading indicator: a solid shape that morphs + rotates. - NodeKind::Progress => "LoadingMorph", + // A ring, as the reference's circular progress draws. `LoadingMorph` is + // splash-widgets' shape-morph blob — it renders nothing on an isolate and + // a morphing blob on the main VM, neither of which is a progress ring. + NodeKind::Progress => "LoadingSpinner", // makepad ships an OpenStreetMap vector-tile renderer with rotation and // tilt. A map is a widget here, not a platform view — which is why // `google_maps` is portable after all. NodeKind::Map => "MapView", + // The six data visualisations. Each is a compiled shader selected by + // NAME from the DSL — the route `splash-widgets` already documents, and + // the one §1.1 said had been dismissed too quickly. A DSL node cannot + // carry shader source, because MPSL compiles at build time; it can name + // a shader that was compiled. + NodeKind::TempBar => "L0TempBar", + NodeKind::SunArc => "L0SunArc", + NodeKind::MoonPhase => "L0MoonPhase", + NodeKind::AqiContour => "L0AqiContour", + NodeKind::StockPlot => "L0StockPlot", NodeKind::Shader => "FlutterShader", NodeKind::Sdf => "FlutterSdf", // No web surface on this backend; the screen branches on st.backend. @@ -122,6 +167,20 @@ fn needs_click_overlay(node: &UiNode) -> bool { } fn emit(node: &UiNode, out: &mut String, depth: usize) { + // A Material node is desugared into primitives first, so it picks up the + // same corner-radius, colour-role and text handling as everything else. + // Re-enter rather than emit directly: lowering is where a component gains + // its `tapto`, and going straight to `emit_widget` skipped the click overlay + // and left every lowered component inert. This terminates because a lowered + // node is primitive, and a resolved text role clears the `variant`. + if let Some(lowered) = material::lower(node, &theme()) { + emit(&lowered, out, depth); + return; + } + if needs_vertical_pad_wrapper(node) { + emit_vertical_pad(node, out, depth); + return; + } if needs_click_overlay(node) { emit_click_overlay(node, out, depth); return; @@ -129,7 +188,110 @@ fn emit(node: &UiNode, out: &mut String, depth: usize) { emit_widget(node, out, depth); } +/// Vertical-only padding (`pady` with no `padx`) on a node that has no fixed +/// height. +/// +/// This is the shape the screens use for a list row — `padding: {top: 12, +/// bottom: 12}` — and it is exactly the shape this dialect cannot express: the +/// per-side object is inert, and the scalar form would indent the text +/// horizontally by the same 12dp. Both were measured on device. +/// +/// So the inset is built structurally instead, as spacer rows above and below. +/// Without this every row in the app rendered at its bare text height, which is +/// the compression that made long screens drift ~24px per row against the +/// reference. +fn needs_vertical_pad_wrapper(node: &UiNode) -> bool { + let a = &node.attrs; + let vertical_only = a.padx.or(a.pad).unwrap_or(0.0) == 0.0 + && a.marginx.or(a.margin).unwrap_or(0.0) == 0.0; + // Margin is emitted through the same inert object form, so a section + // header's `margin: {top: 4, bottom: 4}` was dropped exactly like the row + // padding was. Both are made up structurally, and both at once when a node + // carries them together. + // A text node with no stated inset still gets one: `padding: 0` above kills + // makepad's `Label` default on *both* axes, and only the horizontal half of + // it was wrong. Restoring the vertical half here is what decouples the two — + // the axes cannot be set independently through the padding property itself. + // A text node with no stated inset still gets a small one: Android's TextView + // font padding is what makes the reference's card rows measure 19dp where + // these measure 15, and `line_spacing` alone cannot close that without + // costing the toolbars. 2dp, measured: 3 is clearly worse (36 routes under + // 9.0 drops to 31) because every box whose padding was derived against 2 + // then needs re-deriving, `shape_box` first. + let text_default = node.kind == NodeKind::Text + && a.pady.is_none() + && a.marginy.is_none() + && a.pad.is_none(); + (a.h.is_none() && vertical_only && (a.pady.unwrap_or(0.0) + a.marginy.unwrap_or(0.0)) > 0.0) + || text_default +} + +fn emit_vertical_pad(node: &UiNode, out: &mut String, depth: usize) { + let ind = " ".repeat(depth); + let inner_ind = " ".repeat(depth + 1); + // The widget already carries makepad's own `theme.mspace_1` inset (~4dp), so + // the spacers make up only the remainder. Adding the full `pady` on top + // overshot the reference's row pitch by exactly twice that default. + // The Label default (`theme.mspace_1`) already supplies ~4dp inside a text + // node, so the spacers make up only the remainder. A View has no such + // default and takes the full inset. + // No padding beyond what the DSL states. The reference does leave 173px + // around a section header where this leaves 162 — measured on `color`, whose + // offset collapses by exactly 11px at each section boundary — but adding + // 2dp a side to every stated margin is far worse (38 routes under 9.0 drops + // to 27): screens carry many section headers and it compounds. + let stated = node.attrs.pady.unwrap_or(0.0) + node.attrs.marginy.unwrap_or(0.0); + // 4dp per side is what the zeroed `Label` default was contributing. 6 was + // tried — it closes the slider screen's ~7dp-per-section shortfall but costs + // six other routes more than it gains, so the shortfall stays. + let py = if stated > 0.0 { stated } else { 2.0 }; + // Hug by default. Defaulting to Fill collapsed every label that sits inside + // a hug-content row — the button labels all vanished while the pixel metric + // barely moved, which is why this is checked on screen and not only scored. + let width = if node.attrs.fillw == Some(1) { "Fill" } else { "Fit" }; + let _ = writeln!(out, "{ind}View {{"); + let _ = writeln!(out, "{inner_ind}flow: Down"); + let _ = writeln!(out, "{inner_ind}width: {width}"); + let _ = writeln!(out, "{inner_ind}height: Fit"); + // Symmetric. `color` wants 11px more *above* its section headers and the + // same below, but giving every stated margin an asymmetric top is far worse + // (38 routes under 9.0 -> 26): the extra belongs to the widget above the + // header, not to the header. + // Symmetric. `button`'s section-header blocks do measure 13px shorter than + // the reference's, but giving a stated margin more above than below is far + // worse — re-tested under the aligned layout and still 40 routes under 9.0 + // drops to 36, `color` 4.1 -> 19.5. Unlike the alignment itself, this one + // does not come good once the layout beneath it is right. + // A section header takes 4dp more above than below -- but only one that + // material.rs marked, which is every header except the first on its screen. + // Emitting it here rather than as a sibling matters: a child of the page's + // `spacing: 16` column would cost 4dp *plus another 16dp*, 56px where 11 is + // wanted. These spacers sit inside the wrapper's own `flow: Down`, so 4dp + // is 4dp. + let top = if node.attrs.group.as_deref() == Some("sechead") { + py + 4.0 + } else { + py + }; + let _ = writeln!(out, "{inner_ind}View {{ height: {top} }}"); + let mut bare = node.clone(); + // Zero rather than clear, so the rule above does not fire again on re-entry. + bare.attrs.pady = Some(0.0); + bare.attrs.pad = None; + bare.attrs.marginy = Some(0.0); + bare.attrs.margin = None; + emit(&bare, out, depth + 1); + let _ = writeln!(out, "{inner_ind}View {{ height: {py} }}"); + let _ = writeln!(out, "{ind}}}"); +} + /// `Overlay{ , Button{…on_click} }` — see [`needs_click_overlay`]. +/// +/// The handler calls `NAV`, a global the host registers, rather than reaching +/// through `ui.nav_signal`. `ui` is injected by `Splash::eval_body`, so a body +/// mounted anywhere else — notably on the app's main VM, which is what gets this +/// crate's fonts and a widget kit's theming into reach — had every tap silently +/// do nothing. A global works on either VM. fn emit_click_overlay(node: &UiNode, out: &mut String, depth: usize) { let ind = " ".repeat(depth); let inner_ind = " ".repeat(depth + 1); @@ -154,18 +316,31 @@ fn emit_click_overlay(node: &UiNode, out: &mut String, depth: usize) { // `SplashTap` in splash-widgets is the real answer and cannot be reached: a // widget this workspace defines does not resolve inside the isolate VM a // mounted Splash allocates. See that module. - let strip = a.w.is_none() && a.fillw == Some(1); + // No edge strip, and no need for one. The target below is `SplashTap`, + // which never calls `event.hits` and so never captures the finger: it + // hit-tests itself on `TouchUpdate` and ignores a press that travelled more + // than its slop. The scroll therefore sees the whole gesture even under a + // full-width target, and a tap and a swipe can share the same pixels. + // + // Both earlier settings were wrong, in opposite directions. A strip on + // everything shrank Material buttons to a 64dp sliver and an audit called 16 + // of 18 interactions inert. A strip on nothing left a screen of full-width + // rows -- the catalog index -- with no free pixels for the scroll, so the + // list would not move and the failed swipe opened whatever row it ended on. + let target = a.tapto.as_ref().expect("checked by needs_click_overlay"); let _ = writeln!(out, "{ind}View {{"); let _ = writeln!(out, "{inner_ind}flow: Overlay"); - if strip { - let _ = writeln!(out, "{inner_ind}align: Align{{x: 1.0}}"); - } // The wrapper takes over the node's outer size so the Button, which fills // it, ends up exactly the size of the content it covers. + // A hug-content target has to stay hugging: forcing Fill made three tabs + // each claim the whole row, and the strip collapsed to nothing. match a.w { Some(w) => { let _ = writeln!(out, "{inner_ind}width: {w}"); } + None if a.fitw == Some(1) => { + let _ = writeln!(out, "{inner_ind}width: Fit"); + } None => { let _ = writeln!(out, "{inner_ind}width: Fill"); } @@ -193,24 +368,17 @@ fn emit_click_overlay(node: &UiNode, out: &mut String, depth: usize) { // property per line here for the same reason: the comma-joined form did not // parse. // - // A Button is still the wrong widget: it captures the finger on touch-down - // via `event.hits`, which starves the enclosing scroll and makes the catalog - // feel frozen. `splash_widgets::tap::SplashTap` is the right one and is - // written, compiles, and cannot be used — a brand-new widget type does not - // resolve inside the Splash isolate's prelude, so emitting it renders the - // screen blank. See that module. - let target = a.tapto.as_ref().expect("checked by needs_click_overlay"); - let _ = writeln!(out, "{inner_ind}ButtonFlatter {{"); - if strip { - let _ = writeln!(out, "{inner_ind} width: 64"); - } else { - let _ = writeln!(out, "{inner_ind} width: Fill"); - } + // `SplashTap` — reachable now. A Splash isolate takes makepad's own script + // mods and nothing else, so this type used to be unnameable from a body and + // the target had to be a `Button`, which captures on touch-down and starved + // the scroll. The host registers this crate's mod into every isolate + // through `register_splash_isolate_mod`, so the right widget can be used. + let _ = writeln!(out, "{inner_ind}SplashTap {{"); + let _ = writeln!(out, "{inner_ind} width: Fill"); let _ = writeln!(out, "{inner_ind} height: Fill"); - let _ = writeln!(out, "{inner_ind} text: \"\""); let _ = writeln!( out, - "{inner_ind} on_click: || {{ ui.nav_signal.set_text({target:?}) }}" + "{inner_ind} on_click: || {{ NAV(t: {target:?}) }}" ); let _ = writeln!(out, "{inner_ind}}}"); let _ = writeln!(out, "{ind}}}"); @@ -253,13 +421,102 @@ fn widget_for(node: &UiNode) -> &'static str { if a.elevation.is_some() { return "RoundedShadowView"; } - if a.bg.is_some() || a.radius.is_some() || a.border.is_some() { + if a.bg.is_some() || a.bg2.is_some() || a.radius.is_some() || a.border.is_some() { return "RoundedView"; } } base } +/// Whether this widget's label carries per-state colours (`draw_text.color_*`). +/// A `Label` has only the base colour; every interactive widget mixes. +fn has_text_states(kind: NodeKind) -> bool { + matches!( + kind, + NodeKind::Button + | NodeKind::Checkbox + | NodeKind::Radio + | NodeKind::Toggle + | NodeKind::Slider + | NodeKind::Input + | NodeKind::Textarea + ) +} + +/// The Material state colours for a native control, as `draw_bg` keys. +/// +/// A control is drawn by its own shader, so `bg`/`color` alone cannot describe +/// it. They cannot come from a widget kit either: `Splash` mounts its body on an +/// isolate VM that receives only makepad's own `script_mod`, so variants +/// registered on the app VM are absent there and every control fell back to +/// upstream's grey. A per-instance merge *does* arrive, so the roles travel with +/// the node — and default from the active scheme, because the reference states a +/// control semantically and carries no colour at all. +fn control_roles(kind: NodeKind, a: &Attrs) -> Vec { + let th = theme(); + let error = a.error.is_some() || a.variant.as_deref() == Some("error"); + let accent = a.accent.or(Some(if error { th.error } else { th.primary })); + let mark = a.markcolor.or(Some(if error { th.on_error } else { th.on_primary })); + let outline = a.bordercolor.or(Some(if error { th.error } else { th.on_surface_variant })); + // Disabled is a whole section per component in the reference. + let (accent, mark, outline) = if a.enabled.unwrap_or(1) == 0 { + let d = |c: Option| c.map(|c| (c & 0x00FF_FFFF) | 0x61000000); + (d(accent), d(mark), d(outline)) + } else { + (accent, mark, outline) + }; + let roles: &[(&str, Option)] = match kind { + NodeKind::Checkbox => &[ + ("border_color", if a.bordercolor.is_none() { outline } else { None }), + ("color_active", accent), + ("border_color_active", accent), + ("mark_color_active", mark), + ], + // M3's radio is a ring: the dot and the selected ring carry the accent. + NodeKind::Radio => &[ + ("border_color", if a.bordercolor.is_none() { outline } else { None }), + ("border_color_active", accent), + ("mark_color_active", accent), + ], + NodeKind::Toggle => &[ + ("color_active", accent), + ("border_color_active", accent), + ("mark_color", outline), + ("mark_color_active", mark), + ], + NodeKind::Slider => &[ + ("val_color", accent), + ("val_color_hover", accent), + ("handle_color", accent), + ("handle_color_hover", accent), + ], + // A field's fill is state-keyed: `color` alone leaves an *empty* field + // grey, and empty is the state a catalog screenshot actually shows. + NodeKind::Input | NodeKind::Textarea => &[ + ("color_empty", a.bg), + ("color_focus", a.bg), + ("border_color_empty", outline), + ("border_color_focus", accent), + ], + _ => &[], + }; + let mut p = Vec::new(); + for (k, v) in roles { + if let Some(c) = v { + p.push(format!("{k}: {}", hex_rgba(*c))); + } + } + p +} + +/// A stated dp renders ~0.7% smaller here than on the reference: its 56dp row +/// measures 158px where this one measures 157 (2.82 px/dp against 2.80). Small +/// per element, but it accumulates — eight pixels down a screen of twelve +/// swatches — so explicit sizes are scaled to match. Sizes only: extending this +/// to padding and spacing costs two routes (38 under 9.0 drops to 36), so +/// makepad evidently resolves those on a different rounding path. +const DP_SCALE: f32 = 157.7 / 157.0; + fn emit_attrs(node: &UiNode, out: &mut String, depth: usize) { let ind = " ".repeat(depth); let a = &node.attrs; @@ -269,11 +526,24 @@ fn emit_attrs(node: &UiNode, out: &mut String, depth: usize) { } // Explicit alignment wins; otherwise a row centres its children vertically // (a widget and its label line up), matching how ArkUI lays a row out. - if a.alignx.is_some() || a.aligny.is_some() { - let x = a.alignx.unwrap_or(0.0); - let y = a.aligny.unwrap_or(0.0); + // `align` is the DSL's cross-axis alignment — 0 start, 1 centre, 2 end. It + // was parsed into `Attrs` and then never read by this backend, so `align: 1` + // did nothing: the music player's album art sat left where the reference + // centres it. Cross axis is horizontal in a column, vertical in a row. + let cross = a.align.and_then(|v| match v { + 0 => Some(0.0), + 1 => Some(0.5), + 2 => Some(1.0), + _ => None, + }); + let is_row = node.kind == NodeKind::Row; + let ax = a.alignx.or(if is_row { None } else { cross }); + let ay = a.aligny.or(if is_row { cross } else { None }); + if ax.is_some() || ay.is_some() { + let x = ax.unwrap_or(0.0); + let y = ay.unwrap_or(if is_row { 0.5 } else { 0.0 }); let _ = writeln!(out, "{ind}align: Align{{x: {x}, y: {y}}}"); - } else if node.kind == NodeKind::Row { + } else if is_row { let _ = writeln!(out, "{ind}align: Align{{y: 0.5}}"); } // makepad containers default to Fill on both axes, which makes a card @@ -282,7 +552,7 @@ fn emit_attrs(node: &UiNode, out: &mut String, depth: usize) { let container = flow(node.kind).is_some(); match a.w { Some(w) => { - let _ = writeln!(out, "{ind}width: {w}"); + let _ = writeln!(out, "{ind}width: {}", w * DP_SCALE); } None if a.fillw == Some(1) => { let _ = writeln!(out, "{ind}width: Fill"); @@ -296,8 +566,14 @@ fn emit_attrs(node: &UiNode, out: &mut String, depth: usize) { None => {} } match a.h { + // A label's `h` is its Material *line height*, a typographic metric, not + // a box. Pinning the walk to it cropped the font's real line box and + // sliced descenders across the whole kit. `h: 0` still means hidden. + Some(h) if node.kind == NodeKind::Text && h > 0.0 => { + let _ = writeln!(out, "{ind}height: Fit"); + } Some(h) => { - let _ = writeln!(out, "{ind}height: {h}"); + let _ = writeln!(out, "{ind}height: {}", h * DP_SCALE); } None if a.fillh == Some(1) => { let _ = writeln!(out, "{ind}height: Fill"); @@ -325,18 +601,41 @@ fn emit_attrs(node: &UiNode, out: &mut String, depth: usize) { } None => {} } - // Padding: asymmetric padx/pady (each overriding pad on its axis) emit a - // per-side object; otherwise a uniform pad. Enables M3's asymmetric insets - // (e.g. a button's 24dp horizontal / 6dp vertical padding). + // Padding. Only the *scalar* form lands in this dialect: the per-side object + // `{left: .., top: ..}` parses and silently resolves to nothing, as do + // `Inset{..}` (declared in mod.draw, out of scope in a mounted body) and + // makepad's own base-with-override `0{left: ..}`. All three were tried on + // device. That no-op is why every control rendered hugging its label — a + // button measured 46dp wide against the reference's 89dp. + // + // So when the two axes differ, one has to win. A node with a fixed height + // cannot use vertical padding anyway, which makes the horizontal inset the + // one that matters: it is what gives a button its 24dp sides. Anything else + // keeps the (inert) object form rather than gaining a horizontal inset it + // never asked for, which would indent every list row's text. if a.padx.is_some() || a.pady.is_some() { let px = a.padx.or(a.pad).unwrap_or(0.0); let py = a.pady.or(a.pad).unwrap_or(0.0); + if (px - py).abs() < 0.01 || (a.h.is_some() && px > 0.0) { + let _ = writeln!(out, "{ind}padding: {px}"); + } else { + let _ = writeln!( + out, + "{ind}padding: {{left: {px}, right: {px}, top: {py}, bottom: {py}}}" + ); + } + } else if let Some(p) = a.pad { + let _ = writeln!(out, "{ind}padding: {p}"); + } else if node.kind == NodeKind::Text { + let _ = writeln!(out, "{ind}padding: 0"); + } + let (mx, my) = (a.marginx.or(a.margin), a.marginy.or(a.margin)); + if mx.is_some() || my.is_some() { + let (mx, my) = (mx.unwrap_or(0.0), my.unwrap_or(0.0)); let _ = writeln!( out, - "{ind}padding: {{left: {px}, right: {px}, top: {py}, bottom: {py}}}" + "{ind}margin: {{left: {mx}, right: {mx}, top: {my}, bottom: {my}}}" ); - } else if let Some(p) = a.pad { - let _ = writeln!(out, "{ind}padding: {p}"); } if let Some(sp) = a.spacing { let _ = writeln!(out, "{ind}spacing: {sp}"); @@ -344,7 +643,14 @@ fn emit_attrs(node: &UiNode, out: &mut String, depth: usize) { // bg / radius / border share one draw_bg block. A RoundedView defaults to a // transparent fill, so a border with no bg paints a clean outline (Material // "outlined" components); a border needs show_bg so the outline is painted. - if a.bg.is_some() || a.radius.is_some() || a.border.is_some() || a.elevation.is_some() { + let roles = control_roles(node.kind, a); + if a.bg.is_some() + || a.bg2.is_some() + || a.radius.is_some() + || a.border.is_some() + || a.elevation.is_some() + || !roles.is_empty() + { if a.bg.is_some() || a.border.is_some() || a.elevation.is_some() { let _ = writeln!(out, "{ind}show_bg: true"); } @@ -352,11 +658,34 @@ fn emit_attrs(node: &UiNode, out: &mut String, depth: usize) { if let Some(bg) = a.bg { parts.push(format!("color: {}", hex_rgba(bg))); } + // A second stop turns the fill into a gradient (`color_2`). + if let Some(bg2) = a.bg2 { + parts.push(format!("color_2: {}", hex_rgba(bg2))); + // `group: "gradh"` asks for the horizontal axis. Layering a + // half-alpha horizontal gradient over a vertical one averages to a + // true diagonal, which is what the reference draws and what this + // shader cannot do in one pass. + if a.group.as_deref() == Some("gradh") { + parts.push("gradient_fill_horizontal: 1.0".to_string()); + } + } if let Some(r) = a.radius { - parts.push(format!("radius: {r}")); + // makepad calls this `border_radius` — a bare `radius` silently + // missed, so every corner fell back to its 2.5 default. It is also + // *half* the corner it draws (`Sdf2d.box` works in `2. * r`), so a + // Material dp is emitted halved, which also puts a full corner + // exactly on the SDF's capsule limit instead of past it. + let mut r = r; + for side in [a.h, a.w].into_iter().flatten() { + r = r.min(side * 0.5); + } + parts.push(format!("border_radius: {}", (r * 0.5).max(0.0))); } if let Some(b) = a.border { - parts.push(format!("border_size: {b}")); + // Halved, like `border_radius` above: the shader draws the stroke to + // both sides of the edge, so a 1dp border measured 6px against the + // reference's 3px on the same outlined card. + parts.push(format!("border_size: {}", b * 0.5)); } if let Some(bc) = a.bordercolor { parts.push(format!("border_color: {}", hex_rgba(bc))); @@ -370,6 +699,7 @@ fn emit_attrs(node: &UiNode, out: &mut String, depth: usize) { parts.push(format!("shadow_radius: {radius}")); parts.push(format!("shadow_offset: vec2(0.0, {dy})")); } + parts.extend(roles); // `draw_bg +:` merges onto the widget's draw shader (makepad convention). let _ = writeln!(out, "{ind}draw_bg +: {{ {} }}", parts.join(", ")); } @@ -386,15 +716,47 @@ fn emit_attrs(node: &UiNode, out: &mut String, depth: usize) { if let Some(target) = a.tapto.as_ref() { let _ = writeln!( out, - "{ind}on_click: || {{ ui.nav_signal.set_text({target:?}) }}" + "{ind}on_click: || {{ NAV(t: {target:?}) }}" ); } if let Some(s) = a.size { + // The DSL states type sizes in sp, as Material does; makepad's font_size + // is in points. Measured against the reference the same string came out + // ~1.33x too tall on every screen (toolbar title 66px vs 47, heading 45 + // vs 34, button label 39 vs 29) — 72/96 exactly. + const SP_TO_PT: f32 = 0.75; + let s = s * SP_TO_PT; // icon selects the theme's Font-Awesome face (monochrome icons); // else weight >= 500 selects the Medium (bold) face — M3's label / title / // emphasis weight; else just set the size (Regular). Each swaps the whole // text_style for the theme style at this size. - if a.icon == Some(1) { + // Name the host's Roboto, at the weight the type role asks for. + // + // The reference renders in Android's Roboto and makepad bundles IBM + // Plex, so text could not match while the letterforms differed. `self:` + // resolves against the `cargo_manifest_path` of the script_mod that + // evaluates this — the host's, now that the body mounts on the main VM + // rather than a `Splash` isolate (whose empty manifest path blanked + // every label). + // + // The device ships Roboto as a *variable* font with no separate Medium — + // `fonts.xml` maps weight 500 onto the same file via the `wght` axis — so + // the emphasis weight comes from `FontMember.weight`, which makepad maps + // to that axis. Without it every heading and label rendered regular. + if a.icon != Some(1) { + // 1.45, not makepad's 1.2: Android's TextView adds font padding that + // this does not, so a card's two text rows measured 12dp each against + // the reference's 16.5. Tested at 1.2 / 1.32 / 1.45 on verified + // sweeps — all within 0.02 of each other on the mean, and 1.45 takes + // `adaptive` from 10.2 to 9.6. It costs the toolbars a little + // (topappbar 8.0 -> 8.7), which stays comfortably in range. 1.6 was + // also tried and is marginally worse without moving `adaptive`. + let w = a.weight.unwrap_or(400).max(1) as f32; + let _ = writeln!( + out, + "{ind}draw_text.text_style: TextStyle{{ font_family: FontFamily{{ latin := FontMember{{res: crate_resource(\"self:resources/Roboto-Regular.ttf\") asc: -0.1 desc: 0.0 weight: {w}}} }} line_spacing: 1.45 font_size: {s} }}" + ); + } else if a.icon == Some(1) { let _ = writeln!( out, "{ind}draw_text.text_style: mod.theme.font_icons{{ font_size: {s} }}" @@ -409,12 +771,51 @@ fn emit_attrs(node: &UiNode, out: &mut String, depth: usize) { } } if let Some(c) = a.color { - let _ = writeln!(out, "{ind}draw_text.color: {}", hex_rgba(c)); + let hex = hex_rgba(c); + let _ = writeln!(out, "{ind}draw_text.color: {hex}"); + // An interactive widget's label mixes toward the theme on + // hover/press/focus/select, so setting only the base colour held for an + // idle control and vanished the moment one was selected. The stated + // colour is meant to be *the* colour. + if has_text_states(node.kind) { + for state in ["color_hover", "color_down", "color_focus", "color_active"] { + let _ = writeln!(out, "{ind}draw_text.{state}: {hex}"); + } + // A field's placeholder is a separate role again, and empty is the + // state a catalog screenshot shows. + if matches!(node.kind, NodeKind::Input | NodeKind::Textarea) { + let _ = writeln!(out, "{ind}draw_text.color_empty: {hex}"); + } + } } if let Some(src) = &a.src { let _ = writeln!(out, "{ind}source: {src:?}"); } + // Data-visualisation uniforms. The names are the shader's, not the model's: + // a mismatch draws the bar against zero, which looks like data rather than + // like a bug. + match node.kind { + NodeKind::TempBar => { + let f = |v: Option| v.unwrap_or(0.0); + let _ = writeln!(out, "{ind}draw_bg.tlo: {}", f(a.lo)); + let _ = writeln!(out, "{ind}draw_bg.thi: {}", f(a.hi)); + let _ = writeln!(out, "{ind}draw_bg.wmin: {}", f(a.min)); + let _ = writeln!(out, "{ind}draw_bg.wmax: {}", f(a.max)); + } + NodeKind::SunArc => { + let f = |v: Option| v.unwrap_or(0.0); + let _ = writeln!(out, "{ind}draw_bg.rise: {}", f(a.rise)); + let _ = writeln!(out, "{ind}draw_bg.set: {}", f(a.set)); + let _ = writeln!(out, "{ind}draw_bg.now: {}", f(a.now)); + } + NodeKind::MoonPhase => { + let _ = writeln!(out, "{ind}draw_bg.phase: {}", a.phase.unwrap_or(0.0)); + } + // AqiContour and StockPlot take none: this backend cannot fetch their + // data, so they draw an explicit empty state rather than a curve. + _ => {} + } // Map camera. Field names are MapView's own (widgets/src/map/view.rs). if node.kind == NodeKind::Map { if let Some(v) = a.lat { @@ -445,20 +846,91 @@ fn emit_attrs(node: &UiNode, out: &mut String, depth: usize) { // Field names are makepad's, not invented: `CheckBox` (and `Toggle`, which // is a CheckBox variant — see widgets/src/check_box.rs) exposes // `active: Option`; `Slider` has min/max/step/`default` and no `value`. - if let Some(on) = a.on { + let on = a.on.or_else(|| a.indeterminate.map(|i| (i != 0) as i32)); + if let Some(on) = on { if matches!( node.kind, NodeKind::Checkbox | NodeKind::Toggle | NodeKind::Radio ) { let _ = writeln!(out, "{ind}active: {}", on != 0); } + // `RadioButton` is the one control with no live `active` field — it has + // only `active()`/`set_active()`, so the line above is dropped and a + // selected radio drew as unselected. Writing the shader instance does + // not survive either; the animator rewrites it next frame. Its animator + // *is* live, so move that block's default. `animator +:` is the merge + // form makepad's own variants use; plain `animator:` is ignored. + if node.kind == NodeKind::Radio && on != 0 { + let _ = writeln!(out, "{ind}animator +: {{ active: {{ default: @on }} }}"); + } + } + // A determinate circular indicator: the spinner shader, held still with its + // gap pinned so it draws a fixed arc rather than an animated one. + if node.kind == NodeKind::Progress && a.group.as_deref() == Some("arc") { + let frac = match (a.value, a.total) { + (Some(v), Some(t)) if t > 0.0 => (v / t).clamp(0.0, 1.0), + (Some(v), None) => (v / 100.0).clamp(0.0, 1.0), + _ => 1.0, + }; + let gap = (1.0 - frac).clamp(0.0, 0.98); + let _ = writeln!(out, "{ind}draw_bg.rotation_speed: 0.0"); + let _ = writeln!(out, "{ind}draw_bg.max_gap_ratio: {gap}"); + let _ = writeln!(out, "{ind}draw_bg.min_gap_ratio: {gap}"); + let _ = writeln!(out, "{ind}draw_bg.stroke_width: 4.5"); + } + if node.kind == NodeKind::Slider { + // makepad's Slider prints its value through a nested `text_input`, so a + // `draw_text.color` on the slider itself never reaches it. Material shows + // no inline readout — the reference's screens carry a "Value: N" caption + // from the DSL instead — so hide the widget's own. + // Every state, not just the base: focusing the widget (which a drag + // does) brought its "1.00" readout and a caret back on screen over the + // drawn track. + for f in ["", "_hover", "_focus", "_down", "_active", "_empty"] { + let _ = writeln!(out, "{ind}text_input.draw_text.color{f}: #00000000"); + let _ = writeln!(out, "{ind}text_input.draw_bg.color{f}: #00000000"); + } + let _ = writeln!(out, "{ind}text_input.draw_cursor.color: #00000000"); + let _ = writeln!(out, "{ind}text_input.draw_selection.color: #00000000"); + + // The widget draws its own active/inactive split — `val_color` up to the + // handle, `color` past it — which is exactly the Material track. Earlier + // this was covered with drawn bars because upstream's default paints the + // whole track alike; the real cause was that only `color` had been set, + // leaving 20-odd sibling state colours (`color_2`, five `border_color*`, + // the `val_*` and `handle_*` families) still on the bevelled theme. Pin + // the family and the native split shows through, and it tracks the drag. + let t = theme(); + let enabled = a.enabled != Some(0); + // `accent: 0` is how the Material lowering asks for an invisible widget: + // it draws the Expressive track itself and keeps this one for the drag. + let (track, val) = if a.accent == Some(0) { + (0, 0) + } else if enabled { + (t.secondary_container, a.accent.unwrap_or(t.primary)) + } else { + (material::dim(t.on_surface, 0.12), material::dim(t.on_surface, 0.38)) + }; + for f in ["", "_hover", "_focus", "_drag", "_disabled"] { + let _ = writeln!(out, "{ind}draw_bg.color{f}: {}", hex_rgba(track)); + let _ = writeln!(out, "{ind}draw_bg.color_2{f}: {}", hex_rgba(track)); + let _ = writeln!(out, "{ind}draw_bg.val_color{f}: {}", hex_rgba(val)); + let _ = writeln!(out, "{ind}draw_bg.handle_color{f}: {}", hex_rgba(val)); + // Material's track is flat; upstream's bevel reads as a stray outline. + let _ = writeln!(out, "{ind}draw_bg.border_color{f}: #00000000"); + let _ = writeln!(out, "{ind}draw_bg.border_color_2{f}: #00000000"); + } + let _ = writeln!(out, "{ind}draw_bg.border_size: 0.0"); } if let Some(v) = a.value { if node.kind == NodeKind::Slider { // The kit expresses slider positions as a 0..1 fraction, so pin the - // range unless the caller gave an explicit `total`. - let _ = writeln!(out, "{ind}min: 0.0"); - let _ = writeln!(out, "{ind}max: {}", a.total.unwrap_or(1.0)); + // range unless the caller gave an explicit one -- which the Material + // lowering does, to tag each slider with its own band. + let lo = a.min.unwrap_or(0.0); + let hi = a.max.unwrap_or_else(|| lo + a.total.unwrap_or(1.0)); + let _ = writeln!(out, "{ind}min: {lo}"); + let _ = writeln!(out, "{ind}max: {hi}"); let _ = writeln!(out, "{ind}default: {v}"); } } @@ -519,7 +991,7 @@ mod tests { assert!(ui.contains("color: #141414ff")); assert!(ui.contains("Label {")); assert!(ui.contains("text: \"Hi\"")); - assert!(ui.contains("font_size: 20")); + assert!(ui.contains("font_size: 15"), "sp is converted to points"); } #[test] @@ -549,6 +1021,70 @@ mod tests { ); } + #[test] + fn shape_attrs_use_the_names_makepad_draws_from() { + // The route sweep proves a screen *translates*; it cannot catch a + // correctly-shaped block whose keys makepad does not read. `radius` was + // one: makepad calls it `border_radius` and defaults it to 2.5, so every + // corner in the kit silently rendered at 2.5dp on device. + let ui = to_makepad_ui(&tree( + r#"{t:"column", bg: 4294901760, radius: 28, border: 1, bordercolor: 4278190080}"#, + )); + assert!(ui.contains("border_radius: 14"), "halved, and named:\n{ui}"); + assert!(!ui.contains(" radius: "), "no bare `radius` key:\n{ui}"); + assert!(ui.contains("border_size: 0.5"), "{ui}"); + } + + #[test] + fn a_radius_is_bounded_by_its_box() { + // makepad's `border_radius` is half the corner it draws (`Sdf2d.box` + // works in `2. * r`), so a Material dp is emitted halved and a full + // corner lands exactly on the SDF's capsule limit. + let ui = to_makepad_ui(&tree(r#"{t:"column", bg: 4294901760, radius: 20, h: 40}"#)); + assert!(ui.contains("border_radius: 10"), "{ui}"); + let ui = to_makepad_ui(&tree(r#"{t:"column", bg: 4294901760, radius: 12, h: 56}"#)); + assert!(ui.contains("border_radius: 6"), "{ui}"); + } + + #[test] + fn a_control_carries_its_material_roles() { + // A control's shader colours cannot come from a widget kit: `Splash` + // mounts its body on an isolate VM that never receives one. They have to + // travel with the node — and default from the scheme, because the + // reference's screens carry no colour at all. + let ui = to_makepad_ui(&tree(r#"{t:"slider"}"#)); + assert!(ui.contains("val_color: #6750a4ff"), "themed by default:\n{ui}"); + let ui = to_makepad_ui(&tree(r#"{t:"checkbox", enabled: 0}"#)); + assert!(ui.contains("color_active: #6750a461"), "dimmed:\n{ui}"); + } + + #[test] + fn a_selected_radio_says_so_through_its_animator() { + // `RadioButton` alone has no live `active` field, so the selected state + // has to come from moving its animator's default — via `animator +:`, + // the merge form; plain `animator:` is ignored. + let ui = to_makepad_ui(&tree(r#"{t:"radio", on: 1}"#)); + assert!(ui.contains("animator +: { active: { default: @on } }"), "{ui}"); + let ui = to_makepad_ui(&tree(r#"{t:"radio"}"#)); + assert!(!ui.contains("animator +:"), "{ui}"); + let ui = to_makepad_ui(&tree(r#"{t:"checkbox", on: 1}"#)); + assert!(!ui.contains("animator +:"), "only the radio needs it:\n{ui}"); + } + + #[test] + fn a_field_keeps_its_input_inside_the_chrome() { + // A Material field is a label, a supporting or error line and an + // affordance around an editable box. The native TextInput draws none of + // that, so the chrome is composed — but must still hold a real Input. + let ui = to_makepad_ui(&tree( + r#"{t:"textfield", variant:"outlined", hint:"Email", error:"Not valid", text:"x"}"#, + )); + assert!(ui.contains("TextInput {"), "still editable:\n{ui}"); + assert!(ui.contains("Not valid") && ui.contains("Email"), "{ui}"); + let ui = to_makepad_ui(&tree(r#"{t:"input", placeholder:"plain"}"#)); + assert_eq!(ui.matches("TextInput {").count(), 1, "no chrome:\n{ui}"); + } + #[test] fn control_state_reaches_the_widget() { // Declared state used to be dropped on the floor: the attribute existed @@ -569,26 +1105,32 @@ mod tests { } #[test] - fn a_tappable_container_gets_a_button_over_it() { + fn a_tappable_container_gets_a_non_capturing_target_over_it() { // A View ignores `on_click`, so a row carrying `tapto` must be wrapped in - // an Overlay with a real Button on top or the tap is silently dropped. + // an Overlay with a real widget on top or the tap is silently dropped. let ui = to_makepad_ui(&tree( r#"{t:"row", h: 56, tapto:"date_planner", c:[ {t:"text", text:"Date Planner", h: 20} ]}"#, )); assert!(ui.contains("flow: Overlay"), "needs an overlay wrapper:\n{ui}"); - // ButtonFlatter specifically: a plain `Button` carries the theme's - // chrome and drew a visible outline around every tappable row in the - // kit. Asserting the transparent variant is what keeps that from - // coming back. + // `SplashTap`, not a Button. Any Button captures the finger on + // touch-down through `event.hits`, which starves the enclosing scroll -- + // a screen of full-width tappable rows would not scroll at all, and the + // swipe that failed to scroll fired navigation on release. `SplashTap` + // hit-tests itself and ignores a press that travelled. Asserting the + // type is what keeps a Button from coming back. + assert!( + ui.contains("SplashTap {"), + "the hit target must not capture the finger:\n{ui}" + ); assert!( - ui.contains("ButtonFlatter {"), - "the hit target must be the transparent Button variant:\n{ui}" + !ui.contains("ButtonFlatter"), + "a Button target starves the scroll:\n{ui}" ); assert!( - ui.contains(r#"on_click: || { ui.nav_signal.set_text("date_planner") }"#), - "the Button carries the handler:\n{ui}" + ui.contains(r#"on_click: || { NAV(t: "date_planner") }"#), + "the target carries the handler:\n{ui}" ); // The content survives, and does not itself carry a dead handler. assert!(ui.contains("text: \"Date Planner\""), "{ui}"); diff --git a/crates/splash-makepad/src/material.rs b/crates/splash-makepad/src/material.rs new file mode 100644 index 0000000..6153031 --- /dev/null +++ b/crates/splash-makepad/src/material.rs @@ -0,0 +1,2452 @@ +//! Material 3 components, lowered to primitives. +//! +//! The reference catalog (Splash-Android, rendered with real +//! `com.google.android.material.*` views) states components **semantically** — +//! `{t:"button", variant:"filled"}`, `{t:"fab", variant:"large"}`, +//! `{t:"chip", variant:"filter"}` — and lets the renderer produce the widget. +//! This backend previously had no such vocabulary, so the catalog here hand-drew +//! look-alikes out of boxes and every variant of a family collapsed to one +//! appearance. Feeding it the reference's own screens dropped a third of the +//! nodes on the floor. +//! +//! [`lower`] is the missing half: one Material node in, a tree of primitives out, +//! which the normal emitter then walks. Going through primitives (rather than +//! building dialect strings here) is deliberate — the corner-radius, colour-role +//! and text-state handling in `emit_attrs` is where the Material accuracy lives, +//! and a second path would drift from it. + +use splash_render::{Attrs, NodeKind, UiNode}; + +/// The M3 baseline colour roles, `0xAARRGGBB`. +#[derive(Clone, Copy)] +pub struct Roles { + pub primary: u32, + pub on_primary: u32, + pub primary_container: u32, + pub on_primary_container: u32, + pub secondary: u32, + pub on_secondary: u32, + pub secondary_container: u32, + pub on_secondary_container: u32, + pub tertiary: u32, + pub on_tertiary: u32, + pub tertiary_container: u32, + pub on_tertiary_container: u32, + pub surface_variant: u32, + pub error: u32, + pub on_error: u32, + pub error_container: u32, + pub on_error_container: u32, + pub surface: u32, + pub on_surface: u32, + pub on_surface_variant: u32, + pub outline: u32, + pub outline_variant: u32, + pub surf_lowest: u32, + pub surf_low: u32, + pub surf_cont: u32, + pub surf_high: u32, + pub surf_highest: u32, + pub inverse_surface: u32, + pub inverse_on_surface: u32, +} + +impl Roles { + pub const fn light() -> Self { + Self { + primary: 0xFF6750A4, + on_primary: 0xFFFFFFFF, + primary_container: 0xFFEADDFF, + on_primary_container: 0xFF21005D, + secondary_container: 0xFFE8DEF8, + on_secondary_container: 0xFF1D192B, + tertiary_container: 0xFFFFD8E4, + error: 0xFFB3261E, + on_error: 0xFFFFFFFF, + error_container: 0xFFF9DEDC, + on_error_container: 0xFF410E0B, + surface: 0xFFFEF7FF, + on_surface: 0xFF1D1B20, + on_surface_variant: 0xFF49454F, + outline: 0xFF79747E, + outline_variant: 0xFFCAC4D0, + surf_lowest: 0xFFFFFFFF, + surf_low: 0xFFF7F2FA, + surf_cont: 0xFFF3EDF7, + surf_high: 0xFFECE6F0, + surf_highest: 0xFFE6E0E9, + inverse_surface: 0xFF322F35, + inverse_on_surface: 0xFFF5EFF7, + secondary: 0xFF625B71, + on_secondary: 0xFFFFFFFF, + tertiary: 0xFF7D5260, + on_tertiary: 0xFFFFFFFF, + on_tertiary_container: 0xFF31111D, + surface_variant: 0xFFE7E0EC, + } + } + /// The scheme the reference catalog actually renders. + /// + /// That device runs Material You, so its roles are generated from the + /// wallpaper rather than the M3 baseline — a blue family, not the baseline + /// purple. Every value here is sampled from the reference's own screenshots + /// (`color`, `elevation` and `button` screens), so the two catalogs can be + /// compared directly instead of only structurally. + pub const fn reference_dark() -> Self { + Self { + primary: 0xFF96CDF8, + on_primary: 0xFF00344E, + primary_container: 0xFF004B70, + on_primary_container: 0xFFCAE6FF, + secondary_container: 0xFF384956, + on_secondary_container: 0xFFD3E5F5, + tertiary_container: 0xFF4D4162, + error: 0xFFFFB4AB, + on_error: 0xFF690005, + error_container: 0xFF93000A, + on_error_container: 0xFFFFDAD6, + surface: 0xFF101417, + on_surface: 0xFFE1E3E5, + on_surface_variant: 0xFFC1C7CE, + outline: 0xFF8B9198, + outline_variant: 0xFF41484D, + surf_lowest: 0xFF0E1214, + surf_low: 0xFF181C20, + surf_cont: 0xFF1C2024, + surf_high: 0xFF272B2F, + surf_highest: 0xFF32363A, + inverse_surface: 0xFFE1E3E5, + inverse_on_surface: 0xFF2E3134, + secondary: 0xFFB7C9D9, + on_secondary: 0xFF22323F, + tertiary: 0xFFCFC0E8, + on_tertiary: 0xFF362B4B, + on_tertiary_container: 0xFFEBDDFF, + surface_variant: 0xFF41484D, + } + } + + pub const fn dark() -> Self { + Self { + primary: 0xFFD0BCFF, + on_primary: 0xFF381E72, + primary_container: 0xFF4F378B, + on_primary_container: 0xFFEADDFF, + secondary_container: 0xFF4A4458, + on_secondary_container: 0xFFE8DEF8, + tertiary_container: 0xFF633B48, + error: 0xFFF2B8B5, + on_error: 0xFF601410, + error_container: 0xFF8C1D18, + on_error_container: 0xFFF9DEDC, + surface: 0xFF141218, + on_surface: 0xFFE6E0E9, + on_surface_variant: 0xFFCAC4D0, + outline: 0xFF938F99, + outline_variant: 0xFF49454F, + surf_lowest: 0xFF0F0D13, + surf_low: 0xFF1D1B20, + surf_cont: 0xFF211F26, + surf_high: 0xFF2B2930, + surf_highest: 0xFF36343B, + inverse_surface: 0xFFE6E0E9, + inverse_on_surface: 0xFF322F35, + secondary: 0xFFCCC2DC, + on_secondary: 0xFF332D41, + tertiary: 0xFFEFB8C8, + on_tertiary: 0xFF492532, + on_tertiary_container: 0xFFFFD8E4, + surface_variant: 0xFF49454F, + } + } +} + +/// A Material icon name → a glyph in the theme's icon font (Font Awesome). +/// The reference names its icons rather than spelling codepoints. +pub fn icon_glyph(name: &str) -> &'static str { + match name { + "add" => "\u{f067}", + "home" => "\u{f015}", + "search" => "\u{f002}", + "music_note" => "\u{f001}", + "album" => "\u{f51f}", + "attach_money" => "\u{f155}", + "bookmark" => "\u{f02e}", + "call" => "\u{f095}", + "chat" => "\u{f075}", + "check" => "\u{f00c}", + // f044 is pen-to-square (a pencil in a box); the reference draws the + // plain angled pencil. + "edit" => "\u{f303}", + "favorite" => "\u{f004}", + "inbox" => "\u{f01c}", + "mail" => "\u{f0e0}", + "notifications" => "\u{f0f3}", + "person" => "\u{f007}", + "place" => "\u{f3c5}", + "play_arrow" => "\u{f04b}", + "repeat" => "\u{f01e}", + "settings" => "\u{f013}", + "share" => "\u{f1e0}", + "shopping_cart" => "\u{f07a}", + "shuffle" => "\u{f074}", + "skip_next" => "\u{f051}", + "skip_previous" => "\u{f048}", + "star" => "\u{f005}", + "menu" => "\u{f0c9}", + "more" => "\u{f142}", + "format_bold" => "\u{f032}", + "format_italic" => "\u{f033}", + "format_underlined" => "\u{f0cd}", + "format_align_center" => "\u{f037}", + "drag_handle" => "\u{f7a4}", + // Field affordances: the reveal eye and the error marker. + "visibility" => "\u{f06e}", + "error" => "\u{f06a}", + _ => "\u{f111}", // a filled dot: visibly "an icon we do not have a glyph for" + } +} + +// ---- small builders -------------------------------------------------------- + +fn n(kind: NodeKind) -> UiNode { + UiNode { + kind, + attrs: Attrs::default(), + children: Vec::new(), + } +} +fn col() -> UiNode { + n(NodeKind::Column) +} +fn row() -> UiNode { + n(NodeKind::Row) +} +fn label(s: &str, size: f32, color: u32) -> UiNode { + let mut t = n(NodeKind::Text); + t.attrs.text = Some(s.to_string()); + t.attrs.size = Some(size); + t.attrs.color = Some(color); + t +} +/// A Label Large (14sp / weight 500) — the type role every Material control uses. +fn label_lg(s: &str, color: u32) -> UiNode { + let mut t = label(s, 14.0, color); + t.attrs.weight = Some(500); + t +} +fn glyph(name: &str, size: f32, color: u32) -> UiNode { + let mut t = label(icon_glyph(name), size, color); + t.attrs.icon = Some(1); + t +} +/// A centred, content-sized box. +fn boxed(bg: Option, radius: f32) -> UiNode { + let mut c = col(); + c.attrs.bg = bg; + c.attrs.radius = Some(radius); + c.attrs.alignx = Some(0.5); + c.attrs.aligny = Some(0.5); + c +} + +/// Material's disabled treatment: 38% ink, 12% container. Alpha only, so a role +/// stays recognisably itself. +/// The visible span of a gradient stated for a *diagonal* fill, rendered on a +/// shader that only does vertical. A diagonal spreads its range across both +/// axes, so only part of it shows along y: the reference's tile varies 13 of the +/// gradient's full 41 on green, i.e. 32% — hence stops at 0.34 and 0.66. +fn grad_span(a: u32, b: u32) -> (u32, u32) { + (mix_rgb(a, b, 0.34), mix_rgb(a, b, 0.66)) +} + +/// Linear blend between two ARGB colours. +fn mix_rgb(a: u32, b: u32, t: f32) -> u32 { + let ch = |sh: u32| { + let (x, y) = (((a >> sh) & 0xff) as f32, ((b >> sh) & 0xff) as f32); + ((x + (y - x) * t).round() as u32).min(255) << sh + }; + (a & 0xFF00_0000) | ch(16) | ch(8) | ch(0) +} + +pub(crate) fn dim(argb: u32, alpha: f32) -> u32 { + let a = ((argb >> 24) & 0xff) as f32 * alpha; + (argb & 0x00FF_FFFF) | ((a as u32) << 24) +} + +fn is_enabled(a: &Attrs) -> bool { + a.enabled.unwrap_or(1) != 0 +} + +fn variant<'a>(a: &'a Attrs, dflt: &'a str) -> &'a str { + a.variant.as_deref().unwrap_or(dflt) +} + +/// Roughly how wide a lowered node draws, in dp. +/// +/// Only needed because `flow` has to be packed here (see [`flow`]); it is an +/// estimate, not a measurement — the renderer does the real layout. +fn measure(node: &UiNode) -> f32 { + let a = &node.attrs; + if let Some(w) = a.w { + return w; + } + let pad = a.padx.or(a.pad).unwrap_or(0.0) * 2.0; + if node.kind == NodeKind::Text { + // 0.66em is a serviceable average advance for the UI faces in use, and + // erring wide is the safe direction: a row that wraps one item early + // still reads correctly, one that wraps late runs off the screen. + let len = a.text.as_deref().map(str::chars).map(Iterator::count).unwrap_or(0) as f32; + return pad + len * a.size.unwrap_or(14.0) * 0.66; + } + let spacing = a.spacing.unwrap_or(0.0) * (node.children.len().saturating_sub(1)) as f32; + let kids: f32 = match node.kind { + // a column is as wide as its widest child; a row is the sum + NodeKind::Column | NodeKind::Stack => node + .children + .iter() + .map(measure) + .fold(0.0, f32::max), + _ => node.children.iter().map(measure).sum(), + }; + pad + spacing + kids +} + +/// The width `flow` packs into. The reference's screens are authored for a +/// phone; a host with a different viewport can say so. +static FLOW_WIDTH: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(340); + +/// Set the width, in dp, that a wrapping row packs into. +pub fn set_flow_width(dp: f32) { + FLOW_WIDTH.store(dp as u32, std::sync::atomic::Ordering::Relaxed); +} + +/// A wrapping row, packed into fixed rows here. +/// +/// The reference wraps every group of demo widgets, and its screens overflow the +/// viewport without it. makepad's own `Flow::RightWrap` is inert in this +/// revision — verified on device, with and without an explicit width, a +/// `RightWrap` row clips exactly like a plain `Right` one — so the packing is +/// done here instead, off an estimated child width. +fn flow(node: &UiNode, r: &Roles) -> UiNode { + let limit = FLOW_WIDTH.load(std::sync::atomic::Ordering::Relaxed) as f32; + let spacing = node.attrs.spacing.unwrap_or(8.0); + let mut lines = col(); + lines.attrs.spacing = Some(spacing); + let mut line = row(); + line.attrs.spacing = Some(spacing); + line.attrs.aligny = Some(0.5); + let mut used = 0.0f32; + for kid in &node.children { + // Inside a wrapping row every item hugs; that is what makes it wrap. + let mut kid = kid.clone(); + if kid.attrs.fillw.is_none() { + kid.attrs.fitw = Some(1); + } + let kid = &kid; + // Measure what will actually be drawn, not the semantic node. + let drawn = lower(kid, r).unwrap_or_else(|| kid.clone()); + let w = measure(&drawn); + if used > 0.0 && used + spacing + w > limit { + lines.children.push(std::mem::replace(&mut line, { + let mut l = row(); + l.attrs.spacing = Some(spacing); + l.attrs.aligny = Some(0.5); + l + })); + used = 0.0; + } + used += if used > 0.0 { spacing + w } else { w }; + line.children.push(drawn); + } + if !line.children.is_empty() { + lines.children.push(line); + } + // No trailing gap, at any size. Re-tested under the aligned layout — still much worse + // (allcomponents 8.7 -> 15.6), so unlike the alignment itself this one does + // not come good once the layout beneath it is right. On `allcomponents` the + // reference's chips sit + // 82px above the field where these sit 43. Adding 13dp after every flow was + // tried and is far worse (allcomponents 12.0 -> 17.4, button 10.7 -> 15.4): + // screens with several flows compound it. 4dp -- matching the ~12px each of + // `button`'s sections actually loses -- is also worse (allcomponents 8.7 -> + // 15.6, and `button` itself 11.4 -> 11.9). Whatever that gap is, it is not a + // property of the wrapping row. + lines +} + +/// The reference spells parallel lists comma-separated (`icon:"home,search"`, +/// `badge:",,7,"` — an empty slot means "none"), alongside `;`-separated items. +fn list_at(src: Option<&str>, i: usize) -> Option<&str> { + src.and_then(|s| s.split(',').nth(i)) + .map(str::trim) + .filter(|s| !s.is_empty()) +} + +fn items_of(a: &Attrs) -> Vec<&str> { + a.items + .as_deref() + .unwrap_or("") + .split(';') + .filter(|s| !s.is_empty()) + .collect() +} + +// ---- the lowering ---------------------------------------------------------- + +/// One Material node → a primitive subtree, or `None` if `node` is not one. +pub fn lower(node: &UiNode, r: &Roles) -> Option { + let a = &node.attrs; + Some(match node.kind { + NodeKind::Text => text_role(node, r)?, + // Only a field that states chrome lowers. The composed result contains a + // bare `Input` of its own, and without this guard that one would lower + // again, forever. + NodeKind::Input | NodeKind::Textarea + if a.variant.is_some() + || a.hint.is_some() + || a.helper.is_some() + || a.error.is_some() => + { + text_field(node, r) + } + NodeKind::Button => button(node, r)?, + NodeKind::Image if a.src.is_some() => shapeable_image(a, r), + // An indicator states no size in the reference — Android's widget has an + // intrinsic one. Here it collapsed to zero and the screens drew nothing, + // so supply the M3 default and keep the animating widget. + // A linear indicator is a track with a fill, not a circular one. The + // `variant` was ignored, so "Linear - determinate" drew a spinner. + NodeKind::Progress if variant(a, "circular") == "linear" => { + let total = a.total.unwrap_or(100.0).max(1.0); + let frac = if a.indeterminate.unwrap_or(0) != 0 { + 0.35 + } else { + (a.value.unwrap_or(0.0) / total).clamp(0.0, 1.0) + }; + // 354dp: the reference's linear bar spans x45-1034, the full content + // width, where 340 left it 12dp short. + const TRACK: f32 = 354.0; + // M3 Expressive, like the slider: active | 4dp gap | inactive, and a + // 4dp stop dot at the end when determinate. Measured off the + // reference's own bar — active 643px, an 11px gap, inactive 325px, + // then an 11px dot, totalling the 990px content width. + const GAP: f32 = 4.0; + const DOT: f32 = 4.0; + let determinate = a.indeterminate.unwrap_or(0) == 0; + let seg = |w: f32, c: u32| { + let mut s = col(); + s.attrs.bg = Some(c); + s.attrs.h = Some(4.0); + s.attrs.radius = Some(2.0); + s.attrs.w = Some(w.max(1.0)); + s + }; + // The row is taller than the 4dp bar — the reference reserves space + // below each one, which is why its sections sat further apart than + // these did. 6, measured: 8 overshoots (progressindicator 9.9 -> 10.3 + // where 6 gives 9.6), so the reserve is ~2dp, not the ~4 the gap + // below the bar suggested. + let mut bar = row(); + bar.attrs.h = Some(6.0); + bar.attrs.aligny = Some(0.0); + bar.attrs.fillw = Some(1); + let active = TRACK * frac; + let tail = TRACK - active - GAP - if determinate { DOT } else { 0.0 }; + bar.children.push(seg(active, r.primary)); + bar.children.push(sl_gap(GAP)); + bar.children.push(seg(tail, r.secondary_container)); + if determinate { + let mut dot = col(); + dot.attrs.bg = Some(r.primary); + dot.attrs.w = Some(DOT); + dot.attrs.h = Some(DOT); + dot.attrs.radius = Some(DOT * 0.5); + bar.children.push(dot); + } + bar + } + // A determinate circular indicator is a 40dp ring in the reference + // (measured). Upstream's LoadingSpinner sizes its arc off its own theme + // and drew a sliver beside it, the same way Toggle ignores its rect. + NodeKind::Progress + if variant(a, "circular") == "circular" + && a.value.is_some() + && a.indeterminate.unwrap_or(0) == 0 + && a.group.is_none() => + { + let mut ring = col(); + ring.attrs.w = Some(40.0); + ring.attrs.h = Some(40.0); + ring.attrs.radius = Some(20.0); + // 3.2 nominal, which the emitter halves to the 1.6 that measured + // right — that halving was added after this constant was set, and + // silently thinned the ring to 0.8dp. + ring.attrs.border = Some(3.2); + // The ring is the *track*; the progress arc goes over it. + ring.attrs.bordercolor = Some(r.secondary_container); + // A partial arc is not expressible with box primitives, but the + // spinner shader already draws one — stop its rotation and pin its + // gap to 1 - progress and it becomes a determinate indicator. + let mut arc = node.clone(); + arc.attrs.group = Some("arc".to_string()); + arc.attrs.w = Some(40.0); + arc.attrs.h = Some(40.0); + arc.attrs.bg = Some(a.bg.unwrap_or(r.primary)); + // The row reserves space below the 40dp ring, like the slider and + // linear bar do: the reference's next section sits 21px further down + // than a bare 40dp row leaves room for. 53, measured in two steps: + // 48 closed most of it and left 15px, which is this last 5dp. + let mut stack = n(NodeKind::Stack); + stack.attrs.w = Some(40.0); + stack.attrs.h = Some(53.0); + stack.attrs.aligny = Some(0.0); + stack.children.push(ring); + stack.children.push(arc); + stack + } + NodeKind::Loading | NodeKind::Progress if a.w.is_none() && a.h.is_none() => { + let mut ind = node.clone(); + let d = if variant(a, "uncontained") == "contained" { 48.0 } else { 40.0 }; + ind.attrs.w = Some(d); + ind.attrs.h = Some(d); + ind.attrs.bg = a.bg.or(Some(r.primary)); + ind + } + // A slider states its range in the reference; that is the cue to dress it. + // `accent: 0` marks the invisible native widget this lowering already + // produced -- the same marker the rest of the module uses. Clearing + // min/max used to serve as the guard, which meant the native slider + // could never state a range of its own; it needs one now to carry its + // index, so the guard has to be the marker rather than a side effect. + NodeKind::Slider if a.accent != Some(0) && (a.min.is_some() || a.max.is_some()) => { + slider(node, r) + } + NodeKind::Fab => fab(a, r), + NodeKind::IconButton => icon_button(a, r), + NodeKind::Flow => flow(node, r), + NodeKind::Segmented => segmented(a, r), + NodeKind::Toggle if a.text.is_some() => switch_row(node, r), + NodeKind::Chip => chip(a, r), + // A checkbox had no lowering at all, so nothing ever set its tap: it + // toggled makepad's own visual state and reverted on the next mount, + // while the DSL's `on:` -- and every caption reading that slot -- stayed + // put. `CheckBox` does carry an `on_click`, so the tap can sit on the + // widget itself. The `tapto` guard is what stops this re-entering. + NodeKind::Checkbox if a.key.is_some() && a.tapto.is_none() => { + let mut c = node.clone(); + let on = a.on.unwrap_or(0) != 0; + let k = a.key.as_deref().unwrap_or_default(); + c.attrs.tapto = Some(format!("set:{k}={}", i32::from(!on))); + c + } + NodeKind::Card => card(node, r), + NodeKind::ListItem => list_item(a, r), + NodeKind::Divider => divider(a, r), + NodeKind::Spacer => spacer(a), + NodeKind::ShapeBox => shape_box(a, r), + NodeKind::ColorSwatch => color_swatch(a, r), + // The 11px before a section header is stated on the header now, not as + // a trailing gap on a group of swatches. They are the same boundary, so + // only one may state it; `color` is the screen that had both and it + // drifted 11px per section (4.1 -> 19.5). The swatch-gap arm is deleted + // rather than made to return the node unchanged -- its guard would still + // match on re-entry and run away (color 74.8). + // + // Mark every section header *except the first*. That exception is the + // whole difference: a trailing gap falls after the last group, where + // nothing follows it, but a leading one falls before the first header, + // where it shifts the entire screen down 11px. On `color`, five + // sections of uniform 162px bands, that shift alone costs 8 points. + NodeKind::Column + if node.children.iter().skip(1).any(|k| { + is_section_head(k) && k.attrs.group.as_deref() != Some("sechead") + }) => + { + let mut g = node.clone(); + for k in g.children.iter_mut().skip(1) { + if is_section_head(k) { + k.attrs.group = Some("sechead".to_string()); + } + } + g + } + NodeKind::BadgeIcon => badge_icon(a, r), + NodeKind::SearchBar => search_bar(a, r), + NodeKind::Dropdown => dropdown(a, r), + NodeKind::Tabs => tabs(a, r), + NodeKind::NavBar => nav_bar(a, r), + NodeKind::NavRail => nav_rail(a, r), + NodeKind::Carousel => carousel(a, r), + NodeKind::RadioGroup => radio_group(a, r), + NodeKind::RangeSlider => range_slider(a, r), + NodeKind::AppBarDemo => app_bar(a, r), + NodeKind::BottomBarDemo => bottom_bar(r), + NodeKind::ToolbarDemo => toolbar(a, r), + NodeKind::AdaptiveDemo => adaptive(a, r), + NodeKind::TransitionHost => transition_host(a, r), + NodeKind::WeatherIcon => weather_icon(a, r), + NodeKind::NavMap => nav_map(a, r), + NodeKind::GlassPanel => glass_panel(node, r), + _ => return None, + }) +} + +/// The Material type scale: `(size, line-height, weight)`. +/// +/// The reference sets a type role rather than a size — `variant:"titleMedium"`, +/// `"bodySmall"`, `"displayLarge"` — so without this every heading, caption and +/// sample on a screen renders at the same default size, and the `font` screen +/// (whose whole subject is the scale) shows fifteen identical lines. +fn type_role(name: &str) -> Option<(f32, i32)> { + Some(match name { + "displayLarge" => (57.0, 400), + "displayMedium" => (45.0, 400), + "displaySmall" => (36.0, 400), + "headlineLarge" => (32.0, 400), + "headlineMedium" => (28.0, 400), + "headlineSmall" => (24.0, 400), + "titleLarge" => (22.0, 400), + "titleMedium" => (16.0, 500), + "titleSmall" => (14.0, 500), + "bodyLarge" => (16.0, 400), + "bodyMedium" => (14.0, 400), + "bodySmall" => (12.0, 400), + "labelLarge" => (14.0, 500), + "labelMedium" => (12.0, 500), + "labelSmall" => (11.0, 500), + _ => return None, + }) +} + +/// A text node carrying a type role, resolved against the scale. +fn text_role(node: &UiNode, r: &Roles) -> Option { + let a = &node.attrs; + let (size, weight) = type_role(a.variant.as_deref()?)?; + let mut t = node.clone(); + // An explicit size still wins; the role only supplies what was not said. + t.attrs.size = a.size.or(Some(size)); + t.attrs.weight = a.weight.or(Some(weight)); + t.attrs.color = a.color.or(Some(match a.variant.as_deref() { + // Captions and supporting lines sit on the variant ink, headings on the + // main one — that is how the reference reads on screen. + Some("bodySmall") | Some("labelSmall") | Some("bodyMedium") => r.on_surface_variant, + _ => r.on_surface, + })); + t.attrs.fillw = a.fillw.or(Some(1)); + t.attrs.variant = None; // resolved; do not re-enter + Some(t) +} + +/// The five M3 button variants. A bare `{t:"button"}` with no variant keeps the +/// native widget (the kit uses it for real taps), so only a *stated* variant +/// lowers — otherwise this would silently take over every button in the kit. +fn button(node: &UiNode, r: &Roles) -> Option { + let a = &node.attrs; + let v = a.variant.as_deref()?; + let on = is_enabled(a); + let (bg, ink, border) = match v { + "filled" => (Some(r.primary), r.on_primary, None), + "tonal" => (Some(r.secondary_container), r.on_secondary_container, None), + "elevated" => (Some(r.surf_low), r.primary, None), + "outlined" => (None, r.primary, Some(r.outline)), + "text" => (None, r.primary, None), + _ => (Some(r.primary), r.on_primary, None), + }; + let (bg, ink, border) = if on { + (bg, ink, border) + } else { + ( + bg.map(|c| dim(r.on_surface, 0.12) | (c & 0)), + dim(r.on_surface, 0.38), + border.map(|_| dim(r.on_surface, 0.12)), + ) + }; + + let mut b = boxed(bg, 20.0); + b.attrs.h = Some(40.0); + // Horizontal inset is carried by spacer children, not `padx`: only the + // *symmetric scalar* padding form lands in this dialect, and on a + // fixed-height box 21.5dp of it leaves a negative content area, which + // dropped every label to the bottom edge of its button. + // + // The spacer is short of the target inset by the Label's own default + // padding, which sits inside it — the same correction the vertical wrapper + // makes. Measured: a 21.5 spacer rendered a 94dp Filled against the + // reference's 89. (The overshoot is not font width; a glyph run measures + // 343px here against the reference's 347.) + let inset = if v == "text" { 16.0 } else { 26.0 }; + // Fill by default: the reference's trigger stacks are full-width bars, + // because a MaterialButton in a vertical LinearLayout is match_parent. A + // button inside a `flow` hugs instead — `flow` marks its children. + if a.fitw == Some(1) { + b.attrs.fitw = Some(1); + } else { + b.attrs.fillw = Some(1); + } + b.attrs.spacing = Some(8.0); + if let Some(bc) = border { + b.attrs.border = Some(1.0); + b.attrs.bordercolor = Some(bc); + } + if v == "elevated" && on { + b.attrs.elevation = Some(1.0); + } + // `key` + `action` is the reference's way of asking the host to open a real + // dialog / sheet / picker. Route it through the same tap channel. + if on { + if let Some(k) = a.key.as_deref() { + // `action` names a host behaviour (open this dialog); a bare `tap: 1` + // just records the press. Requiring both left every plain button on + // the `button` screen inert — they carry `key` + `tap`, no `action`. + match a.action.as_deref() { + Some(act) => b.attrs.tapto = Some(format!("set:{k}={act}")), + None if a.tap.unwrap_or(0) != 0 => { + b.attrs.tapto = Some(format!("set:{k}=1")) + } + None => {} + } + } + } + let text = a.label.clone().or_else(|| a.text.clone()).unwrap_or_default(); + // Spacing is stated per gap, not as a row `spacing`, so the leading and + // trailing insets stay exactly `inset` rather than inset + spacing. + let mut inner = row(); + inner.attrs.spacing = Some(0.0); + inner.attrs.aligny = Some(0.5); + inner.attrs.fitw = Some(1); + // M3 tightens the leading inset to 16dp when a label follows an icon. With + // 21.5 on both sides the three icon buttons overflowed the row and clipped + // "Save" off the screen edge, where the reference's three fit. + let icon = a.icon_name.as_deref(); + inner.children.push(sl_gap(if icon.is_some() { 28.0 } else { inset })); + if let Some(icon) = icon { + inner.children.push(glyph(icon, 18.0, ink)); + } + if !text.is_empty() { + if icon.is_some() { + inner.children.push(sl_gap(8.0)); + } + inner.children.push(label_lg(&text, ink)); + } + inner.children.push(sl_gap(inset)); + b.children.push(inner); + // Android's MaterialButton carries a 4dp vertical inset by default, which + // the reference inherits: its stacked triggers sit 16dp apart where the DSL + // asks for 8. Stated as spacer rows around the button rather than a margin, + // which this dialect drops; the button's own 40dp height is unchanged. + let mut outer = col(); + outer.attrs.fillw = if a.fitw == Some(1) { None } else { Some(1) }; + if a.fitw == Some(1) { + outer.attrs.fitw = Some(1); + } + // 4dp. The `button` screen loses ~12px per button row against the reference, + // but this is not where it comes from: 6 makes things far worse (excess over + // 9.0 across the sweep 2.3 -> 14.7) because `dialog`'s six stacked buttons + // compound it, and dialog's button pitch already matches exactly (158). + let mut pad_top = col(); + pad_top.attrs.h = Some(4.0); + let mut pad_bot = col(); + pad_bot.attrs.h = Some(4.0); + // No horizontal counterpart to these spacers, though the reference's + // three-button rows do measure 282dp against 268 here. Even 2dp a side tips + // the `button` screen's first row past makepad's wrap threshold and drops + // its third button to the next line, where the reference keeps three — a + // worse error than the 14dp of width it buys. + outer.children.push(pad_top); + outer.children.push(b); + outer.children.push(pad_bot); + Some(outer) +} + +fn fab(a: &Attrs, r: &Roles) -> UiNode { + let v = variant(a, "regular"); + let icon = a.icon_name.as_deref().unwrap_or("add"); + // M3 FAB: 40/56/96dp with 12/16/28dp corners; extended is 56dp and hugs. + let (size, radius, glyph_size) = match v { + "small" => (40.0, 12.0, 20.0), + "large" => (96.0, 28.0, 36.0), + _ => (56.0, 16.0, 24.0), + }; + // `group` names the container role; the reference's "Colours" row is four + // FABs that differ only by it, and ignoring it made them all identical. + let (fill, ink) = match a.group.as_deref() { + Some("secondary") => (r.secondary_container, r.on_secondary_container), + Some("tertiary") => (r.tertiary_container, r.on_tertiary_container), + Some("surface") => (r.surf_high, r.primary), + _ => (r.primary_container, r.on_primary_container), + }; + let mut f = boxed(Some(fill), radius); + f.attrs.h = Some(if v == "extended" { 56.0 } else { size }); + f.attrs.elevation = Some(3.0); + if v == "extended" { + f.attrs.padx = Some(16.0); + f.attrs.fitw = Some(1); + f.attrs.spacing = Some(12.0); + let mut inner = row(); + inner.attrs.spacing = Some(12.0); + inner.attrs.aligny = Some(0.5); + inner.attrs.fitw = Some(1); + inner.children.push(glyph(icon, 24.0, ink)); + if let Some(l) = a.label.as_deref() { + let mut t = label(l, 16.0, ink); + t.attrs.weight = Some(500); + inner.children.push(t); + } + f.children.push(inner); + } else { + f.attrs.w = Some(size); + f.children + .push(glyph(icon, glyph_size, r.on_primary_container)); + } + f +} + +/// A section header, as `screens/kit.splash` spells one: the only Text node in +/// any of the 41 screens carrying `marginy: 4`. +fn is_section_head(n: &UiNode) -> bool { + n.kind == NodeKind::Text && n.attrs.marginy == Some(4.0) +} + +fn icon_button(a: &Attrs, r: &Roles) -> UiNode { + let v = variant(a, "standard"); + let selected = a.on.unwrap_or(0) != 0; + let (bg, ink, border) = match v { + "filled" => (Some(r.primary), r.on_primary, None), + "tonal" => ( + Some(r.secondary_container), + r.on_secondary_container, + None, + ), + // The reference inks an outlined icon button with primary, not the + // neutral it uses for a standard one (sampled off its gear: #96CDF8). + "outlined" => (None, r.primary, Some(r.outline)), + _ => (None, r.on_surface_variant, None), + }; + // A checkable standard icon button fills with primary when selected. + let ink = if selected && v == "standard" { + r.primary + } else { + ink + }; + let mut b = boxed(bg, 20.0); + b.attrs.w = Some(40.0); + b.attrs.h = Some(40.0); + // Android wires a checkable icon button to `onState(key, checked?1:0)` and a + // plain one to its click. `checkable` is never parsed into `Attrs`, so the + // presence of `on` is what separates the two -- which is the same thing the + // DSL means by it. + if let Some(k) = a.key.as_deref() { + b.attrs.tapto = Some(if a.on.is_some() { + format!("set:{k}={}", i32::from(!selected)) + } else { + format!("set:{k}=1") + }); + } + if let Some(bc) = border { + b.attrs.border = Some(1.0); + b.attrs.bordercolor = Some(bc); + } + // A standard icon button has no container, so its glyph *is* its width: + // the reference's measures 42px against 55 at 20dp, hence 15. The other + // variants sit in a 40dp circle that fixes their width either way. + let glyph_dp = if v == "standard" { 15.0 } else { 20.0 }; + b.children.push(glyph( + a.icon_name.as_deref().unwrap_or("add"), + glyph_dp, + ink, + )); + // Android's icon buttons carry the same 4dp inset its MaterialButton does: + // the reference's sit 56dp apart where the DSL asks for 8dp of spacing on a + // 40dp control. Stated as side gaps, since margin is dropped by this dialect. + let mut outer = row(); + outer.attrs.fitw = Some(1); + outer.attrs.aligny = Some(0.5); + outer.children.push(sl_gap(4.0)); + outer.children.push(b); + outer.children.push(sl_gap(4.0)); + // The inset is vertical too, and only the sides of it were being stated. + // Measured on `button`: the reference's icon-button row starts 13px below + // its header where a *button* row starts in the right place, and the next + // header sits 22px further down still -- 4dp above and below, exactly the + // pair MaterialButton gets. + let mut vouter = col(); + vouter.attrs.fitw = Some(1); + let mut t = col(); + t.attrs.h = Some(4.0); + let mut bm = col(); + bm.attrs.h = Some(4.0); + vouter.children.push(t); + vouter.children.push(outer); + vouter.children.push(bm); + vouter +} +fn segmented(a: &Attrs, r: &Roles) -> UiNode { + let sel = a.selected.unwrap_or(0); + let mut strip = row(); + strip.attrs.h = Some(40.0); + strip.attrs.radius = Some(20.0); + strip.attrs.border = Some(1.0); + strip.attrs.bordercolor = Some(r.outline); + // A segmented button spans its row and divides evenly; hugging its content + // made "Default|Pink|Green|Blue" a small clump against the reference's bar. + strip.attrs.fillw = Some(1); + let count = a + .items + .as_deref() + .unwrap_or("") + .split(';') + .filter(|s| !s.is_empty()) + .count(); + for (i, item) in a + .items + .as_deref() + .unwrap_or("") + .split(';') + .filter(|s| !s.is_empty()) + .enumerate() + { + let on = i as i32 == sel; + // Square, though the reference's selected end segment fills the + // container's rounded corner and this leaves grey slivers there. The SDF + // takes one radius for all four corners (`box_y` splits top/bottom, not + // left/right), so rounding the fill also rounds its inner edge and loses + // the divider — measured as the worse of the two. + let _ = count; + let mut seg = boxed(if on { Some(r.secondary_container) } else { None }, 0.0); + // The tap writes the state slot; the DSL is re-evaluated and the strip + // redraws with the new selection. + if let Some(k) = a.key.as_deref() { + seg.attrs.tapto = Some(format!("set:{k}={i}")); + } + seg.attrs.h = Some(40.0); + seg.attrs.padx = Some(12.0); + seg.attrs.fillw = Some(1); + seg.attrs.spacing = Some(8.0); + let ink = if on { + r.on_secondary_container + } else { + r.on_surface_variant + }; + let mut inner = row(); + inner.attrs.spacing = Some(8.0); + inner.attrs.aligny = Some(0.5); + inner.attrs.fitw = Some(1); + // No leading check: M3 specs one, but the reference's toggle group + // renders text only, and the extra glyph widened the selected segment. + inner.children.push(label_lg(item, ink)); + seg.children.push(inner); + strip.children.push(seg); + // The joined look: a hairline between segments, not a gap. + if i > 0 { + let mut rule = col(); + rule.attrs.w = Some(1.0); + rule.attrs.bg = Some(r.outline); + strip.children.insert(strip.children.len() - 1, rule); + } + } + // Same 4dp vertical inset as a button and an icon button: the reference's + // strip starts 13px below its header, and the header after it sits 22px + // lower again. + let mut vouter = col(); + let mut t = col(); + t.attrs.h = Some(4.0); + let mut bm = col(); + bm.attrs.h = Some(4.0); + vouter.children.push(t); + vouter.children.push(strip); + vouter.children.push(bm); + vouter +} + +fn chip(a: &Attrs, r: &Roles) -> UiNode { + let v = variant(a, "assist"); + let on = a.on.unwrap_or(0) != 0; + let enabled = is_enabled(a); + let (bg, ink, border) = if on { + ( + Some(r.secondary_container), + r.on_secondary_container, + None, + ) + } else { + (None, r.on_surface_variant, Some(r.outline_variant)) + }; + let (ink, border) = if enabled { + (ink, border) + } else { + (dim(r.on_surface, 0.38), border.map(|_| dim(r.on_surface, 0.12))) + }; + let mut c = boxed(bg, 8.0); + if let Some(k) = a.key.as_deref() { + c.attrs.tapto = Some(format!("set:{k}={}", if on { 0 } else { 1 })); + } + c.attrs.h = Some(32.0); + c.attrs.fitw = Some(1); + if let Some(bc) = border { + c.attrs.border = Some(1.0); + c.attrs.bordercolor = Some(bc); + } + // 13dp a side, measured: the reference's chips are ~28px wider on both the + // `chip` screen (253 against 225) and `allcomponents` (226 against 197). + // The inset rides on spacer children, not `padx` — see `button`: the + // only padding form this dialect honours is the symmetric scalar, which on + // a fixed-height chip adds 12dp top and bottom and swells it past 32dp. + let mut inner = row(); + inner.attrs.spacing = Some(0.0); + inner.attrs.aligny = Some(0.5); + inner.attrs.fitw = Some(1); + inner.children.push(sl_gap(13.0)); + if on { + inner.children.push(glyph("check", 16.0, ink)); + inner.children.push(sl_gap(8.0)); + } + if let Some(icon) = a.icon_name.as_deref() { + inner.children.push(glyph(icon, 16.0, ink)); + inner.children.push(sl_gap(8.0)); + } + inner.children.push(label_lg( + a.label.as_deref().or(a.text.as_deref()).unwrap_or(""), + ink, + )); + // An input chip carries a trailing dismiss affordance. + if v == "input" { + inner.children.push(sl_gap(8.0)); + inner.children.push(label("\u{00d7}", 14.0, ink)); + } + inner.children.push(sl_gap(13.0)); + c.children.push(inner); + // Android's chip carries vertical margins the DSL does not state: the + // reference leaves 23px above a chip row and 33px below it (measured on the + // `chip` screen), making the row 52dp around a 32dp chip. That 56px is also + // exactly the gap `allcomponents` was missing between its chips and field. + let mut outer = col(); + outer.attrs.fitw = Some(1); + // Bottom only. The reference also leaves 23px *above* a chip row, but these + // chips already sit ~13px low on `allcomponents`, so a top margin compounds + // there (11.9 -> 14.2) while the bottom margin alone helps both. + let mut bot = col(); + bot.attrs.h = Some(12.0); + outer.children.push(c); + outer.children.push(bot); + outer +} + +fn card(node: &UiNode, r: &Roles) -> UiNode { + let a = &node.attrs; + let v = variant(a, "elevated"); + let mut c = col(); + c.attrs.radius = Some(12.0); + // Only pad when the card supplies its own content. The screens wrap a card + // around a `col` that already carries `pad: 16`, so adding it here too + // doubled the inset and made every card ~32dp taller than the reference's. + if node.children.is_empty() { + c.attrs.pad = Some(16.0); + } + c.attrs.spacing = Some(8.0); + match v { + "filled" => c.attrs.bg = Some(r.surf_highest), + "outlined" => { + c.attrs.bg = Some(r.surface); + c.attrs.border = Some(1.0); + c.attrs.bordercolor = Some(r.outline_variant); + } + _ => { + c.attrs.bg = Some(r.surf_low); + c.attrs.elevation = Some(1.0); + } + } + if let Some(t) = a.title.as_deref().or(a.label.as_deref()) { + let mut h = label(t, 16.0, r.on_surface); + h.attrs.weight = Some(500); + c.children.push(h); + } + if let Some(s) = a.supporting.as_deref().or(a.text.as_deref()) { + let mut body = label(s, 14.0, r.on_surface_variant); + body.attrs.fillw = Some(1); + c.children.push(body); + } + c.children.extend(node.children.iter().cloned()); + c +} + +fn list_item(a: &Attrs, r: &Roles) -> UiNode { + let title = a.label.as_deref().or(a.text.as_deref()).unwrap_or(""); + let supporting = a.supporting.as_deref(); + // M3 list rows: 56dp one-line, 72dp two-line, 88dp three-line. + let lines = a.lines.unwrap_or(if supporting.is_some() { 2 } else { 1 }); + let h = match lines { + 1 => 56.0, + 2 => 72.0, + _ => 88.0, + }; + let mut item = row(); + item.attrs.h = Some(h); + item.attrs.spacing = Some(16.0); + item.attrs.aligny = Some(0.5); + if let Some(icon) = a.icon_name.as_deref() { + // The reference's leading affordance is a small outlined glyph, not the + // filled 40dp avatar this used to draw. + let mut lead = boxed(None, 12.0); + lead.attrs.w = Some(24.0); + lead.attrs.h = Some(24.0); + lead.children.push(glyph(icon, 20.0, r.on_surface_variant)); + item.children.push(lead); + } + let mut stack = col(); + stack.attrs.spacing = Some(2.0); + stack.attrs.fillw = Some(1); + let mut head = label(title, 16.0, r.on_surface); + head.attrs.fillw = Some(1); + stack.children.push(head); + if let Some(s) = supporting { + let mut body = label(s, 14.0, r.on_surface_variant); + body.attrs.fillw = Some(1); + stack.children.push(body); + } + item.children.push(stack); + // `action` names a trailing control; the preferences screen is a column of + // list rows whose switches were simply absent. + match a.action.as_deref() { + Some("switch") => { + // No Fill spacer here: the text stack already fills, and two Fill + // children split the row — which squeezed the supporting line onto + // two rows. The control hugs at the trailing edge instead. + let mut sw = n(NodeKind::Toggle); + sw.attrs.on = a.on; + sw.attrs.accent = Some(r.primary); + sw.attrs.markcolor = Some(r.on_primary); + sw.attrs.bordercolor = Some(r.outline); + sw.attrs.bg = Some(r.surf_highest); + sw.attrs.fitw = Some(1); + item.children.push(sw); + } + Some("more") => { + item.children.push(glyph("more", 20.0, r.on_surface_variant)); + } + _ => {} + } + item +} + +fn divider(a: &Attrs, r: &Roles) -> UiNode { + let v = variant(a, "full"); + let mut d = col(); + d.attrs.bg = Some(r.outline_variant); + if v == "vertical" { + d.attrs.w = Some(1.0); + d.attrs.h = a.h.or(Some(32.0)); + } else { + d.attrs.h = Some(1.0); + if v == "inset" { + d.attrs.margin = Some(16.0); + } + } + d +} + +fn spacer(a: &Attrs) -> UiNode { + let mut s = col(); + s.attrs.h = a.h.or(Some(8.0)); + s.attrs.w = a.w; + // A bare spacer between two items is the DSL's way of pushing them apart — + // the music player's "1:12 … 3:24" row spans 232dp here against the + // reference's 309 because a zero-width spacer moved nothing. + if a.w.is_none() { + s.attrs.fillw = Some(1); + } + s +} + +/// A shape-scale sample: a filled card carrying its own name, at the stated +/// corner. The reference builds a `MaterialCardView` with a Title Small label +/// and 20dp padding — a full-width band, not a swatch, which is why a row of +/// squares looked nothing like it. +fn shape_box(a: &Attrs, r: &Roles) -> UiNode { + let mut card = col(); + card.attrs.bg = Some(r.surf_highest); + card.attrs.fillw = Some(1); + // The reference's rows measure 59dp tall. This has been re-derived each time + // the text metrics moved: 16 and 18 while the Label carried its own inset, + // 22 once that was zeroed, and 20 now that text nodes get a 2dp inset back. + card.attrs.pad = Some(20.0); + // `radius: 999` is the reference's "full"; it resolves that to 2 x 28dp. + let radius = a.radius.unwrap_or(0.0); + card.attrs.radius = Some(if radius >= 999.0 { 56.0 } else { radius }); + if let Some(t) = a.text.as_deref().or(a.label.as_deref()) { + let mut l = label(t, 14.0, r.on_surface); + l.attrs.weight = Some(500); + l.attrs.fillw = Some(1); + card.children.push(l); + } + card +} + +/// The Material role a name refers to, and the ink that reads on it. +fn role_pair(r: &Roles, name: &str) -> (u32, u32) { + match name { + "primary" => (r.primary, r.on_primary), + "onPrimary" => (r.on_primary, r.primary), + "primaryContainer" => (r.primary_container, r.on_primary_container), + "onPrimaryContainer" => (r.on_primary_container, r.primary_container), + "secondary" => (r.secondary, r.on_secondary), + "onSecondary" => (r.on_secondary, r.secondary), + "secondaryContainer" => (r.secondary_container, r.on_secondary_container), + "onSecondaryContainer" => (r.on_secondary_container, r.secondary_container), + "tertiary" => (r.tertiary, r.on_tertiary), + "onTertiary" => (r.on_tertiary, r.tertiary), + "tertiaryContainer" => (r.tertiary_container, r.on_tertiary_container), + "onTertiaryContainer" => (r.on_tertiary_container, r.tertiary_container), + "error" => (r.error, r.on_error), + "onError" => (r.on_error, r.error), + "errorContainer" => (r.error_container, r.on_error_container), + "onErrorContainer" => (r.on_error_container, r.error_container), + "surface" => (r.surface, r.on_surface), + "onSurface" => (r.on_surface, r.surface), + "surfaceVariant" => (r.surface_variant, r.on_surface_variant), + "onSurfaceVariant" => (r.on_surface_variant, r.surface_variant), + "outline" => (r.outline, r.surface), + "outlineVariant" => (r.outline_variant, r.on_surface), + _ => (r.surf_cont, r.on_surface), + } +} + +/// A colour-role band: the reference writes each role's name *on* the role, in +/// the ink that pairs with it — which is what makes the screen a palette rather +/// than a row of squares. +fn color_swatch(a: &Attrs, r: &Roles) -> UiNode { + let (fill, ink) = match a.group.as_deref() { + Some(role) => role_pair(r, role), + None => (a.bg.unwrap_or(r.primary), r.on_surface), + }; + let mut band = col(); + band.attrs.bg = Some(fill); + // +1dp on whatever the screen asks for. The reference's swatches butt flush + // against each other at 158px for a stated 56dp; these render 155 and leave + // a 3px seam between every pair. Not a global scale — a button's 40dp is + // 113px in both — so it is corrected here rather than in the emitter. + band.attrs.h = Some(a.h.unwrap_or(56.0) + 2.0); + band.attrs.padx = Some(16.0); + band.attrs.aligny = Some(0.5); + band.attrs.radius = Some(0.0); + // `w: 999` in the reference means "as wide as the row". + if a.w.unwrap_or(0.0) > 500.0 { + band.attrs.fillw = Some(1); + } else { + band.attrs.w = a.w; + } + if let Some(name) = a.text.as_deref().or(a.label.as_deref()) { + let mut t = label(name, 14.0, ink); + t.attrs.fillw = Some(1); + band.children.push(t); + } + let mut outer = col(); + outer.attrs.fillw = Some(1); + let mut gap = col(); + gap.attrs.h = Some(4.0); + outer.children.push(band); + outer.children.push(gap); + outer +} +fn badge_icon(a: &Attrs, r: &Roles) -> UiNode { + let mut b = row(); + b.attrs.spacing = Some(4.0); + b.attrs.fitw = Some(1); + b.children.push(glyph( + a.icon_name.as_deref().unwrap_or("notifications"), + 22.0, + r.on_surface, + )); + // Material caps an overflowing badge rather than printing the number: the + // reference shows "999+" where the raw value is 1234. + let count = a.badge.clone().or_else(|| { + a.count.map(|c| if c > 999 { "999+".to_string() } else { c.to_string() }) + }); + let count = count.map(|c| match c.trim().parse::() { + Ok(n) if n > 999 => "999+".to_string(), + _ => c, + }); + if let Some(count) = count { + let mut pill = boxed(Some(r.error), 8.0); + pill.attrs.h = Some(16.0); + pill.attrs.padx = Some(4.0); + pill.attrs.fitw = Some(1); + let mut t = label(&count, 11.0, r.on_error); + t.attrs.weight = Some(500); + pill.children.push(t); + b.children.push(pill); + } + b +} + +fn search_bar(a: &Attrs, r: &Roles) -> UiNode { + let mut s = row(); + s.attrs.bg = Some(r.surf_high); + s.attrs.radius = Some(28.0); + s.attrs.h = Some(56.0); + s.attrs.padx = Some(16.0); + s.attrs.spacing = Some(16.0); + s.attrs.aligny = Some(0.5); + s.children.push(glyph("place", 20.0, r.on_surface_variant)); + s.children.push(label( + a.hint.as_deref().or(a.text.as_deref()).unwrap_or("Search"), + 16.0, + r.on_surface_variant, + )); + s +} + +fn dropdown(a: &Attrs, r: &Roles) -> UiNode { + let mut d = row(); + d.attrs.radius = Some(4.0); + d.attrs.border = Some(1.0); + d.attrs.bordercolor = Some(r.outline); + d.attrs.h = Some(56.0); + d.attrs.padx = Some(16.0); + d.attrs.aligny = Some(0.5); + d.attrs.spacing = Some(12.0); + let chosen = a + .items + .as_deref() + .unwrap_or("") + .split(';') + .nth(a.selected.unwrap_or(0).max(0) as usize) + .unwrap_or(""); + let shown = if chosen.is_empty() { + a.hint.as_deref().unwrap_or("Choose") + } else { + chosen + }; + let mut t = label(shown, 16.0, r.on_surface); + t.attrs.fitw = Some(1); + d.children.push(t); + d.children.push(col()); // spacer pushes the caret to the trailing edge + d.children.push(label("\u{25BE}", 14.0, r.on_surface_variant)); + d +} + +fn tabs(a: &Attrs, r: &Roles) -> UiNode { + let sel = a.selected.unwrap_or(0); + // The reference's tab strip spans its row and divides evenly — its three + // tabs centre on thirds of the full width (measured 207/535/868), where a + // hugging strip clustered them left at 156/410/659. + let mut strip = row(); + strip.attrs.fillw = Some(1); + for (i, item) in items_of(a).into_iter().enumerate() { + let on = i as i32 == sel; + let ink = if on { r.primary } else { r.on_surface_variant }; + let mut tab = col(); + if let Some(k) = a.key.as_deref() { + tab.attrs.tapto = Some(format!("set:{k}={i}")); + } + tab.attrs.h = Some(48.0); + tab.attrs.fillw = Some(1); + tab.attrs.alignx = Some(0.5); + tab.attrs.aligny = Some(0.5); + let mut head = row(); + head.attrs.spacing = Some(6.0); + head.attrs.aligny = Some(0.5); + head.attrs.fitw = Some(1); + if let Some(icon) = list_at(a.icon_name.as_deref(), i) { + head.children.push(glyph(icon, 18.0, ink)); + } + let mut t = label_lg(item, ink); + t.attrs.h = Some(24.0); + head.children.push(t); + if let Some(count) = list_at(a.badge.as_deref(), i) { + let mut dot = boxed(Some(r.error), 8.0); + dot.attrs.h = Some(16.0); + dot.attrs.padx = Some(4.0); + dot.attrs.fitw = Some(1); + let mut bt = label(count, 11.0, r.on_error); + bt.attrs.weight = Some(500); + dot.children.push(bt); + head.children.push(dot); + } + tab.children.push(head); + // The 3dp active indicator is the whole point of a tab strip. + let mut ind = col(); + ind.attrs.h = Some(3.0); + ind.attrs.w = Some(40.0); + ind.attrs.radius = Some(2.0); + ind.attrs.bg = Some(if on { r.primary } else { 0 }); + tab.children.push(ind); + strip.children.push(tab); + } + strip +} + +fn nav_bar(a: &Attrs, r: &Roles) -> UiNode { + let sel = a.selected.unwrap_or(0); + let mut bar = row(); + bar.attrs.bg = Some(r.surf_cont); + // 105dp, measured off the reference's own bar (293px); M3's 80 left it short. + bar.attrs.h = Some(105.0); + bar.attrs.pad = Some(8.0); + bar.attrs.radius = Some(16.0); + bar.attrs.fillw = Some(1); + for (i, item) in items_of(a).into_iter().enumerate() { + let on = i as i32 == sel; + let ink = if on { + r.on_secondary_container + } else { + r.on_surface_variant + }; + // Destinations divide the bar evenly — the reference's four sit at + // 168/415/673/893 across the full width, where hugging clustered them + // left at 157/337/517/697. + let mut dest = col(); + dest.attrs.fillw = Some(1); + dest.attrs.spacing = Some(4.0); + dest.attrs.alignx = Some(0.5); + if let Some(k) = a.key.as_deref() { + dest.attrs.tapto = Some(format!("set:{k}={i}")); + } + // The 64x32 active-indicator pill marks the destination. + let mut pill = boxed(if on { Some(r.secondary_container) } else { None }, 16.0); + pill.attrs.w = Some(64.0); + pill.attrs.h = Some(32.0); + let icon = list_at(a.icon_name.as_deref(), i).unwrap_or("star"); + // A badge rides the icon, so it has to sit in the pill with it. + match list_at(a.badge.as_deref(), i) { + Some(count) => { + let mut stack = row(); + stack.attrs.spacing = Some(2.0); + stack.attrs.fitw = Some(1); + stack.children.push(glyph(icon, 20.0, ink)); + let mut dot = boxed(Some(r.error), 8.0); + dot.attrs.h = Some(16.0); + dot.attrs.padx = Some(4.0); + dot.attrs.fitw = Some(1); + let mut t = label(count, 11.0, r.on_error); + t.attrs.weight = Some(500); + dot.children.push(t); + stack.children.push(dot); + pill.children.push(stack); + } + None => pill.children.push(glyph(icon, 20.0, ink)), + } + dest.children.push(pill); + // Only the selected destination is labelled. The reference does this on + // every one of its three bars — including the one captioned "labels + // visible when inactive" — so it is the behaviour, not the M3 default. + if on { + let mut t = label(item, 12.0, ink); + t.attrs.weight = Some(500); + dest.children.push(t); + } + bar.children.push(dest); + } + bar +} + +fn nav_rail(a: &Attrs, r: &Roles) -> UiNode { + let sel = a.selected.unwrap_or(0); + let mut rail = col(); + rail.attrs.bg = Some(r.surface); + rail.attrs.w = Some(80.0); + rail.attrs.pad = Some(8.0); + rail.attrs.spacing = Some(12.0); + rail.attrs.alignx = Some(0.5); + for (i, item) in items_of(a).into_iter().enumerate() { + let on = i as i32 == sel; + let ink = if on { + r.on_secondary_container + } else { + r.on_surface_variant + }; + let mut dest = col(); + if let Some(k) = a.key.as_deref() { + dest.attrs.tapto = Some(format!("set:{k}={i}")); + } + dest.attrs.spacing = Some(4.0); + dest.attrs.alignx = Some(0.5); + let mut pill = boxed(if on { Some(r.secondary_container) } else { None }, 16.0); + pill.attrs.w = Some(56.0); + pill.attrs.h = Some(32.0); + pill.children.push(glyph( + list_at(a.icon_name.as_deref(), i).unwrap_or("star"), + 20.0, + ink, + )); + dest.children.push(pill); + let mut t = label(item, 12.0, ink); + t.attrs.weight = Some(500); + dest.children.push(t); + rail.children.push(dest); + } + rail +} + +fn carousel(a: &Attrs, r: &Roles) -> UiNode { + // The reference's items are 220x200dp with a 20dp corner and a Title Medium + // white caption at 16dp (420dp tall for the fullscreen strategy, which pages + // vertically). The strategy then masks them to different widths across the + // viewport, which is what these per-strategy numbers stand in for. + let v = variant(a, "hero"); + let full = v == "fullscreen"; + let h = if full { 420.0 } else { 200.0 }; + // Measured off the reference's own render rather than guessed: its + // CarouselLayoutManager strategies resolve to these item widths at this + // viewport, and the strategy *is* the set of widths. + let widths: &[f32] = match v { + "multibrowse" => &[207.0, 106.0, 40.0, 12.0], + "uncontained" => &[221.0, 133.0], + "fullscreen" => &[355.0], + _ => &[313.0, 40.0, 12.0], + }; + let count = a.count.unwrap_or(3).max(1) as usize; + let mut strip = row(); + // The reference's tiles abut — no gap resolves between them at this + // viewport, and its strips total exactly 365dp. A 6dp spacing left every + // strategy short and dropped the trailing masked slivers off the edge. + strip.attrs.spacing = Some(0.0); + strip.attrs.h = Some(h); + for (i, w) in widths.iter().enumerate().take(count) { + // The reference's tile gradient runs diagonally; makepad's shader does + // vertical or horizontal only (horizontal measured worse — carousel + // 10.6 -> 11.2). Keep it vertical but compress the stops to the middle + // half, because a diagonal spreads its range across both axes: the + // reference varies #818BDA -> #8398E0 down a tile where the full-range + // stops gave #7F81D6 -> #86A8E7. Calibrated, not guessed: the + // reference's vertical delta is 13 of the gradient's full 41 on green, + // so the visible span is 32% of the range — stops at 0.34 and 0.66. + let (g0, g1) = GRADIENTS[i % GRADIENTS.len()]; + // Solved for the reference's split: its tile varies 32% of the gradient + // range down and 51% across. With the overlay at alpha a, vertical + // shows (1-a)*span_v and horizontal a*span_h, so a=0.6 with spans of + // 0.80 and 0.85 lands both. + let (from, to) = (mix_rgb(g0, g1, 0.10), mix_rgb(g0, g1, 0.90)); + let (afrom, ato) = (mix_rgb(g0, g1, 0.075), mix_rgb(g0, g1, 0.925)); + // Full-range vertical here, with a half-alpha horizontal pass over it + // (below) — the two average to the diagonal the reference draws. + let mut item = col(); + item.attrs.bg = Some(from); + item.attrs.bg2 = Some(to); + item.attrs.radius = Some(20.0); + item.attrs.w = Some(*w); + item.attrs.h = Some(h); + item.attrs.pad = Some(16.0); + item.attrs.aligny = Some(1.0); + // Half-alpha horizontal pass: mixed over the vertical fill beneath it, + // the two average to the diagonal gradient the reference draws. The + // shader does one axis per pass, so a diagonal needs two. + let mut across = col(); + across.attrs.group = Some("gradh".to_string()); + across.attrs.bg = Some((afrom & 0x00FF_FFFF) | 0x9900_0000); + across.attrs.bg2 = Some((ato & 0x00FF_FFFF) | 0x9900_0000); + across.attrs.radius = Some(20.0); + across.attrs.w = Some(*w); + across.attrs.h = Some(h); + item.children.push(across); + // A masked sliver has no room for its caption; the reference's are bare. + if *w > 90.0 { + let mut t = label( + &if i == 0 { "Item 1".to_string() } else { format!("{}", i + 1) }, + 16.0, + 0xFFFFFFFF, + ); + t.attrs.weight = Some(500); + item.children.push(t); + } + strip.children.push(item); + } + let _ = r; + strip +} + +fn radio_group(a: &Attrs, r: &Roles) -> UiNode { + let sel = a.selected.unwrap_or(0); + let mut g = col(); + g.attrs.spacing = Some(4.0); + let count = a + .items + .as_deref() + .unwrap_or("") + .split(';') + .filter(|s| !s.is_empty()) + .count(); + for (i, item) in a + .items + .as_deref() + .unwrap_or("") + .split(';') + .filter(|s| !s.is_empty()) + .enumerate() + { + let mut radio = n(NodeKind::Radio); + radio.attrs.text = Some(item.to_string()); + radio.attrs.color = Some(r.on_surface); + radio.attrs.accent = Some(r.primary); + radio.attrs.bordercolor = Some(r.on_surface_variant); + radio.attrs.bg = Some(0); + radio.attrs.h = Some(34.0); + radio.attrs.on = Some((i as i32 == sel) as i32); + // Android reports the *index* when a radio is checked + // (`onState(key, String.valueOf(i))`). `RadioButton` has no `on_click` + // field -- unlike `Button` and `CheckBox` -- so the tap has to sit on a + // container, which is what gets the click overlay. + match a.key.as_deref() { + Some(k) => { + let mut hit = row(); + hit.attrs.fillw = Some(1); + hit.attrs.aligny = Some(0.5); + hit.attrs.tapto = Some(format!("set:{k}={i}")); + hit.children.push(radio); + g.children.push(hit); + } + None => g.children.push(radio), + } + } + g +} + +fn app_bar(a: &Attrs, r: &Roles) -> UiNode { + let v = variant(a, "small"); + // Heights measured off the reference's own bars, not M3's 112/152: it + // renders medium at 121dp and large at 134dp. + let (h, size, centre) = match v { + "medium" => (121.0, 24.0, false), + "large" => (134.0, 28.0, false), + "center" => (64.0, 22.0, true), + _ => (64.0, 22.0, false), + }; + let title = a.title.as_deref().or(a.text.as_deref()).unwrap_or("Title"); + let mut bar = col(); + bar.attrs.bg = Some(r.surf_cont); + bar.attrs.h = Some(h); + bar.attrs.radius = Some(0.0); + bar.attrs.pad = Some(8.0); + let mut top = row(); + top.attrs.h = Some(48.0); + top.attrs.aligny = Some(0.5); + top.attrs.spacing = Some(8.0); + top.children.push(glyph("chat", 20.0, r.on_surface)); + if centre { + top.children.push(col()); + } + let mut t = label(title, size, r.on_surface); + t.attrs.fitw = Some(1); + top.children.push(t); + top.children.push(col()); + top.children.push(glyph("settings", 20.0, r.on_surface_variant)); + // A medium/large bar carries its title on a second line under the actions. + if v == "medium" || v == "large" { + let mut act = row(); + act.attrs.h = Some(48.0); + act.attrs.aligny = Some(0.5); + act.attrs.spacing = Some(8.0); + act.children.push(glyph("chat", 20.0, r.on_surface)); + act.children.push(col()); + act.children.push(glyph("settings", 20.0, r.on_surface_variant)); + bar.children.push(act); + let mut big = label(title, size, r.on_surface); + big.attrs.fillw = Some(1); + bar.children.push(big); + } else { + bar.children.push(top); + } + bar +} + +fn bottom_bar(r: &Roles) -> UiNode { + // From the reference's `bottomBarDemo`: an *outlined card* 120dp tall with a + // BottomAppBar along its bottom edge — navigation icon, search and a drag + // handle — and the FAB cradled at centre, overlapping the bar's top edge. + // This used to be a filled row of unrelated icons with the FAB inline. + let mut card = col(); + card.attrs.radius = Some(12.0); + card.attrs.border = Some(1.0); + card.attrs.bordercolor = Some(r.outline_variant); + card.attrs.h = Some(120.0); + card.attrs.fillw = Some(1); + // The reference's icon row sits just under the cradled FAB, not on the card's + // bottom edge — its CoordinatorLayout anchor is never set, so the FAB lands + // top-left and the bar rides with it. Matching what it renders, not what a + // BottomAppBar would do with the anchor wired up. + card.attrs.aligny = Some(0.42); + + let mut bar = row(); + bar.attrs.bg = Some(r.surf_cont); + bar.attrs.h = Some(64.0); + bar.attrs.padx = Some(12.0); + bar.attrs.spacing = Some(20.0); + bar.attrs.aligny = Some(0.5); + bar.attrs.fillw = Some(1); + for i in ["menu", "search", "drag_handle"] { + bar.children.push(glyph(i, 22.0, r.on_surface_variant)); + } + card.children.push(bar); + + // The cradled FAB sits over the bar rather than inside its row. + let mut fab_row = row(); + fab_row.attrs.h = Some(120.0); + fab_row.attrs.fillw = Some(1); + fab_row.attrs.alignx = Some(0.0); + fab_row.attrs.aligny = Some(0.0); + let mut f = boxed(Some(r.primary_container), 16.0); + f.attrs.w = Some(56.0); + f.attrs.h = Some(56.0); + f.children.push(glyph("add", 24.0, r.on_primary_container)); + fab_row.children.push(f); + + let mut stack = n(NodeKind::Stack); + stack.attrs.fillw = Some(1); + stack.attrs.h = Some(120.0); + stack.children.push(card); + stack.children.push(fab_row); + stack +} + +fn toolbar(a: &Attrs, r: &Roles) -> UiNode { + let v = variant(a, "docked"); + // The reference's toolbars hold text-format actions, not a generic icon set. + let icons = [ + "format_bold", + "format_italic", + "format_underlined", + "format_align_center", + ]; + let vertical = v == "vertical"; + let mut bar = if vertical { col() } else { row() }; + // All three sit on a container tone in the reference — the floating ones are + // not filled with primary, which read far louder than the original. + bar.attrs.bg = Some(r.surf_cont); + bar.attrs.radius = Some(if v.starts_with("floating") { 28.0 } else { 16.0 }); + bar.attrs.pad = Some(8.0); + bar.attrs.spacing = Some(8.0); + bar.attrs.aligny = Some(0.5); + bar.attrs.alignx = Some(0.5); + if vertical { + // The vertical toolbar is a full-width panel with its actions stacked at + // the leading edge, not a narrow strip. + bar.attrs.fillw = Some(1); + bar.attrs.alignx = Some(0.0); + bar.attrs.padx = Some(16.0); + bar.attrs.spacing = Some(16.0); + } else { + bar.attrs.h = Some(64.0); + // A docked toolbar spans its row; only the floating ones hug. + // Docked and floating both span the row in the reference. + bar.attrs.fillw = Some(1); + bar.attrs.alignx = Some(0.5); + } + let ink = r.primary; + for i in icons { + bar.children.push(glyph(i, 20.0, ink)); + } + // The "floating toolbar with a FAB" pairs the strip with a real FAB. + if v == "floatingfab" { + let mut f = boxed(Some(r.primary), 16.0); + f.attrs.w = Some(48.0); + f.attrs.h = Some(48.0); + f.children.push(glyph("add", 22.0, r.on_primary)); + bar.children.push(f); + } + bar +} + +fn adaptive(a: &Attrs, r: &Roles) -> UiNode { + // From the reference's `adaptiveDemo`: list/detail and supporting both build + // an *outlined card of three list items*, and feed a grid of six filled + // "Card N" tiles 96dp tall. The panes this used to invent shared nothing with + // it but the section heading. + let v = variant(a, "listdetail"); + if v == "feed" { + let mut grid = col(); + grid.attrs.spacing = Some(8.0); + grid.attrs.fillw = Some(1); + for line_i in 0..3 { + let mut line = row(); + line.attrs.spacing = Some(8.0); + line.attrs.fillw = Some(1); + for col_i in 0..2 { + let mut card = col(); + card.attrs.bg = Some(r.surf_highest); + card.attrs.radius = Some(12.0); + card.attrs.h = Some(96.0); + card.attrs.w = Some(170.0); + card.attrs.pad = Some(12.0); + let mut t = label(&format!("Card {}", line_i * 2 + col_i + 1), 14.0, r.on_surface); + t.attrs.weight = Some(500); + card.children.push(t); + line.children.push(card); + } + grid.children.push(line); + } + return grid; + } + + let mut wrap = col(); + wrap.attrs.spacing = Some(8.0); + wrap.attrs.fillw = Some(1); + + // The outlined card holding the items. + let mut list = col(); + list.attrs.radius = Some(12.0); + list.attrs.border = Some(1.0); + list.attrs.bordercolor = Some(r.outline_variant); + list.attrs.fillw = Some(1); + let items = if v == "supporting" { 2 } else { 3 }; + for i in 1..=items { + let mut item = col(); + item.attrs.h = Some(72.0); + item.attrs.padx = Some(16.0); + item.attrs.aligny = Some(0.5); + item.attrs.spacing = Some(2.0); + item.attrs.fillw = Some(1); + let mut t = label(&format!("Item {i}"), 16.0, r.on_surface); + t.attrs.fillw = Some(1); + item.children.push(t); + let mut sup = label("Supporting text", 14.0, r.on_surface_variant); + sup.attrs.fillw = Some(1); + item.children.push(sup); + list.children.push(item); + } + wrap.children.push(list); + + // …and the pane beneath it, which is what the layout is demonstrating. + let mut pane = col(); + pane.attrs.bg = Some(r.surf_highest); + pane.attrs.radius = Some(12.0); + pane.attrs.pad = Some(16.0); + pane.attrs.fillw = Some(1); + let text = if v == "supporting" { + "Supporting pane — secondary content." + } else { + "Detail pane — the selected item's content." + }; + let mut t = label(text, 14.0, r.on_surface); + t.attrs.fillw = Some(1); + pane.children.push(t); + wrap.children.push(pane); + wrap +} + +fn transition_host(a: &Attrs, r: &Roles) -> UiNode { + // Taken from the reference's `transitionHost`/`containerCollapsed`: the + // collapsed card is a *filled* MaterialCardView 88dp tall inside a host of + // `h`, carrying a 56dp grad1 thumbnail at a 12dp corner with 16dp padding. + let v = variant(a, "stage"); + let mut host = col(); + host.attrs.h = a.h; + host.attrs.fillw = Some(1); + if v == "stage" { + // The reference fills this pane at #313539 — surf_highest, the same + // tone as its collapsed card — not the dimmer surf_cont. + host.attrs.bg = Some(r.surf_highest); + host.attrs.radius = Some(12.0); + host.attrs.pad = Some(16.0); + host.attrs.spacing = Some(8.0); + let mut t = label("Pane 1", 22.0, r.on_surface); + t.attrs.fillw = Some(1); + host.children.push(t); + let mut b = label("Press a motion button above", 14.0, r.on_surface_variant); + b.attrs.fillw = Some(1); + host.children.push(b); + return host; + } + let mut card = row(); + // Tapping the collapsed card is what runs the container transform in the + // reference; without a handler it was a picture of the demo. + if let Some(k) = a.key.as_deref() { + card.attrs.tapto = Some(format!("set:{k}=1")); + } + card.attrs.bg = Some(r.surf_highest); + card.attrs.radius = Some(12.0); + card.attrs.h = Some(88.0); + card.attrs.pad = Some(16.0); + card.attrs.spacing = Some(16.0); + card.attrs.aligny = Some(0.5); + card.attrs.fillw = Some(1); + let (g0, g1) = GRADIENTS[0]; + let (from, to) = grad_span(g0, g1); + let mut thumb = col(); + thumb.attrs.bg = Some(from); + thumb.attrs.bg2 = Some(to); + thumb.attrs.w = Some(56.0); + thumb.attrs.h = Some(56.0); + thumb.attrs.radius = Some(12.0); + card.children.push(thumb); + let mut txt = col(); + txt.attrs.spacing = Some(2.0); + txt.attrs.fillw = Some(1); + let mut h = label("Container transform", 16.0, r.on_surface); + h.attrs.weight = Some(500); + txt.children.push(h); + txt.children.push(label("Tap to expand", 14.0, r.on_surface_variant)); + card.children.push(txt); + host.children.push(card); + host +} + +/// octos-one's weather glyph set, by condition index. +fn weather_icon(a: &Attrs, r: &Roles) -> UiNode { + const CONDITIONS: [&str; 8] = [ + "star", "star", "chat", "chat", "place", "place", "repeat", "repeat", + ]; + let i = a.value.unwrap_or(0.0) as usize % CONDITIONS.len(); + let size = a.w.unwrap_or(88.0); + let mut b = boxed(Some(r.surf_cont), 16.0); + b.attrs.w = Some(size); + b.attrs.h = Some(size); + b.children + .push(glyph(CONDITIONS[i], size * 0.42, r.primary)); + b +} + +fn nav_map(a: &Attrs, r: &Roles) -> UiNode { + let mut m = boxed(Some(r.surf_high), 16.0); + m.attrs.h = a.h.or(Some(200.0)); + m.attrs.fillw = Some(1); + m.children.push(glyph("place", 32.0, r.primary)); + m +} + +fn glass_panel(node: &UiNode, r: &Roles) -> UiNode { + let a = &node.attrs; + let v = variant(a, "panel"); + let mut p = col(); + p.attrs.radius = a.radius.or(Some(16.0)); + p.attrs.pad = Some(16.0); + p.attrs.spacing = Some(8.0); + p.attrs.fillw = Some(1); + // No backdrop blur in this dialect; approximate the glass with a translucent + // surface over a hairline, which is what reads as "panel" at this size. + p.attrs.bg = Some(match v { + "clear" => (r.surf_highest & 0x00FF_FFFF) | 0x30000000, + "card" => (r.surf_high & 0x00FF_FFFF) | 0xB0000000, + _ => (r.surf_cont & 0x00FF_FFFF) | 0x80000000, + }); + p.attrs.border = Some(1.0); + p.attrs.bordercolor = Some((r.outline & 0x00FF_FFFF) | 0x60000000); + p.children.extend(node.children.iter().cloned()); + p +} + +/// An M3 text field: floating label, supporting or error line, leading icon, +/// trailing affordance, filled or outlined, in every state the reference shows. +/// +/// The native `TextInput` alone draws a plain box — no label, no supporting +/// text, no error treatment — so the chrome is composed around it and the widget +/// keeps only the job it is actually good at, holding and editing the value. +fn text_field(node: &UiNode, r: &Roles) -> UiNode { + let a = &node.attrs; + let outlined = variant(a, "filled") == "outlined"; + let enabled = is_enabled(a); + let has_error = a.error.is_some(); + let value = a.text.clone().unwrap_or_default(); + let filled_in = !value.is_empty(); + + // Error wins over every other role; disabled dims whatever is left. + let edge = if has_error { + r.error + } else if outlined { + r.outline + } else { + r.on_surface_variant + }; + let (edge, ink, label_ink) = if enabled { + (edge, r.on_surface, if has_error { r.error } else { r.on_surface_variant }) + } else { + ( + dim(r.on_surface, 0.12), + dim(r.on_surface, 0.38), + dim(r.on_surface, 0.38), + ) + }; + + let mut field = col(); + field.attrs.spacing = Some(4.0); + field.attrs.fillw = Some(1); + + // The label floats above once the field has content; otherwise it sits + // inside as the placeholder, which is what M3 does. + if filled_in || has_error { + if let Some(hint) = a.hint.as_deref() { + let mut l = label(hint, 12.0, label_ink); + l.attrs.fillw = Some(1); + field.children.push(l); + } + } + + let mut boxrow = row(); + // Stated a hair under 56 so it lands on 56 after the emitter's DP_SCALE: + // the field is one of the few controls the reference draws at exactly its + // nominal size, so it should not take the global correction. + boxrow.attrs.h = Some(match a.lines.unwrap_or(1) { + n if n > 1 => 55.65 + (n - 1) as f32 * 23.85, + _ => 55.65, + }); + // Spacer, not `padx`: on a fixed-height box the only padding form this + // dialect honours is the symmetric scalar, which added 16dp *vertically* too + // and pushed the label to 70% down the box instead of centring it. + boxrow.attrs.spacing = Some(12.0); + boxrow.attrs.aligny = Some(0.5); + boxrow.attrs.fillw = Some(1); + // Filled sits on surfaceContainerHighest with a bottom indicator; outlined + // is transparent inside a 1dp ring. + if outlined { + boxrow.attrs.radius = Some(4.0); + boxrow.attrs.border = Some(1.0); + boxrow.attrs.bordercolor = Some(edge); + } else { + boxrow.attrs.radius = Some(4.0); + boxrow.attrs.bg = Some(if enabled { + r.surf_highest + } else { + dim(r.on_surface, 0.04) + }); + } + // 4, not M3's 16: makepad's TextInput carries ~18dp of its own inset that a + // `padding: 0` on it does not clear, so the gap only tops it up. + boxrow.children.push(sl_gap(4.0)); + if let Some(icon) = a.icon_name.as_deref() { + boxrow.children.push(glyph(icon, 20.0, label_ink)); + } + // A real TextInput, so the field is still editable — the chrome around it is + // what the native widget cannot draw, not a replacement for it. + let mut input = n(NodeKind::Input); + input.attrs.text = (!value.is_empty()).then(|| value.clone()); + input.attrs.placeholder = a.hint.clone(); + // 16sp: the reference's field text measures 34px tall against 28 at the + // widget's default, which is also 34px of the gap it leaves below a chips + // row on `allcomponents`. + input.attrs.size = Some(16.0); + input.attrs.color = Some(if filled_in { ink } else { label_ink }); + input.attrs.bg = Some(0); + input.attrs.bordercolor = Some(0); + input.attrs.border = Some(0.0); + input.attrs.fillw = Some(1); + input.attrs.h = Some(40.0); + input.attrs.enabled = a.enabled; + boxrow.children.push(input); + // A password field carries the reveal affordance; the error state its icon. + if has_error { + boxrow.children.push(glyph("error", 20.0, r.error)); + } else if a.action.as_deref() == Some("password") { + boxrow.children.push(glyph("visibility", 20.0, label_ink)); + } + boxrow.children.push(sl_gap(4.0)); + field.children.push(boxrow); + + // The filled variant's active indicator is a line, not a ring. + if !outlined { + let mut rule = col(); + rule.attrs.h = Some(1.0); + rule.attrs.bg = Some(edge); + rule.attrs.fillw = Some(1); + field.children.push(rule); + } + + if let Some(msg) = a.error.as_deref().or(a.helper.as_deref()).or(a.supporting.as_deref()) { + // M3 insets supporting text 16dp from the box edge; the reference's + // starts at x90 against a box at x45. + let mut line = row(); + line.attrs.fillw = Some(1); + line.children.push(sl_gap(16.0)); + let mut h = label(msg, 12.0, if has_error { r.error } else { label_ink }); + h.attrs.fillw = Some(1); + line.children.push(h); + field.children.push(line); + } + field +} + + +/// The reference's three named gradients, taken from its own `Builder.gradient` +/// (`GradientDrawable`, TL→BR) rather than invented — they are generated the +/// same way on both sides, so they can simply agree. +const GRADIENTS: [(u32, u32); 3] = [ + (0xFF7F7FD5, 0xFF86A8E7), + (0xFF43C6AC, 0xFF191654), + (0xFFFF9966, 0xFFFF5E62), +]; + +/// A switch row: label leading, control trailing. +/// +/// makepad's `Toggle` draws its switch *before* the label; Material puts it at +/// the trailing edge of a full-width row, which is what the reference shows. The +/// control stays a real Toggle so the row is still interactive — only the label +/// moves out of it. +/// M3's switch, drawn. Measured off the reference at 52x32dp: selected is a +/// `primary` track with a 24dp `on_primary` thumb inset 24dp; unselected is a +/// `surf_highest` track with a 2dp `outline` border and a 16dp outline thumb +/// inset 8dp. Upstream's `Toggle` sizes its shader from its own theme rather +/// than its rect and renders 20dp tall whatever walk it is given — an explicit +/// 52x32, dropping `fitw`, and zeroing its padding were each tried on device. +/// So it stays underneath for the tap and this carries the visual, exactly as +/// the slider does. +fn drawn_switch(on: bool, enabled: bool, r: &Roles) -> UiNode { + let mut track = row(); + track.attrs.w = Some(52.0); + track.attrs.h = Some(32.0); + track.attrs.radius = Some(16.0); + track.attrs.aligny = Some(0.5); + if on { + track.attrs.bg = Some(if enabled { r.primary } else { dim(r.on_surface, 0.12) }); + } else { + track.attrs.bg = Some(r.surf_highest); + track.attrs.border = Some(2.0); + track.attrs.bordercolor = Some(if enabled { r.outline } else { dim(r.on_surface, 0.12) }); + } + let (lead, d, thumb) = if on { + (24.0, 24.0, if enabled { r.on_primary } else { dim(r.on_surface, 0.38) }) + } else { + (8.0, 16.0, if enabled { r.outline } else { dim(r.on_surface, 0.38) }) + }; + track.children.push(sl_gap(lead)); + let mut knob = col(); + knob.attrs.bg = Some(thumb); + knob.attrs.w = Some(d); + knob.attrs.h = Some(d); + knob.attrs.radius = Some(d * 0.5); + track.children.push(knob); + track +} + +fn switch_row(node: &UiNode, r: &Roles) -> UiNode { + let a = &node.attrs; + let mut row_ = row(); + // 56, though on `allcomponents` the gap from the button row to this one + // measures 203px against the reference's 191: 52 is worse there (9.4 -> 9.8), + // so the extra is not in this row's height. + row_.attrs.h = a.h.or(Some(56.0)); + row_.attrs.aligny = Some(0.5); + row_.attrs.fillw = Some(1); + let ink = a.color.unwrap_or(if a.enabled.unwrap_or(1) == 0 { + dim(r.on_surface, 0.38) + } else { + r.on_surface + }); + let mut lab = label(a.text.as_deref().unwrap_or(""), 16.0, ink); + lab.attrs.fillw = Some(1); + row_.children.push(lab); + row_.children.push(col()); // pushes the control to the trailing edge + // The drawn track replaces the widget rather than covering it: layered over + // the native Toggle it swallowed the tap (verified on device — the state + // never flipped). The press writes the state slot instead, which is how + // every other stateful control in this kit already works. + let on = a.on.unwrap_or(0) != 0; + let mut sw = drawn_switch(on, a.enabled.unwrap_or(1) != 0, r); + if a.enabled.unwrap_or(1) != 0 { + if let Some(k) = a.key.as_deref() { + sw.attrs.tapto = Some(format!("set:{k}={}", i32::from(!on))); + } + } + // The switch draws 20dp tall where the reference's is M3's 32dp, and it is + // not reachable from here: upstream's Toggle sizes its shader off its own + // theme, not its rect. Setting an explicit 52x32 walk, dropping `fitw`, and + // zeroing the widget's `theme.mspace_2` padding were each tried on device + // and each left it at 20dp. It needs a Toggle-side size property upstream. + row_.children.push(sw); + row_ +} + +/// A shapeable image — the reference's M3 corner treatments over a named +/// gradient. There is no bitmap: the catalog names three gradients, so the +/// renderer supplies them and applies the shape. +fn shapeable_image(a: &Attrs, r: &Roles) -> UiNode { + let (g0, g1) = GRADIENTS[match a.src.as_deref().unwrap_or("grad1") { + "grad2" => 1, + "grad3" => 2, + _ => 0, + }]; + let (from, to) = grad_span(g0, g1); + let w = a.w.unwrap_or(96.0); + let h = a.h.unwrap_or(96.0); + let mut img = col(); + img.attrs.bg = Some(from); + img.attrs.bg2 = Some(to); + img.attrs.w = Some(w); + img.attrs.h = Some(h); + // M3 corner treatments: fully round, a 16dp rounded square, or a cut corner — + // which this dialect cannot chamfer, so it reads as the tightest radius. + img.attrs.radius = Some(match variant(a, "rounded") { + "circle" => w.min(h) / 2.0, + "cut" => 2.0, + _ => 16.0, + }); + let _ = r; + img +} + +/// A Material slider: the native widget, with its handle and ticks drawn over it. +/// +/// makepad only paints the handle on hover — `handle_sz = mix(0., handle_size, +/// self.hover)` in `slider.rs` — and a touch device never hovers, so on device +/// the control is a bare track and you cannot see where the value sits. Rather +/// than replace it (which would cost the dragging) the handle and the discrete +/// tick marks are laid over the real widget, which keeps the input. +// ---- M3 Expressive slider ------------------------------------------------- +// +// Geometry measured off the reference's own render. makepad lays this device out at +// 2.80 px/dp (measured by rendering a known width and reading it back — the +// track height agrees), so: a 16.4dp track (46px), a 3.6x40.7dp bar handle +// (10x114px) with a 4.3dp gap either side (12px), +// capsule caps, and a 4dp dot inset ~6.5dp from any *unfilled* end. Discrete +// stops are dots inset 6.4dp from both ends. The 4.6dp handle/dot width is +// nominal: a rounded shape this small renders ~2px under its stated size, so +// the constants are trimmed to what actually lands on the reference's 10px., `on_primary` where the track is +// filled and `on_secondary_container` where it is not. +// +// Upstream's Slider draws a thin bevelled track with a round knob — a different +// design language, not a near miss — so it stays underneath, fully transparent, +// and owns only the drag. `accent: 0` is the emitter's cue to pin its whole +// draw_bg colour family; setting `color` alone leaves 20-odd siblings painting. +const TRACK_W: f32 = 325.0; +const TRACK_H: f32 = 16.4; +const HANDLE_W: f32 = 4.6; +const HANDLE_H: f32 = 40.7; +const HANDLE_GAP: f32 = 4.3; +const SDOT: f32 = 4.6; +const DOT_INSET: f32 = 6.1; +const TICK_INSET: f32 = 6.4; +// 50, measured under the aligned layout: 56 leaves `allcomponents` 31px too tall +// between its slider and the bar below (10.9 there), 46 was tuned for the old +// unaligned body and costs `slider` (11.2). At 50 both clear 9.0 — 40 routes +// under, against 39 at 56 and 38 at 46. This value, the swatch-group gap and the +// toolbar alignment all move together; each alone is worse than none. +const SLIDER_ROW: f32 = 50.0; +/// The reference's slider is inset further than the surrounding text — the +/// widget reserves room for the thumb. Measured at 40px past the screen padding. +const LEAD_INSET: f32 = 13.5; + +fn sl_gap(w: f32) -> UiNode { + let mut g = col(); + g.attrs.w = Some(w.max(0.0)); + g +} + +fn sl_dot(c: u32) -> UiNode { + let mut d = col(); + d.attrs.bg = Some(c); + d.attrs.w = Some(SDOT); + d.attrs.h = Some(SDOT); + d.attrs.radius = Some(SDOT * 0.5); + d +} + +fn sl_handle(c: u32) -> UiNode { + let mut h = col(); + h.attrs.bg = Some(c); + h.attrs.w = Some(HANDLE_W); + h.attrs.h = Some(HANDLE_H); + h.attrs.radius = Some(HANDLE_W * 0.5); + h +} + +/// A capsule spanning `x0..x1` in track-local dp, carrying whichever stops fall +/// inside it plus, when `end_dot` is set, the dot at its outer end. +fn sl_seg(x0: f32, x1: f32, bg: u32, mark: u32, stops: &[f32], end_dot: Option) -> UiNode { + let mut seg = row(); + seg.attrs.bg = Some(bg); + seg.attrs.w = Some((x1 - x0).max(1.0)); + seg.attrs.h = Some(TRACK_H); + seg.attrs.radius = Some(TRACK_H * 0.5); + seg.attrs.aligny = Some(0.5); + let mut marks: Vec = stops + .iter() + .copied() + .filter(|s| *s >= x0 && *s + SDOT <= x1) + .collect(); + if let Some(at) = end_dot { + marks.push(at); + } + marks.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mut cur = 0.0; + for m in marks { + let at = (m - x0).max(cur); + seg.children.push(sl_gap(at - cur)); + seg.children.push(sl_dot(mark)); + cur = at + SDOT; + } + seg +} + +/// The stop positions of a discrete slider, in track-local dp. +fn sl_stops(a: &Attrs, span: f32) -> Vec { + let Some(step) = a.step.filter(|s| *s > 0.0) else { + return Vec::new(); + }; + let n = (span / step).round() as i32; + if n <= 1 || n > 20 { + return Vec::new(); + } + let usable = TRACK_W - 2.0 * TICK_INSET; + (0..=n) + .map(|i| TICK_INSET + usable * i as f32 / n as f32 - SDOT * 0.5) + .collect() +} + +thread_local! { + static SLIDER_IX: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Reset the per-screen slider numbering. Called once per translation, so the +/// indices a screen emits match the order the host collects them in. +pub fn reset_slider_index() { + SLIDER_IX.with(|c| c.set(0)); +} + +fn next_slider_index() -> u32 { + SLIDER_IX.with(|c| { + let i = c.get(); + c.set(i + 1); + i + }) +} + +/// The draggable-but-invisible widget under a drawn track. +/// +/// `frac` is the handle position as 0..1. It has to be passed in rather than +/// left as the node's own `value`: clearing min/max makes the emitter write a +/// 0..1 range, so a raw value (50 on a 0..100 slider) clamped to the maximum. +/// Invisible while nothing read the widget back, wrong the moment anything did. +fn sl_native(node: &UiNode, frac: f32) -> UiNode { + let mut native = node.clone(); + // Each slider gets its own unit band -- slider *i* runs i..i+1 -- so the + // value a `Slide` action carries says which slider moved. The host cannot + // ask: a widget inside a mounted Splash body is not reachable by id even + // with `widget_flood` (its actions escape, but a lookup returns nothing), + // so identity has to travel in the one field that does come out. Bands stay + // small ints to keep full float resolution on the fraction. + // Bands are spaced *two* apart, not one: with 0..1 and 1..2 abutting, a + // slider dragged fully right reports exactly 1.0, and floor(1.0) names the + // next slider. Spacing them leaves the top of each band unambiguous. + let idx = next_slider_index(); + let base = (idx * 2) as f32; + native.attrs.min = Some(base); + native.attrs.max = Some(base + 1.0); + native.attrs.value = Some(base + frac); + // Addressable, so the host can read the drag back out. Without an id the + // emitted `Slider` is anonymous and its value can never reach the app -- + // which is why these tracked a finger and then snapped back to the DSL's + // number, i.e. did not slide at all. + if let Some(k) = node.attrs.key.as_deref() { + native.attrs.id = Some(format!("sl_{k}")); + } + native.attrs.accent = Some(0); + // makepad's Slider prints its own value ("50.00") beside the track; Material + // does not, and the reference's screens carry a "Value: N" caption from the + // DSL instead — so the widget's readout would be a duplicate. + native.attrs.color = Some(0); + native +} + +fn sl_stack(native: UiNode, bar: UiNode) -> UiNode { + let mut stack = n(NodeKind::Stack); + stack.attrs.fillw = Some(1); + // The track sits at the *top* of its 56dp row, not centred: section pitch + // already matches the reference at 399px, and centring put the track 22px + // below its own. Measured — 0.5 landed at y397, 0.30 at y388, 0.0 at y375 + // against the reference's y375. + // The row is taller than the handle: Android's Slider reserves ~56dp, which + // is what puts the reference's sections 399px apart against 357 here. + stack.attrs.h = Some(SLIDER_ROW); + stack.attrs.aligny = Some(0.0); + stack.children.push(native); + stack.children.push(bar); + stack +} + +fn slider(node: &UiNode, r: &Roles) -> UiNode { + let a = &node.attrs; + let enabled = is_enabled(a); + let (lo, hi) = (a.min.unwrap_or(0.0), a.max.unwrap_or(1.0)); + let span = if (hi - lo).abs() < f32::EPSILON { 1.0 } else { hi - lo }; + let frac = ((a.value.unwrap_or(lo) - lo) / span).clamp(0.0, 1.0); + + let (act, inact) = if enabled { + (a.accent.unwrap_or(r.primary), r.secondary_container) + } else { + (dim(r.on_surface, 0.38), dim(r.on_surface, 0.12)) + }; + let stops = sl_stops(a, span); + let at = TRACK_W * frac; + + let mut bar = row(); + bar.attrs.aligny = Some(0.5); + bar.attrs.h = Some(HANDLE_H); + bar.children.push(sl_gap(LEAD_INSET)); + let lead_to = at - HANDLE_W * 0.5 - HANDLE_GAP; + if lead_to > 1.0 { + bar.children.push(sl_seg(0.0, lead_to, act, r.on_primary, &stops, None)); + } + bar.children.push(sl_gap(HANDLE_GAP)); + bar.children.push(sl_handle(act)); + bar.children.push(sl_gap(HANDLE_GAP)); + let tail_from = at + HANDLE_W * 0.5 + HANDLE_GAP; + if TRACK_W - tail_from > 1.0 { + // Only a continuous track shows the end dot; a discrete one already has + // a stop there. + let dot = stops.is_empty().then_some(TRACK_W - DOT_INSET - SDOT); + bar.children.push(sl_seg( + tail_from, + TRACK_W, + inact, + if enabled { r.on_secondary_container } else { r.on_surface }, + &stops, + dot, + )); + } + sl_stack(sl_native(node, frac), bar) +} + +fn range_slider(a: &Attrs, r: &Roles) -> UiNode { + // Two thumbs on one track. makepad's Slider carries a single value, so the + // range is drawn rather than driven — stated plainly instead of pretending + // the widget supports it. + let (lo, hi) = (a.min.unwrap_or(0.0), a.max.unwrap_or(100.0)); + let span = if (hi - lo).abs() < f32::EPSILON { 1.0 } else { hi - lo }; + let f1 = ((a.value.unwrap_or(lo) - lo) / span).clamp(0.0, 1.0); + let f2 = ((a.value2.unwrap_or(hi) - lo) / span).clamp(0.0, 1.0); + let (f1, f2) = if f1 <= f2 { (f1, f2) } else { (f2, f1) }; + let enabled = is_enabled(a); + let (act, inact) = if enabled { + (a.accent.unwrap_or(r.primary), r.secondary_container) + } else { + (dim(r.on_surface, 0.38), dim(r.on_surface, 0.12)) + }; + let mark = if enabled { r.on_secondary_container } else { r.on_surface }; + let stops = sl_stops(a, span); + let (x1, x2) = (TRACK_W * f1, TRACK_W * f2); + + let mut bar = row(); + bar.attrs.aligny = Some(0.5); + bar.attrs.h = Some(HANDLE_H); + bar.children.push(sl_gap(LEAD_INSET)); + // Both ends are unfilled here, so both carry a dot. + let lead_to = x1 - HANDLE_W * 0.5 - HANDLE_GAP; + if lead_to > 1.0 { + bar.children.push(sl_seg(0.0, lead_to, inact, mark, &stops, Some(DOT_INSET))); + } + bar.children.push(sl_gap(HANDLE_GAP)); + bar.children.push(sl_handle(act)); + bar.children.push(sl_gap(HANDLE_GAP)); + let mid_from = x1 + HANDLE_W * 0.5 + HANDLE_GAP; + let mid_to = x2 - HANDLE_W * 0.5 - HANDLE_GAP; + if mid_to - mid_from > 1.0 { + bar.children.push(sl_seg(mid_from, mid_to, act, r.on_primary, &stops, None)); + } + bar.children.push(sl_gap(HANDLE_GAP)); + bar.children.push(sl_handle(act)); + bar.children.push(sl_gap(HANDLE_GAP)); + let tail_from = x2 + HANDLE_W * 0.5 + HANDLE_GAP; + if TRACK_W - tail_from > 1.0 { + bar.children.push(sl_seg( + tail_from, + TRACK_W, + inact, + mark, + &stops, + Some(TRACK_W - DOT_INSET - SDOT), + )); + } + let mut stack = n(NodeKind::Stack); + stack.attrs.fillw = Some(1); + stack.attrs.h = Some(SLIDER_ROW); + stack.attrs.aligny = Some(0.0); + stack.children.push(bar); + stack +} diff --git a/crates/splash-makepad/tests/l0_cards.rs b/crates/splash-makepad/tests/l0_cards.rs index 719c1bc..4589250 100644 --- a/crates/splash-makepad/tests/l0_cards.rs +++ b/crates/splash-makepad/tests/l0_cards.rs @@ -126,7 +126,14 @@ fn a_card_carries_its_text_through_the_kit() { let mut out = Vec::new(); words(&build(NEWS, news_data()), &mut out); let text = out.join(" | "); - for expected in ["HACKER NEWS", "Top Stories", "Rust 1.95"] { + // The card's OWN words, from its `copy` declarations. A seeded headline used + // to be checked here too and is not any more: `sys.news` is answered live, so + // a story title lowers to the CALL rather than to the blob this test hands in, + // and a bare VM has no `sys` to run it. What this test guards is that words + // survive the kit at all — a right-sized tree of empty nodes would pass the + // count check beside it — and the card's own copy proves that without + // depending on a value the backend now fetches. + for expected in ["HACKER NEWS", "Top Stories"] { assert!(text.contains(expected), "{expected:?} missing from: {text}"); } } @@ -174,74 +181,116 @@ fn the_lowered_card_names_roles_and_no_presentation() { assert!(src.contains("l0_panel("), "roles must be named:\n{src}"); } -/// A role the kit cannot draw becomes a VISIBLE marker, never nothing. +/// The marker mechanism still works, though nothing now needs it. /// -/// Five of the six data visualisations have no kind in this renderer. A card -/// that silently loses its temperature bars still looks complete, which is the -/// failure `ui-profile-l0.md` §1.1 exists to prevent. +/// Every one of L0's 23 roles has a kit answer since the five data +/// visualisations stopped being markers — so no reference card exercises this, +/// and it is asserted directly instead of through one. +/// +/// Kept because the failure it prevents is the worst kind: a role with no answer +/// that renders as an ABSENCE leaves a card looking complete while missing its +/// temperature bars. The next role added to the catalog will have no kit +/// function on the day it is added, and this is what stands between that and a +/// silently short card. #[test] -fn an_unanswerable_role_survives_as_a_marker() { - let report = realize(WEATHER, &weather_data(), RealizeLimits::default()); - let src = kit::lower(&report.root.expect("root")); - assert!( - src.contains("l0_unsupported(\"TempBar\")"), - "TempBar has no kit answer and must lower to a named marker:\n{src}" - ); +fn a_role_with_no_kit_answer_is_visible_rather_than_absent() { + let src = format!(r#"{KIT} +let node = l0_unsupported("Hologram") +node +"#); + let tree = splash_render::build(&src, |_vm| {}).expect("the marker evaluates"); - fn words(n: &splash_render::UiNode, out: &mut Vec) { + fn words(n: &splash_render::UiNode, out: &mut String) { if let Some(t) = n.attrs.text.as_deref() { - out.push(t.to_owned()); + out.push_str(t); } for c in &n.children { words(c, out); } } - let mut out = Vec::new(); - words(&build(WEATHER, weather_data()), &mut out); + let mut text = String::new(); + words(&tree, &mut text); assert!( - out.iter().any(|w| w.contains("TempBar")), - "and the marker must reach the tree, naming the role: {out:?}" + text.contains("Hologram"), + "the marker must NAME the role it stands in for, got {text:?}" ); } -/// A declared tap must reach the rendered UI as a live target. +/// And the five that used to be markers now draw. /// -/// This is the assertion the whole interaction path rests on, and it is easy to -/// pass falsely: `tapto` on a `card`, `chip` or `image` is accepted by the node -/// model and then DROPPED — the string never reaches the UI, and the card -/// renders looking perfectly correct while every row is dead. Only a container -/// carries a tap, which is why the lowering wraps. +/// The weather card carries four of them and the stock card the fifth. This +/// asserts they reach the tree as their own kinds — the whole point of adding +/// kinds rather than routing them through an anonymous `Shader` with a variant. #[test] -fn a_declared_tap_reaches_the_rendered_ui() { - let tree = build(STOCK, stock_data()); - let ui = splash_makepad::to_makepad_ui(&tree); - - assert!( - ui.contains("on_click"), - "the card declares taps and none reached the UI:\n{ui}" - ); - // The INSTANCE KEY must survive, or a tap cannot say which row was hit — - // §5.1 identity is the whole basis of dispatch. - assert!( - ui.contains("for#0[NVDA]"), - "the tap target lost its instance key:\n{ui}" +fn the_data_visualisations_reach_the_tree_as_themselves() { + fn kinds(n: &splash_render::UiNode, out: &mut Vec) { + out.push(format!("{:?}", n.kind)); + for c in &n.children { + kinds(c, out); + } + } + let mut out = Vec::new(); + kinds(&build(WEATHER, weather_data()), &mut out); + for expected in ["TempBar", "SunArc", "MoonPhase", "AqiContour"] { + assert!(out.iter().any(|k| k == expected), "{expected} missing: {out:?}"); + } + let mut out = Vec::new(); + let mut store = splash_ui_l0::InstanceStore::default(); + splash_ui_l0::dispatch_with( + STOCK, &mut store, "root", "open_quote", + Some(&serde_json::Value::String("NVDA".into()))); + let report = splash_ui_l0::realize_with_state( + STOCK, &stock_data(), &store, RealizeLimits::default()); + let src = format!("{KIT}\n{}", kit::lower(&report.root.expect("root"))); + kinds( + &splash_render::build(&src, |_vm| {}).expect("detail evaluates"), + &mut out, ); - // And the event, and the payload. - assert!(ui.contains("open_quote"), "the event name is missing:\n{ui}"); + assert!(out.iter().any(|k| k == "StockPlot"), "StockPlot missing: {out:?}"); } -/// A node with no declared tap gets no target. +/// Every widget name this backend emits must be one the kit DEFINES. /// -/// Without this the test above passes for an implementation that makes the -/// whole card one hit area, which is worse than none. +/// This exists because it did not. `widget_name` mapped the five data +/// visualisations to `L0TempBar` and friends, and none of them were written — +/// five dangling names, passing every test, because nothing here renders a card +/// to pixels. The mapping and the definition live in different crates and +/// nothing tied them together. #[test] -fn only_declared_taps_become_targets() { - let report = realize(STOCK, &stock_data(), RealizeLimits::default()); - let src = kit::lower(&report.root.expect("root")); - let wrappers = src.matches("l0_tap").count(); - // stock.card's list view declares exactly one tap per mover row. - assert_eq!( - wrappers, 1, - "one wrapper per declared tap, no more — got {wrappers}:\n{src}" +fn every_emitted_widget_name_is_defined_in_the_kit() { + const PRELUDE: &str = include_str!("../../splash-widgets/src/lib.rs"); + + let mut emitted: Vec = Vec::new(); + for tree in [ + build(NEWS, news_data()), + build(STOCK, stock_data()), + build(WEATHER, weather_data()), + ] { + fn names(n: &splash_render::UiNode, out: &mut Vec) { + out.push(splash_makepad::widget_name_of(n.kind).to_owned()); + for c in &n.children { + names(c, out); + } + } + names(&tree, &mut emitted); + } + emitted.sort(); + emitted.dedup(); + + // makepad's own widgets are not the kit's to define; only the ones this + // repository adds have to appear in the prelude. + let missing: Vec<&String> = emitted + .iter() + .filter(|w| w.starts_with("L0") || w.starts_with("Flutter")) + .filter(|w| !PRELUDE.contains(&format!("mod.widgets.{w} ="))) + .collect(); + assert!( + missing.is_empty(), + "these widget names are emitted and never defined: {missing:?}" + ); + // And the check is not vacuous: the cards must reach the L0 widgets at all. + assert!( + emitted.iter().any(|w| w.starts_with("L0")), + "no L0 widget was emitted, so this asserted nothing: {emitted:?}" ); } diff --git a/crates/splash-node/src/node.rs b/crates/splash-node/src/node.rs index 986640b..db4bc04 100644 --- a/crates/splash-node/src/node.rs +++ b/crates/splash-node/src/node.rs @@ -101,6 +101,23 @@ pub enum NodeKind { TransitionHost, /// octos-one's own widgets, ported to Android views in the reference. WeatherIcon, + // ---- data visualisations ------------------------------------------------- + // `ui-profile-l0.md` §1.1: six roles are small data visualisations rather + // than compositions of boxes and text. A gradient temperature bar is a + // fragment shader parameterised by data, and a DSL node cannot carry shader + // SOURCE — MPSL compiles at build time — but it can select a shader that was + // compiled. + // + // Adding kinds obliges every backend at once, which is why §1.1 left this + // unsettled for so long. It is settled this way because the alternative — + // `Shader` plus a `variant` string — makes the parameters untyped: a + // temperature bar and a moon phase would ride the same anonymous fields, and + // nothing would catch a card that passed a latitude where a phase belongs. + TempBar, + SunArc, + MoonPhase, + AqiContour, + StockPlot, NavMap, GlassPanel, /// A web surface positioned into the native tree. The host reserves the @@ -173,6 +190,11 @@ impl NodeKind { "adaptivedemo" => Self::AdaptiveDemo, "transitionhost" => Self::TransitionHost, "weathericon" => Self::WeatherIcon, + "tempbar" => Self::TempBar, + "sunarc" => Self::SunArc, + "moonphase" => Self::MoonPhase, + "aqicontour" => Self::AqiContour, + "stockplot" => Self::StockPlot, "navmap" => Self::NavMap, "glasspanel" => Self::GlassPanel, "web" => Self::Web, @@ -242,6 +264,14 @@ pub struct Attrs { /// horizontal / 6dp vertical padding that a uniform `pad` can't express. pub padx: Option, pub pady: Option, + /// Asymmetric vertical padding, where `pady` cannot say it. + /// + /// A page's top padding clears the status bar and its bottom clears the + /// gesture bar, and those are different numbers on every device. Expressed + /// as one symmetric `pady` the page sat 30px too high, which shifted every + /// row below it and read as a layout bug rather than as a missing inset. + pub padtop: Option, + pub padbottom: Option, pub spacing: Option, pub margin: Option, /// Per-axis margin, overriding `margin` on its axis. Every section heading in @@ -249,6 +279,13 @@ pub struct Attrs { /// little tighter than the reference and drift further down the page. pub marginx: Option, pub marginy: Option, + /// Asymmetric vertical margin, for the same reason as `padtop`. + /// + /// A panel separates itself from what is ABOVE it; repeating that below + /// doubles the gap between two stacked panels and leaves a dead strip under + /// the last one. + pub margintop: Option, + pub marginbottom: Option, pub border: Option, pub bordercolor: Option, /// Which Material variant this node is — `filled`/`tonal`/`outlined`/`text`/ @@ -325,10 +362,60 @@ pub struct Attrs { pub zoom: Option, pub tilt: Option, pub rotation: Option, + /// The route a map draws, as an encoded polyline5. + /// + /// Geometry rather than a query, because the tree carries values and not + /// requests: whoever built this node already resolved the route, and a + /// backend that re-fetched from an origin and destination would fetch again + /// on every rebuild. Which member of the map family this is — route preview, + /// chase camera, flat — travels in `variant`, like every other family. + /// Where a field sends what has been typed SO FAR, per keystroke. + /// + /// Separate from `tapto`, which a field uses for its commit: the two carry + /// different events and fire at different moments. A search box wants both — + /// results while you type, a destination when you press return. + pub changeto: Option, + pub polyline: Option, + /// The pins a map stands on its route: `"lat,lon,kind;…"`, kind 0 origin, + /// 1 an intermediate stop, 2 the destination. + /// + /// A VALUE for the same reason `polyline` is one. The endpoints were already + /// resolved to draw the route, so a backend that re-derived them would resolve + /// the same places twice and could disagree with the line on screen. + pub markers: Option, + /// What the drawn route COSTS, labelled on the path: two lines separated by a + /// pipe, in practice a duration over a distance. On the route rather than in a + /// sheet, because that is where it answers the question being asked of it. + /// + /// `route_badge`, not `badge`: this struct already has one, for the count on a + /// component, and a route's cost is not that. Two meanings under one name is how + /// a field ends up carrying whichever the last writer meant. + pub route_badge: Option, /// Absolute position for a surface the host composites (a web slot). The /// tree does not know where a node lands, so a screen that wants one says. pub x: Option, pub y: Option, + + // ---- data-visualisation parameters --------------------------------------- + // Named, not generic. A `Shader` kind with anonymous slots would let a card + // pass a latitude where a moon phase belongs and nothing would notice. + // `min`/`max` (a bar's range) and `lat`/`lon` (a contour's centre) already + // exist above and mean exactly this, so they are reused rather than doubled. + /// TempBar: the day's low and high, against `min`/`max` for the week. + pub lo: Option, + pub hi: Option, + /// SunArc: sunrise, sunset and now, as fractional hours. + pub rise: Option, + pub set: Option, + pub now: Option, + /// MoonPhase: 0..1 through the cycle, and percent illuminated. + pub phase: Option, + pub illum: Option, + /// AqiContour: degrees of latitude the field covers, around `lat`/`lon`. + pub span: Option, + /// StockPlot: which series, and over what window. + pub symbol: Option, + pub range: Option, } /// One node in the backend-agnostic tree. diff --git a/crates/splash-render/src/eval.rs b/crates/splash-render/src/eval.rs index 15ecf21..1b23ec4 100644 --- a/crates/splash-render/src/eval.rs +++ b/crates/splash-render/src/eval.rs @@ -58,6 +58,10 @@ fn walk(vm: &mut ScriptVm, value: ScriptValue, depth: usize) -> Option { id: string_prop(vm, value, id!(id)), tapto: string_prop(vm, value, id!(tapto)), src: string_prop(vm, value, id!(src)), + changeto: string_prop(vm, value, id!(changeto)), + polyline: string_prop(vm, value, id!(polyline)), + markers: string_prop(vm, value, id!(markers)), + badge: string_prop(vm, value, id!(badge)), fit: int_prop(vm, value, id!(fit)), w: f32_prop(vm, value, id!(w)), h: f32_prop(vm, value, id!(h)), @@ -77,10 +81,14 @@ fn walk(vm: &mut ScriptVm, value: ScriptValue, depth: usize) -> Option { pad: f32_prop(vm, value, id!(pad)), padx: f32_prop(vm, value, id!(padx)), pady: f32_prop(vm, value, id!(pady)), + padtop: f32_prop(vm, value, id!(padtop)), + padbottom: f32_prop(vm, value, id!(padbottom)), spacing: f32_prop(vm, value, id!(spacing)), margin: f32_prop(vm, value, id!(margin)), marginx: f32_prop(vm, value, id!(marginx)), marginy: f32_prop(vm, value, id!(marginy)), + margintop: f32_prop(vm, value, id!(margintop)), + marginbottom: f32_prop(vm, value, id!(marginbottom)), border: f32_prop(vm, value, id!(border)), bordercolor: u32_prop(vm, value, id!(bordercolor)), variant: string_prop(vm, value, id!(variant)), @@ -92,7 +100,7 @@ fn walk(vm: &mut ScriptVm, value: ScriptValue, depth: usize) -> Option { hint: string_prop(vm, value, id!(hint)), title: string_prop(vm, value, id!(title)), lines: int_prop(vm, value, id!(lines)), - badge: string_prop(vm, value, id!(badge)), + route_badge: string_prop(vm, value, id!(route_badge)), count: int_prop(vm, value, id!(count)), supporting: string_prop(vm, value, id!(supporting)), helper: string_prop(vm, value, id!(helper)), @@ -119,6 +127,16 @@ fn walk(vm: &mut ScriptVm, value: ScriptValue, depth: usize) -> Option { rotation: num_prop(vm, value, id!(rotation)), x: num_prop(vm, value, id!(x)), y: num_prop(vm, value, id!(y)), + lo: f32_prop(vm, value, id!(lo)), + hi: f32_prop(vm, value, id!(hi)), + rise: f32_prop(vm, value, id!(rise)), + set: f32_prop(vm, value, id!(set)), + now: f32_prop(vm, value, id!(now)), + phase: f32_prop(vm, value, id!(phase)), + illum: f32_prop(vm, value, id!(illum)), + span: f32_prop(vm, value, id!(span)), + symbol: string_prop(vm, value, id!(symbol)), + range: string_prop(vm, value, id!(range)), }; // `c` is a ScriptArray, NOT an object with a vec — arrays are their own heap diff --git a/crates/splash-widgets/Cargo.toml b/crates/splash-widgets/Cargo.toml index a3b4b58..f842f7c 100644 --- a/crates/splash-widgets/Cargo.toml +++ b/crates/splash-widgets/Cargo.toml @@ -14,4 +14,4 @@ description = "Themed native-widget kits for makepad (M3 / iOS / liquid-glass) a [dependencies] # Upstream makepad — fork-free. The makepad-script rev pulled transitively here # must match the one splash-render pins (align both when wiring an app). -makepad-widgets = { git = "https://github.com/makepad/makepad.git", branch = "dev" } +makepad-widgets = { path = "../../../makepad-splash/widgets" } diff --git a/crates/splash-widgets/src/lib.rs b/crates/splash-widgets/src/lib.rs index 7d2d17f..50828d0 100644 --- a/crates/splash-widgets/src/lib.rs +++ b/crates/splash-widgets/src/lib.rs @@ -237,6 +237,156 @@ script_mod! { } } mod.prelude.widgets.FlutterSdf = mod.widgets.FlutterSdf + + // ---- L0 data visualisations --------------------------------------------- + // + // `ui-profile-l0.md` §1.1: six roles are small data visualisations rather + // than compositions of boxes and text, and a DSL node cannot carry shader + // SOURCE because MPSL compiles at build time. It can name a shader that was + // compiled, which is what these are. + // + // THREE OF THE FIVE ARE PURE FUNCTIONS OF THEIR PARAMETERS and are drawn + // here in full. The other two are not: an air-quality field and a price + // series are DATA, and this backend has no fetch. They draw an explicit + // empty state rather than a plausible-looking curve — a card that looks + // right and is fiction is worse than one that says it has nothing, and §4's + // no-facts rule is the same instinct one layer down. + + // A day's low and high as a segment of the week's range. + mod.widgets.L0TempBar = View{ + width: Fill + height: 8 + show_bg: true + draw_bg +: { + tlo: uniform(0.0) + thi: uniform(0.0) + wmin: uniform(0.0) + wmax: uniform(1.0) + pixel: fn() { + let span = max(self.wmax - self.wmin, 0.001) + let a = clamp((self.tlo - self.wmin) / span, 0.0, 1.0) + let b = clamp((self.thi - self.wmin) / span, 0.0, 1.0) + let x = self.pos.x + // The track, so an unfilled range still reads as a range. + let track = vec3(1.0, 1.0, 1.0) * 0.10 + if x < a || x > b { return vec4(track, 1.0) } + // Cold to warm ACROSS THE SEGMENT, so a wide day reads warmer at + // its top than a narrow one at the same high. + let t = (x - a) / max(b - a, 0.001) + let cold = vec3(90.0, 160.0, 240.0) / 255.0 + let warm = vec3(255.0, 170.0, 60.0) / 255.0 + return vec4(mix(cold, warm, t), 1.0) + } + } + } + mod.prelude.widgets.L0TempBar = mod.widgets.L0TempBar + + // The sun's path, with now marked on it. Hours are fractional: 18.9 is 18:54. + mod.widgets.L0SunArc = View{ + width: Fill + height: 90 + show_bg: true + draw_bg +: { + rise: uniform(6.0) + set: uniform(18.0) + now: uniform(12.0) + pixel: fn() { + let p = self.pos + // A half-ellipse across the width, sitting on the baseline. + let ax = p.x + let ay = 1.0 - sin(ax * 3.14159265) + let d = abs(p.y - ay) + let ink = vec3(1.0, 1.0, 1.0) * 0.35 + let mut c = vec3(0.0, 0.0, 0.0) + let mut a = 0.0 + if d < 0.035 { c = ink; a = 1.0 - smoothstep(0.02, 0.035, d) } + // Where the sun is now, clamped so a pre-dawn or post-dusk time + // sits at the horizon rather than off the arc. + let span = max(self.set - self.rise, 0.001) + let t = clamp((self.now - self.rise) / span, 0.0, 1.0) + let sx = t + let sy = 1.0 - sin(sx * 3.14159265) + let sd = length(vec2(p.x - sx, p.y - sy)) + if sd < 0.06 { + let sun = vec3(255.0, 200.0, 80.0) / 255.0 + let sa = 1.0 - smoothstep(0.035, 0.06, sd) + c = mix(c, sun, sa) + a = max(a, sa) + } + return vec4(c, a) + } + } + } + mod.prelude.widgets.L0SunArc = mod.widgets.L0SunArc + + // A disc with its terminator at `phase`, 0..1 through the cycle. + mod.widgets.L0MoonPhase = View{ + width: 64 + height: 64 + show_bg: true + draw_bg +: { + phase: uniform(0.5) + pixel: fn() { + let q = (self.pos - vec2(0.5, 0.5)) * 2.0 + let r = length(q) + if r > 1.0 { return vec4(0.0, 0.0, 0.0, 0.0) } + let edge = 1.0 - smoothstep(0.94, 1.0, r) + let dark = vec3(1.0, 1.0, 1.0) * 0.12 + let lit = vec3(1.0, 1.0, 1.0) * 0.92 + // The terminator is an ellipse whose width tracks the phase; at + // 0 and 1 it covers the disc, at 0.5 it is a straight edge. + let k = cos(self.phase * 6.2831853) + let tx = q.x - k * sqrt(max(1.0 - q.y * q.y, 0.0)) + let mut c = dark + if self.phase < 0.5 { + if tx < 0.0 { c = lit } + } else { + if tx > 0.0 { c = lit } + } + return vec4(c, edge) + } + } + } + mod.prelude.widgets.L0MoonPhase = mod.widgets.L0MoonPhase + + // An air-quality field. This backend cannot fetch one, so it says so. + // + // Drawing a plausible gradient here would be inventing data — the card would + // look complete and be fiction, which is the failure §1.1 wants a missing + // visualisation to avoid, and worse than the marker it replaced. + mod.widgets.L0AqiContour = View{ + width: Fill + height: 190 + show_bg: true + draw_bg +: { + pixel: fn() { + // A hatched empty state: unmistakably "no data", not a field. + let s = (self.pos.x + self.pos.y) * 22.0 + let h = abs(fract(s) - 0.5) * 2.0 + let bg = vec3(1.0, 1.0, 1.0) * 0.05 + let ln = vec3(1.0, 1.0, 1.0) * 0.11 + return vec4(mix(ln, bg, smoothstep(0.35, 0.5, h)), 1.0) + } + } + } + mod.prelude.widgets.L0AqiContour = mod.widgets.L0AqiContour + + // A price series. Same reasoning: no fetch here, so no invented curve. + mod.widgets.L0StockPlot = View{ + width: Fill + height: 180 + show_bg: true + draw_bg +: { + pixel: fn() { + let s = (self.pos.x + self.pos.y) * 22.0 + let h = abs(fract(s) - 0.5) * 2.0 + let bg = vec3(1.0, 1.0, 1.0) * 0.05 + let ln = vec3(1.0, 1.0, 1.0) * 0.11 + return vec4(mix(ln, bg, smoothstep(0.35, 0.5, h)), 1.0) + } + } + } + mod.prelude.widgets.L0StockPlot = mod.widgets.L0StockPlot } // TODO(kits): Button touch-ripple as a `RippleButton` variant (it modifies the diff --git a/route.sh b/route.sh new file mode 100755 index 0000000..61f6a64 --- /dev/null +++ b/route.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Switch the kit-host catalog to a route on the OnePlus 6 and relaunch. +# ./route.sh button +A=~/Library/Android/sdk/platform-tools/adb +D=cfb7c9e3 # OnePlus 6 only -- never bf0a4730 (the 6T) +$A -s $D shell "echo ${1:-button} > /data/local/tmp/kit_host.route" +$A -s $D shell appops set --uid dev.makepad.kit_host COARSE_LOCATION deny +$A -s $D shell appops set --uid dev.makepad.kit_host FINE_LOCATION deny +$A -s $D shell am start -S -n dev.makepad.kit_host/.MakepadApp >/dev/null +echo "kit-host -> ${1:-button}"