Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion crates/splash-ui-l0/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Args> {
Expand Down Expand Up @@ -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"]),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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::<u32>().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::<u32>().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" => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String, serde_json::Value> {
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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -9203,13 +9313,15 @@ pub mod kit {
"Tile" => "l0_tile",
"Chip" => "l0_chip",
"Photo" => "l0_photo",
"Thumb" => "l0_thumb",
"WeatherIcon" => "l0_weathericon",
"TempBar" => "l0_tempbar",
"SunArc" => "l0_sunarc",
"MoonPhase" => "l0_moonphase",
"AqiContour" => "l0_aqicontour",
"Satellite" => "l0_satellite",
"StockPlot" => "l0_stockplot",
"IndicatorPlot" => "l0_indicatorplot",
"TextHero" => "l0_hero",
"TextTitle" => "l0_title",
"TextBody" => "l0_body",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand Down
100 changes: 100 additions & 0 deletions crates/splash-ui-l0/tests/fixtures/chart.card
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading