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