1use 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
31const 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
63const 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
88const DEBOUNCE: Duration = Duration::from_millis(800);
90const ANSWER_TIMEOUT: Duration = Duration::from_secs(10);
92const 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#[derive(Debug, Clone, Copy, PartialEq, Eq, yog_data::Schema)]
103#[data(rename_all = "camelCase")]
104pub enum Status {
105 None,
107 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 pub components: Vec<ComponentInfo>,
120 pub compiled: Vec<String>,
122 pub revision: String,
125 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 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 components: Vec<ComponentInfo>,
246 mod_name: String,
247 built: Option<u64>,
249 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 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
337fn 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 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
371pub 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
418fn 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, ©).map_err(|e| format!("copying {}: {e}", lib.display()))?;
434 HostProc::spawn(©, root).inspect_err(|_| {
435 let _ = std::fs::remove_file(©);
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
485fn 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;