diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index baf1a9a..187aaaa 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -2820,6 +2820,9 @@ pub mod catalog { pub const CONSTRUCTORS: &[(&str, Args)] = &[ ("Surface", &[("pad", Token(PAD))]), ("Photo", &[("src", Path), ("pad", Token(PAD))]), + // A row-sized image. `Photo` fills its width (it is a backdrop); a list + // row needs a fixed 16:9 tile beside its text. + ("Thumb", &[("src", Path)]), // A map. The card names the TRIP; the widget fetches its own route. // // The same correction `AqiContour` and `StockPlot` already took. The @@ -3016,6 +3019,14 @@ pub mod catalog { "StockPlot", &[("symbol", Path), ("range", TokenOrPath(UNIT))], ), + // Several countries' World Bank series on one axis. Like StockPlot it + // NAMES what to plot and the widget fetches it: a card that carried + // sixty numbers would be stating facts (§4), and they would be wrong + // the moment the series is revised. + ( + "IndicatorPlot", + &[("countries", Path), ("indicator", Path), ("years", Path)], + ), ]; pub fn lookup(name: &str) -> Option { @@ -3086,6 +3097,8 @@ pub mod catalog { // quotes, so a card asks for the fields it wants to show and never // learns that a store exists. ("sys.watchlist", &["ticker", "fields"]), + ("sys.indicator", &["countries", "indicator", "years", "fields"]), + ("sys.video", &["query", "count", "fields"]), ("sys.prefs", &["fields"]), ("sys.reading", &["fields"]), ("sys.topics", &["fields"]), @@ -3212,6 +3225,20 @@ pub mod catalog { "has", ], ), + // One country's reading of a World Bank indicator, indexed like every + // other row source: `0.name`, `0.latest`, `0.change`. The SERIES is the + // chart's to fetch; these are the numbers a card puts beside it. + ( + "sys.indicator", + &["name", "latest", "first", "change", "min", "max", "year", "title"], + ), + // A YouTube search result. `embed` is a player url ready to hand to + // sys.link — a card cannot build one, because L0 has no string + // concatenation, and that is the point. + ( + "sys.video", + &["id", "title", "channel", "length", "views", "age", "thumb", "embed"], + ), ("sys.prefs", &["units", "range", "home", "work", "mode"]), // The reading list: SAVED story ids, joined to the story each id names. // The id is the identity Algolia serves forever, so a bookmarked story @@ -6981,6 +7008,46 @@ pub mod makepad { }; Some(format!("sys.reading({index}, {key:?})")) } + // One search result, by row index. + "sys.video" => { + let (index, field) = binding.field.split_once('.')?; + index.parse::().ok()?; + let key = match field { + f @ ("id" | "title" | "channel" | "length" | "views" | "age" | "thumb" + | "embed") => f, + _ => return None, + }; + let query = arg("query")?; + Some(format!("sys.video({query:?}, {index}, {key:?})")) + } + // One country's reading, by row index. The countries argument is the + // card's own list, so row 0 is the first country it named. + "sys.indicator" => { + // `read.0.name` is a row; `read.title` is the indicator's own + // name, which is the same for every row — so a bare field + // reads row 0 rather than answering nothing (the card titles + // itself with it, and it rendered an em dash). + let (index, field) = match binding.field.split_once('.') { + Some((i, f)) => (i, f), + None => ("0", binding.field.as_str()), + }; + index.parse::().ok()?; + let key = match field { + f @ ("name" | "latest" | "first" | "change" | "min" | "max" | "year" + | "title") => f, + _ => return None, + }; + let countries = arg("countries")?; + let indicator = arg("indicator")?; + let years = arg("years").unwrap_or_else(|| "30".to_owned()); + // `:?` on the two text slots — a bare interpolation put + // `sys.indicator(CHN,IND, ...)` in the DSL, which parses as + // two arguments and answered nothing (measured on device). + // `years` is a numeric slot and stays unquoted. + Some(format!( + "sys.indicator({countries:?}, {indicator:?}, {years}, {index}, {key:?})" + )) + } // The reader overlay's current page — published by the host the // same way locale and the position fix are. "sys.link" => { @@ -7842,6 +7909,27 @@ pub mod makepad { text_of(arg(node, "range")), ); } + "Thumb" => { + // A fixed 16:9 tile, the size a list row wants beside its text. + let _ = writeln!( + out, + "{p}Image{{ width: 108 height: 61 fit: ImageFit.CropToFill \ + src: http_resource({}) }}", + expr_of(node, "src") + ); + } + "IndicatorPlot" => { + // Same contract as StockPlot: the card names which countries + // and which indicator, the widget fetches the series. + let _ = writeln!( + out, + "{p}IndicatorPlot{{ width: Fill height: 210 countries: {} \ + indicator: {} years: {} }}", + text_of(arg(node, "countries")), + text_of(arg(node, "indicator")), + expr_of(node, "years"), + ); + } "Chip" => { // `active` selects the fill. Dropping it made every range chip // render identically, so a card could not show which was @@ -8576,6 +8664,27 @@ pub struct SourcePlan { /// This returns *what to fetch*, never fetches it. The card names a helper; only /// the host knows what answers it. That separation is what lets realization run /// against an empty host surface. +/// Each state's DECLARED literal initial, by path. +/// +/// A host that has to resolve a source argument BEFORE realize — `sys.indicator +/// (countries: state.countries)` has to know which countries to ask about +/// before there is a tree — cannot read it from the store (nothing has been +/// written yet) or from the seed data (an initial is a declaration, not data). +/// It is in the card, and this is how a host reads it. Path-valued initials +/// (`initial: env.locale.temp_unit`) are absent here on purpose: they resolve +/// against injected data at realization, which is after this is useful. +pub fn state_initials(source: &str) -> std::collections::BTreeMap { + let mut sink = Diagnostics::default(); + let Some(tokens) = lex(source, &mut sink) else { + return std::collections::BTreeMap::new(); + }; + let card = Parser::new(&tokens, &mut sink).parse_card(); + card.states + .iter() + .filter_map(|st| st.initial.clone().map(|v| (st.path.clone(), v))) + .collect() +} + pub fn source_plan(source: &str) -> SourcePlan { let mut sink = Diagnostics::default(); let Some(tokens) = lex(source, &mut sink) else { @@ -9085,6 +9194,7 @@ fn dsl_kind(role: &str) -> Option<&'static str> { "Tile" => "listitem", "Chip" => "chip", "Photo" => "image", + "Thumb" => "image", "WeatherIcon" => "weathericon", role if role.starts_with("Text") => "text", // TempBar, SunArc, MoonPhase, AqiContour, StockPlot — no kind exists. @@ -9203,6 +9313,7 @@ pub mod kit { "Tile" => "l0_tile", "Chip" => "l0_chip", "Photo" => "l0_photo", + "Thumb" => "l0_thumb", "WeatherIcon" => "l0_weathericon", "TempBar" => "l0_tempbar", "SunArc" => "l0_sunarc", @@ -9210,6 +9321,7 @@ pub mod kit { "AqiContour" => "l0_aqicontour", "Satellite" => "l0_satellite", "StockPlot" => "l0_stockplot", + "IndicatorPlot" => "l0_indicatorplot", "TextHero" => "l0_hero", "TextTitle" => "l0_title", "TextBody" => "l0_body", @@ -9828,7 +9940,7 @@ pub mod kit { children(node, depth, out); out.push(')'); } - "Photo" => { + "Photo" | "Thumb" => { let _ = write!(out, "{f}({})", makepad::expr_of(node, "src")); } // The trip, as the kit takes it: which member of the map family, how @@ -9900,6 +10012,18 @@ pub mod kit { // the catalog's order — no defaults, because a bar drawn against a // range nobody supplied is a bar drawn against zero, and it looks // like data. + // The card names WHICH countries and WHICH indicator as text; the + // widget resolves the series. Kept out of the numeric group below + // because two of its three arguments are strings. + "IndicatorPlot" => { + let _ = write!( + out, + "{f}({}, {}, {})", + makepad::expr_of(node, "countries"), + makepad::expr_of(node, "indicator"), + scalar_num_of(node, "years") + ); + } "TempBar" | "SunArc" | "MoonPhase" | "AqiContour" | "StockPlot" | "Satellite" => { let params: &[&str] = match node.kind.as_str() { "TempBar" => &["lo", "hi", "min", "max"], diff --git a/crates/splash-ui-l0/tests/fixtures/chart.card b/crates/splash-ui-l0/tests/fixtures/chart.card new file mode 100644 index 0000000..15bf99e --- /dev/null +++ b/crates/splash-ui-l0/tests/fixtures/chart.card @@ -0,0 +1,100 @@ +# ledger chart@1.0.0 +# level: L0 +# profile: ui/l0 +# model: glm-5.2 +# +# Compare countries on one World Bank indicator. The card names WHO and WHAT; +# the chart fetches the series and the scalars beside it read the same request. +# Nothing here states a number — a series baked into a card is wrong the moment +# the World Bank revises it (§4). + +# ── sources ────────────────────────────────────────────────────────────────── +# One reading per country, indexed in the order `countries` lists them — the +# same order IndicatorPlot assigns its legend colours, so row 0's number and +# the first line on the chart are the same country. +source read sys.indicator(countries: state.countries, + indicator: state.metric, + years: state.span, + fields: [name, latest, first, change, min, max, year, title]) +source env.locale sys.locale() + +# ── state ──────────────────────────────────────────────────────────────────── +# The bootstrap surface: an intent fills these three and the whole card follows. +state countries { shape: text, initial: "CHN,IND" } +state metric { shape: text, initial: "NY.GDP.MKTP.KD.ZG" } +state span { shape: number, initial: 30 } + +# ── events ─────────────────────────────────────────────────────────────────── +event show_span { span: set($value) } +# The metric switcher: each chip names an indicator the World Bank publishes +# for every country, so a comparison never half-answers. +event pick_metric { metric: set($value) } + +# ── copy ───────────────────────────────────────────────────────────────────── +copy eyebrow { class: vocabulary, en: "WORLD BANK", zh: "世界银行" } +copy latest { class: vocabulary, en: "Latest", zh: "最新" } +copy first_lbl { class: vocabulary, en: "Start", zh: "起点" } +copy change { class: vocabulary, en: "Change", zh: "变化" } +copy peak { class: vocabulary, en: "Peak", zh: "峰值" } +copy trough { class: vocabulary, en: "Low", zh: "谷值" } +copy loading { class: vocabulary, en: "Fetching the series…", zh: "正在获取数据…" } +copy offline { class: vocabulary, en: "Can't reach the World Bank", zh: "无法获取数据" } +copy m_growth { class: vocabulary, en: "GDP growth", zh: "GDP 增速" } +copy m_gdp { class: vocabulary, en: "GDP", zh: "GDP" } +copy m_cap { class: vocabulary, en: "GDP/capita", zh: "人均 GDP" } +copy m_life { class: vocabulary, en: "Life exp.", zh: "预期寿命" } +copy m_co2 { class: vocabulary, en: "CO₂/capita", zh: "人均碳排" } +copy y10 { class: vocabulary, en: "10y" } +copy y30 { class: vocabulary, en: "30y" } +copy y60 { class: vocabulary, en: "60y" } + +# ── view ───────────────────────────────────────────────────────────────────── +# §5.9: the fetch lifecycle is a state the card branches on — "not yet" and +# "went wrong" are different facts and each says so. +view root Surface(pad: .page) { + TextCaption(text: copy.eyebrow) + # The indicator's own name, as the API spells it — the card does + # not restate what it asked for. + TextTitle(text: read.title, width: .fill) + when read.$state == .pending { TextBody(text: copy.loading) } + when read.$state == .failed { TextBody(text: copy.offline) } + chart + spans + metrics + readings + } + +view chart Col { + IndicatorPlot(countries: countries, indicator: metric, years: span) + } + +# The window. `active` is the chip's own guard, so the theme lights the one +# the card is showing rather than the card describing a colour. +view spans Row(gap: 8) { + Chip(text: copy.y10, on_tap: show_span, value: 10, active: span == 10) + Chip(text: copy.y30, on_tap: show_span, value: 30, active: span == 30) + Chip(text: copy.y60, on_tap: show_span, value: 60, active: span == 60) + } + +view metrics Row(gap: 8) { + Chip(text: copy.m_growth, on_tap: pick_metric, value: "NY.GDP.MKTP.KD.ZG", + active: metric == "NY.GDP.MKTP.KD.ZG") + Chip(text: copy.m_gdp, on_tap: pick_metric, value: "NY.GDP.MKTP.CD", + active: metric == "NY.GDP.MKTP.CD") + Chip(text: copy.m_cap, on_tap: pick_metric, value: "NY.GDP.PCAP.CD", + active: metric == "NY.GDP.PCAP.CD") + } + +# One row per country, each carrying that country's own numbers. The row order +# IS the legend order, so a reader can match a line to its figures without the +# card having to name a colour. +view readings Col { + for r, i in read key r.name { + Row(align: .center, gap: 10) { + TextRow(text: r.name, width: .fill) + TextValue(value: r.latest) + TextCaption(text: r.change) + } + Rule() + } + } diff --git a/crates/splash-ui-l0/tests/fixtures/youtube.card b/crates/splash-ui-l0/tests/fixtures/youtube.card new file mode 100644 index 0000000..2273e88 --- /dev/null +++ b/crates/splash-ui-l0/tests/fixtures/youtube.card @@ -0,0 +1,83 @@ +# ledger youtube@1.0.0 +# level: L0 +# profile: ui/l0 +# model: glm-5.2 +# +# Watch something. The card SEARCHES — it does not carry a list of video ids +# the model remembered, which is what the web card it replaces had to do and +# why its ids went stale. Playback is the host's: a tap hands the player url +# to sys.link and the native overlay plays it over the card. + +# ── sources ────────────────────────────────────────────────────────────────── +# Live YouTube search. `embed` is a player url the helper builds; a card cannot +# concatenate one, which is exactly why the helper answers it. +source hits sys.video(query: state.q, count: 12, + fields: [id, title, channel, length, views, age, thumb, embed]) +# The player overlay (host-owned): writing a url opens it over the card, and +# the host closes it on system back. +source page sys.link(fields: [url]) +source env.locale sys.locale() + +# ── state ──────────────────────────────────────────────────────────────────── +# What to watch. An intent fills this and the card is already the answer. +state q { shape: text, initial: "lofi hip hop radio" } +state typed { shape: text, initial: "" } +state editing { shape: enum[none, find], initial: .none } + +# ── events ─────────────────────────────────────────────────────────────────── +event open_find { editing: set(.find), typed: clear } +event typing { typed: set($value) } +# Committing the field searches for what was typed and closes the editor. +event run_find { q: set($value), typed: clear, editing: set(.none) } +event close_find { editing: set(.none), typed: clear } +# The tap that plays: the row carries its own player url. +event play { page: set($value) } + +# ── copy ───────────────────────────────────────────────────────────────────── +copy eyebrow { class: vocabulary, en: "YOUTUBE", zh: "YOUTUBE" } +copy find_lbl { class: vocabulary, en: "Search", zh: "搜索" } +copy loading { class: vocabulary, en: "Searching…", zh: "正在搜索…" } +copy offline { class: vocabulary, en: "Can't reach YouTube", zh: "无法连接 YouTube" } + +# ── view ───────────────────────────────────────────────────────────────────── +view root Surface(pad: .page) { + Row(align: .center, gap: 8) { + TextCaption(text: copy.eyebrow, width: .fill) + Chip(text: copy.find_lbl, on_tap: open_find, value: "1") + } + # What the list is showing, in the user's own words. + TextTitle(text: q, width: .fill) + finder + when hits.$state == .pending { TextBody(text: copy.loading) } + when hits.$state == .failed { TextBody(text: copy.offline) } + results + } + +view finder Col { + when editing == .find { + Panel { + Row(align: .center, gap: 10) { + Field(text: typed, placeholder: q, on_commit: run_find, + on_change: typing, width: .fill) + Chip(text: "×", on_tap: close_find) + } + } + } + } + +# One row per result: the thumbnail, then what it is and who made it. The whole +# row taps, carrying its OWN player url — so playing is one tap and the card +# never has to know how a video is shown. +view results Col { + for v, i in hits key v.id { + Row(align: .center, gap: 10, on_tap: play, value: v.embed) { + Thumb(src: v.thumb) + Col(gap: 2, width: .fill) { + TextRow(text: v.title, width: .fill) + TextCaption(text: v.channel) + TextCaption(text: v.length) + } + } + Rule() + } + } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 8b8d71e..cbb2a96 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -6945,6 +6945,8 @@ fn every_offered_field_has_a_translation() { ("lon".into(), "2".into()), ("ticker".into(), "N".into()), ("query".into(), "q".into()), + ("countries".into(), "CHN".into()), + ("indicator".into(), "NY.GDP.MKTP.KD.ZG".into()), ("name".into(), "n".into()), ("id".into(), "1".into()), ("category".into(), "c".into()), diff --git a/docs/ui-l0-constructors.toml b/docs/ui-l0-constructors.toml index de0a00d..2d51d05 100644 --- a/docs/ui-l0-constructors.toml +++ b/docs/ui-l0-constructors.toml @@ -268,6 +268,24 @@ span = { kind = "number" } # Likewise: the card names WHICH series, and the widget fetches it. Passing # `points`/`min`/`max` made the card responsible for a scaled series it has no # way to compute — §4's no-facts rule with the arithmetic hidden in the host. +# Several countries' series on one axis, with a legend. Names WHAT to plot; +# the widget fetches it and scales every series to one viewport. +# A list row's image: a fixed 16:9 tile. `Photo` fills its width (it is a +# backdrop); a row needs a thumbnail beside its text. +[Thumb] +src = { kind = "path" } + +# One YouTube search result. `embed` is a player url ready for sys.link — a +# card cannot build one, because L0 has no string concatenation. +[sources."sys.video"] +args = ["query", "count", "fields"] +answers = ["id", "title", "channel", "length", "views", "age", "thumb", "embed"] + +[IndicatorPlot] +countries = { kind = "path" } +indicator = { kind = "path" } +years = { kind = "path" } + [StockPlot] symbol = { kind = "path" } range = { kind = "unit" } @@ -463,6 +481,14 @@ args = ["fields"] answers = ["url"] writes = ["set", "clear"] +# One country's reading of a World Bank indicator (keyless). Rows are indexed +# in the order the card listed its countries, so row 0 is the first one named. +# The SERIES itself belongs to IndicatorPlot, which fetches it: a card that +# carried the numbers would be stating facts that go stale on revision. +[sources."sys.indicator"] +args = ["countries", "indicator", "years", "fields"] +answers = ["name", "latest", "first", "change", "min", "max", "year", "title"] + [sources."sys.watchlist"] # `ticker` turns the source into a membership probe: `has` answers "1" when # that ticker is in the user's list, else "0" — what lets a quote page show