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: a [`HostRequest`] per frame of binary Yog Data on stdin, a
9//! [`HostReply`] per frame on stdout, each after [`MARK`] (the mod's code
10//! may print to stdout too; [`next_answer`] skips what it prints).
11//!
12//! - `Load` → `Loaded` (the mod's components)
13//! - `Render` → `Drawn` (its size and what it drew, at the origin; `w`/`h` ≤ 0: natural size)
14//! - `Png` → `Written`: the same, rasterized into `out` (for checking a component from a shell)
15
16use std::ffi::c_void;
17use std::io::{self, BufRead, Write};
18use std::path::{Path, PathBuf};
19
20use yog_abi::{YogBytes, YogGfxApi};
21use yog_data::Schema;
22use yog_ui::component::designer::{self as protocol, Answer};
23
24pub use yog_ui::component::designer::{ComponentInfo, PropInfo, Render as RenderReq};
25
26use super::raster::DrawOp;
27use super::{gfx, images, raster};
28
29/// Starts every answer on stdout.
30pub const MARK: &[u8] = b"\x01yog-designer\x01";
31
32/// The designer protocol version this host speaks.
33pub const DESIGNER_ABI: u32 = protocol::ABI;
34
35/// How finely `Png` rasterizes when not told, and the finest it may.
36const PNG_SCALE: u32 = 4;
37const MAX_PNG_SCALE: u32 = 8;
38
39type DesignerFn =
40    unsafe extern "C" fn(YogBytes, *const YogGfxApi, *mut c_void, protocol::WriteFn) -> bool;
41
42/// A loaded mod library.
43pub struct Loaded {
44    _lib: libloading::Library,
45    call: DesignerFn,
46}
47
48unsafe extern "C" fn sink(user_data: *mut c_void, reply: YogBytes) {
49    // SAFETY: `user_data` is the `Vec` `Loaded::ask` passes; `reply` is valid for the call.
50    unsafe { (*(user_data as *mut Vec<u8>)).extend_from_slice(reply.as_slice()) };
51}
52
53impl Loaded {
54    /// Loads `lib`, checking it speaks this IDE's gfx ABI and designer protocol.
55    pub fn open(lib: &Path) -> Result<Self, String> {
56        // SAFETY: loading runs the library's constructors — the mod's own
57        // code, which is exactly what this (disposable) process is for.
58        let lib = unsafe { libloading::Library::new(lib) }
59            .map_err(|e| format!("can't load {}: {e}", lib.display()))?;
60        // SAFETY: symbols of `export_mod!` with these signatures.
61        unsafe {
62            let abi = lib
63                .get::<unsafe extern "C" fn() -> u32>(b"yog_abi_version\0")
64                .map_err(|_| {
65                    "not a Yog mod library (no yog_abi_version — is `export_mod!` missing?)"
66                        .to_string()
67                })?;
68            let abi = abi();
69            if abi != yog_abi::ABI_VERSION {
70                return Err(format!(
71                    "the mod is built against Yog ABI {abi}, the designer against {} — update yog-api (or Yog-IDLE) so they match",
72                    yog_abi::ABI_VERSION
73                ));
74            }
75            let designer = lib
76                .get::<unsafe extern "C" fn() -> u32>(b"yog_ui_designer_abi\0")
77                .map_err(|_| {
78                    "the mod's yog-api has no designer support (yog_ui_designer) — update yog-api"
79                        .to_string()
80                })?;
81            let designer = designer();
82            if designer != DESIGNER_ABI {
83                return Err(format!(
84                    "designer protocol {designer} (this IDE speaks {DESIGNER_ABI}) — update yog-api or Yog-IDLE"
85                ));
86            }
87            let call = *lib
88                .get::<DesignerFn>(b"yog_ui_designer\0")
89                .map_err(|e| e.to_string())?;
90            Ok(Self { _lib: lib, call })
91        }
92    }
93
94    /// The mod's answer to `request` (`gfx`: null, or the table to draw through).
95    fn ask(&self, request: &protocol::Request, gfx: *const YogGfxApi) -> Result<Answer, String> {
96        let request = yog_data::to_bytes(request);
97        let mut reply = Vec::new();
98        // SAFETY: `request` lives for the call; `gfx` is null or a valid table; `sink` matches `reply`.
99        unsafe {
100            (self.call)(
101                YogBytes::new(&request),
102                gfx,
103                &mut reply as *mut Vec<u8> as *mut c_void,
104                sink,
105            )
106        };
107        yog_data::read_bytes::<protocol::Reply>(&reply)
108            .map_err(|e| format!("a mod's answer that isn't one: {e:?}"))?
109    }
110
111    pub fn describe(&self) -> Result<Vec<ComponentInfo>, String> {
112        match self.ask(&protocol::Request::Describe, std::ptr::null())? {
113            Answer::Described { components } => Ok(components),
114            other => Err(format!("asked to describe, the mod answered {other:?}")),
115        }
116    }
117
118    /// Draws `req.name` at the origin; returns its size and what it drew.
119    pub fn render(&self, req: &RenderReq) -> Result<(f32, f32, Vec<DrawOp>), String> {
120        let (aw, ah) = (req.avail_w.max(1.0) as i32, req.avail_h.max(1.0) as i32);
121        let mut answer = Err(String::new());
122        // The same recording functions as `record`'s own table, so they record into its list.
123        let table = gfx::api(aw, ah, 1.0);
124        let request = protocol::Request::Render(req.clone());
125        let ops = gfx::record(aw, ah, |_| answer = self.ask(&request, &table));
126        match answer? {
127            Answer::Rendered { w, h } => Ok((w, h, ops)),
128            other => Err(format!("asked to render, the mod answered {other:?}")),
129        }
130    }
131}
132
133/// What the IDE's side asks the host.
134#[derive(Debug, Clone, Schema)]
135#[data(tag = "cmd")]
136pub enum HostRequest {
137    Load {
138        lib: PathBuf,
139        mod_root: Option<PathBuf>,
140    },
141    Render(RenderReq),
142    Png {
143        req: RenderReq,
144        scale: Option<u32>,
145        out: PathBuf,
146    },
147}
148
149/// What the host answers.
150#[derive(Debug, Clone, Schema)]
151#[data(tag = "answer")]
152pub enum HostAnswer {
153    Loaded { components: Vec<ComponentInfo> },
154    Drawn { w: f32, h: f32, ops: Vec<DrawOp> },
155    Written { w: f32, h: f32, out: PathBuf },
156}
157
158/// The answer, or what went wrong.
159pub type HostReply = Result<HostAnswer, String>;
160
161/// The next answer on the host's stdout: what the mod printed skipped up to
162/// [`MARK`], then the frame; `None` once the host has gone.
163pub fn next_answer(out: &mut impl BufRead) -> io::Result<Option<HostReply>> {
164    let mut seen = Vec::new();
165    let last = MARK[MARK.len() - 1];
166    loop {
167        if out.read_until(last, &mut seen)? == 0 {
168            return Ok(None);
169        }
170        if seen.ends_with(MARK) {
171            return yog_data::io::read_frame(out);
172        }
173    }
174}
175
176struct Host {
177    lib: Option<Loaded>,
178    mod_root: Option<PathBuf>,
179}
180
181impl Host {
182    fn handle(&mut self, request: HostRequest) -> HostReply {
183        match request {
184            HostRequest::Load { lib, mod_root } => {
185                if self.lib.is_some() {
186                    return Err("a library is already loaded (restart the host to reload)".into());
187                }
188                images::set_document(mod_root.as_deref());
189                self.mod_root = mod_root;
190                let loaded = Loaded::open(&lib)?;
191                let components = loaded.describe()?;
192                self.lib = Some(loaded);
193                Ok(HostAnswer::Loaded { components })
194            }
195            HostRequest::Render(req) => self
196                .loaded()?
197                .render(&req)
198                .map(|(w, h, ops)| HostAnswer::Drawn { w, h, ops }),
199            HostRequest::Png { req, scale, out } => {
200                let (w, h, ops) = self.loaded()?.render(&req)?;
201                let res = super::assets::Resources::for_mod(self.mod_root.as_deref());
202                let (cw, ch) = (w.ceil().max(1.0) as u32, h.ceil().max(1.0) as u32);
203                let scale = scale.unwrap_or(PNG_SCALE).clamp(1, MAX_PNG_SCALE);
204                let canvas = raster::rasterize(&ops, cw, ch, scale, &res);
205                std::fs::write(&out, canvas.png()?)
206                    .map_err(|e| format!("{}: {e}", out.display()))?;
207                Ok(HostAnswer::Written { w, h, out })
208            }
209        }
210    }
211
212    fn loaded(&self) -> Result<&Loaded, String> {
213        self.lib
214            .as_ref()
215            .ok_or_else(|| "no library loaded".to_string())
216    }
217}
218
219/// The CLI command that runs a host.
220pub const COMMAND: &str = "designer-host";
221
222/// `yog designer-host`: serves requests until stdin closes.
223pub fn serve() {
224    let mut host = Host {
225        lib: None,
226        mod_root: None,
227    };
228    let mut input = io::stdin().lock();
229    while let Ok(Some(bytes)) = yog_data::io::read_frame_bytes(&mut input) {
230        let reply = match yog_data::read_bytes::<HostRequest>(&bytes) {
231            Ok(request) => host.handle(request),
232            Err(e) => Err(format!("bad request: {e:?}")),
233        };
234        let mut out = io::stdout().lock();
235        let sent = out
236            .write_all(MARK)
237            .and_then(|()| yog_data::io::write_frame(&mut out, &reply));
238        if sent.is_err() {
239            break;
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn requests_are_answered() {
250        let mut host = Host {
251            lib: None,
252            mod_root: None,
253        };
254        let render = HostRequest::Render(RenderReq {
255            name: "X".into(),
256            attrs: Vec::new(),
257            w: 0.0,
258            h: 0.0,
259            avail_w: 1.0,
260            avail_h: 1.0,
261        });
262        assert_eq!(host.handle(render).unwrap_err(), "no library loaded");
263        let load = HostRequest::Load {
264            lib: "/nowhere/libnope.so".into(),
265            mod_root: None,
266        };
267        assert!(host.handle(load).unwrap_err().contains("can't load"));
268    }
269
270    #[test]
271    fn answers_are_found_past_what_the_mod_printed() {
272        let mut out = b"the mod says hi\n".to_vec();
273        out.extend_from_slice(MARK);
274        yog_data::io::write_frame(&mut out, &HostReply::Err("no".into())).unwrap();
275        let mut r = out.as_slice();
276        assert_eq!(next_answer(&mut r).unwrap().unwrap().unwrap_err(), "no");
277        assert!(next_answer(&mut r).unwrap().is_none());
278    }
279
280    /// `YOG_DESIGNER_LIB=…/libmod.so YOG_DESIGNER_COMPONENT=StatBar [YOG_DESIGNER_PNG=out.png]
281    /// cargo test --lib designer::host -- --ignored` (`YOG_DESIGNER_PROPS="k=v;k=v"`)
282    #[test]
283    #[ignore]
284    fn renders_a_built_mod() {
285        let (Ok(lib), Ok(name)) = (
286            std::env::var(yog_ids::env::DESIGNER_LIB),
287            std::env::var(yog_ids::env::DESIGNER_COMPONENT),
288        ) else {
289            return;
290        };
291        let props = std::env::var(yog_ids::env::DESIGNER_PROPS).unwrap_or_default();
292        let props = props
293            .split(';')
294            .filter_map(|kv| kv.split_once('='))
295            .map(|(k, v)| (k.to_string(), v.to_string()))
296            .collect();
297        let mut host = Host {
298            lib: None,
299            mod_root: None,
300        };
301        let loaded = host.handle(HostRequest::Load {
302            lib: lib.into(),
303            mod_root: None,
304        });
305        eprintln!("{loaded:?}");
306        let l = host.loaded().unwrap();
307        let (w, h, ops) = l
308            .render(&RenderReq {
309                name: name.clone(),
310                attrs: props,
311                w: 0.0,
312                h: 0.0,
313                avail_w: 400.0,
314                avail_h: 300.0,
315            })
316            .unwrap();
317        eprintln!("{w}x{h}: {ops:?}");
318        assert!(w > 0.0 && h > 0.0 && !ops.is_empty());
319        if let Ok(out) = std::env::var(yog_ids::env::DESIGNER_PNG) {
320            let res = super::super::assets::Resources::for_mod(None);
321            let canvas = raster::rasterize(&ops, w.ceil() as u32, h.ceil() as u32, 6, &res);
322            std::fs::write(&out, canvas.png().unwrap()).unwrap();
323        }
324    }
325}