1use kui_core::schema::{self, Kind, Parsed, PropsOut};
287use kui_core::{
288 Align, Color, Content, EditOptions, Extension, FloatConfig, Key, PadShorthand, Sizing, Slot,
289 Span, Ui, UiEvent, Value, WindowConfig, widgets,
290};
291use mlua::{Lua, Table};
292
293mod meta;
294mod rows;
295pub use meta::luals_meta;
296
297const PRELUDE: &str = include_str!("prelude.lua");
298
299pub struct LuaExtension {
337 lua: Lua,
338 name: String,
339 slots: Vec<String>,
344 loaded: std::cell::RefCell<Vec<Loaded>>,
349 slot_names: std::collections::HashMap<kui_core::Key, String>,
353 tokens: Option<kui_core::Tokens>,
358}
359
360struct Loaded {
363 namespace: String,
364 path: std::path::PathBuf,
365 origin: kui_core::OriginId,
366}
367
368impl LuaExtension {
369 pub fn from_source(name: impl Into<String>, source: &str) -> mlua::Result<Self> {
393 let lua = Lua::new();
394 rows::register(&lua)?;
395 lua.load(PRELUDE).set_name("kui:prelude").exec()?;
396 let name = name.into();
397 lua.load(source).set_name(&name).exec()?;
398 let slots = match lua.globals().get::<mlua::Value>("slots")? {
399 mlua::Value::Nil => Vec::new(),
400 mlua::Value::Table(t) => t.sequence_values::<String>().collect::<mlua::Result<_>>()?,
401 other => {
402 return Err(mlua::Error::runtime(format!(
403 "`slots` must be a list of slot names, not {}",
404 other.type_name()
405 )));
406 }
407 };
408 let tokens = match lua.globals().get::<mlua::Value>("tokens")? {
409 mlua::Value::Nil => None,
410 mlua::Value::Table(t) => Some(parse_tokens(&t)?),
411 other => {
412 return Err(mlua::Error::runtime(format!(
413 "`tokens` must be a table {{ colors = …, lengths = … }}, not {}",
414 other.type_name()
415 )));
416 }
417 };
418 Ok(Self {
419 lua,
420 name,
421 slots,
422 loaded: Default::default(),
423 slot_names: Default::default(),
424 tokens,
425 })
426 }
427
428 fn loaded_as(&self, origin: kui_core::OriginId) -> Option<String> {
431 self.loaded
432 .borrow()
433 .iter()
434 .find(|l| l.origin == origin)
435 .map(|l| l.namespace.clone())
436 }
437
438 pub fn lua(&self) -> &Lua {
442 &self.lua
443 }
444
445 pub fn from_file(path: impl AsRef<std::path::Path>) -> mlua::Result<Self> {
447 let path = path.as_ref();
448 let source = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
449 let name = path
450 .file_name()
451 .map_or_else(|| "lua".into(), |n| n.to_string_lossy().into_owned());
452 Self::from_source(name, &source)
453 }
454}
455
456impl Extension for LuaExtension {
457 fn name(&self) -> &str {
458 &self.name
459 }
460
461 fn slots(&self) -> &[String] {
462 &self.slots
463 }
464
465 fn view(&mut self, slot: &Slot<'_>, ui: &mut Ui<'_>) -> Result<(), String> {
466 let view: mlua::Function = self
467 .lua
468 .globals()
469 .get("view")
470 .map_err(|_| "script defines no view()".to_string())?;
471 let slot_table = slot_table(&self.lua, slot).map_err(|e| format!("slot: {e}"))?;
477 self.slot_names
478 .entry(slot.key)
479 .or_insert_with(|| slot.full_name());
480 let origin = ui.core().origin();
484 if let Some(t) = &self.tokens
485 && !ui.core().tokens_declared(origin)
486 {
487 ui.set_tokens(t.clone());
488 }
489 let root: Table = {
494 let frame = std::cell::RefCell::new(&mut *ui);
495 self.lua
496 .scope(|scope| {
497 let env = env_table(&self.lua, scope, &frame, &self.loaded)?;
498 view.call((env, slot_table))
499 })
500 .map_err(|e| format!("view(): {e}"))?
501 };
502 if let Ok(Some(title)) = root.get::<Option<String>>("window_title") {
504 ui.window_title(&title);
505 }
506 if let Ok(Some(true)) = root.get::<Option<bool>>("always_on_top") {
507 ui.always_on_top(true);
508 }
509 if let Ok(Some(true)) = root.get::<Option<bool>>("secure_input") {
512 ui.secure_input(true);
513 }
514 if let Some(name) = root
521 .get::<Option<String>>("option_as_alt")
522 .map_err(|e| format!("option_as_alt: {e}"))?
523 {
524 let v = kui_core::OptionAsAlt::from_name(&name).ok_or_else(|| {
525 format!(
526 "option_as_alt: expected \"none\", \"left\", \"right\" or \"both\", got {name:?}"
527 )
528 })?;
529 if v != kui_core::OptionAsAlt::None {
530 ui.option_as_alt(v);
531 }
532 }
533 if let Ok(Some(true)) = root.get::<Option<bool>>("ime_off") {
536 ui.ime_off(true);
537 }
538 declare_windows(ui, &root).map_err(|e| format!("windows: {e}"))?;
539 build_node(ui, &root).map_err(|e| format!("view table: {e}"))
540 }
541
542 fn on_event(&mut self, ev: &UiEvent) -> Vec<Value> {
543 let Ok(f) = self.lua.globals().get::<mlua::Function>("on_event") else {
544 return Vec::new();
545 };
546 let payload = value_to_lua(&self.lua, &ev.payload).and_then(|p| {
547 if let mlua::Value::Table(t) = &p
551 && !t.contains_key("node_key")?
552 {
553 t.set("node_key", ev.key.0 as i64)?;
554 t.set("window", ev.window.0)?;
559 if let Some(name) = ev.slot.and_then(|k| self.slot_names.get(&k)) {
565 t.set("slot", name.as_str())?;
566 }
567 if let Some(ns) = self.loaded_as(ev.origin) {
575 t.set("from", ns)?;
576 }
577 }
578 Ok(p)
579 });
580 match payload.and_then(|p| f.call::<mlua::Value>(p)) {
585 Ok(mlua::Value::Nil) => Vec::new(),
586 Ok(v) => match lua_to_value(&v) {
587 Ok(Value::List(replies)) => replies,
588 Ok(reply) => vec![reply],
589 Err(e) => {
590 eprintln!("kui-lua: '{}' on_event reply: {e}", self.name);
591 Vec::new()
592 }
593 },
594 Err(e) => {
595 eprintln!("kui-lua: '{}' on_event error: {e}", self.name);
596 Vec::new()
597 }
598 }
599 }
600}
601
602fn slot_table(lua: &Lua, slot: &Slot<'_>) -> mlua::Result<Table> {
609 let t = lua.create_table()?;
610 t.set("name", slot.name)?;
611 t.set("namespace", slot.namespace)?;
612 t.set("key", slot.key.0 as i64)?;
613 if !matches!(slot.params, Value::Null) {
614 t.set("params", value_to_lua(lua, slot.params)?)?;
615 }
616 Ok(t)
617}
618
619fn key_arg(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Key> {
625 match v {
626 mlua::Value::Integer(i) => Ok(Key(i as u64)),
627 mlua::Value::String(s) => {
628 let label = s.to_str()?;
629 ui.key_of(&label).ok_or_else(|| {
630 mlua::Error::runtime(format!(
631 "no node is keyed {:?}: pass the label a `key` field declared in this or the last \
632 frame, or the integer key an event carried",
633 &*label
634 ))
635 })
636 }
637 other => Err(mlua::Error::runtime(format!(
638 "a node key is an integer or a declared label, not {}",
639 other.type_name()
640 ))),
641 }
642}
643
644fn key_query(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Option<Key>> {
655 match v {
656 mlua::Value::Integer(i) => Ok(Some(Key(i as u64))),
657 mlua::Value::String(s) => Ok(ui.key_of(&s.to_str()?)),
658 other => Err(mlua::Error::runtime(format!(
659 "a node key is an integer or a declared label, not {}",
660 other.type_name()
661 ))),
662 }
663}
664
665fn lua_list_to_value(t: &Table) -> mlua::Result<Value> {
668 let mut items = Vec::with_capacity(t.raw_len());
669 for item in t.sequence_values::<mlua::Value>() {
670 items.push(lua_to_value(&item?)?);
671 }
672 Ok(Value::List(items))
673}
674
675fn alias_menu_role(row: &mut Value) {
680 let Value::Map(fields) = row else { return };
681 for (k, v) in fields.iter_mut() {
682 if k == "role"
683 && let Value::Str(name) = v
684 {
685 match name.as_str() {
686 "select_all" => *name = kui_core::MenuRole::SelectAll.name().to_string(),
687 "look_up" => *name = kui_core::MenuRole::LookUp.name().to_string(),
688 _ => {}
689 }
690 }
691 if k == "items"
693 && let Value::List(rows) = v
694 {
695 rows.iter_mut().for_each(alias_menu_role);
696 }
697 }
698}
699
700fn menu_items(t: &mlua::Table) -> mlua::Result<(Vec<kui_core::MenuItem>, Vec<String>)> {
703 let mut rows = lua_list_to_value(t)?;
704 if let Value::List(rows) = &mut rows {
705 rows.iter_mut().for_each(alias_menu_role);
706 }
707 let items = kui_core::MenuItem::list_from_value(&rows).map_err(mlua::Error::runtime)?;
708 Ok((items, kui_core::MenuItem::stray_keys(&rows)))
709}
710
711fn warn_stray_menu_keys(core: &mut kui_core::Core, keys: Vec<String>) {
713 if core.diagnostics() {
714 for k in keys {
715 core.warn(kui_core::diag::unknown_menu_item_key(&k));
716 }
717 }
718}
719
720fn env_table<'scope, 'env: 'scope>(
769 lua: &Lua,
770 scope: &'scope mlua::Scope<'scope, 'env>,
771 ui: &'env std::cell::RefCell<&'env mut Ui<'_>>,
772 loaded: &'env std::cell::RefCell<Vec<Loaded>>,
773) -> mlua::Result<Table> {
774 let (facts, theme, metrics) = {
775 let ui = ui.borrow();
776 (ui.env_facts(), ui.theme(), ui.metrics())
777 };
778 let t = lua.create_table()?;
779 t.set("tokens", tokens_table(lua, &ui.borrow())?)?;
785 for row in kui_core::schema::ENV_FIELDS {
798 let value = (row.get)(&facts);
799 if value == Value::Null {
800 continue;
801 }
802 if row.name == "window.native_controls" {
803 let win = t
804 .get::<Option<Table>>("window")?
805 .unwrap_or(lua.create_table()?);
806 let f = |k: &str| value.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
807 win.set("controls_w", f("x") + f("w"))?;
808 win.set("controls_h", f("y") + f("h"))?;
809 t.set("window", win)?;
810 continue;
811 }
812 for path in row.lua {
813 let lua_value = value_to_lua(lua, &value)?;
814 match path.split_once('.') {
815 None => t.set(*path, lua_value)?,
816 Some((head, leaf)) => {
817 let sub = t.get::<Option<Table>>(head)?.unwrap_or(lua.create_table()?);
818 sub.set(leaf, lua_value)?;
819 t.set(head, sub)?;
820 }
821 }
822 }
823 }
824 let th = lua.create_table()?;
833 for role in kui_core::schema::THEME_ROLES {
834 th.set(role.name, (role.get)(&theme).to_hex())?;
835 }
836 th.set("appearance", theme.appearance.name())?;
837 th.set("disabled_opacity", theme.disabled_opacity)?;
838 t.set("theme", th)?;
839 let mt = lua.create_table()?;
844 for role in kui_core::schema::METRIC_ROLES {
845 mt.set(role.name, (role.get)(&metrics))?;
846 }
847 t.set("metrics", mt)?;
848 t.set(
854 "edit_text",
855 scope.create_function(move |_, key: mlua::Value| {
856 let mut ui = ui.borrow_mut();
857 let Some(key) = key_query(&mut ui, key)? else {
858 return Ok(None);
859 };
860 Ok(ui.edit_text(key))
861 })?,
862 )?;
863 t.set(
872 "set_edit_text",
873 scope.create_function(move |_, (key, text): (mlua::Value, String)| {
874 let mut ui = ui.borrow_mut();
875 match key {
876 mlua::Value::String(label) => {
877 ui.set_edit_text_by_label(&label.to_str()?, &text);
878 }
879 other => {
880 let key = key_arg(&mut ui, other)?;
881 ui.set_edit_text(key, &text);
882 }
883 }
884 Ok(())
885 })?,
886 )?;
887 t.set(
890 "is_focused",
891 scope.create_function(move |_, key: mlua::Value| {
892 let mut ui = ui.borrow_mut();
893 let Some(key) = key_query(&mut ui, key)? else {
894 return Ok(false);
895 };
896 Ok(ui.is_focused(key))
897 })?,
898 )?;
899 t.set(
900 "is_hovered",
901 scope.create_function(move |_, key: mlua::Value| {
902 let mut ui = ui.borrow_mut();
903 let Some(key) = key_query(&mut ui, key)? else {
904 return Ok(false);
905 };
906 Ok(ui.is_hovered(key))
907 })?,
908 )?;
909 t.set(
913 "is_pressed",
914 scope.create_function(move |_, key: mlua::Value| {
915 let mut ui = ui.borrow_mut();
916 let Some(key) = key_query(&mut ui, key)? else {
917 return Ok(false);
918 };
919 Ok(ui.is_pressed(key))
920 })?,
921 )?;
922 t.set(
925 "is_drop_target",
926 scope.create_function(move |_, key: mlua::Value| {
927 let mut ui = ui.borrow_mut();
928 let Some(key) = key_query(&mut ui, key)? else {
929 return Ok(false);
930 };
931 Ok(ui.is_drop_target(key))
932 })?,
933 )?;
934 t.set(
937 "drop_target",
938 scope.create_function(move |_, ()| {
939 let ui = ui.borrow();
940 Ok(ui.drop_target().map(|k| k.0 as i64))
941 })?,
942 )?;
943 t.set(
954 "request_frame_at",
955 scope.create_function(move |_, at: f64| {
956 ui.borrow_mut().request_frame_at(at);
957 Ok(())
958 })?,
959 )?;
960 t.set(
965 "slot_fill",
966 scope.create_function(move |_, name: String| {
967 Ok(ui.borrow_mut().core().slot_fill(&name).map(|f| f.name()))
968 })?,
969 )?;
970 t.set(
974 "exit_with",
975 scope.create_function(move |_, (key, exit): (mlua::Value, mlua::Value)| {
976 let mut ui = ui.borrow_mut();
977 let key = key_arg(&mut ui, key)?;
978 let e = kui_core::enter::parse(&lua_to_value(&exit)?)
979 .map_err(|m| mlua::Error::runtime(format!("exit_with: {m}")))?;
980 ui.exit_with(key, e);
981 Ok(())
982 })?,
983 )?;
984 t.set(
985 "set_focus",
986 scope.create_function(move |_, key: mlua::Value| {
987 let mut ui = ui.borrow_mut();
988 let key = key_arg(&mut ui, key)?;
989 ui.focus(key);
990 Ok(())
991 })?,
992 )?;
993 t.set(
994 "blur",
995 scope.create_function(move |_, ()| {
996 ui.borrow_mut().blur();
997 Ok(())
998 })?,
999 )?;
1000 t.set(
1007 "focus_next",
1008 scope.create_function(move |_, ()| {
1009 ui.borrow_mut().focus_next();
1010 Ok(())
1011 })?,
1012 )?;
1013 t.set(
1014 "focus_prev",
1015 scope.create_function(move |_, ()| {
1016 ui.borrow_mut().focus_prev();
1017 Ok(())
1018 })?,
1019 )?;
1020 t.set(
1026 "focus_region",
1027 scope.create_function(move |_, key: mlua::Value| {
1028 let mut ui = ui.borrow_mut();
1029 let key = match key {
1030 mlua::Value::Nil => None,
1031 v => Some(key_arg(&mut ui, v)?),
1032 };
1033 ui.focus_region(key);
1034 Ok(())
1035 })?,
1036 )?;
1037 t.set(
1048 "announce",
1049 scope.create_function(move |_, (text, live): (String, Option<String>)| {
1050 let live = live.unwrap_or_else(|| "polite".to_string());
1051 let Some(i) = schema::LIVE.iter().position(|v| *v == live) else {
1052 return Err(mlua::Error::runtime(format!(
1053 "bad politeness {live:?} (one of {})",
1054 schema::LIVE.join(" | ")
1055 )));
1056 };
1057 ui.borrow_mut()
1058 .announce(&text, kui_core::Live::from_index(i));
1059 Ok(())
1060 })?,
1061 )?;
1062 t.set(
1076 "reveal",
1077 scope.create_function(move |_, key: mlua::Value| {
1078 let mut ui = ui.borrow_mut();
1079 match key {
1080 mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
1081 ui.reveal_label(&s.to_str()?);
1082 }
1083 key => {
1084 let key = key_arg(&mut ui, key)?;
1085 ui.reveal(key);
1086 }
1087 }
1088 Ok(())
1089 })?,
1090 )?;
1091 t.set(
1095 "scroll_offset",
1096 scope.create_function(move |lua, key: mlua::Value| {
1097 let mut ui = ui.borrow_mut();
1098 let off = match key_query(&mut ui, key)? {
1099 Some(key) => ui.scroll_offset(key),
1100 None => kui_core::Vec2::ZERO,
1101 };
1102 let r = lua.create_table()?;
1103 r.set("x", off.x)?;
1104 r.set("y", off.y)?;
1105 Ok(r)
1106 })?,
1107 )?;
1108 t.set(
1123 "text_hit",
1124 scope.create_function(move |lua, (key, x, y): (mlua::Value, f32, f32)| {
1125 let mut ui = ui.borrow_mut();
1126 let Some(key) = key_query(&mut ui, key)? else {
1127 return Ok(mlua::Value::Nil);
1128 };
1129 let Some(h) = ui.text_hit(key, kui_core::Vec2::new(x, y)) else {
1130 return Ok(mlua::Value::Nil);
1131 };
1132 value_to_lua(lua, &h.to_value())
1133 })?,
1134 )?;
1135 t.set(
1143 "open_menu",
1144 scope.create_function(
1145 move |_, (key, x, y, items): (mlua::Value, f32, f32, mlua::Table)| {
1146 let mut ui = ui.borrow_mut();
1147 let Some(target) = key_query(&mut ui, key)? else {
1148 return Ok(false);
1149 };
1150 let (items, stray) = menu_items(&items)?;
1151 warn_stray_menu_keys(ui.core(), stray);
1152 ui.open_menu(kui_core::Menu::new(
1153 target,
1154 kui_core::Vec2::new(x, y),
1155 items,
1156 ));
1157 Ok(true)
1158 },
1159 )?,
1160 )?;
1161 t.set(
1163 "close_menu",
1164 scope.create_function(move |_, ()| {
1165 let mut ui = ui.borrow_mut();
1166 Ok(ui.close_menu())
1167 })?,
1168 )?;
1169 t.set(
1174 "selection_text",
1175 scope.create_function(move |_, ()| {
1176 let ui = ui.borrow();
1177 Ok(ui.selection_text())
1178 })?,
1179 )?;
1180 t.set(
1186 "request_copy",
1187 scope.create_function(move |_, ()| {
1188 let mut ui = ui.borrow_mut();
1189 Ok(match ui.request_copy() {
1190 kui_core::CopyRequest::Ready(text) => (Some(text), false),
1191 kui_core::CopyRequest::Asked => (None, true),
1192 kui_core::CopyRequest::Nothing => (None, false),
1193 })
1194 })?,
1195 )?;
1196 t.set(
1199 "answer_selection_range",
1200 scope.create_function(move |_, text: String| {
1201 let mut ui = ui.borrow_mut();
1202 Ok(ui.answer_selection_range(&text))
1203 })?,
1204 )?;
1205 t.set(
1211 "set_clipboard",
1212 scope.create_function(move |_, (text, html): (String, Option<String>)| {
1213 ui.borrow_mut().set_clipboard(text, html);
1214 Ok(())
1215 })?,
1216 )?;
1217 t.set(
1221 "set_clipboard_secret",
1222 scope.create_function(move |_, text: String| {
1223 ui.borrow_mut().set_clipboard_secret(text);
1224 Ok(())
1225 })?,
1226 )?;
1227 t.set(
1228 "request_paste",
1229 scope.create_function(move |_, ()| {
1230 ui.borrow_mut().request_paste();
1231 Ok(())
1232 })?,
1233 )?;
1234 t.set(
1235 "awaiting_paste",
1236 scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_paste()))?,
1237 )?;
1238 t.set(
1244 "request_files",
1245 scope.create_function(move |_, opts: Option<mlua::Value>| {
1246 let v = match &opts {
1247 Some(o) => lua_to_value(o)?,
1248 None => Value::Null,
1249 };
1250 let dialog = kui_core::FileDialog::from_value(&v)
1251 .map_err(|e| mlua::Error::runtime(format!("request_files: {e}")))?;
1252 Ok(ui.borrow_mut().request_files(dialog))
1253 })?,
1254 )?;
1255 t.set(
1256 "awaiting_files",
1257 scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_files()))?,
1258 )?;
1259 t.set(
1266 "selection_ends",
1267 scope.create_function(move |lua, ()| {
1268 let ui = ui.borrow();
1269 let Some((a, f)) = ui.selection_ends() else {
1270 return Ok(mlua::Value::Nil);
1271 };
1272 let end = |e: kui_core::RangeEnd| -> mlua::Result<mlua::Table> {
1273 let t = lua.create_table()?;
1274 if let Some(r) = e.row {
1275 t.set("index", r)?;
1276 }
1277 t.set("byte", e.byte)?;
1278 Ok(t)
1279 };
1280 let out = lua.create_table()?;
1281 out.set("anchor", end(a)?)?;
1282 out.set("focus", end(f)?)?;
1283 Ok(mlua::Value::Table(out))
1284 })?,
1285 )?;
1286 t.set(
1294 "cell_selection",
1295 scope.create_function(move |lua, ()| {
1296 let ui = ui.borrow();
1297 match ui.cell_selection() {
1298 Some(sel) => value_to_lua(lua, &sel.to_value(kui_core::Handles::INT)),
1299 None => Ok(mlua::Value::Nil),
1300 }
1301 })?,
1302 )?;
1303 t.set(
1307 "selection_html",
1308 scope.create_function(move |_, ()| {
1309 let ui = ui.borrow();
1310 Ok(ui.selection_html())
1311 })?,
1312 )?;
1313 t.set(
1318 "select_all_in",
1319 scope.create_function(move |_, key: mlua::Value| {
1320 let mut ui = ui.borrow_mut();
1321 let Some(key) = key_query(&mut ui, key)? else {
1322 return Ok(false);
1323 };
1324 Ok(ui.select_all_in(key))
1325 })?,
1326 )?;
1327 t.set(
1330 "clear_selection",
1331 scope.create_function(move |_, ()| {
1332 let mut ui = ui.borrow_mut();
1333 Ok(ui.clear_selection())
1334 })?,
1335 )?;
1336 t.set(
1340 "caret_rect",
1341 scope.create_function(move |lua, (key, byte): (mlua::Value, usize)| {
1342 let mut ui = ui.borrow_mut();
1343 let Some(key) = key_query(&mut ui, key)? else {
1344 return Ok(mlua::Value::Nil);
1345 };
1346 let Some(r) = ui.caret_rect(key, byte) else {
1347 return Ok(mlua::Value::Nil);
1348 };
1349 value_to_lua(lua, &r.to_value())
1350 })?,
1351 )?;
1352 t.set(
1353 "scroll_geometry",
1354 scope.create_function(move |lua, key: mlua::Value| {
1355 let mut ui = ui.borrow_mut();
1356 let Some(key) = key_query(&mut ui, key)? else {
1357 return Ok(mlua::Value::Nil);
1358 };
1359 let Some(g) = ui.scroll_geometry(key) else {
1360 return Ok(mlua::Value::Nil);
1361 };
1362 value_to_lua(lua, &g.to_value())
1364 })?,
1365 )?;
1366 t.set(
1369 "layout_of",
1370 scope.create_function(move |lua, key: mlua::Value| {
1371 let mut ui = ui.borrow_mut();
1372 let Some(key) = key_query(&mut ui, key)? else {
1373 return Ok(mlua::Value::Nil);
1374 };
1375 let Some(r) = ui.layout_of(key) else {
1376 return Ok(mlua::Value::Nil);
1377 };
1378 value_to_lua(lua, &r.to_value())
1379 })?,
1380 )?;
1381 t.set(
1384 "set_scroll",
1385 scope.create_function(move |_, (key, x, y): (mlua::Value, f32, f32)| {
1386 let mut ui = ui.borrow_mut();
1387 let at = kui_core::Vec2::new(x, y);
1388 match key {
1391 mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
1392 ui.set_scroll_label(&s.to_str()?, at);
1393 }
1394 key => {
1395 let key = key_arg(&mut ui, key)?;
1396 ui.set_scroll(key, at);
1397 }
1398 }
1399 Ok(())
1400 })?,
1401 )?;
1402 t.set(
1407 "shift_scroll",
1408 scope.create_function(move |_, (key, drawn, target): (mlua::Value, f32, f32)| {
1409 let mut ui = ui.borrow_mut();
1410 if let Some(key) = key_query(&mut ui, key)? {
1411 ui.shift_scroll(
1412 key,
1413 kui_core::Vec2::new(0.0, drawn),
1414 kui_core::Vec2::new(0.0, target),
1415 );
1416 }
1417 Ok(())
1418 })?,
1419 )?;
1420 t.set(
1428 "set_window_size",
1429 scope.create_function(move |_, (window, w, h): (i64, f32, f32)| {
1430 ui.borrow_mut()
1431 .set_window_size(kui_core::WindowId(window as u32), kui_core::Size::new(w, h));
1432 Ok(())
1433 })?,
1434 )?;
1435 let env = t.clone();
1440 t.set(
1441 "set_tokens",
1442 scope.create_function(move |lua, decl: Table| {
1443 let tokens = parse_tokens(&decl)?;
1444 ui.borrow_mut().set_tokens(tokens);
1445 env.set("tokens", tokens_table(lua, &ui.borrow())?)
1446 })?,
1447 )?;
1448 t.set(
1449 "focus_window",
1450 scope.create_function(move |_, window: i64| {
1451 ui.borrow_mut()
1452 .focus_window(kui_core::WindowId(window as u32));
1453 Ok(())
1454 })?,
1455 )?;
1456 t.set(
1457 "measure_text",
1458 scope.create_function(
1459 move |lua, (s, opts, max_w): (mlua::Value, Option<Table>, Option<f32>)| {
1460 let mut guard = ui.borrow_mut();
1461 let m = measure_from_lua(&mut guard, &s, opts.as_ref(), max_w)?;
1462 value_to_lua(lua, &m.to_value())
1463 },
1464 )?,
1465 )?;
1466 t.set(
1483 "add_extension",
1484 scope.create_function(move |_, (namespace, path): (String, String)| {
1485 let path = std::path::PathBuf::from(path);
1486 if let Some(prev) = loaded.borrow().iter().find(|l| l.namespace == namespace) {
1487 return Ok(if prev.path == path {
1488 (Some(true), None)
1490 } else {
1491 (
1492 None,
1493 Some(format!(
1494 "`{namespace}` is already {} in this script; a second plugin needs a \
1495 second namespace",
1496 prev.path.display()
1497 )),
1498 )
1499 });
1500 }
1501 let ext = match unsafe { kui_ffi::CExtension::open(&path) } {
1505 Ok(ext) => ext,
1506 Err(e) => return Ok((None, Some(e))),
1507 };
1508 let origin = match ui.borrow_mut().add_extension(&namespace, Box::new(ext)) {
1509 Ok(origin) => origin,
1510 Err(e) => return Ok((None, Some(e))),
1511 };
1512 loaded.borrow_mut().push(Loaded {
1513 namespace,
1514 path,
1515 origin,
1516 });
1517 Ok((Some(true), None))
1518 })?,
1519 )?;
1520 t.set(
1523 "extension_namespaces",
1524 scope.create_function(move |lua, ()| {
1525 lua.create_sequence_from(loaded.borrow().iter().map(|l| l.namespace.clone()))
1526 })?,
1527 )?;
1528 Ok(t)
1529}
1530
1531fn tokens_table(lua: &Lua, ui: &Ui<'_>) -> mlua::Result<Table> {
1543 let look = ui.tokens();
1544 let tokens = lua.create_table()?;
1545 let colors = lua.create_table()?;
1546 for (name, c) in look.colors() {
1547 colors.set(name, c.to_hex())?;
1548 }
1549 let lengths = lua.create_table()?;
1550 for (name, v) in look.lengths() {
1551 lengths.set(name, v)?;
1552 }
1553 tokens.set("colors", colors)?;
1554 tokens.set("lengths", lengths)?;
1555 Ok(tokens)
1556}
1557
1558fn measure_from_lua(
1559 ui: &mut Ui<'_>,
1560 s: &mlua::Value,
1561 opts: Option<&Table>,
1562 max_w: Option<f32>,
1563) -> mlua::Result<kui_core::TextMetrics> {
1564 let style = match opts {
1565 Some(t) => with_refs(ui, |refs| parse_props(t, false, refs))?.style,
1566 None => kui_core::TextStyle::default(),
1567 };
1568 let measure_spans = |ui: &mut Ui<'_>, spans: &Table, style: &kui_core::TextStyle| {
1569 let parts = with_refs(ui, |refs| collect_spans(spans, refs))?;
1570 let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1571 Ok(ui.measure_rich_text(&spans, style, max_w))
1572 };
1573 match s {
1574 mlua::Value::String(s) => Ok(ui.measure_text(&s.to_str()?, &style, max_w)),
1575 mlua::Value::Table(t) => {
1576 if t.get::<Option<String>>("type")?.as_deref() == Some("text") {
1577 let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
1578 match t.get::<Option<Table>>("spans")? {
1579 Some(spans) => measure_spans(ui, &spans, &style),
1580 None => {
1581 let value: String = t.get("value")?;
1582 Ok(ui.measure_text(&value, &style, max_w))
1583 }
1584 }
1585 } else {
1586 measure_spans(ui, t, &style)
1587 }
1588 }
1589 other => Err(bad(format!(
1590 "measure_text: expected a string, a span list or a text node, got {}",
1591 other.type_name()
1592 ))),
1593 }
1594}
1595
1596fn bad(msg: impl std::fmt::Display) -> mlua::Error {
1600 mlua::Error::runtime(msg.to_string())
1601}
1602
1603fn build_children(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1604 for child in t.sequence_values::<Table>() {
1605 build_node(ui, &child?)?;
1606 }
1607 Ok(())
1608}
1609
1610fn with_children(
1613 ui: &mut Ui<'_>,
1614 t: &Table,
1615 widget: impl FnOnce(&mut Ui<'_>, &mut dyn FnMut(&mut Ui<'_>)),
1616) -> mlua::Result<()> {
1617 let mut result = Ok(());
1618 widget(ui, &mut |ui| result = build_children(ui, t));
1619 result
1620}
1621
1622fn element_of(ty: &str) -> &str {
1626 match ty {
1627 "row" | "column" => "box",
1628 "grid" => "table",
1629 "input" => "edit",
1630 "dropdown" => "select",
1631 "radio_group" => "radioGroup",
1632 "window_buttons" => "windowButtons",
1633 "menu_bar" => "menuBar",
1634 "latency_graph" | "latency_hud" => "latencyGraph",
1635 other => other,
1636 }
1637}
1638
1639fn menu_bar_of(t: &Table) -> mlua::Result<(kui_core::MenuBar, Vec<String>)> {
1640 let Some(list) = t.get::<Option<Table>>("menu")? else {
1641 return Ok((kui_core::MenuBar::default(), Vec::new()));
1642 };
1643 let mut menus = lua_list_to_value(&list)?;
1644 if let Value::List(menus) = &mut menus {
1645 for menu in menus.iter_mut() {
1646 let Value::Map(fields) = menu else { continue };
1647 for (k, items) in fields.iter_mut() {
1648 if k != "items" {
1649 continue;
1650 }
1651 if matches!(items, Value::Map(m) if m.is_empty()) {
1653 *items = Value::List(Vec::new());
1654 }
1655 if let Value::List(rows) = items {
1656 rows.iter_mut().for_each(alias_menu_role);
1657 }
1658 }
1659 }
1660 }
1661 let bar = kui_core::MenuBar::from_value(&menus).map_err(mlua::Error::runtime)?;
1662 Ok((bar, kui_core::MenuBar::stray_keys(&menus)))
1663}
1664
1665fn declare_windows(ui: &mut Ui<'_>, root: &Table) -> mlua::Result<()> {
1666 let Some(list) = root.get::<Option<Table>>("windows")? else {
1667 return Ok(());
1668 };
1669 for entry in list.sequence_values::<mlua::Value>() {
1673 let v = lua_to_value(&entry?)?;
1674 let (name, cfg) = WindowConfig::from_value(&v).map_err(mlua::Error::runtime)?;
1675 ui.window(&name, cfg);
1676 }
1677 Ok(())
1678}
1679
1680fn check_props(ui: &mut Ui<'_>, t: &Table, element: &str) -> mlua::Result<()> {
1684 if !ui.core().diagnostics() {
1685 return Ok(());
1686 }
1687 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1688 let (k, _) = pair?;
1689 let mlua::Value::String(k) = k else { continue };
1690 let name = k.to_str()?;
1691 if name.as_ref() == "type" || schema::known_prop(element, &name, schema::Spelling::Snake) {
1693 continue;
1694 }
1695 let w = kui_core::diag::unknown_prop(element, &name, schema::Spelling::Snake);
1696 ui.core().warn(w);
1697 }
1698 Ok(())
1699}
1700
1701fn build_fill(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1711 let name: String = t
1712 .get::<Option<String>>("name")?
1713 .filter(|n| !n.is_empty())
1714 .ok_or_else(|| bad("fill needs a name (\"namespace/slot\")"))?;
1715 if !name.contains(kui_core::NAMESPACE_SEPARATOR) {
1716 return Err(bad(format!(
1717 "bad slot name {name:?} (a full \"namespace/slot\")"
1718 )));
1719 }
1720 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1721 let (k, _) = pair?;
1722 let mlua::Value::String(k) = k else { continue };
1723 let k = k.to_str()?;
1724 if !matches!(k.as_ref(), "type" | "name" | "params" | "keep" | "replay") {
1725 return Err(bad(format!(
1726 "fill takes name, params, keep and replay, not {:?} — it is a position, not a box",
1727 k.as_ref()
1728 )));
1729 }
1730 }
1731 let params = match t.get::<mlua::Value>("params")? {
1732 mlua::Value::Nil => Value::Null,
1733 v => lua_to_value(&v)?,
1734 };
1735 let keep: bool = t.get::<Option<bool>>("keep")?.unwrap_or(false);
1740 let replay: bool = t.get::<Option<bool>>("replay")?.unwrap_or(false);
1741 if keep && replay {
1742 return Err(bad("fill takes keep or replay, not both"));
1743 }
1744 if replay {
1745 ui.slot_replay(&name, ¶ms);
1746 } else if keep {
1747 ui.slot_kept(&name, ¶ms);
1748 } else {
1749 ui.slot_with(&name, ¶ms);
1750 }
1751 Ok(())
1752}
1753
1754fn build_devtools_tab(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1763 let name: String = t.get::<Option<String>>("name")?.unwrap_or_default();
1764 let refuse = |ui: &mut Ui<'_>, why: &str| {
1765 ui.core().warn(kui_core::diag::bad_devtools_tab(&name, why));
1766 Ok(())
1767 };
1768 if name.is_empty() {
1769 return refuse(ui, "it needs a name (its identity)");
1770 }
1771 let label: String = t
1772 .get::<Option<String>>("label")?
1773 .unwrap_or_else(|| name.clone());
1774 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1775 let (k, _) = pair?;
1776 let mlua::Value::String(k) = k else { continue };
1777 let k = k.to_str()?;
1778 if !matches!(k.as_ref(), "type" | "name" | "label" | "slot" | "view") {
1779 return refuse(
1780 ui,
1781 &format!(
1782 "it takes name, label and slot or view, not {:?}",
1783 k.as_ref()
1784 ),
1785 );
1786 }
1787 }
1788 let slot = t.get::<Option<String>>("slot")?;
1789 let view = t.get::<mlua::Value>("view")?;
1790 match (slot, view) {
1791 (Some(slot), mlua::Value::Nil) => {
1792 if !slot.contains(kui_core::NAMESPACE_SEPARATOR) {
1793 return refuse(
1794 ui,
1795 &format!("bad slot {slot:?} (a full \"namespace/slot\")"),
1796 );
1797 }
1798 ui.devtools_tab(&name, &label, &slot);
1799 Ok(())
1800 }
1801 (None, mlua::Value::Function(view)) => {
1802 let mut result = Ok(());
1803 ui.devtools_tab_with(&name, &label, |ui| {
1804 result = view
1805 .call::<Table>(())
1806 .and_then(|tree| build_node(ui, &tree));
1807 });
1808 result
1809 }
1810 (Some(_), mlua::Value::Function(_)) => refuse(ui, "it takes a slot or a view, not both"),
1811 (None, mlua::Value::Nil) => refuse(ui, "it needs a slot or a view function"),
1812 (_, other) => refuse(
1813 ui,
1814 &format!("view is a {}, not a function", other.type_name()),
1815 ),
1816 }
1817}
1818
1819pub const MAX_VIEW_DEPTH: usize = 128;
1823
1824thread_local! {
1825 static VIEW_PATH: std::cell::RefCell<Vec<*const std::ffi::c_void>> =
1829 const { std::cell::RefCell::new(Vec::new()) };
1830}
1831
1832fn build_node(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1833 let at = t.to_pointer();
1834 VIEW_PATH.with_borrow_mut(|path| {
1835 if path.contains(&at) {
1836 return Err(bad("a view node that holds itself"));
1837 }
1838 if path.len() >= MAX_VIEW_DEPTH {
1839 return Err(bad(format!("a view nested past {MAX_VIEW_DEPTH} nodes")));
1840 }
1841 path.push(at);
1842 Ok(())
1843 })?;
1844 let built = build_one(ui, t);
1845 VIEW_PATH.with_borrow_mut(|path| path.pop());
1846 built
1847}
1848
1849fn build_one(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1856 let ty: mlua::LuaString = t.get("type")?;
1857 let ty = ty.to_str()?;
1858 let ty: &str = &ty;
1859 match ty {
1860 "fill" => build_fill(ui, t),
1861 "devtools_tab" => build_devtools_tab(ui, t),
1862 "row" | "column" | "grid" => {
1863 open_box(ui, t, ty)?;
1864 build_children(ui, t)?;
1865 ui.close();
1866 Ok(())
1867 }
1868 "fragment" => {
1869 open_fragment(ui, t)?;
1870 build_children(ui, t)?;
1871 ui.close();
1872 Ok(())
1873 }
1874 "titlebar" | "tooltip" | "radio_group" => build_holder(ui, t, ty),
1875 _ => build_widget(ui, t, ty),
1876 }
1877}
1878
1879#[inline(never)]
1880fn open_box(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1881 let (mut p, _) = node_props(ui, t, ty == "row", element_of(ty))?;
1882 p.spec.layout.table = ty == "grid";
1884 ui.core().open_from(p, Content::Box);
1885 Ok(())
1886}
1887
1888#[inline(never)]
1889fn open_fragment(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1890 let id: i64 = t.get("id")?;
1895 let image: Option<i64> = t.get("image")?;
1896 let params: Vec<f32> = match t.get::<Option<Table>>("params")? {
1897 Some(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
1898 None => Vec::new(),
1899 };
1900 let (p, _) = node_props(ui, t, false, element_of("fragment"))?;
1901 let frag = kui_core::FragmentRef {
1902 id: kui_core::FragmentId::from_ffi(id as u64),
1903 image: image
1904 .filter(|i| *i != 0)
1905 .map(|i| kui_core::ImageId::from_ffi(i as u64)),
1906 };
1907 ui.core().open_from(p, Content::Fragment(frag, ¶ms));
1908 Ok(())
1909}
1910
1911#[inline(never)]
1914fn build_holder(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1915 if ty != "radio_group" {
1916 check_props(ui, t, element_of(ty))?;
1917 }
1918 match ty {
1919 "titlebar" => {
1920 if t.raw_len() > 0 {
1921 with_children(ui, t, |ui, body| widgets::titlebar_with(ui, body))
1922 } else {
1923 let title: String = t.get::<Option<String>>("title")?.unwrap_or_default();
1924 widgets::titlebar(ui, &title);
1925 Ok(())
1926 }
1927 }
1928 "tooltip" => {
1929 if t.raw_len() > 0 {
1930 with_children(ui, t, |ui, body| widgets::tooltip_with(ui, body))
1931 } else {
1932 let value: String = t.get("value")?;
1933 widgets::tooltip(ui, &value);
1934 Ok(())
1935 }
1936 }
1937 "radio_group" => {
1938 let label: String = t.get("label")?;
1941 let (p, _) = node_props(ui, t, false, element_of(ty))?;
1942 let mut result = Ok(());
1943 widgets::radio_group_with(ui, &label, p.spec, |ui| result = build_children(ui, t));
1944 result
1945 }
1946 other => unreachable!("{other} holds no children"),
1947 }
1948}
1949
1950#[inline(never)]
1951fn build_widget(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1952 let mut checked = false;
1956 let built = match ty {
1957 "text" => {
1958 checked = true;
1959 let (p, walked) = node_props(ui, t, false, element_of(ty))?;
1960 let style = p.style;
1961 let spans = match walked.spans {
1965 Some(mlua::Value::Table(spans)) => Some(spans),
1966 Some(_) => t.get::<Option<Table>>("spans")?,
1967 None => None,
1968 };
1969 if let Some(spans) = spans {
1970 let parts = with_refs(ui, |refs| collect_spans(&spans, refs))?;
1971 let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1972 ui.rich_text(&spans, style);
1973 } else if let Some(mlua::Value::String(value)) = walked.value {
1974 ui.text(&value.to_str()?, style);
1975 } else {
1976 let value: String = t.get("value")?;
1977 ui.text(&value, style);
1978 }
1979 Ok(())
1980 }
1981 "image" => {
1982 let id: i64 = t.get("id")?;
1986 let spec = props_of(ui, t, ty, &mut checked)?.for_leaf().spec;
1988 let named = |row: &str, names: &[&str]| -> mlua::Result<usize> {
1989 match t.get::<Option<String>>(row)? {
1990 None => Ok(0),
1991 Some(s) => names
1992 .iter()
1993 .position(|n| *n == s)
1994 .ok_or_else(|| bad(format!("{row} must be one of {}", names.join(", ")))),
1995 }
1996 };
1997 let sampling_names: Vec<&str> =
1998 kui_core::Sampling::ALL.iter().map(|s| s.name()).collect();
1999 let fit_names: Vec<&str> = kui_core::ImageFit::ALL.iter().map(|f| f.name()).collect();
2000 let opts = kui_core::ImageOpts {
2001 sampling: kui_core::Sampling::ALL[named("sampling", &sampling_names)?],
2002 fit: kui_core::ImageFit::ALL[named("fit", &fit_names)?],
2003 };
2004 ui.image_with(kui_core::ImageId::from_ffi(id as u64), opts, spec);
2005 Ok(())
2006 }
2007 "polygon" => {
2008 let p = props_of(ui, t, ty, &mut checked)?;
2011 let Some(list) = t.get::<Option<Table>>("points")? else {
2012 return Err(bad("polygon needs points"));
2013 };
2014 let points: Vec<kui_core::Vec2> = list
2015 .sequence_values::<mlua::Value>()
2016 .map(|v| {
2017 let mlua::Value::Table(pt) = v? else {
2018 return Err(bad("a polygon point is a {x, y} table"));
2019 };
2020 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
2021 })
2022 .collect::<mlua::Result<_>>()?;
2023 ui.core().open_from(p, Content::Polygon(&points));
2024 Ok(())
2025 }
2026 "path" => {
2027 let mut p = props_of(ui, t, ty, &mut checked)?;
2032 if let Some(i) = p.spec.interact.as_deref_mut()
2038 && let Some(tr) = i.transform.as_deref_mut()
2039 {
2040 tr.rotate = 0.0;
2041 if *tr == kui_core::TransformSpec::NONE {
2042 i.transform = None;
2043 }
2044 }
2045 let rule = match t.get::<Option<String>>("fill_rule")? {
2046 Some(name) => kui_core::FillRule::parse(&name)
2047 .ok_or_else(|| bad("fill_rule is \"nonzero\" or \"evenodd\""))?,
2048 None => kui_core::FillRule::NonZero,
2049 };
2050 let width = match t.get::<mlua::Value>("width")? {
2051 mlua::Value::Nil => None,
2052 v => with_refs(ui, |refs| length_of(&v, refs))?,
2053 };
2054 let dash = dash_of(t)?;
2055 let stroke = width.filter(|w| *w > 0.0).map(|w| {
2056 kui_core::Stroke::new(w, p.style.color.unwrap_or(ui.theme().fg)).dashed(dash)
2057 });
2058 let turns = t.get::<Option<f32>>("rotate")?;
2062 let pivot = match t.get::<Option<Table>>("pivot")? {
2063 Some(p) => Some(kui_core::Vec2::new(p.get::<f32>(1)?, p.get::<f32>(2)?)),
2064 None => None,
2065 };
2066 let turn = (turns.is_some() || pivot.is_some()).then_some(kui_core::Turn {
2067 turns: turns.unwrap_or(0.0),
2068 pivot,
2069 });
2070 if let Some(d) = t.get::<Option<String>>("d")? {
2071 ui.core()
2072 .open_from(p, Content::PathD(&d, rule, stroke, turn));
2073 } else if let Some(list) = t.get::<Option<Table>>("ops")? {
2074 let floats: Vec<f32> =
2075 list.sequence_values::<f32>().collect::<mlua::Result<_>>()?;
2076 ui.core()
2077 .open_from(p, Content::PathFlat(&floats, rule, stroke, turn));
2078 } else {
2079 return Err(bad("path needs d or ops"));
2080 }
2081 Ok(())
2082 }
2083 "line" => {
2084 let p = props_of(ui, t, ty, &mut checked)?;
2090 let point = |v: mlua::Value| -> mlua::Result<kui_core::Vec2> {
2091 let mlua::Value::Table(pt) = v else {
2092 return Err(bad("a line point is a {x, y} table"));
2093 };
2094 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
2095 };
2096 let points: Vec<kui_core::Vec2> = match t.get::<Option<Table>>("points")? {
2097 Some(list) => list
2098 .sequence_values::<mlua::Value>()
2099 .map(|v| point(v?))
2100 .collect::<mlua::Result<_>>()?,
2101 None => {
2102 let (Some(from), Some(to)) = (
2103 t.get::<Option<mlua::Value>>("from")?,
2104 t.get::<Option<mlua::Value>>("to")?,
2105 ) else {
2106 return Err(bad("line needs from and to, or points"));
2107 };
2108 vec![point(from)?, point(to)?]
2109 }
2110 };
2111 let width = match t.get::<mlua::Value>("width")? {
2114 mlua::Value::Nil => None,
2115 v => with_refs(ui, |refs| length_of(&v, refs))?,
2116 }
2117 .unwrap_or(1.0);
2118 let stroke_color = p.style.color.unwrap_or(ui.theme().fg);
2120 let mut stroke = kui_core::Stroke::new(width, stroke_color);
2121 stroke.curve = t.get::<Option<bool>>("curve")?.unwrap_or(false);
2122 stroke.dash = dash_of(t)?;
2123 ui.core().open_from(p, Content::Line(&points, stroke));
2124 Ok(())
2125 }
2126 "cells" => {
2127 let p = props_of(ui, t, ty, &mut checked)?;
2133 let rows: usize = t.get::<Option<usize>>("rows")?.unwrap_or(0);
2134 let cols: usize = t.get::<Option<usize>>("cols")?.unwrap_or(0);
2135 if rows == 0 || cols == 0 {
2136 return Err(bad("cells needs rows and cols"));
2137 }
2138 let default_fg = p.style.color.unwrap_or(ui.theme().fg).to_hex();
2139 let mut cells = vec![kui_core::Cell::new(' ', default_fg, 0); rows * cols];
2140 if let Some(lines) = t.get::<Option<Table>>("lines")? {
2141 for (r, line) in lines.sequence_values::<String>().enumerate() {
2142 if r >= rows {
2143 break;
2144 }
2145 for (c, ch) in line?.chars().take(cols).enumerate() {
2146 cells[r * cols + c].ch = ch;
2147 }
2148 }
2149 }
2150 if let Some(runs) = t.get::<Option<Table>>("runs")? {
2151 for run in runs.sequence_values::<Table>() {
2152 let run = run?;
2153 let row: usize = run.get(1)?;
2154 let col: usize = run.get(2)?;
2155 let len: usize = run.get(3)?;
2156 let fg: u32 = run.get::<Option<u32>>(4)?.unwrap_or(0);
2157 let bg: u32 = run.get::<Option<u32>>(5)?.unwrap_or(0);
2158 let flags: u8 = run.get::<Option<u8>>(6)?.unwrap_or(0);
2159 let ul: u32 = run.get::<Option<u32>>(7)?.unwrap_or(0);
2160 if row >= rows {
2161 continue;
2162 }
2163 for c in col..(col + len).min(cols) {
2164 let cell = &mut cells[row * cols + c];
2165 if fg != 0 {
2166 cell.fg = fg;
2167 }
2168 if bg != 0 {
2169 cell.bg = bg;
2170 }
2171 cell.flags |= flags;
2172 if ul != 0 {
2173 cell.ul = ul;
2174 }
2175 }
2176 }
2177 }
2178 let cursor = match t.get::<Option<Table>>("cursor_at")? {
2179 Some(cur) => {
2180 let shape = match t.get::<Option<String>>("cursor_shape")? {
2183 None => kui_core::CellCursor::Block,
2184 Some(s) => kui_core::CellCursor::from_name(&s).ok_or_else(|| {
2185 bad(format!(
2186 "cursor_shape must be {}, not {s:?}",
2187 kui_core::CellCursor::NAMES.join(" | ")
2188 ))
2189 })?,
2190 };
2191 let color = match t.get::<mlua::Value>("cursor_color")? {
2192 mlua::Value::Nil => None,
2193 v => with_refs(ui, |refs| parse_color(&v, refs))?,
2194 }
2195 .unwrap_or(Color::rgb8(0xff, 0xff, 0xff));
2196 Some((cur.get::<usize>(1)?, cur.get::<usize>(2)?, shape, color))
2197 }
2198 None => None,
2199 };
2200 let origin_line = t.get::<Option<u64>>("origin_line")?.unwrap_or(0);
2202 let grid = kui_core::CellGrid {
2203 rows,
2204 cols,
2205 cells: &cells,
2206 style: p.style,
2207 cursor,
2208 origin_line,
2209 };
2210 ui.core().open_from(p, Content::Cells(&grid));
2211 Ok(())
2212 }
2213 "audio" => {
2214 let src: i64 = t.get("src")?;
2217 let mut spec = kui_core::AudioSpec::new(kui_core::SoundId::from_ffi(src as u64));
2218 if let Some(v) = t.get::<Option<f32>>("volume")? {
2219 spec = spec.volume(v);
2220 }
2221 if t.get::<Option<bool>>("loop")?.unwrap_or(false) {
2222 spec = spec.looped();
2223 }
2224 spec = spec.paused(t.get::<Option<bool>>("paused")?.unwrap_or(false));
2225 if t.get::<Option<bool>>("finish")?.unwrap_or(false) {
2226 spec = spec.finish();
2227 }
2228 if let Some(tag) = t.get::<Option<mlua::Value>>("tag")? {
2229 spec.tag = Some(lua_to_value(&tag)?);
2230 }
2231 match t.get::<Option<String>>("key")? {
2232 Some(k) => ui.audio_keyed(&k, spec),
2233 None => ui.audio(spec),
2234 };
2235 Ok(())
2236 }
2237 "input" => {
2238 let label: String = t.get("label")?;
2239 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
2240 widgets::text_input(ui, &label, &initial);
2241 Ok(())
2242 }
2243 "dropdown" => {
2244 let Some(label) = t.get::<Option<String>>("label")?.filter(|l| !l.is_empty()) else {
2248 return Err(bad("dropdown needs a label (its key and accessible name)"));
2249 };
2250 let Some(options) = t.get::<Option<Table>>("options")? else {
2251 return Err(bad(
2252 "dropdown needs options, a list of strings or menu rows",
2253 ));
2254 };
2255 let mut rows = lua_list_to_value(&options)?;
2256 if let Value::List(rows) = &mut rows {
2257 rows.iter_mut().for_each(alias_menu_role);
2258 }
2259 if ui.core().diagnostics() {
2263 for k in kui_core::MenuItem::stray_option_keys(&rows) {
2264 ui.core().warn(kui_core::diag::unknown_menu_item_key(&k));
2265 }
2266 }
2267 let items =
2268 kui_core::MenuItem::options_from_value(&rows).map_err(mlua::Error::runtime)?;
2269 let current = match t.get::<Option<i64>>("current")? {
2270 None => None,
2271 Some(i) if i >= 1 => Some(i as usize - 1),
2272 Some(i) => {
2273 return Err(bad(format!("dropdown current is an index from 1, not {i}")));
2274 }
2275 };
2276 widgets::select_items(ui, &label, &items, current);
2277 Ok(())
2278 }
2279 "edit" => {
2280 let p = props_of(ui, t, ty, &mut checked)?.for_leaf();
2282 let label = match p.key.clone().or(t.get::<Option<String>>("label")?) {
2283 Some(l) => l,
2284 None => return Err(bad("edit needs a key (state is retained by key)")),
2285 };
2286 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
2287 let opts = EditOptions {
2288 style: p.style,
2289 multiline: t.get::<Option<bool>>("multiline")?.unwrap_or(false),
2290 autofocus: t.get::<Option<bool>>("autofocus")?.unwrap_or(false),
2291 keep_tab: t.get::<Option<bool>>("keep_tab")?.unwrap_or(false),
2292 placeholder: t.get::<Option<String>>("placeholder")?,
2293 wrap: p.wrap,
2294 ..Default::default()
2295 };
2296 ui.text_edit(&label, &initial, &opts, p.spec);
2297 Ok(())
2298 }
2299 "window_buttons" => {
2300 widgets::window_buttons(ui);
2301 Ok(())
2302 }
2303 "menu_bar" => {
2304 let (bar, stray) = menu_bar_of(t)?;
2305 warn_stray_menu_keys(ui.core(), stray);
2306 widgets::menu_bar(ui, bar);
2307 Ok(())
2308 }
2309 "latency_graph" => {
2310 widgets::latency_graph(ui);
2311 Ok(())
2312 }
2313 "latency_hud" => {
2314 let (mut x, mut y) = (Align::End, Align::End);
2315 if let Some(at) = t.get::<Option<Table>>("at")? {
2316 x = parse_align(&at.get::<String>(1)?)?;
2317 y = parse_align(&at.get::<String>(2)?)?;
2318 }
2319 widgets::latency_hud_at(ui, x, y);
2320 Ok(())
2321 }
2322 "button" => {
2323 let label: String = t.get("label")?;
2333 let text: String = t
2334 .get::<Option<String>>("text")?
2335 .unwrap_or_else(|| label.clone());
2336 let key: String = t
2337 .get::<Option<String>>("key")?
2338 .unwrap_or_else(|| label.clone());
2339 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
2340 Some(v) => lua_to_value(&v)?,
2341 None => Value::Null,
2342 };
2343 let mut out = PropsOut::new();
2344 out.spec = widgets::button_spec(&ui.theme(), &ui.metrics())
2345 .on_click(payload)
2346 .label(label.as_str());
2347 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2348 out.apply_tooltip(&hint);
2349 }
2350 with_refs(ui, |refs| {
2351 for name in ["description", "disabled", "accent"] {
2352 let v = t.get::<mlua::Value>(name)?;
2353 if v.is_nil() {
2354 continue;
2355 }
2356 let def = schema::by_snake_name(name).expect("a button row");
2357 if let Some(parsed) =
2358 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2359 {
2360 schema::apply(def, parsed, &mut out).map_err(bad)?;
2361 }
2362 }
2363 Ok(())
2364 })?;
2365 match t.get::<Option<u64>>("index")? {
2368 Some(i) => widgets::button_indexed(ui, i, &text, out.spec, out.tooltip.as_deref()),
2369 None => widgets::button_with(ui, &key, &text, out.spec, out.tooltip.as_deref()),
2370 }
2371 Ok(())
2372 }
2373 "checkbox" | "radio" | "switch" => {
2374 let kind = match ty {
2379 "checkbox" => widgets::Toggle::Checkbox,
2380 "radio" => widgets::Toggle::Radio,
2381 _ => widgets::Toggle::Switch,
2382 };
2383 let label: String = t.get("label")?;
2384 let text: String = t
2385 .get::<Option<String>>("text")?
2386 .unwrap_or_else(|| label.clone());
2387 let key: String = t
2388 .get::<Option<String>>("key")?
2389 .unwrap_or_else(|| label.clone());
2390 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
2391 Some(v) => lua_to_value(&v)?,
2392 None => Value::Null,
2393 };
2394 let mut out = PropsOut::new();
2395 out.spec = widgets::toggle_spec(&ui.metrics())
2396 .on_click(payload)
2397 .label(label.as_str());
2398 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2399 out.apply_tooltip(&hint);
2400 }
2401 apply_named_rows(
2402 ui,
2403 t,
2404 &["description", "disabled", "checked", "mixed"],
2405 &mut out,
2406 )?;
2407 widgets::toggle_with(ui, kind, &key, &text, out.spec, out.tooltip.as_deref());
2408 Ok(())
2409 }
2410 "slider" => {
2411 let label: String = t.get("label")?;
2415 let key: String = t
2416 .get::<Option<String>>("key")?
2417 .unwrap_or_else(|| label.clone());
2418 let mut out = PropsOut::new();
2419 out.spec = widgets::slider_spec(&ui.metrics()).label(label.as_str());
2420 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2421 out.apply_tooltip(&hint);
2422 }
2423 apply_named_rows(
2424 ui,
2425 t,
2426 &[
2427 "description",
2428 "disabled",
2429 "value_now",
2430 "value_min",
2431 "value_max",
2432 "value_step",
2433 "value_text",
2434 "on_change",
2435 "width",
2436 "min_width",
2437 "max_width",
2438 ],
2439 &mut out,
2440 )?;
2441 widgets::slider_with(ui, &key, out.spec, out.tooltip.as_deref());
2442 Ok(())
2443 }
2444 other => Err(mlua::Error::runtime(format!("unknown node type '{other}'"))),
2445 };
2446 if !checked {
2447 check_props(ui, t, element_of(ty))?;
2448 }
2449 built
2450}
2451
2452fn apply_named_rows(
2457 ui: &mut Ui<'_>,
2458 t: &Table,
2459 names: &[&str],
2460 out: &mut PropsOut,
2461) -> mlua::Result<()> {
2462 with_refs(ui, |refs| {
2463 for name in names {
2464 let v = t.get::<mlua::Value>(*name)?;
2465 if v.is_nil() {
2466 continue;
2467 }
2468 let def = schema::by_snake_name(name).expect("a schema row");
2469 if let Some(parsed) =
2470 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2471 {
2472 schema::apply(def, parsed, out).map_err(bad)?;
2473 }
2474 }
2475 Ok(())
2476 })
2477}
2478
2479struct SpanPart {
2480 text: String,
2481 bold: bool,
2482 italic: bool,
2483 underline: bool,
2484 underline_color: Option<Color>,
2487 underline_style: Option<kui_core::UnderlineStyle>,
2488 strikethrough: bool,
2489 color: Option<Color>,
2490 bg: Option<Color>,
2491 bg_radius: f32,
2494 family: Option<kui_core::FontFamily>,
2497 size: Option<f32>,
2499}
2500
2501fn span_of(p: &SpanPart) -> Span<'_> {
2502 let mut s = Span::new(&p.text);
2503 if p.bold {
2504 s = s.bold();
2505 }
2506 if p.italic {
2507 s = s.italic();
2508 }
2509 if p.underline {
2510 s = s.underline();
2511 }
2512 if let Some(c) = p.underline_color {
2513 s = s.underline_color(c);
2514 }
2515 if let Some(st) = p.underline_style {
2516 s = s.underline_style(st);
2517 }
2518 if p.strikethrough {
2519 s = s.strikethrough();
2520 }
2521 if let Some(c) = p.color {
2522 s = s.color(c);
2523 }
2524 if let Some(c) = p.bg {
2525 s = s.bg(c);
2526 }
2527 if p.bg_radius > 0.0 {
2528 s = s.bg_radius(p.bg_radius);
2529 }
2530 if let Some(f) = p.family {
2531 s = s.family(f);
2532 }
2533 if let Some(px) = p.size {
2534 s = s.size(px);
2535 }
2536 s
2537}
2538
2539fn collect_spans(spans: &Table, refs: &mut Refs<'_>) -> mlua::Result<Vec<SpanPart>> {
2541 let mut out = Vec::new();
2542 for item in spans.sequence_values::<mlua::Value>() {
2543 match item? {
2544 mlua::Value::String(s) => out.push(SpanPart {
2545 text: s.to_str()?.to_string(),
2546 bold: false,
2547 italic: false,
2548 underline: false,
2549 underline_color: None,
2550 underline_style: None,
2551 strikethrough: false,
2552 color: None,
2553 bg: None,
2554 bg_radius: 0.0,
2555 family: None,
2556 size: None,
2557 }),
2558 mlua::Value::Table(t) => out.push(span_part(&t, refs)?),
2559 other => {
2560 return Err(bad(format!(
2561 "span must be a string or table, got {}",
2562 other.type_name()
2563 )));
2564 }
2565 }
2566 }
2567 Ok(out)
2568}
2569
2570fn span_part(t: &Table, refs: &mut Refs<'_>) -> mlua::Result<SpanPart> {
2578 let mut text = None;
2579 let (mut color, mut bg, mut underline_color) = (None, None, None);
2580 let (mut underline_style, mut font, mut family) = (None, None, None);
2581 let (mut size, mut bg_radius) = (None, None);
2582 let (mut bold, mut italic, mut underline, mut strikethrough) = (false, false, false, false);
2583 let flag = |v: &mlua::Value| !matches!(v, mlua::Value::Nil | mlua::Value::Boolean(false));
2586 t.for_each::<NodeKey, mlua::Value>(|k, v| {
2587 match &k {
2588 NodeKey::Int(1) => text = Some(v),
2589 NodeKey::Str { .. } => match k.bytes() {
2590 b"color" => color = Some(v),
2591 b"bg" => bg = Some(v),
2592 b"underline_color" => underline_color = Some(v),
2593 b"underline_style" => underline_style = Some(v),
2594 b"font" => font = Some(v),
2595 b"family" => family = Some(v),
2596 b"size" => size = Some(v),
2597 b"bg_radius" => bg_radius = Some(v),
2598 b"bold" => bold = flag(&v),
2599 b"italic" => italic = flag(&v),
2600 b"underline" => underline = flag(&v),
2601 b"strikethrough" => strikethrough = flag(&v),
2602 _ => {}
2603 },
2604 _ => {}
2605 }
2606 Ok(())
2607 })?;
2608 let text = match text {
2609 Some(mlua::Value::String(s)) => s.to_str()?.to_string(),
2610 _ => t
2611 .get::<Option<String>>(1)?
2612 .ok_or_else(|| bad("span table needs its text at [1]"))?,
2613 };
2614 let color = match color {
2615 Some(v) => parse_color(&v, refs)?,
2616 None => None,
2617 };
2618 let bg = match bg {
2619 Some(v) => parse_color(&v, refs)?,
2620 None => None,
2621 };
2622 let underline_color = match underline_color {
2623 Some(v) => parse_color(&v, refs)?,
2624 None => None,
2625 };
2626 let underline_style = match underline_style {
2627 None => None,
2628 Some(_) => {
2629 let name: String = t.get("underline_style")?;
2630 Some(
2631 kui_core::UnderlineStyle::NAMES
2632 .iter()
2633 .position(|n| *n == name)
2634 .map(|i| kui_core::UnderlineStyle::from_index(i as u32))
2635 .ok_or_else(|| {
2636 bad(format!(
2637 "underline_style must be one of {}, got {name:?}",
2638 kui_core::UnderlineStyle::NAMES.join(" | ")
2639 ))
2640 })?,
2641 )
2642 }
2643 };
2644 let family = match (font, family) {
2647 (Some(v), _) => match parse_value(&Kind::Resource, &v, refs)
2648 .map_err(|e| bad(format!("span font: {e}")))?
2649 {
2650 Some(Parsed::Resource(id)) => {
2651 Some(kui_core::FontFamily::Custom(kui_core::FontId::from_ffi(id)))
2652 }
2653 _ => None,
2654 },
2655 (None, Some(v)) => match parse_value(&Kind::Family, &v, refs)
2656 .map_err(|e| bad(format!("span family: {e}")))?
2657 {
2658 Some(Parsed::Family(f)) => Some(f),
2659 _ => None,
2660 },
2661 (None, None) => None,
2662 };
2663 let number = |v: Option<mlua::Value>, key: &str| -> mlua::Result<Option<f32>> {
2664 match v {
2665 None => Ok(None),
2666 Some(v) => match number(&v) {
2667 Some(n) => Ok(Some(n)),
2668 None => t.get::<Option<f32>>(key),
2669 },
2670 }
2671 };
2672 Ok(SpanPart {
2673 family,
2674 size: number(size, "size")?,
2675 text,
2676 bold,
2677 italic,
2678 underline,
2679 underline_color,
2680 underline_style,
2681 strikethrough,
2682 color,
2683 bg,
2684 bg_radius: number(bg_radius, "bg_radius")?.unwrap_or(0.0).max(0.0),
2685 })
2686}
2687
2688pub fn parse_tokens(t: &Table) -> mlua::Result<kui_core::Tokens> {
2698 let mut out = kui_core::Tokens::new();
2699 for pair in t.pairs::<String, mlua::Value>() {
2700 let (k, _) = pair?;
2701 if k != "colors" && k != "lengths" {
2702 return Err(bad(format!("tokens: unknown key {k:?} (colors, lengths)")));
2703 }
2704 }
2705 if let Some(colors) = t.get::<Option<Table>>("colors")? {
2706 let mut entries: Vec<(String, mlua::Value)> = colors
2707 .pairs::<String, mlua::Value>()
2708 .collect::<mlua::Result<_>>()?;
2709 entries.sort_by(|a, b| a.0.cmp(&b.0));
2710 let mut derived: Vec<(String, String, Vec<kui_core::ColorOp>)> = Vec::new();
2712 for (name, v) in entries {
2713 out = match &v {
2714 mlua::Value::Table(t) if t.contains_key("from")? => {
2715 let (from, ops) = parse_recipe(&name, t)?;
2716 derived.push((name, from, ops));
2717 out
2718 }
2719 mlua::Value::Table(halves) => {
2720 let half = |k: &str| -> mlua::Result<Color> {
2721 match halves.get::<mlua::Value>(k)? {
2722 mlua::Value::Nil => Err(bad(format!(
2723 "tokens.colors.{name}: needs both light and dark"
2724 ))),
2725 v => parse_color_value(&v),
2726 }
2727 };
2728 out.color_themed(&name, half("light")?, half("dark")?)
2729 }
2730 v => out.color(
2731 &name,
2732 parse_color_value(v).map_err(|e| bad(format!("tokens.colors.{name}: {e}")))?,
2733 ),
2734 };
2735 }
2736 while !derived.is_empty() {
2739 let ready = derived.iter().position(|(_, from, ops)| {
2740 let known = |s: &str| kui_core::tokens::is_role(s) || out.color_id(s).is_some();
2741 known(from)
2742 && ops.iter().all(|op| match op {
2743 kui_core::ColorOp::Mix(c, _) | kui_core::ColorOp::Readable(c, _) => {
2744 known(c)
2745 }
2746 _ => true,
2747 })
2748 });
2749 let (name, from, ops) = derived.remove(ready.unwrap_or(0));
2750 out = out.derive(&name, &from, ops);
2751 }
2752 }
2753 if let Some(lengths) = t.get::<Option<Table>>("lengths")? {
2754 let mut entries: Vec<(String, mlua::Value)> = lengths
2755 .pairs::<String, mlua::Value>()
2756 .collect::<mlua::Result<_>>()?;
2757 entries.sort_by(|a, b| a.0.cmp(&b.0));
2758 for (name, v) in entries {
2759 let px = number(&v)
2760 .ok_or_else(|| bad(format!("tokens.lengths.{name}: a length is a number")))?;
2761 out = out.length(&name, px);
2762 }
2763 }
2764 Ok(out)
2765}
2766
2767fn parse_recipe(name: &str, t: &Table) -> mlua::Result<(String, Vec<kui_core::ColorOp>)> {
2772 for pair in t.pairs::<String, mlua::Value>() {
2773 let (k, _) = pair?;
2774 if k != "from" && k != "ops" {
2775 return Err(bad(format!(
2776 "tokens.colors.{name}: unknown key {k:?} (from, ops)"
2777 )));
2778 }
2779 }
2780 let from = match t.get::<mlua::Value>("from")? {
2781 mlua::Value::String(s) => s.to_str()?.to_string(),
2782 _ => {
2783 return Err(bad(format!(
2784 "tokens.colors.{name}.from names a colour token or role"
2785 )));
2786 }
2787 };
2788 let ops = match t.get::<mlua::Value>("ops")? {
2789 mlua::Value::Nil => Vec::new(),
2790 mlua::Value::Table(list) => {
2791 let tuples: Vec<Table> = match list.get::<mlua::Value>(1)? {
2793 mlua::Value::String(_) => vec![list],
2794 _ => list
2795 .sequence_values::<Table>()
2796 .collect::<mlua::Result<_>>()?,
2797 };
2798 tuples
2799 .iter()
2800 .map(|op| parse_color_op(name, op))
2801 .collect::<mlua::Result<_>>()?
2802 }
2803 _ => {
2804 return Err(bad(format!(
2805 "tokens.colors.{name}.ops is a list of {{ verb, … }} tuples"
2806 )));
2807 }
2808 };
2809 Ok((from, ops))
2810}
2811
2812fn parse_color_op(name: &str, op: &Table) -> mlua::Result<kui_core::ColorOp> {
2813 let verb = match op.get::<mlua::Value>(1)? {
2814 mlua::Value::String(s) => s.to_str()?.to_string(),
2815 _ => {
2816 return Err(bad(format!(
2817 "tokens.colors.{name}.ops: an op starts with its verb"
2818 )));
2819 }
2820 };
2821 let Some(takes_color) = kui_core::ColorOp::takes_color(&verb) else {
2822 return Err(bad(format!(
2823 "tokens.colors.{name}.ops: unknown verb {verb:?} (lift, darken, raise, alpha, mix, readable)"
2824 )));
2825 };
2826 let arity = if takes_color { 3 } else { 2 };
2827 if op.raw_len() != arity {
2828 return Err(bad(format!(
2829 "tokens.colors.{name}.ops: {verb} takes {} — {{ \"{verb}\", {} }}",
2830 if takes_color {
2831 "a colour and a number"
2832 } else {
2833 "one number"
2834 },
2835 if takes_color { "token, t" } else { "t" }
2836 )));
2837 }
2838 let color = if takes_color {
2839 match op.get::<mlua::Value>(2)? {
2840 mlua::Value::String(s) => Some(s.to_str()?.to_string()),
2841 _ => {
2842 return Err(bad(format!(
2843 "tokens.colors.{name}.ops: {verb}'s colour is a token or role name"
2844 )));
2845 }
2846 }
2847 } else {
2848 None
2849 };
2850 let n = number(&op.get::<mlua::Value>(arity as i64)?).ok_or_else(|| {
2851 bad(format!(
2852 "tokens.colors.{name}.ops: {verb}'s number is a number"
2853 ))
2854 })?;
2855 Ok(kui_core::ColorOp::parse(&verb, color.as_deref(), n).expect("checked above"))
2856}
2857
2858pub type Refs<'a> = kui_core::NameRefs<'a>;
2864
2865fn with_refs<R>(
2869 ui: &mut Ui<'_>,
2870 f: impl FnOnce(&mut Refs<'_>) -> mlua::Result<R>,
2871) -> mlua::Result<R> {
2872 let (r, errors, families) = {
2873 let mut refs = Refs::new(ui.core().token_lookup());
2874 let r = f(&mut refs);
2875 (r, refs.take_missed(), refs.take_missed_families())
2876 };
2877 for e in errors {
2878 ui.core().warn_unknown_token(&e);
2879 }
2880 for name in families {
2881 ui.core().warn_unknown_family(&name);
2882 }
2883 r
2884}
2885
2886fn reference(v: &mlua::Value) -> mlua::Result<Option<String>> {
2888 if let mlua::Value::String(s) = v
2889 && let Some(name) = kui_core::tokens::reference(&s.to_str()?)
2890 {
2891 return Ok(Some(name.to_string()));
2892 }
2893 Ok(None)
2894}
2895
2896fn dash_of(t: &Table) -> mlua::Result<kui_core::Dash> {
2900 let offset = t.get::<Option<f32>>("dash_offset")?.unwrap_or(0.0);
2901 let lengths: Vec<f32> = match t.get::<mlua::Value>("dash")? {
2902 mlua::Value::Nil => return Ok(kui_core::Dash::SOLID),
2903 mlua::Value::Table(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
2904 mlua::Value::Integer(n) => vec![n as f32],
2905 mlua::Value::Number(n) => vec![n as f32],
2906 _ => Vec::new(),
2907 };
2908 kui_core::Dash::of(&lengths)
2909 .map(|d| d.offset(offset))
2910 .ok_or_else(|| bad("dash is a length, {mark, gap} or {mark, gap, mark, gap}"))
2911}
2912
2913fn length_of(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<f32>> {
2915 if let Some(name) = reference(v)? {
2916 return Ok(refs.length(&name));
2917 }
2918 number(v)
2919 .map(Some)
2920 .ok_or_else(|| bad("expected a number or a \"$token\""))
2921}
2922
2923pub fn parse_props(t: &Table, is_row: bool, refs: &mut Refs<'_>) -> mlua::Result<PropsOut> {
2931 parse_node(t, is_row, refs, Check::Off).map(|(out, _)| out)
2932}
2933
2934#[derive(Default)]
2940struct Walked {
2941 unknown: Vec<NodeKey>,
2942 value: Option<mlua::Value>,
2943 spans: Option<mlua::Value>,
2944}
2945
2946#[derive(Clone)]
2954enum NodeKey {
2955 Str {
2957 len: u8,
2958 buf: [u8; NodeKey::INLINE],
2959 },
2960 Long(String),
2963 Int(i64),
2964 Other,
2965}
2966
2967impl NodeKey {
2968 const INLINE: usize = 30;
2969
2970 fn of_bytes(b: &[u8]) -> Self {
2971 if b.len() <= Self::INLINE {
2972 let mut buf = [0; Self::INLINE];
2973 buf[..b.len()].copy_from_slice(b);
2974 NodeKey::Str {
2975 len: b.len() as u8,
2976 buf,
2977 }
2978 } else {
2979 NodeKey::Long(String::from_utf8_lossy(b).into_owned())
2980 }
2981 }
2982
2983 fn bytes(&self) -> &[u8] {
2985 match self {
2986 NodeKey::Str { len, buf } => &buf[..*len as usize],
2987 _ => &[],
2988 }
2989 }
2990
2991 fn name(&self) -> String {
2993 match self {
2994 NodeKey::Str { .. } => String::from_utf8_lossy(self.bytes()).into_owned(),
2995 NodeKey::Long(name) => name.clone(),
2996 NodeKey::Int(i) => i.to_string(),
2997 NodeKey::Other => "<a key>".into(),
2998 }
2999 }
3000}
3001
3002impl mlua::FromLua for NodeKey {
3003 fn from_lua(value: mlua::Value, _: &Lua) -> mlua::Result<Self> {
3004 Ok(match value {
3005 mlua::Value::String(s) => NodeKey::of_bytes(&s.as_bytes()),
3006 mlua::Value::Integer(i) => NodeKey::Int(i),
3007 _ => NodeKey::Other,
3008 })
3009 }
3010
3011 unsafe fn from_stack(idx: std::ffi::c_int, lua: &mlua::state::RawLua) -> mlua::Result<Self> {
3012 use mlua::ffi;
3013 let state = lua.state();
3014 unsafe {
3019 Ok(match ffi::lua_type(state, idx) {
3020 ffi::LUA_TSTRING => {
3021 let mut len = 0;
3022 let ptr = ffi::lua_tolstring(state, idx, &mut len);
3023 NodeKey::of_bytes(std::slice::from_raw_parts(ptr as *const u8, len))
3024 }
3025 ffi::LUA_TNUMBER if ffi::lua_isinteger(state, idx) != 0 => {
3026 NodeKey::Int(ffi::lua_tointeger(state, idx))
3027 }
3028 _ => NodeKey::Other,
3029 })
3030 }
3031 }
3032}
3033
3034#[derive(Clone, Copy, PartialEq, Eq)]
3040enum Check {
3041 Off,
3042 Unclaimed,
3043 All,
3044}
3045
3046enum Item {
3049 Pad,
3050 Border,
3051 Overflow(u32),
3052 Float,
3053 KeyFocus,
3054 Key,
3055 Index,
3056 RowCount,
3057 Tooltip,
3058 Row(&'static schema::PropDef),
3059}
3060
3061fn parse_node(
3070 t: &Table,
3071 is_row: bool,
3072 refs: &mut Refs<'_>,
3073 check: Check,
3074) -> mlua::Result<(PropsOut, Walked)> {
3075 let mut out = PropsOut::new();
3076 if is_row {
3077 out.spec = kui_core::NodeSpec::row();
3078 }
3079 let mut walked = Walked::default();
3080 let mut size = None;
3081 let mut radius = None;
3082 let mut items: Vec<(Item, mlua::Value)> = Vec::with_capacity(8);
3083 t.for_each::<NodeKey, mlua::Value>(|k, v| {
3084 if !matches!(k, NodeKey::Str { .. } | NodeKey::Long(_)) {
3085 return Ok(());
3086 }
3087 if check == Check::All && k.bytes() != b"type" {
3088 walked.unknown.push(k.clone());
3089 }
3090 let item = match k.bytes() {
3091 b"size" => {
3092 size = Some(v);
3093 return Ok(());
3094 }
3095 b"radius" => {
3096 radius = Some(v);
3097 return Ok(());
3098 }
3099 b"pad" => Item::Pad,
3100 b"border" => Item::Border,
3101 b"clip" => Item::Overflow(kui_core::OVERFLOW_CLIP),
3102 b"scroll_x" => Item::Overflow(kui_core::OVERFLOW_SCROLL_X),
3103 b"scroll" | b"scroll_y" => Item::Overflow(kui_core::OVERFLOW_SCROLL_Y),
3104 b"float" => Item::Float,
3105 b"key_focus" => Item::KeyFocus,
3106 b"key" => Item::Key,
3107 b"index" => Item::Index,
3108 b"row_count" => Item::RowCount,
3109 b"tooltip" => Item::Tooltip,
3110 name => {
3111 let name = schema::LUA_ALIASES
3115 .iter()
3116 .find(|(alias, _)| alias.as_bytes() == name)
3117 .map_or(name, |(_, real)| real.as_bytes());
3118 match schema::by_snake_bytes(name) {
3119 Some(def) => Item::Row(def),
3120 None => {
3121 match name {
3122 b"value" => walked.value = Some(v),
3123 b"spans" => walked.spans = Some(v),
3124 _ => {}
3125 }
3126 if check == Check::Unclaimed && name != b"type" {
3128 walked.unknown.push(k);
3129 }
3130 return Ok(());
3131 }
3132 }
3133 }
3134 };
3135 items.push((item, v));
3136 Ok(())
3137 })?;
3138 if let Some(v) = size
3139 && let Some(size) = length_of(&v, refs)?
3140 {
3141 out.style = kui_core::TextStyle::new(size);
3142 }
3143 if let Some(v) = radius
3144 && let Some(r) = length_of(&v, refs)?
3145 {
3146 out.with_spec(|s| s.radius(r));
3147 }
3148 let mut overflow = 0;
3152 for (item, v) in items {
3153 match item {
3154 Item::Pad => {
3155 let pad = parse_pad(&v, refs)?;
3156 out.apply_pad(pad);
3157 }
3158 Item::Border => {
3159 let b = match v {
3160 mlua::Value::Table(b) => b,
3161 _ => return Err(bad("border must be a table {w=, color=}")),
3162 };
3163 let w = length_of(&b.get::<mlua::Value>("w")?, refs)?.unwrap_or(0.0);
3164 let c = parse_color(&b.get::<mlua::Value>("color")?, refs)?
3165 .unwrap_or(Color::TRANSPARENT);
3166 out.with_spec(|s| s.border(w, c));
3167 }
3168 Item::Overflow(flag) => overflow |= bit(&v, flag),
3169 Item::Float => {
3170 let cfg = parse_float(&v)?;
3171 out.with_spec(|s| s.float(cfg));
3172 }
3173 Item::KeyFocus => out.key_focus = truthy(&v),
3174 Item::Key => {
3175 let mlua::Value::String(s) = &v else {
3176 return Err(bad("key must be a string"));
3177 };
3178 out.key = Some(s.to_str()?.to_string());
3179 }
3180 Item::Index => {
3181 let Some(i) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
3182 return Err(bad("index must be a number (the row's data index)"));
3183 };
3184 out.index = Some(i.max(0.0) as u64);
3185 }
3186 Item::RowCount => {
3187 let Some(n) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
3188 return Err(bad(
3189 "row_count must be a number (how many indexed rows the list has)",
3190 ));
3191 };
3192 out.row_count = Some(n.max(0.0) as u64);
3193 }
3194 Item::Tooltip => {
3195 let mlua::Value::String(s) = &v else {
3196 return Err(bad("tooltip must be a string"));
3197 };
3198 out.apply_tooltip(s.to_str()?.as_ref());
3199 }
3200 Item::Row(def) => {
3201 if let Some(parsed) = parse_value(&def.kind, &v, refs)
3202 .map_err(|e| bad(format!("{}: {e}", schema::snake_case(def.name))))?
3203 {
3204 schema::apply(def, parsed, &mut out).map_err(bad)?;
3205 }
3206 }
3207 }
3208 }
3209 out.with_spec(|s| s.overflow_bits(overflow));
3210 Ok((out, walked))
3211}
3212
3213fn props_of(ui: &mut Ui<'_>, t: &Table, ty: &str, checked: &mut bool) -> mlua::Result<PropsOut> {
3216 *checked = true;
3217 node_props(ui, t, false, element_of(ty)).map(|(out, _)| out)
3218}
3219
3220fn node_props(
3225 ui: &mut Ui<'_>,
3226 t: &Table,
3227 is_row: bool,
3228 element: &str,
3229) -> mlua::Result<(PropsOut, Walked)> {
3230 let check = if !ui.core().diagnostics() {
3231 Check::Off
3232 } else if schema::element_rows(element, schema::Spelling::Snake).is_some() {
3233 Check::All
3234 } else {
3235 Check::Unclaimed
3236 };
3237 let (out, walked) = with_refs(ui, |refs| parse_node(t, is_row, refs, check))?;
3238 for k in &walked.unknown {
3239 let name = k.name();
3240 if !schema::known_prop(element, &name, schema::Spelling::Snake) {
3241 let w = kui_core::diag::unknown_prop(element, &name, schema::Spelling::Snake);
3242 ui.core().warn(w);
3243 }
3244 }
3245 Ok((out, walked))
3246}
3247
3248fn truthy(v: &mlua::Value) -> bool {
3249 matches!(v, mlua::Value::Boolean(true))
3250}
3251
3252fn bit(v: &mlua::Value, bit: u32) -> u32 {
3254 if truthy(v) { bit } else { 0 }
3255}
3256
3257fn number(v: &mlua::Value) -> Option<f32> {
3258 match v {
3259 mlua::Value::Number(n) => Some(*n as f32),
3260 mlua::Value::Integer(n) => Some(*n as f32),
3261 _ => None,
3262 }
3263}
3264
3265fn parse_value(kind: &Kind, v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Parsed>> {
3267 Ok(Some(match kind {
3268 Kind::F32 => match length_of(v, refs)? {
3269 Some(px) => Parsed::F32(px),
3270 None => return Ok(None),
3271 },
3272 Kind::Color => match parse_color(v, refs)? {
3273 Some(c) => Parsed::Color(c),
3274 None => return Ok(None),
3275 },
3276 Kind::Flag => {
3277 if truthy(v) {
3278 Parsed::Flag
3279 } else {
3280 return Ok(None);
3281 }
3282 }
3283 Kind::Enum(names) => {
3284 let mlua::Value::String(s) = v else {
3285 return Err(bad(format!("expected one of {names:?}")));
3286 };
3287 Parsed::Enum(schema::enum_index(names, &s.to_str()?).map_err(bad)?)
3288 }
3289 Kind::Sizing => match parse_sizing(v, refs)? {
3290 Some(s) => Parsed::Sizing(s),
3291 None => return Ok(None),
3292 },
3293 Kind::Min | Kind::Max => Parsed::Bound(match v {
3298 v if reference(v)?.is_some() => match length_of(v, refs)? {
3299 Some(px) => kui_core::Bound::Px(px),
3300 None => return Ok(None),
3301 },
3302 mlua::Value::String(s) => {
3305 let s = s.to_str()?;
3306 let b = if matches!(kind, Kind::Min) {
3307 schema::min_str(&s)
3308 } else {
3309 schema::max_str(&s)
3310 };
3311 match kept(b)? {
3312 Some(b) => b,
3313 None => return Ok(None),
3314 }
3315 }
3316 mlua::Value::Table(_) => match kept(kui_core::calc::bound_value(&size_value(v)?))? {
3317 Some(b) => b,
3318 None => return Ok(None),
3319 },
3320 v => kui_core::Bound::Px(
3321 number(v).ok_or_else(|| bad("expected a number, a string or a size table"))?,
3322 ),
3323 }),
3324 Kind::Msg | Kind::Tag => Parsed::Msg(lua_to_value(v)?),
3325 Kind::Str => {
3326 let mlua::Value::String(s) = v else {
3327 return Err(bad("expected a string"));
3328 };
3329 Parsed::Str(s.to_str()?.to_string())
3330 }
3331 Kind::Family => {
3334 let mlua::Value::String(s) = v else {
3335 return Err(bad("expected a family name"));
3336 };
3337 Parsed::Family(refs.family(&s.to_str()?))
3338 }
3339 Kind::Resource => match v {
3340 mlua::Value::Integer(n) => Parsed::Resource(*n as u64),
3341 mlua::Value::Number(n) => Parsed::Resource(*n as u64),
3342 _ => return Err(bad("expected a resource handle (integer)")),
3343 },
3344 Kind::Keyframes => Parsed::Keyframes(
3347 kui_core::keyframes::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
3348 ),
3349 Kind::Enter => {
3350 Parsed::Enter(kui_core::enter::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?)
3351 }
3352 Kind::Gradient => Parsed::Gradient(
3353 kui_core::gradient::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
3354 ),
3355 }))
3356}
3357
3358fn parse_color_value(v: &mlua::Value) -> mlua::Result<Color> {
3361 match v {
3362 mlua::Value::Integer(n) => Ok(schema::color_num(*n as u32)),
3363 mlua::Value::Number(n) => Ok(schema::color_num(*n as u32)),
3364 mlua::Value::String(s) => schema::color_hex_str(&s.to_str()?).map_err(bad),
3365 _ => Err(bad("color must be a 0xRRGGBBAA integer or \"#hex\" string")),
3366 }
3367}
3368
3369fn parse_color(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Color>> {
3371 if let Some(name) = reference(v)? {
3372 return Ok(refs.color(&name));
3373 }
3374 parse_color_value(v)
3375 .map(Some)
3376 .map_err(|_| bad("color must be a 0xRRGGBBAA integer, a \"#hex\" string or a \"$token\""))
3377}
3378
3379fn size_value(v: &mlua::Value) -> mlua::Result<Value> {
3384 fn check(v: &Value) -> mlua::Result<()> {
3385 match v {
3386 Value::Map(m) => {
3387 for (k, x) in m {
3388 if k == "percent" {
3389 return Err(bad("a percentage is { pct = n } in Lua"));
3390 }
3391 check(x)?;
3392 }
3393 Ok(())
3394 }
3395 Value::List(xs) => xs.iter().try_for_each(check),
3396 _ => Ok(()),
3397 }
3398 }
3399 let value = lua_to_value(v)?;
3400 check(&value)?;
3401 Ok(value)
3402}
3403
3404fn kept<T>(r: Result<T, String>) -> mlua::Result<Option<T>> {
3408 match r {
3409 Ok(v) => Ok(Some(v)),
3410 Err(e) if kui_core::calc::is_full(&e) => Ok(None),
3411 Err(e) => Err(bad(e)),
3412 }
3413}
3414
3415fn parse_sizing(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Sizing>> {
3416 if let Some(name) = reference(v)? {
3417 return Ok(refs.length(&name).map(Sizing::Fixed));
3418 }
3419 Ok(Some(match v {
3420 mlua::Value::Number(n) => Sizing::Fixed(*n as f32),
3421 mlua::Value::Integer(n) => Sizing::Fixed(*n as f32),
3422 mlua::Value::String(s) => match kept(schema::sizing_str(&s.to_str()?))? {
3423 Some(s) => s,
3424 None => return Ok(None),
3425 },
3426 mlua::Value::Table(t) => {
3427 if let Some(p) = t.get::<Option<f32>>("pct")? {
3428 Sizing::Percent(p / 100.0)
3429 } else if let Some(f) = t.get::<Option<f32>>("grow")? {
3430 Sizing::Grow(f)
3431 } else {
3432 match kept(kui_core::calc::sizing_value(&size_value(v)?)).map_err(|e| {
3435 bad(format!(
3436 "sizing table needs pct, grow or a size expression: {e}"
3437 ))
3438 })? {
3439 Some(s) => s,
3440 None => return Ok(None),
3441 }
3442 }
3443 }
3444 _ => return Err(bad("invalid sizing value")),
3445 }))
3446}
3447
3448fn parse_pad(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<PadShorthand> {
3452 match v {
3453 mlua::Value::Table(t) => {
3454 let mut edges: [Option<mlua::Value>; 7] = Default::default();
3457 t.for_each::<NodeKey, mlua::Value>(|k, v| {
3458 let at = match k.bytes() {
3459 b"all" => 0,
3460 b"x" => 1,
3461 b"y" => 2,
3462 b"l" => 3,
3463 b"r" => 4,
3464 b"t" => 5,
3465 b"b" => 6,
3466 _ => return Ok(()),
3467 };
3468 edges[at] = Some(v);
3469 Ok(())
3470 })?;
3471 let mut edge = |i: usize| -> mlua::Result<Option<f32>> {
3472 match edges[i].take() {
3473 None => Ok(None),
3474 Some(v) => length_of(&v, refs),
3475 }
3476 };
3477 Ok(PadShorthand {
3478 all: edge(0)?,
3479 x: edge(1)?,
3480 y: edge(2)?,
3481 l: edge(3)?,
3482 r: edge(4)?,
3483 t: edge(5)?,
3484 b: edge(6)?,
3485 })
3486 }
3487 v => Ok(PadShorthand {
3488 all: length_of(v, refs).map_err(|_| bad("invalid padding value"))?,
3489 ..PadShorthand::default()
3490 }),
3491 }
3492}
3493
3494fn parse_align(s: &str) -> mlua::Result<Align> {
3495 schema::enum_index(schema::ALIGNS, s)
3496 .map(schema::align_idx)
3497 .map_err(bad)
3498}
3499
3500fn float_preset(name: &str) -> mlua::Result<FloatConfig> {
3503 FloatConfig::preset(name).ok_or_else(|| {
3504 bad(format!(
3505 "bad float preset '{name}' (one of {})",
3506 kui_core::FLOAT_PRESETS.join(" | ")
3507 ))
3508 })
3509}
3510
3511fn parse_attach(f: &Table, key: &str) -> mlua::Result<Option<(Align, Align)>> {
3513 let Some(at) = f.get::<Option<Table>>(key)? else {
3514 return Ok(None);
3515 };
3516 Ok(Some((
3517 parse_align(&at.get::<String>(1)?)?,
3518 parse_align(&at.get::<String>(2)?)?,
3519 )))
3520}
3521
3522fn parse_float(v: &mlua::Value) -> mlua::Result<FloatConfig> {
3523 let f = match v {
3524 mlua::Value::String(s) => return float_preset(&s.to_str()?),
3525 mlua::Value::Table(f) => f,
3526 _ => return Err(bad("float must be a preset string or a table")),
3527 };
3528 let self_at = match parse_attach(f, "self")? {
3531 Some(at) => Some(at),
3532 None => parse_attach(f, "self_at")?,
3533 };
3534 Ok(FloatConfig::build(
3535 match f.get::<Option<String>>("anchor")? {
3536 Some(name) => float_preset(&name)?,
3537 None => FloatConfig::parent(),
3538 },
3539 parse_attach(f, "at")?,
3540 self_at,
3541 f.get::<Option<f32>>("dx")?,
3542 f.get::<Option<f32>>("dy")?,
3543 f.get::<Option<bool>>("fit")?.unwrap_or(false),
3544 f.get::<Option<bool>>("clip")?.unwrap_or(false),
3545 ))
3546}
3547
3548pub const MAX_VALUE_DEPTH: usize = 64;
3555
3556pub fn lua_to_value(v: &mlua::Value) -> mlua::Result<Value> {
3574 to_value(
3575 v,
3576 &mut ValuePath {
3577 depth: 0,
3578 seen: Vec::new(),
3579 },
3580 )
3581}
3582
3583const VALUE_TRACKED_PAST: usize = 8;
3589
3590struct ValuePath {
3593 depth: usize,
3594 seen: Vec<*const std::ffi::c_void>,
3595}
3596
3597fn to_value(v: &mlua::Value, path: &mut ValuePath) -> mlua::Result<Value> {
3601 Ok(match v {
3602 mlua::Value::Nil => Value::Null,
3603 mlua::Value::Boolean(b) => Value::Bool(*b),
3604 mlua::Value::Integer(i) => Value::Int(*i),
3605 mlua::Value::Number(n) => Value::Float(*n),
3606 mlua::Value::String(s) => Value::Str(s.to_str()?.to_string()),
3607 mlua::Value::Table(t) => {
3608 let tracked = path.depth >= VALUE_TRACKED_PAST;
3609 if tracked {
3610 let at = t.to_pointer();
3611 if path.seen.contains(&at) {
3612 return Err(bad("a table that holds itself cannot be a value"));
3613 }
3614 path.seen.push(at);
3615 }
3616 if path.depth >= MAX_VALUE_DEPTH {
3617 return Err(bad(format!("a value nested past {MAX_VALUE_DEPTH} tables")));
3618 }
3619 path.depth += 1;
3620 let len = t.raw_len();
3621 let value = if len > 0 {
3622 let mut list = Vec::with_capacity(len);
3623 for item in t.sequence_values::<mlua::Value>() {
3624 list.push(to_value(&item?, path)?);
3625 }
3626 Value::List(list)
3627 } else {
3628 let mut map = Vec::new();
3629 for pair in t.pairs::<String, mlua::Value>() {
3630 let (k, v) = pair?;
3631 map.push((k, to_value(&v, path)?));
3632 }
3633 Value::Map(map)
3634 };
3635 path.depth -= 1;
3636 if tracked {
3637 path.seen.pop();
3638 }
3639 value
3640 }
3641 other => {
3642 return Err(mlua::Error::runtime(format!(
3643 "cannot convert {} to event payload",
3644 other.type_name()
3645 )));
3646 }
3647 })
3648}
3649
3650pub fn value_to_lua(lua: &Lua, v: &Value) -> mlua::Result<mlua::Value> {
3653 Ok(match v {
3654 Value::Null => mlua::Value::Nil,
3655 Value::Bool(b) => mlua::Value::Boolean(*b),
3656 Value::Int(i) => mlua::Value::Integer(*i),
3657 Value::Float(f) => mlua::Value::Number(*f),
3658 Value::Str(s) => mlua::Value::String(lua.create_string(s)?),
3659 Value::List(items) => {
3660 let t = lua.create_table_with_capacity(items.len(), 0)?;
3661 for (i, item) in items.iter().enumerate() {
3662 t.set(i + 1, value_to_lua(lua, item)?)?;
3663 }
3664 mlua::Value::Table(t)
3665 }
3666 Value::Map(entries) => {
3667 let t = lua.create_table_with_capacity(0, entries.len())?;
3668 for (k, v) in entries {
3669 t.set(k.as_str(), value_to_lua(lua, v)?)?;
3670 }
3671 mlua::Value::Table(t)
3672 }
3673 })
3674}
3675
3676#[cfg(test)]
3677mod tests {
3678 use super::*;
3679 use kui_core::{
3680 Core, Edges, FontFamily, InputEvent, NodeSpec, OriginId, Rect, Size, TextStyle, Vec2,
3681 WindowButton, WindowId,
3682 };
3683
3684 #[test]
3685 fn value_round_trips_through_lua() {
3686 let lua = Lua::new();
3687 let original = Value::map([
3688 ("kind", "inc".into()),
3689 ("by", Value::Int(2)),
3690 (
3691 "weights",
3692 Value::List(vec![Value::Float(0.5), Value::Float(1.5)]),
3693 ),
3694 ("enabled", Value::Bool(true)),
3695 ]);
3696 let lua_v = value_to_lua(&lua, &original).unwrap();
3697 let back = lua_to_value(&lua_v).unwrap();
3698 assert_eq!(back.get_str("kind"), Some("inc"));
3699 assert_eq!(back.get_int("by"), Some(2));
3700 assert_eq!(back.get_bool("enabled"), Some(true));
3701 match back.get("weights") {
3702 Some(Value::List(items)) => assert_eq!(items.len(), 2),
3703 other => panic!("expected list, got {other:?}"),
3704 }
3705 }
3706
3707 fn test_refs() -> Refs<'static> {
3711 let core: &'static Core = Box::leak(Box::new(Core::new()));
3712 Refs::new(core.token_lookup())
3713 }
3714
3715 fn eval_table(lua: &Lua, src: &str) -> Table {
3716 lua.load(src).eval().unwrap()
3717 }
3718
3719 #[test]
3721 fn schema_props_match_the_rust_builder() {
3722 let lua = Lua::new();
3723 let t = eval_table(
3724 &lua,
3725 r##"{
3726 width = "grow", height = {pct = 50},
3727 min_width = 10, max_width = 500, min_height = 5, max_height = 300,
3728 pad = {l = 1, r = 2, t = 3, b = 4}, gap = 8,
3729 main_align = "center", cross_align = "end", center = false,
3730 bg = "#14161e", radius = 6, border = {w = 1, color = 0x2a2d3aff},
3731 clip = true, scroll = true, scroll_x = true,
3732 float = {anchor = "viewport", at = {"end", "end"}, self_at = {"end", "end"},
3733 dx = -8, dy = -8, fit = true},
3734 hoverable = true, window = "close",
3735 on_click = {kind = "hit"}, on_drag = "d", on_key = 7, key_up = true,
3736 modal = "dlg", on_context_menu = {kind = "menu"},
3737 initial_focus = true,
3738 key = "panel", key_focus = true,
3739 }"##,
3740 );
3741 let p = parse_props(&t, true, &mut test_refs()).unwrap();
3742 let expected = NodeSpec::row()
3743 .grow_width()
3744 .height(Sizing::Percent(0.5))
3745 .min_width(10.0)
3746 .max_width(500.0)
3747 .min_height(5.0)
3748 .max_height(300.0)
3749 .padding(Edges {
3750 l: 1.0,
3751 r: 2.0,
3752 t: 3.0,
3753 b: 4.0,
3754 })
3755 .gap(8.0)
3756 .main_align(Align::Center)
3757 .cross_align(Align::End)
3758 .bg(Color::hex(0x14161eff))
3759 .radius(6.0)
3760 .border(1.0, Color::hex(0x2a2d3aff))
3761 .clip()
3762 .scroll_y()
3763 .scroll_x()
3764 .float(
3765 FloatConfig::viewport()
3766 .inside(Align::End, Align::End)
3767 .offset(-8.0, -8.0)
3768 .fit(),
3769 )
3770 .hoverable()
3771 .window_button(WindowButton::Close)
3772 .on_click(Value::map([("kind", "hit".into())]))
3773 .on_drag("d")
3774 .on_key(Value::Int(7))
3775 .key_up()
3776 .modal("dlg")
3777 .initial_focus()
3778 .on_context_menu(Value::map([("kind", "menu".into())]));
3779 assert_eq!(p.spec, expected);
3780 assert_eq!(p.key.as_deref(), Some("panel"));
3781 assert!(p.key_focus);
3782 }
3783
3784 #[test]
3787 fn a_min_is_a_number_or_fit() {
3788 let lua = Lua::new();
3789 let t = eval_table(&lua, r#"{ min_width = "fit", min_height = 3 }"#);
3790 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3791 let expected = NodeSpec::column()
3792 .min_width(kui_core::Min::FIT)
3793 .min_height(3.0);
3794 assert_eq!(p.spec, expected);
3795 let t = eval_table(&lua, r#"{ min_width = "grow" }"#);
3796 let err = parse_props(&t, false, &mut test_refs())
3797 .unwrap_err()
3798 .to_string();
3799 assert!(err.contains("bad min"), "{err}");
3800 }
3801
3802 #[test]
3806 fn the_lua_composites_resolve_the_way_the_core_says() {
3807 let lua = Lua::new();
3808 let t = eval_table(
3809 &lua,
3810 r#"{ pad = { all = 4, x = 10, b = 1 },
3811 float = { anchor = "below", dx = 6 } }"#,
3812 );
3813 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3814 assert_eq!(
3815 p.spec.layout.padding,
3816 Edges {
3817 l: 10.0,
3818 r: 10.0,
3819 t: 4.0,
3820 b: 1.0,
3821 }
3822 );
3823 assert_eq!(
3824 p.spec.layout.float,
3825 Some(FloatConfig::build(
3826 FloatConfig::below(),
3827 None,
3828 None,
3829 Some(6.0),
3830 None,
3831 false,
3832 false
3833 ))
3834 );
3835
3836 let by_self = eval_table(&lua, r#"{ float = { self = {"end", "start"} } }"#);
3838 let by_self_at = eval_table(&lua, r#"{ float = { self_at = {"end", "start"} } }"#);
3839 assert_eq!(
3840 parse_props(&by_self, false, &mut test_refs())
3841 .unwrap()
3842 .spec
3843 .layout
3844 .float,
3845 parse_props(&by_self_at, false, &mut test_refs())
3846 .unwrap()
3847 .spec
3848 .layout
3849 .float
3850 );
3851
3852 let bad_preset = eval_table(&lua, r#"{ float = "beneath" }"#);
3855 let e = parse_props(&bad_preset, false, &mut test_refs())
3856 .unwrap_err()
3857 .to_string();
3858 assert!(e.contains("beneath") && e.contains("below"), "{e}");
3859 }
3860
3861 #[test]
3862 fn text_style_props_match_the_rust_builder() {
3863 let lua = Lua::new();
3864 let t = eval_table(
3865 &lua,
3866 r#"{ size = 20, line_height = 30, color = 0x73d98cff, family = "mono" }"#,
3867 );
3868 let style = parse_props(&t, false, &mut test_refs()).unwrap().style;
3869 assert_eq!(
3870 style,
3871 TextStyle::new(20.0)
3872 .line_height(30.0)
3873 .color(Color::hex(0x73d98cff))
3874 .family(FontFamily::Mono)
3875 );
3876 let t = eval_table(&lua, r#"{ wrap = "none", max_lines = 2, ellipsis = true }"#);
3877 assert_eq!(
3878 parse_props(&t, false, &mut test_refs()).unwrap().style,
3879 TextStyle::default().nowrap().max_lines(2).ellipsis()
3880 );
3881 let t = eval_table(&lua, r##"{ color = "#fff", size = 12 }"##);
3884 assert_eq!(
3885 parse_props(&t, false, &mut test_refs()).unwrap().style,
3886 TextStyle::new(12.0).color(Color::hex(0xffffffff))
3887 );
3888 }
3889
3890 #[test]
3891 fn bad_values_name_the_prop() {
3892 let lua = Lua::new();
3893 let t = eval_table(&lua, r#"{ main_align = "middle" }"#);
3894 let e = parse_props(&t, false, &mut test_refs())
3895 .unwrap_err()
3896 .to_string();
3897 assert!(e.contains("main_align"), "{e}");
3898 assert!(e.contains("middle"), "{e}");
3899 }
3900
3901 fn frame(core: &mut Core, ext: &mut LuaExtension) -> usize {
3902 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3903 ui.set_origin(OriginId(1));
3904 ext.view(&Slot::root(), &mut ui).unwrap();
3905 ui.finish();
3906 core.output().0.quads.len()
3907 }
3908
3909 #[test]
3910 fn script_view_builds_ir_nodes() {
3911 let mut ext = LuaExtension::from_source(
3912 "test",
3913 r#"
3914 count = 41
3915 function view()
3916 return column { gap = 8, pad = 16, bg = 0x10121aff,
3917 text("count: " .. count, { size = 20 }),
3918 button { label = "bump", on_click = { kind = "bump" } },
3919 }
3920 end
3921 function on_event(ev)
3922 if ev.kind == "bump" then count = count + 1 end
3923 end
3924 "#,
3925 )
3926 .unwrap();
3927
3928 let mut core = Core::new();
3929 let quads = frame(&mut core, &mut ext);
3930 assert!(quads > 10, "expected panel/button/glyph quads, got {quads}");
3932
3933 ext.on_event(&UiEvent {
3935 origin: OriginId(1),
3936 key: Key::ROOT,
3937 payload: Value::map([("kind", "bump".into())]),
3938 window: WindowId::MAIN,
3939 slot: None,
3940 });
3941 let count: i64 = ext.lua.globals().get("count").unwrap();
3942 assert_eq!(count, 42);
3943 }
3944
3945 #[test]
3951 fn a_devtools_tab_calls_its_view_only_while_on_show() {
3952 let mut ext = LuaExtension::from_source(
3953 "test",
3954 r#"
3955 calls = 0
3956 function view(env)
3957 return column { gap = 8,
3958 text("app"),
3959 devtools_tab { name = "syntax", label = "Tree-sitter", view = function()
3960 calls = calls + 1
3961 return column { text("from lua"),
3962 button { label = "jump", on_click = { kind = "jump" } } }
3963 end },
3964 devtools_tab { name = "plug", label = "Plugin", slot = "ts/panel" },
3965 devtools_tab { name = "bad", label = "Bad" },
3966 }
3967 end
3968 "#,
3969 )
3970 .unwrap();
3971 let mut core = Core::new();
3972 core.set_devtools(true);
3973 core.set_devtools_dock(kui_core::DevtoolsDock::Right);
3974 core.set_inspect(true);
3975 frame(&mut core, &mut ext);
3976 frame(&mut core, &mut ext);
3977 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3978 assert_eq!(calls, 0, "not on show: the view was not called");
3979 let ws = core.take_warnings();
3980 assert_eq!(
3981 ws.iter()
3982 .filter(|w| w.code == kui_core::diag::BAD_DEVTOOLS_TAB)
3983 .count(),
3984 1,
3985 "the tab with neither form warns once: {ws:?}"
3986 );
3987 let chord = || {
3990 kui_core::InputEvent::KeyDown(kui_core::KeyPress::new(
3991 kui_core::KeyCode::Char('N'),
3992 kui_core::KeyMods::NONE.with_ctrl().with_shift(),
3993 ))
3994 };
3995 core.handle_input(chord());
3996 core.handle_input(chord());
3997 frame(&mut core, &mut ext);
3998 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3999 assert_eq!(calls, 1, "on show: called once a frame");
4000 frame(&mut core, &mut ext);
4001 let body = core
4002 .nodes()
4003 .iter()
4004 .find(|n| n.label.as_deref() == Some("kui-devtools/tab/syntax"))
4005 .map(|n| n.rect)
4006 .expect("the body");
4007 let jump = core
4008 .nodes()
4009 .iter()
4010 .find(|n| n.label.as_deref() == Some("jump"))
4011 .map(|n| n.rect)
4012 .expect("the script's button");
4013 assert!(
4014 jump.x >= body.x && jump.x + jump.w <= body.x + body.w,
4015 "{jump:?} in {body:?}"
4016 );
4017 assert!(
4018 core.nodes()
4019 .iter()
4020 .any(|n| n.text.as_deref() == Some("from lua")),
4021 "the script's text painted"
4022 );
4023 let evs = kui_core::testing::click_at(&mut core, jump.x + 2.0, jump.y + 2.0);
4024 assert_eq!(evs.len(), 1);
4025 assert_eq!(evs[0].origin, OriginId(1), "the script's own event");
4026 assert_eq!(evs[0].kind(), Some("jump"));
4027 }
4028
4029 #[test]
4034 fn the_root_table_declares_windows() {
4035 use kui_core::{WindowCommand, WindowConfig};
4036 let mut ext = LuaExtension::from_source(
4037 "windows",
4038 r#"
4039 function view(env)
4040 return column {
4041 windows = { { name = "palette", width = 400, height = 300,
4042 activates = false }, "tools" },
4043 text("main"),
4044 }
4045 end
4046 "#,
4047 )
4048 .unwrap();
4049 let mut core = Core::new();
4050 frame(&mut core, &mut ext);
4051 let cmds = core.take_window_commands();
4052 assert_eq!(cmds.len(), 2, "{cmds:?}");
4053 assert_eq!(
4054 cmds[0],
4055 WindowCommand::Open {
4056 id: WindowId(1),
4057 owner: WindowId::MAIN,
4058 origin: OriginId(1),
4059 config: WindowConfig {
4060 size: Size::new(400.0, 300.0),
4061 activates: false,
4062 ..WindowConfig::default()
4063 },
4064 }
4065 );
4066 assert_eq!(
4067 cmds[1],
4068 WindowCommand::Open {
4069 id: WindowId(2),
4070 owner: WindowId::MAIN,
4071 origin: OriginId(1),
4072 config: WindowConfig::default(),
4073 }
4074 );
4075 frame(&mut core, &mut ext);
4077 assert!(core.take_window_commands().is_empty());
4078 assert!(core.take_warnings().is_empty());
4079 }
4080
4081 #[test]
4085 fn a_windows_entry_declares_a_popup() {
4086 use kui_core::{Rect, WindowCommand, WindowConfig, WindowKind};
4087 let mut ext = LuaExtension::from_source(
4088 "windows-popup",
4089 r#"
4090 function view(env)
4091 return column {
4092 windows = { { name = "menu", kind = "popup",
4093 width = 160, height = 320,
4094 anchor = { x = 12, y = 40, w = 160, h = 24 } } },
4095 text("main"),
4096 }
4097 end
4098 "#,
4099 )
4100 .unwrap();
4101 let mut core = Core::new();
4102 frame(&mut core, &mut ext);
4103 assert_eq!(
4104 core.take_window_commands(),
4105 vec![WindowCommand::Open {
4106 id: WindowId(1),
4107 owner: WindowId::MAIN,
4108 origin: OriginId(1),
4109 config: WindowConfig {
4110 kind: WindowKind::Popup,
4111 size: Size::new(160.0, 320.0),
4112 activates: false,
4113 anchor: Rect::new(12.0, 40.0, 160.0, 24.0),
4114 },
4115 }]
4116 );
4117 }
4118
4119 #[test]
4124 fn a_windows_entry_cannot_name_an_unknown_kind() {
4125 let mut ext = LuaExtension::from_source(
4126 "windows-kind",
4127 r#"
4128 function view(env)
4129 return column {
4130 windows = { { name = "palette", kind = "sheet" } },
4131 text("main"),
4132 }
4133 end
4134 "#,
4135 )
4136 .unwrap();
4137 let mut core = Core::new();
4138 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
4139 ui.set_origin(OriginId(1));
4140 let err = ext.view(&Slot::root(), &mut ui).unwrap_err();
4141 assert!(err.contains("sheet"), "{err}");
4142 assert!(err.contains("popup"), "{err}");
4143 }
4144
4145 #[test]
4149 fn a_button_takes_an_index_and_a_cursor_shape_is_refused() {
4150 let mut ext = LuaExtension::from_source(
4151 "rows",
4152 r#"
4153 from = 0
4154 function view(env)
4155 return column {
4156 button { label = "Open", index = from, on_click = { row = from } },
4157 button { label = "Open", index = from + 1, on_click = { row = from + 1 } },
4158 button { label = "Keyed", key = "named", index = 7, on_click = "k" },
4159 }
4160 end
4161 "#,
4162 )
4163 .unwrap();
4164 let mut core = Core::new();
4165 core.set_diagnostics(true);
4166 let frame = |core: &mut Core, ext: &mut LuaExtension| {
4167 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4168 ui.set_origin(OriginId(1));
4169 ext.view(&Slot::root(), &mut ui).unwrap();
4170 ui.finish();
4171 };
4172 frame(&mut core, &mut ext);
4173 assert!(core.take_warnings().is_empty(), "index is a button row");
4174 let buttons = |core: &mut Core| -> Vec<Key> {
4175 core.access_tree()
4176 .nodes
4177 .iter()
4178 .filter(|n| n.role == kui_core::Role::Button)
4179 .map(|n| n.key)
4180 .collect()
4181 };
4182 let before = buttons(&mut core);
4183 assert_eq!(before.len(), 3, "two rows with the same text are two nodes");
4184 assert!(
4185 core.key_of("named").is_none(),
4186 "declared beside `key`, the index wins"
4187 );
4188 ext.lua.globals().set("from", 1).unwrap();
4189 frame(&mut core, &mut ext);
4190 let after = buttons(&mut core);
4191 assert_eq!(
4192 after[0], before[1],
4193 "row 1 keeps its key as it moves up the list"
4194 );
4195
4196 let mut ext = LuaExtension::from_source(
4197 "term",
4198 r#"
4199 function view(env)
4200 return column { cells { key = "term", rows = 1, cols = 4, size = 14,
4201 lines = { "abcd" }, cursor_at = { 1, 1 }, cursor_shape = "blob" } }
4202 end
4203 "#,
4204 )
4205 .unwrap();
4206 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4207 ui.set_origin(OriginId(1));
4208 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4209 assert!(
4210 err.contains("block | bar | underline") && err.contains("blob"),
4211 "{err}"
4212 );
4213 }
4214
4215 #[test]
4218 fn option_as_alt_is_a_side_by_name() {
4219 let mut core = Core::new();
4220 let mut ext = LuaExtension::from_source(
4221 "keys",
4222 r#"
4223 function view(env)
4224 return column { option_as_alt = "left", text("x") }
4225 end
4226 "#,
4227 )
4228 .unwrap();
4229 frame(&mut core, &mut ext);
4230 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
4231
4232 let mut ext = LuaExtension::from_source(
4233 "keys",
4234 r#"
4235 function view(env)
4236 return column { option_as_alt = "meta", text("x") }
4237 end
4238 "#,
4239 )
4240 .unwrap();
4241 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4242 ui.set_origin(OriginId(1));
4243 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4244 assert!(
4245 err.contains("option_as_alt") && err.contains("\"both\"") && err.contains("meta"),
4246 "{err}"
4247 );
4248 }
4249
4250 #[test]
4254 fn a_value_that_holds_itself_or_nests_too_deep_is_refused() {
4255 let lua = Lua::new();
4256 let read = |src: &str| lua_to_value(&lua.load(src).eval::<mlua::Value>().unwrap());
4257 let err = |src: &str| read(src).unwrap_err().to_string();
4258 assert!(err("local t = {}; t[1] = t; return t").contains("holds itself"));
4259 assert!(err("local t = {}; t.me = { t }; return t").contains("holds itself"));
4260 let shared = read("local l = { 1, 2 }; return { a = l, b = l }").unwrap();
4261 assert_eq!(shared.get("a"), shared.get("b"));
4262 let nested = |n: usize| format!("local t = 1; for _ = 1, {n} do t = {{ t }} end; return t");
4263 assert!(read(&nested(MAX_VALUE_DEPTH)).is_ok());
4264 assert!(err(&nested(MAX_VALUE_DEPTH + 1)).contains("nested past 64"));
4265 assert!(err(&nested(100_000)).contains("nested past 64"));
4267 }
4268
4269 #[test]
4273 fn a_view_that_holds_itself_or_nests_too_deep_is_refused() {
4274 let run = |body: &str| {
4275 let mut core = Core::new();
4276 let mut ext =
4277 LuaExtension::from_source("deep", &format!("function view(env)\n{body}\nend"))
4278 .unwrap();
4279 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4280 ui.set_origin(OriginId(1));
4281 let built = ext.view(&Slot::root(), &mut ui);
4282 ui.finish();
4283 built
4284 };
4285 let err = run("local t = column {}; t[1] = row { t }; return t").unwrap_err();
4286 assert!(err.contains("holds itself"), "{err}");
4287 let nested = |n: usize| {
4288 format!(
4289 "local t = text('x')
4290 for i = 2, {n} do
4291 if i % 2 == 0 then t = radio_group {{ label = 'g', t }}
4292 elseif i % 3 == 0 then t = tooltip {{ t }}
4293 else t = column {{ t }} end
4294 end
4295 return t"
4296 )
4297 };
4298 run(&nested(MAX_VIEW_DEPTH)).unwrap();
4299 let err = run(&nested(MAX_VIEW_DEPTH + 1)).unwrap_err();
4300 assert!(err.contains("nested past 128"), "{err}");
4301 run(&nested(8)).unwrap();
4303 }
4304
4305 #[test]
4309 fn option_as_alt_none_leaves_the_hosts_side() {
4310 let mut core = Core::new();
4311 let mut ext = LuaExtension::from_source(
4312 "keys",
4313 r#"
4314 function view(env)
4315 return column { option_as_alt = "none", text("x") }
4316 end
4317 "#,
4318 )
4319 .unwrap();
4320 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4321 ui.option_as_alt(kui_core::OptionAsAlt::Left);
4322 ui.set_origin(OriginId(1));
4323 ext.view(&Slot::root(), &mut ui).unwrap();
4324 ui.finish();
4325 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
4326 }
4327
4328 #[test]
4330 fn every_node_type_lowers() {
4331 let mut ext = LuaExtension::from_source(
4332 "all",
4333 r##"
4334 function view(env)
4335 return column { gap = 4, window_title = "all nodes", always_on_top = true,
4336 secure_input = true, option_as_alt = "right", ime_off = true,
4337 titlebar { text("custom title"), window_buttons() },
4338 titlebar { title = "plain title" },
4339 text({ "same IR as ", { "Rust", bold = true, color = "#73d98c" },
4340 { " — flatter", italic = true } }, { size = 13 }),
4341 edit { key = "note", initial = "hello", size = 14, width = 200,
4342 multiline = true },
4343 input { label = "name", initial = "" },
4344 dropdown { label = "language", options = { "English", "Deutsch" }, current = 1 },
4345 row { tooltip = "hover hint", pad = 4, text("badge") },
4346 row { hoverable = true, text("legend"), tooltip("always shown") },
4347 row { text("rich tip"), tooltip { text("a"), text("b") } },
4348 latency_graph(),
4349 latency_hud { at = { "start", "end" } },
4350 button { label = "ok", on_click = "ok" },
4351 }
4352 end
4353 "##,
4354 )
4355 .unwrap();
4356 let mut core = Core::new();
4357 let quads = frame(&mut core, &mut ext);
4358 assert!(quads > 60, "got {quads} quads");
4359 assert_eq!(core.window_title(), Some("all nodes"));
4360 assert!(core.always_on_top());
4361 assert!(core.secure_input());
4362 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Right);
4363 assert!(core.ime_off());
4364 let unknown: Vec<String> = core
4368 .take_warnings()
4369 .into_iter()
4370 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4371 .map(|w| w.message)
4372 .collect();
4373 assert!(unknown.is_empty(), "{unknown:#?}");
4374 }
4375
4376 #[test]
4379 fn unknown_props_warn_once_in_lua_spelling() {
4380 let mut ext = LuaExtension::from_source(
4381 "typos",
4382 r#"
4383 function view(env)
4384 return column { pad = 8,
4385 row { hoverBg = 0x333333ff, width = 10, height = 10 },
4386 row { hoverBg = 0x333333ff, width = 10, height = 10 },
4387 row { colour = 0x333333ff, width = 10, height = 10 },
4388 }
4389 end
4390 "#,
4391 )
4392 .unwrap();
4393 let mut core = Core::new();
4394 frame(&mut core, &mut ext);
4395 let mut warned: Vec<String> = core
4396 .take_warnings()
4397 .into_iter()
4398 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4399 .map(|w| w.message)
4400 .collect();
4401 warned.sort();
4402 assert_eq!(warned.len(), 2, "one per name, not per node: {warned:#?}");
4403 assert!(
4404 warned[1].contains("`hoverBg` is not a prop of box")
4405 && warned[1].contains("did you mean `hover_bg`?"),
4406 "{warned:#?}"
4407 );
4408 assert!(warned[0].contains("`colour`") && !warned[0].contains("did you mean"));
4410 frame(&mut core, &mut ext);
4412 assert!(core.take_warnings().is_empty());
4413 }
4414
4415 #[test]
4419 fn a_text_warns_about_the_rows_it_does_not_read() {
4420 let mut ext = LuaExtension::from_source(
4421 "textrows",
4422 r#"
4423 function view(env)
4424 return column { pad = 8,
4425 text("hi", { live = "polite", label = "x", on_click = "go", size = 14, line_height = 20, max_lines = 2 }),
4426 text({ "a", { "b", bold = true, bg = 0x00ff00ff } }, { color = 0xff0000ff }),
4427 }
4428 end
4429 "#,
4430 )
4431 .unwrap();
4432 let mut core = Core::new();
4433 frame(&mut core, &mut ext);
4434 let mut warned: Vec<String> = core
4435 .take_warnings()
4436 .into_iter()
4437 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4438 .map(|w| w.message)
4439 .collect();
4440 warned.sort();
4441 assert_eq!(warned.len(), 3, "{warned:#?}");
4442 for (w, name) in warned.iter().zip(["label", "live", "on_click"]) {
4443 assert!(
4444 w.contains(&format!("`{name}`")) && w.contains("not one text reads"),
4445 "{w}"
4446 );
4447 assert!(w.contains("`max_lines`"), "names the rows it reads: {w}");
4448 }
4449 }
4450
4451 #[test]
4458 fn size_and_radius_land_first_whatever_the_order() {
4459 let lua = Lua::new();
4460 for src in [
4461 "return { radius = 8, radius_tl = 2, size = 20, color = 0xff0000ff, line_height = 30 }",
4462 "return { line_height = 30, color = 0xff0000ff, size = 20, radius_tl = 2, radius = 8 }",
4463 ] {
4464 let t = eval_table(&lua, src);
4465 let p = parse_props(&t, false, &mut test_refs()).unwrap();
4466 assert_eq!(p.spec.style.radius, [2.0, 8.0, 8.0, 8.0], "{src}");
4467 assert_eq!(p.style.size, 20.0, "{src}");
4468 assert_eq!(p.style.line_height, 30.0, "the row over size's own: {src}");
4469 assert_eq!(p.style.color, Some(Color::hex(0xff0000ff)), "{src}");
4470 }
4471 }
4472
4473 #[test]
4478 fn the_folded_check_warns_as_the_walk_did() {
4479 let mut ext = LuaExtension::from_source(
4480 "folded",
4481 r#"
4482 function view(env)
4483 return column { bogus_box = 1,
4484 row { bogus_row = 1 },
4485 grid { bogus_grid = 1 },
4486 text("t", { bogus_text = 1, on_click = "go" }),
4487 image { id = 0, bogus_image = 1 },
4488 }
4489 end
4490 "#,
4491 )
4492 .unwrap();
4493 let mut core = Core::new();
4494 frame(&mut core, &mut ext);
4495 let mut warned: Vec<String> = core
4496 .take_warnings()
4497 .into_iter()
4498 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4499 .map(|w| {
4500 [
4501 "bogus_box",
4502 "bogus_row",
4503 "bogus_grid",
4504 "bogus_text",
4505 "on_click",
4506 "bogus_image",
4507 ]
4508 .into_iter()
4509 .find(|k| w.message.contains(&format!("`{k}`")))
4510 .unwrap_or("?")
4511 .to_string()
4512 })
4513 .collect();
4514 warned.sort();
4515 assert_eq!(
4516 warned,
4517 [
4518 "bogus_box",
4519 "bogus_grid",
4520 "bogus_image",
4521 "bogus_row",
4522 "bogus_text",
4523 "on_click"
4524 ]
4525 );
4526 let mut core = Core::new();
4528 core.set_diagnostics(false);
4529 frame(&mut core, &mut ext);
4530 assert!(core.take_warnings().is_empty());
4531 }
4532
4533 #[test]
4536 fn the_repeat_alias_does_not_warn() {
4537 let mut ext = LuaExtension::from_source(
4538 "alias",
4539 r#"
4540 function view(env)
4541 return column {
4542 row { width = 10, height = 10, keyframes = { { bg = 0x000000ff } },
4543 transition = 100, direction = "alternate" },
4544 }
4545 end
4546 "#,
4547 )
4548 .unwrap();
4549 let mut core = Core::new();
4550 frame(&mut core, &mut ext);
4551 assert!(core.take_warnings().is_empty());
4552 }
4553
4554 #[test]
4556 fn unknown_props_stay_quiet_with_diagnostics_off() {
4557 let mut ext = LuaExtension::from_source(
4558 "quiet",
4559 r#"
4560 function view(env)
4561 return column { hoverBg = 0x333333ff }
4562 end
4563 "#,
4564 )
4565 .unwrap();
4566 let mut core = Core::new();
4567 core.set_diagnostics(false);
4568 frame(&mut core, &mut ext);
4569 assert!(core.take_warnings().is_empty());
4570 }
4571
4572 #[test]
4575 fn tooltip_prop_is_hover_gated() {
4576 let mut ext = LuaExtension::from_source(
4577 "tip",
4578 r#"
4579 function view(env)
4580 return column { pad = 10,
4581 row { width = 100, height = 40, bg = 0x333333ff, tooltip = "a long hint" },
4582 }
4583 end
4584 "#,
4585 )
4586 .unwrap();
4587 let mut core = Core::new();
4588 let idle = frame(&mut core, &mut ext);
4589 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
4590 let hovered = frame(&mut core, &mut ext);
4591 assert!(
4592 hovered > idle + 5,
4593 "hover should add the tooltip's quads ({idle} -> {hovered})"
4594 );
4595 core.handle_input(InputEvent::CursorLeft);
4596 assert_eq!(frame(&mut core, &mut ext), idle);
4597 }
4598
4599 #[test]
4605 fn the_stock_button_admits_the_access_rows() {
4606 let mut ext = LuaExtension::from_source(
4607 "button",
4608 r#"
4609 function view(env)
4610 return column { pad = 10, gap = 4,
4611 button { label = "go", on_click = "go", description = "Starts the run" },
4612 button { label = "Stop the run", text = "stop", on_click = "stop",
4613 disabled = true, tooltip = "Nothing is running" },
4614 button { label = "x", on_click = "x", radius = 12, hoverBg = 0x333333ff },
4615 }
4616 end
4617 "#,
4618 )
4619 .unwrap();
4620 let mut core = Core::new();
4621 frame(&mut core, &mut ext);
4622 let tree = core.access_tree().clone();
4623 let named = |n: &str| {
4624 tree.nodes
4625 .iter()
4626 .find(|node| node.name.as_deref() == Some(n))
4627 };
4628 let go = named("go").expect("the button, named by its label");
4629 assert_eq!(go.role, kui_core::Role::Button);
4630 assert_eq!(go.description.as_deref(), Some("Starts the run"));
4631 assert!(!go.disabled);
4632 let stop = named("Stop the run").expect("named past its text");
4633 assert_eq!(stop.description.as_deref(), Some("Nothing is running"));
4634 assert!(stop.disabled);
4635 assert_eq!(tree.nodes.len(), 4, "window and three buttons");
4636 let ws = core.take_warnings();
4637 assert_eq!(ws.len(), 2, "{ws:?}");
4638 let radius = ws
4639 .iter()
4640 .find(|w| w.message.contains("`radius`"))
4641 .expect("a row the button does not read");
4642 assert!(
4643 radius
4644 .message
4645 .contains("is a prop, but not one button reads")
4646 );
4647 assert!(radius.message.contains("`description`"));
4648 let hover = ws
4651 .iter()
4652 .find(|w| w.message.contains("hoverBg"))
4653 .expect("a misspelling");
4654 assert!(hover.message.contains("is not a prop of button"));
4655 assert!(!hover.message.contains("did you mean"));
4656 }
4657
4658 #[test]
4663 fn an_accent_button_takes_the_colour_the_host_pushed() {
4664 let mut ext = LuaExtension::from_source(
4665 "accent",
4666 r#"
4667 function view(env)
4668 return column { pad = 10,
4669 button { label = "go", on_click = "go", accent = true },
4670 }
4671 end
4672 "#,
4673 )
4674 .unwrap();
4675 let mut bg = |core: &mut Core| {
4676 frame(core, &mut ext);
4677 core.output().0.quads[0].color
4678 };
4679 let mut core = Core::new();
4680 assert_eq!(
4681 bg(&mut core),
4682 kui_core::Color::rgb8(0x3b, 0x5b, 0xd4),
4683 "no accent pushed, the stock button"
4684 );
4685 assert!(
4686 core.take_warnings().is_empty(),
4687 "and a row the button reads"
4688 );
4689
4690 let accent = kui_core::Color::hex(0x007affff);
4691 core.env.system.accent = Some(accent);
4692 assert_eq!(bg(&mut core), accent);
4693 }
4694
4695 #[test]
4699 fn semantics_reach_the_access_tree() {
4700 let mut ext = LuaExtension::from_source(
4701 "a11y",
4702 r#"
4703 function view(env)
4704 return column { pad = 10,
4705 row { key = "save", on_click = "save", label = "Save", width = 20, height = 20 },
4706 row { key = "check", role = "checkbox", checked = true, text("Remember") },
4707 row { key = "vol", role = "slider", label = "Volume",
4708 value_now = 3, value_min = 0, value_max = 10 },
4709 row { key = "art", role = "none", on_click = "art", text("Art") },
4710 row { key = "tip", tooltip = "more here", on_click = "t", text("Tip") },
4711 row { key = "tabs", role = "tabList",
4712 row { key = "t0", role = "tab", text("General") },
4713 row { key = "t1", role = "tab", selected = true, text("Network") },
4714 },
4715 row { key = "adv", on_click = "adv", expanded = "collapsed",
4716 label = "Advanced" },
4717 }
4718 end
4719 "#,
4720 )
4721 .unwrap();
4722 let mut core = Core::new();
4723 frame(&mut core, &mut ext);
4724 let tree = core.access_tree().clone();
4725 let named = |n: &str| {
4726 tree.nodes
4727 .iter()
4728 .find(|node| node.name.as_deref() == Some(n))
4729 .cloned()
4730 };
4731 let save = named("Save").expect("labelled button");
4732 assert_eq!(save.role, kui_core::Role::Button);
4733 assert_eq!(save.origin, OriginId(1));
4734 let check = named("Remember").expect("checkbox named by its text");
4735 assert_eq!(check.role, kui_core::Role::Checkbox);
4736 assert_eq!(check.checked, Some(true));
4737 let vol = named("Volume").expect("slider");
4738 assert_eq!(
4739 (vol.number, vol.min, vol.max),
4740 (Some(3.0), Some(0.0), Some(10.0))
4741 );
4742 assert!(named("Art").is_none(), "role none hides a would-be button");
4743 let tip = named("Tip").expect("button");
4744 assert_eq!(tip.description.as_deref(), Some("more here"));
4745 let (t0, t1) = (named("General").unwrap(), named("Network").unwrap());
4748 assert_eq!((t0.selected, t1.selected), (Some(false), Some(true)));
4749 assert_eq!((t0.pos_in_set, t1.pos_in_set), (Some(0), Some(1)));
4750 let tabs = tree
4751 .nodes
4752 .iter()
4753 .find(|n| n.role == kui_core::Role::TabList)
4754 .expect("tab list");
4755 assert_eq!(tabs.set_size, Some(2));
4756 let adv = named("Advanced").expect("disclosure");
4758 assert_eq!(adv.expanded, Some(false));
4759
4760 let evs = core.handle_input(InputEvent::Access(kui_core::AccessRequest {
4761 key: save.key,
4762 action: kui_core::AccessAction::Click,
4763 value: None,
4764 anchor: None,
4765 focus: None,
4766 }));
4767 assert_eq!(evs.len(), 1);
4768 assert_eq!(evs[0].origin, OriginId(1));
4769 assert_eq!(evs[0].payload.as_str(), Some("save"));
4770 }
4771
4772 #[test]
4776 fn a_custom_editor_in_lua_reaches_the_access_tree() {
4777 let mut ext = LuaExtension::from_source(
4778 "ed",
4779 r#"
4780 function view(env)
4781 return column { key = "ed", role = "multilineTextInput", label = "Doc",
4782 on_key = "keys",
4783 row { role = "line", selection_anchor = 1, text("ab"), text("cd") },
4784 row { role = "line", caret = 2, text("ef") },
4785 }
4786 end
4787 "#,
4788 )
4789 .unwrap();
4790 let mut core = Core::new();
4791 frame(&mut core, &mut ext);
4792 let tree = core.access_tree().clone();
4793 let ed = tree
4794 .nodes
4795 .iter()
4796 .find(|n| n.name.as_deref() == Some("Doc"))
4797 .expect("the sink is the editor")
4798 .clone();
4799 assert_eq!(ed.role, kui_core::Role::MultilineTextInput);
4800 assert_eq!(ed.value.as_deref(), Some("abcd\nef"));
4801 assert_eq!(ed.runs.len(), 3);
4802 assert_eq!(ed.runs[1].text, "cd\n");
4803 assert_eq!(ed.caret, Some(7));
4804 assert_eq!(ed.selection, Some((1, 7)));
4805 let (a, f) = (ed.anchor.unwrap(), ed.focus.unwrap());
4806 assert_eq!((a.run, a.character), (ed.runs[0].key, 1));
4807 assert_eq!((f.run, f.character), (ed.runs[2].key, 2));
4808
4809 let evs = core.handle_input(InputEvent::Access(
4810 kui_core::AccessRequest::new(ed.key, kui_core::AccessAction::SetTextSelection)
4811 .with_selection(
4812 kui_core::TextPos {
4813 run: ed.runs[1].key,
4814 character: 1,
4815 },
4816 kui_core::TextPos {
4817 run: ed.runs[2].key,
4818 character: 0,
4819 },
4820 ),
4821 ));
4822 assert_eq!(evs.len(), 1);
4823 assert_eq!(evs[0].origin, OriginId(1));
4824 let p = &evs[0].payload;
4825 assert_eq!(p.get_str("action"), Some("setTextSelection"));
4826 let at = |k: &str, f: &str| p.get(k).and_then(|v| v.get(f)).and_then(Value::as_int);
4827 assert_eq!(
4828 (at("anchor", "line"), at("anchor", "offset")),
4829 (Some(0), Some(3))
4830 );
4831 assert_eq!(
4832 (at("focus", "line"), at("focus", "offset")),
4833 (Some(1), Some(0))
4834 );
4835 assert_eq!(p.get_str("tag"), Some("keys"));
4836 }
4837
4838 #[test]
4841 fn keep_tab_hands_a_fields_tab_to_the_sink_above() {
4842 let mut ext = LuaExtension::from_source(
4843 "keep_tab",
4844 r#"
4845 function view(env)
4846 return column { on_key = "shell",
4847 edit { key = "f", initial = "ab", autofocus = true, keep_tab = true, width = 200 },
4848 }
4849 end
4850 "#,
4851 )
4852 .unwrap();
4853 let mut core = Core::new();
4854 frame(&mut core, &mut ext);
4855 frame(&mut core, &mut ext);
4856 let focused = core.focus();
4857 let events = core.handle_input(InputEvent::KeyDown(kui_core::KeyPress::new(
4858 kui_core::KeyCode::Tab,
4859 kui_core::KeyMods::default(),
4860 )));
4861 assert_eq!(events.len(), 1);
4862 assert_eq!(events[0].payload.get_str("code"), Some("tab"));
4863 assert_eq!(events[0].payload.get_str("tag"), Some("shell"));
4864 core.handle_input(InputEvent::Key(kui_core::EditKey::Tab, Default::default()));
4865 assert_eq!(core.focus(), focused, "focus stays in the field");
4866 }
4867
4868 #[test]
4871 fn edit_text_round_trips_through_events() {
4872 let mut ext = LuaExtension::from_source(
4873 "edit",
4874 r#"
4875 pending = nil
4876 seen = nil
4877 by_label = nil
4878 window = nil
4879 function view(env)
4880 if pending then seen = env.edit_text(pending) end
4881 -- AR26: by the label its `key` declares, like every
4882 -- query beside it; a label nothing declared is nil.
4883 by_label = env.edit_text("note")
4884 nothing = env.edit_text("nope")
4885 return column {
4886 edit { key = "note", initial = "hi", autofocus = true, width = 200 },
4887 }
4888 end
4889 function on_event(ev)
4890 if ev.kind == "changed" then pending = ev.node_key; window = ev.window end
4891 end
4892 "#,
4893 )
4894 .unwrap();
4895 let mut core = Core::new();
4896 frame(&mut core, &mut ext);
4897 let events = core.handle_input(InputEvent::Text("!".into()));
4898 assert_eq!(
4899 events.len(),
4900 1,
4901 "typing into the autofocused editor emits one event"
4902 );
4903 assert_eq!(events[0].kind(), Some("changed"));
4904 for ev in &events {
4905 ext.on_event(ev);
4906 }
4907 frame(&mut core, &mut ext);
4908 let seen: Option<String> = ext.lua.globals().get("seen").unwrap();
4910 assert_eq!(seen.as_deref(), Some("hi!"));
4911 let by_label: Option<String> = ext.lua.globals().get("by_label").unwrap();
4912 assert_eq!(by_label.as_deref(), Some("hi!"), "the same text by label");
4913 let nothing: Option<String> = ext.lua.globals().get("nothing").unwrap();
4914 assert_eq!(nothing, None);
4915 let window: Option<i64> = ext.lua.globals().get("window").unwrap();
4918 assert_eq!(window, Some(0));
4919 }
4920
4921 #[test]
4926 fn a_dropdown_opens_the_cores_menu_and_hears_the_choice() {
4927 let mut ext = LuaExtension::from_source(
4928 "dd",
4929 r#"
4930 current = 1
4931 heard = {}
4932 function view(env)
4933 return column { pad = 10,
4934 dropdown { label = "language",
4935 options = { "English", "Deutsch", { label = "Latin", id = "la" } },
4936 current = current },
4937 }
4938 end
4939 function on_event(ev)
4940 heard[#heard + 1] = ev.kind
4941 if ev.kind == "menu" then
4942 if ev.item == "Deutsch" then current = 2 end
4943 if ev.item == "la" then current = 3 end
4944 end
4945 end
4946 "#,
4947 )
4948 .unwrap();
4949 let mut core = Core::new();
4950 frame(&mut core, &mut ext);
4951 let field = core
4952 .key_of("language")
4953 .expect("the field is keyed by its label");
4954 let node = |core: &mut Core| {
4955 core.access_tree()
4956 .nodes
4957 .iter()
4958 .find(|n| n.key == field)
4959 .cloned()
4960 .unwrap()
4961 };
4962 assert_eq!(node(&mut core).description.as_deref(), Some("English"));
4963 let events = core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4964 field,
4965 kui_core::AccessAction::Click,
4966 )));
4967 assert!(
4968 events.is_empty(),
4969 "the field's click is the core's: {events:?}"
4970 );
4971 let menu = core.menu().expect("the menu opened");
4972 assert_eq!(menu.target, field);
4973 assert_eq!(menu.items.len(), 3);
4974 assert!(menu.items[0].checked);
4975 frame(&mut core, &mut ext);
4976 let events = core.activate_menu_item(2).expect("the row is enabled");
4978 assert_eq!(events.len(), 1);
4979 for ev in &events {
4980 ext.on_event(ev);
4981 }
4982 frame(&mut core, &mut ext);
4983 assert_eq!(node(&mut core).description.as_deref(), Some("Latin"));
4984 let heard: Vec<String> = ext
4985 .lua
4986 .globals()
4987 .get::<Table>("heard")
4988 .unwrap()
4989 .sequence_values()
4990 .collect::<mlua::Result<_>>()
4991 .unwrap();
4992 assert_eq!(heard, vec!["menu".to_string()]);
4993 assert!(core.menu().is_none());
4994 let mut ext = LuaExtension::from_source(
4996 "bad",
4997 r#"function view(env) return dropdown { label = "x" } end"#,
4998 )
4999 .unwrap();
5000 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
5001 ui.set_origin(OriginId(1));
5002 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
5003 assert!(err.contains("needs options"), "{err}");
5004 ui.finish();
5005 let mut ext = LuaExtension::from_source(
5006 "bad",
5007 r#"function view(env) return dropdown { label = "x", options = { "a" }, current = 0 } end"#,
5008 )
5009 .unwrap();
5010 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
5011 ui.set_origin(OriginId(1));
5012 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
5013 assert!(err.contains("index from 1"), "{err}");
5014 ui.finish();
5015 let refused = |core: &mut Core, src: &str| {
5018 let mut ext = LuaExtension::from_source("bad", src).unwrap();
5019 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
5020 ui.set_origin(OriginId(1));
5021 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
5022 ui.finish();
5023 err
5024 };
5025 let err = refused(
5026 &mut core,
5027 r#"function view(env) return dropdown { options = { "a" } } end"#,
5028 );
5029 assert!(err.contains("dropdown needs a label"), "{err}");
5030 let err = refused(
5031 &mut core,
5032 r#"function view(env) return dropdown { label = "x", options = {} } end"#,
5033 );
5034 assert!(err.contains("at least one option"), "{err}");
5035 }
5036
5037 #[test]
5046 fn a_stray_key_inside_a_submenu_warns_and_an_option_takes_no_items() {
5047 let mut ext = LuaExtension::from_source(
5048 "sub",
5049 r#"
5050 function view(env)
5051 return column {
5052 menu_bar { menu = { { label = "View", items = {
5053 { label = "Sort by", items = { { label = "Name", disabled = true } } },
5054 } } } },
5055 dropdown { label = "sort",
5056 options = { "Name", { label = "Date", items = { { label = "Newest" } } } } },
5057 }
5058 end
5059 "#,
5060 )
5061 .unwrap();
5062 let mut core = Core::new();
5063 frame(&mut core, &mut ext);
5064 let warned = core.take_warnings();
5065 let messages: Vec<&str> = warned.iter().map(|w| w.message.as_str()).collect();
5066 assert_eq!(warned.len(), 2, "{messages:?}");
5067 assert!(
5068 messages[0].contains("`disabled` is not a key of a menu item"),
5069 "{messages:?}"
5070 );
5071 assert!(
5072 messages[1].contains("`items` on a select's option is dropped"),
5073 "{messages:?}"
5074 );
5075 let field = core.key_of("sort").unwrap();
5077 core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
5078 field,
5079 kui_core::AccessAction::Click,
5080 )));
5081 let menu = core.menu().expect("the dropdown opened");
5082 assert_eq!(menu.items.len(), 2);
5083 assert!(
5084 menu.items.iter().all(|i| !i.has_submenu()),
5085 "{:?}",
5086 menu.items
5087 );
5088 }
5089
5090 #[test]
5091 fn a_dropdowns_bad_rows_and_current_are_warned_and_a_disabled_option_is_refused() {
5092 let mut ext = LuaExtension::from_source(
5093 "dd",
5094 r#"
5095 current = 4
5096 function view(env)
5097 return column { pad = 10,
5098 dropdown { label = "language",
5099 options = { "English", { role = "separator" },
5100 { label = "Latin", id = "la", disabled = true } },
5101 current = current },
5102 }
5103 end
5104 "#,
5105 )
5106 .unwrap();
5107 let mut core = Core::new();
5108 frame(&mut core, &mut ext);
5109 let field = core.key_of("language").unwrap();
5110 let node = |core: &mut Core| {
5111 core.access_tree()
5112 .nodes
5113 .iter()
5114 .find(|n| n.key == field)
5115 .cloned()
5116 .unwrap()
5117 };
5118 assert_eq!(node(&mut core).description.as_deref(), Some(""));
5119 let warned = core.take_warnings();
5120 let codes: Vec<&str> = warned.iter().map(|w| w.code).collect();
5121 assert_eq!(
5122 codes,
5123 [
5124 kui_core::diag::UNKNOWN_PROP,
5125 kui_core::diag::SELECT_CURRENT_IGNORED
5126 ],
5127 "{warned:?}"
5128 );
5129 assert!(
5130 warned[0]
5131 .message
5132 .contains("`disabled` is not a key of a menu item")
5133 && warned[0].message.contains("`enabled: false`"),
5134 "{}",
5135 warned[0].message
5136 );
5137 assert_eq!(warned[1].key, field);
5138 assert!(
5139 warned[1].message.contains("names option 3 counted from 0")
5140 && warned[1].message.contains("the field has 3 options"),
5141 "{}",
5142 warned[1].message
5143 );
5144 ext.lua.globals().set("current", 2).unwrap();
5146 let mut core = Core::new();
5147 frame(&mut core, &mut ext);
5148 let warned = core.take_warnings();
5149 assert!(
5150 warned
5151 .iter()
5152 .any(|w| w.code == kui_core::diag::SELECT_CURRENT_IGNORED
5153 && w.message
5154 .contains("option 1 counted from 0, which is a separator")),
5155 "{warned:?}"
5156 );
5157 let mut ext = LuaExtension::from_source(
5160 "dd",
5161 r#"
5162 function view(env)
5163 return column { pad = 10,
5164 dropdown { label = "language",
5165 options = { "English", { label = "Latin", id = "la", enabled = false } },
5166 current = 1 },
5167 }
5168 end
5169 "#,
5170 )
5171 .unwrap();
5172 let mut core = Core::new();
5173 frame(&mut core, &mut ext);
5174 let field = core.key_of("language").unwrap();
5175 core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
5176 field,
5177 kui_core::AccessAction::Click,
5178 )));
5179 assert!(core.menu().is_some());
5180 assert_eq!(core.activate_menu_item(1), None, "refused");
5181 assert!(core.menu().is_some(), "the menu stays open");
5182 let events = core
5183 .activate_menu_item(0)
5184 .expect("the enabled row is taken");
5185 assert_eq!(events.len(), 1);
5186 assert!(core.menu().is_none());
5187 assert!(core.take_warnings().is_empty());
5188 }
5189
5190 #[test]
5193 fn a_grid_lines_its_rows_cells_up() {
5194 let mut ext = LuaExtension::from_source(
5195 "grid",
5196 r#"
5197 function view(env)
5198 return grid { key = "t", width = 300,
5199 row { key = "r1", width = "grow", gap = 8,
5200 text("ab", { size = 12 }),
5201 column { key = "b1", width = 10, height = 10, bg = 0xff0000ff },
5202 column { key = "c1", width = "grow", height = 10, bg = 0x00ff00ff },
5203 },
5204 row { key = "r2", width = "grow", gap = 8,
5205 text("abcdef", { size = 12 }),
5206 column { key = "b2", width = 50, height = 10, bg = 0xff0000ff },
5207 column { key = "c2", width = 20, height = 10, bg = 0x00ff00ff },
5208 },
5209 }
5210 end
5211 "#,
5212 )
5213 .unwrap();
5214 let mut core = Core::new();
5215 core.set_inspect(true);
5216 frame(&mut core, &mut ext);
5217 let rect = |core: &mut Core, label: &str| {
5218 let key = core.key_of(label).unwrap_or_else(|| panic!("{label}"));
5219 core.nodes()
5220 .iter()
5221 .find(|n| n.key == key)
5222 .map(|n| n.rect)
5223 .unwrap_or_else(|| panic!("{label}"))
5224 };
5225 let (b1, b2) = (rect(&mut core, "b1"), rect(&mut core, "b2"));
5226 let (c1, c2) = (rect(&mut core, "c1"), rect(&mut core, "c2"));
5227 assert_eq!(
5228 b1.x, b2.x,
5229 "the fixed column starts after the longest label"
5230 );
5231 assert_eq!(b1.w, 50.0, "the fixed column is its widest cell");
5232 assert_eq!(b2.w, 50.0);
5233 assert_eq!(c1.x, c2.x);
5234 assert_eq!(c1.w, c2.w, "the grow column is one width in both rows");
5235 assert_eq!(c1.x + c1.w, 300.0, "and it takes the rest");
5236 let t = core.key_of("t").unwrap();
5237 assert!(
5238 core.nodes().iter().any(|n| n.key == t && n.table),
5239 "the grid is a table"
5240 );
5241 assert!(core.take_warnings().is_empty());
5242 }
5243
5244 #[test]
5249 fn set_edit_text_by_label_seeds_the_editor_the_next_frame_declares() {
5250 let mut ext = LuaExtension::from_source(
5251 "edit",
5252 r#"
5253 frames = 0
5254 function view(env)
5255 frames = frames + 1
5256 if frames == 1 then return column {} end
5257 if frames == 2 then
5258 env.set_edit_text("note", "from the model")
5259 end
5260 return column {
5261 edit { key = "note", initial = "ignored", width = 200 },
5262 }
5263 end
5264 "#,
5265 )
5266 .unwrap();
5267 let mut core = Core::new();
5268 frame(&mut core, &mut ext);
5272 frame(&mut core, &mut ext);
5273 let key = core.key_of("note").expect("the view declared the editor");
5274 assert_eq!(core.edit_text(key).as_deref(), Some("from the model"));
5275 let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
5276 assert!(!codes.contains(&"edit-text-without-editor"), "{codes:?}");
5277 core.set_focus(Some(key));
5280 core.handle_input(InputEvent::Text("!".into()));
5281 frame(&mut core, &mut ext);
5282 assert_eq!(core.edit_text(key).as_deref(), Some("from the model!"));
5283 }
5284
5285 #[test]
5290 fn a_lua_key_sink_hears_press_and_release() {
5291 use kui_core::{KeyCode, KeyMods, KeyPress};
5292 let mut ext = LuaExtension::from_source(
5293 "game",
5294 r#"
5295 log = {}
5296 function view(env)
5297 return column { key = "world", on_key = "keys", key_up = true,
5298 key_focus = true, width = 400, height = 300 }
5299 end
5300 function on_event(ev)
5301 if ev.kind == "key" then
5302 log[#log + 1] = ev.phase .. ":" .. ev.code ..
5303 "@" .. ev.physical ..
5304 ":" .. tostring(ev.text) .. ":" .. tostring(ev.tag)
5305 end
5306 end
5307 "#,
5308 )
5309 .unwrap();
5310 let mut core = Core::new();
5311 frame(&mut core, &mut ext);
5312 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
5313 for e in core.handle_input(ev) {
5314 ext.on_event(&e);
5315 }
5316 };
5317 let w = || KeyPress::new(KeyCode::Char('w'), KeyMods::default()).with_text("w");
5318 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
5319 feed(&mut core, &mut ext, InputEvent::KeyUp(w()));
5320 let ru = || {
5325 KeyPress::from_layout(KeyCode::Char('ц'), KeyCode::Char('w'), KeyMods::default())
5326 .with_text("ц")
5327 };
5328 feed(&mut core, &mut ext, InputEvent::KeyDown(ru()));
5329 feed(&mut core, &mut ext, InputEvent::KeyUp(ru()));
5330 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
5332 core.set_focus(None);
5333 for e in core.take_pending_events() {
5334 ext.on_event(&e);
5335 }
5336 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
5337 assert_eq!(
5338 log,
5339 [
5340 "down:w@w:w:keys",
5341 "up:w@w:nil:keys",
5343 "down:w@w:ц:keys",
5346 "up:w@w:nil:keys",
5347 "down:w@w:w:keys",
5348 "up:w@w:nil:keys",
5349 ]
5350 );
5351 }
5352
5353 #[test]
5359 fn a_path_keeps_the_node_scale_beside_its_own_rotate() {
5360 let mut ext = LuaExtension::from_source(
5361 "p",
5362 r#"
5363 function view(env)
5364 return column {
5365 path { key = "p", d = "M0 0 H10 V10 H0 Z", bg = 0xffffffff,
5366 rotate = 0.25, scale = 2 },
5367 }
5368 end
5369 "#,
5370 )
5371 .unwrap();
5372 let mut core = Core::new();
5373 core.set_inspect(true);
5374 frame(&mut core, &mut ext);
5375 frame(&mut core, &mut ext);
5376 let node = core
5377 .nodes()
5378 .iter()
5379 .find(|n| n.kind == kui_core::NodeKind::Path)
5380 .cloned()
5381 .expect("the path");
5382 assert_eq!(node.scale, 2.0, "the node's scale stays");
5383 assert_eq!(node.rotate, 0.0, "the turn is the path's own");
5384 }
5385
5386 #[test]
5387 fn a_script_reads_the_frame_clock() {
5388 let mut ext = LuaExtension::from_source(
5389 "clock",
5390 r#"
5391 function view(env)
5392 if env.now < 2 then
5393 return column { key = "toast", width = 10, height = 10 }
5394 end
5395 return column {}
5396 end
5397 "#,
5398 )
5399 .unwrap();
5400 let mut core = Core::new();
5401 core.set_time(1.5);
5402 frame(&mut core, &mut ext);
5403 assert!(core.key_of("toast").is_some(), "before the deadline");
5404 core.set_time(2.5);
5405 frame(&mut core, &mut ext);
5406 assert!(core.key_of("toast").is_none(), "after it");
5407 }
5408
5409 #[test]
5412 fn a_script_asks_for_a_frame_at_a_time() {
5413 let mut ext = LuaExtension::from_source(
5414 "toast",
5415 r#"
5416 function view(env)
5417 env.request_frame_at(env.now + 3)
5418 return column { key = "toast", width = 10, height = 10 }
5419 end
5420 "#,
5421 )
5422 .unwrap();
5423 let mut core = Core::new();
5424 core.set_time(1.0);
5425 frame(&mut core, &mut ext);
5426 assert_eq!(core.next_frame_at(), Some(4.0));
5427 assert!(!core.animating());
5428 }
5429
5430 #[test]
5433 fn a_script_names_the_exit_at_the_removal() {
5434 let mut ext = LuaExtension::from_source(
5435 "throw",
5436 r##"
5437 function view(env)
5438 if env.now < 1 then
5439 return column { pad = 20,
5440 column { key = "card", width = 40, height = 40, bg = "#ffffff",
5441 transition = 1000, easing = "linear", exit = { opacity = 0 } } }
5442 end
5443 if env.now < 1.1 then env.exit_with("card", { dx = 400, opacity = 0 }) end
5444 return column { pad = 20 }
5445 end
5446 "##,
5447 )
5448 .unwrap();
5449 let mut core = Core::new();
5450 core.set_time(0.0);
5451 frame(&mut core, &mut ext);
5452 core.set_time(1.0);
5453 frame(&mut core, &mut ext);
5454 core.set_time(1.5);
5455 frame(&mut core, &mut ext);
5456 let xs: Vec<f32> = kui_core::testing::solids(&mut core)
5457 .iter()
5458 .map(|q| q.rect.x)
5459 .collect();
5460 assert!(
5461 xs.iter().any(|x| (x - 220.0).abs() < 1.0),
5462 "halfway along the throw: {xs:?}"
5463 );
5464 }
5465
5466 #[test]
5470 fn a_lua_editor_draws_its_caret_on_the_phase_the_host_sets() {
5471 let mut ext = LuaExtension::from_source(
5472 "ed",
5473 r#"
5474 function view(env)
5475 local caret = env.caret_visible and row { key = "caret", width = 2, height = 16 } or nil
5476 return column { key = "editor", on_key = "ed", key_focus = true,
5477 role = "multilineTextInput", label = "buf",
5478 row { role = "line", caret = 3, text("let value", { family = "mono", size = 14 }), caret },
5479 }
5480 end
5481 "#,
5482 )
5483 .unwrap();
5484 let mut core = Core::new();
5485 frame(&mut core, &mut ext);
5486 assert!(
5487 core.key_of("caret").is_some(),
5488 "the on phase draws the caret"
5489 );
5490 assert!(core.has_caret(), "the caret row is a caret to blink");
5491 core.set_caret_visible(false);
5492 frame(&mut core, &mut ext);
5493 assert!(core.key_of("caret").is_none(), "the off phase draws none");
5494 assert!(
5495 core.has_caret(),
5496 "and the row stays, so the clock stays armed"
5497 );
5498 core.set_caret_visible(true);
5499 frame(&mut core, &mut ext);
5500 assert!(core.key_of("caret").is_some());
5501 }
5502
5503 #[test]
5508 fn a_lua_editors_solid_caret_arms_no_clock() {
5509 let mut ext = LuaExtension::from_source(
5510 "ed",
5511 r#"
5512 function view(env)
5513 return column { key = "editor", on_key = "ed", key_focus = true,
5514 role = "multilineTextInput", label = "buf",
5515 row { role = "line", caret = 3, caret_solid = true,
5516 text("let value", { family = "mono", size = 14 }) },
5517 }
5518 end
5519 "#,
5520 )
5521 .unwrap();
5522 let mut core = Core::new();
5523 frame(&mut core, &mut ext);
5524 assert!(!core.has_caret(), "a solid caret is not a caret to blink");
5525 assert!(core.ime_rect().is_some(), "and still the IME's anchor");
5526 let editor = core.key_of("editor").unwrap();
5527 assert_eq!(core.access_tree().get(editor).unwrap().caret, Some(3));
5528 assert!(
5529 core.warnings_raised().is_empty(),
5530 "{:?}",
5531 core.warnings_raised()
5532 );
5533 }
5534
5535 #[test]
5541 fn a_lua_editor_yanks_pastes_and_hears_where_a_press_landed() {
5542 use kui_core::testing::{press, release};
5543 use kui_core::{KeyCode, KeyMods, KeyPress, MenuAction, Vec2};
5544 let mut ext = LuaExtension::from_source(
5545 "ed",
5546 r#"
5547 yank = nil
5548 paste = false
5549 log = {}
5550 function view(env)
5551 if yank then env.set_clipboard(yank, nil); yank = nil end
5552 if secret then env.set_clipboard_secret(secret); secret = nil end
5553 -- Asked on every view until the answer lands: the core
5554 -- takes one ask at a time (AR34), so this is one paste.
5555 if paste then env.request_paste() end
5556 return column { key = "editor", on_key = "ed", on_drag = "sel",
5557 key_focus = true, role = "multilineTextInput", label = "buf",
5558 width = 400, height = 300,
5559 row { role = "line", height = 20, text("hello world", { family = "mono", size = 14 }) },
5560 row { role = "line", height = 20, text("second", { family = "mono", size = 14 }) },
5561 }
5562 end
5563 function on_event(ev)
5564 if ev.kind == "key" and ev.code == "y" then yank = "hello world" end
5565 if ev.kind == "key" and ev.code == "s" then secret = "hunter2" end
5566 if ev.kind == "key" and ev.code == "p" then paste = true end
5567 if ev.kind == "text" then
5568 paste = false
5569 local marks = (ev.concealed and ":concealed" or "") .. (ev.transient and ":transient" or "")
5570 log[#log + 1] = "text:" .. ev.text .. marks
5571 end
5572 if ev.kind == "drag" then
5573 log[#log + 1] = ev.phase .. ":" .. ev.line .. ":" .. ev.byte .. ":" .. ev.clicks
5574 end
5575 end
5576 "#,
5577 )
5578 .unwrap();
5579 let mut core = Core::new();
5580 frame(&mut core, &mut ext);
5581 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
5582 for e in core.handle_input(ev) {
5583 ext.on_event(&e);
5584 }
5585 };
5586 let key = |c| KeyPress::new(KeyCode::Char(c), KeyMods::default());
5587 feed(&mut core, &mut ext, InputEvent::KeyDown(key('y')));
5588 frame(&mut core, &mut ext);
5589 assert_eq!(
5590 core.take_menu_actions(),
5591 vec![MenuAction::SetClipboard {
5592 text: "hello world".into(),
5593 html: None
5594 }]
5595 );
5596 feed(&mut core, &mut ext, InputEvent::KeyDown(key('s')));
5598 frame(&mut core, &mut ext);
5599 assert_eq!(
5600 core.take_menu_actions(),
5601 vec![MenuAction::SetClipboardSecret {
5602 text: "hunter2".into()
5603 }]
5604 );
5605 feed(&mut core, &mut ext, InputEvent::KeyDown(key('p')));
5606 frame(&mut core, &mut ext);
5607 frame(&mut core, &mut ext);
5608 assert_eq!(
5609 core.take_menu_actions(),
5610 vec![MenuAction::Paste],
5611 "two views asked, one paste queued"
5612 );
5613 assert!(core.awaiting_paste());
5614 feed(
5616 &mut core,
5617 &mut ext,
5618 InputEvent::Commit("from the clipboard".into()),
5619 );
5620 assert!(!core.awaiting_paste());
5621 frame(&mut core, &mut ext);
5622 assert!(
5623 core.take_menu_actions().is_empty(),
5624 "answered: the script stopped asking"
5625 );
5626 feed(
5628 &mut core,
5629 &mut ext,
5630 InputEvent::Paste {
5631 text: "s3cret".into(),
5632 marks: kui_core::ClipboardMarks::SECRET,
5633 },
5634 );
5635 for e in press(&mut core, Vec2::new(390.0, 30.0)) {
5637 ext.on_event(&e);
5638 }
5639 for e in release(&mut core) {
5640 ext.on_event(&e);
5641 }
5642 for e in core.handle_input(InputEvent::mouse_down(2)) {
5643 ext.on_event(&e);
5644 }
5645 for e in release(&mut core) {
5646 ext.on_event(&e);
5647 }
5648 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
5649 assert_eq!(
5650 log,
5651 [
5652 "text:from the clipboard",
5653 "text:s3cret:concealed:transient",
5654 "start:1:6:1",
5655 "end:1:6:1",
5656 "start:1:6:2",
5657 "end:1:6:2",
5658 ]
5659 );
5660 }
5661
5662 #[test]
5666 fn audio_nodes_drive_playback_commands() {
5667 use kui_core::AudioCommand;
5668 let mut ext = LuaExtension::from_source(
5669 "audio",
5670 r#"
5671 playing = true
5672 function view(env)
5673 local items = {}
5674 if playing then
5675 items[1] = audio { src = SOUND, loop = true, volume = 0.5,
5676 key = "music", tag = { kind = "music" } }
5677 end
5678 return column { table.unpack(items) }
5679 end
5680 "#,
5681 )
5682 .unwrap();
5683 let mut core = Core::new();
5684 let sound = core.add_sound(vec![0; 8]);
5685 ext.lua
5686 .globals()
5687 .set("SOUND", sound.to_ffi() as i64)
5688 .unwrap();
5689 frame(&mut core, &mut ext);
5690 let cmds = core.take_audio_commands();
5691 assert!(
5692 matches!(
5693 cmds.as_slice(),
5694 [AudioCommand::Play { sound: s, looped: true, volume, .. }]
5695 if *s == sound && *volume == 0.5
5696 ),
5697 "{cmds:?}"
5698 );
5699 frame(&mut core, &mut ext);
5700 assert!(
5701 core.take_audio_commands().is_empty(),
5702 "re-declaring is silent"
5703 );
5704 ext.lua.globals().set("playing", false).unwrap();
5705 frame(&mut core, &mut ext);
5706 assert!(matches!(
5707 core.take_audio_commands().as_slice(),
5708 [AudioCommand::Stop { .. }]
5709 ));
5710 }
5711
5712 #[test]
5716 fn measure_and_layout_events_reach_scripts() {
5717 let mut ext = LuaExtension::from_source(
5718 "measure",
5719 r#"
5720 seen = nil
5721 function view(env)
5722 local plain = env.measure_text("hello world", { size = 14 })
5723 local node = env.measure_text(text("hello world", { size = 14 }))
5724 local rich = env.measure_text({ "hello ", { "world", bold = true } }, { size = 14 })
5725 local narrow = env.measure_text("hello world", { size = 14 }, plain.width / 2)
5726 assert(plain.width > 0 and plain.lines == 1)
5727 assert(node.width == plain.width and node.height == plain.height)
5728 assert(rich.width > 0)
5729 assert(narrow.lines > 1 and narrow.width <= plain.width / 2 + 0.5)
5730 return column {
5731 row { key = "panel", width = plain.width, height = 20, on_layout = "panel" },
5732 }
5733 end
5734 function on_event(ev)
5735 if ev.kind == "layout" then seen = ev end
5736 end
5737 "#,
5738 )
5739 .unwrap();
5740 let mut core = Core::new();
5741 frame(&mut core, &mut ext);
5742 let evs = core.take_pending_events();
5743 assert_eq!(evs.len(), 1, "one layout event on first sight");
5744 for ev in &evs {
5745 ext.on_event(ev);
5746 }
5747 let seen: Table = ext.lua.globals().get("seen").unwrap();
5748 assert_eq!(seen.get::<String>("tag").unwrap(), "panel");
5749 assert_eq!(seen.get::<f64>("h").unwrap(), 20.0);
5750 assert!(seen.get::<f64>("w").unwrap() > 0.0);
5751 assert_eq!(seen.get::<i64>("node_key").unwrap(), evs[0].key.0 as i64);
5752 frame(&mut core, &mut ext);
5754 assert!(core.take_pending_events().is_empty());
5755 }
5756
5757 #[test]
5761 fn scripts_queue_window_size_and_focus_requests() {
5762 let mut ext = LuaExtension::from_source(
5763 "win",
5764 r#"
5765 function view(env)
5766 env.set_window_size(env.window.id, 640, 480)
5767 env.focus_window(env.window.id)
5768 return column { width = "grow", height = "grow" }
5769 end
5770 "#,
5771 )
5772 .unwrap();
5773 let mut core = Core::new();
5774 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5775 ui.set_origin(OriginId(1));
5776 ext.view(&Slot::root(), &mut ui).unwrap();
5777 ui.finish();
5778 assert_eq!(
5779 core.take_window_commands(),
5780 vec![
5781 kui_core::WindowCommand::SetSize {
5782 window: WindowId::MAIN,
5783 size: Size::new(640.0, 480.0),
5784 },
5785 kui_core::WindowCommand::Focus(WindowId::MAIN),
5786 ]
5787 );
5788 assert!(core.take_window_commands().is_empty());
5789 assert_eq!(core.viewport(), Size::new(400.0, 200.0));
5790 }
5791
5792 #[test]
5798 fn scripts_reveal_and_move_scroll_offsets() {
5799 let mut ext = LuaExtension::from_source(
5800 "scroll",
5801 r#"
5802 rows, want, jump, seen = 20, nil, nil, nil
5803 function view(env)
5804 if want then env.reveal(want) end
5805 if jump then env.set_scroll(jump[1], jump[2], jump[3]) end
5806 want, jump = nil, nil
5807 local list = { key = "list", width = "grow", height = "grow",
5808 scroll_y = true }
5809 for i = 0, rows - 1 do
5810 list[#list + 1] = row { key = "row" .. i, width = "grow",
5811 height = 30, bg = 0x282840ff }
5812 end
5813 seen = env.scroll_offset(list_key)
5814 return column(list)
5815 end
5816 "#,
5817 )
5818 .unwrap();
5819 let list = Key::ROOT.str("list");
5821 let row = |i: usize| list.str(&format!("row{i}"));
5822 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
5823 let mut core = Core::new();
5824 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5825 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5826 ui.set_origin(OriginId(1));
5827 ext.view(&Slot::root(), &mut ui).unwrap();
5828 ui.finish();
5829 };
5830
5831 frame(&mut core, &mut ext);
5832 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
5833
5834 ext.lua.globals().set("want", row(15).0 as i64).unwrap();
5837 frame(&mut core, &mut ext);
5838 let after = core.scroll_offset(list);
5839 assert!(after.y > 0.0, "reveal moved nothing: {after:?}");
5840 assert!(after.y <= 15.0 * 30.0, "scrolled past row 15: {after:?}");
5841 frame(&mut core, &mut ext);
5843 assert_eq!(core.scroll_offset(list), after);
5844
5845 let seen: Table = ext.lua.globals().get("seen").unwrap();
5847 assert_eq!(seen.get::<f32>("y").unwrap(), after.y);
5848 assert_eq!(seen.get::<f32>("x").unwrap(), 0.0);
5849
5850 let jump = |ext: &LuaExtension, y: f64| {
5852 let t = ext.lua.create_table().unwrap();
5853 t.set(1, list.0 as i64).unwrap();
5854 t.set(2, 0.0).unwrap();
5855 t.set(3, y).unwrap();
5856 ext.lua.globals().set("jump", t).unwrap();
5857 };
5858 jump(&ext, 1e9);
5859 frame(&mut core, &mut ext);
5860 assert_eq!(core.scroll_offset(list).y, 400.0);
5861 jump(&ext, -1e9);
5862 frame(&mut core, &mut ext);
5863 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
5864
5865 jump(&ext, 120.0);
5867 frame(&mut core, &mut ext);
5868 ext.lua
5869 .globals()
5870 .set("want", Key::ROOT.str("ghost").0 as i64)
5871 .unwrap();
5872 frame(&mut core, &mut ext);
5873 assert_eq!(core.scroll_offset(list).y, 120.0);
5874 frame(&mut core, &mut ext);
5875 assert_eq!(core.scroll_offset(list).y, 120.0);
5876 }
5877
5878 #[test]
5882 fn scripts_read_the_pressed_state() {
5883 let mut ext = LuaExtension::from_source(
5884 "press",
5885 r#"
5886 function view(env)
5887 pressed = env.is_pressed(btn_key)
5888 hovered = env.is_hovered(btn_key)
5889 return column { key = "root", pad = 10,
5890 row { key = "btn", width = 100, height = 40,
5891 bg = 0x333333ff, on_click = "hit" },
5892 }
5893 end
5894 "#,
5895 )
5896 .unwrap();
5897 let btn = Key::ROOT.str("root").str("btn");
5898 ext.lua.globals().set("btn_key", btn.0 as i64).unwrap();
5899 let mut core = Core::new();
5900 let pressed = |ext: &LuaExtension| ext.lua.globals().get::<bool>("pressed").unwrap();
5901 let hovered = |ext: &LuaExtension| ext.lua.globals().get::<bool>("hovered").unwrap();
5902
5903 frame(&mut core, &mut ext);
5904 assert!(!pressed(&ext), "idle");
5905
5906 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
5908 frame(&mut core, &mut ext);
5909 assert!(hovered(&ext) && !pressed(&ext), "hover is not press");
5910
5911 core.handle_input(InputEvent::MouseDown {
5912 button: kui_core::MouseButton::Primary,
5913 clicks: 1,
5914 });
5915 frame(&mut core, &mut ext);
5916 assert!(pressed(&ext), "held down");
5917
5918 core.handle_input(InputEvent::MouseUp {
5922 button: kui_core::MouseButton::Primary,
5923 });
5924 frame(&mut core, &mut ext);
5925 assert!(!pressed(&ext), "released");
5926 }
5927
5928 #[test]
5933 fn scripts_read_the_drop_target() {
5934 let mut ext = LuaExtension::from_source(
5935 "drop",
5936 r#"
5937 function view(env)
5938 over = env.is_drop_target(zone_key)
5939 target = env.drop_target()
5940 return column { key = "root", pad = 10,
5941 row { key = "zone", width = 100, height = 40,
5942 bg = 0x333333ff, drop_bg = 0x335533ff, on_drop = "files" },
5943 }
5944 end
5945 "#,
5946 )
5947 .unwrap();
5948 let zone = Key::ROOT.str("root").str("zone");
5949 ext.lua.globals().set("zone_key", zone.0 as i64).unwrap();
5950 let mut core = Core::new();
5951 let over = |ext: &LuaExtension| ext.lua.globals().get::<bool>("over").unwrap();
5952 let target = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("target").unwrap();
5953
5954 frame(&mut core, &mut ext);
5955 assert!(!over(&ext) && target(&ext).is_none(), "idle");
5956
5957 let paths = vec!["/drop/1.txt".to_string()];
5958 let evs = core.handle_input(InputEvent::DragFiles {
5959 paths: paths.clone(),
5960 at: Vec2::new(50.0, 30.0),
5961 });
5962 assert_eq!(evs[0].payload.get_str("tag"), Some("files"));
5963 frame(&mut core, &mut ext);
5964 assert!(over(&ext), "the files are over the zone");
5965 assert_eq!(target(&ext), Some(zone.0 as i64));
5966 let (dl, _) = core.output();
5967 let lit = dl
5968 .quads
5969 .iter()
5970 .find(|q| q.rect.w == 100.0)
5971 .map(|q| q.color)
5972 .expect("zone quad");
5973 assert!(
5974 (lit.g - 0x55 as f32 / 255.0).abs() < 0.01,
5975 "drop_bg painted"
5976 );
5977
5978 core.handle_input(InputEvent::DropFiles {
5979 paths,
5980 at: Vec2::new(50.0, 30.0),
5981 });
5982 frame(&mut core, &mut ext);
5983 assert!(
5984 !over(&ext) && target(&ext).is_none(),
5985 "a drop ends the hover"
5986 );
5987 }
5988
5989 #[test]
6000 fn the_env_table_is_the_documented_env_shape() {
6001 let mut ext = LuaExtension::from_source(
6002 "env",
6003 r#"
6004 function view(env)
6005 values, calls = {}, {}
6006 for k, v in pairs(env) do
6007 if type(v) == "function" then calls[#calls + 1] = k
6008 elseif type(v) == "table" then
6009 for wk in pairs(v) do values[#values + 1] = k .. "." .. wk end
6010 else values[#values + 1] = k end
6011 end
6012 return column { key = "root",
6013 row { key = "a", focusable = true, width = 50, height = 20 },
6014 column { key = "dock", focus_region = true,
6015 row { key = "d", focusable = true, width = 50, height = 20 },
6016 },
6017 }
6018 end
6019 "#,
6020 )
6021 .unwrap();
6022 let mut core = Core::new();
6023 core.env.refresh_hz = Some(60.0);
6028 core.env.system.accent = Some(kui_core::Color::hex(0x3b82f6ff));
6029 core.env.system.locale = kui_core::Locale::new("en-US");
6030 core.env.window.native_controls = Some(Rect::new(0.0, 0.0, 78.0, 28.0));
6031 frame(&mut core, &mut ext);
6032 core.set_focus(Some(Key::ROOT.str("root").str("dock").str("d")));
6033 frame(&mut core, &mut ext);
6034
6035 let sorted = |name: &str| -> Vec<String> {
6036 let mut v: Vec<String> = ext.lua.globals().get(name).unwrap();
6037 v.sort();
6038 v
6039 };
6040 let mut documented: Vec<String> = kui_core::schema::ENV_FIELDS
6041 .iter()
6042 .flat_map(|f| f.lua.iter().map(|k| (*k).to_string()))
6043 .collect();
6044 documented.extend(
6050 kui_core::schema::THEME_ROLES
6051 .iter()
6052 .map(|r| format!("theme.{}", r.name)),
6053 );
6054 documented.push("theme.appearance".into());
6055 documented.push("theme.disabled_opacity".into());
6056 documented.extend(
6059 kui_core::schema::METRIC_ROLES
6060 .iter()
6061 .map(|r| format!("metrics.{}", r.name)),
6062 );
6063 documented.push("tokens.colors".into());
6066 documented.push("tokens.lengths".into());
6067 documented.sort();
6068 assert_eq!(
6069 sorted("values"),
6070 documented,
6071 "env's value keys and schema::ENV_FIELDS + THEME_ROLES + METRIC_ROLES + tokens disagree"
6072 );
6073 let mut doors: Vec<String> = kui_core::schema::DOORS
6080 .iter()
6081 .filter_map(|d| match d.lua {
6082 kui_core::schema::Cell::Is(name) => Some(name.to_string()),
6083 _ => None,
6084 })
6085 .collect();
6086 doors.sort();
6087 assert_eq!(
6088 sorted("calls"),
6089 doors,
6090 "env's queries and verbs and schema::DOORS's Lua column disagree; update env_table's doc too"
6091 );
6092 }
6093
6094 #[test]
6099 fn a_script_declares_tokens_and_references_them_by_name() {
6100 let mut ext = LuaExtension::from_source(
6101 "tokens",
6102 r##"
6103 tokens = {
6104 colors = { peach = "#ffcc99", ink = { light = "#111111", dark = "#eeeeee" } },
6105 lengths = { side_w = 132, gap = 6 },
6106 }
6107 function view(env)
6108 seen = env.tokens
6109 return column { pad = "$gap", gap = "$gap",
6110 row { key = "a", width = "$side_w", height = 20, bg = "$peach",
6111 border = { w = "$gap", color = "$ink" }, radius = "$radius" },
6112 row { key = "b", width = 20, height = "$side_w", bg = "$surface",
6113 pad = { l = "$gap", r = 2 } },
6114 text({ "x", { "y", color = "$peach", bg = "$ink" } }, { size = "$gap", color = "$peach" }),
6115 }
6116 end
6117 "##,
6118 )
6119 .unwrap();
6120 let mut core = Core::new();
6121 frame(&mut core, &mut ext);
6122 assert!(core.take_warnings().is_empty());
6123 let peach = Color::hex(0xffcc99ff);
6124 let paper = Color::hex(0xeeeeeeff);
6125 let radius = core.metrics().radius;
6126 let surface = core.theme().surface;
6127 let dl = core.output().0;
6128 let a = dl
6129 .quads
6130 .iter()
6131 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
6132 .expect("the peach box");
6133 assert_eq!(a.rect.w, 132.0, "width from a length token");
6134 assert_eq!(a.border_w, 6.0);
6135 assert_eq!(
6136 a.border_color, paper,
6137 "the dark half on an unknown appearance"
6138 );
6139 assert_eq!(a.radius[0], radius, "$radius is the metric");
6140 let b = dl
6141 .quads
6142 .iter()
6143 .find(|q| q.color == surface && q.rect.h == 132.0)
6144 .expect("the $surface box, 132 tall");
6145 assert_eq!(b.rect.w, 20.0);
6146 let seen: Table = ext.lua.globals().get("seen").unwrap();
6147 let colors: Table = seen.get("colors").unwrap();
6148 let lengths: Table = seen.get("lengths").unwrap();
6149 assert_eq!(colors.get::<u32>("peach").unwrap(), 0xffcc99ff);
6150 assert_eq!(colors.get::<u32>("ink").unwrap(), 0xeeeeeeff);
6151 assert_eq!(lengths.get::<f32>("side_w").unwrap(), 132.0);
6152 core.set_system(kui_core::SystemEnv {
6154 appearance: kui_core::Appearance::Light,
6155 ..Default::default()
6156 });
6157 frame(&mut core, &mut ext);
6158 let a = core
6159 .output()
6160 .0
6161 .quads
6162 .iter()
6163 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
6164 .unwrap();
6165 assert_eq!(a.border_color, Color::hex(0x111111ff));
6166 }
6167
6168 #[test]
6174 fn a_script_derives_a_token_from_another() {
6175 let mut ext = LuaExtension::from_source(
6176 "derived",
6177 r##"
6178 tokens = {
6179 colors = {
6180 peach = "#ffcc99",
6181 z_lit = { from = "peach", ops = { "lift", 0.5 } },
6182 a_deep = { from = "z_lit", ops = { { "alpha", 0.5 }, { "mix", "peach", 0.0 } } },
6183 up = { from = "surface", ops = { { "raise", 0.25 } } },
6184 bad = { from = "nothing" },
6185 },
6186 }
6187 function view(env)
6188 seen = env.tokens.colors
6189 return column {
6190 row { key = "a", width = 20, height = 20, bg = "$a_deep" },
6191 row { key = "b", width = 20, height = 20, bg = "$bad" },
6192 }
6193 end
6194 "##,
6195 )
6196 .unwrap();
6197 let mut core = Core::new();
6198 frame(&mut core, &mut ext);
6199 let warnings = core.take_warnings();
6200 let codes: Vec<&str> = warnings.iter().map(|w| w.code).collect();
6201 assert_eq!(codes, ["unknown-token"], "{warnings:?}");
6202 assert!(
6203 warnings[0].message.contains("`$bad`") && warnings[0].message.contains("`$nothing`")
6204 );
6205 let seen: Table = ext.lua.globals().get("seen").unwrap();
6206 assert_eq!(seen.get::<u32>("z_lit").unwrap(), 0xffe6ccff);
6207 assert_eq!(seen.get::<u32>("a_deep").unwrap(), 0xffe6cc80);
6208 let surface = core.theme().surface;
6209 assert_eq!(
6210 seen.get::<u32>("up").unwrap(),
6211 surface.mix(Color::WHITE, 0.25).to_hex(),
6212 "a role source, raised toward the dark base's front"
6213 );
6214 assert!(seen.get::<mlua::Value>("bad").unwrap().is_nil());
6215 let dl = core.output().0;
6216 assert!(
6217 dl.quads.iter().any(|q| q.color.to_hex() == 0xffe6cc80),
6218 "$a_deep painted the derived colour"
6219 );
6220
6221 let refused = |colors: &str| {
6223 LuaExtension::from_source(
6224 "bad",
6225 &format!(
6226 "tokens = {{ colors = {{ {colors} }} }}\nfunction view() return column {{}} end"
6227 ),
6228 )
6229 .err()
6230 .map(|e| e.to_string())
6231 .unwrap_or_default()
6232 };
6233 assert!(
6234 refused(r#"x = { from = "peach", ops = { { "glow", 1 } } }"#)
6235 .contains("unknown verb \"glow\"")
6236 );
6237 assert!(
6238 refused(r#"x = { from = "peach", ops = { { "mix", 0.5 } } }"#)
6239 .contains("mix takes a colour and a number")
6240 );
6241 assert!(
6242 refused(r#"x = { from = "peach", ops = { { "lift", "lots" } } }"#)
6243 .contains("number is a number")
6244 );
6245 assert!(refused(r#"x = { from = 3 }"#).contains("from names a colour token or role"));
6246 assert!(refused(r#"x = { from = "peach", glow = 1 }"#).contains("unknown key \"glow\""));
6247 }
6248
6249 #[test]
6254 fn a_scripts_tokens_sit_over_the_hosts() {
6255 let mut ext = LuaExtension::from_source(
6256 "guest",
6257 r##"
6258 tokens = { colors = { grey = "#808080" } }
6259 function view(env)
6260 first = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
6261 if redeclare then
6262 env.set_tokens { colors = { peach = "#ffaa77" }, lengths = { w = 40 } }
6263 end
6264 second = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
6265 return column { row { key = "a", width = redeclare and "$w" or 10, height = 10, bg = "$peach" } }
6266 end
6267 "##,
6268 )
6269 .unwrap();
6270 let mut core = Core::new();
6271 core.set_tokens(kui_core::Tokens::new().color("peach", Color::hex(0xffcc99ff)));
6272 frame(&mut core, &mut ext);
6273 let first: Table = ext.lua.globals().get("first").unwrap();
6274 assert_eq!(
6275 first.get::<u32>("peach").unwrap(),
6276 0xffcc99ff,
6277 "the host's peach"
6278 );
6279 assert_eq!(
6280 first.get::<u32>("grey").unwrap(),
6281 0x808080ff,
6282 "its own grey"
6283 );
6284 assert!(
6285 core.token_lookup().color("grey").is_err(),
6286 "the guest's grey is not the host's"
6287 );
6288 assert!(core.take_warnings().is_empty());
6289
6290 ext.lua.globals().set("redeclare", true).unwrap();
6291 frame(&mut core, &mut ext);
6292 let second: Table = ext.lua.globals().get("second").unwrap();
6293 assert_eq!(
6294 second.get::<u32>("peach").unwrap(),
6295 0xffaa77ff,
6296 "its own peach now"
6297 );
6298 assert!(
6299 second.get::<Option<u32>>("grey").unwrap().is_none(),
6300 "replaced whole"
6301 );
6302 let a = core
6303 .output()
6304 .0
6305 .quads
6306 .iter()
6307 .find(|q| q.color == Color::hex(0xffaa77ff))
6308 .expect("painted the script's peach");
6309 assert_eq!(a.rect.w, 40.0);
6310 assert_eq!(
6311 core.token_lookup().color("peach"),
6312 Ok(Color::hex(0xffcc99ff)),
6313 "the host's table did not move"
6314 );
6315 }
6316
6317 #[test]
6320 fn a_missing_token_warns_and_paints_nothing() {
6321 let mut ext = LuaExtension::from_source(
6322 "missing",
6323 r##"
6324 tokens = { colors = { surface = "#ff0000", peach = "#ffcc99" }, lengths = { gap = 6 } }
6325 function view(env)
6326 return column {
6327 row { key = "a", width = "$gap", height = 10, bg = "$peech", pad = "$peach" },
6328 row { key = "b", width = "$peach", height = 10, bg = "$gap" },
6329 text("still here", { color = "$peech", size = "$gap" }),
6330 text("still here", { size = 6 }),
6331 text("still here"),
6332 }
6333 end
6334 "##,
6335 )
6336 .unwrap();
6337 let mut core = Core::new();
6338 core.set_inspect(true);
6339 frame(&mut core, &mut ext);
6340 frame(&mut core, &mut ext);
6341 let ws = core.take_warnings();
6342 let mut codes: Vec<&str> = ws.iter().map(|w| w.code).collect();
6343 codes.sort();
6344 assert_eq!(
6345 codes,
6346 [
6347 kui_core::diag::RESERVED_TOKEN,
6348 kui_core::diag::UNKNOWN_TOKEN,
6349 kui_core::diag::UNKNOWN_TOKEN,
6350 kui_core::diag::UNKNOWN_TOKEN,
6351 ],
6352 "surface refused once; peech, peach-as-length and gap-as-colour once each: {ws:?}"
6353 );
6354 assert!(
6355 ws.iter().any(|w| w
6356 .message
6357 .contains("`$gap` is a length token, and this slot takes a color")),
6358 "{ws:?}"
6359 );
6360 let fg = core.theme().fg;
6364 let dl = core.output().0;
6365 assert!(
6366 dl.quads
6367 .iter()
6368 .all(|q| q.kind != kui_core::QuadKind::Solid || q.color.a == 0.0)
6369 );
6370 let glyphs: Vec<_> = dl
6371 .quads
6372 .iter()
6373 .filter(|q| {
6374 matches!(
6375 q.kind,
6376 kui_core::QuadKind::GlyphMask | kui_core::QuadKind::GlyphSubpixel
6377 )
6378 })
6379 .collect();
6380 assert!(!glyphs.is_empty(), "the text painted");
6381 assert!(glyphs.iter().all(|q| q.color == fg), "in the foreground");
6382 let heights: Vec<f32> = core
6387 .nodes()
6388 .iter()
6389 .filter(|n| n.text.as_deref() == Some("still here"))
6390 .map(|n| n.rect.h)
6391 .collect();
6392 assert_eq!(heights.len(), 3, "{heights:?}");
6393 assert!(heights[0] > 0.0, "{heights:?}");
6394 assert_eq!(heights[0], heights[1], "$gap is the literal 6: {heights:?}");
6395 assert!(heights[2] > heights[0], "and not the default: {heights:?}");
6396 }
6397
6398 #[test]
6403 fn a_gradient_stop_that_misses_is_left_out() {
6404 let mut ext = LuaExtension::from_source(
6405 "gradient",
6406 r##"
6407 tokens = { colors = { peach = "#ffcc99" } }
6408 function view(env)
6409 return column {
6410 row { key = "three", width = 40, height = 10,
6411 gradient = { stops = { "$peach", "$peech", "#0000ff" } } },
6412 row { key = "two", width = 40, height = 10, bg = "#000000",
6413 gradient = { stops = { "$peach", "$peech" } } },
6414 }
6415 end
6416 "##,
6417 )
6418 .unwrap();
6419 let mut core = Core::new();
6420 frame(&mut core, &mut ext);
6421 frame(&mut core, &mut ext);
6422 let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
6423 assert_eq!(codes, [kui_core::diag::UNKNOWN_TOKEN], "once, for the name");
6424 let dl = core.output().0;
6425 let images: Vec<_> = dl
6426 .quads
6427 .iter()
6428 .filter(|q| q.kind == kui_core::QuadKind::Image)
6429 .collect();
6430 assert_eq!(images.len(), 1, "the three-stop row's two");
6431 assert!(
6432 images[0].rect.y < 10.0,
6433 "on the first row: {:?}",
6434 images[0].rect
6435 );
6436 let black = dl
6437 .quads
6438 .iter()
6439 .filter(|q| q.kind == kui_core::QuadKind::Solid && q.color == Color::hex(0x000000ff))
6440 .count();
6441 assert_eq!(black, 1, "the second row's bg");
6442 }
6443
6444 #[test]
6449 fn a_token_reaches_a_min_a_stroke_and_a_stop_and_misses_by_leaving_the_slot() {
6450 let mut ext = LuaExtension::from_source(
6451 "everywhere",
6452 r##"
6453 tokens = { colors = { peach = "#ffcc99" }, lengths = { gap = 6, wide = 40 } }
6454 function view(env)
6455 return column { pad = 4,
6456 row { key = "clamp", width = 10, height = 10, min_width = "$wide" },
6457 row { key = "typo", width = 10, height = 10, min_width = "$nope" },
6458 line { key = "stroke", from = { 0, 0 }, to = { 30, 0 }, width = "$gap", color = "$peach" },
6459 row { key = "anim", width = 10, height = 10, transition = 100,
6460 keyframes = { { bg = "$peach", width = "$wide" }, { bg = "$peech", radius = "$gap" } },
6461 enter = { width = "$nothing", bg = "$peach" } },
6462 }
6463 end
6464 "##,
6465 )
6466 .unwrap();
6467 let mut core = Core::new();
6468 core.set_inspect(true);
6469 frame(&mut core, &mut ext);
6470 let nodes = core.nodes();
6471 let by = |label: &str| {
6472 nodes
6473 .iter()
6474 .find(|n| n.label.as_deref() == Some(label))
6475 .unwrap()
6476 };
6477 assert_eq!(by("clamp").rect.w, 40.0, "the clamp is the token's 40 px");
6478 assert_eq!(
6479 by("typo").rect.w,
6480 10.0,
6481 "a miss leaves the row at its default"
6482 );
6483 let dl = core.output().0;
6484 let seg = dl
6485 .quads
6486 .iter()
6487 .find(|q| q.kind == kui_core::QuadKind::Segment)
6488 .expect("the line drew");
6489 assert_eq!(seg.color, Color::hex(0xffcc99ff));
6490 assert_eq!(seg.border_w, 6.0, "the stroke is the token's width");
6491 let ws = core.take_warnings();
6492 let mut names: Vec<String> = ws
6493 .iter()
6494 .filter(|w| w.code == kui_core::diag::UNKNOWN_TOKEN)
6495 .map(|w| w.message.clone())
6496 .collect();
6497 names.sort();
6498 assert_eq!(names.len(), 3, "nope, peech, nothing: {ws:?}");
6499 assert!(names.iter().any(|m| m.contains("`$nope`")), "{names:?}");
6500 assert!(names.iter().any(|m| m.contains("`$peech`")), "{names:?}");
6501 assert!(names.iter().any(|m| m.contains("`$nothing`")), "{names:?}");
6502 }
6503
6504 #[test]
6505 fn scripts_move_focus_with_the_verbs() {
6506 let mut ext = LuaExtension::from_source(
6507 "focus",
6508 r#"
6509 function view(env)
6510 if cmd == "set" then env.set_focus(target)
6511 elseif cmd == "blur" then env.blur()
6512 elseif cmd == "next" then env.focus_next()
6513 elseif cmd == "prev" then env.focus_prev() end
6514 cmd = nil
6515 -- `env.focus` is a value the host filled in before view()
6516 -- ran, so it lags a verb called above by a frame;
6517 -- `env.is_focused` is a call and answers about now.
6518 seen_focus = env.focus
6519 seen_live = env.is_focused(target or 0)
6520 seen_window = env.focused
6521 return column { key = "root", pad = 10,
6522 row { key = "a", focusable = true, width = 50, height = 20 },
6523 row { key = "b", focusable = true, width = 50, height = 20 },
6524 row { key = "c", focusable = true, width = 50, height = 20 },
6525 }
6526 end
6527 "#,
6528 )
6529 .unwrap();
6530 let root = Key::ROOT.str("root");
6531 let (a, b, c) = (root.str("a"), root.str("b"), root.str("c"));
6532 let mut core = Core::new();
6533 let cmd = |ext: &LuaExtension, c: &str| ext.lua.globals().set("cmd", c).unwrap();
6534 let seen = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("seen_focus").unwrap();
6535 let live = |ext: &LuaExtension| ext.lua.globals().get::<bool>("seen_live").unwrap();
6536
6537 frame(&mut core, &mut ext);
6539 assert_eq!(core.focus(), None);
6540 assert_eq!(seen(&ext), None, "nil for no focus, not 0");
6541
6542 ext.lua.globals().set("target", b.0 as i64).unwrap();
6544 cmd(&ext, "set");
6545 frame(&mut core, &mut ext);
6546 assert_eq!(core.focus(), Some(b));
6547 assert_eq!(seen(&ext), None, "the value lags a same-frame verb");
6551 assert!(live(&ext), "the query does not");
6552 frame(&mut core, &mut ext);
6553 assert_eq!(
6554 seen(&ext),
6555 Some(b.0 as i64),
6556 "and the next frame carries it"
6557 );
6558
6559 cmd(&ext, "next");
6561 frame(&mut core, &mut ext);
6562 assert_eq!(core.focus(), Some(c));
6563 cmd(&ext, "next");
6564 frame(&mut core, &mut ext);
6565 assert_eq!(core.focus(), Some(a), "wraps");
6566 cmd(&ext, "prev");
6567 frame(&mut core, &mut ext);
6568 assert_eq!(core.focus(), Some(c), "wraps back");
6569
6570 cmd(&ext, "blur");
6571 frame(&mut core, &mut ext);
6572 assert_eq!(core.focus(), None);
6573 frame(&mut core, &mut ext);
6574 assert_eq!(seen(&ext), None);
6575
6576 ext.lua.globals().set("target", "c").unwrap();
6579 cmd(&ext, "set");
6580 frame(&mut core, &mut ext);
6581 assert_eq!(core.focus(), Some(c), "set_focus(\"c\") resolves the label");
6582 assert!(live(&ext), "and is_focused(\"c\") answers about it");
6583 assert!(core.take_warnings().is_empty());
6584 ext.lua.globals().set("target", "nope").unwrap();
6586 cmd(&ext, "set");
6587 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
6588 let e = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
6589 ui.finish();
6590 assert!(
6591 e.contains("no node is keyed \"nope\"") && e.contains("integer key"),
6592 "{e}"
6593 );
6594
6595 assert!(ext.lua.globals().get::<bool>("seen_window").unwrap());
6598 }
6599
6600 #[test]
6603 fn a_shared_label_resolves_to_the_first_and_warns() {
6604 let mut ext = LuaExtension::from_source(
6605 "dup",
6606 r#"
6607 function view(env)
6608 if go then env.set_focus("item"); go = nil end
6609 return column { key = "root",
6610 row { row { key = "item", focusable = true, width = 50, height = 20 } },
6611 row { row { key = "item", focusable = true, width = 50, height = 20 } },
6612 }
6613 end
6614 "#,
6615 )
6616 .unwrap();
6617 let mut core = Core::new();
6618 frame(&mut core, &mut ext);
6619 ext.lua.globals().set("go", true).unwrap();
6620 frame(&mut core, &mut ext);
6621 assert_eq!(
6622 core.focus(),
6623 Some(Key::ROOT.str("root").index(0).str("item")),
6624 "the first, under the auto-keyed first row"
6625 );
6626 let ws = core.take_warnings();
6627 assert_eq!(ws.len(), 1, "{ws:?}");
6628 assert_eq!(ws[0].code, kui_core::diag::AMBIGUOUS_KEY);
6629 assert!(ws[0].message.contains("\"item\""), "{}", ws[0].message);
6630 }
6631
6632 #[test]
6635 fn scripts_open_a_menu_and_hear_the_row() {
6636 let mut ext = LuaExtension::from_source(
6637 "menu",
6638 r#"
6639 frames = 0
6640 function view(env)
6641 frames = frames + 1
6642 if frames == 2 then
6643 opened = env.open_menu("card", 40, 30, {
6644 { role = "copy" },
6645 { role = "separator" },
6646 -- The wire spelling the event reports, and the
6647 -- snake one a script may have learned first.
6648 { role = "selectAll" },
6649 { role = "look_up" },
6650 { label = "Wrap", checked = true },
6651 { label = "Inspect", id = "inspect" },
6652 })
6653 end
6654 return column {
6655 column { key = "card", selectable = true, text("one", { size = 14 }) },
6656 }
6657 end
6658 "#,
6659 )
6660 .unwrap();
6661 let mut core = Core::new();
6662 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6663 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6664 ui.set_origin(OriginId(1));
6665 ext.view(&Slot::root(), &mut ui).unwrap();
6666 ui.finish();
6667 };
6668 frame(&mut core, &mut ext);
6669 frame(&mut core, &mut ext);
6670 assert!(ext.lua.globals().get::<bool>("opened").unwrap());
6671 let roles: Vec<_> = core
6672 .menu()
6673 .expect("open")
6674 .items
6675 .iter()
6676 .map(|i| (i.role, i.checked))
6677 .collect();
6678 assert_eq!(
6679 roles,
6680 [
6681 (kui_core::MenuRole::Copy, false),
6682 (kui_core::MenuRole::Separator, false),
6683 (kui_core::MenuRole::SelectAll, false),
6684 (kui_core::MenuRole::LookUp, false),
6685 (kui_core::MenuRole::Custom, true),
6686 (kui_core::MenuRole::Custom, false),
6687 ]
6688 );
6689 frame(&mut core, &mut ext);
6690 let tree = core.access_tree();
6693 assert_eq!(
6694 tree.nodes
6695 .iter()
6696 .find(|n| n.name.as_deref() == Some("Wrap"))
6697 .expect("the checked row")
6698 .checked,
6699 Some(true)
6700 );
6701 let row = tree
6702 .nodes
6703 .iter()
6704 .find(|n| n.name.as_deref() == Some("Inspect"))
6705 .expect("the menu drew")
6706 .rect;
6707 let at = kui_core::Vec2::new(row.x + row.w / 2.0, row.y + row.h / 2.0);
6708 core.handle_input(InputEvent::CursorMoved(at));
6709 core.handle_input(InputEvent::mouse_down(1));
6710 let events = core.handle_input(InputEvent::mouse_up());
6711 assert_eq!(events.len(), 1, "{events:?}");
6712 assert_eq!(events[0].payload.get_str("item"), Some("inspect"));
6713 assert_eq!(
6714 events[0].origin,
6715 OriginId(1),
6716 "posted with the origin that asked for the menu"
6717 );
6718 }
6719
6720 #[test]
6723 fn scripts_select_and_read_a_scope() {
6724 let mut ext = LuaExtension::from_source(
6725 "sel",
6726 r#"
6727 frames = 0
6728 function view(env)
6729 frames = frames + 1
6730 if frames > 1 then
6731 before = env.selection_text()
6732 took = env.select_all_in("card")
6733 text_out = env.selection_text()
6734 not_a_scope = env.select_all_in("plain")
6735 end
6736 return column {
6737 column { key = "card", selectable = true,
6738 text("one", { size = 14 }),
6739 text("two", { size = 14 }) },
6740 row { key = "plain", width = 10, height = 10 },
6741 }
6742 end
6743 "#,
6744 )
6745 .unwrap();
6746 let mut core = Core::new();
6747 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6748 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6749 ui.set_origin(OriginId(1));
6750 ext.view(&Slot::root(), &mut ui).unwrap();
6751 ui.finish();
6752 };
6753 frame(&mut core, &mut ext);
6754 frame(&mut core, &mut ext);
6755 let g = ext.lua.globals();
6756 assert!(matches!(
6757 g.get::<mlua::Value>("before").unwrap(),
6758 mlua::Value::Nil
6759 ));
6760 assert!(g.get::<bool>("took").unwrap());
6761 assert_eq!(g.get::<String>("text_out").unwrap(), "one\ntwo");
6762 assert!(
6763 !g.get::<bool>("not_a_scope").unwrap(),
6764 "a node that drew no text is not a scope"
6765 );
6766 }
6767
6768 #[test]
6773 fn scripts_map_points_to_bytes_on_a_line_of_runs() {
6774 let mut ext = LuaExtension::from_source(
6775 "hit",
6776 r#"
6777 frames = 0
6778 function view(env)
6779 local mono = { size = 14, family = "mono" }
6780 local w = env.measure_text("M", mono, 0).width
6781 frames = frames + 1
6782 -- A label nothing has declared yet is an error by name
6783 -- (F5), so the first frame declares and the next asks.
6784 if frames > 1 then
6785 hit = env.text_hit("line", 7.2 * w, 5)
6786 seam = env.caret_rect("line", 4)
6787 far = env.text_hit("line", 390, 5)
6788 none = env.caret_rect("plain", 0)
6789 end
6790 cell = w
6791 return column {
6792 row { key = "line",
6793 text("let ", mono),
6794 row { bg = 0x3b5bd455, text("value", mono) },
6795 text(" = 1;", mono) },
6796 row { key = "plain", width = 10, height = 10 },
6797 }
6798 end
6799 "#,
6800 )
6801 .unwrap();
6802 let mut core = Core::new();
6803 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6804 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6805 ui.set_origin(OriginId(1));
6806 ext.view(&Slot::root(), &mut ui).unwrap();
6807 ui.finish();
6808 };
6809 frame(&mut core, &mut ext);
6810 let hit: mlua::Value = ext.lua.globals().get("hit").unwrap();
6811 assert!(
6812 matches!(hit, mlua::Value::Nil),
6813 "nothing drawn before the first frame"
6814 );
6815 frame(&mut core, &mut ext);
6816 let hit: mlua::Table = ext.lua.globals().get("hit").unwrap();
6817 assert_eq!(
6818 hit.get::<usize>("byte").unwrap(),
6819 7,
6820 "the fourth cell of \"value\""
6821 );
6822 assert_eq!(hit.get::<u32>("line").unwrap(), 0);
6823 let cell: f32 = ext.lua.globals().get("cell").unwrap();
6824 let seam: mlua::Table = ext.lua.globals().get("seam").unwrap();
6825 let x: f32 = seam.get("x").unwrap();
6826 assert!((x - 4.0 * cell).abs() < 0.75, "{x} vs {}", 4.0 * cell);
6827 assert_eq!(seam.get::<f32>("w").unwrap(), 0.0);
6828 let far: mlua::Table = ext.lua.globals().get("far").unwrap();
6829 assert_eq!(
6834 far.get::<usize>("byte").unwrap(),
6835 14,
6836 "the end, across the runs"
6837 );
6838 let none: mlua::Value = ext.lua.globals().get("none").unwrap();
6839 assert!(matches!(none, mlua::Value::Nil), "a node that drew no text");
6840 }
6841
6842 #[test]
6846 fn scripts_draw_a_screen_of_cells() {
6847 let mut ext = LuaExtension::from_source(
6848 "term",
6849 r#"
6850 function view(env)
6851 return column { cells { key = "term", rows = 2, cols = 11, size = 14, family = "mono",
6852 lines = { "hello world", " bye" },
6853 runs = { { 1, 2, 3, 0xff0000ff, 0x0000ffff, 1 } },
6854 cursor_at = { 1, 4 }, cursor_shape = "underline" } }
6855 end
6856 "#,
6857 )
6858 .unwrap();
6859 let mut core = Core::new();
6860 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6861 ui.set_origin(OriginId(1));
6862 ext.view(&Slot::root(), &mut ui).unwrap();
6863 ui.finish();
6864 let (dl, _) = core.output();
6865 let glyphs = dl
6866 .quads
6867 .iter()
6868 .filter(|q| q.kind == kui_core::QuadKind::GlyphMask)
6869 .count();
6870 assert_eq!(glyphs, 13, "hello world + bye");
6871 let tree = core.access_tree();
6872 let term = tree
6873 .nodes
6874 .iter()
6875 .find(|n| n.role == kui_core::Role::Terminal)
6876 .expect("a terminal node");
6877 assert_eq!(term.value.as_deref(), Some("hello world\n bye"));
6878 }
6879
6880 #[test]
6886 fn a_lua_grid_selects_by_cell_word_and_row() {
6887 let mut ext = LuaExtension::from_source(
6888 "term",
6889 r#"
6890 function view(env)
6891 said = env.selection_text()
6892 return column { cells { key = "term", rows = 2, cols = 12,
6893 size = 14, family = "mono", line_height = 20,
6894 origin_line = 900, selectable = true,
6895 lines = { "hello world", "bye there" } } }
6896 end
6897 "#,
6898 )
6899 .unwrap();
6900 let mut core = Core::new();
6901 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6902 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6903 ui.set_origin(OriginId(1));
6904 ext.view(&Slot::root(), &mut ui).unwrap();
6905 ui.finish();
6906 };
6907 frame(&mut core, &mut ext);
6908 let said = |ext: &LuaExtension| ext.lua.globals().get::<Option<String>>("said").unwrap();
6909
6910 let w = core
6912 .measure_text(
6913 "M",
6914 &kui_core::TextStyle::new(14.0)
6915 .family(kui_core::FontFamily::Mono)
6916 .line_height(20.0),
6917 None,
6918 )
6919 .width;
6920 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
6921 let click = |core: &mut Core, p: Vec2, clicks: u8| {
6922 core.handle_input(InputEvent::CursorMoved(p));
6923 core.handle_input(InputEvent::MouseDown {
6924 button: kui_core::MouseButton::Primary,
6925 clicks,
6926 });
6927 core.handle_input(InputEvent::MouseUp {
6928 button: kui_core::MouseButton::Primary,
6929 });
6930 };
6931
6932 click(&mut core, at(0, 8), 2);
6933 frame(&mut core, &mut ext);
6934 assert_eq!(said(&ext).as_deref(), Some("world"), "a double click");
6935
6936 click(&mut core, at(1, 1), 3);
6937 frame(&mut core, &mut ext);
6938 assert_eq!(said(&ext).as_deref(), Some("bye there"), "a triple click");
6939 }
6940
6941 #[test]
6947 fn a_grid_selection_reads_back_as_absolute_lines_and_columns() {
6948 let mut ext = LuaExtension::from_source(
6949 "term",
6950 r#"
6951 function view(env)
6952 sel = env.cell_selection()
6953 return column { cells { key = "term", rows = 2, cols = 12,
6954 size = 14, family = "mono", line_height = 20,
6955 origin_line = 900, selectable = true,
6956 lines = { "hello world", "bye there" } } }
6957 end
6958 "#,
6959 )
6960 .unwrap();
6961 let mut core = Core::new();
6962 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6963 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6964 ui.set_origin(OriginId(1));
6965 ext.view(&Slot::root(), &mut ui).unwrap();
6966 ui.finish();
6967 };
6968 frame(&mut core, &mut ext);
6969 assert!(
6970 ext.lua
6971 .globals()
6972 .get::<mlua::Value>("sel")
6973 .unwrap()
6974 .is_nil(),
6975 "nothing selected yet"
6976 );
6977 let w = core
6978 .measure_text(
6979 "M",
6980 &kui_core::TextStyle::new(14.0)
6981 .family(kui_core::FontFamily::Mono)
6982 .line_height(20.0),
6983 None,
6984 )
6985 .width;
6986 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
6987 core.handle_input(InputEvent::CursorMoved(at(1, 4)));
6988 core.handle_input(InputEvent::MouseDown {
6989 button: kui_core::MouseButton::Primary,
6990 clicks: 1,
6991 });
6992 core.handle_input(InputEvent::CursorMoved(at(0, 1)));
6993 core.handle_input(InputEvent::MouseUp {
6994 button: kui_core::MouseButton::Primary,
6995 });
6996 frame(&mut core, &mut ext);
6997 let sel: Table = ext.lua.globals().get("sel").unwrap();
6998 let end = |name: &str| -> (u64, usize) {
6999 let t: Table = sel.get(name).unwrap();
7000 (t.get("line").unwrap(), t.get("col").unwrap())
7001 };
7002 assert_eq!(end("anchor"), (901, 4), "the press, on the second row");
7003 assert_eq!(end("focus"), (900, 1), "the pointer, backwards");
7004 assert_eq!(
7005 sel.get::<i64>("node").unwrap(),
7006 core.key_of("term").unwrap().0 as i64
7007 );
7008 assert!(!sel.get::<bool>("block").unwrap());
7009 }
7010
7011 #[test]
7014 fn spans_carry_their_decorations() {
7015 let mut ext = LuaExtension::from_source(
7016 "deco",
7017 r#"
7018 function view(env)
7019 return row { text({ "let ", { "value", bg = 0x3b5bd455, underline = true },
7020 " = 1;" }, { size = 14, family = "mono" }) }
7021 end
7022 "#,
7023 )
7024 .unwrap();
7025 let mut core = Core::new();
7026 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
7027 ui.set_origin(OriginId(1));
7028 ext.view(&Slot::root(), &mut ui).unwrap();
7029 ui.finish();
7030 let (dl, _) = core.output();
7031 let solids = dl
7032 .quads
7033 .iter()
7034 .filter(|q| q.kind == kui_core::QuadKind::Solid)
7035 .count();
7036 assert_eq!(solids, 2, "a background and an underline");
7037 }
7038
7039 #[test]
7044 fn underlines_have_a_colour_and_a_shape_of_their_own() {
7045 let mut ext = LuaExtension::from_source(
7046 "k4",
7047 r#"
7048 function view(env)
7049 return column {
7050 text({ "let ", { "value", underline_color = 0xff0000ff, underline_style = "wavy" } },
7051 { size = 14, family = "mono" }),
7052 text("warn", { size = 14, family = "mono", underline_color = 0x00ff00ff }),
7053 cells { rows = 1, cols = 3, size = 14, family = "mono",
7054 lines = { "abc" }, runs = { { 0, 0, 3, 0, 0, 32, 0xff0000ff } } },
7055 }
7056 end
7057 "#,
7058 )
7059 .unwrap();
7060 let mut core = Core::new();
7061 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7062 ui.set_origin(OriginId(1));
7063 ext.view(&Slot::root(), &mut ui).unwrap();
7064 ui.finish();
7065 let (dl, _) = core.output();
7066 let red = Color::hex(0xff0000ff);
7067 let segs: Vec<_> = dl
7068 .quads
7069 .iter()
7070 .filter(|q| q.kind == kui_core::QuadKind::Segment)
7071 .collect();
7072 assert!(
7073 segs.len() >= 6,
7074 "a wave under the span and an undercurl over the cells: {}",
7075 segs.len()
7076 );
7077 assert!(
7078 segs.iter().all(|q| q.color == red),
7079 "in the underline colour"
7080 );
7081 let solids: Vec<_> = dl
7082 .quads
7083 .iter()
7084 .filter(|q| q.kind == kui_core::QuadKind::Solid)
7085 .collect();
7086 assert_eq!(solids.len(), 1, "the text's solid line, coloured");
7087 assert_eq!(solids[0].color, Color::hex(0x00ff00ff));
7088 let mut bad = LuaExtension::from_source(
7090 "k4bad",
7091 r#"function view(env) return text({ { "x", underline_style = "squiggly" } }) end"#,
7092 )
7093 .unwrap();
7094 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7095 ui.set_origin(OriginId(1));
7096 let err = bad.view(&Slot::root(), &mut ui).unwrap_err();
7097 assert!(err.contains("solid | wavy | dotted"), "{err}");
7098 }
7099
7100 #[test]
7104 fn a_line_takes_a_dash_as_a_length_a_pair_or_four() {
7105 let segments = |view: &str| -> Result<usize, String> {
7106 let mut ext = LuaExtension::from_source("dash", view).unwrap();
7107 let mut core = Core::new();
7108 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7109 ui.set_origin(OriginId(1));
7110 let lowered = ext.view(&Slot::root(), &mut ui);
7111 ui.finish();
7112 lowered?;
7113 let (dl, _) = core.output();
7114 Ok(dl
7115 .quads
7116 .iter()
7117 .filter(|q| q.kind == kui_core::QuadKind::Segment)
7118 .count())
7119 };
7120 let line = |dash: &str| {
7121 format!(
7122 "function view(env) return column {{ width = 200, height = 40,
7123 line {{ from = {{0, 20}}, to = {{100, 20}}, width = 2, {dash} }} }} end"
7124 )
7125 };
7126 assert_eq!(segments(&line("")), Ok(1));
7127 assert_eq!(segments(&line("dash = 5")), Ok(10));
7129 assert_eq!(segments(&line("dash = {6, 4}")), Ok(10));
7130 assert_eq!(segments(&line("dash = {6, 4}, dash_offset = 5")), Ok(10));
7131 assert_eq!(segments(&line("dash = {10, 4, 2, 4}")), Ok(10));
7132 let err = segments(&line("dash = {1, 2, 3}")).unwrap_err();
7133 assert!(err.contains("dash is a length"), "{err}");
7134 }
7135
7136 #[test]
7140 fn features_are_a_text_option() {
7141 let mut ext = LuaExtension::from_source(
7142 "features",
7143 r#"
7144 function view(env)
7145 return row { text("fi ->", { size = 16 }),
7146 text("fi ->", { size = 16, features = "liga=0 calt=0" }) }
7147 end
7148 "#,
7149 )
7150 .unwrap();
7151 let mut core = Core::new();
7152 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
7153 ui.set_origin(OriginId(1));
7154 ext.view(&Slot::root(), &mut ui).unwrap();
7155 ui.finish();
7156 assert_eq!(core.text_cache_len(), 2);
7157 }
7158
7159 #[test]
7165 fn a_style_table_shared_by_three_texts_is_three_texts() {
7166 let mut ext = LuaExtension::from_source(
7167 "shared",
7168 r#"
7169 local mono = { size = 14, family = "mono" }
7170 function view(env)
7171 return row { text("a", mono), text("bb", mono), text("ccc", mono) }
7172 end
7173 "#,
7174 )
7175 .unwrap();
7176 let mut core = Core::new();
7177 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
7178 ui.set_origin(OriginId(1));
7179 ext.view(&Slot::root(), &mut ui).unwrap();
7180 ui.finish();
7181 let names: Vec<String> = core
7182 .access_tree()
7183 .nodes
7184 .iter()
7185 .filter_map(|n| n.value.clone().or_else(|| n.name.clone()))
7186 .collect();
7187 let joined = names.join("|");
7188 assert!(
7189 joined.contains("a") && joined.contains("bb") && joined.contains("ccc"),
7190 "three different texts, got {joined:?}"
7191 );
7192 assert_eq!(
7193 core.text_cache_len(),
7194 3,
7195 "three strings shaped, not one three times"
7196 );
7197 }
7198
7199 #[test]
7204 fn scripts_slice_a_long_list_from_the_geometry() {
7205 let mut ext = LuaExtension::from_source(
7206 "virtual",
7207 r#"
7208 ROWS, ROW_H, built = 10000, 30, 0
7209 function view(env)
7210 local g = env.scroll_geometry(list_key)
7211 -- No layout yet: fall back to the window for one frame.
7212 local h = g and g.h or 200
7213 local top = g and g.offset.y or 0
7214 local first = math.min(ROWS, math.max(0, math.floor(top / ROW_H)))
7215 local last = math.min(ROWS, math.ceil((top + h) / ROW_H))
7216 local list = { key = "list", width = "grow", height = "grow",
7217 scroll_y = true }
7218 if first > 0 then
7219 list[#list + 1] = row { key = "lead", width = "grow",
7220 height = first * ROW_H }
7221 end
7222 for i = first, last - 1 do
7223 list[#list + 1] = row { key = "row" .. i, width = "grow",
7224 height = ROW_H, bg = 0x282840ff }
7225 end
7226 if last < ROWS then
7227 list[#list + 1] = row { key = "tail", width = "grow",
7228 height = (ROWS - last) * ROW_H }
7229 end
7230 built = last - first
7231 return column(list)
7232 end
7233 "#,
7234 )
7235 .unwrap();
7236 let list = Key::ROOT.str("list");
7237 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
7238 let mut core = Core::new();
7239 let frame = |core: &mut Core, ext: &mut LuaExtension| {
7240 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7241 ui.set_origin(OriginId(1));
7242 ext.view(&Slot::root(), &mut ui).unwrap();
7243 ui.finish();
7244 };
7245
7246 frame(&mut core, &mut ext);
7247 frame(&mut core, &mut ext);
7248 let built: i64 = ext.lua.globals().get("built").unwrap();
7249 assert_eq!(built, 7, "200 / 30 rounded up");
7250
7251 let g = core.scroll_geometry(list).expect("laid out");
7254 assert_eq!(g.content.h, 10_000.0 * 30.0);
7255 core.set_scroll(list, Vec2::new(0.0, 1e9));
7256 frame(&mut core, &mut ext);
7257 frame(&mut core, &mut ext);
7258 assert_eq!(core.scroll_offset(list).y, 10_000.0 * 30.0 - 200.0);
7259 let built: i64 = ext.lua.globals().get("built").unwrap();
7260 assert_eq!(built, 7, "still a screenful at the far end");
7261 }
7262
7263 #[test]
7269 fn the_prelude_virtualizes_a_long_list_in_one_call() {
7270 let mut ext = LuaExtension::from_source(
7271 "virtual",
7272 r#"
7273 ROWS, ROW_H, first_built, built = 10000, 30, -1, 0
7274 function view(env)
7275 built, first_built = 0, -1
7276 return uniform_list(env,
7277 { key = "list", rows = ROWS, row_h = ROW_H,
7278 width = "grow", height = "grow" },
7279 function(i)
7280 built = built + 1
7281 if first_built < 0 then first_built = i end
7282 return column { fill = true, bg = 0x282840ff,
7283 on_click = { kind = "pick", row = i } }
7284 end)
7285 end
7286 "#,
7287 )
7288 .unwrap();
7289 let list = Key::ROOT.str("list");
7290 let mut core = Core::new();
7291 let frame = |core: &mut Core, ext: &mut LuaExtension| {
7292 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7293 ui.set_origin(OriginId(1));
7294 ext.view(&Slot::root(), &mut ui).unwrap();
7295 ui.finish();
7296 };
7297
7298 frame(&mut core, &mut ext);
7299 frame(&mut core, &mut ext);
7300 let built: i64 = ext.lua.globals().get("built").unwrap();
7301 assert_eq!(built, 9, "200 / 30 rounded up, plus two rows of overscan");
7302
7303 let g = core.scroll_geometry(list).expect("laid out");
7306 assert_eq!(g.content.h, 10_000.0 * 30.0);
7307 core.set_scroll(list, Vec2::new(0.0, 300.0 * 30.0));
7308 frame(&mut core, &mut ext);
7309 frame(&mut core, &mut ext);
7310 let first: i64 = ext.lua.globals().get("first_built").unwrap();
7311 assert_eq!(first, 298, "two rows of overscan above row 300");
7312 let built: i64 = ext.lua.globals().get("built").unwrap();
7313 assert_eq!(built, 11, "a screenful with overscan on both sides now");
7314 assert!(
7317 core.access_tree()
7318 .nodes
7319 .iter()
7320 .any(|n| n.key == list.index(300).index(0)),
7321 "row 300 is not keyed by its data index"
7322 );
7323 }
7324
7325 #[test]
7329 fn a_view_names_a_family_and_draws_in_it() {
7330 let mut ext = LuaExtension::from_source(
7331 "fam",
7332 r#"
7333 named, sans, missed = nil, nil, nil
7334 function view(env)
7335 named = env.measure_text("iiiWWW", { family = "Fixture Mono", size = 20 }).width
7336 sans = env.measure_text("iiiWWW", { family = "sans", size = 20 }).width
7337 return column {
7338 text("iiiWWW", { family = "Fixture Mono", size = 20 }),
7339 text("x", { family = "No Such Family 7" }),
7340 }
7341 end
7342 "#,
7343 )
7344 .unwrap();
7345 let mut core = Core::new();
7346 core.add_font_data(kui_core::testing::font_face(
7347 "Fixture Mono",
7348 400,
7349 false,
7350 true,
7351 ))
7352 .unwrap();
7353 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
7354 ui.set_origin(OriginId(1));
7355 ext.view(&Slot::root(), &mut ui).unwrap();
7356 ui.finish();
7357 let handle = core.add_system_font("Fixture Mono").unwrap();
7358 let expected = core
7359 .measure_text("iiiWWW", &kui_core::TextStyle::new(20.0).font(handle), None)
7360 .width;
7361 let g = ext.lua.globals();
7362 let (named, sans) = (
7363 g.get::<f32>("named").unwrap(),
7364 g.get::<f32>("sans").unwrap(),
7365 );
7366 assert_eq!(named, expected, "the face the host's handle draws");
7367 assert_ne!(named, sans, "and not sans");
7368 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
7369 assert_eq!(codes, ["unknown-family"]);
7370 }
7371
7372 #[test]
7375 fn the_prelude_reveals_a_row_styles_rows_and_splits() {
7376 let mut ext = LuaExtension::from_source(
7377 "dx22",
7378 r#"
7379 target, scrolled, page = nil, nil, nil
7380 function view(env)
7381 if target then scrolled = reveal_row(env, "list", target, 20) end
7382 page = rows_in_view(env, "list", 20)
7383 zero = rows_in_view(env, "list", 0)
7384 return row { width = 300, height = 100,
7385 uniform_list(env,
7386 { key = "list", rows = 50, row_h = 20, width = 200, height = 100,
7387 row_props = function(i) return { on_click = { kind = "pick", row = i } } end },
7388 function(i) return text("row " .. i) end),
7389 splitter(env, { key = "bar", on_drag = { kind = "split" } }),
7390 column { width = "grow", height = "grow" },
7391 }
7392 end
7393 "#,
7394 )
7395 .unwrap();
7396 let mut core = Core::new();
7397 let frame = |core: &mut Core, ext: &mut LuaExtension| {
7398 let mut ui = core.frame(Size::new(300.0, 100.0), 1.0);
7399 ui.set_origin(OriginId(1));
7400 ext.view(&Slot::root(), &mut ui).unwrap();
7401 ui.finish();
7402 };
7403 frame(&mut core, &mut ext);
7404 frame(&mut core, &mut ext);
7405 assert_eq!(ext.lua.globals().get::<i64>("page").unwrap(), 5);
7406 assert!(
7407 core.take_warnings().is_empty(),
7408 "every prop the three spell is known"
7409 );
7410
7411 ext.lua.globals().set("target", 40).unwrap();
7412 frame(&mut core, &mut ext);
7413 assert!(ext.lua.globals().get::<bool>("scrolled").unwrap());
7414 let list = core.key_of("list").unwrap();
7415 assert_eq!(
7416 core.scroll_offset(list),
7417 Vec2::new(0.0, 760.0),
7418 "row 40 to the middle"
7419 );
7420 core.handle_input(InputEvent::CursorMoved(Vec2::new(10.0, 45.0)));
7423 core.handle_input(InputEvent::mouse_down(1));
7424 let up = core.handle_input(InputEvent::mouse_up());
7425 assert_eq!(up.iter().find_map(|e| e.payload.get_int("row")), Some(40));
7426
7427 core.handle_input(InputEvent::CursorMoved(Vec2::new(202.0, 50.0)));
7429 core.handle_input(InputEvent::mouse_down(1));
7430 core.handle_input(InputEvent::CursorMoved(Vec2::new(225.0, 50.0)));
7431 let end = core.handle_input(InputEvent::mouse_up());
7432 let d = end.iter().find_map(|e| e.drag()).expect("the drag's end");
7433 assert_eq!(d.ratio().x, 0.75);
7434
7435 ext.lua.globals().set("target", 500).unwrap();
7437 frame(&mut core, &mut ext);
7438 assert!(!ext.lua.globals().get::<bool>("scrolled").unwrap());
7439 assert_eq!(core.scroll_offset(list), Vec2::new(0.0, 760.0));
7440 assert_eq!(ext.lua.globals().get::<i64>("zero").unwrap(), 0);
7441 }
7442
7443 #[test]
7447 fn a_uniform_list_that_shrank_lands_in_one_frame() {
7448 let mut ext = LuaExtension::from_source(
7449 "virtual",
7450 r#"
7451 ROWS, first_built = 200, -1
7452 function view(env)
7453 first_built = -1
7454 return uniform_list(env,
7455 { key = "list", rows = ROWS, row_h = 20,
7456 width = "grow", height = "grow" },
7457 function(i)
7458 if first_built < 0 then first_built = i end
7459 return column { fill = true, bg = 0x282840ff }
7460 end)
7461 end
7462 "#,
7463 )
7464 .unwrap();
7465 let list = Key::ROOT.str("list");
7466 let mut core = Core::new();
7467 let frame = |core: &mut Core, ext: &mut LuaExtension| {
7468 let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
7469 ui.set_origin(OriginId(1));
7470 ext.view(&Slot::root(), &mut ui).unwrap();
7471 ui.finish();
7472 };
7473 frame(&mut core, &mut ext);
7474 frame(&mut core, &mut ext);
7475 core.set_scroll(list, Vec2::new(0.0, 3_000.0));
7476 frame(&mut core, &mut ext);
7477 frame(&mut core, &mut ext);
7478 let deep: i64 = ext.lua.globals().get("first_built").unwrap();
7479 assert!(
7480 deep > 100,
7481 "expected to be deep in the list, built from {deep}"
7482 );
7483
7484 ext.lua.globals().set("ROWS", 10).unwrap();
7485 frame(&mut core, &mut ext);
7486 let g = core.scroll_geometry(list).expect("laid out");
7487 assert_eq!(
7488 g.content.h,
7489 10.0 * 20.0,
7490 "the content is the list it has now"
7491 );
7492 frame(&mut core, &mut ext);
7493 let first: i64 = ext.lua.globals().get("first_built").unwrap();
7494 assert_eq!(first, 0, "and every row of it is built");
7495 assert_eq!(core.scroll_offset(list).y, 0.0);
7496 }
7497
7498 const VARIABLE_LIST: &str = r#"
7503 heights = row_heights(1000, 20)
7504 measured = 0
7505 function view(env)
7506 return list(env,
7507 { key = "list", heights = heights, width = "grow", height = "grow",
7508 transition = TRANSITION },
7509 function(i, width)
7510 measured = measured + 1
7511 if i < 100 then return 20 else return 60 end
7512 end,
7513 function(i)
7514 return column { width = "grow", height = "grow", bg = 0x282840ff,
7515 label = "row", on_click = i }
7516 end)
7517 end
7518 "#;
7519
7520 fn variable_frame(core: &mut Core, ext: &mut LuaExtension, t: f64) {
7522 core.set_time(t);
7523 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7524 ui.set_origin(OriginId(1));
7525 ext.view(&Slot::root(), &mut ui).unwrap();
7526 ui.finish();
7527 }
7528
7529 fn row_under(core: &mut Core, y: f32) -> Option<i64> {
7532 core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, y)));
7533 core.handle_input(InputEvent::mouse_down(1));
7534 let evs = core.handle_input(InputEvent::mouse_up());
7535 evs.first()?.payload.as_int()
7536 }
7537
7538 #[test]
7542 fn a_lua_list_keeps_the_row_under_the_pointer_while_the_estimate_moves() {
7543 let mut ext = LuaExtension::from_source("variable", VARIABLE_LIST).unwrap();
7544 let list = Key::ROOT.str("list");
7545 let mut core = Core::new();
7546 variable_frame(&mut core, &mut ext, 0.0);
7547 variable_frame(&mut core, &mut ext, 0.0);
7548 let measured: i64 = ext.lua.globals().get("measured").unwrap();
7549 assert!(
7550 (10..=20).contains(&measured),
7551 "a screenful measured, not the list: {measured}"
7552 );
7553
7554 core.set_scroll(list, Vec2::new(0.0, 5_000.0));
7555 variable_frame(&mut core, &mut ext, 0.0);
7556 let settled = row_under(&mut core, 4.0);
7557 assert!(settled.is_some(), "nothing under the top edge");
7558 for n in 0..4 {
7559 variable_frame(&mut core, &mut ext, 0.0);
7560 assert_eq!(
7561 row_under(&mut core, 4.0),
7562 settled,
7563 "the content slid on frame {n} as the estimate moved"
7564 );
7565 }
7566 let g = core.scroll_geometry(list).expect("laid out");
7567 assert!(
7568 g.content.h > 1000.0 * 20.0 * 1.5,
7569 "the list learned it is longer: {}",
7570 g.content.h
7571 );
7572 }
7573
7574 #[test]
7578 fn a_lua_list_glides_to_the_row_asked_for() {
7579 let mut ext =
7580 LuaExtension::from_source("variable", &format!("TRANSITION = 100\n{VARIABLE_LIST}"))
7581 .unwrap();
7582 let list = Key::ROOT.str("list");
7583 let mut core = Core::new();
7584 let mut t = 0.0;
7585 variable_frame(&mut core, &mut ext, t);
7586 variable_frame(&mut core, &mut ext, t);
7587 let target = 400;
7588 let offset: f32 = ext
7589 .lua
7590 .load(format!("return heights:offset_of({target})"))
7591 .eval()
7592 .unwrap();
7593 core.set_scroll(list, Vec2::new(0.0, offset));
7594 for _ in 0..30 {
7595 t += 1.0 / 60.0;
7596 variable_frame(&mut core, &mut ext, t);
7597 }
7598 assert_eq!(row_under(&mut core, 4.0), Some(target));
7599 }
7600
7601 #[test]
7605 fn a_script_asks_for_a_file_dialog_and_hears_the_answer() {
7606 let mut ext = LuaExtension::from_source(
7607 "files",
7608 r#"
7609 ask, asked, again, waiting, heard = true, nil, nil, nil, nil
7610 function view(env)
7611 if ask then
7612 asked = env.request_files {
7613 mode = "save", title = "Export", file_name = "notes.md",
7614 filters = { { name = "Markdown", extensions = { "md" } } },
7615 tag = "export",
7616 }
7617 again = env.request_files {}
7618 waiting = env.awaiting_files()
7619 ask = false
7620 end
7621 return column {}
7622 end
7623 function on_event(ev)
7624 if ev.kind == "files" then heard = ev.paths[1] .. "|" .. ev.tag end
7625 end
7626 "#,
7627 )
7628 .unwrap();
7629 let mut core = Core::new();
7630 variable_frame(&mut core, &mut ext, 0.0);
7631 let g = ext.lua.globals();
7632 assert!(g.get::<bool>("asked").unwrap(), "the first ask is taken");
7633 assert!(
7634 !g.get::<bool>("again").unwrap(),
7635 "a second while it is out is not"
7636 );
7637 assert!(g.get::<bool>("waiting").unwrap());
7638
7639 let asks = core.take_file_requests();
7640 assert_eq!(asks.len(), 1);
7641 let d = &asks[0];
7642 assert_eq!(d.mode, kui_core::FileDialogMode::Save);
7643 assert_eq!(d.file_name.as_deref(), Some("notes.md"));
7644 assert_eq!(d.filters[0].extensions, vec!["md".to_string()]);
7645
7646 let evs = core.handle_input(InputEvent::Files(vec!["/tmp/notes.md".into()]));
7647 assert_eq!(evs.len(), 1);
7648 assert_eq!(
7649 evs[0].origin,
7650 OriginId(1),
7651 "the answer goes to the script that asked"
7652 );
7653 for e in &evs {
7654 ext.on_event(e);
7655 }
7656 let heard: String = ext.lua.globals().get("heard").unwrap();
7657 assert_eq!(heard, "/tmp/notes.md|export");
7658 }
7659
7660 #[test]
7664 fn a_host_hands_a_script_the_documents_the_os_opened() {
7665 let mut ext = LuaExtension::from_source(
7666 "open",
7667 r#"
7668 heard = nil
7669 function view(env) return column {} end
7670 function on_event(ev)
7671 if ev.kind == "open" then heard = #ev.paths .. "|" .. ev.paths[2] end
7672 end
7673 "#,
7674 )
7675 .unwrap();
7676 let mut core = Core::new();
7677 variable_frame(&mut core, &mut ext, 0.0);
7678 let evs = core.handle_input(InputEvent::Open(vec![
7679 "/tmp/a.txt".into(),
7680 "/tmp/b.md".into(),
7681 ]));
7682 assert_eq!(evs.len(), 1);
7683 assert_eq!(evs[0].origin, OriginId::HOST, "nobody asked: the host's");
7684 assert_eq!(evs[0].key, kui_core::Key::ROOT);
7685 for e in &evs {
7686 ext.on_event(e);
7687 }
7688 let heard: String = ext.lua.globals().get("heard").unwrap();
7689 assert_eq!(heard, "2|/tmp/b.md");
7690 assert!(
7691 core.handle_input(InputEvent::Open(Vec::new())).is_empty(),
7692 "no documents is no event"
7693 );
7694 }
7695
7696 #[test]
7699 fn a_lua_list_names_what_it_is_missing() {
7700 let mut ext = LuaExtension::from_source(
7701 "bad",
7702 r#"
7703 function view(env)
7704 ok, err = pcall(list, env, { key = "list" }, function() return 1 end,
7705 function() return column {} end)
7706 return column {}
7707 end
7708 "#,
7709 )
7710 .unwrap();
7711 let mut core = Core::new();
7712 variable_frame(&mut core, &mut ext, 0.0);
7713 let err: String = ext.lua.globals().get("err").unwrap();
7714 assert!(err.contains("row_heights"), "{err}");
7715 }
7716
7717 #[test]
7721 fn a_query_answers_for_an_undeclared_label_and_a_command_refuses() {
7722 let mut ext = LuaExtension::from_source(
7723 "queries",
7724 r#"
7725 function view(env)
7726 geom = env.scroll_geometry("nothing")
7727 off = env.scroll_offset("nothing")
7728 hovered = env.is_hovered("nothing")
7729 pressed = env.is_pressed("nothing")
7730 focused = env.is_focused("nothing")
7731 hit = env.text_hit("nothing", 1, 1)
7732 caret = env.caret_rect("nothing", 0)
7733 deferred = pcall(function() env.set_scroll("nothing", 0, 0) end)
7734 refused_focus = not pcall(function() env.set_focus("nothing") end)
7735 return column { width = "grow", height = "grow" }
7736 end
7737 "#,
7738 )
7739 .unwrap();
7740 let mut core = Core::new();
7741 let mut ui = core.frame(Size::new(200.0, 100.0), 1.0);
7742 ext.view(&Slot::root(), &mut ui).unwrap();
7743 ui.finish();
7744 let g = ext.lua.globals();
7745 assert_eq!(g.get::<mlua::Value>("geom").unwrap(), mlua::Value::Nil);
7746 assert_eq!(g.get::<mlua::Value>("hit").unwrap(), mlua::Value::Nil);
7747 assert_eq!(g.get::<mlua::Value>("caret").unwrap(), mlua::Value::Nil);
7748 assert!(!g.get::<bool>("hovered").unwrap());
7749 assert!(!g.get::<bool>("pressed").unwrap());
7750 assert!(!g.get::<bool>("focused").unwrap());
7751 let off: Table = g.get("off").unwrap();
7752 assert_eq!(off.get::<f32>("y").unwrap(), 0.0);
7753 assert!(g.get::<bool>("deferred").unwrap());
7756 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
7757 assert_eq!(codes, ["label-without-node"]);
7758 assert!(g.get::<bool>("refused_focus").unwrap());
7759 }
7760
7761 #[test]
7762 fn bad_script_reports_error_not_panic() {
7763 let mut ext = LuaExtension::from_source("bad", "function view() return 5 end").unwrap();
7764 let mut core = Core::new();
7765 let mut ui = core.frame(Size::new(100.0, 100.0), 1.0);
7766 assert!(ext.view(&Slot::root(), &mut ui).is_err());
7767 }
7768}