use std::collections::HashMap;
use std::sync::{Mutex, PoisonError};
use crate::widget::{self, Widget};
use crate::FlexDir;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PropKind {
Text,
Number,
Int,
Bool,
Color,
Choice(&'static [&'static str]),
Event,
Image,
}
impl PropKind {
pub fn as_str(self) -> &'static str {
match self {
PropKind::Text => "text",
PropKind::Number => "number",
PropKind::Int => "int",
PropKind::Bool => "bool",
PropKind::Color => "color",
PropKind::Choice(_) => "choice",
PropKind::Event => "event",
PropKind::Image => "image",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Prop {
pub name: &'static str,
pub kind: PropKind,
pub default: &'static str,
pub doc: &'static str,
pub category: &'static str,
}
impl Prop {
pub const fn new(name: &'static str, kind: PropKind, default: &'static str) -> Self {
Self { name, kind, default, doc: "", category: "" }
}
pub const fn text(name: &'static str, default: &'static str) -> Self {
Self::new(name, PropKind::Text, default)
}
pub const fn number(name: &'static str, default: &'static str) -> Self {
Self::new(name, PropKind::Number, default)
}
pub const fn int(name: &'static str, default: &'static str) -> Self {
Self::new(name, PropKind::Int, default)
}
pub const fn bool(name: &'static str, default: bool) -> Self {
Self::new(name, PropKind::Bool, if default { "true" } else { "false" })
}
pub const fn color(name: &'static str, default: &'static str) -> Self {
Self::new(name, PropKind::Color, default)
}
pub const fn choice(name: &'static str, options: &'static [&'static str], default: &'static str) -> Self {
Self::new(name, PropKind::Choice(options), default)
}
pub const fn event(name: &'static str) -> Self {
Self::new(name, PropKind::Event, "")
}
pub const fn image(name: &'static str, default: &'static str) -> Self {
Self::new(name, PropKind::Image, default)
}
pub const fn doc(self, doc: &'static str) -> Self {
Self { doc, ..self }
}
pub const fn category(self, category: &'static str) -> Self {
Self { category, ..self }
}
}
pub trait Component {
const NAME: &'static str;
const DOC: &'static str = "";
const DISPLAY_NAME: &'static str = "";
const ICON: &'static str = "";
const CATEGORY: &'static str = "";
const DESIGN_SIZE: (f32, f32) = (0.0, 0.0);
const TOOLBOX: bool = true;
const PROPS: &'static [Prop] = &[];
fn build(props: &Props) -> Widget;
}
#[derive(Debug, Clone, Copy)]
pub struct ComponentSpec {
pub name: &'static str,
pub doc: &'static str,
pub display_name: &'static str,
pub icon: &'static str,
pub category: &'static str,
pub design_size: (f32, f32),
pub toolbox: bool,
pub props: &'static [Prop],
pub build: fn(&Props) -> Widget,
}
impl ComponentSpec {
pub fn of<C: Component>() -> Self {
Self {
name: C::NAME,
doc: C::DOC,
display_name: C::DISPLAY_NAME,
icon: C::ICON,
category: C::CATEGORY,
design_size: C::DESIGN_SIZE,
toolbox: C::TOOLBOX,
props: C::PROPS,
build: C::build,
}
}
}
static REGISTRY: Mutex<Vec<ComponentSpec>> = Mutex::new(Vec::new());
pub fn register<C: Component>() {
register_spec(ComponentSpec::of::<C>());
}
pub fn register_spec(spec: ComponentSpec) {
let mut all = REGISTRY.lock().unwrap_or_else(PoisonError::into_inner);
all.retain(|s| s.name != spec.name);
all.push(spec);
}
pub fn components() -> Vec<ComponentSpec> {
REGISTRY.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
pub fn find(name: &str) -> Option<ComponentSpec> {
REGISTRY.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|s| s.name == name).copied()
}
pub fn build(name: &str, attrs: &[(&str, &str)]) -> Widget {
match find(name) {
Some(spec) => (spec.build)(&Props::new(spec.props, attrs)),
None => error_box(&format!("unknown component <{name}>")),
}
}
pub fn error_box(message: &str) -> Widget {
widget::panel(FlexDir::Column)
.bg(0xC0_400000)
.padding(2.0, 2.0, 2.0, 2.0)
.child(widget::label(message).color(0xFF_FF8080))
.min_w(16.0)
.min_h(12.0)
}
#[derive(Debug, Clone, Default)]
pub struct Props {
values: HashMap<String, String>,
}
impl Props {
pub fn new(props: &[Prop], attrs: &[(&str, &str)]) -> Self {
let mut values: HashMap<String, String> = props.iter().map(|p| (p.name.to_string(), p.default.to_string())).collect();
for (k, v) in attrs {
values.insert((*k).to_string(), (*v).to_string());
}
Self { values }
}
pub fn get(&self, name: &str) -> Option<&str> {
self.values.get(name).map(String::as_str)
}
pub fn text(&self, name: &str) -> String {
let v = self.get(name).unwrap_or_default();
match v.strip_prefix("@@") {
Some(rest) => format!("@{rest}"),
None => v.to_string(),
}
}
pub fn number(&self, name: &str) -> f32 {
self.get(name).and_then(|v| v.trim().parse().ok()).unwrap_or(0.0)
}
pub fn int(&self, name: &str) -> i64 {
self.get(name).and_then(|v| v.trim().parse().ok()).unwrap_or(0)
}
pub fn bool(&self, name: &str) -> bool {
self.get(name).is_some_and(|v| v.trim() == "true")
}
pub fn color(&self, name: &str) -> u32 {
self.get(name).and_then(parse_color).unwrap_or(0xFF_FFFFFF)
}
pub fn word(&self, name: &str) -> &str {
self.get(name).map(str::trim).unwrap_or_default()
}
}
pub mod bind {
use std::fmt::Display;
use crate::Units;
pub fn text<T: Display + ?Sized>(v: &T) -> String {
v.to_string()
}
pub fn color<T: BindColor>(v: T) -> u32 {
v.to_argb()
}
pub fn flag(v: impl Into<bool>) -> bool {
v.into()
}
pub fn count<T: BindNumber>(v: T) -> u32 {
v.to_f32().max(0.0) as u32
}
pub fn number<T: BindNumber>(v: T) -> f32 {
v.to_f32()
}
pub fn units<T: BindUnits>(v: T) -> Units {
v.to_units()
}
pub trait BindColor {
fn to_argb(self) -> u32;
}
impl BindColor for u32 {
fn to_argb(self) -> u32 {
self
}
}
impl BindColor for &u32 {
fn to_argb(self) -> u32 {
*self
}
}
impl BindColor for &str {
fn to_argb(self) -> u32 {
super::parse_color(self).unwrap_or(0xFF_FFFFFF)
}
}
impl BindColor for &&str {
fn to_argb(self) -> u32 {
(*self).to_argb()
}
}
impl BindColor for &String {
fn to_argb(self) -> u32 {
self.as_str().to_argb()
}
}
impl BindColor for String {
fn to_argb(self) -> u32 {
self.as_str().to_argb()
}
}
pub trait BindNumber {
fn to_f32(self) -> f32;
}
macro_rules! numbers {
($($t:ty),*) => {$(
impl BindNumber for $t {
fn to_f32(self) -> f32 {
self as f32
}
}
impl BindNumber for &$t {
fn to_f32(self) -> f32 {
*self as f32
}
}
)*};
}
numbers!(f32, f64, i32, i64, u32, u64, usize);
pub trait BindUnits {
fn to_units(self) -> Units;
}
impl<T: BindNumber> BindUnits for T {
fn to_units(self) -> Units {
Units::Px(self.to_f32())
}
}
impl BindUnits for Units {
fn to_units(self) -> Units {
self
}
}
impl BindUnits for &str {
fn to_units(self) -> Units {
let v = self.trim();
if v.is_empty() || v == "auto" {
Units::Auto
} else if let Some(p) = v.strip_suffix('%') {
Units::Percent(p.trim().parse().unwrap_or(0.0))
} else {
Units::Px(v.parse().unwrap_or(0.0))
}
}
}
impl BindUnits for &Units {
fn to_units(self) -> Units {
*self
}
}
impl BindUnits for &&str {
fn to_units(self) -> Units {
(*self).to_units()
}
}
impl BindUnits for &String {
fn to_units(self) -> Units {
self.as_str().to_units()
}
}
}
pub const COMMON_ATTRS: &[&str] = &[
"id", "x", "y", "w", "h", "min_w", "min_h", "flex", "dock", "gap", "padding", "bg", "color", "align", "font_scale",
"font", "shadow", "no_wrap", "ellipsis", "focus_style", "focus_color", "enabled", "on_click", "visible",
];
pub fn apply_style(w: Widget, key: &str, value: &str) -> Widget {
let v = value.trim();
let num = || v.parse::<f32>().ok();
match key {
"bg" => parse_color(v).map_or(w.clone(), |c| w.clone().bg(c)),
"color" => parse_color(v).map_or(w.clone(), |c| w.clone().color(c)),
"focus_color" => parse_color(v).map_or(w.clone(), |c| w.clone().focus_color(c)),
"min_w" => num().map_or(w.clone(), |n| w.clone().min_w(n)),
"min_h" => num().map_or(w.clone(), |n| w.clone().min_h(n)),
"gap" => num().map_or(w.clone(), |n| w.clone().gap(n)),
"font_scale" => num().map_or(w.clone(), |n| w.clone().font_scale(n)),
"font" => w.font(v),
"shadow" => w.shadow(v == "true"),
"enabled" => w.enabled(v != "false"),
"no_wrap" if v == "true" => w.no_wrap(),
"ellipsis" if v == "true" => w.ellipsis(),
"align" => w.align(match v {
"center" => crate::Align::Center,
"end" => crate::Align::End,
_ => crate::Align::Start,
}),
"padding" => {
let parts: Option<Vec<f32>> = v.split([' ', ',']).filter(|s| !s.is_empty()).map(|s| s.parse().ok()).collect();
match parts.as_deref() {
Some([a]) => w.padding(*a, *a, *a, *a),
Some([vv, hh]) => w.padding(*vv, *hh, *vv, *hh),
Some([t, r, b, l]) => w.padding(*t, *r, *b, *l),
_ => w,
}
}
_ => w,
}
}
pub fn parse_color(value: &str) -> Option<u32> {
let v = value.trim();
let hex = v.strip_prefix('#').or_else(|| v.strip_prefix("0x")).or_else(|| v.strip_prefix("0X"))?.replace('_', "");
let n = u32::from_str_radix(&hex, 16).ok()?;
match hex.len() {
6 => Some(0xFF00_0000 | n),
8 => Some(n),
_ => None,
}
}
pub mod designer {
use std::ffi::c_void;
use std::fmt::Write as _;
use std::panic::{catch_unwind, AssertUnwindSafe};
use yog_abi::{YogGfxApi, YogStr};
use yog_gfx::GfxContext;
use super::{apply_style, components, find, PropKind, Props, COMMON_ATTRS};
use crate::widget::{self, Widget};
use crate::{FlexDir, UiRoot, Units};
pub const ABI: u32 = 1;
pub type WriteFn = unsafe extern "C" fn(user_data: *mut c_void, text: YogStr);
pub unsafe fn call(request: YogStr, gfx: *const YogGfxApi, user_data: *mut c_void, write: WriteFn) -> bool {
let request = unsafe { request.as_str() };
let (ok, answer) = match catch_unwind(AssertUnwindSafe(|| answer(request, gfx))) {
Ok(Ok(a)) => (true, a),
Ok(Err(e)) => (false, e),
Err(panic) => (false, panic_message(&*panic)),
};
unsafe { write(user_data, YogStr::from_str(&answer)) };
ok
}
fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
let msg = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".into());
format!("the component panicked: {msg}")
}
fn answer(request: &str, gfx: *const YogGfxApi) -> Result<String, String> {
let mut lines = request.lines();
match lines.next().map(str::trim) {
Some("describe") => Ok(describe()),
Some("render") => {
if gfx.is_null() {
return Err("render: no gfx table".into());
}
let name = lines.next().unwrap_or_default().trim().to_string();
let size: Vec<f32> = lines.next().unwrap_or_default().split_whitespace().filter_map(|n| n.parse().ok()).collect();
let [w, h, aw, ah] = size[..] else { return Err("render: expected `w h avail_w avail_h`".into()) };
let attrs: Vec<(String, String)> =
lines.filter_map(|l| l.split_once('=')).map(|(k, v)| (k.trim().to_string(), unescape(v))).collect();
let ctx = unsafe { GfxContext::from_raw(gfx) };
render(&ctx, &name, &attrs, [w, h, aw, ah])
}
other => Err(format!("unknown designer request {other:?}")),
}
}
fn render(ctx: &GfxContext, name: &str, attrs: &[(String, String)], [w, h, aw, ah]: [f32; 4]) -> Result<String, String> {
let spec = find(name).ok_or_else(|| format!("no component named {name}"))?;
type Attrs<'a> = Vec<(&'a str, &'a str)>;
let (common, own): (Attrs, Attrs) =
attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).partition(|(k, _)| COMMON_ATTRS.contains(k));
let mut root: Widget = (spec.build)(&Props::new(spec.props, &own));
for (k, v) in common {
root = apply_style(root, k, v);
}
if w > 0.0 {
root = root.w(Units::Px(w));
}
if h > 0.0 {
root = root.h(Units::Px(h));
}
let (aw, ah) = (aw.max(1.0), ah.max(1.0));
let screen = widget::panel(FlexDir::Column).w(Units::Px(aw)).h(Units::Px(ah)).child(root.at(0.0, 0.0));
crate::text::use_host_metrics(ctx);
crate::image::use_host_images(ctx);
let mut ui = UiRoot::new("yog:designer", screen);
ui.layout(aw, ah);
let rect = ui.layout_root.children.first().map(|n| n.rect).unwrap_or_default();
ui.render(ctx);
Ok(format!("{{\"w\":{},\"h\":{}}}", rect.w, rect.h))
}
fn describe() -> String {
let mut out = format!("{{\"abi\":{ABI},\"components\":[");
for (i, c) in components().iter().enumerate() {
if i > 0 {
out.push(',');
}
let _ = write!(
out,
"{{\"name\":{},\"description\":{},\"display_name\":{},\"icon\":{},\"category\":{},\"design_w\":{},\"design_h\":{},\"toolbox\":{},\"props\":[",
json(c.name),
json(c.doc),
json(c.display_name),
json(c.icon),
json(c.category),
c.design_size.0,
c.design_size.1,
c.toolbox
);
for (j, p) in c.props.iter().enumerate() {
if j > 0 {
out.push(',');
}
let options = match p.kind {
PropKind::Choice(o) => o.iter().map(|s| json(s)).collect::<Vec<_>>().join(","),
_ => String::new(),
};
let _ = write!(
out,
"{{\"name\":{},\"kind\":{},\"default\":{},\"doc\":{},\"category\":{},\"options\":[{options}]}}",
json(p.name),
json(p.kind.as_str()),
json(p.default),
json(p.doc),
json(p.category)
);
}
out.push_str("]}");
}
out.push_str("]}");
out
}
fn json(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
pub fn unescape(v: &str) -> String {
let mut out = String::with_capacity(v.len());
let mut chars = v.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some(other) => out.push(other),
None => out.push('\\'),
}
}
out
}
pub fn escape(v: &str) -> String {
v.replace('\\', "\\\\").replace('\n', "\\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Badge;
impl Component for Badge {
const NAME: &'static str = "TestBadge";
const ICON: &'static str = "item:apple";
const DESIGN_SIZE: (f32, f32) = (40.0, 12.0);
const PROPS: &'static [Prop] = &[
Prop::text("label", "new"),
Prop::color("fill", "#FF3060C0").doc("Background"),
Prop::choice("size", &["small", "large"], "small"),
];
fn build(p: &Props) -> Widget {
widget::label(p.text("label")).bg(p.color("fill")).padding(1.0, 2.0, 1.0, 2.0)
}
}
#[test]
fn props_fall_back_to_defaults() {
let p = Props::new(Badge::PROPS, &[("label", "@@x"), ("n", " 2.5 ")]);
assert_eq!(p.text("label"), "@x");
assert_eq!(p.color("fill"), 0xFF3060C0);
assert_eq!(p.word("size"), "small");
assert_eq!(p.number("n"), 2.5);
assert_eq!(p.int("missing"), 0);
assert!(!p.bool("missing"));
}
#[test]
fn builds_registered_components() {
register::<Badge>();
let w = build("TestBadge", &[("label", "hi")]);
assert!(matches!(&w.kind, crate::widget::WidgetKind::Label(t) if t == "hi"));
assert_eq!(w.style.bg, 0xFF3060C0);
let missing = build("Nope", &[]);
assert!(!missing.children.is_empty());
}
#[test]
fn describes_and_escapes() {
register::<Badge>();
let mut out = String::new();
unsafe extern "C" fn sink(ud: *mut std::ffi::c_void, s: yog_abi::YogStr) {
unsafe { (*(ud as *mut String)).push_str(s.as_str()) };
}
let req = "describe";
let ok = unsafe { designer::call(yog_abi::YogStr::from_str(req), std::ptr::null(), &mut out as *mut String as *mut _, sink) };
assert!(ok, "{out}");
assert!(out.contains("\"name\":\"TestBadge\""), "{out}");
assert!(out.contains("\"options\":[\"small\",\"large\"]"), "{out}");
assert!(out.contains("\"icon\":\"item:apple\",\"category\":\"\",\"design_w\":40,\"design_h\":12,\"toolbox\":true"), "{out}");
assert_eq!(designer::unescape(&designer::escape("a\\b\nc")), "a\\b\nc");
}
}