1use std::collections::HashMap;
34use std::sync::{Mutex, PoisonError};
35
36use crate::widget::{self, Widget};
37use crate::FlexDir;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PropKind {
42 Text,
44 Number,
46 Int,
48 Bool,
50 Color,
52 Choice(&'static [&'static str]),
54 Event,
57 Image,
59}
60
61impl PropKind {
62 pub fn as_str(self) -> &'static str {
64 match self {
65 PropKind::Text => "text",
66 PropKind::Number => "number",
67 PropKind::Int => "int",
68 PropKind::Bool => "bool",
69 PropKind::Color => "color",
70 PropKind::Choice(_) => "choice",
71 PropKind::Event => "event",
72 PropKind::Image => "image",
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct Prop {
80 pub name: &'static str,
81 pub kind: PropKind,
82 pub default: &'static str,
84 pub doc: &'static str,
86}
87
88impl Prop {
89 pub const fn new(name: &'static str, kind: PropKind, default: &'static str) -> Self {
90 Self { name, kind, default, doc: "" }
91 }
92 pub const fn text(name: &'static str, default: &'static str) -> Self {
93 Self::new(name, PropKind::Text, default)
94 }
95 pub const fn number(name: &'static str, default: &'static str) -> Self {
96 Self::new(name, PropKind::Number, default)
97 }
98 pub const fn int(name: &'static str, default: &'static str) -> Self {
99 Self::new(name, PropKind::Int, default)
100 }
101 pub const fn bool(name: &'static str, default: bool) -> Self {
102 Self::new(name, PropKind::Bool, if default { "true" } else { "false" })
103 }
104 pub const fn color(name: &'static str, default: &'static str) -> Self {
105 Self::new(name, PropKind::Color, default)
106 }
107 pub const fn choice(name: &'static str, options: &'static [&'static str], default: &'static str) -> Self {
108 Self::new(name, PropKind::Choice(options), default)
109 }
110 pub const fn event(name: &'static str) -> Self {
111 Self::new(name, PropKind::Event, "")
112 }
113 pub const fn image(name: &'static str, default: &'static str) -> Self {
114 Self::new(name, PropKind::Image, default)
115 }
116 pub const fn doc(self, doc: &'static str) -> Self {
118 Self { doc, ..self }
119 }
120}
121
122pub trait Component {
125 const NAME: &'static str;
127 const DOC: &'static str = "";
129 const PROPS: &'static [Prop] = &[];
130 fn build(props: &Props) -> Widget;
133}
134
135#[derive(Debug, Clone, Copy)]
137pub struct ComponentSpec {
138 pub name: &'static str,
139 pub doc: &'static str,
140 pub props: &'static [Prop],
141 pub build: fn(&Props) -> Widget,
142}
143
144impl ComponentSpec {
145 pub fn of<C: Component>() -> Self {
146 Self { name: C::NAME, doc: C::DOC, props: C::PROPS, build: C::build }
147 }
148}
149
150static REGISTRY: Mutex<Vec<ComponentSpec>> = Mutex::new(Vec::new());
151
152pub fn register<C: Component>() {
156 register_spec(ComponentSpec::of::<C>());
157}
158
159pub fn register_spec(spec: ComponentSpec) {
160 let mut all = REGISTRY.lock().unwrap_or_else(PoisonError::into_inner);
161 all.retain(|s| s.name != spec.name);
162 all.push(spec);
163}
164
165pub fn components() -> Vec<ComponentSpec> {
167 REGISTRY.lock().unwrap_or_else(PoisonError::into_inner).clone()
168}
169
170pub fn find(name: &str) -> Option<ComponentSpec> {
172 REGISTRY.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|s| s.name == name).copied()
173}
174
175pub fn build(name: &str, attrs: &[(&str, &str)]) -> Widget {
179 match find(name) {
180 Some(spec) => (spec.build)(&Props::new(spec.props, attrs)),
181 None => error_box(&format!("unknown component <{name}>")),
182 }
183}
184
185pub fn error_box(message: &str) -> Widget {
187 widget::panel(FlexDir::Column)
188 .bg(0xC0_400000)
189 .padding(2.0, 2.0, 2.0, 2.0)
190 .child(widget::label(message).color(0xFF_FF8080))
191 .min_w(16.0)
192 .min_h(12.0)
193}
194
195#[derive(Debug, Clone, Default)]
197pub struct Props {
198 values: HashMap<String, String>,
199}
200
201impl Props {
202 pub fn new(props: &[Prop], attrs: &[(&str, &str)]) -> Self {
204 let mut values: HashMap<String, String> = props.iter().map(|p| (p.name.to_string(), p.default.to_string())).collect();
205 for (k, v) in attrs {
206 values.insert((*k).to_string(), (*v).to_string());
207 }
208 Self { values }
209 }
210
211 pub fn get(&self, name: &str) -> Option<&str> {
213 self.values.get(name).map(String::as_str)
214 }
215
216 pub fn text(&self, name: &str) -> String {
218 let v = self.get(name).unwrap_or_default();
219 match v.strip_prefix("@@") {
220 Some(rest) => format!("@{rest}"),
221 None => v.to_string(),
222 }
223 }
224
225 pub fn number(&self, name: &str) -> f32 {
227 self.get(name).and_then(|v| v.trim().parse().ok()).unwrap_or(0.0)
228 }
229
230 pub fn int(&self, name: &str) -> i64 {
232 self.get(name).and_then(|v| v.trim().parse().ok()).unwrap_or(0)
233 }
234
235 pub fn bool(&self, name: &str) -> bool {
237 self.get(name).is_some_and(|v| v.trim() == "true")
238 }
239
240 pub fn color(&self, name: &str) -> u32 {
242 self.get(name).and_then(parse_color).unwrap_or(0xFF_FFFFFF)
243 }
244
245 pub fn word(&self, name: &str) -> &str {
247 self.get(name).map(str::trim).unwrap_or_default()
248 }
249}
250
251pub mod bind {
254 use std::fmt::Display;
255
256 use crate::Units;
257
258 pub fn text<T: Display + ?Sized>(v: &T) -> String {
260 v.to_string()
261 }
262
263 pub fn color<T: BindColor>(v: T) -> u32 {
265 v.to_argb()
266 }
267
268 pub fn flag(v: impl Into<bool>) -> bool {
270 v.into()
271 }
272
273 pub fn count<T: BindNumber>(v: T) -> u32 {
275 v.to_f32().max(0.0) as u32
276 }
277
278 pub fn number<T: BindNumber>(v: T) -> f32 {
280 v.to_f32()
281 }
282
283 pub fn units<T: BindUnits>(v: T) -> Units {
285 v.to_units()
286 }
287
288 pub trait BindColor {
289 fn to_argb(self) -> u32;
290 }
291 impl BindColor for u32 {
292 fn to_argb(self) -> u32 {
293 self
294 }
295 }
296 impl BindColor for &u32 {
297 fn to_argb(self) -> u32 {
298 *self
299 }
300 }
301 impl BindColor for &str {
302 fn to_argb(self) -> u32 {
303 super::parse_color(self).unwrap_or(0xFF_FFFFFF)
304 }
305 }
306 impl BindColor for &&str {
307 fn to_argb(self) -> u32 {
308 (*self).to_argb()
309 }
310 }
311 impl BindColor for &String {
312 fn to_argb(self) -> u32 {
313 self.as_str().to_argb()
314 }
315 }
316 impl BindColor for String {
317 fn to_argb(self) -> u32 {
318 self.as_str().to_argb()
319 }
320 }
321
322 pub trait BindNumber {
323 fn to_f32(self) -> f32;
324 }
325 macro_rules! numbers {
326 ($($t:ty),*) => {$(
327 impl BindNumber for $t {
328 fn to_f32(self) -> f32 {
329 self as f32
330 }
331 }
332 impl BindNumber for &$t {
333 fn to_f32(self) -> f32 {
334 *self as f32
335 }
336 }
337 )*};
338 }
339 numbers!(f32, f64, i32, i64, u32, u64, usize);
340
341 pub trait BindUnits {
342 fn to_units(self) -> Units;
343 }
344 impl<T: BindNumber> BindUnits for T {
345 fn to_units(self) -> Units {
346 Units::Px(self.to_f32())
347 }
348 }
349 impl BindUnits for Units {
350 fn to_units(self) -> Units {
351 self
352 }
353 }
354 impl BindUnits for &str {
355 fn to_units(self) -> Units {
356 let v = self.trim();
357 if v.is_empty() || v == "auto" {
358 Units::Auto
359 } else if let Some(p) = v.strip_suffix('%') {
360 Units::Percent(p.trim().parse().unwrap_or(0.0))
361 } else {
362 Units::Px(v.parse().unwrap_or(0.0))
363 }
364 }
365 }
366 impl BindUnits for &Units {
367 fn to_units(self) -> Units {
368 *self
369 }
370 }
371 impl BindUnits for &&str {
372 fn to_units(self) -> Units {
373 (*self).to_units()
374 }
375 }
376 impl BindUnits for &String {
377 fn to_units(self) -> Units {
378 self.as_str().to_units()
379 }
380 }
381}
382
383pub const COMMON_ATTRS: &[&str] = &[
387 "id", "x", "y", "w", "h", "min_w", "min_h", "flex", "dock", "gap", "padding", "bg", "color", "align", "font_scale",
388 "font", "shadow", "no_wrap", "ellipsis", "focus_style", "focus_color", "enabled", "on_click", "visible",
389];
390
391pub fn apply_style(w: Widget, key: &str, value: &str) -> Widget {
395 let v = value.trim();
396 let num = || v.parse::<f32>().ok();
397 match key {
398 "bg" => parse_color(v).map_or(w.clone(), |c| w.clone().bg(c)),
399 "color" => parse_color(v).map_or(w.clone(), |c| w.clone().color(c)),
400 "focus_color" => parse_color(v).map_or(w.clone(), |c| w.clone().focus_color(c)),
401 "min_w" => num().map_or(w.clone(), |n| w.clone().min_w(n)),
402 "min_h" => num().map_or(w.clone(), |n| w.clone().min_h(n)),
403 "gap" => num().map_or(w.clone(), |n| w.clone().gap(n)),
404 "font_scale" => num().map_or(w.clone(), |n| w.clone().font_scale(n)),
405 "font" => w.font(v),
406 "shadow" => w.shadow(v == "true"),
407 "enabled" => w.enabled(v != "false"),
408 "no_wrap" if v == "true" => w.no_wrap(),
409 "ellipsis" if v == "true" => w.ellipsis(),
410 "align" => w.align(match v {
411 "center" => crate::Align::Center,
412 "end" => crate::Align::End,
413 _ => crate::Align::Start,
414 }),
415 "padding" => {
416 let parts: Option<Vec<f32>> = v.split([' ', ',']).filter(|s| !s.is_empty()).map(|s| s.parse().ok()).collect();
417 match parts.as_deref() {
418 Some([a]) => w.padding(*a, *a, *a, *a),
419 Some([vv, hh]) => w.padding(*vv, *hh, *vv, *hh),
420 Some([t, r, b, l]) => w.padding(*t, *r, *b, *l),
421 _ => w,
422 }
423 }
424 _ => w,
425 }
426}
427
428pub fn parse_color(value: &str) -> Option<u32> {
430 let v = value.trim();
431 let hex = v.strip_prefix('#').or_else(|| v.strip_prefix("0x")).or_else(|| v.strip_prefix("0X"))?.replace('_', "");
432 let n = u32::from_str_radix(&hex, 16).ok()?;
433 match hex.len() {
434 6 => Some(0xFF00_0000 | n),
435 8 => Some(n),
436 _ => None,
437 }
438}
439
440pub mod designer {
455 use std::ffi::c_void;
456 use std::fmt::Write as _;
457 use std::panic::{catch_unwind, AssertUnwindSafe};
458
459 use yog_abi::{YogGfxApi, YogStr};
460 use yog_gfx::GfxContext;
461
462 use super::{apply_style, components, find, PropKind, Props, COMMON_ATTRS};
463 use crate::widget::{self, Widget};
464 use crate::{FlexDir, UiRoot, Units};
465
466 pub const ABI: u32 = 1;
468
469 pub type WriteFn = unsafe extern "C" fn(user_data: *mut c_void, text: YogStr);
471
472 pub unsafe fn call(request: YogStr, gfx: *const YogGfxApi, user_data: *mut c_void, write: WriteFn) -> bool {
478 let request = unsafe { request.as_str() };
480 let (ok, answer) = match catch_unwind(AssertUnwindSafe(|| answer(request, gfx))) {
481 Ok(Ok(a)) => (true, a),
482 Ok(Err(e)) => (false, e),
483 Err(panic) => (false, panic_message(&*panic)),
484 };
485 unsafe { write(user_data, YogStr::from_str(&answer)) };
487 ok
488 }
489
490 fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
491 let msg = panic
492 .downcast_ref::<&str>()
493 .map(|s| s.to_string())
494 .or_else(|| panic.downcast_ref::<String>().cloned())
495 .unwrap_or_else(|| "unknown panic".into());
496 format!("the component panicked: {msg}")
497 }
498
499 fn answer(request: &str, gfx: *const YogGfxApi) -> Result<String, String> {
500 let mut lines = request.lines();
501 match lines.next().map(str::trim) {
502 Some("describe") => Ok(describe()),
503 Some("render") => {
504 if gfx.is_null() {
505 return Err("render: no gfx table".into());
506 }
507 let name = lines.next().unwrap_or_default().trim().to_string();
508 let size: Vec<f32> = lines.next().unwrap_or_default().split_whitespace().filter_map(|n| n.parse().ok()).collect();
509 let [w, h, aw, ah] = size[..] else { return Err("render: expected `w h avail_w avail_h`".into()) };
510 let attrs: Vec<(String, String)> =
511 lines.filter_map(|l| l.split_once('=')).map(|(k, v)| (k.trim().to_string(), unescape(v))).collect();
512 let ctx = unsafe { GfxContext::from_raw(gfx) };
514 render(&ctx, &name, &attrs, [w, h, aw, ah])
515 }
516 other => Err(format!("unknown designer request {other:?}")),
517 }
518 }
519
520 fn render(ctx: &GfxContext, name: &str, attrs: &[(String, String)], [w, h, aw, ah]: [f32; 4]) -> Result<String, String> {
521 let spec = find(name).ok_or_else(|| format!("no component named {name}"))?;
522 type Attrs<'a> = Vec<(&'a str, &'a str)>;
523 let (common, own): (Attrs, Attrs) =
524 attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).partition(|(k, _)| COMMON_ATTRS.contains(k));
525 let mut root: Widget = (spec.build)(&Props::new(spec.props, &own));
526 for (k, v) in common {
527 root = apply_style(root, k, v);
528 }
529 if w > 0.0 {
530 root = root.w(Units::Px(w));
531 }
532 if h > 0.0 {
533 root = root.h(Units::Px(h));
534 }
535 let (aw, ah) = (aw.max(1.0), ah.max(1.0));
536 let screen = widget::panel(FlexDir::Column).w(Units::Px(aw)).h(Units::Px(ah)).child(root.at(0.0, 0.0));
537 crate::text::use_host_metrics(ctx);
539 crate::image::use_host_images(ctx);
540 let mut ui = UiRoot::new("yog:designer", screen);
541 ui.layout(aw, ah);
542 let rect = ui.layout_root.children.first().map(|n| n.rect).unwrap_or_default();
543 ui.render(ctx);
544 Ok(format!("{{\"w\":{},\"h\":{}}}", rect.w, rect.h))
545 }
546
547 fn describe() -> String {
548 let mut out = format!("{{\"abi\":{ABI},\"components\":[");
549 for (i, c) in components().iter().enumerate() {
550 if i > 0 {
551 out.push(',');
552 }
553 let _ = write!(out, "{{\"name\":{},\"doc\":{},\"props\":[", json(c.name), json(c.doc));
554 for (j, p) in c.props.iter().enumerate() {
555 if j > 0 {
556 out.push(',');
557 }
558 let options = match p.kind {
559 PropKind::Choice(o) => o.iter().map(|s| json(s)).collect::<Vec<_>>().join(","),
560 _ => String::new(),
561 };
562 let _ = write!(
563 out,
564 "{{\"name\":{},\"kind\":{},\"default\":{},\"doc\":{},\"options\":[{options}]}}",
565 json(p.name),
566 json(p.kind.as_str()),
567 json(p.default),
568 json(p.doc)
569 );
570 }
571 out.push_str("]}");
572 }
573 out.push_str("]}");
574 out
575 }
576
577 fn json(s: &str) -> String {
579 let mut out = String::with_capacity(s.len() + 2);
580 out.push('"');
581 for c in s.chars() {
582 match c {
583 '"' => out.push_str("\\\""),
584 '\\' => out.push_str("\\\\"),
585 '\n' => out.push_str("\\n"),
586 '\r' => out.push_str("\\r"),
587 '\t' => out.push_str("\\t"),
588 c if (c as u32) < 0x20 => {
589 let _ = write!(out, "\\u{:04x}", c as u32);
590 }
591 c => out.push(c),
592 }
593 }
594 out.push('"');
595 out
596 }
597
598 pub fn unescape(v: &str) -> String {
600 let mut out = String::with_capacity(v.len());
601 let mut chars = v.chars();
602 while let Some(c) = chars.next() {
603 if c != '\\' {
604 out.push(c);
605 continue;
606 }
607 match chars.next() {
608 Some('n') => out.push('\n'),
609 Some(other) => out.push(other),
610 None => out.push('\\'),
611 }
612 }
613 out
614 }
615
616 pub fn escape(v: &str) -> String {
618 v.replace('\\', "\\\\").replace('\n', "\\n")
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 struct Badge;
627 impl Component for Badge {
628 const NAME: &'static str = "TestBadge";
629 const PROPS: &'static [Prop] = &[
630 Prop::text("label", "new"),
631 Prop::color("fill", "#FF3060C0").doc("Background"),
632 Prop::choice("size", &["small", "large"], "small"),
633 ];
634 fn build(p: &Props) -> Widget {
635 widget::label(p.text("label")).bg(p.color("fill")).padding(1.0, 2.0, 1.0, 2.0)
636 }
637 }
638
639 #[test]
640 fn props_fall_back_to_defaults() {
641 let p = Props::new(Badge::PROPS, &[("label", "@@x"), ("n", " 2.5 ")]);
642 assert_eq!(p.text("label"), "@x");
643 assert_eq!(p.color("fill"), 0xFF3060C0);
644 assert_eq!(p.word("size"), "small");
645 assert_eq!(p.number("n"), 2.5);
646 assert_eq!(p.int("missing"), 0);
647 assert!(!p.bool("missing"));
648 }
649
650 #[test]
651 fn builds_registered_components() {
652 register::<Badge>();
653 let w = build("TestBadge", &[("label", "hi")]);
654 assert!(matches!(&w.kind, crate::widget::WidgetKind::Label(t) if t == "hi"));
655 assert_eq!(w.style.bg, 0xFF3060C0);
656 let missing = build("Nope", &[]);
657 assert!(!missing.children.is_empty());
658 }
659
660 #[test]
661 fn describes_and_escapes() {
662 register::<Badge>();
663 let mut out = String::new();
664 unsafe extern "C" fn sink(ud: *mut std::ffi::c_void, s: yog_abi::YogStr) {
665 unsafe { (*(ud as *mut String)).push_str(s.as_str()) };
667 }
668 let req = "describe";
669 let ok = unsafe { designer::call(yog_abi::YogStr::from_str(req), std::ptr::null(), &mut out as *mut String as *mut _, sink) };
670 assert!(ok, "{out}");
671 assert!(out.contains("\"name\":\"TestBadge\""), "{out}");
672 assert!(out.contains("\"options\":[\"small\",\"large\"]"), "{out}");
673 assert_eq!(designer::unescape(&designer::escape("a\\b\nc")), "a\\b\nc");
674 }
675}