1use std::collections::HashMap;
36use std::sync::{Mutex, PoisonError};
37
38use crate::widget::{self, Widget};
39use crate::FlexDir;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum PropKind {
44 Text,
46 Number,
48 Int,
50 Bool,
52 Color,
54 Choice(&'static [&'static str]),
56 Event,
59 Image,
61}
62
63impl PropKind {
64 pub fn as_str(self) -> &'static str {
66 match self {
67 PropKind::Text => "text",
68 PropKind::Number => "number",
69 PropKind::Int => "int",
70 PropKind::Bool => "bool",
71 PropKind::Color => "color",
72 PropKind::Choice(_) => "choice",
73 PropKind::Event => "event",
74 PropKind::Image => "image",
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct Prop {
82 pub name: &'static str,
83 pub kind: PropKind,
84 pub default: &'static str,
86 pub doc: &'static str,
88 pub category: &'static str,
90}
91
92impl Prop {
93 pub const fn new(name: &'static str, kind: PropKind, default: &'static str) -> Self {
94 Self {
95 name,
96 kind,
97 default,
98 doc: "",
99 category: "",
100 }
101 }
102 pub const fn text(name: &'static str, default: &'static str) -> Self {
103 Self::new(name, PropKind::Text, default)
104 }
105 pub const fn number(name: &'static str, default: &'static str) -> Self {
106 Self::new(name, PropKind::Number, default)
107 }
108 pub const fn int(name: &'static str, default: &'static str) -> Self {
109 Self::new(name, PropKind::Int, default)
110 }
111 pub const fn bool(name: &'static str, default: bool) -> Self {
112 Self::new(name, PropKind::Bool, if default { "true" } else { "false" })
113 }
114 pub const fn color(name: &'static str, default: &'static str) -> Self {
115 Self::new(name, PropKind::Color, default)
116 }
117 pub const fn choice(
118 name: &'static str,
119 options: &'static [&'static str],
120 default: &'static str,
121 ) -> Self {
122 Self::new(name, PropKind::Choice(options), default)
123 }
124 pub const fn event(name: &'static str) -> Self {
125 Self::new(name, PropKind::Event, "")
126 }
127 pub const fn image(name: &'static str, default: &'static str) -> Self {
128 Self::new(name, PropKind::Image, default)
129 }
130 pub const fn doc(self, doc: &'static str) -> Self {
132 Self { doc, ..self }
133 }
134 pub const fn category(self, category: &'static str) -> Self {
136 Self { category, ..self }
137 }
138}
139
140pub trait Component {
143 const NAME: &'static str;
145 const DOC: &'static str = "";
148 const DISPLAY_NAME: &'static str = "";
150 const ICON: &'static str = "";
153 const CATEGORY: &'static str = "";
155 const DESIGN_SIZE: (f32, f32) = (0.0, 0.0);
157 const TOOLBOX: bool = true;
159 const PROPS: &'static [Prop] = &[];
160 fn build(props: &Props) -> Widget;
163}
164
165#[derive(Debug, Clone, Copy)]
167pub struct ComponentSpec {
168 pub name: &'static str,
169 pub doc: &'static str,
170 pub display_name: &'static str,
171 pub icon: &'static str,
172 pub category: &'static str,
173 pub design_size: (f32, f32),
174 pub toolbox: bool,
175 pub props: &'static [Prop],
176 pub build: fn(&Props) -> Widget,
177}
178
179impl ComponentSpec {
180 pub fn of<C: Component>() -> Self {
181 Self {
182 name: C::NAME,
183 doc: C::DOC,
184 display_name: C::DISPLAY_NAME,
185 icon: C::ICON,
186 category: C::CATEGORY,
187 design_size: C::DESIGN_SIZE,
188 toolbox: C::TOOLBOX,
189 props: C::PROPS,
190 build: C::build,
191 }
192 }
193}
194
195static REGISTRY: Mutex<Vec<ComponentSpec>> = Mutex::new(Vec::new());
196
197pub fn register<C: Component>() {
201 register_spec(ComponentSpec::of::<C>());
202}
203
204pub fn register_spec(spec: ComponentSpec) {
205 let mut all = REGISTRY.lock().unwrap_or_else(PoisonError::into_inner);
206 all.retain(|s| s.name != spec.name);
207 all.push(spec);
208}
209
210pub fn components() -> Vec<ComponentSpec> {
212 REGISTRY
213 .lock()
214 .unwrap_or_else(PoisonError::into_inner)
215 .clone()
216}
217
218pub fn find(name: &str) -> Option<ComponentSpec> {
220 REGISTRY
221 .lock()
222 .unwrap_or_else(PoisonError::into_inner)
223 .iter()
224 .find(|s| s.name == name)
225 .copied()
226}
227
228pub fn build(name: &str, attrs: &[(&str, &str)]) -> Widget {
232 match find(name) {
233 Some(spec) => (spec.build)(&Props::new(spec.props, attrs)),
234 None => error_box(&format!("unknown component <{name}>")),
235 }
236}
237
238pub fn error_box(message: &str) -> Widget {
240 widget::panel(FlexDir::Column)
241 .bg(0xC0_400000)
242 .padding(2.0, 2.0, 2.0, 2.0)
243 .child(widget::label(message).color(0xFF_FF8080))
244 .min_w(16.0)
245 .min_h(12.0)
246}
247
248#[derive(Debug, Clone, Default)]
250pub struct Props {
251 values: HashMap<String, String>,
252}
253
254impl Props {
255 pub fn new(props: &[Prop], attrs: &[(&str, &str)]) -> Self {
257 let mut values: HashMap<String, String> = props
258 .iter()
259 .map(|p| (p.name.to_string(), p.default.to_string()))
260 .collect();
261 for (k, v) in attrs {
262 values.insert((*k).to_string(), (*v).to_string());
263 }
264 Self { values }
265 }
266
267 pub fn get(&self, name: &str) -> Option<&str> {
269 self.values.get(name).map(String::as_str)
270 }
271
272 pub fn text(&self, name: &str) -> String {
274 let v = self.get(name).unwrap_or_default();
275 match v.strip_prefix("@@") {
276 Some(rest) => format!("@{rest}"),
277 None => v.to_string(),
278 }
279 }
280
281 pub fn number(&self, name: &str) -> f32 {
283 self.get(name)
284 .and_then(|v| v.trim().parse().ok())
285 .unwrap_or(0.0)
286 }
287
288 pub fn int(&self, name: &str) -> i64 {
290 self.get(name)
291 .and_then(|v| v.trim().parse().ok())
292 .unwrap_or(0)
293 }
294
295 pub fn bool(&self, name: &str) -> bool {
297 self.get(name).is_some_and(|v| v.trim() == "true")
298 }
299
300 pub fn color(&self, name: &str) -> u32 {
302 self.get(name).and_then(parse_color).unwrap_or(0xFF_FFFFFF)
303 }
304
305 pub fn word(&self, name: &str) -> &str {
307 self.get(name).map(str::trim).unwrap_or_default()
308 }
309}
310
311pub mod bind {
314 use std::fmt::Display;
315
316 use crate::Units;
317
318 pub fn text<T: Display + ?Sized>(v: &T) -> String {
320 v.to_string()
321 }
322
323 pub fn color<T: BindColor>(v: T) -> u32 {
325 v.to_argb()
326 }
327
328 pub fn flag(v: impl Into<bool>) -> bool {
330 v.into()
331 }
332
333 pub fn count<T: BindNumber>(v: T) -> u32 {
335 v.to_f32().max(0.0) as u32
336 }
337
338 pub fn number<T: BindNumber>(v: T) -> f32 {
340 v.to_f32()
341 }
342
343 pub fn units<T: BindUnits>(v: T) -> Units {
345 v.to_units()
346 }
347
348 pub trait BindColor {
349 fn to_argb(self) -> u32;
350 }
351 impl BindColor for u32 {
352 fn to_argb(self) -> u32 {
353 self
354 }
355 }
356 impl BindColor for &u32 {
357 fn to_argb(self) -> u32 {
358 *self
359 }
360 }
361 impl BindColor for &str {
362 fn to_argb(self) -> u32 {
363 super::parse_color(self).unwrap_or(0xFF_FFFFFF)
364 }
365 }
366 impl BindColor for &&str {
367 fn to_argb(self) -> u32 {
368 (*self).to_argb()
369 }
370 }
371 impl BindColor for &String {
372 fn to_argb(self) -> u32 {
373 self.as_str().to_argb()
374 }
375 }
376 impl BindColor for String {
377 fn to_argb(self) -> u32 {
378 self.as_str().to_argb()
379 }
380 }
381
382 pub trait BindNumber {
383 fn to_f32(self) -> f32;
384 }
385 macro_rules! numbers {
386 ($($t:ty),*) => {$(
387 impl BindNumber for $t {
388 fn to_f32(self) -> f32 {
389 self as f32
390 }
391 }
392 impl BindNumber for &$t {
393 fn to_f32(self) -> f32 {
394 *self as f32
395 }
396 }
397 )*};
398 }
399 numbers!(f32, f64, i32, i64, u32, u64, usize);
400
401 pub trait BindUnits {
402 fn to_units(self) -> Units;
403 }
404 impl<T: BindNumber> BindUnits for T {
405 fn to_units(self) -> Units {
406 Units::Px(self.to_f32())
407 }
408 }
409 impl BindUnits for Units {
410 fn to_units(self) -> Units {
411 self
412 }
413 }
414 impl BindUnits for &str {
415 fn to_units(self) -> Units {
416 let v = self.trim();
417 if v.is_empty() || v == "auto" {
418 Units::Auto
419 } else if let Some(p) = v.strip_suffix('%') {
420 Units::Percent(p.trim().parse().unwrap_or(0.0))
421 } else {
422 Units::Px(v.parse().unwrap_or(0.0))
423 }
424 }
425 }
426 impl BindUnits for &Units {
427 fn to_units(self) -> Units {
428 *self
429 }
430 }
431 impl BindUnits for &&str {
432 fn to_units(self) -> Units {
433 (*self).to_units()
434 }
435 }
436 impl BindUnits for &String {
437 fn to_units(self) -> Units {
438 self.as_str().to_units()
439 }
440 }
441}
442
443pub const COMMON_ATTRS: &[&str] = &[
447 "id",
448 "x",
449 "y",
450 "w",
451 "h",
452 "min_w",
453 "min_h",
454 "flex",
455 "dock",
456 "gap",
457 "padding",
458 "bg",
459 "color",
460 "align",
461 "font_scale",
462 "font",
463 "shadow",
464 "no_wrap",
465 "ellipsis",
466 "focus_style",
467 "focus_color",
468 "enabled",
469 "on_click",
470 "visible",
471];
472
473pub fn apply_style(w: Widget, key: &str, value: &str) -> Widget {
477 let v = value.trim();
478 let num = || v.parse::<f32>().ok();
479 match key {
480 "bg" => parse_color(v).map_or(w.clone(), |c| w.clone().bg(c)),
481 "color" => parse_color(v).map_or(w.clone(), |c| w.clone().color(c)),
482 "focus_color" => parse_color(v).map_or(w.clone(), |c| w.clone().focus_color(c)),
483 "min_w" => num().map_or(w.clone(), |n| w.clone().min_w(n)),
484 "min_h" => num().map_or(w.clone(), |n| w.clone().min_h(n)),
485 "gap" => num().map_or(w.clone(), |n| w.clone().gap(n)),
486 "font_scale" => num().map_or(w.clone(), |n| w.clone().font_scale(n)),
487 "font" => w.font(v),
488 "shadow" => w.shadow(v == "true"),
489 "enabled" => w.enabled(v != "false"),
490 "no_wrap" if v == "true" => w.no_wrap(),
491 "ellipsis" if v == "true" => w.ellipsis(),
492 "align" => w.align(match v {
493 "center" => crate::Align::Center,
494 "end" => crate::Align::End,
495 _ => crate::Align::Start,
496 }),
497 "padding" => {
498 let parts: Option<Vec<f32>> = v
499 .split([' ', ','])
500 .filter(|s| !s.is_empty())
501 .map(|s| s.parse().ok())
502 .collect();
503 match parts.as_deref() {
504 Some([a]) => w.padding(*a, *a, *a, *a),
505 Some([vv, hh]) => w.padding(*vv, *hh, *vv, *hh),
506 Some([t, r, b, l]) => w.padding(*t, *r, *b, *l),
507 _ => w,
508 }
509 }
510 _ => w,
511 }
512}
513
514pub fn parse_color(value: &str) -> Option<u32> {
517 let v = value.trim();
518 if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
519 return v.parse().ok();
520 }
521 let hex = v
522 .strip_prefix('#')
523 .or_else(|| v.strip_prefix("0x"))
524 .or_else(|| v.strip_prefix("0X"))?
525 .replace('_', "");
526 let n = u32::from_str_radix(&hex, 16).ok()?;
527 match hex.len() {
528 6 => Some(0xFF00_0000 | n),
529 8 => Some(n),
530 _ => None,
531 }
532}
533
534pub mod designer {
549 use std::ffi::c_void;
550 use std::fmt::Write as _;
551 use std::panic::{catch_unwind, AssertUnwindSafe};
552
553 use yog_abi::{YogGfxApi, YogStr};
554 use yog_gfx::GfxContext;
555
556 use super::{apply_style, components, find, PropKind, Props, COMMON_ATTRS};
557 use crate::widget::{self, Widget};
558 use crate::{FlexDir, UiRoot, Units};
559
560 pub const ABI: u32 = 1;
562
563 pub type WriteFn = unsafe extern "C" fn(user_data: *mut c_void, text: YogStr);
565
566 pub unsafe fn call(
572 request: YogStr,
573 gfx: *const YogGfxApi,
574 user_data: *mut c_void,
575 write: WriteFn,
576 ) -> bool {
577 let request = unsafe { request.as_str() };
579 let (ok, answer) = match catch_unwind(AssertUnwindSafe(|| answer(request, gfx))) {
580 Ok(Ok(a)) => (true, a),
581 Ok(Err(e)) => (false, e),
582 Err(panic) => (false, panic_message(&*panic)),
583 };
584 unsafe { write(user_data, YogStr::from_str(&answer)) };
586 ok
587 }
588
589 fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
590 let msg = panic
591 .downcast_ref::<&str>()
592 .map(|s| s.to_string())
593 .or_else(|| panic.downcast_ref::<String>().cloned())
594 .unwrap_or_else(|| "unknown panic".into());
595 format!("the component panicked: {msg}")
596 }
597
598 fn answer(request: &str, gfx: *const YogGfxApi) -> Result<String, String> {
599 let mut lines = request.lines();
600 match lines.next().map(str::trim) {
601 Some("describe") => Ok(describe()),
602 Some("render") => {
603 if gfx.is_null() {
604 return Err("render: no gfx table".into());
605 }
606 let name = lines.next().unwrap_or_default().trim().to_string();
607 let size: Vec<f32> = lines
608 .next()
609 .unwrap_or_default()
610 .split_whitespace()
611 .filter_map(|n| n.parse().ok())
612 .collect();
613 let [w, h, aw, ah] = size[..] else {
614 return Err("render: expected `w h avail_w avail_h`".into());
615 };
616 let attrs: Vec<(String, String)> = lines
617 .filter_map(|l| l.split_once('='))
618 .map(|(k, v)| (k.trim().to_string(), unescape(v)))
619 .collect();
620 let ctx = unsafe { GfxContext::from_raw(gfx) };
622 render(&ctx, &name, &attrs, [w, h, aw, ah])
623 }
624 other => Err(format!("unknown designer request {other:?}")),
625 }
626 }
627
628 fn render(
629 ctx: &GfxContext,
630 name: &str,
631 attrs: &[(String, String)],
632 [w, h, aw, ah]: [f32; 4],
633 ) -> Result<String, String> {
634 let spec = find(name).ok_or_else(|| format!("no component named {name}"))?;
635 type Attrs<'a> = Vec<(&'a str, &'a str)>;
636 let (common, own): (Attrs, Attrs) = attrs
637 .iter()
638 .map(|(k, v)| (k.as_str(), v.as_str()))
639 .partition(|(k, _)| COMMON_ATTRS.contains(k));
640 let mut root: Widget = (spec.build)(&Props::new(spec.props, &own));
641 for (k, v) in common {
642 root = apply_style(root, k, v);
643 }
644 if w > 0.0 {
645 root = root.w(Units::Px(w));
646 }
647 if h > 0.0 {
648 root = root.h(Units::Px(h));
649 }
650 let (aw, ah) = (aw.max(1.0), ah.max(1.0));
651 let screen = widget::panel(FlexDir::Column)
652 .w(Units::Px(aw))
653 .h(Units::Px(ah))
654 .child(root.at(0.0, 0.0));
655 crate::text::use_host_metrics(ctx);
657 crate::image::use_host_images(ctx);
658 let mut ui = UiRoot::new("yog:designer", screen);
659 ui.layout(aw, ah);
660 let rect = ui
661 .layout_root
662 .children
663 .first()
664 .map(|n| n.rect)
665 .unwrap_or_default();
666 ui.render(ctx);
667 Ok(format!("{{\"w\":{},\"h\":{}}}", rect.w, rect.h))
668 }
669
670 fn describe() -> String {
671 let mut out = format!("{{\"abi\":{ABI},\"components\":[");
672 for (i, c) in components().iter().enumerate() {
673 if i > 0 {
674 out.push(',');
675 }
676 let _ = write!(
677 out,
678 "{{\"name\":{},\"description\":{},\"display_name\":{},\"icon\":{},\"category\":{},\"design_w\":{},\"design_h\":{},\"toolbox\":{},\"props\":[",
679 json(c.name),
680 json(c.doc),
681 json(c.display_name),
682 json(c.icon),
683 json(c.category),
684 c.design_size.0,
685 c.design_size.1,
686 c.toolbox
687 );
688 for (j, p) in c.props.iter().enumerate() {
689 if j > 0 {
690 out.push(',');
691 }
692 let options = match p.kind {
693 PropKind::Choice(o) => o.iter().map(|s| json(s)).collect::<Vec<_>>().join(","),
694 _ => String::new(),
695 };
696 let _ = write!(
697 out,
698 "{{\"name\":{},\"kind\":{},\"default\":{},\"doc\":{},\"category\":{},\"options\":[{options}]}}",
699 json(p.name),
700 json(p.kind.as_str()),
701 json(p.default),
702 json(p.doc),
703 json(p.category)
704 );
705 }
706 out.push_str("]}");
707 }
708 out.push_str("]}");
709 out
710 }
711
712 fn json(s: &str) -> String {
714 let mut out = String::with_capacity(s.len() + 2);
715 out.push('"');
716 for c in s.chars() {
717 match c {
718 '"' => out.push_str("\\\""),
719 '\\' => out.push_str("\\\\"),
720 '\n' => out.push_str("\\n"),
721 '\r' => out.push_str("\\r"),
722 '\t' => out.push_str("\\t"),
723 c if (c as u32) < 0x20 => {
724 let _ = write!(out, "\\u{:04x}", c as u32);
725 }
726 c => out.push(c),
727 }
728 }
729 out.push('"');
730 out
731 }
732
733 pub fn unescape(v: &str) -> String {
735 let mut out = String::with_capacity(v.len());
736 let mut chars = v.chars();
737 while let Some(c) = chars.next() {
738 if c != '\\' {
739 out.push(c);
740 continue;
741 }
742 match chars.next() {
743 Some('n') => out.push('\n'),
744 Some(other) => out.push(other),
745 None => out.push('\\'),
746 }
747 }
748 out
749 }
750
751 pub fn escape(v: &str) -> String {
753 v.replace('\\', "\\\\").replace('\n', "\\n")
754 }
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760
761 struct Badge;
762 impl Component for Badge {
763 const NAME: &'static str = "TestBadge";
764 const ICON: &'static str = "item:apple";
765 const DESIGN_SIZE: (f32, f32) = (40.0, 12.0);
766 const PROPS: &'static [Prop] = &[
767 Prop::text("label", "new"),
768 Prop::color("fill", "#FF3060C0").doc("Background"),
769 Prop::choice("size", &["small", "large"], "small"),
770 ];
771 fn build(p: &Props) -> Widget {
772 widget::label(p.text("label"))
773 .bg(p.color("fill"))
774 .padding(1.0, 2.0, 1.0, 2.0)
775 }
776 }
777
778 #[test]
779 fn props_fall_back_to_defaults() {
780 let p = Props::new(Badge::PROPS, &[("label", "@@x"), ("n", " 2.5 ")]);
781 assert_eq!(p.text("label"), "@x");
782 assert_eq!(p.color("fill"), 0xFF3060C0);
783 assert_eq!(p.word("size"), "small");
784 assert_eq!(p.number("n"), 2.5);
785 assert_eq!(p.int("missing"), 0);
786 assert!(!p.bool("missing"));
787 }
788
789 #[test]
790 fn builds_registered_components() {
791 register::<Badge>();
792 let w = build("TestBadge", &[("label", "hi")]);
793 assert!(matches!(&w.kind, crate::widget::WidgetKind::Label(t) if t == "hi"));
794 assert_eq!(w.style.bg, 0xFF3060C0);
795 let missing = build("Nope", &[]);
796 assert!(!missing.children.is_empty());
797 }
798
799 #[test]
800 fn describes_and_escapes() {
801 register::<Badge>();
802 let mut out = String::new();
803 unsafe extern "C" fn sink(ud: *mut std::ffi::c_void, s: yog_abi::YogStr) {
804 unsafe { (*(ud as *mut String)).push_str(s.as_str()) };
806 }
807 let req = "describe";
808 let ok = unsafe {
809 designer::call(
810 yog_abi::YogStr::from_str(req),
811 std::ptr::null(),
812 &mut out as *mut String as *mut _,
813 sink,
814 )
815 };
816 assert!(ok, "{out}");
817 assert!(out.contains("\"name\":\"TestBadge\""), "{out}");
818 assert!(out.contains("\"options\":[\"small\",\"large\"]"), "{out}");
819 assert!(out.contains("\"icon\":\"item:apple\",\"category\":\"\",\"design_w\":40,\"design_h\":12,\"toolbox\":true"), "{out}");
820 assert_eq!(designer::unescape(&designer::escape("a\\b\nc")), "a\\b\nc");
821 }
822}