The grey cat as Kotlin source, so the app draws what the site draws without a native call per frame (a Robolectric screenshot cannot load the native library, and a cat that could not be photographed off-device could not have goldens). bin/gen-kotlin.rs writes it to android/app/src/main/kotlin/app/whiskers/art/GreyScenes.kt, a build output like the UniFFI bindings; tools/build-core.sh runs it before every Gradle build.

Only the drawing is generated. The Kotlin evaluator that plays the motions is written by hand (art/Motion.kt), and fixtures/frames.txt pins it to motion.rs.

10use std::fmt::Write;
12use whiskers_pet::Mood;
13
14use crate::node::{Color, Group, Look, Node, Role};
15use crate::scene::{MOODS, Mouth, scene};
16
17fn f(v: f32) -> String {
18    format!("{v:?}f")
19}
20
21fn rgb(c: Color) -> String {
22    format!("0x{:02X}{:02X}{:02X}", c.0, c.1, c.2)
23}
24
25fn look(look: Look) -> String {
26    match look {
27        Look::Follow => "Look.Follow".into(),
28        Look::Fixed(x, y) => format!("Look.Fixed({}, {})", f(x), f(y)),
29    }
30}
31
32fn node(n: &Node, out: &mut String, depth: usize) {
33    let pad = "    ".repeat(depth);
34    match n {
35        Node::Rect(r) => {
36            let alpha = if r.alpha == 1.0 { String::new() } else { format!(", {}", f(r.alpha)) };
37            let _ = writeln!(out, "{pad}r({}, {}, {}, {}, {}{alpha}),", f(r.x), f(r.y), f(r.w), f(r.h), rgb(r.color));
38        }
39        Node::Group(g) => group(g, out, depth),
40    }
41}
42
43fn group(g: &Group, out: &mut String, depth: usize) {
44    let pad = "    ".repeat(depth);
45    match g.role {
46        Role::Eyes(l) => {
47            let _ = writeln!(out, "{pad}eyes({}, listOf(", look(l));
48        }
49        Role::Plain => {
50            let _ = writeln!(out, "{pad}group(Motion.{:?}, {}, {}, listOf(", g.motion, f(g.origin.0), f(g.origin.1));
51        }
52    }
53    for child in &g.children {
54        node(child, out, depth + 1);
55    }
56    let _ = writeln!(out, "{pad})),");
57}
58
59fn function_name(mood: Mood, mouth: Option<Mouth>) -> String {
60    let base = format!("{mood:?}");
61    let base = format!("{}{}", base[..1].to_lowercase(), &base[1..]);
62    match mouth {
63        None => base,
64        Some(m) => format!("{base}{m:?}"),
65    }
66}

The whole of GreyScenes.kt.

69pub fn generate() -> String {
70    let mut out = String::new();
71    out.push_str(
72        "// GENERATED by `cargo run -p whiskers-art --bin gen-kotlin` from crates/whiskers-art/src/grey.rs.\n\
73         // Do not edit; change the Rust and run tools/build-core.sh.\n\
74         package app.whiskers.art\n\n\
75         import app.whiskers.Mood\n\
76         import app.whiskers.MouthShape\n\n\
77         /** The grey cat in each mood: the same data the website draws. */\n\
78         internal object GreyScenes {\n\
79         \x20   fun scene(mood: Mood, mouth: MouthShape?): List<ArtNode> = when (mood) {\n",
80    );
81    for mood in MOODS {
82        if mood == Mood::Speaking {
83            let _ = writeln!(out, "        Mood.Speaking -> when (mouth) {{");
84            let _ = writeln!(out, "            null -> {}", function_name(mood, None));
85            for m in Mouth::ALL {
86                let _ = writeln!(out, "            MouthShape.{m:?} -> {}", function_name(mood, Some(m)));
87            }
88            out.push_str("        }\n");
89        } else {
90            let _ = writeln!(out, "        Mood.{mood:?} -> {}", function_name(mood, None));
91        }
92    }
93    out.push_str("    }\n");
94    for mood in MOODS {
95        let cases: Vec<Option<Mouth>> = if mood == Mood::Speaking { std::iter::once(None).chain(Mouth::ALL.map(Some)).collect() } else { vec![None] };
96        for mouth in cases {
97            let _ = writeln!(out, "\n    private val {}: List<ArtNode> by lazy {{\n        listOf(", function_name(mood, mouth));
98            for n in scene(mood, mouth) {
99                node(&n, &mut out, 3);
100            }
101            out.push_str("        )\n    }\n");
102        }
103    }
104    out.push_str("}\n");
105    out
106}
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn every_mood_and_mouth_is_written_and_the_text_is_balanced() {
114        let kt = generate();
115        for mood in MOODS {
116            assert!(kt.contains(&format!("Mood.{mood:?} ->")), "{mood:?}");
117        }
118        for m in Mouth::ALL {
119            assert!(kt.contains(&format!("MouthShape.{m:?} ->")), "{m:?}");
120        }
121        assert_eq!(kt.matches('(').count(), kt.matches(')').count());
122        assert_eq!(kt.matches('{').count(), kt.matches('}').count());
123        assert!(!kt.contains("NaN") && !kt.contains("inf"));
124    }
125
126    #[test]
127    fn floats_are_written_so_kotlin_reads_back_the_same_number() {
128        assert_eq!(f(1.0), "1.0f");
129        assert_eq!(f(-0.7), "-0.7f");
130        assert_eq!(f(0.93), "0.93f");
131    }
132}