Skip to main content

yog_cli/design/
custom.rs

1//! The mod's `.yui` components in the designer. Markup-only ones are
2//! expanded in place ([`components`](super::components)); *compiled* ones —
3//! whose bindings call the code-behind — are drawn for real by the mod's
4//! code: a designer host process ([`host`](super::host)) per mod loads the
5//! mod's library built for this machine (`yog designer-lib`) and draws them
6//! on request.
7//!
8//! This side supervises it — rebuilds the library and restarts the host
9//! when the mod's sources change (debounced), restarts nothing while they
10//! don't — and fits the components into the designer's picture: each
11//! compiled instance is laid out by yog-ui as a spacer of the component's
12//! (measured) size, painted with a marker color; the marker's fill in the
13//! recorded display list is then replaced by the host's drawing of the
14//! component. While the library builds, or when it can't be built or
15//! loaded, the element is a labelled placeholder box instead ("designer
16//! could not load"), never an IDE crash.
17
18use std::collections::HashMap;
19use std::collections::hash_map::DefaultHasher;
20use std::hash::{Hash, Hasher};
21use std::io::{BufRead, BufReader, Read, Write};
22use std::path::{Path, PathBuf};
23use std::process::{Child, ChildStdin, Stdio};
24use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
25use std::sync::{Arc, Mutex, OnceLock, PoisonError};
26use std::time::{Duration, Instant, SystemTime};
27
28use serde::Serialize;
29use serde_json::{Value, json};
30
31use super::components::{self, Def};
32use super::host::{ComponentInfo, MARK, RenderReq};
33use super::markup::YuiNode;
34use super::raster::DrawOp;
35use crate::design::project;
36
37/// Elements the designer draws itself (yog-ui's and yog-kit's); any other
38/// PascalCase element is a custom component (as for `yog build`).
39const BUILT_IN: &[&str] = &[
40    yog_ids::yui::SCREEN,
41    yog_ids::yui::CANVAS,
42    yog_ids::yui::COLUMN,
43    yog_ids::yui::ROW,
44    yog_ids::yui::SCROLL_VIEW,
45    yog_ids::yui::LABEL,
46    yog_ids::yui::BUTTON,
47    yog_ids::yui::TEXT_BOX,
48    yog_ids::yui::ITEM_SLOT,
49    yog_ids::yui::INV_SLOT,
50    yog_ids::yui::SLOT,
51    yog_ids::yui::PLAYER_INVENTORY,
52    yog_ids::yui::IMAGE,
53    yog_ids::yui::SPACER,
54    yog_ids::yui::SURFACE,
55    yog_ids::yui::RECIPE_SLOT,
56    yog_ids::yui::RECIPE_RESULT,
57    yog_ids::yui::RECIPE_COUNT,
58    yog_ids::yui::GRID,
59    "RowDefinition",
60    "ColumnDefinition",
61    yog_ids::yui::TOGGLE_BUTTON,
62    yog_ids::yui::LIST_VIEW,
63    yog_ids::yui::ITEMS_CONTROL,
64    yog_ids::yui::CONTENT_PRESENTER,
65    "DataTemplate",
66    "Dialog",
67];
68
69/// What every element takes that sizes/places it — kept on the stand-in
70/// spacer; the rest (the component's own properties and its style) goes
71/// to the host.
72const LAYOUT_ATTRS: &[&str] = &[
73    "x",
74    "y",
75    "w",
76    "h",
77    "min_w",
78    "min_h",
79    "dock",
80    "flex",
81    "margin",
82    "halign",
83    "valign",
84    "Grid.Row",
85    "Grid.Column",
86    "Grid.RowSpan",
87    "Grid.ColumnSpan",
88    "id",
89    "on_click",
90    "enabled",
91    "visible",
92];
93
94/// Quiet time after a source change before the library is rebuilt.
95const DEBOUNCE: Duration = Duration::from_millis(800);
96/// How long one host answer may take (a component looping forever).
97const ANSWER_TIMEOUT: Duration = Duration::from_secs(10);
98
99pub fn is_component(kind: &str) -> bool {
100    !BUILT_IN.contains(&kind)
101        && kind.chars().next().is_some_and(|c| c.is_ascii_uppercase())
102        && kind.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
103}
104
105/// The designer library's state, as the toolbox shows it.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
107#[serde(rename_all = "camelCase")]
108pub enum Status {
109    /// The mod has no compiled components (nothing to build).
110    None,
111    /// Building the library / starting the host.
112    Building,
113    Ready,
114    Failed,
115}
116
117#[derive(Debug, Clone, Serialize)]
118#[serde(rename_all = "camelCase")]
119pub struct Snapshot {
120    pub status: Status,
121    pub error: Option<String>,
122    /// Every component of the mod, from its `.yui` files.
123    pub components: Vec<ComponentInfo>,
124    /// The ones that need the mod's library (the rest are drawn from markup).
125    pub compiled: Vec<String>,
126    /// Changes whenever the drawing of an instance may: a component `.yui`
127    /// edited, the library rebuilt.
128    pub revision: String,
129    /// The mod's display name (`yog.toml` `name`, else its id) for the toolbox category.
130    pub mod_name: String,
131}
132
133struct HostProc {
134    child: Child,
135    stdin: ChildStdin,
136    answers: Receiver<String>,
137    stderr: Arc<Mutex<String>>,
138    lib_copy: PathBuf,
139}
140
141impl HostProc {
142    fn spawn(lib: &Path, mod_root: &Path) -> Result<(Self, Vec<ComponentInfo>), String> {
143        let mut child = super::this_cli()
144            .arg(super::host::COMMAND)
145            .current_dir(mod_root)
146            .stdin(Stdio::piped())
147            .stdout(Stdio::piped())
148            .stderr(Stdio::piped())
149            .spawn()
150            .map_err(|e| format!("can't start the designer host: {e}"))?;
151        let stdin = child.stdin.take().ok_or("no host stdin")?;
152        let stdout = child.stdout.take().ok_or("no host stdout")?;
153        let mut stderr_pipe = child.stderr.take().ok_or("no host stderr")?;
154        let (tx, answers) = mpsc::channel();
155        std::thread::spawn(move || {
156            for line in BufReader::new(stdout).lines() {
157                let Ok(line) = line else { break };
158                if let Some(answer) = line.strip_prefix(MARK)
159                    && tx.send(answer.to_string()).is_err()
160                {
161                    break;
162                }
163            }
164        });
165        let stderr = Arc::new(Mutex::new(String::new()));
166        let tail = stderr.clone();
167        std::thread::spawn(move || {
168            let mut buf = [0u8; 4096];
169            while let Ok(n) = stderr_pipe.read(&mut buf) {
170                if n == 0 {
171                    break;
172                }
173                let mut t = tail.lock().unwrap_or_else(PoisonError::into_inner);
174                t.push_str(&String::from_utf8_lossy(&buf[..n]));
175                if t.len() > 16_000 {
176                    let cut = t.len() - 8_000;
177                    let cut = (cut..t.len())
178                        .find(|i| t.is_char_boundary(*i))
179                        .unwrap_or(t.len());
180                    t.drain(..cut);
181                }
182            }
183        });
184        let mut host = HostProc {
185            child,
186            stdin,
187            answers,
188            stderr,
189            lib_copy: lib.to_path_buf(),
190        };
191        let answer = host.ask(
192            &json!({"cmd": "load", "lib": lib, "modRoot": mod_root}),
193            Duration::from_secs(30),
194        )?;
195        let components =
196            serde_json::from_value(answer.get("components").cloned().unwrap_or_default())
197                .map_err(|e| e.to_string())?;
198        Ok((host, components))
199    }
200
201    fn ask(&mut self, request: &Value, timeout: Duration) -> Result<Value, String> {
202        writeln!(self.stdin, "{request}")
203            .and_then(|_| self.stdin.flush())
204            .map_err(|_| self.died())?;
205        match self.answers.recv_timeout(timeout) {
206            Ok(line) => {
207                let v: Value =
208                    serde_json::from_str(&line).map_err(|e| format!("bad host answer: {e}"))?;
209                if v.get("ok").and_then(Value::as_bool) == Some(true) {
210                    Ok(v)
211                } else {
212                    Err(v
213                        .get("error")
214                        .and_then(Value::as_str)
215                        .unwrap_or("the designer host failed")
216                        .to_string())
217                }
218            }
219            Err(RecvTimeoutError::Timeout) => {
220                let _ = self.child.kill();
221                Err("the designer host stopped responding (a component that never returns?) — it was stopped".into())
222            }
223            Err(RecvTimeoutError::Disconnected) => Err(self.died()),
224        }
225    }
226
227    fn died(&mut self) -> String {
228        let _ = self.child.wait();
229        // Give the stderr reader a moment to drain the last words.
230        std::thread::sleep(Duration::from_millis(50));
231        let tail = self
232            .stderr
233            .lock()
234            .unwrap_or_else(PoisonError::into_inner)
235            .clone();
236        let tail: Vec<&str> = tail
237            .lines()
238            .rev()
239            .take(12)
240            .collect::<Vec<_>>()
241            .into_iter()
242            .rev()
243            .collect();
244        format!(
245            "the designer host crashed{}",
246            if tail.is_empty() {
247                String::new()
248            } else {
249                format!(":\n{}", tail.join("\n"))
250            }
251        )
252    }
253}
254
255impl Drop for HostProc {
256    fn drop(&mut self) {
257        let _ = self.child.kill();
258        let _ = self.child.wait();
259        let _ = std::fs::remove_file(&self.lib_copy);
260    }
261}
262
263type Drawn = (f32, f32, Vec<DrawOp>);
264
265struct ModState {
266    status: Status,
267    error: Option<String>,
268    /// What the loaded library registered.
269    components: Vec<ComponentInfo>,
270    mod_name: String,
271    /// The sources the current library (or failure) came from.
272    built: Option<u64>,
273    /// The latest sources seen, and since when (debounce).
274    seen: Option<(u64, Instant)>,
275    building: bool,
276    host: Option<HostProc>,
277    generation: u64,
278    cache: HashMap<String, Result<Drawn, String>>,
279}
280
281impl ModState {
282    fn new() -> Self {
283        Self {
284            status: Status::None,
285            error: None,
286            components: Vec::new(),
287            mod_name: String::new(),
288            built: None,
289            seen: None,
290            building: false,
291            host: None,
292            generation: 0,
293            cache: HashMap::new(),
294        }
295    }
296
297    fn snapshot(&self, (rev, defs): &(u64, Arc<Vec<Def>>)) -> Snapshot {
298        Snapshot {
299            revision: format!("{rev:x}.{}", self.generation),
300            status: self.status,
301            error: self.error.clone(),
302            components: defs.iter().map(|d| d.info.clone()).collect(),
303            compiled: defs
304                .iter()
305                .filter(|d| d.compiled)
306                .map(|d| d.info.name.clone())
307                .collect(),
308            mod_name: self.mod_name.clone(),
309        }
310    }
311
312    fn render(&mut self, req: &RenderReq) -> Result<Drawn, String> {
313        let key = serde_json::to_string(req).unwrap_or_default();
314        if let Some(hit) = self.cache.get(&key) {
315            return hit.clone();
316        }
317        let Some(host) = self.host.as_mut() else {
318            return Err(self
319                .error
320                .clone()
321                .unwrap_or_else(|| "the designer library isn't loaded".into()));
322        };
323        let mut request = serde_json::to_value(req).unwrap_or_default();
324        request["cmd"] = json!("render");
325        let result = host.ask(&request, ANSWER_TIMEOUT).and_then(|v| {
326            let dim = |k: &str| v.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
327            let ops: Vec<DrawOp> =
328                serde_json::from_value(v.get("ops").cloned().unwrap_or_default())
329                    .map_err(|e| e.to_string())?;
330            Ok((dim("w"), dim("h"), ops))
331        });
332        if result.is_err() && host.child.try_wait().ok().flatten().is_some() {
333            // The host is gone: say so until the next build brings a new one.
334            self.host = None;
335            self.status = Status::Failed;
336            self.error = result.clone().err();
337            self.cache.clear();
338            return result;
339        }
340        if self.cache.len() > 512 {
341            self.cache.clear();
342        }
343        self.cache.insert(key, result.clone());
344        result
345    }
346}
347
348type Shared = Arc<Mutex<ModState>>;
349
350fn mods() -> &'static Mutex<HashMap<PathBuf, Shared>> {
351    static M: OnceLock<Mutex<HashMap<PathBuf, Shared>>> = OnceLock::new();
352    M.get_or_init(Default::default)
353}
354
355fn state(root: &Path) -> Shared {
356    mods()
357        .lock()
358        .unwrap_or_else(PoisonError::into_inner)
359        .entry(root.to_path_buf())
360        .or_insert_with(|| Arc::new(Mutex::new(ModState::new())))
361        .clone()
362}
363
364fn lock(s: &Shared) -> std::sync::MutexGuard<'_, ModState> {
365    s.lock().unwrap_or_else(PoisonError::into_inner)
366}
367
368/// The mod's Rust sources, `yog.toml` and resources — what the library is built from.
369fn sources(root: &Path) -> Vec<PathBuf> {
370    let mut out = vec![project::path(root)];
371    for dir in [yog_ids::project::SRC, yog_ids::project::RESOURCES] {
372        let walk = ignore::WalkBuilder::new(root.join(dir))
373            .hidden(false)
374            .build();
375        for entry in walk.flatten() {
376            let p = entry.path();
377            let ext = p.extension().and_then(|e| e.to_str()).unwrap_or_default();
378            if p.is_file() && matches!(ext, "rs" | "yui" | "toml" | "yres" | "json" | "png") {
379                out.push(p.to_path_buf());
380            }
381        }
382    }
383    out.sort();
384    out
385}
386
387fn fingerprint(files: &[PathBuf]) -> u64 {
388    let mut h = DefaultHasher::new();
389    for f in files {
390        f.hash(&mut h);
391        if let Ok(m) = std::fs::metadata(f) {
392            m.len().hash(&mut h);
393            m.modified()
394                .ok()
395                .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
396                .hash(&mut h);
397        }
398    }
399    h.finish()
400}
401
402fn mod_name(root: &Path) -> String {
403    let manifest = crate::manifest::YogToml::read(&project::path(root)).ok();
404    // `@Strings.x` names a resource text — the id reads better than the reference.
405    manifest
406        .as_ref()
407        .map(|m| m.name.clone())
408        .filter(|n| !n.is_empty() && !n.starts_with(yog_ids::sep::TYPED))
409        .or_else(|| manifest.map(|m| m.id).filter(|id| !id.is_empty()))
410        .unwrap_or_else(|| {
411            root.file_name()
412                .map(|n| n.to_string_lossy().into_owned())
413                .unwrap_or_default()
414        })
415}
416
417/// The mod's components and the library's state; starts a rebuild (and
418/// host restart) when the sources changed and have been quiet a moment —
419/// only if some component is compiled. Cheap enough to poll.
420pub fn status(root: &Path) -> Snapshot {
421    let defs = components::scan_with_revision(root);
422    let shared = state(root);
423    let files = sources(root);
424    let fp = fingerprint(&files);
425    let mut s = lock(&shared);
426    if s.mod_name.is_empty() {
427        s.mod_name = mod_name(root);
428    }
429    if s.building || s.built == Some(fp) {
430        return s.snapshot(&defs);
431    }
432    if !defs.1.iter().any(|d| d.compiled) {
433        s.status = Status::None;
434        s.error = None;
435        s.components.clear();
436        s.host = None;
437        s.built = Some(fp);
438        return s.snapshot(&defs);
439    }
440    let now = Instant::now();
441    match s.seen {
442        Some((seen, since)) if seen == fp => {
443            if now.duration_since(since) < DEBOUNCE && s.built.is_some() {
444                return s.snapshot(&defs);
445            }
446        }
447        _ => {
448            s.seen = Some((fp, now));
449            if s.built.is_some() {
450                return s.snapshot(&defs);
451            }
452        }
453    }
454    s.building = true;
455    s.status = Status::Building;
456    s.mod_name = mod_name(root);
457    let snapshot = s.snapshot(&defs);
458    drop(s);
459    let root = root.to_path_buf();
460    std::thread::spawn(move || rebuild(&root, &shared, fp));
461    snapshot
462}
463
464/// `yog designer-lib`, then a fresh host on a copy of the library (cargo
465/// may overwrite the original while the host has it loaded).
466fn rebuild(root: &Path, shared: &Shared, fp: u64) {
467    let result = build_lib(root).and_then(|lib| {
468        let generation = lock(shared).generation + 1;
469        let copy = std::env::temp_dir().join("yog-idle-designer").join(format!(
470            "{}-{}-{generation}{}",
471            lib.file_stem().unwrap_or_default().to_string_lossy(),
472            std::process::id(),
473            lib.extension()
474                .map(|e| format!(".{}", e.to_string_lossy()))
475                .unwrap_or_default()
476        ));
477        std::fs::create_dir_all(copy.parent().unwrap_or(Path::new(".")))
478            .map_err(|e| e.to_string())?;
479        std::fs::copy(&lib, &copy).map_err(|e| format!("copying {}: {e}", lib.display()))?;
480        HostProc::spawn(&copy, root).inspect_err(|_| {
481            let _ = std::fs::remove_file(&copy);
482        })
483    });
484    let mut s = lock(shared);
485    s.building = false;
486    s.built = Some(fp);
487    s.generation += 1;
488    s.cache.clear();
489    match result {
490        Ok((host, components)) => {
491            s.host = Some(host);
492            s.components = components;
493            s.status = Status::Ready;
494            s.error = None;
495        }
496        Err(e) => {
497            s.host = None;
498            s.status = Status::Failed;
499            s.error = Some(e);
500        }
501    }
502}
503
504fn build_lib(root: &Path) -> Result<PathBuf, String> {
505    let out = crate::design::this_cli()
506        .arg("designer-lib")
507        .current_dir(root)
508        .stdin(Stdio::null())
509        .output()
510        .map_err(|e| format!("failed to run yog: {e}"))?;
511    let stdout = String::from_utf8_lossy(&out.stdout);
512    let stderr = String::from_utf8_lossy(&out.stderr);
513    if out.status.success() {
514        let path = stdout
515            .lines()
516            .map(str::trim)
517            .rfind(|l| !l.is_empty())
518            .unwrap_or_default();
519        return if Path::new(path).is_file() {
520            Ok(PathBuf::from(path))
521        } else {
522            Err(format!("yog designer-lib gave no library path:\n{stdout}"))
523        };
524    }
525    if stderr.contains("unknown command: designer-lib") {
526        return Err("this yog CLI can't build designer libraries — update it (Tools → Environment, or `cargo install --locked yog-cli`)".into());
527    }
528    Err(build_errors(&stderr))
529}
530
531/// Cargo's errors (each with its location), else the output's tail.
532fn build_errors(stderr: &str) -> String {
533    let lines: Vec<&str> = stderr.lines().collect();
534    let mut out = Vec::new();
535    for (i, l) in lines.iter().enumerate() {
536        if l.starts_with("error") && !l.starts_with("error: could not compile") {
537            out.push(l.to_string());
538            if let Some(at) = lines
539                .get(i + 1)
540                .filter(|n| n.trim_start().starts_with("-->"))
541            {
542                out.push(at.trim().to_string());
543            }
544        }
545    }
546    if out.is_empty() {
547        out = lines
548            .iter()
549            .rev()
550            .take(15)
551            .rev()
552            .map(|l| l.to_string())
553            .collect();
554    }
555    let text = out.join("\n");
556    if text.trim().is_empty() {
557        "building the designer library failed".into()
558    } else {
559        text.chars().take(4000).collect()
560    }
561}
562
563// ── Fitting components into the picture ────────────────────────────────────────
564
565/// A custom element of the document being drawn.
566struct Slot {
567    uid: String,
568    marker: u32,
569    req: RenderReq,
570    /// Why it can't be drawn (placeholder text).
571    problem: Option<String>,
572}
573
574/// The document with custom elements replaced by marked spacers.
575pub struct Prepared {
576    pub root: YuiNode,
577    slots: Vec<Slot>,
578    mod_root: Option<PathBuf>,
579}
580
581impl Prepared {
582    pub fn is_empty(&self) -> bool {
583        self.slots.is_empty()
584    }
585}
586
587/// A component's stand-in color: alpha 1 (never a real color in a .yui,
588/// invisible if left behind), a tag, and the component's index.
589const MARKER: u32 = 0x01D0_0000;
590const MARKER_INDEX: u32 = 0xFFFF;
591
592fn marker(i: usize) -> u32 {
593    MARKER | (i as u32 & MARKER_INDEX)
594}
595
596fn px(v: Option<&str>) -> Option<f32> {
597    let v = v?.trim();
598    if v.ends_with('%') {
599        None
600    } else {
601        v.parse().ok()
602    }
603}
604
605/// The document as yog-ui should draw it: a `<Component>` document as a
606/// screen of its design size; markup-only component instances expanded;
607/// each compiled one (or unknown) measured through the host (its natural
608/// size, when the element doesn't size it in px) and swapped for a marked
609/// spacer.
610pub fn prepare(root: &YuiNode, mod_root: Option<&Path>, screen_w: f32, screen_h: f32) -> Prepared {
611    let root = if root.kind == yog_ids::yui::COMPONENT {
612        components::design_root(root)
613    } else {
614        root.clone()
615    };
616    let mut prepared = Prepared {
617        root,
618        slots: Vec::new(),
619        mod_root: mod_root.map(Path::to_path_buf),
620    };
621    if !has_custom(&prepared.root) {
622        return prepared;
623    }
624    let mut cx = Cx {
625        mod_root,
626        defs: mod_root.map(components::scan).unwrap_or_default(),
627        snapshot: None,
628        slots: Vec::new(),
629        screen: (screen_w, screen_h),
630    };
631    cx.process(&mut prepared.root, 0);
632    prepared.slots = cx.slots;
633    prepared
634}
635
636/// How deep components may nest (a component using itself stops here).
637const MAX_NESTING: usize = 16;
638
639struct Cx<'a> {
640    mod_root: Option<&'a Path>,
641    defs: Arc<Vec<Def>>,
642    snapshot: Option<Snapshot>,
643    slots: Vec<Slot>,
644    screen: (f32, f32),
645}
646
647impl Cx<'_> {
648    fn process(&mut self, node: &mut YuiNode, depth: usize) {
649        if !is_component(&node.kind) {
650            for child in &mut node.children {
651                self.process(child, depth);
652            }
653            return;
654        }
655        let defs = self.defs.clone();
656        match Def::find(&defs, &node.kind) {
657            Some(def) if !def.compiled && depth < MAX_NESTING => {
658                *node = components::expand(def, node);
659                // The body's root may itself be a component.
660                self.process(node, depth + 1);
661            }
662            def => {
663                let problem = match def {
664                    None => Some(format!(
665                        "no component <{}> in this mod — add one: New Item → Component (.yui)",
666                        node.kind
667                    )),
668                    Some(d) if !d.compiled => Some("this component contains itself".to_string()),
669                    Some(_) => self.library_problem(&node.kind),
670                };
671                self.slot(node, problem);
672            }
673        }
674    }
675
676    /// Why a compiled component can't be drawn right now, if it can't.
677    fn library_problem(&mut self, name: &str) -> Option<String> {
678        let Some(mod_root) = self.mod_root else {
679            return Some("not inside a mod (no src/ folder)".into());
680        };
681        let s = self.snapshot.get_or_insert_with(|| status(mod_root));
682        match s.status {
683            Status::Building | Status::None => Some(format!("{BUILDING} designer library…")),
684            Status::Failed => Some(format!(
685                "The designer could not load the mod's library:\n{}",
686                s.error.clone().unwrap_or_default()
687            )),
688            Status::Ready => {
689                let registered = lock(&state(mod_root))
690                    .components
691                    .iter()
692                    .any(|c| c.name == name);
693                (!registered).then(|| format!("the mod's library doesn't register <{name}> — does its code-behind call yog_api::ui_include!?"))
694            }
695        }
696    }
697
698    fn slot(&mut self, node: &mut YuiNode, problem: Option<String>) {
699        let name = node.kind.clone();
700        let props: Vec<(String, String)> = node
701            .attrs
702            .iter()
703            .filter(|(k, _)| !LAYOUT_ATTRS.contains(&k.as_str()))
704            .map(|(k, v)| (k.clone(), v.clone()))
705            .collect();
706        let (w, h) = (px(node.attr(yog_ids::yui_attr::W)), px(node.attr(yog_ids::yui_attr::H)));
707        let req = RenderReq {
708            name,
709            props,
710            w: w.unwrap_or(0.0),
711            h: h.unwrap_or(0.0),
712            avail_w: self.screen.0,
713            avail_h: self.screen.1,
714        };
715        let (mut nw, mut nh) = (
716            w.unwrap_or(PLACEHOLDER_W),
717            h.unwrap_or(if problem.is_some() { PROBLEM_H } else { PLACEHOLDER_H }),
718        );
719        let problem = match (problem, self.mod_root) {
720            (None, Some(root)) if w.is_none() || h.is_none() => {
721                match lock(&state(root)).render(&req) {
722                    Ok((mw, mh, _)) => {
723                        nw = w.unwrap_or(mw);
724                        nh = h.unwrap_or(mh);
725                        None
726                    }
727                    Err(e) => Some(e),
728                }
729            }
730            (p, _) => p,
731        };
732        let i = self.slots.len();
733        let mut attrs: HashMap<String, String> = node
734            .attrs
735            .iter()
736            .filter(|(k, _)| LAYOUT_ATTRS.contains(&k.as_str()))
737            .map(|(k, v)| (k.clone(), v.clone()))
738            .collect();
739        attrs.entry(yog_ids::yui_attr::W.into()).or_insert_with(|| nw.to_string());
740        attrs.entry(yog_ids::yui_attr::H.into()).or_insert_with(|| nh.to_string());
741        attrs.insert(yog_ids::yui_attr::BG.into(), format!("#{:08X}", marker(i)));
742        self.slots.push(Slot {
743            uid: node.uid.clone(),
744            marker: marker(i),
745            req,
746            problem,
747        });
748        *node = YuiNode {
749            uid: node.uid.clone(),
750            kind: yog_ids::yui::SPACER.into(),
751            attrs,
752            children: Vec::new(),
753        };
754    }
755}
756
757fn has_custom(node: &YuiNode) -> bool {
758    is_component(&node.kind) || node.children.iter().any(has_custom)
759}
760
761/// Replaces each marker fill in `ops` with the component drawn at its
762/// element's laid-out rect (`rects`: uid → `x y w h`), clipped to the part
763/// that was visible (the marker fill, already cut to scroll views).
764pub fn splice(ops: &mut Vec<DrawOp>, prepared: &Prepared, rects: &HashMap<String, [f32; 4]>) {
765    if prepared.slots.is_empty() {
766        return;
767    }
768    let shared = prepared.mod_root.as_deref().map(state);
769    let mut out = Vec::with_capacity(ops.len());
770    for op in ops.drain(..) {
771        let DrawOp::Rect {
772            x1,
773            y1,
774            x2,
775            y2,
776            color,
777        } = op
778        else {
779            out.push(op);
780            continue;
781        };
782        let Some(slot) = prepared.slots.iter().find(|s| s.marker == color) else {
783            out.push(op);
784            continue;
785        };
786        let [x, y, w, h] = rects
787            .get(&slot.uid)
788            .copied()
789            .unwrap_or([x1, y1, x2 - x1, y2 - y1]);
790        let drawn = match (&slot.problem, &shared) {
791            (Some(p), _) => Err(p.clone()),
792            (None, None) => Err("not inside a mod".into()),
793            (None, Some(sh)) => lock(sh)
794                .render(&RenderReq {
795                    w,
796                    h,
797                    ..slot.req.clone()
798                })
799                .map(|(_, _, ops)| ops),
800        };
801        let inner = drawn.unwrap_or_else(|e| placeholder(&slot.req.name, &e, w, h));
802        out.push(DrawOp::Scissor {
803            x: x1.floor() as i32,
804            y: y1.floor() as i32,
805            w: (x2.ceil() - x1.floor()) as i32,
806            h: (y2.ceil() - y1.floor()) as i32,
807        });
808        out.extend(inner.into_iter().map(|o| translate(o, x, y)));
809        out.push(DrawOp::ClearScissor);
810    }
811    *ops = out;
812}
813
814fn translate(op: DrawOp, dx: f32, dy: f32) -> DrawOp {
815    match op {
816        DrawOp::Svg { data, x, y, w, h } => DrawOp::Svg { data, x: x + dx, y: y + dy, w, h },
817        DrawOp::TextScaled { text, font, x, y, scale, color, shadow } => {
818            DrawOp::TextScaled { text, font, x: x + dx, y: y + dy, scale, color, shadow }
819        }
820        DrawOp::Blocks { list, x, y, w, h, yaw, pitch } => DrawOp::Blocks { list, x: x + dx, y: y + dy, w, h, yaw, pitch },
821        DrawOp::Entity { entity, x, y, w, h } => DrawOp::Entity { entity, x: x + dx, y: y + dy, w, h },
822        DrawOp::Rect {
823            x1,
824            y1,
825            x2,
826            y2,
827            color,
828        } => DrawOp::Rect {
829            x1: x1 + dx,
830            y1: y1 + dy,
831            x2: x2 + dx,
832            y2: y2 + dy,
833            color,
834        },
835        DrawOp::Gradient {
836            x1,
837            y1,
838            x2,
839            y2,
840            top,
841            bottom,
842        } => DrawOp::Gradient {
843            x1: x1 + dx,
844            y1: y1 + dy,
845            x2: x2 + dx,
846            y2: y2 + dy,
847            top,
848            bottom,
849        },
850        DrawOp::Text {
851            text,
852            font,
853            x,
854            y,
855            color,
856            shadow,
857        } => DrawOp::Text {
858            text,
859            font,
860            x: x + dx,
861            y: y + dy,
862            color,
863            shadow,
864        },
865        DrawOp::Item { id, x, y, size } => DrawOp::Item {
866            id,
867            x: x + dx,
868            y: y + dy,
869            size,
870        },
871        DrawOp::Image {
872            source,
873            x,
874            y,
875            w,
876            h,
877            region,
878            tint,
879        } => DrawOp::Image {
880            source,
881            x: x + dx,
882            y: y + dy,
883            w,
884            h,
885            region,
886            tint,
887        },
888        // yog-ui clips in software; a component's own scissor would undo the slot's.
889        DrawOp::Scissor { .. } | DrawOp::ClearScissor => DrawOp::Rect {
890            x1: 0.0,
891            y1: 0.0,
892            x2: 0.0,
893            y2: 0.0,
894            color: 0,
895        },
896    }
897}
898
899/// A placeholder's size when its element gives none, and its look: a dark
900/// box, its border and message bluish while the library builds, red when
901/// it failed; text padded, a line apart, wrapped at a few columns at least.
902const PLACEHOLDER_W: f32 = 80.0;
903const PLACEHOLDER_H: f32 = 20.0;
904/// A placeholder with a problem to tell: a little taller.
905const PROBLEM_H: f32 = 24.0;
906const PLACEHOLDER_BG: u32 = 0xD0_202028;
907const BUILDING_BORDER: u32 = 0xFF_8080B0;
908const FAILED_BORDER: u32 = 0xFF_E05050;
909const BUILDING_TEXT: u32 = 0xFF_B0B0D0;
910const FAILED_TEXT: u32 = 0xFF_FF9090;
911const PAD: f32 = 3.0;
912const LINE: f32 = yog_ui::text::LINE_H + yog_ui::text::LINE_GAP;
913const GLYPH_H: f32 = yog_ui::text::LINE_H - 1.0;
914const CHAR_W: f32 = yog_ui::text::CHAR_W;
915const MIN_COLS: usize = 4;
916/// What the message of a library still being built starts with.
917const BUILDING: &str = "Building";
918
919/// The box shown for a component that can't be drawn: its name and why.
920pub fn placeholder(name: &str, message: &str, w: f32, h: f32) -> Vec<DrawOp> {
921    let building = message.starts_with(BUILDING);
922    let border = if building { BUILDING_BORDER } else { FAILED_BORDER };
923    let mut ops = vec![
924        DrawOp::Rect {
925            x1: 0.0,
926            y1: 0.0,
927            x2: w,
928            y2: h,
929            color: PLACEHOLDER_BG,
930        },
931        DrawOp::Rect {
932            x1: 0.0,
933            y1: 0.0,
934            x2: w,
935            y2: 1.0,
936            color: border,
937        },
938        DrawOp::Rect {
939            x1: 0.0,
940            y1: h - 1.0,
941            x2: w,
942            y2: h,
943            color: border,
944        },
945        DrawOp::Rect {
946            x1: 0.0,
947            y1: 0.0,
948            x2: 1.0,
949            y2: h,
950            color: border,
951        },
952        DrawOp::Rect {
953            x1: w - 1.0,
954            y1: 0.0,
955            x2: w,
956            y2: h,
957            color: border,
958        },
959    ];
960    let cols = (((w - 2.0 * PAD) / CHAR_W).floor() as usize).max(MIN_COLS);
961    let mut lines = vec![format!("<{name}>")];
962    for l in message.lines() {
963        let chars: Vec<char> = l.chars().collect();
964        lines.extend(chars.chunks(cols).map(|c| c.iter().collect::<String>()));
965    }
966    for (i, line) in lines.iter().enumerate() {
967        let y = PAD + i as f32 * LINE;
968        if y + GLYPH_H > h {
969            break;
970        }
971        let color = if i == 0 {
972            yog_gfx::Color::WHITE.0
973        } else if building {
974            BUILDING_TEXT
975        } else {
976            FAILED_TEXT
977        };
978        ops.push(DrawOp::Text {
979            text: line.clone(),
980            font: yog_ids::game::DEFAULT_FONT.into(),
981            x: PAD,
982            y,
983            color,
984            shadow: false,
985        });
986    }
987    ops
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993
994    fn node(uid: &str, kind: &str, attrs: &[(&str, &str)], children: Vec<YuiNode>) -> YuiNode {
995        YuiNode {
996            uid: uid.into(),
997            kind: kind.into(),
998            attrs: attrs
999                .iter()
1000                .map(|(k, v)| (k.to_string(), v.to_string()))
1001                .collect(),
1002            children,
1003        }
1004    }
1005
1006    #[test]
1007    fn custom_elements_become_marked_spacers_with_placeholders() {
1008        let root = node(
1009            "s",
1010            "Screen",
1011            &[("width", "100"), ("height", "60")],
1012            vec![
1013                node(
1014                    "c",
1015                    "StatBar",
1016                    &[("x", "4"), ("y", "6"), ("w", "50"), ("value", "0.5")],
1017                    vec![],
1018                ),
1019                node("l", "Label", &[("text", "hi")], vec![]),
1020            ],
1021        );
1022        assert!(is_component("StatBar") && !is_component("Label") && !is_component("statBar"));
1023        let p = prepare(&root, None, 200.0, 100.0);
1024        let c = &p.root.children[0];
1025        assert_eq!(c.kind, "Spacer");
1026        assert_eq!(c.attr("w"), Some("50"));
1027        assert_eq!(c.attr("h"), Some("24"));
1028        assert_eq!(c.attr("bg"), Some("#01D00000"));
1029        assert!(c.attr("value").is_none());
1030        assert_eq!(
1031            p.slots[0].req.props,
1032            vec![("value".to_string(), "0.5".to_string())]
1033        );
1034
1035        let mut ops = vec![
1036            DrawOp::Rect {
1037                x1: 4.0,
1038                y1: 6.0,
1039                x2: 54.0,
1040                y2: 30.0,
1041                color: marker(0),
1042            },
1043            DrawOp::ClearScissor,
1044        ];
1045        let rects = HashMap::from([("c".to_string(), [4.0, 6.0, 50.0, 24.0])]);
1046        splice(&mut ops, &p, &rects);
1047        assert!(matches!(
1048            ops[0],
1049            DrawOp::Scissor {
1050                x: 4,
1051                y: 6,
1052                w: 50,
1053                h: 24
1054            }
1055        ));
1056        assert!(ops.iter().any(
1057            |o| matches!(o, DrawOp::Text { text, x, .. } if text == "<StatBar>" && *x == 7.0)
1058        ));
1059    }
1060
1061    /// The whole path — `yog designer-lib`, the host process, splicing — on a real mod:
1062    /// `YOG_DESIGNER_MOD=../../Yog-Modlist YOG_IDLE_DESIGNER_HOST=target/debug/yog-idle-tauri
1063    /// YOG_DESIGNER_PNG=out.png cargo test --lib designer::custom -- --ignored`
1064    #[test]
1065    #[ignore]
1066    fn draws_a_mods_components() {
1067        let (Ok(root), Ok(out)) = (
1068            std::env::var("YOG_DESIGNER_MOD"),
1069            std::env::var("YOG_DESIGNER_PNG"),
1070        ) else {
1071            return;
1072        };
1073        let root = std::fs::canonicalize(root).unwrap();
1074        let started = Instant::now();
1075        while status(&root).status == Status::Building || started.elapsed() < DEBOUNCE {
1076            assert!(
1077                started.elapsed() < Duration::from_secs(600),
1078                "build took too long"
1079            );
1080            std::thread::sleep(Duration::from_millis(200));
1081        }
1082        let snap = status(&root);
1083        assert_eq!(snap.status, Status::Ready, "{:?}", snap.error);
1084        eprintln!(
1085            "{}: {:?}",
1086            snap.mod_name,
1087            snap.components.iter().map(|c| &c.name).collect::<Vec<_>>()
1088        );
1089        let doc = node(
1090            "s",
1091            "Screen",
1092            &[("width", "160"), ("height", "70"), ("bg", "#E0101010")],
1093            vec![
1094                node(
1095                    "p",
1096                    "Pill",
1097                    &[("x", "6"), ("y", "6"), ("label", "Custom!")],
1098                    vec![],
1099                ),
1100                node(
1101                    "b",
1102                    "StatBar",
1103                    &[
1104                        ("x", "6"),
1105                        ("y", "24"),
1106                        ("w", "140"),
1107                        ("label", "Loaded"),
1108                        ("value", "0.7"),
1109                    ],
1110                    vec![],
1111                ),
1112                node(
1113                    "m",
1114                    "Missing",
1115                    &[("x", "6"), ("y", "44"), ("w", "140"), ("h", "22")],
1116                    vec![],
1117                ),
1118            ],
1119        );
1120        let args = super::super::RenderArgs {
1121            path: root.join("src/demo.yui").to_string_lossy().into_owned(),
1122            root: doc,
1123            screen_w: 170,
1124            screen_h: 80,
1125            scale: 4,
1126            preview: false,
1127        };
1128        let r = super::super::render(&args).unwrap();
1129        use base64::Engine as _;
1130        let png = base64::engine::general_purpose::STANDARD
1131            .decode(r.image.trim_start_matches("data:image/png;base64,"))
1132            .unwrap();
1133        std::fs::write(out, png).unwrap();
1134        let pill = r.rects.iter().find(|n| n.uid == "p").unwrap();
1135        assert!(
1136            pill.rect[2] > 10.0 && pill.rect[3] < 20.0,
1137            "{:?}",
1138            pill.rect
1139        );
1140    }
1141
1142    #[test]
1143    fn cargo_errors_are_picked_out() {
1144        let e = build_errors(
1145            "   Compiling x\nerror[E0425]: cannot find value `y`\n  --> src/lib.rs:3:5\n   |\nerror: could not compile `x`\n",
1146        );
1147        assert_eq!(e, "error[E0425]: cannot find value `y`\n--> src/lib.rs:3:5");
1148    }
1149}