Skip to main content

dynamic_ui/common/
spec.rs

1//! A view tree parsed at run time, from text the binary has never seen.
2//!
3//! Every other example in this directory writes its screen in Rust: the view
4//! types are chosen when the example is compiled, and changing the screen means
5//! building again. That is what a SwiftUI app is too — `VStack { Text("hi") }`
6//! is a *type*, `VStack<TupleView<(Text,)>>`, fixed at compile time.
7//!
8//! This module has no idea what it is going to build. It reads a string,
9//! decides which SwiftUI types to instantiate, asks the Swift runtime for their
10//! metadata, and assembles the tree. Nothing about the result is in the
11//! binary — the same executable renders any screen the text describes.
12//!
13//! The format is small on purpose. It is not the point; being able to *have* a
14//! format is:
15//!
16//! ```text
17//! // a comment
18//! vstack spacing=16 pad=20 {
19//!   text "System" style=large_title bold
20//!   hstack spacing=8 {
21//!     text "left"
22//!     spacer
23//!     text "right" color=gray
24//!   }
25//!   divider
26//!   progress 0.4
27//!   button "Refresh"
28//! }
29//! ```
30//!
31//! Errors are returned rather than panicked on: the text comes from outside, so
32//! a mistake in it is an ordinary event, not a bug. [`build`] reports the line
33//! it gave up on, which is what a person editing the file needs.
34
35#![allow(dead_code)]
36
37use swiftui_native::prelude::*;
38use swiftui_native::views;
39
40/// One node of the parsed tree: a name, its arguments, and its children.
41#[derive(Debug, Clone)]
42pub struct Node {
43    pub name: String,
44    /// The bare argument, if the node had one — `text "hello"` or `progress 0.4`.
45    pub value: Option<String>,
46    /// `key=value` pairs, in the order they were written.
47    pub attrs: Vec<(String, String)>,
48    pub children: Vec<Node>,
49    /// The 1-based line the node started on, for error messages.
50    pub line: usize,
51}
52
53impl Node {
54    fn attr(&self, key: &str) -> Option<&str> {
55        self.attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
56    }
57
58    /// A flag written either bare (`bold`) or as `bold=true`.
59    fn flag(&self, key: &str) -> bool {
60        self.attrs.iter().any(|(k, v)| k == key && (v.is_empty() || v == "true"))
61    }
62
63    fn f64_attr(&self, key: &str) -> Option<f64> {
64        self.attr(key).and_then(|v| v.parse().ok())
65    }
66}
67
68// ── Tokeniser ────────────────────────────────────────────────────────────
69
70#[derive(Debug, PartialEq)]
71enum Tok {
72    Word(String),
73    Str(String),
74    Eq,
75    Open,
76    Close,
77}
78
79struct Lexer<'a> {
80    rest: &'a str,
81    line: usize,
82}
83
84impl<'a> Lexer<'a> {
85    fn new(src: &'a str) -> Self {
86        Self { rest: src, line: 1 }
87    }
88
89    /// The next token and the line it started on.
90    fn next(&mut self) -> Result<Option<(Tok, usize)>, String> {
91        loop {
92            let mut chars = self.rest.char_indices();
93            match chars.next() {
94                None => return Ok(None),
95                Some((_, '\n')) => {
96                    self.line += 1;
97                    self.rest = &self.rest[1..];
98                }
99                Some((_, c)) if c.is_whitespace() => {
100                    self.rest = &self.rest[c.len_utf8()..];
101                }
102                // `//` to end of line. Not `#`: a colour is written `#1d2b53`,
103                // and treating `#` as a comment silently ate the rest of the
104                // line — which showed up as a whole screen falling back to the
105                // error view rather than as a parse error.
106                Some((_, '/')) if self.rest.as_bytes().get(1) == Some(&b'/') => {
107                    let end = self.rest.find('\n').unwrap_or(self.rest.len());
108                    self.rest = &self.rest[end..];
109                }
110                Some((_, '{')) => {
111                    self.rest = &self.rest[1..];
112                    return Ok(Some((Tok::Open, self.line)));
113                }
114                Some((_, '}')) => {
115                    self.rest = &self.rest[1..];
116                    return Ok(Some((Tok::Close, self.line)));
117                }
118                Some((_, '=')) => {
119                    self.rest = &self.rest[1..];
120                    return Ok(Some((Tok::Eq, self.line)));
121                }
122                Some((_, '"')) => {
123                    let line = self.line;
124                    let body = &self.rest[1..];
125                    let end = body.find('"').ok_or_else(|| {
126                        format!("line {line}: string is never closed")
127                    })?;
128                    let s = body[..end].to_string();
129                    self.line += s.matches('\n').count();
130                    self.rest = &body[end + 1..];
131                    return Ok(Some((Tok::Str(s), line)));
132                }
133                Some((_, _)) => {
134                    let line = self.line;
135                    let end = self
136                        .rest
137                        .find(|c: char| c.is_whitespace() || "{}=\"".contains(c))
138                        .unwrap_or(self.rest.len());
139                    let w = self.rest[..end].to_string();
140                    self.rest = &self.rest[end..];
141                    return Ok(Some((Tok::Word(w), line)));
142                }
143            }
144        }
145    }
146}
147
148// ── Parser ───────────────────────────────────────────────────────────────
149
150/// Every token, up front.
151///
152/// The grammar needs to look at the token *after* a word to know whether the
153/// word is an attribute name, a flag, or the next sibling — and after an `=`
154/// it needs the one after that. Streaming it with a single pushback slot was
155/// wrong in a way that misparsed `text "a" text "b"` as one node, so the whole
156/// list is materialised and walked by index.
157fn tokens(src: &str) -> Result<Vec<(Tok, usize)>, String> {
158    let mut lex = Lexer::new(src);
159    let mut out = Vec::new();
160    while let Some(t) = lex.next()? {
161        out.push(t);
162    }
163    Ok(out)
164}
165
166struct Parser {
167    toks: Vec<(Tok, usize)>,
168    at: usize,
169}
170
171impl Parser {
172    fn peek(&self) -> Option<&Tok> {
173        self.toks.get(self.at).map(|(t, _)| t)
174    }
175
176    fn peek_at(&self, n: usize) -> Option<&Tok> {
177        self.toks.get(self.at + n).map(|(t, _)| t)
178    }
179
180    fn line(&self) -> usize {
181        self.toks.get(self.at).map_or_else(
182            || self.toks.last().map_or(1, |(_, l)| *l),
183            |(_, l)| *l,
184        )
185    }
186
187    fn bump(&mut self) -> Option<(Tok, usize)> {
188        if self.at < self.toks.len() {
189            self.at += 1;
190            // Cheaper than cloning the whole vector: take the token out and
191            // leave a placeholder, since nothing reads a consumed slot.
192            Some(core::mem::replace(&mut self.toks[self.at - 1], (Tok::Eq, 0)))
193        } else {
194            None
195        }
196    }
197
198    /// Nodes until end of input or a `}`, which is left unconsumed.
199    fn nodes(&mut self) -> Result<Vec<Node>, String> {
200        let mut out = Vec::new();
201        loop {
202            match self.peek() {
203                None | Some(Tok::Close) => return Ok(out),
204                Some(Tok::Word(_)) => out.push(self.node()?),
205                Some(other) => {
206                    return Err(format!(
207                        "line {}: expected a view name, found {other:?}",
208                        self.line()
209                    ))
210                }
211            }
212        }
213    }
214
215    fn node(&mut self) -> Result<Node, String> {
216        let (name, line) = match self.bump() {
217            Some((Tok::Word(w), l)) => (w, l),
218            other => return Err(format!("internal: node() on {other:?}")),
219        };
220        let mut node =
221            Node { name, value: None, attrs: Vec::new(), children: Vec::new(), line };
222
223        loop {
224            match self.peek() {
225                // A string argument, once, before any attribute.
226                Some(Tok::Str(_)) if node.value.is_none() && node.attrs.is_empty() => {
227                    if let Some((Tok::Str(s), _)) = self.bump() {
228                        node.value = Some(s);
229                    }
230                }
231                Some(Tok::Word(w)) => {
232                    // `w=` makes it an attribute whatever else it looks like.
233                    if matches!(self.peek_at(1), Some(Tok::Eq)) {
234                        let key = w.clone();
235                        self.bump();
236                        self.bump();
237                        let value = match self.bump() {
238                            Some((Tok::Word(v), _)) | Some((Tok::Str(v), _)) => v,
239                            other => {
240                                return Err(format!(
241                                    "line {line}: `{key}=` wants a value, found {other:?}"
242                                ))
243                            }
244                        };
245                        node.attrs.push((key, value));
246                        continue;
247                    }
248                    // A bare word before any attribute is the argument, for
249                    // the nodes that take one: `progress 0.4`, `color #1d2b53`.
250                    // Restricted to those nodes so that a flag on a node with
251                    // no argument — `divider`, `spacer` — is still a flag.
252                    // `#1d2b53` was being taken as a flag, which silently gave
253                    // the card the default grey instead of navy.
254                    if node.value.is_none()
255                        && node.attrs.is_empty()
256                        && VALUED.contains(&node.name.as_str())
257                    {
258                        if let Some((Tok::Word(v), _)) = self.bump() {
259                            node.value = Some(v);
260                        }
261                        continue;
262                    }
263                    // Otherwise a known name starts the next sibling and
264                    // anything else is a flag on this node. Unknown words are
265                    // flags so that `build` reports them with a line number
266                    // rather than the parser guessing.
267                    if is_known(w) {
268                        return Ok(node);
269                    }
270                    if let Some((Tok::Word(f), _)) = self.bump() {
271                        node.attrs.push((f, String::new()));
272                    }
273                }
274                Some(Tok::Open) => {
275                    self.bump();
276                    node.children = self.nodes()?;
277                    match self.bump() {
278                        Some((Tok::Close, _)) => {}
279                        _ => {
280                            return Err(format!(
281                                "line {line}: `{}` is never closed",
282                                node.name
283                            ))
284                        }
285                    }
286                    return Ok(node);
287                }
288                None | Some(Tok::Close) => return Ok(node),
289                Some(Tok::Str(s)) => {
290                    return Err(format!(
291                        "line {}: `{}` already has a value; {s:?} is extra",
292                        self.line(),
293                        node.name
294                    ))
295                }
296                Some(Tok::Eq) => {
297                    return Err(format!("line {}: `=` with nothing before it", self.line()))
298                }
299            }
300        }
301    }
302}
303
304/// Parse a spec into a forest. Most specs are one tree; a file may hold several.
305pub fn parse(src: &str) -> Result<Vec<Node>, String> {
306    let mut p = Parser { toks: tokens(src)?, at: 0 };
307    let out = p.nodes()?;
308    if p.at < p.toks.len() {
309        return Err(format!("line {}: unexpected `}}`", p.line()));
310    }
311    Ok(out)
312}
313
314/// Whether a word names a view this module can build.
315///
316/// Used to tell a sibling apart from a flag. An unknown word is taken as a flag
317/// and reported by [`build`], which knows the line.
318fn is_known(w: &str) -> bool {
319    NAMES.contains(&w)
320}
321
322/// The nodes that take a bare argument, as opposed to only attributes.
323///
324/// `text` and `button` normally take a quoted string, but a one-word label
325/// needs no quotes, so they are here too.
326const VALUED: &[&str] =
327    &["text", "button", "color", "image", "progress", "gauge", "label"];
328
329/// Every view name the format accepts.
330///
331/// Public so a tool can list what a spec may contain — `dynamic_ui --names`
332/// prints this, which is the closest the format has to documentation that
333/// cannot go out of date.
334pub const NAMES: &[&str] = &[
335    "vstack", "hstack", "zstack", "text", "spacer", "divider", "button",
336    "color", "image", "rect", "circle", "capsule", "progress", "gauge",
337    "group_box", "list", "scroll", "form", "label",
338    // Controls bound to named slots. What makes reloading useful rather than
339    // only impressive: the slot outlives the view tree, so a toggle stays on
340    // while the file around it is rewritten.
341    "toggle", "slider", "stepper", "field",
342];
343
344// ── Named state, shared across rebuilds ──────────────────────────────────
345
346/// The slots `@name` refers to.
347///
348/// Keyed by name and created on first sight, so a spec rebuilt from changed
349/// text finds the same `BoundState` it had before — which is the difference
350/// between reloading a screen and resetting it. `BoundState` is `Arc`-backed,
351/// so handing the same one to a freshly built control is all it takes.
352///
353/// Three maps rather than one of an enum: the controls take
354/// `&BoundState<bool>`, `&BoundState<f64>` and `&BoundState<String>`, and a
355/// slot's type is fixed by the first control that claims it.
356#[derive(Default)]
357struct Slots {
358    flags: std::collections::HashMap<String, views::BoundState<bool>>,
359    numbers: std::collections::HashMap<String, views::BoundState<f64>>,
360    strings: std::collections::HashMap<String, views::BoundState<String>>,
361}
362
363fn slots() -> &'static std::sync::Mutex<Slots> {
364    static SLOTS: std::sync::OnceLock<std::sync::Mutex<Slots>> = std::sync::OnceLock::new();
365    SLOTS.get_or_init(Default::default)
366}
367
368/// Write a flag slot from outside, as a control's user would.
369///
370/// The slot has to exist: creating one here would hide a spec that never
371/// mentioned the name, which is the mistake this is used to check for.
372pub fn set_flag(name: &str, value: bool) {
373    slots()
374        .lock()
375        .expect("slots")
376        .flags
377        .get(name)
378        .unwrap_or_else(|| panic!("no flag slot called {name:?}"))
379        .set(value);
380}
381
382/// What one slot holds, whatever its type, or `None` if nothing claimed it.
383///
384/// Prefer this to scanning [`slot_summary`] when only one name matters: the
385/// registry is global and a process with two specs in it — a test suite, say —
386/// sees both.
387pub fn slot_value(name: &str) -> Option<String> {
388    let g = slots().lock().expect("slots");
389    g.flags
390        .get(name)
391        .map(|v| v.get().to_string())
392        .or_else(|| g.numbers.get(name).map(|v| format!("{:.2}", v.get())))
393        .or_else(|| g.strings.get(name).map(|v| format!("{:?}", v.get())))
394}
395
396/// What every slot currently holds, for a status line or a test.
397pub fn slot_summary() -> Vec<(String, String)> {
398    let g = slots().lock().expect("slots");
399    let mut out: Vec<(String, String)> = g
400        .flags
401        .iter()
402        .map(|(k, v)| (k.clone(), v.get().to_string()))
403        .chain(g.numbers.iter().map(|(k, v)| (k.clone(), format!("{:.2}", v.get()))))
404        .chain(g.strings.iter().map(|(k, v)| (k.clone(), format!("{:?}", v.get()))))
405        .collect();
406    out.sort();
407    out
408}
409
410/// The `@name` a control is bound to.
411///
412/// Written bare — `toggle "Dark" @dark` — which the parser sees as a flag,
413/// since `@dark` is not a view name. Accepting `state=dark` too means a spec
414/// generated by a program need not know about the sigil.
415fn slot_name(node: &Node) -> Option<String> {
416    if let Some(s) = node.attr("state") {
417        return Some(s.to_string());
418    }
419    node.attrs
420        .iter()
421        .find_map(|(k, v)| v.is_empty().then(|| k.strip_prefix('@')).flatten())
422        .map(str::to_string)
423}
424
425/// The slot a bound control names, or a message saying it must.
426fn require_slot(node: &Node) -> Result<String, String> {
427    slot_name(node).ok_or_else(|| {
428        format!(
429            "line {}: `{}` needs a slot to keep its value in — \
430             write `{} @name`",
431            node.line, node.name, node.name
432        )
433    })
434}
435
436fn flag_slot(name: &str, initial: bool) -> views::BoundState<bool> {
437    slots()
438        .lock()
439        .expect("slots")
440        .flags
441        .entry(name.to_string())
442        .or_insert_with(|| views::BoundState::new(initial))
443        .clone()
444}
445
446fn number_slot(name: &str, initial: f64) -> views::BoundState<f64> {
447    slots()
448        .lock()
449        .expect("slots")
450        .numbers
451        .entry(name.to_string())
452        .or_insert_with(|| views::BoundState::new(initial))
453        .clone()
454}
455
456fn string_slot(name: &str, initial: &str) -> views::BoundState<String> {
457    slots()
458        .lock()
459        .expect("slots")
460        .strings
461        .entry(name.to_string())
462        .or_insert_with(|| views::BoundState::new(initial.to_string()))
463        .clone()
464}
465
466// ── Builder ──────────────────────────────────────────────────────────────
467
468/// Turn a parsed forest into one view.
469///
470/// Several roots are stacked, so a file that forgot its outer `vstack` still
471/// renders rather than erroring.
472pub fn build(nodes: &[Node]) -> Result<View, String> {
473    match nodes {
474        [] => Err("the spec is empty".to_string()),
475        [one] => node_view(one),
476        many => {
477            let kids: Result<Vec<View>, String> = many.iter().map(node_view).collect();
478            Ok(vstack_spacing(&kids?, 8.0))
479        }
480    }
481}
482
483fn children(node: &Node) -> Result<Vec<View>, String> {
484    node.children.iter().map(node_view).collect()
485}
486
487fn node_view(node: &Node) -> Result<View, String> {
488    let spacing = node.f64_attr("spacing").unwrap_or(8.0);
489    let base = match node.name.as_str() {
490        "vstack" => {
491            let kids = children(node)?;
492            match node.attr("align") {
493                Some(a) => vstack_aligned_spacing(&kids, h_align(a, node.line)?, Some(spacing)),
494                None => vstack_spacing(&kids, spacing),
495            }
496        }
497        "hstack" => {
498            let kids = children(node)?;
499            match node.attr("align") {
500                Some(a) => hstack_aligned_spacing(&kids, v_align(a, node.line)?, Some(spacing)),
501                None => hstack_spacing(&kids, spacing),
502            }
503        }
504        "zstack" => zstack_aligned(&children(node)?, align(node.attr("align").unwrap_or("center"), node.line)?),
505        "text" => {
506            let s = node.value.as_deref().unwrap_or("");
507            let mut v = text(s);
508            if let Some(style) = node.attr("style") {
509                v = v.text_style(text_style(style, node.line)?);
510            }
511            if let Some(size) = node.f64_attr("size") {
512                v = v.font_size(size);
513            }
514            if node.flag("bold") {
515                v = v.bold();
516            }
517            if node.flag("italic") {
518                v = v.italic();
519            }
520            if let Some(c) = node.attr("color") {
521                v = match tint(c, node.line)? {
522                    Tint::System(sc) => v.fg_system(sc),
523                    Tint::Literal(lc) => v.fg(lc),
524                };
525            }
526            v
527        }
528        "spacer" => spacer(),
529        "divider" => divider(),
530        // The action is a no-op on purpose: a spec read from a file has nowhere
531        // to put behaviour, and pretending otherwise would be worse than a
532        // button that visibly does nothing. `hot_reload.rs` is where a spec
533        // gains behaviour, by being reloaded.
534        "button" => button(node.value.as_deref().unwrap_or("Button"), || {}),
535        "color" => color(parse_color(node.value.as_deref().unwrap_or("#808080"), node.line)?),
536        "image" => system_image(node.value.as_deref().unwrap_or("star")).into_view(),
537        "rect" => match node.attr("color") {
538            Some(c) => match tint(c, node.line)? {
539                Tint::System(sc) => rectangle_shape().fill_system(sc),
540                Tint::Literal(lc) => rectangle_shape().fill(lc),
541            },
542            None => rectangle_shape().into_view(),
543        },
544        "circle" => match node.attr("color") {
545            Some(c) => match tint(c, node.line)? {
546                Tint::System(sc) => circle_shape().fill_system(sc),
547                Tint::Literal(lc) => circle_shape().fill(lc),
548            },
549            None => circle_shape().into_view(),
550        },
551        "capsule" => match node.attr("color") {
552            Some(c) => match tint(c, node.line)? {
553                Tint::System(sc) => capsule_shape().fill_system(sc),
554                Tint::Literal(lc) => capsule_shape().fill(lc),
555            },
556            None => capsule_shape().into_view(),
557        },
558        "progress" => progress(
559            node.value.as_deref().and_then(|v| v.parse().ok()).unwrap_or(0.5),
560            node.f64_attr("total").unwrap_or(1.0),
561        ),
562        "gauge" => gauge(
563            node.value.as_deref().and_then(|v| v.parse().ok()).unwrap_or(0.5),
564            node.f64_attr("min").unwrap_or(0.0),
565            node.f64_attr("max").unwrap_or(1.0),
566            text(node.attr("label").unwrap_or("")),
567        ),
568        "group_box" => group_box(stack_children(node)?),
569        "list" => list(stack_children(node)?),
570        "scroll" => stack_children(node)?.scroll(),
571        "form" => form(stack_children(node)?),
572        // Every bound control needs an explicit `@name`.
573        //
574        // Inventing one — from the line, or from position in the tree — looks
575        // friendlier and is a trap: adding a line above a `toggle` would
576        // change its derived name, and the control would silently reset on the
577        // next reload. A name the file states is a name that survives the file
578        // being edited, which is the entire point of the slots.
579        "toggle" => toggle_bound(
580            node.value.as_deref().unwrap_or("Toggle"),
581            &flag_slot(&require_slot(node)?, node.flag("on")),
582        ),
583        "slider" => {
584            let lo = node.f64_attr("min").unwrap_or(0.0);
585            let hi = node.f64_attr("max").unwrap_or(1.0);
586            slider_bound(
587                &number_slot(&require_slot(node)?, node.f64_attr("value").unwrap_or(lo)),
588                lo,
589                hi,
590            )
591        }
592        "stepper" => stepper_bound(
593            node.value.as_deref().unwrap_or("Stepper"),
594            &number_slot(&require_slot(node)?, node.f64_attr("value").unwrap_or(0.0)),
595            node.f64_attr("min").unwrap_or(0.0),
596            node.f64_attr("max").unwrap_or(100.0),
597            node.f64_attr("step").unwrap_or(1.0),
598        ),
599        "field" => textfield_bound(
600            node.value.as_deref().unwrap_or(""),
601            &string_slot(&require_slot(node)?, node.attr("text").unwrap_or("")),
602        ),
603        "label" => label(
604            node.value.as_deref().unwrap_or(""),
605            node.attr("icon").unwrap_or("star"),
606        ),
607        other => {
608            return Err(format!(
609                "line {}: no view called `{other}`; try one of {}",
610                node.line,
611                NAMES.join(", ")
612            ))
613        }
614    };
615    decorate(base, node)
616}
617
618/// A container that takes a single child gets its children stacked.
619fn stack_children(node: &Node) -> Result<View, String> {
620    let kids = children(node)?;
621    Ok(match kids.len() {
622        1 => kids.into_iter().next().expect("one"),
623        _ => vstack_spacing(&kids, node.f64_attr("spacing").unwrap_or(8.0)),
624    })
625}
626
627/// The modifiers any node may carry, applied in a fixed order.
628///
629/// Order matters in SwiftUI — `.padding().background()` and
630/// `.background().padding()` draw differently — so it is written down here
631/// rather than left to the order the attributes happened to appear in.
632fn decorate(mut v: View, node: &Node) -> Result<View, String> {
633    if let Some(p) = node.f64_attr("pad") {
634        v = v.padding(p);
635    }
636    if let Some(size) = node.attr("frame") {
637        let (w, h) = parse_size(size, node.line)?;
638        v = v.frame(w, h);
639    }
640    if let Some(o) = node.f64_attr("opacity") {
641        v = v.opacity(o);
642    }
643    if let Some(c) = node.attr("bg") {
644        let c = parse_color(c, node.line)?;
645        v = v.bg(c);
646    }
647    if let Some(r) = node.f64_attr("corner") {
648        v = v.corner_radius(r);
649    }
650    Ok(v)
651}
652
653// ── Value parsing ────────────────────────────────────────────────────────
654
655fn parse_size(s: &str, line: usize) -> Result<(f64, f64), String> {
656    let (w, h) = s
657        .split_once('x')
658        .ok_or_else(|| format!("line {line}: `frame={s}` should be WIDTHxHEIGHT"))?;
659    let w = w.trim().parse().map_err(|_| format!("line {line}: `{w}` is not a number"))?;
660    let h = h.trim().parse().map_err(|_| format!("line {line}: `{h}` is not a number"))?;
661    Ok((w, h))
662}
663
664fn parse_color(s: &str, line: usize) -> Result<Color, String> {
665    if let Some(hex) = s.strip_prefix('#') {
666        let v = u32::from_str_radix(hex, 16)
667            .map_err(|_| format!("line {line}: `#{hex}` is not a hex colour"))?;
668        return Ok(Color::hex(v));
669    }
670    Ok(match s {
671        "red" => Color::RED,
672        "green" => Color::GREEN,
673        "blue" => Color::BLUE,
674        "white" => Color::WHITE,
675        "black" => Color::BLACK,
676        "dark" => Color::DARK,
677        other => return Err(format!("line {line}: no colour called `{other}`")),
678    })
679}
680
681/// What a `color=` word means.
682///
683/// SwiftUI has two different things called a colour: the semantic ones that
684/// resolve against the appearance (`secondary`, `blue`) and literal values
685/// (`white`, `#1d2b53`). They reach different modifiers, so which one a word
686/// names has to be decided rather than assumed — and decided in one place,
687/// because `text`, `rect`, `circle` and `capsule` all ask the same question.
688/// They did not, at first: `rect color=#336699` reported "no system colour".
689enum Tint {
690    System(SystemColor),
691    Literal(Color),
692}
693
694fn tint(s: &str, line: usize) -> Result<Tint, String> {
695    match system_color(s, line) {
696        Ok(sc) => Ok(Tint::System(sc)),
697        // Not a semantic name, so it must be a literal — and if it is neither,
698        // the literal parser has the better error message.
699        Err(_) => Ok(Tint::Literal(parse_color(s, line)?)),
700    }
701}
702
703fn system_color(s: &str, line: usize) -> Result<SystemColor, String> {
704    Ok(match s {
705        "red" => SystemColor::Red,
706        "orange" => SystemColor::Orange,
707        "yellow" => SystemColor::Yellow,
708        "green" => SystemColor::Green,
709        "mint" => SystemColor::Mint,
710        "teal" => SystemColor::Teal,
711        "cyan" => SystemColor::Cyan,
712        "blue" => SystemColor::Blue,
713        "indigo" => SystemColor::Indigo,
714        "purple" => SystemColor::Purple,
715        "pink" => SystemColor::Pink,
716        "brown" => SystemColor::Brown,
717        "gray" | "grey" => SystemColor::Gray,
718        "primary" => SystemColor::Primary,
719        "secondary" => SystemColor::Secondary,
720        other => return Err(format!("line {line}: no system colour called `{other}`")),
721    })
722}
723
724fn text_style(s: &str, line: usize) -> Result<TextStyle, String> {
725    Ok(match s {
726        "large_title" => TextStyle::LargeTitle,
727        "title" => TextStyle::Title,
728        "title2" => TextStyle::Title2,
729        "title3" => TextStyle::Title3,
730        "headline" => TextStyle::Headline,
731        "subheadline" => TextStyle::Subheadline,
732        "body" => TextStyle::Body,
733        "callout" => TextStyle::Callout,
734        "footnote" => TextStyle::Footnote,
735        "caption" => TextStyle::Caption,
736        "caption2" => TextStyle::Caption2,
737        other => return Err(format!("line {line}: no text style called `{other}`")),
738    })
739}
740
741fn align(s: &str, line: usize) -> Result<views::Alignment, String> {
742    Ok(match s {
743        "center" => views::Alignment::Center,
744        "leading" => views::Alignment::Leading,
745        "trailing" => views::Alignment::Trailing,
746        "top" => views::Alignment::Top,
747        "bottom" => views::Alignment::Bottom,
748        "top_leading" => views::Alignment::TopLeading,
749        "top_trailing" => views::Alignment::TopTrailing,
750        "bottom_leading" => views::Alignment::BottomLeading,
751        "bottom_trailing" => views::Alignment::BottomTrailing,
752        other => return Err(format!("line {line}: no alignment called `{other}`")),
753    })
754}
755
756fn h_align(s: &str, line: usize) -> Result<HorizontalAlignment, String> {
757    Ok(match s {
758        "leading" => HorizontalAlignment::Leading,
759        "center" => HorizontalAlignment::Center,
760        "trailing" => HorizontalAlignment::Trailing,
761        other => return Err(format!("line {line}: no horizontal alignment called `{other}`")),
762    })
763}
764
765fn v_align(s: &str, line: usize) -> Result<views::VerticalAlignment, String> {
766    Ok(match s {
767        "top" => views::VerticalAlignment::Top,
768        "center" => views::VerticalAlignment::Center,
769        "bottom" => views::VerticalAlignment::Bottom,
770        "first_baseline" => views::VerticalAlignment::FirstTextBaseline,
771        "last_baseline" => views::VerticalAlignment::LastTextBaseline,
772        other => return Err(format!("line {line}: no vertical alignment called `{other}`")),
773    })
774}