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}