1use std::collections::HashMap;
49use std::sync::{Mutex, PoisonError};
50
51use crate::widget::{self, Widget};
52use crate::FlexDir;
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum PropKind {
57 Text,
59 Number,
61 Int,
63 Bool,
65 Color,
67 Choice(&'static [&'static str]),
69 Event,
72 Image,
74}
75
76impl PropKind {
77 pub fn as_str(self) -> &'static str {
79 match self {
80 PropKind::Text => "text",
81 PropKind::Number => "number",
82 PropKind::Int => "int",
83 PropKind::Bool => "bool",
84 PropKind::Color => "color",
85 PropKind::Choice(_) => "choice",
86 PropKind::Event => "event",
87 PropKind::Image => "image",
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct Prop {
95 pub name: &'static str,
96 pub kind: PropKind,
97 pub default: &'static str,
99 pub doc: &'static str,
101}
102
103impl Prop {
104 pub const fn new(name: &'static str, kind: PropKind, default: &'static str) -> Self {
105 Self { name, kind, default, doc: "" }
106 }
107 pub const fn text(name: &'static str, default: &'static str) -> Self {
108 Self::new(name, PropKind::Text, default)
109 }
110 pub const fn number(name: &'static str, default: &'static str) -> Self {
111 Self::new(name, PropKind::Number, default)
112 }
113 pub const fn int(name: &'static str, default: &'static str) -> Self {
114 Self::new(name, PropKind::Int, default)
115 }
116 pub const fn bool(name: &'static str, default: bool) -> Self {
117 Self::new(name, PropKind::Bool, if default { "true" } else { "false" })
118 }
119 pub const fn color(name: &'static str, default: &'static str) -> Self {
120 Self::new(name, PropKind::Color, default)
121 }
122 pub const fn choice(name: &'static str, options: &'static [&'static str], default: &'static str) -> Self {
123 Self::new(name, PropKind::Choice(options), default)
124 }
125 pub const fn event(name: &'static str) -> Self {
126 Self::new(name, PropKind::Event, "")
127 }
128 pub const fn image(name: &'static str, default: &'static str) -> Self {
129 Self::new(name, PropKind::Image, default)
130 }
131 pub const fn doc(self, doc: &'static str) -> Self {
133 Self { doc, ..self }
134 }
135}
136
137pub trait Component {
139 const NAME: &'static str;
141 const DOC: &'static str = "";
143 const PROPS: &'static [Prop] = &[];
144 fn build(props: &Props) -> Widget;
147}
148
149#[derive(Debug, Clone, Copy)]
151pub struct ComponentSpec {
152 pub name: &'static str,
153 pub doc: &'static str,
154 pub props: &'static [Prop],
155 pub build: fn(&Props) -> Widget,
156}
157
158impl ComponentSpec {
159 pub fn of<C: Component>() -> Self {
160 Self { name: C::NAME, doc: C::DOC, props: C::PROPS, build: C::build }
161 }
162}
163
164static REGISTRY: Mutex<Vec<ComponentSpec>> = Mutex::new(Vec::new());
165
166pub fn register<C: Component>() {
170 register_spec(ComponentSpec::of::<C>());
171}
172
173pub fn register_spec(spec: ComponentSpec) {
174 let mut all = REGISTRY.lock().unwrap_or_else(PoisonError::into_inner);
175 all.retain(|s| s.name != spec.name);
176 all.push(spec);
177}
178
179pub fn components() -> Vec<ComponentSpec> {
181 REGISTRY.lock().unwrap_or_else(PoisonError::into_inner).clone()
182}
183
184pub fn find(name: &str) -> Option<ComponentSpec> {
186 REGISTRY.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|s| s.name == name).copied()
187}
188
189pub fn build(name: &str, attrs: &[(&str, &str)]) -> Widget {
193 match find(name) {
194 Some(spec) => (spec.build)(&Props::new(spec.props, attrs)),
195 None => error_box(&format!("unknown component <{name}>")),
196 }
197}
198
199pub fn error_box(message: &str) -> Widget {
201 widget::panel(FlexDir::Column)
202 .bg(0xC0_400000)
203 .padding(2.0, 2.0, 2.0, 2.0)
204 .child(widget::label(message).color(0xFF_FF8080))
205 .min_w(16.0)
206 .min_h(12.0)
207}
208
209#[derive(Debug, Clone, Default)]
211pub struct Props {
212 values: HashMap<String, String>,
213}
214
215impl Props {
216 pub fn new(props: &[Prop], attrs: &[(&str, &str)]) -> Self {
218 let mut values: HashMap<String, String> = props.iter().map(|p| (p.name.to_string(), p.default.to_string())).collect();
219 for (k, v) in attrs {
220 values.insert((*k).to_string(), (*v).to_string());
221 }
222 Self { values }
223 }
224
225 pub fn get(&self, name: &str) -> Option<&str> {
227 self.values.get(name).map(String::as_str)
228 }
229
230 pub fn text(&self, name: &str) -> String {
232 let v = self.get(name).unwrap_or_default();
233 match v.strip_prefix("@@") {
234 Some(rest) => format!("@{rest}"),
235 None => v.to_string(),
236 }
237 }
238
239 pub fn number(&self, name: &str) -> f32 {
241 self.get(name).and_then(|v| v.trim().parse().ok()).unwrap_or(0.0)
242 }
243
244 pub fn int(&self, name: &str) -> i64 {
246 self.get(name).and_then(|v| v.trim().parse().ok()).unwrap_or(0)
247 }
248
249 pub fn bool(&self, name: &str) -> bool {
251 self.get(name).is_some_and(|v| v.trim() == "true")
252 }
253
254 pub fn color(&self, name: &str) -> u32 {
256 self.get(name).and_then(parse_color).unwrap_or(0xFF_FFFFFF)
257 }
258
259 pub fn word(&self, name: &str) -> &str {
261 self.get(name).map(str::trim).unwrap_or_default()
262 }
263}
264
265pub const COMMON_ATTRS: &[&str] = &[
269 "id", "x", "y", "w", "h", "min_w", "min_h", "flex", "dock", "gap", "padding", "bg", "color", "align", "font_scale",
270 "font", "shadow", "no_wrap", "ellipsis", "focus_style", "focus_color", "enabled", "on_click", "visible",
271];
272
273pub fn apply_style(w: Widget, key: &str, value: &str) -> Widget {
277 let v = value.trim();
278 let num = || v.parse::<f32>().ok();
279 match key {
280 "bg" => parse_color(v).map_or(w.clone(), |c| w.clone().bg(c)),
281 "color" => parse_color(v).map_or(w.clone(), |c| w.clone().color(c)),
282 "focus_color" => parse_color(v).map_or(w.clone(), |c| w.clone().focus_color(c)),
283 "min_w" => num().map_or(w.clone(), |n| w.clone().min_w(n)),
284 "min_h" => num().map_or(w.clone(), |n| w.clone().min_h(n)),
285 "gap" => num().map_or(w.clone(), |n| w.clone().gap(n)),
286 "font_scale" => num().map_or(w.clone(), |n| w.clone().font_scale(n)),
287 "font" => w.font(v),
288 "shadow" => w.shadow(v == "true"),
289 "enabled" => w.enabled(v != "false"),
290 "no_wrap" if v == "true" => w.no_wrap(),
291 "ellipsis" if v == "true" => w.ellipsis(),
292 "align" => w.align(match v {
293 "center" => crate::Align::Center,
294 "end" => crate::Align::End,
295 _ => crate::Align::Start,
296 }),
297 "padding" => {
298 let parts: Option<Vec<f32>> = v.split([' ', ',']).filter(|s| !s.is_empty()).map(|s| s.parse().ok()).collect();
299 match parts.as_deref() {
300 Some([a]) => w.padding(*a, *a, *a, *a),
301 Some([vv, hh]) => w.padding(*vv, *hh, *vv, *hh),
302 Some([t, r, b, l]) => w.padding(*t, *r, *b, *l),
303 _ => w,
304 }
305 }
306 _ => w,
307 }
308}
309
310pub fn parse_color(value: &str) -> Option<u32> {
312 let v = value.trim();
313 let hex = v.strip_prefix('#').or_else(|| v.strip_prefix("0x")).or_else(|| v.strip_prefix("0X"))?.replace('_', "");
314 let n = u32::from_str_radix(&hex, 16).ok()?;
315 match hex.len() {
316 6 => Some(0xFF00_0000 | n),
317 8 => Some(n),
318 _ => None,
319 }
320}
321
322pub mod designer {
337 use std::ffi::c_void;
338 use std::fmt::Write as _;
339 use std::panic::{catch_unwind, AssertUnwindSafe};
340
341 use yog_abi::{YogGfxApi, YogStr};
342 use yog_gfx::GfxContext;
343
344 use super::{apply_style, components, find, PropKind, Props, COMMON_ATTRS};
345 use crate::widget::{self, Widget};
346 use crate::{FlexDir, UiRoot, Units};
347
348 pub const ABI: u32 = 1;
350
351 pub type WriteFn = unsafe extern "C" fn(user_data: *mut c_void, text: YogStr);
353
354 pub unsafe fn call(request: YogStr, gfx: *const YogGfxApi, user_data: *mut c_void, write: WriteFn) -> bool {
360 let request = unsafe { request.as_str() };
362 let (ok, answer) = match catch_unwind(AssertUnwindSafe(|| answer(request, gfx))) {
363 Ok(Ok(a)) => (true, a),
364 Ok(Err(e)) => (false, e),
365 Err(panic) => (false, panic_message(&*panic)),
366 };
367 unsafe { write(user_data, YogStr::from_str(&answer)) };
369 ok
370 }
371
372 fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
373 let msg = panic
374 .downcast_ref::<&str>()
375 .map(|s| s.to_string())
376 .or_else(|| panic.downcast_ref::<String>().cloned())
377 .unwrap_or_else(|| "unknown panic".into());
378 format!("the component panicked: {msg}")
379 }
380
381 fn answer(request: &str, gfx: *const YogGfxApi) -> Result<String, String> {
382 let mut lines = request.lines();
383 match lines.next().map(str::trim) {
384 Some("describe") => Ok(describe()),
385 Some("render") => {
386 if gfx.is_null() {
387 return Err("render: no gfx table".into());
388 }
389 let name = lines.next().unwrap_or_default().trim().to_string();
390 let size: Vec<f32> = lines.next().unwrap_or_default().split_whitespace().filter_map(|n| n.parse().ok()).collect();
391 let [w, h, aw, ah] = size[..] else { return Err("render: expected `w h avail_w avail_h`".into()) };
392 let attrs: Vec<(String, String)> =
393 lines.filter_map(|l| l.split_once('=')).map(|(k, v)| (k.trim().to_string(), unescape(v))).collect();
394 let ctx = unsafe { GfxContext::from_raw(gfx) };
396 render(&ctx, &name, &attrs, [w, h, aw, ah])
397 }
398 other => Err(format!("unknown designer request {other:?}")),
399 }
400 }
401
402 fn render(ctx: &GfxContext, name: &str, attrs: &[(String, String)], [w, h, aw, ah]: [f32; 4]) -> Result<String, String> {
403 let spec = find(name).ok_or_else(|| format!("no component named {name}"))?;
404 type Attrs<'a> = Vec<(&'a str, &'a str)>;
405 let (common, own): (Attrs, Attrs) =
406 attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).partition(|(k, _)| COMMON_ATTRS.contains(k));
407 let mut root: Widget = (spec.build)(&Props::new(spec.props, &own));
408 for (k, v) in common {
409 root = apply_style(root, k, v);
410 }
411 if w > 0.0 {
412 root = root.w(Units::Px(w));
413 }
414 if h > 0.0 {
415 root = root.h(Units::Px(h));
416 }
417 let (aw, ah) = (aw.max(1.0), ah.max(1.0));
418 let screen = widget::panel(FlexDir::Column).w(Units::Px(aw)).h(Units::Px(ah)).child(root.at(0.0, 0.0));
419 crate::text::use_host_metrics(ctx);
421 crate::image::use_host_images(ctx);
422 let mut ui = UiRoot::new("yog:designer", screen);
423 ui.layout(aw, ah);
424 let rect = ui.layout_root.children.first().map(|n| n.rect).unwrap_or_default();
425 ui.render(ctx);
426 Ok(format!("{{\"w\":{},\"h\":{}}}", rect.w, rect.h))
427 }
428
429 fn describe() -> String {
430 let mut out = format!("{{\"abi\":{ABI},\"components\":[");
431 for (i, c) in components().iter().enumerate() {
432 if i > 0 {
433 out.push(',');
434 }
435 let _ = write!(out, "{{\"name\":{},\"doc\":{},\"props\":[", json(c.name), json(c.doc));
436 for (j, p) in c.props.iter().enumerate() {
437 if j > 0 {
438 out.push(',');
439 }
440 let options = match p.kind {
441 PropKind::Choice(o) => o.iter().map(|s| json(s)).collect::<Vec<_>>().join(","),
442 _ => String::new(),
443 };
444 let _ = write!(
445 out,
446 "{{\"name\":{},\"kind\":{},\"default\":{},\"doc\":{},\"options\":[{options}]}}",
447 json(p.name),
448 json(p.kind.as_str()),
449 json(p.default),
450 json(p.doc)
451 );
452 }
453 out.push_str("]}");
454 }
455 out.push_str("]}");
456 out
457 }
458
459 fn json(s: &str) -> String {
461 let mut out = String::with_capacity(s.len() + 2);
462 out.push('"');
463 for c in s.chars() {
464 match c {
465 '"' => out.push_str("\\\""),
466 '\\' => out.push_str("\\\\"),
467 '\n' => out.push_str("\\n"),
468 '\r' => out.push_str("\\r"),
469 '\t' => out.push_str("\\t"),
470 c if (c as u32) < 0x20 => {
471 let _ = write!(out, "\\u{:04x}", c as u32);
472 }
473 c => out.push(c),
474 }
475 }
476 out.push('"');
477 out
478 }
479
480 pub fn unescape(v: &str) -> String {
482 let mut out = String::with_capacity(v.len());
483 let mut chars = v.chars();
484 while let Some(c) = chars.next() {
485 if c != '\\' {
486 out.push(c);
487 continue;
488 }
489 match chars.next() {
490 Some('n') => out.push('\n'),
491 Some(other) => out.push(other),
492 None => out.push('\\'),
493 }
494 }
495 out
496 }
497
498 pub fn escape(v: &str) -> String {
500 v.replace('\\', "\\\\").replace('\n', "\\n")
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 struct Badge;
509 impl Component for Badge {
510 const NAME: &'static str = "TestBadge";
511 const PROPS: &'static [Prop] = &[
512 Prop::text("label", "new"),
513 Prop::color("fill", "#FF3060C0").doc("Background"),
514 Prop::choice("size", &["small", "large"], "small"),
515 ];
516 fn build(p: &Props) -> Widget {
517 widget::label(p.text("label")).bg(p.color("fill")).padding(1.0, 2.0, 1.0, 2.0)
518 }
519 }
520
521 #[test]
522 fn props_fall_back_to_defaults() {
523 let p = Props::new(Badge::PROPS, &[("label", "@@x"), ("n", " 2.5 ")]);
524 assert_eq!(p.text("label"), "@x");
525 assert_eq!(p.color("fill"), 0xFF3060C0);
526 assert_eq!(p.word("size"), "small");
527 assert_eq!(p.number("n"), 2.5);
528 assert_eq!(p.int("missing"), 0);
529 assert!(!p.bool("missing"));
530 }
531
532 #[test]
533 fn builds_registered_components() {
534 register::<Badge>();
535 let w = build("TestBadge", &[("label", "hi")]);
536 assert!(matches!(&w.kind, crate::widget::WidgetKind::Label(t) if t == "hi"));
537 assert_eq!(w.style.bg, 0xFF3060C0);
538 let missing = build("Nope", &[]);
539 assert!(!missing.children.is_empty());
540 }
541
542 #[test]
543 fn describes_and_escapes() {
544 register::<Badge>();
545 let mut out = String::new();
546 unsafe extern "C" fn sink(ud: *mut std::ffi::c_void, s: yog_abi::YogStr) {
547 unsafe { (*(ud as *mut String)).push_str(s.as_str()) };
549 }
550 let req = "describe";
551 let ok = unsafe { designer::call(yog_abi::YogStr::from_str(req), std::ptr::null(), &mut out as *mut String as *mut _, sink) };
552 assert!(ok, "{out}");
553 assert!(out.contains("\"name\":\"TestBadge\""), "{out}");
554 assert!(out.contains("\"options\":[\"small\",\"large\"]"), "{out}");
555 assert_eq!(designer::unescape(&designer::escape("a\\b\nc")), "a\\b\nc");
556 }
557}