1use 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
37const 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
69const 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
94const DEBOUNCE: Duration = Duration::from_millis(800);
96const 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
107#[serde(rename_all = "camelCase")]
108pub enum Status {
109 None,
111 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 pub components: Vec<ComponentInfo>,
124 pub compiled: Vec<String>,
126 pub revision: String,
129 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 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 components: Vec<ComponentInfo>,
270 mod_name: String,
271 built: Option<u64>,
273 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 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
368fn 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 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
417pub 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
464fn 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, ©).map_err(|e| format!("copying {}: {e}", lib.display()))?;
480 HostProc::spawn(©, root).inspect_err(|_| {
481 let _ = std::fs::remove_file(©);
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
531fn 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
563struct Slot {
567 uid: String,
568 marker: u32,
569 req: RenderReq,
570 problem: Option<String>,
572}
573
574pub 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
587const 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
605pub 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
636const 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 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 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
761pub 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 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
899const PLACEHOLDER_W: f32 = 80.0;
903const PLACEHOLDER_H: f32 = 20.0;
904const 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;
916const BUILDING: &str = "Building";
918
919pub 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 #[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}