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/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..a503092 100644 --- a/components/l0/_kit.splash +++ b/components/l0/_kit.splash @@ -79,10 +79,17 @@ 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) } +// 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, 26, 700, l0_text) } +fn l0_body(s) { return l0_para(s, 17, 400, l0_text) } +fn l0_row_text(s) { return l0_txt(s, 14, 400, l0_soft) } +fn l0_caption(s) { return l0_txt(s, 11, 400, l0_dim) } fn l0_value(s) { return l0_txt(s, 20, 600, l0_text) } // A statistic, tinted by direction. `dir` is -1, 0 or 1 — the SIGN, decided by @@ -93,14 +100,30 @@ 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, 14, 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 +} // ---- 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,7 +132,7 @@ 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} ]} } @@ -122,7 +145,7 @@ fn l0_grid(kids) { return {t: "grid", fillw: 1, w: 2, 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} + padx: 14, pady: 12, margintop: 16, c: kids} } fn l0_rule() { return {t: "divider", fillw: 1, h: 1, bg: l0_hairline} } @@ -187,6 +210,40 @@ fn l0_tap_fit(target, node) { fn l0_weathericon(cond) { return {t: "weathericon", variant: cond, w: 44, h: 44} } +// 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. +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, c: [ l0_caption("[" + role + " has no kind in this renderer]") ]} 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/tests/l0_cards.rs b/crates/splash-makepad/tests/l0_cards.rs index 719c1bc..e635c9e 100644 --- a/crates/splash-makepad/tests/l0_cards.rs +++ b/crates/splash-makepad/tests/l0_cards.rs @@ -174,74 +174,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..5890b41 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`/ @@ -329,6 +366,27 @@ pub struct Attrs { /// 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..e9c7db8 100644 --- a/crates/splash-render/src/eval.rs +++ b/crates/splash-render/src/eval.rs @@ -77,10 +77,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)), @@ -119,6 +123,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/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