Skip to main content

yog_cli/design/
host.rs

1//! The designer host: a child process of the IDE (`yog designer-host`)
2//! that loads a mod's library built for this machine (`yog designer-lib`)
3//! and draws the mod's custom `.yui` components through the same recording
4//! `YogGfxApi` the designer uses — the mod's own yog-ui and `build` code,
5//! out of the IDE's process, so a panicking/crashing/looping component (or
6//! a rebuilt library) costs a restart of the host, never the IDE.
7//!
8//! Protocol: one JSON request per stdin line; one JSON answer per stdout
9//! line, prefixed with [`MARK`] (the mod's code may print to stdout too).
10//!
11//! - `{"cmd":"load","lib":…,"modRoot":…}` → `{"ok":true,"components":[…]}`
12//! - `{"cmd":"render","name":…,"props":[[k,v]…],"w":…,"h":…,"availW":…,"availH":…}`
13//!   → `{"ok":true,"w":…,"h":…,"ops":[DrawOp…]}` (drawn at the origin; `w`/`h` ≤ 0: natural size)
14//! - `{"cmd":"png", …render…, "scale":4, "out":"x.png"}` → the same, also
15//!   rasterized into `out` (for checking a component from a shell)
16//!
17//! Failures answer `{"ok":false,"error":…}`.
18
19use std::ffi::c_void;
20use std::io::{BufRead, Write};
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24use serde_json::{Value, json};
25use yog_abi::{YogGfxApi, YogStr};
26
27use super::raster::DrawOp;
28use super::{gfx, images, raster};
29
30/// Starts every answer line on stdout.
31pub const MARK: &str = "\u{1}yog-designer\u{1}";
32
33/// The designer protocol version this host speaks (`yog_ui::component::designer::ABI`).
34pub const DESIGNER_ABI: u32 = 1;
35
36type DesignerFn = unsafe extern "C" fn(
37    YogStr,
38    *const YogGfxApi,
39    *mut c_void,
40    unsafe extern "C" fn(*mut c_void, YogStr),
41) -> bool;
42
43/// A loaded mod library.
44pub struct Loaded {
45    _lib: libloading::Library,
46    call: DesignerFn,
47}
48
49/// One component as the mod describes it (`describe`).
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct ComponentInfo {
52    pub name: String,
53    /// Toolbox tooltip and property grid header (may be `@Set.key`).
54    #[serde(default)]
55    pub description: String,
56    /// The toolbox label (empty: `name`; may be `@Set.key`).
57    #[serde(default)]
58    pub display_name: String,
59    /// The toolbox icon — a Yog image source or `@Icons.x` (empty: a glyph).
60    #[serde(default)]
61    pub icon: String,
62    /// The toolbox category (empty: "<mod> Components"; may be `@Set.key`).
63    #[serde(default)]
64    pub category: String,
65    /// The canvas size for editing it, and an instance's size when dropped (0: unset).
66    #[serde(default)]
67    pub design_w: f32,
68    #[serde(default)]
69    pub design_h: f32,
70    /// Whether the toolbox offers it.
71    #[serde(default = "yes")]
72    pub toolbox: bool,
73    #[serde(default)]
74    pub props: Vec<PropInfo>,
75    /// The `.yui` declaring it (the designer's own listing; not from the host).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub file: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub struct PropInfo {
82    pub name: String,
83    /// `text`, `number`, `int`, `bool`, `color`, `choice`, `event`, `image`.
84    pub kind: String,
85    #[serde(default)]
86    pub default: String,
87    #[serde(default)]
88    pub doc: String,
89    /// The property grid category (empty: the component's own).
90    #[serde(default)]
91    pub category: String,
92    #[serde(default)]
93    pub options: Vec<String>,
94}
95
96fn yes() -> bool {
97    true
98}
99
100unsafe extern "C" fn sink(user_data: *mut c_void, text: YogStr) {
101    // SAFETY: `user_data` is the `String` `Loaded::call` passes; `text` is valid for the call.
102    unsafe { (*(user_data as *mut String)).push_str(text.as_str()) };
103}
104
105impl Loaded {
106    /// Loads `lib`, checking it speaks this IDE's gfx ABI and designer protocol.
107    pub fn open(lib: &Path) -> Result<Self, String> {
108        // SAFETY: loading runs the library's constructors — the mod's own
109        // code, which is exactly what this (disposable) process is for.
110        let lib = unsafe { libloading::Library::new(lib) }
111            .map_err(|e| format!("can't load {}: {e}", lib.display()))?;
112        // SAFETY: symbols of `export_mod!` with these signatures.
113        unsafe {
114            let abi = lib
115                .get::<unsafe extern "C" fn() -> u32>(b"yog_abi_version\0")
116                .map_err(|_| {
117                    "not a Yog mod library (no yog_abi_version — is `export_mod!` missing?)"
118                        .to_string()
119                })?;
120            let abi = abi();
121            if abi != yog_abi::ABI_VERSION {
122                return Err(format!(
123                    "the mod is built against Yog ABI {abi}, the designer against {} — update yog-api (or Yog-IDLE) so they match",
124                    yog_abi::ABI_VERSION
125                ));
126            }
127            let designer = lib
128                .get::<unsafe extern "C" fn() -> u32>(b"yog_ui_designer_abi\0")
129                .map_err(|_| {
130                    "the mod's yog-api has no designer support (yog_ui_designer) — update yog-api"
131                        .to_string()
132                })?;
133            let designer = designer();
134            if designer != DESIGNER_ABI {
135                return Err(format!(
136                    "designer protocol {designer} (this IDE speaks {DESIGNER_ABI}) — update yog-api or Yog-IDLE"
137                ));
138            }
139            let call = *lib
140                .get::<DesignerFn>(b"yog_ui_designer\0")
141                .map_err(|e| e.to_string())?;
142            Ok(Self { _lib: lib, call })
143        }
144    }
145
146    fn call(&self, request: &str, gfx: *const YogGfxApi) -> Result<String, String> {
147        let mut out = String::new();
148        // SAFETY: `request` lives for the call; `gfx` is null or a valid table; `sink` matches `out`.
149        let ok = unsafe {
150            (self.call)(
151                YogStr::from_str(request),
152                gfx,
153                &mut out as *mut String as *mut c_void,
154                sink,
155            )
156        };
157        if ok { Ok(out) } else { Err(out) }
158    }
159
160    pub fn describe(&self) -> Result<Vec<ComponentInfo>, String> {
161        let answer = self.call("describe", std::ptr::null())?;
162        #[derive(Deserialize)]
163        struct Described {
164            components: Vec<ComponentInfo>,
165        }
166        serde_json::from_str::<Described>(&answer)
167            .map(|d| d.components)
168            .map_err(|e| format!("bad describe answer: {e}"))
169    }
170
171    /// Draws `name` at the origin; returns its size and what it drew.
172    pub fn render(&self, req: &RenderReq) -> Result<(f32, f32, Vec<DrawOp>), String> {
173        let mut request = format!(
174            "render\n{}\n{} {} {} {}\n",
175            req.name, req.w, req.h, req.avail_w, req.avail_h
176        );
177        for (k, v) in &req.props {
178            request.push_str(k);
179            request.push('=');
180            request.push_str(&v.replace('\\', "\\\\").replace('\n', "\\n"));
181            request.push('\n');
182        }
183        let (aw, ah) = (req.avail_w.max(1.0) as i32, req.avail_h.max(1.0) as i32);
184        let mut answer = Err(String::new());
185        // The same recording functions as `record`'s own table, so they record into its list.
186        let table = gfx::api(aw, ah, 1.0);
187        let ops = gfx::record(aw, ah, |_| answer = self.call(&request, &table));
188        let answer = answer?;
189        let size: Value =
190            serde_json::from_str(&answer).map_err(|e| format!("bad render answer: {e}"))?;
191        let dim = |k: &str| size.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
192        Ok((dim("w"), dim("h"), ops))
193    }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct RenderReq {
199    pub name: String,
200    #[serde(default)]
201    pub props: Vec<(String, String)>,
202    #[serde(default)]
203    pub w: f32,
204    #[serde(default)]
205    pub h: f32,
206    #[serde(default = "big")]
207    pub avail_w: f32,
208    #[serde(default = "big")]
209    pub avail_h: f32,
210}
211
212/// Room for a component that gives no bound: more than any screen has.
213const UNBOUNDED: f32 = 1024.0;
214
215fn big() -> f32 {
216    UNBOUNDED
217}
218
219#[derive(Deserialize)]
220#[serde(tag = "cmd", rename_all = "camelCase")]
221enum Request {
222    #[serde(rename_all = "camelCase")]
223    Load {
224        lib: PathBuf,
225        mod_root: Option<PathBuf>,
226    },
227    Render(RenderReq),
228    Png {
229        #[serde(flatten)]
230        req: RenderReq,
231        #[serde(default)]
232        scale: Option<u32>,
233        out: PathBuf,
234    },
235}
236
237struct Host {
238    lib: Option<Loaded>,
239    mod_root: Option<PathBuf>,
240}
241
242impl Host {
243    fn handle(&mut self, line: &str) -> Value {
244        let request: Request = match serde_json::from_str(line) {
245            Ok(r) => r,
246            Err(e) => return json!({"ok": false, "error": format!("bad request: {e}")}),
247        };
248        let result = match request {
249            Request::Load { lib, mod_root } => {
250                if self.lib.is_some() {
251                    return json!({"ok": false, "error": "a library is already loaded (restart the host to reload)"});
252                }
253                images::set_document(mod_root.as_deref());
254                self.mod_root = mod_root;
255                Loaded::open(&lib).and_then(|l| {
256                    let components = l.describe()?;
257                    self.lib = Some(l);
258                    Ok(json!({"ok": true, "components": components}))
259                })
260            }
261            Request::Render(req) => self
262                .loaded()
263                .and_then(|l| l.render(&req))
264                .map(|(w, h, ops)| json!({"ok": true, "w": w, "h": h, "ops": ops})),
265            Request::Png { req, scale, out } => self
266                .loaded()
267                .and_then(|l| l.render(&req))
268                .and_then(|(w, h, ops)| {
269                    let res = super::assets::Resources::for_mod(self.mod_root.as_deref());
270                    let (cw, ch) = (w.ceil().max(1.0) as u32, h.ceil().max(1.0) as u32);
271                    let canvas =
272                        raster::rasterize(&ops, cw, ch, scale.unwrap_or(4).clamp(1, 8), &res);
273                    std::fs::write(&out, canvas.png()?)
274                        .map_err(|e| format!("{}: {e}", out.display()))?;
275                    Ok(json!({"ok": true, "w": w, "h": h, "ops": ops.len(), "out": out}))
276                }),
277        };
278        result.unwrap_or_else(|e| json!({"ok": false, "error": e}))
279    }
280
281    fn loaded(&self) -> Result<&Loaded, String> {
282        self.lib
283            .as_ref()
284            .ok_or_else(|| "no library loaded".to_string())
285    }
286}
287
288/// The CLI command that runs a host.
289pub const COMMAND: &str = "designer-host";
290
291/// `yog designer-host`: serves requests until stdin closes.
292pub fn serve() {
293    let mut host = Host {
294        lib: None,
295        mod_root: None,
296    };
297    let stdin = std::io::stdin();
298    for line in stdin.lock().lines() {
299        let Ok(line) = line else { break };
300        if line.trim().is_empty() {
301            continue;
302        }
303        let answer = host.handle(&line);
304        let mut out = std::io::stdout().lock();
305        if writeln!(out, "{MARK}{answer}")
306            .and_then(|_| out.flush())
307            .is_err()
308        {
309            break;
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn requests_parse() {
320        let r: Request = serde_json::from_str(
321            r#"{"cmd":"render","name":"StatBar","props":[["value","0.5"]],"w":0}"#,
322        )
323        .unwrap();
324        assert!(
325            matches!(r, Request::Render(RenderReq { ref name, ref props, avail_w, .. }) if name == "StatBar" && props.len() == 1 && avail_w == 1024.0)
326        );
327        let mut host = Host {
328            lib: None,
329            mod_root: None,
330        };
331        assert_eq!(
332            host.handle(r#"{"cmd":"render","name":"X"}"#)["error"],
333            "no library loaded"
334        );
335        assert!(
336            host.handle(r#"{"cmd":"load","lib":"/nowhere/libnope.so"}"#)["error"]
337                .as_str()
338                .unwrap()
339                .contains("can't load")
340        );
341    }
342
343    /// `YOG_DESIGNER_LIB=…/libmod.so YOG_DESIGNER_COMPONENT=StatBar [YOG_DESIGNER_PNG=out.png]
344    /// cargo test --lib designer::host -- --ignored` (`YOG_DESIGNER_PROPS="k=v;k=v"`)
345    #[test]
346    #[ignore]
347    fn renders_a_built_mod() {
348        let (Ok(lib), Ok(name)) = (
349            std::env::var("YOG_DESIGNER_LIB"),
350            std::env::var("YOG_DESIGNER_COMPONENT"),
351        ) else {
352            return;
353        };
354        let props = std::env::var("YOG_DESIGNER_PROPS").unwrap_or_default();
355        let props = props
356            .split(';')
357            .filter_map(|kv| kv.split_once('='))
358            .map(|(k, v)| (k.to_string(), v.to_string()))
359            .collect();
360        let mut host = Host {
361            lib: None,
362            mod_root: None,
363        };
364        let loaded = host.handle(&json!({"cmd": "load", "lib": lib}).to_string());
365        eprintln!("{loaded:#}");
366        let l = host.loaded().unwrap();
367        let (w, h, ops) = l
368            .render(&RenderReq {
369                name: name.clone(),
370                props,
371                w: 0.0,
372                h: 0.0,
373                avail_w: 400.0,
374                avail_h: 300.0,
375            })
376            .unwrap();
377        eprintln!("{w}x{h}: {ops:?}");
378        assert!(w > 0.0 && h > 0.0 && !ops.is_empty());
379        if let Ok(out) = std::env::var("YOG_DESIGNER_PNG") {
380            let res = super::super::assets::Resources::for_mod(None);
381            let canvas = raster::rasterize(&ops, w.ceil() as u32, h.ceil() as u32, 6, &res);
382            std::fs::write(&out, canvas.png().unwrap()).unwrap();
383        }
384    }
385}