Skip to main content

yog_cli/design/custom/
mod.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::io::{BufReader, Read};
20use std::path::{Path, PathBuf};
21use std::process::{Child, ChildStdin, Stdio};
22use std::time::{Duration, Instant};
23use yog_sync::mpsc::{self, Receiver, RecvTimeoutError};
24use yog_sync::{Arc, Mutex, OnceLock};
25
26use super::components::{self, Def};
27use super::host::{ComponentInfo, HostAnswer, HostReply, HostRequest, RenderReq};
28use super::raster::DrawOp;
29use crate::design::project;
30
31/// Elements the designer draws itself (yog-ui's and yog-kit's); any other
32/// PascalCase element is a custom component (as for `yog build`).
33const BUILT_IN: &[&str] = &[
34    yog_ids::yui::SCREEN,
35    yog_ids::yui::CANVAS,
36    yog_ids::yui::COLUMN,
37    yog_ids::yui::ROW,
38    yog_ids::yui::SCROLL_VIEW,
39    yog_ids::yui::LABEL,
40    yog_ids::yui::BUTTON,
41    yog_ids::yui::TEXT_BOX,
42    yog_ids::yui::ITEM_SLOT,
43    yog_ids::yui::INV_SLOT,
44    yog_ids::yui::SLOT,
45    yog_ids::yui::PLAYER_INVENTORY,
46    yog_ids::yui::IMAGE,
47    yog_ids::yui::SPACER,
48    yog_ids::yui::SURFACE,
49    yog_ids::yui::RECIPE_SLOT,
50    yog_ids::yui::RECIPE_RESULT,
51    yog_ids::yui::RECIPE_COUNT,
52    yog_ids::yui::GRID,
53    "RowDefinition",
54    "ColumnDefinition",
55    yog_ids::yui::TOGGLE_BUTTON,
56    yog_ids::yui::LIST_VIEW,
57    yog_ids::yui::ITEMS_CONTROL,
58    yog_ids::yui::CONTENT_PRESENTER,
59    yog_ids::yui_vm::DATA_TEMPLATE,
60    "Dialog",
61];
62
63/// What every element takes that sizes/places it — kept on the stand-in
64/// spacer; the rest (the component's own properties and its style) goes
65/// to the host.
66const LAYOUT_ATTRS: &[&str] = &[
67    "x",
68    "y",
69    "w",
70    "h",
71    "min_w",
72    "min_h",
73    "dock",
74    "flex",
75    "margin",
76    "halign",
77    "valign",
78    "Grid.Row",
79    "Grid.Column",
80    "Grid.RowSpan",
81    "Grid.ColumnSpan",
82    "id",
83    "on_click",
84    "enabled",
85    "visible",
86];
87
88/// Quiet time after a source change before the library is rebuilt.
89const DEBOUNCE: Duration = Duration::from_millis(800);
90/// How long one host answer may take (a component looping forever).
91const ANSWER_TIMEOUT: Duration = Duration::from_secs(10);
92/// How long the host may take to load a library and describe it.
93const LOAD_TIMEOUT: Duration = Duration::from_secs(30);
94
95pub fn is_component(kind: &str) -> bool {
96    !BUILT_IN.contains(&kind)
97        && kind.chars().next().is_some_and(|c| c.is_ascii_uppercase())
98        && kind.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
99}
100
101/// The designer library's state, as the toolbox shows it.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, yog_data::Schema)]
103#[data(rename_all = "camelCase")]
104pub enum Status {
105    /// The mod has no compiled components (nothing to build).
106    None,
107    /// Building the library / starting the host.
108    Building,
109    Ready,
110    Failed,
111}
112
113#[derive(Debug, Clone, yog_data::Schema)]
114#[data(rename_all = "camelCase")]
115pub struct Snapshot {
116    pub status: Status,
117    pub error: Option<String>,
118    /// Every component of the mod, from its `.yui` files.
119    pub components: Vec<ComponentInfo>,
120    /// The ones that need the mod's library (the rest are drawn from markup).
121    pub compiled: Vec<String>,
122    /// Changes whenever the drawing of an instance may: a component `.yui`
123    /// edited, the library rebuilt.
124    pub revision: String,
125    /// The mod's display name (`yog.toml` `name`, else its id) for the toolbox category.
126    pub mod_name: String,
127}
128
129struct HostProc {
130    child: Child,
131    stdin: ChildStdin,
132    answers: Receiver<HostReply>,
133    stderr: Arc<Mutex<String>>,
134    lib_copy: PathBuf,
135}
136
137impl HostProc {
138    fn spawn(lib: &Path, mod_root: &Path) -> Result<(Self, Vec<ComponentInfo>), String> {
139        let mut child = super::this_cli()
140            .arg(super::host::COMMAND)
141            .current_dir(mod_root)
142            .stdin(Stdio::piped())
143            .stdout(Stdio::piped())
144            .stderr(Stdio::piped())
145            .spawn()
146            .map_err(|e| format!("can't start the designer host: {e}"))?;
147        let stdin = child.stdin.take().ok_or("no host stdin")?;
148        let stdout = child.stdout.take().ok_or("no host stdout")?;
149        let mut stderr_pipe = child.stderr.take().ok_or("no host stderr")?;
150        let (tx, answers) = mpsc::channel();
151        std::thread::spawn(move || {
152            let mut stdout = BufReader::new(stdout);
153            while let Ok(Some(answer)) = super::host::next_answer(&mut stdout) {
154                if tx.send(answer).is_err() {
155                    break;
156                }
157            }
158        });
159        let stderr = Arc::new(Mutex::new(String::new()));
160        let tail = stderr.clone();
161        std::thread::spawn(move || {
162            let mut buf = [0u8; 4096];
163            while let Ok(n) = stderr_pipe.read(&mut buf) {
164                if n == 0 {
165                    break;
166                }
167                let mut t = tail.lock();
168                t.push_str(&String::from_utf8_lossy(&buf[..n]));
169                if t.len() > 16_000 {
170                    let cut = t.len() - 8_000;
171                    let cut = (cut..t.len())
172                        .find(|i| t.is_char_boundary(*i))
173                        .unwrap_or(t.len());
174                    t.drain(..cut);
175                }
176            }
177        });
178        let mut host = HostProc {
179            child,
180            stdin,
181            answers,
182            stderr,
183            lib_copy: lib.to_path_buf(),
184        };
185        let load = HostRequest::Load {
186            lib: lib.to_path_buf(),
187            mod_root: Some(mod_root.to_path_buf()),
188        };
189        match host.ask(&load, LOAD_TIMEOUT)? {
190            HostAnswer::Loaded { components } => Ok((host, components)),
191            other => Err(format!("asked to load, the host answered {other:?}")),
192        }
193    }
194
195    fn ask(&mut self, request: &HostRequest, timeout: Duration) -> Result<HostAnswer, String> {
196        yog_data::io::write_frame(&mut self.stdin, request).map_err(|_| self.died())?;
197        match self.answers.recv_timeout(timeout) {
198            Ok(answer) => answer,
199            Err(RecvTimeoutError::Timeout) => {
200                let _ = self.child.kill();
201                Err("the designer host stopped responding (a component that never returns?) — it was stopped".into())
202            }
203            Err(RecvTimeoutError::Disconnected) => Err(self.died()),
204        }
205    }
206
207    fn died(&mut self) -> String {
208        let _ = self.child.wait();
209        // Give the stderr reader a moment to drain the last words.
210        std::thread::sleep(Duration::from_millis(50));
211        let tail = self.stderr.lock().clone();
212        let tail: Vec<&str> = tail
213            .lines()
214            .rev()
215            .take(12)
216            .collect::<Vec<_>>()
217            .into_iter()
218            .rev()
219            .collect();
220        format!(
221            "the designer host crashed{}",
222            if tail.is_empty() {
223                String::new()
224            } else {
225                format!(":\n{}", tail.join("\n"))
226            }
227        )
228    }
229}
230
231impl Drop for HostProc {
232    fn drop(&mut self) {
233        let _ = self.child.kill();
234        let _ = self.child.wait();
235        let _ = std::fs::remove_file(&self.lib_copy);
236    }
237}
238
239type Drawn = (f32, f32, Vec<DrawOp>);
240
241struct ModState {
242    status: Status,
243    error: Option<String>,
244    /// What the loaded library registered.
245    components: Vec<ComponentInfo>,
246    mod_name: String,
247    /// The sources the current library (or failure) came from.
248    built: Option<u64>,
249    /// The latest sources seen, and since when (debounce).
250    seen: Option<(u64, Instant)>,
251    building: bool,
252    host: Option<HostProc>,
253    generation: u64,
254    cache: HashMap<Vec<u8>, Result<Drawn, String>>,
255}
256
257impl ModState {
258    fn new() -> Self {
259        Self {
260            status: Status::None,
261            error: None,
262            components: Vec::new(),
263            mod_name: String::new(),
264            built: None,
265            seen: None,
266            building: false,
267            host: None,
268            generation: 0,
269            cache: HashMap::new(),
270        }
271    }
272
273    fn snapshot(&self, (rev, defs): &(u64, Arc<Vec<Def>>)) -> Snapshot {
274        Snapshot {
275            revision: format!("{rev:x}.{}", self.generation),
276            status: self.status,
277            error: self.error.clone(),
278            components: defs.iter().map(|d| d.info.clone()).collect(),
279            compiled: defs
280                .iter()
281                .filter(|d| d.compiled)
282                .map(|d| d.info.name.clone())
283                .collect(),
284            mod_name: self.mod_name.clone(),
285        }
286    }
287
288    fn render(&mut self, req: &RenderReq) -> Result<Drawn, String> {
289        let key = yog_data::to_bytes(req);
290        if let Some(hit) = self.cache.get(&key) {
291            return hit.clone();
292        }
293        let Some(host) = self.host.as_mut() else {
294            return Err(self
295                .error
296                .clone()
297                .unwrap_or_else(|| "the designer library isn't loaded".into()));
298        };
299        let request = HostRequest::Render(req.clone());
300        let result = host
301            .ask(&request, ANSWER_TIMEOUT)
302            .and_then(|answer| match answer {
303                HostAnswer::Drawn { w, h, ops } => Ok((w, h, ops)),
304                other => Err(format!("asked to render, the host answered {other:?}")),
305            });
306        if result.is_err() && host.child.try_wait().ok().flatten().is_some() {
307            // The host is gone: say so until the next build brings a new one.
308            self.host = None;
309            self.status = Status::Failed;
310            self.error = result.clone().err();
311            self.cache.clear();
312            return result;
313        }
314        if self.cache.len() > 512 {
315            self.cache.clear();
316        }
317        self.cache.insert(key, result.clone());
318        result
319    }
320}
321
322type Shared = Arc<Mutex<ModState>>;
323
324fn mods() -> &'static Mutex<HashMap<PathBuf, Shared>> {
325    static M: OnceLock<Mutex<HashMap<PathBuf, Shared>>> = OnceLock::new();
326    M.get_or_init(Default::default)
327}
328
329fn state(root: &Path) -> Shared {
330    mods()
331        .lock()
332        .entry(root.to_path_buf())
333        .or_insert_with(|| Arc::new(Mutex::new(ModState::new())))
334        .clone()
335}
336
337/// The mod's Rust sources, `yog.toml` and resources — what the library is built from.
338fn sources(root: &Path) -> Vec<PathBuf> {
339    let mut out = vec![project::path(root)];
340    for dir in [yog_ids::project::SRC, yog_ids::project::RESOURCES] {
341        let walk = ignore::WalkBuilder::new(root.join(dir))
342            .hidden(false)
343            .build();
344        for entry in walk.flatten() {
345            let p = entry.path();
346            let ext = p.extension().and_then(|e| e.to_str()).unwrap_or_default();
347            if p.is_file() && matches!(ext, "rs" | "yui" | "toml" | "yres" | "json" | "png") {
348                out.push(p.to_path_buf());
349            }
350        }
351    }
352    out.sort();
353    out
354}
355
356fn mod_name(root: &Path) -> String {
357    let manifest = crate::manifest::YogToml::read(&project::path(root)).ok();
358    // `@Strings.x` names a resource text — the id reads better than the reference.
359    manifest
360        .as_ref()
361        .map(|m| m.name.clone())
362        .filter(|n| !n.is_empty() && !n.starts_with(yog_ids::sep::TYPED))
363        .or_else(|| manifest.map(|m| m.id).filter(|id| !id.is_empty()))
364        .unwrap_or_else(|| {
365            root.file_name()
366                .map(|n| n.to_string_lossy().into_owned())
367                .unwrap_or_default()
368        })
369}
370
371/// The mod's components and the library's state; starts a rebuild (and
372/// host restart) when the sources changed and have been quiet a moment —
373/// only if some component is compiled. Cheap enough to poll.
374pub fn status(root: &Path) -> Snapshot {
375    let defs = components::scan_with_revision(root);
376    let shared = state(root);
377    let files = sources(root);
378    let fp = crate::files::fingerprint(&files);
379    let mut s = shared.lock();
380    if s.mod_name.is_empty() {
381        s.mod_name = mod_name(root);
382    }
383    if s.building || s.built == Some(fp) {
384        return s.snapshot(&defs);
385    }
386    if !defs.1.iter().any(|d| d.compiled) {
387        s.status = Status::None;
388        s.error = None;
389        s.components.clear();
390        s.host = None;
391        s.built = Some(fp);
392        return s.snapshot(&defs);
393    }
394    let now = Instant::now();
395    match s.seen {
396        Some((seen, since)) if seen == fp => {
397            if now.duration_since(since) < DEBOUNCE && s.built.is_some() {
398                return s.snapshot(&defs);
399            }
400        }
401        _ => {
402            s.seen = Some((fp, now));
403            if s.built.is_some() {
404                return s.snapshot(&defs);
405            }
406        }
407    }
408    s.building = true;
409    s.status = Status::Building;
410    s.mod_name = mod_name(root);
411    let snapshot = s.snapshot(&defs);
412    drop(s);
413    let root = root.to_path_buf();
414    std::thread::spawn(move || rebuild(&root, &shared, fp));
415    snapshot
416}
417
418/// `yog designer-lib`, then a fresh host on a copy of the library (cargo
419/// may overwrite the original while the host has it loaded).
420fn rebuild(root: &Path, shared: &Shared, fp: u64) {
421    let result = build_lib(root).and_then(|lib| {
422        let generation = shared.lock().generation + 1;
423        let copy = std::env::temp_dir().join("yog-idle-designer").join(format!(
424            "{}-{}-{generation}{}",
425            lib.file_stem().unwrap_or_default().to_string_lossy(),
426            std::process::id(),
427            lib.extension()
428                .map(|e| format!(".{}", e.to_string_lossy()))
429                .unwrap_or_default()
430        ));
431        std::fs::create_dir_all(copy.parent().unwrap_or(Path::new(".")))
432            .map_err(|e| e.to_string())?;
433        std::fs::copy(&lib, &copy).map_err(|e| format!("copying {}: {e}", lib.display()))?;
434        HostProc::spawn(&copy, root).inspect_err(|_| {
435            let _ = std::fs::remove_file(&copy);
436        })
437    });
438    let mut s = shared.lock();
439    s.building = false;
440    s.built = Some(fp);
441    s.generation += 1;
442    s.cache.clear();
443    match result {
444        Ok((host, components)) => {
445            s.host = Some(host);
446            s.components = components;
447            s.status = Status::Ready;
448            s.error = None;
449        }
450        Err(e) => {
451            s.host = None;
452            s.status = Status::Failed;
453            s.error = Some(e);
454        }
455    }
456}
457
458fn build_lib(root: &Path) -> Result<PathBuf, String> {
459    let out = crate::design::this_cli()
460        .arg("designer-lib")
461        .current_dir(root)
462        .stdin(Stdio::null())
463        .output()
464        .map_err(|e| format!("failed to run yog: {e}"))?;
465    let stdout = String::from_utf8_lossy(&out.stdout);
466    let stderr = String::from_utf8_lossy(&out.stderr);
467    if out.status.success() {
468        let path = stdout
469            .lines()
470            .map(str::trim)
471            .rfind(|l| !l.is_empty())
472            .unwrap_or_default();
473        return if Path::new(path).is_file() {
474            Ok(PathBuf::from(path))
475        } else {
476            Err(format!("yog designer-lib gave no library path:\n{stdout}"))
477        };
478    }
479    if stderr.contains("unknown command: designer-lib") {
480        return Err("this yog CLI can't build designer libraries — update it (Tools → Environment, or `cargo install --locked yog-cli`)".into());
481    }
482    Err(build_errors(&stderr))
483}
484
485/// Cargo's errors (each with its location), else the output's tail.
486fn build_errors(stderr: &str) -> String {
487    let lines: Vec<&str> = stderr.lines().collect();
488    let mut out = Vec::new();
489    for (i, l) in lines.iter().enumerate() {
490        if l.starts_with("error") && !l.starts_with("error: could not compile") {
491            out.push(l.to_string());
492            if let Some(at) = lines
493                .get(i + 1)
494                .filter(|n| n.trim_start().starts_with("-->"))
495            {
496                out.push(at.trim().to_string());
497            }
498        }
499    }
500    if out.is_empty() {
501        out = lines
502            .iter()
503            .rev()
504            .take(15)
505            .rev()
506            .map(|l| l.to_string())
507            .collect();
508    }
509    let text = out.join("\n");
510    if text.trim().is_empty() {
511        "building the designer library failed".into()
512    } else {
513        text.chars().take(4000).collect()
514    }
515}
516
517mod fit;
518mod placeholder;
519#[cfg(test)]
520mod tests;
521
522pub use fit::{Prepared, prepare, splice};
523pub use placeholder::placeholder;