1use kui_core::schema::{self, Kind, Parsed, PropsOut};
280use kui_core::{
281 Align, Color, Content, EditOptions, Extension, FloatConfig, Key, PadShorthand, Sizing, Slot,
282 Span, Ui, UiEvent, Value, WindowConfig, widgets,
283};
284use mlua::{Lua, Table};
285
286mod meta;
287mod rows;
288pub use meta::luals_meta;
289
290const PRELUDE: &str = include_str!("prelude.lua");
291
292pub struct LuaExtension {
330 lua: Lua,
331 name: String,
332 slots: Vec<String>,
337 loaded: std::cell::RefCell<Vec<Loaded>>,
342 slot_names: std::collections::HashMap<kui_core::Key, String>,
346 tokens: Option<kui_core::Tokens>,
351}
352
353struct Loaded {
356 namespace: String,
357 path: std::path::PathBuf,
358 origin: kui_core::OriginId,
359}
360
361impl LuaExtension {
362 pub fn from_source(name: impl Into<String>, source: &str) -> mlua::Result<Self> {
386 let lua = Lua::new();
387 rows::register(&lua)?;
388 lua.load(PRELUDE).set_name("kui:prelude").exec()?;
389 let name = name.into();
390 lua.load(source).set_name(&name).exec()?;
391 let slots = match lua.globals().get::<mlua::Value>("slots")? {
392 mlua::Value::Nil => Vec::new(),
393 mlua::Value::Table(t) => t.sequence_values::<String>().collect::<mlua::Result<_>>()?,
394 other => {
395 return Err(mlua::Error::runtime(format!(
396 "`slots` must be a list of slot names, not {}",
397 other.type_name()
398 )));
399 }
400 };
401 let tokens = match lua.globals().get::<mlua::Value>("tokens")? {
402 mlua::Value::Nil => None,
403 mlua::Value::Table(t) => Some(parse_tokens(&t)?),
404 other => {
405 return Err(mlua::Error::runtime(format!(
406 "`tokens` must be a table {{ colors = …, lengths = … }}, not {}",
407 other.type_name()
408 )));
409 }
410 };
411 Ok(Self {
412 lua,
413 name,
414 slots,
415 loaded: Default::default(),
416 slot_names: Default::default(),
417 tokens,
418 })
419 }
420
421 fn loaded_as(&self, origin: kui_core::OriginId) -> Option<String> {
424 self.loaded
425 .borrow()
426 .iter()
427 .find(|l| l.origin == origin)
428 .map(|l| l.namespace.clone())
429 }
430
431 pub fn lua(&self) -> &Lua {
435 &self.lua
436 }
437
438 pub fn from_file(path: impl AsRef<std::path::Path>) -> mlua::Result<Self> {
440 let path = path.as_ref();
441 let source = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
442 let name = path
443 .file_name()
444 .map_or_else(|| "lua".into(), |n| n.to_string_lossy().into_owned());
445 Self::from_source(name, &source)
446 }
447}
448
449impl Extension for LuaExtension {
450 fn name(&self) -> &str {
451 &self.name
452 }
453
454 fn slots(&self) -> &[String] {
455 &self.slots
456 }
457
458 fn view(&mut self, slot: &Slot<'_>, ui: &mut Ui<'_>) -> Result<(), String> {
459 let view: mlua::Function = self
460 .lua
461 .globals()
462 .get("view")
463 .map_err(|_| "script defines no view()".to_string())?;
464 let slot_table = slot_table(&self.lua, slot).map_err(|e| format!("slot: {e}"))?;
470 self.slot_names
471 .entry(slot.key)
472 .or_insert_with(|| slot.full_name());
473 let origin = ui.core().origin();
477 if let Some(t) = &self.tokens
478 && !ui.core().tokens_declared(origin)
479 {
480 ui.set_tokens(t.clone());
481 }
482 let root: Table = {
487 let frame = std::cell::RefCell::new(&mut *ui);
488 self.lua
489 .scope(|scope| {
490 let env = env_table(&self.lua, scope, &frame, &self.loaded)?;
491 view.call((env, slot_table))
492 })
493 .map_err(|e| format!("view(): {e}"))?
494 };
495 if let Ok(Some(title)) = root.get::<Option<String>>("window_title") {
497 ui.window_title(&title);
498 }
499 if let Ok(Some(true)) = root.get::<Option<bool>>("always_on_top") {
500 ui.always_on_top(true);
501 }
502 if let Ok(Some(true)) = root.get::<Option<bool>>("secure_input") {
505 ui.secure_input(true);
506 }
507 if let Some(name) = root
514 .get::<Option<String>>("option_as_alt")
515 .map_err(|e| format!("option_as_alt: {e}"))?
516 {
517 let v = kui_core::OptionAsAlt::from_name(&name).ok_or_else(|| {
518 format!(
519 "option_as_alt: expected \"none\", \"left\", \"right\" or \"both\", got {name:?}"
520 )
521 })?;
522 if v != kui_core::OptionAsAlt::None {
523 ui.option_as_alt(v);
524 }
525 }
526 declare_windows(ui, &root).map_err(|e| format!("windows: {e}"))?;
527 build_node(ui, &root).map_err(|e| format!("view table: {e}"))
528 }
529
530 fn on_event(&mut self, ev: &UiEvent) -> Vec<Value> {
531 let Ok(f) = self.lua.globals().get::<mlua::Function>("on_event") else {
532 return Vec::new();
533 };
534 let payload = value_to_lua(&self.lua, &ev.payload).and_then(|p| {
535 if let mlua::Value::Table(t) = &p
539 && !t.contains_key("node_key")?
540 {
541 t.set("node_key", ev.key.0 as i64)?;
542 t.set("window", ev.window.0)?;
547 if let Some(name) = ev.slot.and_then(|k| self.slot_names.get(&k)) {
553 t.set("slot", name.as_str())?;
554 }
555 if let Some(ns) = self.loaded_as(ev.origin) {
563 t.set("from", ns)?;
564 }
565 }
566 Ok(p)
567 });
568 match payload.and_then(|p| f.call::<mlua::Value>(p)) {
573 Ok(mlua::Value::Nil) => Vec::new(),
574 Ok(v) => match lua_to_value(&v) {
575 Ok(Value::List(replies)) => replies,
576 Ok(reply) => vec![reply],
577 Err(e) => {
578 eprintln!("kui-lua: '{}' on_event reply: {e}", self.name);
579 Vec::new()
580 }
581 },
582 Err(e) => {
583 eprintln!("kui-lua: '{}' on_event error: {e}", self.name);
584 Vec::new()
585 }
586 }
587 }
588}
589
590fn slot_table(lua: &Lua, slot: &Slot<'_>) -> mlua::Result<Table> {
597 let t = lua.create_table()?;
598 t.set("name", slot.name)?;
599 t.set("namespace", slot.namespace)?;
600 t.set("key", slot.key.0 as i64)?;
601 if !matches!(slot.params, Value::Null) {
602 t.set("params", value_to_lua(lua, slot.params)?)?;
603 }
604 Ok(t)
605}
606
607fn key_arg(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Key> {
613 match v {
614 mlua::Value::Integer(i) => Ok(Key(i as u64)),
615 mlua::Value::String(s) => {
616 let label = s.to_str()?;
617 ui.key_of(&label).ok_or_else(|| {
618 mlua::Error::runtime(format!(
619 "no node is keyed {:?}: pass the label a `key` field declared in this or the last \
620 frame, or the integer key an event carried",
621 &*label
622 ))
623 })
624 }
625 other => Err(mlua::Error::runtime(format!(
626 "a node key is an integer or a declared label, not {}",
627 other.type_name()
628 ))),
629 }
630}
631
632fn key_query(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Option<Key>> {
643 match v {
644 mlua::Value::Integer(i) => Ok(Some(Key(i as u64))),
645 mlua::Value::String(s) => Ok(ui.key_of(&s.to_str()?)),
646 other => Err(mlua::Error::runtime(format!(
647 "a node key is an integer or a declared label, not {}",
648 other.type_name()
649 ))),
650 }
651}
652
653fn lua_list_to_value(t: &Table) -> mlua::Result<Value> {
656 let mut items = Vec::with_capacity(t.raw_len());
657 for item in t.sequence_values::<mlua::Value>() {
658 items.push(lua_to_value(&item?)?);
659 }
660 Ok(Value::List(items))
661}
662
663fn alias_menu_role(row: &mut Value) {
668 let Value::Map(fields) = row else { return };
669 for (k, v) in fields.iter_mut() {
670 if k == "role"
671 && let Value::Str(name) = v
672 {
673 match name.as_str() {
674 "select_all" => *name = kui_core::MenuRole::SelectAll.name().to_string(),
675 "look_up" => *name = kui_core::MenuRole::LookUp.name().to_string(),
676 _ => {}
677 }
678 }
679 }
680}
681
682fn menu_items(t: &mlua::Table) -> mlua::Result<Vec<kui_core::MenuItem>> {
685 let mut rows = lua_list_to_value(t)?;
686 if let Value::List(rows) = &mut rows {
687 rows.iter_mut().for_each(alias_menu_role);
688 }
689 kui_core::MenuItem::list_from_value(&rows).map_err(mlua::Error::runtime)
690}
691
692fn env_table<'scope, 'env: 'scope>(
739 lua: &Lua,
740 scope: &'scope mlua::Scope<'scope, 'env>,
741 ui: &'env std::cell::RefCell<&'env mut Ui<'_>>,
742 loaded: &'env std::cell::RefCell<Vec<Loaded>>,
743) -> mlua::Result<Table> {
744 let (facts, theme, metrics) = {
745 let ui = ui.borrow();
746 (ui.env_facts(), ui.theme(), ui.metrics())
747 };
748 let t = lua.create_table()?;
749 t.set("tokens", tokens_table(lua, &ui.borrow())?)?;
755 for row in kui_core::schema::ENV_FIELDS {
768 let value = (row.get)(&facts);
769 if value == Value::Null {
770 continue;
771 }
772 if row.name == "window.native_controls" {
773 let win = t
774 .get::<Option<Table>>("window")?
775 .unwrap_or(lua.create_table()?);
776 let f = |k: &str| value.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
777 win.set("controls_w", f("x") + f("w"))?;
778 win.set("controls_h", f("y") + f("h"))?;
779 t.set("window", win)?;
780 continue;
781 }
782 for path in row.lua {
783 let lua_value = value_to_lua(lua, &value)?;
784 match path.split_once('.') {
785 None => t.set(*path, lua_value)?,
786 Some((head, leaf)) => {
787 let sub = t.get::<Option<Table>>(head)?.unwrap_or(lua.create_table()?);
788 sub.set(leaf, lua_value)?;
789 t.set(head, sub)?;
790 }
791 }
792 }
793 }
794 let th = lua.create_table()?;
803 for role in kui_core::schema::THEME_ROLES {
804 th.set(role.name, (role.get)(&theme).to_hex())?;
805 }
806 th.set("appearance", theme.appearance.name())?;
807 th.set("disabled_opacity", theme.disabled_opacity)?;
808 t.set("theme", th)?;
809 let mt = lua.create_table()?;
814 for role in kui_core::schema::METRIC_ROLES {
815 mt.set(role.name, (role.get)(&metrics))?;
816 }
817 t.set("metrics", mt)?;
818 t.set(
824 "edit_text",
825 scope.create_function(move |_, key: mlua::Value| {
826 let mut ui = ui.borrow_mut();
827 let Some(key) = key_query(&mut ui, key)? else {
828 return Ok(None);
829 };
830 Ok(ui.edit_text(key))
831 })?,
832 )?;
833 t.set(
842 "set_edit_text",
843 scope.create_function(move |_, (key, text): (mlua::Value, String)| {
844 let mut ui = ui.borrow_mut();
845 match key {
846 mlua::Value::String(label) => {
847 ui.set_edit_text_by_label(&label.to_str()?, &text);
848 }
849 other => {
850 let key = key_arg(&mut ui, other)?;
851 ui.set_edit_text(key, &text);
852 }
853 }
854 Ok(())
855 })?,
856 )?;
857 t.set(
860 "is_focused",
861 scope.create_function(move |_, key: mlua::Value| {
862 let mut ui = ui.borrow_mut();
863 let Some(key) = key_query(&mut ui, key)? else {
864 return Ok(false);
865 };
866 Ok(ui.is_focused(key))
867 })?,
868 )?;
869 t.set(
870 "is_hovered",
871 scope.create_function(move |_, key: mlua::Value| {
872 let mut ui = ui.borrow_mut();
873 let Some(key) = key_query(&mut ui, key)? else {
874 return Ok(false);
875 };
876 Ok(ui.is_hovered(key))
877 })?,
878 )?;
879 t.set(
883 "is_pressed",
884 scope.create_function(move |_, key: mlua::Value| {
885 let mut ui = ui.borrow_mut();
886 let Some(key) = key_query(&mut ui, key)? else {
887 return Ok(false);
888 };
889 Ok(ui.is_pressed(key))
890 })?,
891 )?;
892 t.set(
895 "is_drop_target",
896 scope.create_function(move |_, key: mlua::Value| {
897 let mut ui = ui.borrow_mut();
898 let Some(key) = key_query(&mut ui, key)? else {
899 return Ok(false);
900 };
901 Ok(ui.is_drop_target(key))
902 })?,
903 )?;
904 t.set(
907 "drop_target",
908 scope.create_function(move |_, ()| {
909 let ui = ui.borrow();
910 Ok(ui.drop_target().map(|k| k.0 as i64))
911 })?,
912 )?;
913 t.set(
922 "set_focus",
923 scope.create_function(move |_, key: mlua::Value| {
924 let mut ui = ui.borrow_mut();
925 let key = key_arg(&mut ui, key)?;
926 ui.focus(key);
927 Ok(())
928 })?,
929 )?;
930 t.set(
931 "blur",
932 scope.create_function(move |_, ()| {
933 ui.borrow_mut().blur();
934 Ok(())
935 })?,
936 )?;
937 t.set(
944 "focus_next",
945 scope.create_function(move |_, ()| {
946 ui.borrow_mut().focus_next();
947 Ok(())
948 })?,
949 )?;
950 t.set(
951 "focus_prev",
952 scope.create_function(move |_, ()| {
953 ui.borrow_mut().focus_prev();
954 Ok(())
955 })?,
956 )?;
957 t.set(
963 "focus_region",
964 scope.create_function(move |_, key: mlua::Value| {
965 let mut ui = ui.borrow_mut();
966 let key = match key {
967 mlua::Value::Nil => None,
968 v => Some(key_arg(&mut ui, v)?),
969 };
970 ui.focus_region(key);
971 Ok(())
972 })?,
973 )?;
974 t.set(
985 "announce",
986 scope.create_function(move |_, (text, live): (String, Option<String>)| {
987 let live = live.unwrap_or_else(|| "polite".to_string());
988 let Some(i) = schema::LIVE.iter().position(|v| *v == live) else {
989 return Err(mlua::Error::runtime(format!(
990 "bad politeness {live:?} (one of {})",
991 schema::LIVE.join(" | ")
992 )));
993 };
994 ui.borrow_mut()
995 .announce(&text, kui_core::Live::from_index(i));
996 Ok(())
997 })?,
998 )?;
999 t.set(
1013 "reveal",
1014 scope.create_function(move |_, key: mlua::Value| {
1015 let mut ui = ui.borrow_mut();
1016 match key {
1017 mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
1018 ui.reveal_label(&s.to_str()?);
1019 }
1020 key => {
1021 let key = key_arg(&mut ui, key)?;
1022 ui.reveal(key);
1023 }
1024 }
1025 Ok(())
1026 })?,
1027 )?;
1028 t.set(
1032 "scroll_offset",
1033 scope.create_function(move |lua, key: mlua::Value| {
1034 let mut ui = ui.borrow_mut();
1035 let off = match key_query(&mut ui, key)? {
1036 Some(key) => ui.scroll_offset(key),
1037 None => kui_core::Vec2::ZERO,
1038 };
1039 let r = lua.create_table()?;
1040 r.set("x", off.x)?;
1041 r.set("y", off.y)?;
1042 Ok(r)
1043 })?,
1044 )?;
1045 t.set(
1060 "text_hit",
1061 scope.create_function(move |lua, (key, x, y): (mlua::Value, f32, f32)| {
1062 let mut ui = ui.borrow_mut();
1063 let Some(key) = key_query(&mut ui, key)? else {
1064 return Ok(mlua::Value::Nil);
1065 };
1066 let Some(h) = ui.text_hit(key, kui_core::Vec2::new(x, y)) else {
1067 return Ok(mlua::Value::Nil);
1068 };
1069 value_to_lua(lua, &h.to_value())
1070 })?,
1071 )?;
1072 t.set(
1080 "open_menu",
1081 scope.create_function(
1082 move |_, (key, x, y, items): (mlua::Value, f32, f32, mlua::Table)| {
1083 let mut ui = ui.borrow_mut();
1084 let Some(target) = key_query(&mut ui, key)? else {
1085 return Ok(false);
1086 };
1087 let items = menu_items(&items)?;
1088 ui.open_menu(kui_core::Menu::new(
1089 target,
1090 kui_core::Vec2::new(x, y),
1091 items,
1092 ));
1093 Ok(true)
1094 },
1095 )?,
1096 )?;
1097 t.set(
1099 "close_menu",
1100 scope.create_function(move |_, ()| {
1101 let mut ui = ui.borrow_mut();
1102 Ok(ui.close_menu())
1103 })?,
1104 )?;
1105 t.set(
1110 "selection_text",
1111 scope.create_function(move |_, ()| {
1112 let ui = ui.borrow();
1113 Ok(ui.selection_text())
1114 })?,
1115 )?;
1116 t.set(
1122 "request_copy",
1123 scope.create_function(move |_, ()| {
1124 let mut ui = ui.borrow_mut();
1125 Ok(match ui.request_copy() {
1126 kui_core::CopyRequest::Ready(text) => (Some(text), false),
1127 kui_core::CopyRequest::Asked => (None, true),
1128 kui_core::CopyRequest::Nothing => (None, false),
1129 })
1130 })?,
1131 )?;
1132 t.set(
1135 "answer_selection_range",
1136 scope.create_function(move |_, text: String| {
1137 let mut ui = ui.borrow_mut();
1138 Ok(ui.answer_selection_range(&text))
1139 })?,
1140 )?;
1141 t.set(
1147 "set_clipboard",
1148 scope.create_function(move |_, (text, html): (String, Option<String>)| {
1149 ui.borrow_mut().set_clipboard(text, html);
1150 Ok(())
1151 })?,
1152 )?;
1153 t.set(
1157 "set_clipboard_secret",
1158 scope.create_function(move |_, text: String| {
1159 ui.borrow_mut().set_clipboard_secret(text);
1160 Ok(())
1161 })?,
1162 )?;
1163 t.set(
1164 "request_paste",
1165 scope.create_function(move |_, ()| {
1166 ui.borrow_mut().request_paste();
1167 Ok(())
1168 })?,
1169 )?;
1170 t.set(
1171 "awaiting_paste",
1172 scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_paste()))?,
1173 )?;
1174 t.set(
1180 "request_files",
1181 scope.create_function(move |_, opts: Option<mlua::Value>| {
1182 let v = match &opts {
1183 Some(o) => lua_to_value(o)?,
1184 None => Value::Null,
1185 };
1186 let dialog = kui_core::FileDialog::from_value(&v)
1187 .map_err(|e| mlua::Error::runtime(format!("request_files: {e}")))?;
1188 Ok(ui.borrow_mut().request_files(dialog))
1189 })?,
1190 )?;
1191 t.set(
1192 "awaiting_files",
1193 scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_files()))?,
1194 )?;
1195 t.set(
1202 "selection_ends",
1203 scope.create_function(move |lua, ()| {
1204 let ui = ui.borrow();
1205 let Some((a, f)) = ui.selection_ends() else {
1206 return Ok(mlua::Value::Nil);
1207 };
1208 let end = |e: kui_core::RangeEnd| -> mlua::Result<mlua::Table> {
1209 let t = lua.create_table()?;
1210 if let Some(r) = e.row {
1211 t.set("index", r)?;
1212 }
1213 t.set("byte", e.byte)?;
1214 Ok(t)
1215 };
1216 let out = lua.create_table()?;
1217 out.set("anchor", end(a)?)?;
1218 out.set("focus", end(f)?)?;
1219 Ok(mlua::Value::Table(out))
1220 })?,
1221 )?;
1222 t.set(
1230 "cell_selection",
1231 scope.create_function(move |lua, ()| {
1232 let ui = ui.borrow();
1233 match ui.cell_selection() {
1234 Some(sel) => value_to_lua(lua, &sel.to_value(kui_core::Handles::INT)),
1235 None => Ok(mlua::Value::Nil),
1236 }
1237 })?,
1238 )?;
1239 t.set(
1243 "selection_html",
1244 scope.create_function(move |_, ()| {
1245 let ui = ui.borrow();
1246 Ok(ui.selection_html())
1247 })?,
1248 )?;
1249 t.set(
1254 "select_all_in",
1255 scope.create_function(move |_, key: mlua::Value| {
1256 let mut ui = ui.borrow_mut();
1257 let Some(key) = key_query(&mut ui, key)? else {
1258 return Ok(false);
1259 };
1260 Ok(ui.select_all_in(key))
1261 })?,
1262 )?;
1263 t.set(
1266 "clear_selection",
1267 scope.create_function(move |_, ()| {
1268 let mut ui = ui.borrow_mut();
1269 Ok(ui.clear_selection())
1270 })?,
1271 )?;
1272 t.set(
1276 "caret_rect",
1277 scope.create_function(move |lua, (key, byte): (mlua::Value, usize)| {
1278 let mut ui = ui.borrow_mut();
1279 let Some(key) = key_query(&mut ui, key)? else {
1280 return Ok(mlua::Value::Nil);
1281 };
1282 let Some(r) = ui.caret_rect(key, byte) else {
1283 return Ok(mlua::Value::Nil);
1284 };
1285 value_to_lua(lua, &r.to_value())
1286 })?,
1287 )?;
1288 t.set(
1289 "scroll_geometry",
1290 scope.create_function(move |lua, key: mlua::Value| {
1291 let mut ui = ui.borrow_mut();
1292 let Some(key) = key_query(&mut ui, key)? else {
1293 return Ok(mlua::Value::Nil);
1294 };
1295 let Some(g) = ui.scroll_geometry(key) else {
1296 return Ok(mlua::Value::Nil);
1297 };
1298 value_to_lua(lua, &g.to_value())
1300 })?,
1301 )?;
1302 t.set(
1305 "layout_of",
1306 scope.create_function(move |lua, key: mlua::Value| {
1307 let mut ui = ui.borrow_mut();
1308 let Some(key) = key_query(&mut ui, key)? else {
1309 return Ok(mlua::Value::Nil);
1310 };
1311 let Some(r) = ui.layout_of(key) else {
1312 return Ok(mlua::Value::Nil);
1313 };
1314 value_to_lua(lua, &r.to_value())
1315 })?,
1316 )?;
1317 t.set(
1320 "set_scroll",
1321 scope.create_function(move |_, (key, x, y): (mlua::Value, f32, f32)| {
1322 let mut ui = ui.borrow_mut();
1323 let at = kui_core::Vec2::new(x, y);
1324 match key {
1327 mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
1328 ui.set_scroll_label(&s.to_str()?, at);
1329 }
1330 key => {
1331 let key = key_arg(&mut ui, key)?;
1332 ui.set_scroll(key, at);
1333 }
1334 }
1335 Ok(())
1336 })?,
1337 )?;
1338 t.set(
1343 "shift_scroll",
1344 scope.create_function(move |_, (key, drawn, target): (mlua::Value, f32, f32)| {
1345 let mut ui = ui.borrow_mut();
1346 if let Some(key) = key_query(&mut ui, key)? {
1347 ui.shift_scroll(
1348 key,
1349 kui_core::Vec2::new(0.0, drawn),
1350 kui_core::Vec2::new(0.0, target),
1351 );
1352 }
1353 Ok(())
1354 })?,
1355 )?;
1356 t.set(
1364 "set_window_size",
1365 scope.create_function(move |_, (window, w, h): (i64, f32, f32)| {
1366 ui.borrow_mut()
1367 .set_window_size(kui_core::WindowId(window as u32), kui_core::Size::new(w, h));
1368 Ok(())
1369 })?,
1370 )?;
1371 let env = t.clone();
1376 t.set(
1377 "set_tokens",
1378 scope.create_function(move |lua, decl: Table| {
1379 let tokens = parse_tokens(&decl)?;
1380 ui.borrow_mut().set_tokens(tokens);
1381 env.set("tokens", tokens_table(lua, &ui.borrow())?)
1382 })?,
1383 )?;
1384 t.set(
1385 "focus_window",
1386 scope.create_function(move |_, window: i64| {
1387 ui.borrow_mut()
1388 .focus_window(kui_core::WindowId(window as u32));
1389 Ok(())
1390 })?,
1391 )?;
1392 t.set(
1393 "measure_text",
1394 scope.create_function(
1395 move |lua, (s, opts, max_w): (mlua::Value, Option<Table>, Option<f32>)| {
1396 let mut guard = ui.borrow_mut();
1397 let m = measure_from_lua(&mut guard, &s, opts.as_ref(), max_w)?;
1398 value_to_lua(lua, &m.to_value())
1399 },
1400 )?,
1401 )?;
1402 t.set(
1419 "add_extension",
1420 scope.create_function(move |_, (namespace, path): (String, String)| {
1421 let path = std::path::PathBuf::from(path);
1422 if let Some(prev) = loaded.borrow().iter().find(|l| l.namespace == namespace) {
1423 return Ok(if prev.path == path {
1424 (Some(true), None)
1426 } else {
1427 (
1428 None,
1429 Some(format!(
1430 "`{namespace}` is already {} in this script; a second plugin needs a \
1431 second namespace",
1432 prev.path.display()
1433 )),
1434 )
1435 });
1436 }
1437 let ext = match unsafe { kui_ffi::CExtension::open(&path) } {
1441 Ok(ext) => ext,
1442 Err(e) => return Ok((None, Some(e))),
1443 };
1444 let origin = match ui.borrow_mut().add_extension(&namespace, Box::new(ext)) {
1445 Ok(origin) => origin,
1446 Err(e) => return Ok((None, Some(e))),
1447 };
1448 loaded.borrow_mut().push(Loaded {
1449 namespace,
1450 path,
1451 origin,
1452 });
1453 Ok((Some(true), None))
1454 })?,
1455 )?;
1456 t.set(
1459 "extension_namespaces",
1460 scope.create_function(move |lua, ()| {
1461 lua.create_sequence_from(loaded.borrow().iter().map(|l| l.namespace.clone()))
1462 })?,
1463 )?;
1464 Ok(t)
1465}
1466
1467fn tokens_table(lua: &Lua, ui: &Ui<'_>) -> mlua::Result<Table> {
1479 let look = ui.tokens();
1480 let tokens = lua.create_table()?;
1481 let colors = lua.create_table()?;
1482 for (name, c) in look.colors() {
1483 colors.set(name, c.to_hex())?;
1484 }
1485 let lengths = lua.create_table()?;
1486 for (name, v) in look.lengths() {
1487 lengths.set(name, v)?;
1488 }
1489 tokens.set("colors", colors)?;
1490 tokens.set("lengths", lengths)?;
1491 Ok(tokens)
1492}
1493
1494fn measure_from_lua(
1495 ui: &mut Ui<'_>,
1496 s: &mlua::Value,
1497 opts: Option<&Table>,
1498 max_w: Option<f32>,
1499) -> mlua::Result<kui_core::TextMetrics> {
1500 let style = match opts {
1501 Some(t) => with_refs(ui, |refs| parse_props(t, false, refs))?.style,
1502 None => kui_core::TextStyle::default(),
1503 };
1504 let measure_spans = |ui: &mut Ui<'_>, spans: &Table, style: &kui_core::TextStyle| {
1505 let parts = with_refs(ui, |refs| collect_spans(spans, refs))?;
1506 let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1507 Ok(ui.measure_rich_text(&spans, style, max_w))
1508 };
1509 match s {
1510 mlua::Value::String(s) => Ok(ui.measure_text(&s.to_str()?, &style, max_w)),
1511 mlua::Value::Table(t) => {
1512 if t.get::<Option<String>>("type")?.as_deref() == Some("text") {
1513 let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
1514 match t.get::<Option<Table>>("spans")? {
1515 Some(spans) => measure_spans(ui, &spans, &style),
1516 None => {
1517 let value: String = t.get("value")?;
1518 Ok(ui.measure_text(&value, &style, max_w))
1519 }
1520 }
1521 } else {
1522 measure_spans(ui, t, &style)
1523 }
1524 }
1525 other => Err(bad(format!(
1526 "measure_text: expected a string, a span list or a text node, got {}",
1527 other.type_name()
1528 ))),
1529 }
1530}
1531
1532fn bad(msg: impl std::fmt::Display) -> mlua::Error {
1536 mlua::Error::runtime(msg.to_string())
1537}
1538
1539fn build_children(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1540 for child in t.sequence_values::<Table>() {
1541 build_node(ui, &child?)?;
1542 }
1543 Ok(())
1544}
1545
1546fn with_children(
1549 ui: &mut Ui<'_>,
1550 t: &Table,
1551 widget: impl FnOnce(&mut Ui<'_>, &mut dyn FnMut(&mut Ui<'_>)),
1552) -> mlua::Result<()> {
1553 let mut result = Ok(());
1554 widget(ui, &mut |ui| result = build_children(ui, t));
1555 result
1556}
1557
1558fn element_of(ty: &str) -> &str {
1562 match ty {
1563 "row" | "column" => "box",
1564 "grid" => "table",
1565 "input" => "edit",
1566 "dropdown" => "select",
1567 "radio_group" => "radioGroup",
1568 "window_buttons" => "windowButtons",
1569 "menu_bar" => "menuBar",
1570 "latency_graph" | "latency_hud" => "latencyGraph",
1571 other => other,
1572 }
1573}
1574
1575fn menu_bar_of(t: &Table) -> mlua::Result<kui_core::MenuBar> {
1576 let Some(list) = t.get::<Option<Table>>("menu")? else {
1577 return Ok(kui_core::MenuBar::default());
1578 };
1579 let mut menus = lua_list_to_value(&list)?;
1580 if let Value::List(menus) = &mut menus {
1581 for menu in menus.iter_mut() {
1582 let Value::Map(fields) = menu else { continue };
1583 for (k, items) in fields.iter_mut() {
1584 if k != "items" {
1585 continue;
1586 }
1587 if matches!(items, Value::Map(m) if m.is_empty()) {
1589 *items = Value::List(Vec::new());
1590 }
1591 if let Value::List(rows) = items {
1592 rows.iter_mut().for_each(alias_menu_role);
1593 }
1594 }
1595 }
1596 }
1597 kui_core::MenuBar::from_value(&menus).map_err(mlua::Error::runtime)
1598}
1599
1600fn declare_windows(ui: &mut Ui<'_>, root: &Table) -> mlua::Result<()> {
1601 let Some(list) = root.get::<Option<Table>>("windows")? else {
1602 return Ok(());
1603 };
1604 for entry in list.sequence_values::<mlua::Value>() {
1608 let v = lua_to_value(&entry?)?;
1609 let (name, cfg) = WindowConfig::from_value(&v).map_err(mlua::Error::runtime)?;
1610 ui.window(&name, cfg);
1611 }
1612 Ok(())
1613}
1614
1615fn check_props(ui: &mut Ui<'_>, t: &Table, element: &str) -> mlua::Result<()> {
1619 if !ui.core().diagnostics() {
1620 return Ok(());
1621 }
1622 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1623 let (k, _) = pair?;
1624 let mlua::Value::String(k) = k else { continue };
1625 let name = k.to_str()?;
1626 if name.as_ref() == "type" || schema::known_prop(element, &name, schema::Spelling::Snake) {
1628 continue;
1629 }
1630 let w = kui_core::diag::unknown_prop(element, &name, schema::Spelling::Snake);
1631 ui.core().warn(w);
1632 }
1633 Ok(())
1634}
1635
1636fn build_fill(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1646 let name: String = t
1647 .get::<Option<String>>("name")?
1648 .filter(|n| !n.is_empty())
1649 .ok_or_else(|| bad("fill needs a name (\"namespace/slot\")"))?;
1650 if !name.contains(kui_core::NAMESPACE_SEPARATOR) {
1651 return Err(bad(format!(
1652 "bad slot name {name:?} (a full \"namespace/slot\")"
1653 )));
1654 }
1655 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1656 let (k, _) = pair?;
1657 let mlua::Value::String(k) = k else { continue };
1658 let k = k.to_str()?;
1659 if !matches!(k.as_ref(), "type" | "name" | "params") {
1660 return Err(bad(format!(
1661 "fill takes name and params, not {:?} — it is a position, not a box",
1662 k.as_ref()
1663 )));
1664 }
1665 }
1666 let params = match t.get::<mlua::Value>("params")? {
1667 mlua::Value::Nil => Value::Null,
1668 v => lua_to_value(&v)?,
1669 };
1670 ui.slot_with(&name, ¶ms);
1671 Ok(())
1672}
1673
1674fn build_devtools_tab(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1683 let name: String = t.get::<Option<String>>("name")?.unwrap_or_default();
1684 let refuse = |ui: &mut Ui<'_>, why: &str| {
1685 ui.core().warn(kui_core::diag::bad_devtools_tab(&name, why));
1686 Ok(())
1687 };
1688 if name.is_empty() {
1689 return refuse(ui, "it needs a name (its identity)");
1690 }
1691 let label: String = t
1692 .get::<Option<String>>("label")?
1693 .unwrap_or_else(|| name.clone());
1694 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1695 let (k, _) = pair?;
1696 let mlua::Value::String(k) = k else { continue };
1697 let k = k.to_str()?;
1698 if !matches!(k.as_ref(), "type" | "name" | "label" | "slot" | "view") {
1699 return refuse(
1700 ui,
1701 &format!(
1702 "it takes name, label and slot or view, not {:?}",
1703 k.as_ref()
1704 ),
1705 );
1706 }
1707 }
1708 let slot = t.get::<Option<String>>("slot")?;
1709 let view = t.get::<mlua::Value>("view")?;
1710 match (slot, view) {
1711 (Some(slot), mlua::Value::Nil) => {
1712 if !slot.contains(kui_core::NAMESPACE_SEPARATOR) {
1713 return refuse(
1714 ui,
1715 &format!("bad slot {slot:?} (a full \"namespace/slot\")"),
1716 );
1717 }
1718 ui.devtools_tab(&name, &label, &slot);
1719 Ok(())
1720 }
1721 (None, mlua::Value::Function(view)) => {
1722 let mut result = Ok(());
1723 ui.devtools_tab_with(&name, &label, |ui| {
1724 result = view
1725 .call::<Table>(())
1726 .and_then(|tree| build_node(ui, &tree));
1727 });
1728 result
1729 }
1730 (Some(_), mlua::Value::Function(_)) => refuse(ui, "it takes a slot or a view, not both"),
1731 (None, mlua::Value::Nil) => refuse(ui, "it needs a slot or a view function"),
1732 (_, other) => refuse(
1733 ui,
1734 &format!("view is a {}, not a function", other.type_name()),
1735 ),
1736 }
1737}
1738
1739pub const MAX_VIEW_DEPTH: usize = 128;
1743
1744thread_local! {
1745 static VIEW_PATH: std::cell::RefCell<Vec<*const std::ffi::c_void>> =
1749 const { std::cell::RefCell::new(Vec::new()) };
1750}
1751
1752fn build_node(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1753 let at = t.to_pointer();
1754 VIEW_PATH.with_borrow_mut(|path| {
1755 if path.contains(&at) {
1756 return Err(bad("a view node that holds itself"));
1757 }
1758 if path.len() >= MAX_VIEW_DEPTH {
1759 return Err(bad(format!("a view nested past {MAX_VIEW_DEPTH} nodes")));
1760 }
1761 path.push(at);
1762 Ok(())
1763 })?;
1764 let built = build_one(ui, t);
1765 VIEW_PATH.with_borrow_mut(|path| path.pop());
1766 built
1767}
1768
1769fn build_one(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1776 let ty: String = t.get("type")?;
1777 match ty.as_str() {
1778 "fill" => build_fill(ui, t),
1779 "devtools_tab" => build_devtools_tab(ui, t),
1780 "row" | "column" | "grid" => {
1781 open_box(ui, t, &ty)?;
1782 build_children(ui, t)?;
1783 ui.close();
1784 Ok(())
1785 }
1786 "fragment" => {
1787 open_fragment(ui, t)?;
1788 build_children(ui, t)?;
1789 ui.close();
1790 Ok(())
1791 }
1792 "titlebar" | "tooltip" | "radio_group" => build_holder(ui, t, &ty),
1793 _ => build_widget(ui, t, &ty),
1794 }
1795}
1796
1797#[inline(never)]
1798fn open_box(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1799 check_props(ui, t, element_of(ty))?;
1800 let mut p = with_refs(ui, |refs| parse_props(t, ty == "row", refs))?;
1801 p.spec.layout.table = ty == "grid";
1803 ui.core().open_from(p, Content::Box);
1804 Ok(())
1805}
1806
1807#[inline(never)]
1808fn open_fragment(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1809 check_props(ui, t, element_of("fragment"))?;
1810 let id: i64 = t.get("id")?;
1815 let image: Option<i64> = t.get("image")?;
1816 let params: Vec<f32> = match t.get::<Option<Table>>("params")? {
1817 Some(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
1818 None => Vec::new(),
1819 };
1820 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1821 let frag = kui_core::FragmentRef {
1822 id: kui_core::FragmentId::from_ffi(id as u64),
1823 image: image
1824 .filter(|i| *i != 0)
1825 .map(|i| kui_core::ImageId::from_ffi(i as u64)),
1826 };
1827 ui.core().open_from(p, Content::Fragment(frag, ¶ms));
1828 Ok(())
1829}
1830
1831#[inline(never)]
1834fn build_holder(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1835 check_props(ui, t, element_of(ty))?;
1836 match ty {
1837 "titlebar" => {
1838 if t.raw_len() > 0 {
1839 with_children(ui, t, |ui, body| widgets::titlebar_with(ui, body))
1840 } else {
1841 let title: String = t.get::<Option<String>>("title")?.unwrap_or_default();
1842 widgets::titlebar(ui, &title);
1843 Ok(())
1844 }
1845 }
1846 "tooltip" => {
1847 if t.raw_len() > 0 {
1848 with_children(ui, t, |ui, body| widgets::tooltip_with(ui, body))
1849 } else {
1850 let value: String = t.get("value")?;
1851 widgets::tooltip(ui, &value);
1852 Ok(())
1853 }
1854 }
1855 "radio_group" => {
1856 let label: String = t.get("label")?;
1859 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1860 let mut result = Ok(());
1861 widgets::radio_group_with(ui, &label, p.spec, |ui| result = build_children(ui, t));
1862 result
1863 }
1864 other => unreachable!("{other} holds no children"),
1865 }
1866}
1867
1868#[inline(never)]
1869fn build_widget(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1870 check_props(ui, t, element_of(ty))?;
1871 match ty {
1872 "text" => {
1873 let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
1874 if let Some(spans) = t.get::<Option<Table>>("spans")? {
1875 let parts = with_refs(ui, |refs| collect_spans(&spans, refs))?;
1876 let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1877 ui.rich_text(&spans, style);
1878 } else {
1879 let value: String = t.get("value")?;
1880 ui.text(&value, style);
1881 }
1882 Ok(())
1883 }
1884 "image" => {
1885 let id: i64 = t.get("id")?;
1889 let spec = with_refs(ui, |refs| parse_props(t, false, refs))?.spec;
1890 let named = |row: &str, names: &[&str]| -> mlua::Result<usize> {
1891 match t.get::<Option<String>>(row)? {
1892 None => Ok(0),
1893 Some(s) => names
1894 .iter()
1895 .position(|n| *n == s)
1896 .ok_or_else(|| bad(format!("{row} must be one of {}", names.join(", ")))),
1897 }
1898 };
1899 let sampling_names: Vec<&str> =
1900 kui_core::Sampling::ALL.iter().map(|s| s.name()).collect();
1901 let fit_names: Vec<&str> = kui_core::ImageFit::ALL.iter().map(|f| f.name()).collect();
1902 let opts = kui_core::ImageOpts {
1903 sampling: kui_core::Sampling::ALL[named("sampling", &sampling_names)?],
1904 fit: kui_core::ImageFit::ALL[named("fit", &fit_names)?],
1905 };
1906 ui.image_with(kui_core::ImageId::from_ffi(id as u64), opts, spec);
1907 Ok(())
1908 }
1909 "polygon" => {
1910 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1913 let Some(list) = t.get::<Option<Table>>("points")? else {
1914 return Err(bad("polygon needs points"));
1915 };
1916 let points: Vec<kui_core::Vec2> = list
1917 .sequence_values::<mlua::Value>()
1918 .map(|v| {
1919 let mlua::Value::Table(pt) = v? else {
1920 return Err(bad("a polygon point is a {x, y} table"));
1921 };
1922 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
1923 })
1924 .collect::<mlua::Result<_>>()?;
1925 ui.core().open_from(p, Content::Polygon(&points));
1926 Ok(())
1927 }
1928 "path" => {
1929 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1934 let rule = match t.get::<Option<String>>("fill_rule")? {
1935 Some(name) => kui_core::FillRule::parse(&name)
1936 .ok_or_else(|| bad("fill_rule is \"nonzero\" or \"evenodd\""))?,
1937 None => kui_core::FillRule::NonZero,
1938 };
1939 let width = match t.get::<mlua::Value>("width")? {
1940 mlua::Value::Nil => None,
1941 v => with_refs(ui, |refs| length_of(&v, refs))?,
1942 };
1943 let dash = dash_of(t)?;
1944 let stroke = width.filter(|w| *w > 0.0).map(|w| {
1945 kui_core::Stroke::new(w, p.style.color.unwrap_or(ui.theme().fg)).dashed(dash)
1946 });
1947 let turns = t.get::<Option<f32>>("rotate")?;
1951 let pivot = match t.get::<Option<Table>>("pivot")? {
1952 Some(p) => Some(kui_core::Vec2::new(p.get::<f32>(1)?, p.get::<f32>(2)?)),
1953 None => None,
1954 };
1955 let turn = (turns.is_some() || pivot.is_some()).then_some(kui_core::Turn {
1956 turns: turns.unwrap_or(0.0),
1957 pivot,
1958 });
1959 if let Some(d) = t.get::<Option<String>>("d")? {
1960 ui.core()
1961 .open_from(p, Content::PathD(&d, rule, stroke, turn));
1962 } else if let Some(list) = t.get::<Option<Table>>("ops")? {
1963 let floats: Vec<f32> =
1964 list.sequence_values::<f32>().collect::<mlua::Result<_>>()?;
1965 ui.core()
1966 .open_from(p, Content::PathFlat(&floats, rule, stroke, turn));
1967 } else {
1968 return Err(bad("path needs d or ops"));
1969 }
1970 Ok(())
1971 }
1972 "line" => {
1973 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1979 let point = |v: mlua::Value| -> mlua::Result<kui_core::Vec2> {
1980 let mlua::Value::Table(pt) = v else {
1981 return Err(bad("a line point is a {x, y} table"));
1982 };
1983 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
1984 };
1985 let points: Vec<kui_core::Vec2> = match t.get::<Option<Table>>("points")? {
1986 Some(list) => list
1987 .sequence_values::<mlua::Value>()
1988 .map(|v| point(v?))
1989 .collect::<mlua::Result<_>>()?,
1990 None => {
1991 let (Some(from), Some(to)) = (
1992 t.get::<Option<mlua::Value>>("from")?,
1993 t.get::<Option<mlua::Value>>("to")?,
1994 ) else {
1995 return Err(bad("line needs from and to, or points"));
1996 };
1997 vec![point(from)?, point(to)?]
1998 }
1999 };
2000 let width = match t.get::<mlua::Value>("width")? {
2003 mlua::Value::Nil => None,
2004 v => with_refs(ui, |refs| length_of(&v, refs))?,
2005 }
2006 .unwrap_or(1.0);
2007 let stroke_color = p.style.color.unwrap_or(ui.theme().fg);
2009 let mut stroke = kui_core::Stroke::new(width, stroke_color);
2010 stroke.curve = t.get::<Option<bool>>("curve")?.unwrap_or(false);
2011 stroke.dash = dash_of(t)?;
2012 ui.core().open_from(p, Content::Line(&points, stroke));
2013 Ok(())
2014 }
2015 "cells" => {
2016 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
2022 let rows: usize = t.get::<Option<usize>>("rows")?.unwrap_or(0);
2023 let cols: usize = t.get::<Option<usize>>("cols")?.unwrap_or(0);
2024 if rows == 0 || cols == 0 {
2025 return Err(bad("cells needs rows and cols"));
2026 }
2027 let default_fg = p.style.color.unwrap_or(ui.theme().fg).to_hex();
2028 let mut cells = vec![kui_core::Cell::new(' ', default_fg, 0); rows * cols];
2029 if let Some(lines) = t.get::<Option<Table>>("lines")? {
2030 for (r, line) in lines.sequence_values::<String>().enumerate() {
2031 if r >= rows {
2032 break;
2033 }
2034 for (c, ch) in line?.chars().take(cols).enumerate() {
2035 cells[r * cols + c].ch = ch;
2036 }
2037 }
2038 }
2039 if let Some(runs) = t.get::<Option<Table>>("runs")? {
2040 for run in runs.sequence_values::<Table>() {
2041 let run = run?;
2042 let row: usize = run.get(1)?;
2043 let col: usize = run.get(2)?;
2044 let len: usize = run.get(3)?;
2045 let fg: u32 = run.get::<Option<u32>>(4)?.unwrap_or(0);
2046 let bg: u32 = run.get::<Option<u32>>(5)?.unwrap_or(0);
2047 let flags: u8 = run.get::<Option<u8>>(6)?.unwrap_or(0);
2048 let ul: u32 = run.get::<Option<u32>>(7)?.unwrap_or(0);
2049 if row >= rows {
2050 continue;
2051 }
2052 for c in col..(col + len).min(cols) {
2053 let cell = &mut cells[row * cols + c];
2054 if fg != 0 {
2055 cell.fg = fg;
2056 }
2057 if bg != 0 {
2058 cell.bg = bg;
2059 }
2060 cell.flags |= flags;
2061 if ul != 0 {
2062 cell.ul = ul;
2063 }
2064 }
2065 }
2066 }
2067 let cursor = match t.get::<Option<Table>>("cursor_at")? {
2068 Some(cur) => {
2069 let shape = match t.get::<Option<String>>("cursor_shape")? {
2072 None => kui_core::CellCursor::Block,
2073 Some(s) => kui_core::CellCursor::from_name(&s).ok_or_else(|| {
2074 bad(format!(
2075 "cursor_shape must be {}, not {s:?}",
2076 kui_core::CellCursor::NAMES.join(" | ")
2077 ))
2078 })?,
2079 };
2080 let color = match t.get::<mlua::Value>("cursor_color")? {
2081 mlua::Value::Nil => None,
2082 v => with_refs(ui, |refs| parse_color(&v, refs))?,
2083 }
2084 .unwrap_or(Color::rgb8(0xff, 0xff, 0xff));
2085 Some((cur.get::<usize>(1)?, cur.get::<usize>(2)?, shape, color))
2086 }
2087 None => None,
2088 };
2089 let origin_line = t.get::<Option<u64>>("origin_line")?.unwrap_or(0);
2091 let grid = kui_core::CellGrid {
2092 rows,
2093 cols,
2094 cells: &cells,
2095 style: p.style,
2096 cursor,
2097 origin_line,
2098 };
2099 ui.core().open_from(p, Content::Cells(&grid));
2100 Ok(())
2101 }
2102 "audio" => {
2103 let src: i64 = t.get("src")?;
2106 let mut spec = kui_core::AudioSpec::new(kui_core::SoundId::from_ffi(src as u64));
2107 if let Some(v) = t.get::<Option<f32>>("volume")? {
2108 spec = spec.volume(v);
2109 }
2110 if t.get::<Option<bool>>("loop")?.unwrap_or(false) {
2111 spec = spec.looped();
2112 }
2113 spec = spec.paused(t.get::<Option<bool>>("paused")?.unwrap_or(false));
2114 if t.get::<Option<bool>>("finish")?.unwrap_or(false) {
2115 spec = spec.finish();
2116 }
2117 if let Some(tag) = t.get::<Option<mlua::Value>>("tag")? {
2118 spec.tag = Some(lua_to_value(&tag)?);
2119 }
2120 match t.get::<Option<String>>("key")? {
2121 Some(k) => ui.audio_keyed(&k, spec),
2122 None => ui.audio(spec),
2123 };
2124 Ok(())
2125 }
2126 "input" => {
2127 let label: String = t.get("label")?;
2128 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
2129 widgets::text_input(ui, &label, &initial);
2130 Ok(())
2131 }
2132 "dropdown" => {
2133 let Some(label) = t.get::<Option<String>>("label")?.filter(|l| !l.is_empty()) else {
2137 return Err(bad("dropdown needs a label (its key and accessible name)"));
2138 };
2139 let Some(options) = t.get::<Option<Table>>("options")? else {
2140 return Err(bad(
2141 "dropdown needs options, a list of strings or menu rows",
2142 ));
2143 };
2144 let mut rows = lua_list_to_value(&options)?;
2145 if let Value::List(rows) = &mut rows {
2146 rows.iter_mut().for_each(alias_menu_role);
2147 if ui.core().diagnostics() {
2151 for row in rows.iter() {
2152 let Value::Map(fields) = row else { continue };
2153 for (k, _) in fields.iter() {
2154 if !kui_core::MenuItem::KEYS.contains(&k.as_str()) {
2155 ui.core().warn(kui_core::diag::unknown_menu_item_key(k));
2156 }
2157 }
2158 }
2159 }
2160 }
2161 let items =
2162 kui_core::MenuItem::options_from_value(&rows).map_err(mlua::Error::runtime)?;
2163 let current = match t.get::<Option<i64>>("current")? {
2164 None => None,
2165 Some(i) if i >= 1 => Some(i as usize - 1),
2166 Some(i) => {
2167 return Err(bad(format!("dropdown current is an index from 1, not {i}")));
2168 }
2169 };
2170 widgets::select_items(ui, &label, &items, current);
2171 Ok(())
2172 }
2173 "edit" => {
2174 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
2175 let label = match p.key.clone().or(t.get::<Option<String>>("label")?) {
2176 Some(l) => l,
2177 None => return Err(bad("edit needs a key (state is retained by key)")),
2178 };
2179 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
2180 let opts = EditOptions {
2181 style: p.style,
2182 multiline: t.get::<Option<bool>>("multiline")?.unwrap_or(false),
2183 autofocus: t.get::<Option<bool>>("autofocus")?.unwrap_or(false),
2184 wrap: p.wrap,
2185 ..Default::default()
2186 };
2187 ui.text_edit(&label, &initial, &opts, p.spec);
2188 Ok(())
2189 }
2190 "window_buttons" => {
2191 widgets::window_buttons(ui);
2192 Ok(())
2193 }
2194 "menu_bar" => {
2195 widgets::menu_bar(ui, menu_bar_of(t)?);
2196 Ok(())
2197 }
2198 "latency_graph" => {
2199 widgets::latency_graph(ui);
2200 Ok(())
2201 }
2202 "latency_hud" => {
2203 let (mut x, mut y) = (Align::End, Align::End);
2204 if let Some(at) = t.get::<Option<Table>>("at")? {
2205 x = parse_align(&at.get::<String>(1)?)?;
2206 y = parse_align(&at.get::<String>(2)?)?;
2207 }
2208 widgets::latency_hud_at(ui, x, y);
2209 Ok(())
2210 }
2211 "button" => {
2212 let label: String = t.get("label")?;
2222 let text: String = t
2223 .get::<Option<String>>("text")?
2224 .unwrap_or_else(|| label.clone());
2225 let key: String = t
2226 .get::<Option<String>>("key")?
2227 .unwrap_or_else(|| label.clone());
2228 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
2229 Some(v) => lua_to_value(&v)?,
2230 None => Value::Null,
2231 };
2232 let mut out = PropsOut::new();
2233 out.spec = widgets::button_spec(&ui.theme(), &ui.metrics())
2234 .on_click(payload)
2235 .label(label.as_str());
2236 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2237 out.apply_tooltip(&hint);
2238 }
2239 with_refs(ui, |refs| {
2240 for name in ["description", "disabled", "accent"] {
2241 let v = t.get::<mlua::Value>(name)?;
2242 if v.is_nil() {
2243 continue;
2244 }
2245 let def = schema::by_snake_name(name).expect("a button row");
2246 if let Some(parsed) =
2247 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2248 {
2249 schema::apply(def, parsed, &mut out).map_err(bad)?;
2250 }
2251 }
2252 Ok(())
2253 })?;
2254 match t.get::<Option<u64>>("index")? {
2257 Some(i) => widgets::button_indexed(ui, i, &text, out.spec, out.tooltip.as_deref()),
2258 None => widgets::button_with(ui, &key, &text, out.spec, out.tooltip.as_deref()),
2259 }
2260 Ok(())
2261 }
2262 "checkbox" | "radio" | "switch" => {
2263 let kind = match ty {
2268 "checkbox" => widgets::Toggle::Checkbox,
2269 "radio" => widgets::Toggle::Radio,
2270 _ => widgets::Toggle::Switch,
2271 };
2272 let label: String = t.get("label")?;
2273 let text: String = t
2274 .get::<Option<String>>("text")?
2275 .unwrap_or_else(|| label.clone());
2276 let key: String = t
2277 .get::<Option<String>>("key")?
2278 .unwrap_or_else(|| label.clone());
2279 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
2280 Some(v) => lua_to_value(&v)?,
2281 None => Value::Null,
2282 };
2283 let mut out = PropsOut::new();
2284 out.spec = widgets::toggle_spec(&ui.metrics())
2285 .on_click(payload)
2286 .label(label.as_str());
2287 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2288 out.apply_tooltip(&hint);
2289 }
2290 apply_named_rows(
2291 ui,
2292 t,
2293 &["description", "disabled", "checked", "mixed"],
2294 &mut out,
2295 )?;
2296 widgets::toggle_with(ui, kind, &key, &text, out.spec, out.tooltip.as_deref());
2297 Ok(())
2298 }
2299 "slider" => {
2300 let label: String = t.get("label")?;
2304 let key: String = t
2305 .get::<Option<String>>("key")?
2306 .unwrap_or_else(|| label.clone());
2307 let mut out = PropsOut::new();
2308 out.spec = widgets::slider_spec(&ui.metrics()).label(label.as_str());
2309 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2310 out.apply_tooltip(&hint);
2311 }
2312 apply_named_rows(
2313 ui,
2314 t,
2315 &[
2316 "description",
2317 "disabled",
2318 "value_now",
2319 "value_min",
2320 "value_max",
2321 "value_step",
2322 "value_text",
2323 "on_change",
2324 "width",
2325 "min_width",
2326 "max_width",
2327 ],
2328 &mut out,
2329 )?;
2330 widgets::slider_with(ui, &key, out.spec, out.tooltip.as_deref());
2331 Ok(())
2332 }
2333 other => Err(mlua::Error::runtime(format!("unknown node type '{other}'"))),
2334 }
2335}
2336
2337fn apply_named_rows(
2342 ui: &mut Ui<'_>,
2343 t: &Table,
2344 names: &[&str],
2345 out: &mut PropsOut,
2346) -> mlua::Result<()> {
2347 with_refs(ui, |refs| {
2348 for name in names {
2349 let v = t.get::<mlua::Value>(*name)?;
2350 if v.is_nil() {
2351 continue;
2352 }
2353 let def = schema::by_snake_name(name).expect("a schema row");
2354 if let Some(parsed) =
2355 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2356 {
2357 schema::apply(def, parsed, out).map_err(bad)?;
2358 }
2359 }
2360 Ok(())
2361 })
2362}
2363
2364struct SpanPart {
2365 text: String,
2366 bold: bool,
2367 italic: bool,
2368 underline: bool,
2369 underline_color: Option<Color>,
2372 underline_style: Option<kui_core::UnderlineStyle>,
2373 strikethrough: bool,
2374 color: Option<Color>,
2375 bg: Option<Color>,
2376 bg_radius: f32,
2379}
2380
2381fn span_of(p: &SpanPart) -> Span<'_> {
2382 let mut s = Span::new(&p.text);
2383 if p.bold {
2384 s = s.bold();
2385 }
2386 if p.italic {
2387 s = s.italic();
2388 }
2389 if p.underline {
2390 s = s.underline();
2391 }
2392 if let Some(c) = p.underline_color {
2393 s = s.underline_color(c);
2394 }
2395 if let Some(st) = p.underline_style {
2396 s = s.underline_style(st);
2397 }
2398 if p.strikethrough {
2399 s = s.strikethrough();
2400 }
2401 if let Some(c) = p.color {
2402 s = s.color(c);
2403 }
2404 if let Some(c) = p.bg {
2405 s = s.bg(c);
2406 }
2407 if p.bg_radius > 0.0 {
2408 s = s.bg_radius(p.bg_radius);
2409 }
2410 s
2411}
2412
2413fn collect_spans(spans: &Table, refs: &mut Refs<'_>) -> mlua::Result<Vec<SpanPart>> {
2415 let mut out = Vec::new();
2416 for item in spans.sequence_values::<mlua::Value>() {
2417 match item? {
2418 mlua::Value::String(s) => out.push(SpanPart {
2419 text: s.to_str()?.to_string(),
2420 bold: false,
2421 italic: false,
2422 underline: false,
2423 underline_color: None,
2424 underline_style: None,
2425 strikethrough: false,
2426 color: None,
2427 bg: None,
2428 bg_radius: 0.0,
2429 }),
2430 mlua::Value::Table(t) => {
2431 let text: String = t
2432 .get::<Option<String>>(1)?
2433 .ok_or_else(|| bad("span table needs its text at [1]"))?;
2434 let color = match t.get::<mlua::Value>("color")? {
2435 mlua::Value::Nil => None,
2436 v => parse_color(&v, refs)?,
2437 };
2438 let bg = match t.get::<mlua::Value>("bg")? {
2439 mlua::Value::Nil => None,
2440 v => parse_color(&v, refs)?,
2441 };
2442 let underline_color = match t.get::<mlua::Value>("underline_color")? {
2443 mlua::Value::Nil => None,
2444 v => parse_color(&v, refs)?,
2445 };
2446 let underline_style = match t.get::<Option<String>>("underline_style")? {
2447 None => None,
2448 Some(name) => Some(
2449 kui_core::UnderlineStyle::NAMES
2450 .iter()
2451 .position(|n| *n == name)
2452 .map(|i| kui_core::UnderlineStyle::from_index(i as u32))
2453 .ok_or_else(|| {
2454 bad(format!(
2455 "underline_style must be one of {}, got {name:?}",
2456 kui_core::UnderlineStyle::NAMES.join(" | ")
2457 ))
2458 })?,
2459 ),
2460 };
2461 out.push(SpanPart {
2462 text,
2463 bold: t.get::<Option<bool>>("bold")?.unwrap_or(false),
2464 italic: t.get::<Option<bool>>("italic")?.unwrap_or(false),
2465 underline: t.get::<Option<bool>>("underline")?.unwrap_or(false),
2466 underline_color,
2467 underline_style,
2468 strikethrough: t.get::<Option<bool>>("strikethrough")?.unwrap_or(false),
2469 color,
2470 bg,
2471 bg_radius: t.get::<Option<f32>>("bg_radius")?.unwrap_or(0.0).max(0.0),
2472 });
2473 }
2474 other => {
2475 return Err(bad(format!(
2476 "span must be a string or table, got {}",
2477 other.type_name()
2478 )));
2479 }
2480 }
2481 }
2482 Ok(out)
2483}
2484
2485pub fn parse_tokens(t: &Table) -> mlua::Result<kui_core::Tokens> {
2495 let mut out = kui_core::Tokens::new();
2496 for pair in t.pairs::<String, mlua::Value>() {
2497 let (k, _) = pair?;
2498 if k != "colors" && k != "lengths" {
2499 return Err(bad(format!("tokens: unknown key {k:?} (colors, lengths)")));
2500 }
2501 }
2502 if let Some(colors) = t.get::<Option<Table>>("colors")? {
2503 let mut entries: Vec<(String, mlua::Value)> = colors
2504 .pairs::<String, mlua::Value>()
2505 .collect::<mlua::Result<_>>()?;
2506 entries.sort_by(|a, b| a.0.cmp(&b.0));
2507 let mut derived: Vec<(String, String, Vec<kui_core::ColorOp>)> = Vec::new();
2509 for (name, v) in entries {
2510 out = match &v {
2511 mlua::Value::Table(t) if t.contains_key("from")? => {
2512 let (from, ops) = parse_recipe(&name, t)?;
2513 derived.push((name, from, ops));
2514 out
2515 }
2516 mlua::Value::Table(halves) => {
2517 let half = |k: &str| -> mlua::Result<Color> {
2518 match halves.get::<mlua::Value>(k)? {
2519 mlua::Value::Nil => Err(bad(format!(
2520 "tokens.colors.{name}: needs both light and dark"
2521 ))),
2522 v => parse_color_value(&v),
2523 }
2524 };
2525 out.color_themed(&name, half("light")?, half("dark")?)
2526 }
2527 v => out.color(
2528 &name,
2529 parse_color_value(v).map_err(|e| bad(format!("tokens.colors.{name}: {e}")))?,
2530 ),
2531 };
2532 }
2533 while !derived.is_empty() {
2536 let ready = derived.iter().position(|(_, from, ops)| {
2537 let known = |s: &str| kui_core::tokens::is_role(s) || out.color_id(s).is_some();
2538 known(from)
2539 && ops.iter().all(|op| match op {
2540 kui_core::ColorOp::Mix(c, _) | kui_core::ColorOp::Readable(c, _) => {
2541 known(c)
2542 }
2543 _ => true,
2544 })
2545 });
2546 let (name, from, ops) = derived.remove(ready.unwrap_or(0));
2547 out = out.derive(&name, &from, ops);
2548 }
2549 }
2550 if let Some(lengths) = t.get::<Option<Table>>("lengths")? {
2551 let mut entries: Vec<(String, mlua::Value)> = lengths
2552 .pairs::<String, mlua::Value>()
2553 .collect::<mlua::Result<_>>()?;
2554 entries.sort_by(|a, b| a.0.cmp(&b.0));
2555 for (name, v) in entries {
2556 let px = number(&v)
2557 .ok_or_else(|| bad(format!("tokens.lengths.{name}: a length is a number")))?;
2558 out = out.length(&name, px);
2559 }
2560 }
2561 Ok(out)
2562}
2563
2564fn parse_recipe(name: &str, t: &Table) -> mlua::Result<(String, Vec<kui_core::ColorOp>)> {
2569 for pair in t.pairs::<String, mlua::Value>() {
2570 let (k, _) = pair?;
2571 if k != "from" && k != "ops" {
2572 return Err(bad(format!(
2573 "tokens.colors.{name}: unknown key {k:?} (from, ops)"
2574 )));
2575 }
2576 }
2577 let from = match t.get::<mlua::Value>("from")? {
2578 mlua::Value::String(s) => s.to_str()?.to_string(),
2579 _ => {
2580 return Err(bad(format!(
2581 "tokens.colors.{name}.from names a colour token or role"
2582 )));
2583 }
2584 };
2585 let ops = match t.get::<mlua::Value>("ops")? {
2586 mlua::Value::Nil => Vec::new(),
2587 mlua::Value::Table(list) => {
2588 let tuples: Vec<Table> = match list.get::<mlua::Value>(1)? {
2590 mlua::Value::String(_) => vec![list],
2591 _ => list
2592 .sequence_values::<Table>()
2593 .collect::<mlua::Result<_>>()?,
2594 };
2595 tuples
2596 .iter()
2597 .map(|op| parse_color_op(name, op))
2598 .collect::<mlua::Result<_>>()?
2599 }
2600 _ => {
2601 return Err(bad(format!(
2602 "tokens.colors.{name}.ops is a list of {{ verb, … }} tuples"
2603 )));
2604 }
2605 };
2606 Ok((from, ops))
2607}
2608
2609fn parse_color_op(name: &str, op: &Table) -> mlua::Result<kui_core::ColorOp> {
2610 let verb = match op.get::<mlua::Value>(1)? {
2611 mlua::Value::String(s) => s.to_str()?.to_string(),
2612 _ => {
2613 return Err(bad(format!(
2614 "tokens.colors.{name}.ops: an op starts with its verb"
2615 )));
2616 }
2617 };
2618 let Some(takes_color) = kui_core::ColorOp::takes_color(&verb) else {
2619 return Err(bad(format!(
2620 "tokens.colors.{name}.ops: unknown verb {verb:?} (lift, darken, raise, alpha, mix, readable)"
2621 )));
2622 };
2623 let arity = if takes_color { 3 } else { 2 };
2624 if op.raw_len() != arity {
2625 return Err(bad(format!(
2626 "tokens.colors.{name}.ops: {verb} takes {} — {{ \"{verb}\", {} }}",
2627 if takes_color {
2628 "a colour and a number"
2629 } else {
2630 "one number"
2631 },
2632 if takes_color { "token, t" } else { "t" }
2633 )));
2634 }
2635 let color = if takes_color {
2636 match op.get::<mlua::Value>(2)? {
2637 mlua::Value::String(s) => Some(s.to_str()?.to_string()),
2638 _ => {
2639 return Err(bad(format!(
2640 "tokens.colors.{name}.ops: {verb}'s colour is a token or role name"
2641 )));
2642 }
2643 }
2644 } else {
2645 None
2646 };
2647 let n = number(&op.get::<mlua::Value>(arity as i64)?).ok_or_else(|| {
2648 bad(format!(
2649 "tokens.colors.{name}.ops: {verb}'s number is a number"
2650 ))
2651 })?;
2652 Ok(kui_core::ColorOp::parse(&verb, color.as_deref(), n).expect("checked above"))
2653}
2654
2655pub type Refs<'a> = kui_core::NameRefs<'a>;
2661
2662fn with_refs<R>(
2666 ui: &mut Ui<'_>,
2667 f: impl FnOnce(&mut Refs<'_>) -> mlua::Result<R>,
2668) -> mlua::Result<R> {
2669 let (r, errors, families) = {
2670 let mut refs = Refs::new(ui.core().token_lookup());
2671 let r = f(&mut refs);
2672 (r, refs.take_missed(), refs.take_missed_families())
2673 };
2674 for e in errors {
2675 ui.core().warn_unknown_token(&e);
2676 }
2677 for name in families {
2678 ui.core().warn_unknown_family(&name);
2679 }
2680 r
2681}
2682
2683fn reference(v: &mlua::Value) -> mlua::Result<Option<String>> {
2685 if let mlua::Value::String(s) = v
2686 && let Some(name) = kui_core::tokens::reference(&s.to_str()?)
2687 {
2688 return Ok(Some(name.to_string()));
2689 }
2690 Ok(None)
2691}
2692
2693fn dash_of(t: &Table) -> mlua::Result<kui_core::Dash> {
2697 let offset = t.get::<Option<f32>>("dash_offset")?.unwrap_or(0.0);
2698 let lengths: Vec<f32> = match t.get::<mlua::Value>("dash")? {
2699 mlua::Value::Nil => return Ok(kui_core::Dash::SOLID),
2700 mlua::Value::Table(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
2701 mlua::Value::Integer(n) => vec![n as f32],
2702 mlua::Value::Number(n) => vec![n as f32],
2703 _ => Vec::new(),
2704 };
2705 kui_core::Dash::of(&lengths)
2706 .map(|d| d.offset(offset))
2707 .ok_or_else(|| bad("dash is a length, {mark, gap} or {mark, gap, mark, gap}"))
2708}
2709
2710fn length_of(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<f32>> {
2712 if let Some(name) = reference(v)? {
2713 return Ok(refs.length(&name));
2714 }
2715 number(v)
2716 .map(Some)
2717 .ok_or_else(|| bad("expected a number or a \"$token\""))
2718}
2719
2720pub fn parse_props(t: &Table, is_row: bool, refs: &mut Refs<'_>) -> mlua::Result<PropsOut> {
2728 let mut out = PropsOut::new();
2729 if is_row {
2730 out.spec = kui_core::NodeSpec::row();
2731 }
2732 match t.get::<mlua::Value>("size")? {
2733 mlua::Value::Nil => {}
2734 v => {
2735 if let Some(size) = length_of(&v, refs)? {
2736 out.style = kui_core::TextStyle::new(size);
2737 }
2738 }
2739 }
2740 match t.get::<mlua::Value>("radius")? {
2743 mlua::Value::Nil => {}
2744 v => {
2745 if let Some(r) = length_of(&v, refs)? {
2746 out.with_spec(|s| s.radius(r));
2747 }
2748 }
2749 }
2750 let mut overflow = 0;
2754 for pair in t.pairs::<mlua::Value, mlua::Value>() {
2755 let (k, v) = pair?;
2756 let mlua::Value::String(k) = k else { continue };
2757 let k = k.to_str()?;
2758 match k.as_ref() {
2759 "size" | "radius" => {}
2760 "pad" => {
2761 let pad = parse_pad(&v, refs)?;
2762 out.apply_pad(pad);
2763 }
2764 "border" => {
2765 let b = match v {
2766 mlua::Value::Table(b) => b,
2767 _ => return Err(bad("border must be a table {w=, color=}")),
2768 };
2769 let w = length_of(&b.get::<mlua::Value>("w")?, refs)?.unwrap_or(0.0);
2770 let c = parse_color(&b.get::<mlua::Value>("color")?, refs)?
2771 .unwrap_or(Color::TRANSPARENT);
2772 out.with_spec(|s| s.border(w, c));
2773 }
2774 "clip" => overflow |= bit(&v, kui_core::OVERFLOW_CLIP),
2775 "scroll_x" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_X),
2776 "scroll" | "scroll_y" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_Y),
2777 "float" => {
2778 let cfg = parse_float(&v)?;
2779 out.with_spec(|s| s.float(cfg));
2780 }
2781 "key_focus" => out.key_focus = truthy(&v),
2782 "key" => {
2783 let mlua::Value::String(s) = &v else {
2784 return Err(bad("key must be a string"));
2785 };
2786 out.key = Some(s.to_str()?.to_string());
2787 }
2788 "index" => {
2789 let Some(i) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
2790 return Err(bad("index must be a number (the row's data index)"));
2791 };
2792 out.index = Some(i.max(0.0) as u64);
2793 }
2794 "row_count" => {
2795 let Some(n) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
2796 return Err(bad(
2797 "row_count must be a number (how many indexed rows the list has)",
2798 ));
2799 };
2800 out.row_count = Some(n.max(0.0) as u64);
2801 }
2802 "tooltip" => {
2803 let mlua::Value::String(s) = &v else {
2804 return Err(bad("tooltip must be a string"));
2805 };
2806 out.apply_tooltip(s.to_str()?.as_ref());
2807 }
2808 name => {
2809 let name = schema::lua_alias(name).unwrap_or(name);
2813 let Some(def) = schema::by_snake_name(name) else {
2814 continue;
2815 };
2816 if let Some(parsed) =
2817 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2818 {
2819 schema::apply(def, parsed, &mut out).map_err(bad)?;
2820 }
2821 }
2822 }
2823 }
2824 out.with_spec(|s| s.overflow_bits(overflow));
2825 Ok(out)
2826}
2827
2828fn truthy(v: &mlua::Value) -> bool {
2829 matches!(v, mlua::Value::Boolean(true))
2830}
2831
2832fn bit(v: &mlua::Value, bit: u32) -> u32 {
2834 if truthy(v) { bit } else { 0 }
2835}
2836
2837fn number(v: &mlua::Value) -> Option<f32> {
2838 match v {
2839 mlua::Value::Number(n) => Some(*n as f32),
2840 mlua::Value::Integer(n) => Some(*n as f32),
2841 _ => None,
2842 }
2843}
2844
2845fn parse_value(kind: &Kind, v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Parsed>> {
2847 Ok(Some(match kind {
2848 Kind::F32 => match length_of(v, refs)? {
2849 Some(px) => Parsed::F32(px),
2850 None => return Ok(None),
2851 },
2852 Kind::Color => match parse_color(v, refs)? {
2853 Some(c) => Parsed::Color(c),
2854 None => return Ok(None),
2855 },
2856 Kind::Flag => {
2857 if truthy(v) {
2858 Parsed::Flag
2859 } else {
2860 return Ok(None);
2861 }
2862 }
2863 Kind::Enum(names) => {
2864 let mlua::Value::String(s) = v else {
2865 return Err(bad(format!("expected one of {names:?}")));
2866 };
2867 Parsed::Enum(schema::enum_index(names, &s.to_str()?).map_err(bad)?)
2868 }
2869 Kind::Sizing => match parse_sizing(v, refs)? {
2870 Some(s) => Parsed::Sizing(s),
2871 None => return Ok(None),
2872 },
2873 Kind::Min | Kind::Max => Parsed::Bound(match v {
2878 v if reference(v)?.is_some() => match length_of(v, refs)? {
2879 Some(px) => kui_core::Bound::Px(px),
2880 None => return Ok(None),
2881 },
2882 mlua::Value::String(s) => {
2885 let s = s.to_str()?;
2886 let b = if matches!(kind, Kind::Min) {
2887 schema::min_str(&s)
2888 } else {
2889 schema::max_str(&s)
2890 };
2891 match kept(b)? {
2892 Some(b) => b,
2893 None => return Ok(None),
2894 }
2895 }
2896 mlua::Value::Table(_) => match kept(kui_core::calc::bound_value(&size_value(v)?))? {
2897 Some(b) => b,
2898 None => return Ok(None),
2899 },
2900 v => kui_core::Bound::Px(
2901 number(v).ok_or_else(|| bad("expected a number, a string or a size table"))?,
2902 ),
2903 }),
2904 Kind::Msg | Kind::Tag => Parsed::Msg(lua_to_value(v)?),
2905 Kind::Str => {
2906 let mlua::Value::String(s) = v else {
2907 return Err(bad("expected a string"));
2908 };
2909 Parsed::Str(s.to_str()?.to_string())
2910 }
2911 Kind::Family => {
2914 let mlua::Value::String(s) = v else {
2915 return Err(bad("expected a family name"));
2916 };
2917 Parsed::Family(refs.family(&s.to_str()?))
2918 }
2919 Kind::Resource => match v {
2920 mlua::Value::Integer(n) => Parsed::Resource(*n as u64),
2921 mlua::Value::Number(n) => Parsed::Resource(*n as u64),
2922 _ => return Err(bad("expected a resource handle (integer)")),
2923 },
2924 Kind::Keyframes => Parsed::Keyframes(
2927 kui_core::keyframes::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
2928 ),
2929 Kind::Enter => {
2930 Parsed::Enter(kui_core::enter::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?)
2931 }
2932 Kind::Gradient => Parsed::Gradient(
2933 kui_core::gradient::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
2934 ),
2935 }))
2936}
2937
2938fn parse_color_value(v: &mlua::Value) -> mlua::Result<Color> {
2941 match v {
2942 mlua::Value::Integer(n) => Ok(schema::color_num(*n as u32)),
2943 mlua::Value::Number(n) => Ok(schema::color_num(*n as u32)),
2944 mlua::Value::String(s) => schema::color_hex_str(&s.to_str()?).map_err(bad),
2945 _ => Err(bad("color must be a 0xRRGGBBAA integer or \"#hex\" string")),
2946 }
2947}
2948
2949fn parse_color(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Color>> {
2951 if let Some(name) = reference(v)? {
2952 return Ok(refs.color(&name));
2953 }
2954 parse_color_value(v)
2955 .map(Some)
2956 .map_err(|_| bad("color must be a 0xRRGGBBAA integer, a \"#hex\" string or a \"$token\""))
2957}
2958
2959fn size_value(v: &mlua::Value) -> mlua::Result<Value> {
2964 fn check(v: &Value) -> mlua::Result<()> {
2965 match v {
2966 Value::Map(m) => {
2967 for (k, x) in m {
2968 if k == "percent" {
2969 return Err(bad("a percentage is { pct = n } in Lua"));
2970 }
2971 check(x)?;
2972 }
2973 Ok(())
2974 }
2975 Value::List(xs) => xs.iter().try_for_each(check),
2976 _ => Ok(()),
2977 }
2978 }
2979 let value = lua_to_value(v)?;
2980 check(&value)?;
2981 Ok(value)
2982}
2983
2984fn kept<T>(r: Result<T, String>) -> mlua::Result<Option<T>> {
2988 match r {
2989 Ok(v) => Ok(Some(v)),
2990 Err(e) if kui_core::calc::is_full(&e) => Ok(None),
2991 Err(e) => Err(bad(e)),
2992 }
2993}
2994
2995fn parse_sizing(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Sizing>> {
2996 if let Some(name) = reference(v)? {
2997 return Ok(refs.length(&name).map(Sizing::Fixed));
2998 }
2999 Ok(Some(match v {
3000 mlua::Value::Number(n) => Sizing::Fixed(*n as f32),
3001 mlua::Value::Integer(n) => Sizing::Fixed(*n as f32),
3002 mlua::Value::String(s) => match kept(schema::sizing_str(&s.to_str()?))? {
3003 Some(s) => s,
3004 None => return Ok(None),
3005 },
3006 mlua::Value::Table(t) => {
3007 if let Some(p) = t.get::<Option<f32>>("pct")? {
3008 Sizing::Percent(p / 100.0)
3009 } else if let Some(f) = t.get::<Option<f32>>("grow")? {
3010 Sizing::Grow(f)
3011 } else {
3012 match kept(kui_core::calc::sizing_value(&size_value(v)?)).map_err(|e| {
3015 bad(format!(
3016 "sizing table needs pct, grow or a size expression: {e}"
3017 ))
3018 })? {
3019 Some(s) => s,
3020 None => return Ok(None),
3021 }
3022 }
3023 }
3024 _ => return Err(bad("invalid sizing value")),
3025 }))
3026}
3027
3028fn parse_pad(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<PadShorthand> {
3032 match v {
3033 mlua::Value::Table(t) => {
3034 let mut edge = |k: &str| -> mlua::Result<Option<f32>> {
3035 match t.get::<mlua::Value>(k)? {
3036 mlua::Value::Nil => Ok(None),
3037 v => length_of(&v, refs),
3038 }
3039 };
3040 Ok(PadShorthand {
3041 all: edge("all")?,
3042 x: edge("x")?,
3043 y: edge("y")?,
3044 l: edge("l")?,
3045 r: edge("r")?,
3046 t: edge("t")?,
3047 b: edge("b")?,
3048 })
3049 }
3050 v => Ok(PadShorthand {
3051 all: length_of(v, refs).map_err(|_| bad("invalid padding value"))?,
3052 ..PadShorthand::default()
3053 }),
3054 }
3055}
3056
3057fn parse_align(s: &str) -> mlua::Result<Align> {
3058 schema::enum_index(schema::ALIGNS, s)
3059 .map(schema::align_idx)
3060 .map_err(bad)
3061}
3062
3063fn float_preset(name: &str) -> mlua::Result<FloatConfig> {
3066 FloatConfig::preset(name).ok_or_else(|| {
3067 bad(format!(
3068 "bad float preset '{name}' (one of {})",
3069 kui_core::FLOAT_PRESETS.join(" | ")
3070 ))
3071 })
3072}
3073
3074fn parse_attach(f: &Table, key: &str) -> mlua::Result<Option<(Align, Align)>> {
3076 let Some(at) = f.get::<Option<Table>>(key)? else {
3077 return Ok(None);
3078 };
3079 Ok(Some((
3080 parse_align(&at.get::<String>(1)?)?,
3081 parse_align(&at.get::<String>(2)?)?,
3082 )))
3083}
3084
3085fn parse_float(v: &mlua::Value) -> mlua::Result<FloatConfig> {
3086 let f = match v {
3087 mlua::Value::String(s) => return float_preset(&s.to_str()?),
3088 mlua::Value::Table(f) => f,
3089 _ => return Err(bad("float must be a preset string or a table")),
3090 };
3091 let self_at = match parse_attach(f, "self")? {
3094 Some(at) => Some(at),
3095 None => parse_attach(f, "self_at")?,
3096 };
3097 Ok(FloatConfig::build(
3098 match f.get::<Option<String>>("anchor")? {
3099 Some(name) => float_preset(&name)?,
3100 None => FloatConfig::parent(),
3101 },
3102 parse_attach(f, "at")?,
3103 self_at,
3104 f.get::<Option<f32>>("dx")?,
3105 f.get::<Option<f32>>("dy")?,
3106 f.get::<Option<bool>>("fit")?.unwrap_or(false),
3107 f.get::<Option<bool>>("clip")?.unwrap_or(false),
3108 ))
3109}
3110
3111pub const MAX_VALUE_DEPTH: usize = 64;
3118
3119pub fn lua_to_value(v: &mlua::Value) -> mlua::Result<Value> {
3137 to_value(
3138 v,
3139 &mut ValuePath {
3140 depth: 0,
3141 seen: Vec::new(),
3142 },
3143 )
3144}
3145
3146const VALUE_TRACKED_PAST: usize = 8;
3152
3153struct ValuePath {
3156 depth: usize,
3157 seen: Vec<*const std::ffi::c_void>,
3158}
3159
3160fn to_value(v: &mlua::Value, path: &mut ValuePath) -> mlua::Result<Value> {
3164 Ok(match v {
3165 mlua::Value::Nil => Value::Null,
3166 mlua::Value::Boolean(b) => Value::Bool(*b),
3167 mlua::Value::Integer(i) => Value::Int(*i),
3168 mlua::Value::Number(n) => Value::Float(*n),
3169 mlua::Value::String(s) => Value::Str(s.to_str()?.to_string()),
3170 mlua::Value::Table(t) => {
3171 let tracked = path.depth >= VALUE_TRACKED_PAST;
3172 if tracked {
3173 let at = t.to_pointer();
3174 if path.seen.contains(&at) {
3175 return Err(bad("a table that holds itself cannot be a value"));
3176 }
3177 path.seen.push(at);
3178 }
3179 if path.depth >= MAX_VALUE_DEPTH {
3180 return Err(bad(format!("a value nested past {MAX_VALUE_DEPTH} tables")));
3181 }
3182 path.depth += 1;
3183 let len = t.raw_len();
3184 let value = if len > 0 {
3185 let mut list = Vec::with_capacity(len);
3186 for item in t.sequence_values::<mlua::Value>() {
3187 list.push(to_value(&item?, path)?);
3188 }
3189 Value::List(list)
3190 } else {
3191 let mut map = Vec::new();
3192 for pair in t.pairs::<String, mlua::Value>() {
3193 let (k, v) = pair?;
3194 map.push((k, to_value(&v, path)?));
3195 }
3196 Value::Map(map)
3197 };
3198 path.depth -= 1;
3199 if tracked {
3200 path.seen.pop();
3201 }
3202 value
3203 }
3204 other => {
3205 return Err(mlua::Error::runtime(format!(
3206 "cannot convert {} to event payload",
3207 other.type_name()
3208 )));
3209 }
3210 })
3211}
3212
3213pub fn value_to_lua(lua: &Lua, v: &Value) -> mlua::Result<mlua::Value> {
3216 Ok(match v {
3217 Value::Null => mlua::Value::Nil,
3218 Value::Bool(b) => mlua::Value::Boolean(*b),
3219 Value::Int(i) => mlua::Value::Integer(*i),
3220 Value::Float(f) => mlua::Value::Number(*f),
3221 Value::Str(s) => mlua::Value::String(lua.create_string(s)?),
3222 Value::List(items) => {
3223 let t = lua.create_table_with_capacity(items.len(), 0)?;
3224 for (i, item) in items.iter().enumerate() {
3225 t.set(i + 1, value_to_lua(lua, item)?)?;
3226 }
3227 mlua::Value::Table(t)
3228 }
3229 Value::Map(entries) => {
3230 let t = lua.create_table_with_capacity(0, entries.len())?;
3231 for (k, v) in entries {
3232 t.set(k.as_str(), value_to_lua(lua, v)?)?;
3233 }
3234 mlua::Value::Table(t)
3235 }
3236 })
3237}
3238
3239#[cfg(test)]
3240mod tests {
3241 use super::*;
3242 use kui_core::{
3243 Core, Edges, FontFamily, InputEvent, NodeSpec, OriginId, Rect, Size, TextStyle, Vec2,
3244 WindowButton, WindowId,
3245 };
3246
3247 #[test]
3248 fn value_round_trips_through_lua() {
3249 let lua = Lua::new();
3250 let original = Value::map([
3251 ("kind", "inc".into()),
3252 ("by", Value::Int(2)),
3253 (
3254 "weights",
3255 Value::List(vec![Value::Float(0.5), Value::Float(1.5)]),
3256 ),
3257 ("enabled", Value::Bool(true)),
3258 ]);
3259 let lua_v = value_to_lua(&lua, &original).unwrap();
3260 let back = lua_to_value(&lua_v).unwrap();
3261 assert_eq!(back.get_str("kind"), Some("inc"));
3262 assert_eq!(back.get_int("by"), Some(2));
3263 assert_eq!(back.get_bool("enabled"), Some(true));
3264 match back.get("weights") {
3265 Some(Value::List(items)) => assert_eq!(items.len(), 2),
3266 other => panic!("expected list, got {other:?}"),
3267 }
3268 }
3269
3270 fn test_refs() -> Refs<'static> {
3274 let core: &'static Core = Box::leak(Box::new(Core::new()));
3275 Refs::new(core.token_lookup())
3276 }
3277
3278 fn eval_table(lua: &Lua, src: &str) -> Table {
3279 lua.load(src).eval().unwrap()
3280 }
3281
3282 #[test]
3284 fn schema_props_match_the_rust_builder() {
3285 let lua = Lua::new();
3286 let t = eval_table(
3287 &lua,
3288 r##"{
3289 width = "grow", height = {pct = 50},
3290 min_width = 10, max_width = 500, min_height = 5, max_height = 300,
3291 pad = {l = 1, r = 2, t = 3, b = 4}, gap = 8,
3292 main_align = "center", cross_align = "end", center = false,
3293 bg = "#14161e", radius = 6, border = {w = 1, color = 0x2a2d3aff},
3294 clip = true, scroll = true, scroll_x = true,
3295 float = {anchor = "viewport", at = {"end", "end"}, self_at = {"end", "end"},
3296 dx = -8, dy = -8, fit = true},
3297 hoverable = true, window = "close",
3298 on_click = {kind = "hit"}, on_drag = "d", on_key = 7, key_up = true,
3299 modal = "dlg", on_context_menu = {kind = "menu"},
3300 initial_focus = true,
3301 key = "panel", key_focus = true,
3302 }"##,
3303 );
3304 let p = parse_props(&t, true, &mut test_refs()).unwrap();
3305 let expected = NodeSpec::row()
3306 .grow_width()
3307 .height(Sizing::Percent(0.5))
3308 .min_width(10.0)
3309 .max_width(500.0)
3310 .min_height(5.0)
3311 .max_height(300.0)
3312 .padding(Edges {
3313 l: 1.0,
3314 r: 2.0,
3315 t: 3.0,
3316 b: 4.0,
3317 })
3318 .gap(8.0)
3319 .main_align(Align::Center)
3320 .cross_align(Align::End)
3321 .bg(Color::hex(0x14161eff))
3322 .radius(6.0)
3323 .border(1.0, Color::hex(0x2a2d3aff))
3324 .clip()
3325 .scroll_y()
3326 .scroll_x()
3327 .float(
3328 FloatConfig::viewport()
3329 .inside(Align::End, Align::End)
3330 .offset(-8.0, -8.0)
3331 .fit(),
3332 )
3333 .hoverable()
3334 .window_button(WindowButton::Close)
3335 .on_click(Value::map([("kind", "hit".into())]))
3336 .on_drag("d")
3337 .on_key(Value::Int(7))
3338 .key_up()
3339 .modal("dlg")
3340 .initial_focus()
3341 .on_context_menu(Value::map([("kind", "menu".into())]));
3342 assert_eq!(p.spec, expected);
3343 assert_eq!(p.key.as_deref(), Some("panel"));
3344 assert!(p.key_focus);
3345 }
3346
3347 #[test]
3350 fn a_min_is_a_number_or_fit() {
3351 let lua = Lua::new();
3352 let t = eval_table(&lua, r#"{ min_width = "fit", min_height = 3 }"#);
3353 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3354 let expected = NodeSpec::column()
3355 .min_width(kui_core::Min::FIT)
3356 .min_height(3.0);
3357 assert_eq!(p.spec, expected);
3358 let t = eval_table(&lua, r#"{ min_width = "grow" }"#);
3359 let err = parse_props(&t, false, &mut test_refs())
3360 .unwrap_err()
3361 .to_string();
3362 assert!(err.contains("bad min"), "{err}");
3363 }
3364
3365 #[test]
3369 fn the_lua_composites_resolve_the_way_the_core_says() {
3370 let lua = Lua::new();
3371 let t = eval_table(
3372 &lua,
3373 r#"{ pad = { all = 4, x = 10, b = 1 },
3374 float = { anchor = "below", dx = 6 } }"#,
3375 );
3376 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3377 assert_eq!(
3378 p.spec.layout.padding,
3379 Edges {
3380 l: 10.0,
3381 r: 10.0,
3382 t: 4.0,
3383 b: 1.0,
3384 }
3385 );
3386 assert_eq!(
3387 p.spec.layout.float,
3388 Some(FloatConfig::build(
3389 FloatConfig::below(),
3390 None,
3391 None,
3392 Some(6.0),
3393 None,
3394 false,
3395 false
3396 ))
3397 );
3398
3399 let by_self = eval_table(&lua, r#"{ float = { self = {"end", "start"} } }"#);
3401 let by_self_at = eval_table(&lua, r#"{ float = { self_at = {"end", "start"} } }"#);
3402 assert_eq!(
3403 parse_props(&by_self, false, &mut test_refs())
3404 .unwrap()
3405 .spec
3406 .layout
3407 .float,
3408 parse_props(&by_self_at, false, &mut test_refs())
3409 .unwrap()
3410 .spec
3411 .layout
3412 .float
3413 );
3414
3415 let bad_preset = eval_table(&lua, r#"{ float = "beneath" }"#);
3418 let e = parse_props(&bad_preset, false, &mut test_refs())
3419 .unwrap_err()
3420 .to_string();
3421 assert!(e.contains("beneath") && e.contains("below"), "{e}");
3422 }
3423
3424 #[test]
3425 fn text_style_props_match_the_rust_builder() {
3426 let lua = Lua::new();
3427 let t = eval_table(
3428 &lua,
3429 r#"{ size = 20, line_height = 30, color = 0x73d98cff, family = "mono" }"#,
3430 );
3431 let style = parse_props(&t, false, &mut test_refs()).unwrap().style;
3432 assert_eq!(
3433 style,
3434 TextStyle::new(20.0)
3435 .line_height(30.0)
3436 .color(Color::hex(0x73d98cff))
3437 .family(FontFamily::Mono)
3438 );
3439 let t = eval_table(&lua, r#"{ wrap = "none", max_lines = 2, ellipsis = true }"#);
3440 assert_eq!(
3441 parse_props(&t, false, &mut test_refs()).unwrap().style,
3442 TextStyle::default().nowrap().max_lines(2).ellipsis()
3443 );
3444 let t = eval_table(&lua, r##"{ color = "#fff", size = 12 }"##);
3447 assert_eq!(
3448 parse_props(&t, false, &mut test_refs()).unwrap().style,
3449 TextStyle::new(12.0).color(Color::hex(0xffffffff))
3450 );
3451 }
3452
3453 #[test]
3454 fn bad_values_name_the_prop() {
3455 let lua = Lua::new();
3456 let t = eval_table(&lua, r#"{ main_align = "middle" }"#);
3457 let e = parse_props(&t, false, &mut test_refs())
3458 .unwrap_err()
3459 .to_string();
3460 assert!(e.contains("main_align"), "{e}");
3461 assert!(e.contains("middle"), "{e}");
3462 }
3463
3464 fn frame(core: &mut Core, ext: &mut LuaExtension) -> usize {
3465 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3466 ui.set_origin(OriginId(1));
3467 ext.view(&Slot::root(), &mut ui).unwrap();
3468 ui.finish();
3469 core.output().0.quads.len()
3470 }
3471
3472 #[test]
3473 fn script_view_builds_ir_nodes() {
3474 let mut ext = LuaExtension::from_source(
3475 "test",
3476 r#"
3477 count = 41
3478 function view()
3479 return column { gap = 8, pad = 16, bg = 0x10121aff,
3480 text("count: " .. count, { size = 20 }),
3481 button { label = "bump", on_click = { kind = "bump" } },
3482 }
3483 end
3484 function on_event(ev)
3485 if ev.kind == "bump" then count = count + 1 end
3486 end
3487 "#,
3488 )
3489 .unwrap();
3490
3491 let mut core = Core::new();
3492 let quads = frame(&mut core, &mut ext);
3493 assert!(quads > 10, "expected panel/button/glyph quads, got {quads}");
3495
3496 ext.on_event(&UiEvent {
3498 origin: OriginId(1),
3499 key: Key::ROOT,
3500 payload: Value::map([("kind", "bump".into())]),
3501 window: WindowId::MAIN,
3502 slot: None,
3503 });
3504 let count: i64 = ext.lua.globals().get("count").unwrap();
3505 assert_eq!(count, 42);
3506 }
3507
3508 #[test]
3514 fn a_devtools_tab_calls_its_view_only_while_on_show() {
3515 let mut ext = LuaExtension::from_source(
3516 "test",
3517 r#"
3518 calls = 0
3519 function view(env)
3520 return column { gap = 8,
3521 text("app"),
3522 devtools_tab { name = "syntax", label = "Tree-sitter", view = function()
3523 calls = calls + 1
3524 return column { text("from lua"),
3525 button { label = "jump", on_click = { kind = "jump" } } }
3526 end },
3527 devtools_tab { name = "plug", label = "Plugin", slot = "ts/panel" },
3528 devtools_tab { name = "bad", label = "Bad" },
3529 }
3530 end
3531 "#,
3532 )
3533 .unwrap();
3534 let mut core = Core::new();
3535 core.set_devtools(true);
3536 core.set_devtools_dock(kui_core::DevtoolsDock::Right);
3537 core.set_inspect(true);
3538 frame(&mut core, &mut ext);
3539 frame(&mut core, &mut ext);
3540 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3541 assert_eq!(calls, 0, "not on show: the view was not called");
3542 let ws = core.take_warnings();
3543 assert_eq!(
3544 ws.iter()
3545 .filter(|w| w.code == kui_core::diag::BAD_DEVTOOLS_TAB)
3546 .count(),
3547 1,
3548 "the tab with neither form warns once: {ws:?}"
3549 );
3550 let chord = || {
3553 kui_core::InputEvent::KeyDown(kui_core::KeyPress::new(
3554 kui_core::KeyCode::Char('N'),
3555 kui_core::KeyMods::NONE.with_ctrl().with_shift(),
3556 ))
3557 };
3558 core.handle_input(chord());
3559 core.handle_input(chord());
3560 frame(&mut core, &mut ext);
3561 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3562 assert_eq!(calls, 1, "on show: called once a frame");
3563 frame(&mut core, &mut ext);
3564 let body = core
3565 .nodes()
3566 .iter()
3567 .find(|n| n.label.as_deref() == Some("kui-devtools/tab/syntax"))
3568 .map(|n| n.rect)
3569 .expect("the body");
3570 let jump = core
3571 .nodes()
3572 .iter()
3573 .find(|n| n.label.as_deref() == Some("jump"))
3574 .map(|n| n.rect)
3575 .expect("the script's button");
3576 assert!(
3577 jump.x >= body.x && jump.x + jump.w <= body.x + body.w,
3578 "{jump:?} in {body:?}"
3579 );
3580 assert!(
3581 core.nodes()
3582 .iter()
3583 .any(|n| n.text.as_deref() == Some("from lua")),
3584 "the script's text painted"
3585 );
3586 let evs = kui_core::testing::click_at(&mut core, jump.x + 2.0, jump.y + 2.0);
3587 assert_eq!(evs.len(), 1);
3588 assert_eq!(evs[0].origin, OriginId(1), "the script's own event");
3589 assert_eq!(evs[0].kind(), Some("jump"));
3590 }
3591
3592 #[test]
3597 fn the_root_table_declares_windows() {
3598 use kui_core::{WindowCommand, WindowConfig};
3599 let mut ext = LuaExtension::from_source(
3600 "windows",
3601 r#"
3602 function view(env)
3603 return column {
3604 windows = { { name = "palette", width = 400, height = 300,
3605 activates = false }, "tools" },
3606 text("main"),
3607 }
3608 end
3609 "#,
3610 )
3611 .unwrap();
3612 let mut core = Core::new();
3613 frame(&mut core, &mut ext);
3614 let cmds = core.take_window_commands();
3615 assert_eq!(cmds.len(), 2, "{cmds:?}");
3616 assert_eq!(
3617 cmds[0],
3618 WindowCommand::Open {
3619 id: WindowId(1),
3620 owner: WindowId::MAIN,
3621 origin: OriginId(1),
3622 config: WindowConfig {
3623 size: Size::new(400.0, 300.0),
3624 activates: false,
3625 ..WindowConfig::default()
3626 },
3627 }
3628 );
3629 assert_eq!(
3630 cmds[1],
3631 WindowCommand::Open {
3632 id: WindowId(2),
3633 owner: WindowId::MAIN,
3634 origin: OriginId(1),
3635 config: WindowConfig::default(),
3636 }
3637 );
3638 frame(&mut core, &mut ext);
3640 assert!(core.take_window_commands().is_empty());
3641 assert!(core.take_warnings().is_empty());
3642 }
3643
3644 #[test]
3648 fn a_windows_entry_declares_a_popup() {
3649 use kui_core::{Rect, WindowCommand, WindowConfig, WindowKind};
3650 let mut ext = LuaExtension::from_source(
3651 "windows-popup",
3652 r#"
3653 function view(env)
3654 return column {
3655 windows = { { name = "menu", kind = "popup",
3656 width = 160, height = 320,
3657 anchor = { x = 12, y = 40, w = 160, h = 24 } } },
3658 text("main"),
3659 }
3660 end
3661 "#,
3662 )
3663 .unwrap();
3664 let mut core = Core::new();
3665 frame(&mut core, &mut ext);
3666 assert_eq!(
3667 core.take_window_commands(),
3668 vec![WindowCommand::Open {
3669 id: WindowId(1),
3670 owner: WindowId::MAIN,
3671 origin: OriginId(1),
3672 config: WindowConfig {
3673 kind: WindowKind::Popup,
3674 size: Size::new(160.0, 320.0),
3675 activates: false,
3676 anchor: Rect::new(12.0, 40.0, 160.0, 24.0),
3677 },
3678 }]
3679 );
3680 }
3681
3682 #[test]
3687 fn a_windows_entry_cannot_name_an_unknown_kind() {
3688 let mut ext = LuaExtension::from_source(
3689 "windows-kind",
3690 r#"
3691 function view(env)
3692 return column {
3693 windows = { { name = "palette", kind = "sheet" } },
3694 text("main"),
3695 }
3696 end
3697 "#,
3698 )
3699 .unwrap();
3700 let mut core = Core::new();
3701 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3702 ui.set_origin(OriginId(1));
3703 let err = ext.view(&Slot::root(), &mut ui).unwrap_err();
3704 assert!(err.contains("sheet"), "{err}");
3705 assert!(err.contains("popup"), "{err}");
3706 }
3707
3708 #[test]
3712 fn a_button_takes_an_index_and_a_cursor_shape_is_refused() {
3713 let mut ext = LuaExtension::from_source(
3714 "rows",
3715 r#"
3716 from = 0
3717 function view(env)
3718 return column {
3719 button { label = "Open", index = from, on_click = { row = from } },
3720 button { label = "Open", index = from + 1, on_click = { row = from + 1 } },
3721 button { label = "Keyed", key = "named", index = 7, on_click = "k" },
3722 }
3723 end
3724 "#,
3725 )
3726 .unwrap();
3727 let mut core = Core::new();
3728 core.set_diagnostics(true);
3729 let frame = |core: &mut Core, ext: &mut LuaExtension| {
3730 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3731 ui.set_origin(OriginId(1));
3732 ext.view(&Slot::root(), &mut ui).unwrap();
3733 ui.finish();
3734 };
3735 frame(&mut core, &mut ext);
3736 assert!(core.take_warnings().is_empty(), "index is a button row");
3737 let buttons = |core: &mut Core| -> Vec<Key> {
3738 core.access_tree()
3739 .nodes
3740 .iter()
3741 .filter(|n| n.role == kui_core::Role::Button)
3742 .map(|n| n.key)
3743 .collect()
3744 };
3745 let before = buttons(&mut core);
3746 assert_eq!(before.len(), 3, "two rows with the same text are two nodes");
3747 assert!(
3748 core.key_of("named").is_none(),
3749 "declared beside `key`, the index wins"
3750 );
3751 ext.lua.globals().set("from", 1).unwrap();
3752 frame(&mut core, &mut ext);
3753 let after = buttons(&mut core);
3754 assert_eq!(
3755 after[0], before[1],
3756 "row 1 keeps its key as it moves up the list"
3757 );
3758
3759 let mut ext = LuaExtension::from_source(
3760 "term",
3761 r#"
3762 function view(env)
3763 return column { cells { key = "term", rows = 1, cols = 4, size = 14,
3764 lines = { "abcd" }, cursor_at = { 1, 1 }, cursor_shape = "blob" } }
3765 end
3766 "#,
3767 )
3768 .unwrap();
3769 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3770 ui.set_origin(OriginId(1));
3771 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
3772 assert!(
3773 err.contains("block | bar | underline") && err.contains("blob"),
3774 "{err}"
3775 );
3776 }
3777
3778 #[test]
3781 fn option_as_alt_is_a_side_by_name() {
3782 let mut core = Core::new();
3783 let mut ext = LuaExtension::from_source(
3784 "keys",
3785 r#"
3786 function view(env)
3787 return column { option_as_alt = "left", text("x") }
3788 end
3789 "#,
3790 )
3791 .unwrap();
3792 frame(&mut core, &mut ext);
3793 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
3794
3795 let mut ext = LuaExtension::from_source(
3796 "keys",
3797 r#"
3798 function view(env)
3799 return column { option_as_alt = "meta", text("x") }
3800 end
3801 "#,
3802 )
3803 .unwrap();
3804 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3805 ui.set_origin(OriginId(1));
3806 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
3807 assert!(
3808 err.contains("option_as_alt") && err.contains("\"both\"") && err.contains("meta"),
3809 "{err}"
3810 );
3811 }
3812
3813 #[test]
3817 fn a_value_that_holds_itself_or_nests_too_deep_is_refused() {
3818 let lua = Lua::new();
3819 let read = |src: &str| lua_to_value(&lua.load(src).eval::<mlua::Value>().unwrap());
3820 let err = |src: &str| read(src).unwrap_err().to_string();
3821 assert!(err("local t = {}; t[1] = t; return t").contains("holds itself"));
3822 assert!(err("local t = {}; t.me = { t }; return t").contains("holds itself"));
3823 let shared = read("local l = { 1, 2 }; return { a = l, b = l }").unwrap();
3824 assert_eq!(shared.get("a"), shared.get("b"));
3825 let nested = |n: usize| format!("local t = 1; for _ = 1, {n} do t = {{ t }} end; return t");
3826 assert!(read(&nested(MAX_VALUE_DEPTH)).is_ok());
3827 assert!(err(&nested(MAX_VALUE_DEPTH + 1)).contains("nested past 64"));
3828 assert!(err(&nested(100_000)).contains("nested past 64"));
3830 }
3831
3832 #[test]
3836 fn a_view_that_holds_itself_or_nests_too_deep_is_refused() {
3837 let run = |body: &str| {
3838 let mut core = Core::new();
3839 let mut ext =
3840 LuaExtension::from_source("deep", &format!("function view(env)\n{body}\nend"))
3841 .unwrap();
3842 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3843 ui.set_origin(OriginId(1));
3844 let built = ext.view(&Slot::root(), &mut ui);
3845 ui.finish();
3846 built
3847 };
3848 let err = run("local t = column {}; t[1] = row { t }; return t").unwrap_err();
3849 assert!(err.contains("holds itself"), "{err}");
3850 let nested = |n: usize| {
3851 format!(
3852 "local t = text('x')
3853 for i = 2, {n} do
3854 if i % 2 == 0 then t = radio_group {{ label = 'g', t }}
3855 elseif i % 3 == 0 then t = tooltip {{ t }}
3856 else t = column {{ t }} end
3857 end
3858 return t"
3859 )
3860 };
3861 run(&nested(MAX_VIEW_DEPTH)).unwrap();
3862 let err = run(&nested(MAX_VIEW_DEPTH + 1)).unwrap_err();
3863 assert!(err.contains("nested past 128"), "{err}");
3864 run(&nested(8)).unwrap();
3866 }
3867
3868 #[test]
3872 fn option_as_alt_none_leaves_the_hosts_side() {
3873 let mut core = Core::new();
3874 let mut ext = LuaExtension::from_source(
3875 "keys",
3876 r#"
3877 function view(env)
3878 return column { option_as_alt = "none", text("x") }
3879 end
3880 "#,
3881 )
3882 .unwrap();
3883 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3884 ui.option_as_alt(kui_core::OptionAsAlt::Left);
3885 ui.set_origin(OriginId(1));
3886 ext.view(&Slot::root(), &mut ui).unwrap();
3887 ui.finish();
3888 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
3889 }
3890
3891 #[test]
3893 fn every_node_type_lowers() {
3894 let mut ext = LuaExtension::from_source(
3895 "all",
3896 r##"
3897 function view(env)
3898 return column { gap = 4, window_title = "all nodes", always_on_top = true,
3899 secure_input = true, option_as_alt = "right",
3900 titlebar { text("custom title"), window_buttons() },
3901 titlebar { title = "plain title" },
3902 text({ "same IR as ", { "Rust", bold = true, color = "#73d98c" },
3903 { " — flatter", italic = true } }, { size = 13 }),
3904 edit { key = "note", initial = "hello", size = 14, width = 200,
3905 multiline = true },
3906 input { label = "name", initial = "" },
3907 dropdown { label = "language", options = { "English", "Deutsch" }, current = 1 },
3908 row { tooltip = "hover hint", pad = 4, text("badge") },
3909 row { hoverable = true, text("legend"), tooltip("always shown") },
3910 row { text("rich tip"), tooltip { text("a"), text("b") } },
3911 latency_graph(),
3912 latency_hud { at = { "start", "end" } },
3913 button { label = "ok", on_click = "ok" },
3914 }
3915 end
3916 "##,
3917 )
3918 .unwrap();
3919 let mut core = Core::new();
3920 let quads = frame(&mut core, &mut ext);
3921 assert!(quads > 60, "got {quads} quads");
3922 assert_eq!(core.window_title(), Some("all nodes"));
3923 assert!(core.always_on_top());
3924 assert!(core.secure_input());
3925 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Right);
3926 let unknown: Vec<String> = core
3930 .take_warnings()
3931 .into_iter()
3932 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
3933 .map(|w| w.message)
3934 .collect();
3935 assert!(unknown.is_empty(), "{unknown:#?}");
3936 }
3937
3938 #[test]
3941 fn unknown_props_warn_once_in_lua_spelling() {
3942 let mut ext = LuaExtension::from_source(
3943 "typos",
3944 r#"
3945 function view(env)
3946 return column { pad = 8,
3947 row { hoverBg = 0x333333ff, width = 10, height = 10 },
3948 row { hoverBg = 0x333333ff, width = 10, height = 10 },
3949 row { colour = 0x333333ff, width = 10, height = 10 },
3950 }
3951 end
3952 "#,
3953 )
3954 .unwrap();
3955 let mut core = Core::new();
3956 frame(&mut core, &mut ext);
3957 let mut warned: Vec<String> = core
3958 .take_warnings()
3959 .into_iter()
3960 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
3961 .map(|w| w.message)
3962 .collect();
3963 warned.sort();
3964 assert_eq!(warned.len(), 2, "one per name, not per node: {warned:#?}");
3965 assert!(
3966 warned[1].contains("`hoverBg` is not a prop of box")
3967 && warned[1].contains("did you mean `hover_bg`?"),
3968 "{warned:#?}"
3969 );
3970 assert!(warned[0].contains("`colour`") && !warned[0].contains("did you mean"));
3972 frame(&mut core, &mut ext);
3974 assert!(core.take_warnings().is_empty());
3975 }
3976
3977 #[test]
3981 fn a_text_warns_about_the_rows_it_does_not_read() {
3982 let mut ext = LuaExtension::from_source(
3983 "textrows",
3984 r#"
3985 function view(env)
3986 return column { pad = 8,
3987 text("hi", { live = "polite", label = "x", on_click = "go", size = 14, line_height = 20, max_lines = 2 }),
3988 text({ "a", { "b", bold = true, bg = 0x00ff00ff } }, { color = 0xff0000ff }),
3989 }
3990 end
3991 "#,
3992 )
3993 .unwrap();
3994 let mut core = Core::new();
3995 frame(&mut core, &mut ext);
3996 let mut warned: Vec<String> = core
3997 .take_warnings()
3998 .into_iter()
3999 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4000 .map(|w| w.message)
4001 .collect();
4002 warned.sort();
4003 assert_eq!(warned.len(), 3, "{warned:#?}");
4004 for (w, name) in warned.iter().zip(["label", "live", "on_click"]) {
4005 assert!(
4006 w.contains(&format!("`{name}`")) && w.contains("not one text reads"),
4007 "{w}"
4008 );
4009 assert!(w.contains("`max_lines`"), "names the rows it reads: {w}");
4010 }
4011 }
4012
4013 #[test]
4016 fn the_repeat_alias_does_not_warn() {
4017 let mut ext = LuaExtension::from_source(
4018 "alias",
4019 r#"
4020 function view(env)
4021 return column {
4022 row { width = 10, height = 10, keyframes = { { bg = 0x000000ff } },
4023 transition = 100, direction = "alternate" },
4024 }
4025 end
4026 "#,
4027 )
4028 .unwrap();
4029 let mut core = Core::new();
4030 frame(&mut core, &mut ext);
4031 assert!(core.take_warnings().is_empty());
4032 }
4033
4034 #[test]
4036 fn unknown_props_stay_quiet_with_diagnostics_off() {
4037 let mut ext = LuaExtension::from_source(
4038 "quiet",
4039 r#"
4040 function view(env)
4041 return column { hoverBg = 0x333333ff }
4042 end
4043 "#,
4044 )
4045 .unwrap();
4046 let mut core = Core::new();
4047 core.set_diagnostics(false);
4048 frame(&mut core, &mut ext);
4049 assert!(core.take_warnings().is_empty());
4050 }
4051
4052 #[test]
4055 fn tooltip_prop_is_hover_gated() {
4056 let mut ext = LuaExtension::from_source(
4057 "tip",
4058 r#"
4059 function view(env)
4060 return column { pad = 10,
4061 row { width = 100, height = 40, bg = 0x333333ff, tooltip = "a long hint" },
4062 }
4063 end
4064 "#,
4065 )
4066 .unwrap();
4067 let mut core = Core::new();
4068 let idle = frame(&mut core, &mut ext);
4069 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
4070 let hovered = frame(&mut core, &mut ext);
4071 assert!(
4072 hovered > idle + 5,
4073 "hover should add the tooltip's quads ({idle} -> {hovered})"
4074 );
4075 core.handle_input(InputEvent::CursorLeft);
4076 assert_eq!(frame(&mut core, &mut ext), idle);
4077 }
4078
4079 #[test]
4085 fn the_stock_button_admits_the_access_rows() {
4086 let mut ext = LuaExtension::from_source(
4087 "button",
4088 r#"
4089 function view(env)
4090 return column { pad = 10, gap = 4,
4091 button { label = "go", on_click = "go", description = "Starts the run" },
4092 button { label = "Stop the run", text = "stop", on_click = "stop",
4093 disabled = true, tooltip = "Nothing is running" },
4094 button { label = "x", on_click = "x", radius = 12, hoverBg = 0x333333ff },
4095 }
4096 end
4097 "#,
4098 )
4099 .unwrap();
4100 let mut core = Core::new();
4101 frame(&mut core, &mut ext);
4102 let tree = core.access_tree().clone();
4103 let named = |n: &str| {
4104 tree.nodes
4105 .iter()
4106 .find(|node| node.name.as_deref() == Some(n))
4107 };
4108 let go = named("go").expect("the button, named by its label");
4109 assert_eq!(go.role, kui_core::Role::Button);
4110 assert_eq!(go.description.as_deref(), Some("Starts the run"));
4111 assert!(!go.disabled);
4112 let stop = named("Stop the run").expect("named past its text");
4113 assert_eq!(stop.description.as_deref(), Some("Nothing is running"));
4114 assert!(stop.disabled);
4115 assert_eq!(tree.nodes.len(), 4, "window and three buttons");
4116 let ws = core.take_warnings();
4117 assert_eq!(ws.len(), 2, "{ws:?}");
4118 let radius = ws
4119 .iter()
4120 .find(|w| w.message.contains("`radius`"))
4121 .expect("a row the button does not read");
4122 assert!(
4123 radius
4124 .message
4125 .contains("is a prop, but not one button reads")
4126 );
4127 assert!(radius.message.contains("`description`"));
4128 let hover = ws
4131 .iter()
4132 .find(|w| w.message.contains("hoverBg"))
4133 .expect("a misspelling");
4134 assert!(hover.message.contains("is not a prop of button"));
4135 assert!(!hover.message.contains("did you mean"));
4136 }
4137
4138 #[test]
4143 fn an_accent_button_takes_the_colour_the_host_pushed() {
4144 let mut ext = LuaExtension::from_source(
4145 "accent",
4146 r#"
4147 function view(env)
4148 return column { pad = 10,
4149 button { label = "go", on_click = "go", accent = true },
4150 }
4151 end
4152 "#,
4153 )
4154 .unwrap();
4155 let mut bg = |core: &mut Core| {
4156 frame(core, &mut ext);
4157 core.output().0.quads[0].color
4158 };
4159 let mut core = Core::new();
4160 assert_eq!(
4161 bg(&mut core),
4162 kui_core::Color::rgb8(0x3b, 0x5b, 0xd4),
4163 "no accent pushed, the stock button"
4164 );
4165 assert!(
4166 core.take_warnings().is_empty(),
4167 "and a row the button reads"
4168 );
4169
4170 let accent = kui_core::Color::hex(0x007affff);
4171 core.env.system.accent = Some(accent);
4172 assert_eq!(bg(&mut core), accent);
4173 }
4174
4175 #[test]
4179 fn semantics_reach_the_access_tree() {
4180 let mut ext = LuaExtension::from_source(
4181 "a11y",
4182 r#"
4183 function view(env)
4184 return column { pad = 10,
4185 row { key = "save", on_click = "save", label = "Save", width = 20, height = 20 },
4186 row { key = "check", role = "checkbox", checked = true, text("Remember") },
4187 row { key = "vol", role = "slider", label = "Volume",
4188 value_now = 3, value_min = 0, value_max = 10 },
4189 row { key = "art", role = "none", on_click = "art", text("Art") },
4190 row { key = "tip", tooltip = "more here", on_click = "t", text("Tip") },
4191 row { key = "tabs", role = "tabList",
4192 row { key = "t0", role = "tab", text("General") },
4193 row { key = "t1", role = "tab", selected = true, text("Network") },
4194 },
4195 row { key = "adv", on_click = "adv", expanded = "collapsed",
4196 label = "Advanced" },
4197 }
4198 end
4199 "#,
4200 )
4201 .unwrap();
4202 let mut core = Core::new();
4203 frame(&mut core, &mut ext);
4204 let tree = core.access_tree().clone();
4205 let named = |n: &str| {
4206 tree.nodes
4207 .iter()
4208 .find(|node| node.name.as_deref() == Some(n))
4209 .cloned()
4210 };
4211 let save = named("Save").expect("labelled button");
4212 assert_eq!(save.role, kui_core::Role::Button);
4213 assert_eq!(save.origin, OriginId(1));
4214 let check = named("Remember").expect("checkbox named by its text");
4215 assert_eq!(check.role, kui_core::Role::Checkbox);
4216 assert_eq!(check.checked, Some(true));
4217 let vol = named("Volume").expect("slider");
4218 assert_eq!(
4219 (vol.number, vol.min, vol.max),
4220 (Some(3.0), Some(0.0), Some(10.0))
4221 );
4222 assert!(named("Art").is_none(), "role none hides a would-be button");
4223 let tip = named("Tip").expect("button");
4224 assert_eq!(tip.description.as_deref(), Some("more here"));
4225 let (t0, t1) = (named("General").unwrap(), named("Network").unwrap());
4228 assert_eq!((t0.selected, t1.selected), (Some(false), Some(true)));
4229 assert_eq!((t0.pos_in_set, t1.pos_in_set), (Some(0), Some(1)));
4230 let tabs = tree
4231 .nodes
4232 .iter()
4233 .find(|n| n.role == kui_core::Role::TabList)
4234 .expect("tab list");
4235 assert_eq!(tabs.set_size, Some(2));
4236 let adv = named("Advanced").expect("disclosure");
4238 assert_eq!(adv.expanded, Some(false));
4239
4240 let evs = core.handle_input(InputEvent::Access(kui_core::AccessRequest {
4241 key: save.key,
4242 action: kui_core::AccessAction::Click,
4243 value: None,
4244 anchor: None,
4245 focus: None,
4246 }));
4247 assert_eq!(evs.len(), 1);
4248 assert_eq!(evs[0].origin, OriginId(1));
4249 assert_eq!(evs[0].payload.as_str(), Some("save"));
4250 }
4251
4252 #[test]
4256 fn a_custom_editor_in_lua_reaches_the_access_tree() {
4257 let mut ext = LuaExtension::from_source(
4258 "ed",
4259 r#"
4260 function view(env)
4261 return column { key = "ed", role = "multilineTextInput", label = "Doc",
4262 on_key = "keys",
4263 row { role = "line", selection_anchor = 1, text("ab"), text("cd") },
4264 row { role = "line", caret = 2, text("ef") },
4265 }
4266 end
4267 "#,
4268 )
4269 .unwrap();
4270 let mut core = Core::new();
4271 frame(&mut core, &mut ext);
4272 let tree = core.access_tree().clone();
4273 let ed = tree
4274 .nodes
4275 .iter()
4276 .find(|n| n.name.as_deref() == Some("Doc"))
4277 .expect("the sink is the editor")
4278 .clone();
4279 assert_eq!(ed.role, kui_core::Role::MultilineTextInput);
4280 assert_eq!(ed.value.as_deref(), Some("abcd\nef"));
4281 assert_eq!(ed.runs.len(), 3);
4282 assert_eq!(ed.runs[1].text, "cd\n");
4283 assert_eq!(ed.caret, Some(7));
4284 assert_eq!(ed.selection, Some((1, 7)));
4285 let (a, f) = (ed.anchor.unwrap(), ed.focus.unwrap());
4286 assert_eq!((a.run, a.character), (ed.runs[0].key, 1));
4287 assert_eq!((f.run, f.character), (ed.runs[2].key, 2));
4288
4289 let evs = core.handle_input(InputEvent::Access(
4290 kui_core::AccessRequest::new(ed.key, kui_core::AccessAction::SetTextSelection)
4291 .with_selection(
4292 kui_core::TextPos {
4293 run: ed.runs[1].key,
4294 character: 1,
4295 },
4296 kui_core::TextPos {
4297 run: ed.runs[2].key,
4298 character: 0,
4299 },
4300 ),
4301 ));
4302 assert_eq!(evs.len(), 1);
4303 assert_eq!(evs[0].origin, OriginId(1));
4304 let p = &evs[0].payload;
4305 assert_eq!(p.get_str("action"), Some("setTextSelection"));
4306 let at = |k: &str, f: &str| p.get(k).and_then(|v| v.get(f)).and_then(Value::as_int);
4307 assert_eq!(
4308 (at("anchor", "line"), at("anchor", "offset")),
4309 (Some(0), Some(3))
4310 );
4311 assert_eq!(
4312 (at("focus", "line"), at("focus", "offset")),
4313 (Some(1), Some(0))
4314 );
4315 assert_eq!(p.get_str("tag"), Some("keys"));
4316 }
4317
4318 #[test]
4321 fn edit_text_round_trips_through_events() {
4322 let mut ext = LuaExtension::from_source(
4323 "edit",
4324 r#"
4325 pending = nil
4326 seen = nil
4327 by_label = nil
4328 window = nil
4329 function view(env)
4330 if pending then seen = env.edit_text(pending) end
4331 -- AR26: by the label its `key` declares, like every
4332 -- query beside it; a label nothing declared is nil.
4333 by_label = env.edit_text("note")
4334 nothing = env.edit_text("nope")
4335 return column {
4336 edit { key = "note", initial = "hi", autofocus = true, width = 200 },
4337 }
4338 end
4339 function on_event(ev)
4340 if ev.kind == "changed" then pending = ev.node_key; window = ev.window end
4341 end
4342 "#,
4343 )
4344 .unwrap();
4345 let mut core = Core::new();
4346 frame(&mut core, &mut ext);
4347 let events = core.handle_input(InputEvent::Text("!".into()));
4348 assert_eq!(
4349 events.len(),
4350 1,
4351 "typing into the autofocused editor emits one event"
4352 );
4353 assert_eq!(events[0].kind(), Some("changed"));
4354 for ev in &events {
4355 ext.on_event(ev);
4356 }
4357 frame(&mut core, &mut ext);
4358 let seen: Option<String> = ext.lua.globals().get("seen").unwrap();
4360 assert_eq!(seen.as_deref(), Some("hi!"));
4361 let by_label: Option<String> = ext.lua.globals().get("by_label").unwrap();
4362 assert_eq!(by_label.as_deref(), Some("hi!"), "the same text by label");
4363 let nothing: Option<String> = ext.lua.globals().get("nothing").unwrap();
4364 assert_eq!(nothing, None);
4365 let window: Option<i64> = ext.lua.globals().get("window").unwrap();
4368 assert_eq!(window, Some(0));
4369 }
4370
4371 #[test]
4376 fn a_dropdown_opens_the_cores_menu_and_hears_the_choice() {
4377 let mut ext = LuaExtension::from_source(
4378 "dd",
4379 r#"
4380 current = 1
4381 heard = {}
4382 function view(env)
4383 return column { pad = 10,
4384 dropdown { label = "language",
4385 options = { "English", "Deutsch", { label = "Latin", id = "la" } },
4386 current = current },
4387 }
4388 end
4389 function on_event(ev)
4390 heard[#heard + 1] = ev.kind
4391 if ev.kind == "menu" then
4392 if ev.item == "Deutsch" then current = 2 end
4393 if ev.item == "la" then current = 3 end
4394 end
4395 end
4396 "#,
4397 )
4398 .unwrap();
4399 let mut core = Core::new();
4400 frame(&mut core, &mut ext);
4401 let field = core
4402 .key_of("language")
4403 .expect("the field is keyed by its label");
4404 let node = |core: &mut Core| {
4405 core.access_tree()
4406 .nodes
4407 .iter()
4408 .find(|n| n.key == field)
4409 .cloned()
4410 .unwrap()
4411 };
4412 assert_eq!(node(&mut core).description.as_deref(), Some("English"));
4413 let events = core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4414 field,
4415 kui_core::AccessAction::Click,
4416 )));
4417 assert!(
4418 events.is_empty(),
4419 "the field's click is the core's: {events:?}"
4420 );
4421 let menu = core.menu().expect("the menu opened");
4422 assert_eq!(menu.target, field);
4423 assert_eq!(menu.items.len(), 3);
4424 assert!(menu.items[0].checked);
4425 frame(&mut core, &mut ext);
4426 let events = core.activate_menu_item(2).expect("the row is enabled");
4428 assert_eq!(events.len(), 1);
4429 for ev in &events {
4430 ext.on_event(ev);
4431 }
4432 frame(&mut core, &mut ext);
4433 assert_eq!(node(&mut core).description.as_deref(), Some("Latin"));
4434 let heard: Vec<String> = ext
4435 .lua
4436 .globals()
4437 .get::<Table>("heard")
4438 .unwrap()
4439 .sequence_values()
4440 .collect::<mlua::Result<_>>()
4441 .unwrap();
4442 assert_eq!(heard, vec!["menu".to_string()]);
4443 assert!(core.menu().is_none());
4444 let mut ext = LuaExtension::from_source(
4446 "bad",
4447 r#"function view(env) return dropdown { label = "x" } end"#,
4448 )
4449 .unwrap();
4450 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4451 ui.set_origin(OriginId(1));
4452 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4453 assert!(err.contains("needs options"), "{err}");
4454 ui.finish();
4455 let mut ext = LuaExtension::from_source(
4456 "bad",
4457 r#"function view(env) return dropdown { label = "x", options = { "a" }, current = 0 } end"#,
4458 )
4459 .unwrap();
4460 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4461 ui.set_origin(OriginId(1));
4462 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4463 assert!(err.contains("index from 1"), "{err}");
4464 ui.finish();
4465 let refused = |core: &mut Core, src: &str| {
4468 let mut ext = LuaExtension::from_source("bad", src).unwrap();
4469 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4470 ui.set_origin(OriginId(1));
4471 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4472 ui.finish();
4473 err
4474 };
4475 let err = refused(
4476 &mut core,
4477 r#"function view(env) return dropdown { options = { "a" } } end"#,
4478 );
4479 assert!(err.contains("dropdown needs a label"), "{err}");
4480 let err = refused(
4481 &mut core,
4482 r#"function view(env) return dropdown { label = "x", options = {} } end"#,
4483 );
4484 assert!(err.contains("at least one option"), "{err}");
4485 }
4486
4487 #[test]
4493 fn a_dropdowns_bad_rows_and_current_are_warned_and_a_disabled_option_is_refused() {
4494 let mut ext = LuaExtension::from_source(
4495 "dd",
4496 r#"
4497 current = 4
4498 function view(env)
4499 return column { pad = 10,
4500 dropdown { label = "language",
4501 options = { "English", { role = "separator" },
4502 { label = "Latin", id = "la", disabled = true } },
4503 current = current },
4504 }
4505 end
4506 "#,
4507 )
4508 .unwrap();
4509 let mut core = Core::new();
4510 frame(&mut core, &mut ext);
4511 let field = core.key_of("language").unwrap();
4512 let node = |core: &mut Core| {
4513 core.access_tree()
4514 .nodes
4515 .iter()
4516 .find(|n| n.key == field)
4517 .cloned()
4518 .unwrap()
4519 };
4520 assert_eq!(node(&mut core).description.as_deref(), Some(""));
4521 let warned = core.take_warnings();
4522 let codes: Vec<&str> = warned.iter().map(|w| w.code).collect();
4523 assert_eq!(
4524 codes,
4525 [
4526 kui_core::diag::UNKNOWN_PROP,
4527 kui_core::diag::SELECT_CURRENT_IGNORED
4528 ],
4529 "{warned:?}"
4530 );
4531 assert!(
4532 warned[0]
4533 .message
4534 .contains("`disabled` is not a key of a menu item")
4535 && warned[0].message.contains("`enabled: false`"),
4536 "{}",
4537 warned[0].message
4538 );
4539 assert_eq!(warned[1].key, field);
4540 assert!(
4541 warned[1].message.contains("names option 3 counted from 0")
4542 && warned[1].message.contains("the field has 3 options"),
4543 "{}",
4544 warned[1].message
4545 );
4546 ext.lua.globals().set("current", 2).unwrap();
4548 let mut core = Core::new();
4549 frame(&mut core, &mut ext);
4550 let warned = core.take_warnings();
4551 assert!(
4552 warned
4553 .iter()
4554 .any(|w| w.code == kui_core::diag::SELECT_CURRENT_IGNORED
4555 && w.message
4556 .contains("option 1 counted from 0, which is a separator")),
4557 "{warned:?}"
4558 );
4559 let mut ext = LuaExtension::from_source(
4562 "dd",
4563 r#"
4564 function view(env)
4565 return column { pad = 10,
4566 dropdown { label = "language",
4567 options = { "English", { label = "Latin", id = "la", enabled = false } },
4568 current = 1 },
4569 }
4570 end
4571 "#,
4572 )
4573 .unwrap();
4574 let mut core = Core::new();
4575 frame(&mut core, &mut ext);
4576 let field = core.key_of("language").unwrap();
4577 core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4578 field,
4579 kui_core::AccessAction::Click,
4580 )));
4581 assert!(core.menu().is_some());
4582 assert_eq!(core.activate_menu_item(1), None, "refused");
4583 assert!(core.menu().is_some(), "the menu stays open");
4584 let events = core
4585 .activate_menu_item(0)
4586 .expect("the enabled row is taken");
4587 assert_eq!(events.len(), 1);
4588 assert!(core.menu().is_none());
4589 assert!(core.take_warnings().is_empty());
4590 }
4591
4592 #[test]
4595 fn a_grid_lines_its_rows_cells_up() {
4596 let mut ext = LuaExtension::from_source(
4597 "grid",
4598 r#"
4599 function view(env)
4600 return grid { key = "t", width = 300,
4601 row { key = "r1", width = "grow", gap = 8,
4602 text("ab", { size = 12 }),
4603 column { key = "b1", width = 10, height = 10, bg = 0xff0000ff },
4604 column { key = "c1", width = "grow", height = 10, bg = 0x00ff00ff },
4605 },
4606 row { key = "r2", width = "grow", gap = 8,
4607 text("abcdef", { size = 12 }),
4608 column { key = "b2", width = 50, height = 10, bg = 0xff0000ff },
4609 column { key = "c2", width = 20, height = 10, bg = 0x00ff00ff },
4610 },
4611 }
4612 end
4613 "#,
4614 )
4615 .unwrap();
4616 let mut core = Core::new();
4617 core.set_inspect(true);
4618 frame(&mut core, &mut ext);
4619 let rect = |core: &mut Core, label: &str| {
4620 let key = core.key_of(label).unwrap_or_else(|| panic!("{label}"));
4621 core.nodes()
4622 .iter()
4623 .find(|n| n.key == key)
4624 .map(|n| n.rect)
4625 .unwrap_or_else(|| panic!("{label}"))
4626 };
4627 let (b1, b2) = (rect(&mut core, "b1"), rect(&mut core, "b2"));
4628 let (c1, c2) = (rect(&mut core, "c1"), rect(&mut core, "c2"));
4629 assert_eq!(
4630 b1.x, b2.x,
4631 "the fixed column starts after the longest label"
4632 );
4633 assert_eq!(b1.w, 50.0, "the fixed column is its widest cell");
4634 assert_eq!(b2.w, 50.0);
4635 assert_eq!(c1.x, c2.x);
4636 assert_eq!(c1.w, c2.w, "the grow column is one width in both rows");
4637 assert_eq!(c1.x + c1.w, 300.0, "and it takes the rest");
4638 let t = core.key_of("t").unwrap();
4639 assert!(
4640 core.nodes().iter().any(|n| n.key == t && n.table),
4641 "the grid is a table"
4642 );
4643 assert!(core.take_warnings().is_empty());
4644 }
4645
4646 #[test]
4651 fn set_edit_text_by_label_seeds_the_editor_the_next_frame_declares() {
4652 let mut ext = LuaExtension::from_source(
4653 "edit",
4654 r#"
4655 frames = 0
4656 function view(env)
4657 frames = frames + 1
4658 if frames == 1 then return column {} end
4659 if frames == 2 then
4660 env.set_edit_text("note", "from the model")
4661 end
4662 return column {
4663 edit { key = "note", initial = "ignored", width = 200 },
4664 }
4665 end
4666 "#,
4667 )
4668 .unwrap();
4669 let mut core = Core::new();
4670 frame(&mut core, &mut ext);
4674 frame(&mut core, &mut ext);
4675 let key = core.key_of("note").expect("the view declared the editor");
4676 assert_eq!(core.edit_text(key).as_deref(), Some("from the model"));
4677 let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
4678 assert!(!codes.contains(&"edit-text-without-editor"), "{codes:?}");
4679 core.set_focus(Some(key));
4682 core.handle_input(InputEvent::Text("!".into()));
4683 frame(&mut core, &mut ext);
4684 assert_eq!(core.edit_text(key).as_deref(), Some("from the model!"));
4685 }
4686
4687 #[test]
4692 fn a_lua_key_sink_hears_press_and_release() {
4693 use kui_core::{KeyCode, KeyMods, KeyPress};
4694 let mut ext = LuaExtension::from_source(
4695 "game",
4696 r#"
4697 log = {}
4698 function view(env)
4699 return column { key = "world", on_key = "keys", key_up = true,
4700 key_focus = true, width = 400, height = 300 }
4701 end
4702 function on_event(ev)
4703 if ev.kind == "key" then
4704 log[#log + 1] = ev.phase .. ":" .. ev.code ..
4705 "@" .. ev.physical ..
4706 ":" .. tostring(ev.text) .. ":" .. tostring(ev.tag)
4707 end
4708 end
4709 "#,
4710 )
4711 .unwrap();
4712 let mut core = Core::new();
4713 frame(&mut core, &mut ext);
4714 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
4715 for e in core.handle_input(ev) {
4716 ext.on_event(&e);
4717 }
4718 };
4719 let w = || KeyPress::new(KeyCode::Char('w'), KeyMods::default()).with_text("w");
4720 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
4721 feed(&mut core, &mut ext, InputEvent::KeyUp(w()));
4722 let ru = || {
4727 KeyPress::from_layout(KeyCode::Char('ц'), KeyCode::Char('w'), KeyMods::default())
4728 .with_text("ц")
4729 };
4730 feed(&mut core, &mut ext, InputEvent::KeyDown(ru()));
4731 feed(&mut core, &mut ext, InputEvent::KeyUp(ru()));
4732 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
4734 core.set_focus(None);
4735 for e in core.take_pending_events() {
4736 ext.on_event(&e);
4737 }
4738 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
4739 assert_eq!(
4740 log,
4741 [
4742 "down:w@w:w:keys",
4743 "up:w@w:nil:keys",
4745 "down:w@w:ц:keys",
4748 "up:w@w:nil:keys",
4749 "down:w@w:w:keys",
4750 "up:w@w:nil:keys",
4751 ]
4752 );
4753 }
4754
4755 #[test]
4759 fn a_lua_editor_draws_its_caret_on_the_phase_the_host_sets() {
4760 let mut ext = LuaExtension::from_source(
4761 "ed",
4762 r#"
4763 function view(env)
4764 local caret = env.caret_visible and row { key = "caret", width = 2, height = 16 } or nil
4765 return column { key = "editor", on_key = "ed", key_focus = true,
4766 role = "multilineTextInput", label = "buf",
4767 row { role = "line", caret = 3, text("let value", { family = "mono", size = 14 }), caret },
4768 }
4769 end
4770 "#,
4771 )
4772 .unwrap();
4773 let mut core = Core::new();
4774 frame(&mut core, &mut ext);
4775 assert!(
4776 core.key_of("caret").is_some(),
4777 "the on phase draws the caret"
4778 );
4779 assert!(core.has_caret(), "the caret row is a caret to blink");
4780 core.set_caret_visible(false);
4781 frame(&mut core, &mut ext);
4782 assert!(core.key_of("caret").is_none(), "the off phase draws none");
4783 assert!(
4784 core.has_caret(),
4785 "and the row stays, so the clock stays armed"
4786 );
4787 core.set_caret_visible(true);
4788 frame(&mut core, &mut ext);
4789 assert!(core.key_of("caret").is_some());
4790 }
4791
4792 #[test]
4797 fn a_lua_editors_solid_caret_arms_no_clock() {
4798 let mut ext = LuaExtension::from_source(
4799 "ed",
4800 r#"
4801 function view(env)
4802 return column { key = "editor", on_key = "ed", key_focus = true,
4803 role = "multilineTextInput", label = "buf",
4804 row { role = "line", caret = 3, caret_solid = true,
4805 text("let value", { family = "mono", size = 14 }) },
4806 }
4807 end
4808 "#,
4809 )
4810 .unwrap();
4811 let mut core = Core::new();
4812 frame(&mut core, &mut ext);
4813 assert!(!core.has_caret(), "a solid caret is not a caret to blink");
4814 assert!(core.ime_rect().is_some(), "and still the IME's anchor");
4815 let editor = core.key_of("editor").unwrap();
4816 assert_eq!(core.access_tree().get(editor).unwrap().caret, Some(3));
4817 assert!(
4818 core.warnings_raised().is_empty(),
4819 "{:?}",
4820 core.warnings_raised()
4821 );
4822 }
4823
4824 #[test]
4830 fn a_lua_editor_yanks_pastes_and_hears_where_a_press_landed() {
4831 use kui_core::testing::{press, release};
4832 use kui_core::{KeyCode, KeyMods, KeyPress, MenuAction, Vec2};
4833 let mut ext = LuaExtension::from_source(
4834 "ed",
4835 r#"
4836 yank = nil
4837 paste = false
4838 log = {}
4839 function view(env)
4840 if yank then env.set_clipboard(yank, nil); yank = nil end
4841 if secret then env.set_clipboard_secret(secret); secret = nil end
4842 -- Asked on every view until the answer lands: the core
4843 -- takes one ask at a time (AR34), so this is one paste.
4844 if paste then env.request_paste() end
4845 return column { key = "editor", on_key = "ed", on_drag = "sel",
4846 key_focus = true, role = "multilineTextInput", label = "buf",
4847 width = 400, height = 300,
4848 row { role = "line", height = 20, text("hello world", { family = "mono", size = 14 }) },
4849 row { role = "line", height = 20, text("second", { family = "mono", size = 14 }) },
4850 }
4851 end
4852 function on_event(ev)
4853 if ev.kind == "key" and ev.code == "y" then yank = "hello world" end
4854 if ev.kind == "key" and ev.code == "s" then secret = "hunter2" end
4855 if ev.kind == "key" and ev.code == "p" then paste = true end
4856 if ev.kind == "text" then
4857 paste = false
4858 local marks = (ev.concealed and ":concealed" or "") .. (ev.transient and ":transient" or "")
4859 log[#log + 1] = "text:" .. ev.text .. marks
4860 end
4861 if ev.kind == "drag" then
4862 log[#log + 1] = ev.phase .. ":" .. ev.line .. ":" .. ev.byte .. ":" .. ev.clicks
4863 end
4864 end
4865 "#,
4866 )
4867 .unwrap();
4868 let mut core = Core::new();
4869 frame(&mut core, &mut ext);
4870 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
4871 for e in core.handle_input(ev) {
4872 ext.on_event(&e);
4873 }
4874 };
4875 let key = |c| KeyPress::new(KeyCode::Char(c), KeyMods::default());
4876 feed(&mut core, &mut ext, InputEvent::KeyDown(key('y')));
4877 frame(&mut core, &mut ext);
4878 assert_eq!(
4879 core.take_menu_actions(),
4880 vec![MenuAction::SetClipboard {
4881 text: "hello world".into(),
4882 html: None
4883 }]
4884 );
4885 feed(&mut core, &mut ext, InputEvent::KeyDown(key('s')));
4887 frame(&mut core, &mut ext);
4888 assert_eq!(
4889 core.take_menu_actions(),
4890 vec![MenuAction::SetClipboardSecret {
4891 text: "hunter2".into()
4892 }]
4893 );
4894 feed(&mut core, &mut ext, InputEvent::KeyDown(key('p')));
4895 frame(&mut core, &mut ext);
4896 frame(&mut core, &mut ext);
4897 assert_eq!(
4898 core.take_menu_actions(),
4899 vec![MenuAction::Paste],
4900 "two views asked, one paste queued"
4901 );
4902 assert!(core.awaiting_paste());
4903 feed(
4905 &mut core,
4906 &mut ext,
4907 InputEvent::Commit("from the clipboard".into()),
4908 );
4909 assert!(!core.awaiting_paste());
4910 frame(&mut core, &mut ext);
4911 assert!(
4912 core.take_menu_actions().is_empty(),
4913 "answered: the script stopped asking"
4914 );
4915 feed(
4917 &mut core,
4918 &mut ext,
4919 InputEvent::Paste {
4920 text: "s3cret".into(),
4921 marks: kui_core::ClipboardMarks::SECRET,
4922 },
4923 );
4924 for e in press(&mut core, Vec2::new(390.0, 30.0)) {
4926 ext.on_event(&e);
4927 }
4928 for e in release(&mut core) {
4929 ext.on_event(&e);
4930 }
4931 for e in core.handle_input(InputEvent::mouse_down(2)) {
4932 ext.on_event(&e);
4933 }
4934 for e in release(&mut core) {
4935 ext.on_event(&e);
4936 }
4937 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
4938 assert_eq!(
4939 log,
4940 [
4941 "text:from the clipboard",
4942 "text:s3cret:concealed:transient",
4943 "start:1:6:1",
4944 "end:1:6:1",
4945 "start:1:6:2",
4946 "end:1:6:2",
4947 ]
4948 );
4949 }
4950
4951 #[test]
4955 fn audio_nodes_drive_playback_commands() {
4956 use kui_core::AudioCommand;
4957 let mut ext = LuaExtension::from_source(
4958 "audio",
4959 r#"
4960 playing = true
4961 function view(env)
4962 local items = {}
4963 if playing then
4964 items[1] = audio { src = SOUND, loop = true, volume = 0.5,
4965 key = "music", tag = { kind = "music" } }
4966 end
4967 return column { table.unpack(items) }
4968 end
4969 "#,
4970 )
4971 .unwrap();
4972 let mut core = Core::new();
4973 let sound = core.add_sound(vec![0; 8]);
4974 ext.lua
4975 .globals()
4976 .set("SOUND", sound.to_ffi() as i64)
4977 .unwrap();
4978 frame(&mut core, &mut ext);
4979 let cmds = core.take_audio_commands();
4980 assert!(
4981 matches!(
4982 cmds.as_slice(),
4983 [AudioCommand::Play { sound: s, looped: true, volume, .. }]
4984 if *s == sound && *volume == 0.5
4985 ),
4986 "{cmds:?}"
4987 );
4988 frame(&mut core, &mut ext);
4989 assert!(
4990 core.take_audio_commands().is_empty(),
4991 "re-declaring is silent"
4992 );
4993 ext.lua.globals().set("playing", false).unwrap();
4994 frame(&mut core, &mut ext);
4995 assert!(matches!(
4996 core.take_audio_commands().as_slice(),
4997 [AudioCommand::Stop { .. }]
4998 ));
4999 }
5000
5001 #[test]
5005 fn measure_and_layout_events_reach_scripts() {
5006 let mut ext = LuaExtension::from_source(
5007 "measure",
5008 r#"
5009 seen = nil
5010 function view(env)
5011 local plain = env.measure_text("hello world", { size = 14 })
5012 local node = env.measure_text(text("hello world", { size = 14 }))
5013 local rich = env.measure_text({ "hello ", { "world", bold = true } }, { size = 14 })
5014 local narrow = env.measure_text("hello world", { size = 14 }, plain.width / 2)
5015 assert(plain.width > 0 and plain.lines == 1)
5016 assert(node.width == plain.width and node.height == plain.height)
5017 assert(rich.width > 0)
5018 assert(narrow.lines > 1 and narrow.width <= plain.width / 2 + 0.5)
5019 return column {
5020 row { key = "panel", width = plain.width, height = 20, on_layout = "panel" },
5021 }
5022 end
5023 function on_event(ev)
5024 if ev.kind == "layout" then seen = ev end
5025 end
5026 "#,
5027 )
5028 .unwrap();
5029 let mut core = Core::new();
5030 frame(&mut core, &mut ext);
5031 let evs = core.take_pending_events();
5032 assert_eq!(evs.len(), 1, "one layout event on first sight");
5033 for ev in &evs {
5034 ext.on_event(ev);
5035 }
5036 let seen: Table = ext.lua.globals().get("seen").unwrap();
5037 assert_eq!(seen.get::<String>("tag").unwrap(), "panel");
5038 assert_eq!(seen.get::<f64>("h").unwrap(), 20.0);
5039 assert!(seen.get::<f64>("w").unwrap() > 0.0);
5040 assert_eq!(seen.get::<i64>("node_key").unwrap(), evs[0].key.0 as i64);
5041 frame(&mut core, &mut ext);
5043 assert!(core.take_pending_events().is_empty());
5044 }
5045
5046 #[test]
5050 fn scripts_queue_window_size_and_focus_requests() {
5051 let mut ext = LuaExtension::from_source(
5052 "win",
5053 r#"
5054 function view(env)
5055 env.set_window_size(env.window.id, 640, 480)
5056 env.focus_window(env.window.id)
5057 return column { width = "grow", height = "grow" }
5058 end
5059 "#,
5060 )
5061 .unwrap();
5062 let mut core = Core::new();
5063 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5064 ui.set_origin(OriginId(1));
5065 ext.view(&Slot::root(), &mut ui).unwrap();
5066 ui.finish();
5067 assert_eq!(
5068 core.take_window_commands(),
5069 vec![
5070 kui_core::WindowCommand::SetSize {
5071 window: WindowId::MAIN,
5072 size: Size::new(640.0, 480.0),
5073 },
5074 kui_core::WindowCommand::Focus(WindowId::MAIN),
5075 ]
5076 );
5077 assert!(core.take_window_commands().is_empty());
5078 assert_eq!(core.viewport(), Size::new(400.0, 200.0));
5079 }
5080
5081 #[test]
5087 fn scripts_reveal_and_move_scroll_offsets() {
5088 let mut ext = LuaExtension::from_source(
5089 "scroll",
5090 r#"
5091 rows, want, jump, seen = 20, nil, nil, nil
5092 function view(env)
5093 if want then env.reveal(want) end
5094 if jump then env.set_scroll(jump[1], jump[2], jump[3]) end
5095 want, jump = nil, nil
5096 local list = { key = "list", width = "grow", height = "grow",
5097 scroll_y = true }
5098 for i = 0, rows - 1 do
5099 list[#list + 1] = row { key = "row" .. i, width = "grow",
5100 height = 30, bg = 0x282840ff }
5101 end
5102 seen = env.scroll_offset(list_key)
5103 return column(list)
5104 end
5105 "#,
5106 )
5107 .unwrap();
5108 let list = Key::ROOT.str("list");
5110 let row = |i: usize| list.str(&format!("row{i}"));
5111 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
5112 let mut core = Core::new();
5113 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5114 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5115 ui.set_origin(OriginId(1));
5116 ext.view(&Slot::root(), &mut ui).unwrap();
5117 ui.finish();
5118 };
5119
5120 frame(&mut core, &mut ext);
5121 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
5122
5123 ext.lua.globals().set("want", row(15).0 as i64).unwrap();
5126 frame(&mut core, &mut ext);
5127 let after = core.scroll_offset(list);
5128 assert!(after.y > 0.0, "reveal moved nothing: {after:?}");
5129 assert!(after.y <= 15.0 * 30.0, "scrolled past row 15: {after:?}");
5130 frame(&mut core, &mut ext);
5132 assert_eq!(core.scroll_offset(list), after);
5133
5134 let seen: Table = ext.lua.globals().get("seen").unwrap();
5136 assert_eq!(seen.get::<f32>("y").unwrap(), after.y);
5137 assert_eq!(seen.get::<f32>("x").unwrap(), 0.0);
5138
5139 let jump = |ext: &LuaExtension, y: f64| {
5141 let t = ext.lua.create_table().unwrap();
5142 t.set(1, list.0 as i64).unwrap();
5143 t.set(2, 0.0).unwrap();
5144 t.set(3, y).unwrap();
5145 ext.lua.globals().set("jump", t).unwrap();
5146 };
5147 jump(&ext, 1e9);
5148 frame(&mut core, &mut ext);
5149 assert_eq!(core.scroll_offset(list).y, 400.0);
5150 jump(&ext, -1e9);
5151 frame(&mut core, &mut ext);
5152 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
5153
5154 jump(&ext, 120.0);
5156 frame(&mut core, &mut ext);
5157 ext.lua
5158 .globals()
5159 .set("want", Key::ROOT.str("ghost").0 as i64)
5160 .unwrap();
5161 frame(&mut core, &mut ext);
5162 assert_eq!(core.scroll_offset(list).y, 120.0);
5163 frame(&mut core, &mut ext);
5164 assert_eq!(core.scroll_offset(list).y, 120.0);
5165 }
5166
5167 #[test]
5171 fn scripts_read_the_pressed_state() {
5172 let mut ext = LuaExtension::from_source(
5173 "press",
5174 r#"
5175 function view(env)
5176 pressed = env.is_pressed(btn_key)
5177 hovered = env.is_hovered(btn_key)
5178 return column { key = "root", pad = 10,
5179 row { key = "btn", width = 100, height = 40,
5180 bg = 0x333333ff, on_click = "hit" },
5181 }
5182 end
5183 "#,
5184 )
5185 .unwrap();
5186 let btn = Key::ROOT.str("root").str("btn");
5187 ext.lua.globals().set("btn_key", btn.0 as i64).unwrap();
5188 let mut core = Core::new();
5189 let pressed = |ext: &LuaExtension| ext.lua.globals().get::<bool>("pressed").unwrap();
5190 let hovered = |ext: &LuaExtension| ext.lua.globals().get::<bool>("hovered").unwrap();
5191
5192 frame(&mut core, &mut ext);
5193 assert!(!pressed(&ext), "idle");
5194
5195 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
5197 frame(&mut core, &mut ext);
5198 assert!(hovered(&ext) && !pressed(&ext), "hover is not press");
5199
5200 core.handle_input(InputEvent::MouseDown {
5201 button: kui_core::MouseButton::Primary,
5202 clicks: 1,
5203 });
5204 frame(&mut core, &mut ext);
5205 assert!(pressed(&ext), "held down");
5206
5207 core.handle_input(InputEvent::MouseUp {
5211 button: kui_core::MouseButton::Primary,
5212 });
5213 frame(&mut core, &mut ext);
5214 assert!(!pressed(&ext), "released");
5215 }
5216
5217 #[test]
5222 fn scripts_read_the_drop_target() {
5223 let mut ext = LuaExtension::from_source(
5224 "drop",
5225 r#"
5226 function view(env)
5227 over = env.is_drop_target(zone_key)
5228 target = env.drop_target()
5229 return column { key = "root", pad = 10,
5230 row { key = "zone", width = 100, height = 40,
5231 bg = 0x333333ff, drop_bg = 0x335533ff, on_drop = "files" },
5232 }
5233 end
5234 "#,
5235 )
5236 .unwrap();
5237 let zone = Key::ROOT.str("root").str("zone");
5238 ext.lua.globals().set("zone_key", zone.0 as i64).unwrap();
5239 let mut core = Core::new();
5240 let over = |ext: &LuaExtension| ext.lua.globals().get::<bool>("over").unwrap();
5241 let target = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("target").unwrap();
5242
5243 frame(&mut core, &mut ext);
5244 assert!(!over(&ext) && target(&ext).is_none(), "idle");
5245
5246 let paths = vec!["/drop/1.txt".to_string()];
5247 let evs = core.handle_input(InputEvent::DragFiles {
5248 paths: paths.clone(),
5249 at: Vec2::new(50.0, 30.0),
5250 });
5251 assert_eq!(evs[0].payload.get_str("tag"), Some("files"));
5252 frame(&mut core, &mut ext);
5253 assert!(over(&ext), "the files are over the zone");
5254 assert_eq!(target(&ext), Some(zone.0 as i64));
5255 let (dl, _) = core.output();
5256 let lit = dl
5257 .quads
5258 .iter()
5259 .find(|q| q.rect.w == 100.0)
5260 .map(|q| q.color)
5261 .expect("zone quad");
5262 assert!(
5263 (lit.g - 0x55 as f32 / 255.0).abs() < 0.01,
5264 "drop_bg painted"
5265 );
5266
5267 core.handle_input(InputEvent::DropFiles {
5268 paths,
5269 at: Vec2::new(50.0, 30.0),
5270 });
5271 frame(&mut core, &mut ext);
5272 assert!(
5273 !over(&ext) && target(&ext).is_none(),
5274 "a drop ends the hover"
5275 );
5276 }
5277
5278 #[test]
5289 fn the_env_table_is_the_documented_env_shape() {
5290 let mut ext = LuaExtension::from_source(
5291 "env",
5292 r#"
5293 function view(env)
5294 values, calls = {}, {}
5295 for k, v in pairs(env) do
5296 if type(v) == "function" then calls[#calls + 1] = k
5297 elseif type(v) == "table" then
5298 for wk in pairs(v) do values[#values + 1] = k .. "." .. wk end
5299 else values[#values + 1] = k end
5300 end
5301 return column { key = "root",
5302 row { key = "a", focusable = true, width = 50, height = 20 },
5303 column { key = "dock", focus_region = true,
5304 row { key = "d", focusable = true, width = 50, height = 20 },
5305 },
5306 }
5307 end
5308 "#,
5309 )
5310 .unwrap();
5311 let mut core = Core::new();
5312 core.env.refresh_hz = Some(60.0);
5317 core.env.system.accent = Some(kui_core::Color::hex(0x3b82f6ff));
5318 core.env.system.locale = kui_core::Locale::new("en-US");
5319 core.env.window.native_controls = Some(Rect::new(0.0, 0.0, 78.0, 28.0));
5320 frame(&mut core, &mut ext);
5321 core.set_focus(Some(Key::ROOT.str("root").str("dock").str("d")));
5322 frame(&mut core, &mut ext);
5323
5324 let sorted = |name: &str| -> Vec<String> {
5325 let mut v: Vec<String> = ext.lua.globals().get(name).unwrap();
5326 v.sort();
5327 v
5328 };
5329 let mut documented: Vec<String> = kui_core::schema::ENV_FIELDS
5330 .iter()
5331 .flat_map(|f| f.lua.iter().map(|k| (*k).to_string()))
5332 .collect();
5333 documented.extend(
5339 kui_core::schema::THEME_ROLES
5340 .iter()
5341 .map(|r| format!("theme.{}", r.name)),
5342 );
5343 documented.push("theme.appearance".into());
5344 documented.push("theme.disabled_opacity".into());
5345 documented.extend(
5348 kui_core::schema::METRIC_ROLES
5349 .iter()
5350 .map(|r| format!("metrics.{}", r.name)),
5351 );
5352 documented.push("tokens.colors".into());
5355 documented.push("tokens.lengths".into());
5356 documented.sort();
5357 assert_eq!(
5358 sorted("values"),
5359 documented,
5360 "env's value keys and schema::ENV_FIELDS + THEME_ROLES + METRIC_ROLES + tokens disagree"
5361 );
5362 let mut doors: Vec<String> = kui_core::schema::DOORS
5369 .iter()
5370 .filter_map(|d| match d.lua {
5371 kui_core::schema::Cell::Is(name) => Some(name.to_string()),
5372 _ => None,
5373 })
5374 .collect();
5375 doors.sort();
5376 assert_eq!(
5377 sorted("calls"),
5378 doors,
5379 "env's queries and verbs and schema::DOORS's Lua column disagree; update env_table's doc too"
5380 );
5381 }
5382
5383 #[test]
5388 fn a_script_declares_tokens_and_references_them_by_name() {
5389 let mut ext = LuaExtension::from_source(
5390 "tokens",
5391 r##"
5392 tokens = {
5393 colors = { peach = "#ffcc99", ink = { light = "#111111", dark = "#eeeeee" } },
5394 lengths = { side_w = 132, gap = 6 },
5395 }
5396 function view(env)
5397 seen = env.tokens
5398 return column { pad = "$gap", gap = "$gap",
5399 row { key = "a", width = "$side_w", height = 20, bg = "$peach",
5400 border = { w = "$gap", color = "$ink" }, radius = "$radius" },
5401 row { key = "b", width = 20, height = "$side_w", bg = "$surface",
5402 pad = { l = "$gap", r = 2 } },
5403 text({ "x", { "y", color = "$peach", bg = "$ink" } }, { size = "$gap", color = "$peach" }),
5404 }
5405 end
5406 "##,
5407 )
5408 .unwrap();
5409 let mut core = Core::new();
5410 frame(&mut core, &mut ext);
5411 assert!(core.take_warnings().is_empty());
5412 let peach = Color::hex(0xffcc99ff);
5413 let paper = Color::hex(0xeeeeeeff);
5414 let radius = core.metrics().radius;
5415 let surface = core.theme().surface;
5416 let dl = core.output().0;
5417 let a = dl
5418 .quads
5419 .iter()
5420 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
5421 .expect("the peach box");
5422 assert_eq!(a.rect.w, 132.0, "width from a length token");
5423 assert_eq!(a.border_w, 6.0);
5424 assert_eq!(
5425 a.border_color, paper,
5426 "the dark half on an unknown appearance"
5427 );
5428 assert_eq!(a.radius[0], radius, "$radius is the metric");
5429 let b = dl
5430 .quads
5431 .iter()
5432 .find(|q| q.color == surface && q.rect.h == 132.0)
5433 .expect("the $surface box, 132 tall");
5434 assert_eq!(b.rect.w, 20.0);
5435 let seen: Table = ext.lua.globals().get("seen").unwrap();
5436 let colors: Table = seen.get("colors").unwrap();
5437 let lengths: Table = seen.get("lengths").unwrap();
5438 assert_eq!(colors.get::<u32>("peach").unwrap(), 0xffcc99ff);
5439 assert_eq!(colors.get::<u32>("ink").unwrap(), 0xeeeeeeff);
5440 assert_eq!(lengths.get::<f32>("side_w").unwrap(), 132.0);
5441 core.set_system(kui_core::SystemEnv {
5443 appearance: kui_core::Appearance::Light,
5444 ..Default::default()
5445 });
5446 frame(&mut core, &mut ext);
5447 let a = core
5448 .output()
5449 .0
5450 .quads
5451 .iter()
5452 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
5453 .unwrap();
5454 assert_eq!(a.border_color, Color::hex(0x111111ff));
5455 }
5456
5457 #[test]
5463 fn a_script_derives_a_token_from_another() {
5464 let mut ext = LuaExtension::from_source(
5465 "derived",
5466 r##"
5467 tokens = {
5468 colors = {
5469 peach = "#ffcc99",
5470 z_lit = { from = "peach", ops = { "lift", 0.5 } },
5471 a_deep = { from = "z_lit", ops = { { "alpha", 0.5 }, { "mix", "peach", 0.0 } } },
5472 up = { from = "surface", ops = { { "raise", 0.25 } } },
5473 bad = { from = "nothing" },
5474 },
5475 }
5476 function view(env)
5477 seen = env.tokens.colors
5478 return column {
5479 row { key = "a", width = 20, height = 20, bg = "$a_deep" },
5480 row { key = "b", width = 20, height = 20, bg = "$bad" },
5481 }
5482 end
5483 "##,
5484 )
5485 .unwrap();
5486 let mut core = Core::new();
5487 frame(&mut core, &mut ext);
5488 let warnings = core.take_warnings();
5489 let codes: Vec<&str> = warnings.iter().map(|w| w.code).collect();
5490 assert_eq!(codes, ["unknown-token"], "{warnings:?}");
5491 assert!(
5492 warnings[0].message.contains("`$bad`") && warnings[0].message.contains("`$nothing`")
5493 );
5494 let seen: Table = ext.lua.globals().get("seen").unwrap();
5495 assert_eq!(seen.get::<u32>("z_lit").unwrap(), 0xffe6ccff);
5496 assert_eq!(seen.get::<u32>("a_deep").unwrap(), 0xffe6cc80);
5497 let surface = core.theme().surface;
5498 assert_eq!(
5499 seen.get::<u32>("up").unwrap(),
5500 surface.mix(Color::WHITE, 0.25).to_hex(),
5501 "a role source, raised toward the dark base's front"
5502 );
5503 assert!(seen.get::<mlua::Value>("bad").unwrap().is_nil());
5504 let dl = core.output().0;
5505 assert!(
5506 dl.quads.iter().any(|q| q.color.to_hex() == 0xffe6cc80),
5507 "$a_deep painted the derived colour"
5508 );
5509
5510 let refused = |colors: &str| {
5512 LuaExtension::from_source(
5513 "bad",
5514 &format!(
5515 "tokens = {{ colors = {{ {colors} }} }}\nfunction view() return column {{}} end"
5516 ),
5517 )
5518 .err()
5519 .map(|e| e.to_string())
5520 .unwrap_or_default()
5521 };
5522 assert!(
5523 refused(r#"x = { from = "peach", ops = { { "glow", 1 } } }"#)
5524 .contains("unknown verb \"glow\"")
5525 );
5526 assert!(
5527 refused(r#"x = { from = "peach", ops = { { "mix", 0.5 } } }"#)
5528 .contains("mix takes a colour and a number")
5529 );
5530 assert!(
5531 refused(r#"x = { from = "peach", ops = { { "lift", "lots" } } }"#)
5532 .contains("number is a number")
5533 );
5534 assert!(refused(r#"x = { from = 3 }"#).contains("from names a colour token or role"));
5535 assert!(refused(r#"x = { from = "peach", glow = 1 }"#).contains("unknown key \"glow\""));
5536 }
5537
5538 #[test]
5543 fn a_scripts_tokens_sit_over_the_hosts() {
5544 let mut ext = LuaExtension::from_source(
5545 "guest",
5546 r##"
5547 tokens = { colors = { grey = "#808080" } }
5548 function view(env)
5549 first = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
5550 if redeclare then
5551 env.set_tokens { colors = { peach = "#ffaa77" }, lengths = { w = 40 } }
5552 end
5553 second = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
5554 return column { row { key = "a", width = redeclare and "$w" or 10, height = 10, bg = "$peach" } }
5555 end
5556 "##,
5557 )
5558 .unwrap();
5559 let mut core = Core::new();
5560 core.set_tokens(kui_core::Tokens::new().color("peach", Color::hex(0xffcc99ff)));
5561 frame(&mut core, &mut ext);
5562 let first: Table = ext.lua.globals().get("first").unwrap();
5563 assert_eq!(
5564 first.get::<u32>("peach").unwrap(),
5565 0xffcc99ff,
5566 "the host's peach"
5567 );
5568 assert_eq!(
5569 first.get::<u32>("grey").unwrap(),
5570 0x808080ff,
5571 "its own grey"
5572 );
5573 assert!(
5574 core.token_lookup().color("grey").is_err(),
5575 "the guest's grey is not the host's"
5576 );
5577 assert!(core.take_warnings().is_empty());
5578
5579 ext.lua.globals().set("redeclare", true).unwrap();
5580 frame(&mut core, &mut ext);
5581 let second: Table = ext.lua.globals().get("second").unwrap();
5582 assert_eq!(
5583 second.get::<u32>("peach").unwrap(),
5584 0xffaa77ff,
5585 "its own peach now"
5586 );
5587 assert!(
5588 second.get::<Option<u32>>("grey").unwrap().is_none(),
5589 "replaced whole"
5590 );
5591 let a = core
5592 .output()
5593 .0
5594 .quads
5595 .iter()
5596 .find(|q| q.color == Color::hex(0xffaa77ff))
5597 .expect("painted the script's peach");
5598 assert_eq!(a.rect.w, 40.0);
5599 assert_eq!(
5600 core.token_lookup().color("peach"),
5601 Ok(Color::hex(0xffcc99ff)),
5602 "the host's table did not move"
5603 );
5604 }
5605
5606 #[test]
5609 fn a_missing_token_warns_and_paints_nothing() {
5610 let mut ext = LuaExtension::from_source(
5611 "missing",
5612 r##"
5613 tokens = { colors = { surface = "#ff0000", peach = "#ffcc99" }, lengths = { gap = 6 } }
5614 function view(env)
5615 return column {
5616 row { key = "a", width = "$gap", height = 10, bg = "$peech", pad = "$peach" },
5617 row { key = "b", width = "$peach", height = 10, bg = "$gap" },
5618 text("still here", { color = "$peech", size = "$gap" }),
5619 text("still here", { size = 6 }),
5620 text("still here"),
5621 }
5622 end
5623 "##,
5624 )
5625 .unwrap();
5626 let mut core = Core::new();
5627 core.set_inspect(true);
5628 frame(&mut core, &mut ext);
5629 frame(&mut core, &mut ext);
5630 let ws = core.take_warnings();
5631 let mut codes: Vec<&str> = ws.iter().map(|w| w.code).collect();
5632 codes.sort();
5633 assert_eq!(
5634 codes,
5635 [
5636 kui_core::diag::RESERVED_TOKEN,
5637 kui_core::diag::UNKNOWN_TOKEN,
5638 kui_core::diag::UNKNOWN_TOKEN,
5639 kui_core::diag::UNKNOWN_TOKEN,
5640 ],
5641 "surface refused once; peech, peach-as-length and gap-as-colour once each: {ws:?}"
5642 );
5643 assert!(
5644 ws.iter().any(|w| w
5645 .message
5646 .contains("`$gap` is a length token, and this slot takes a color")),
5647 "{ws:?}"
5648 );
5649 let fg = core.theme().fg;
5653 let dl = core.output().0;
5654 assert!(
5655 dl.quads
5656 .iter()
5657 .all(|q| q.kind != kui_core::QuadKind::Solid || q.color.a == 0.0)
5658 );
5659 let glyphs: Vec<_> = dl
5660 .quads
5661 .iter()
5662 .filter(|q| {
5663 matches!(
5664 q.kind,
5665 kui_core::QuadKind::GlyphMask | kui_core::QuadKind::GlyphSubpixel
5666 )
5667 })
5668 .collect();
5669 assert!(!glyphs.is_empty(), "the text painted");
5670 assert!(glyphs.iter().all(|q| q.color == fg), "in the foreground");
5671 let heights: Vec<f32> = core
5676 .nodes()
5677 .iter()
5678 .filter(|n| n.text.as_deref() == Some("still here"))
5679 .map(|n| n.rect.h)
5680 .collect();
5681 assert_eq!(heights.len(), 3, "{heights:?}");
5682 assert!(heights[0] > 0.0, "{heights:?}");
5683 assert_eq!(heights[0], heights[1], "$gap is the literal 6: {heights:?}");
5684 assert!(heights[2] > heights[0], "and not the default: {heights:?}");
5685 }
5686
5687 #[test]
5692 fn a_token_reaches_a_min_a_stroke_and_a_stop_and_misses_by_leaving_the_slot() {
5693 let mut ext = LuaExtension::from_source(
5694 "everywhere",
5695 r##"
5696 tokens = { colors = { peach = "#ffcc99" }, lengths = { gap = 6, wide = 40 } }
5697 function view(env)
5698 return column { pad = 4,
5699 row { key = "clamp", width = 10, height = 10, min_width = "$wide" },
5700 row { key = "typo", width = 10, height = 10, min_width = "$nope" },
5701 line { key = "stroke", from = { 0, 0 }, to = { 30, 0 }, width = "$gap", color = "$peach" },
5702 row { key = "anim", width = 10, height = 10, transition = 100,
5703 keyframes = { { bg = "$peach", width = "$wide" }, { bg = "$peech", radius = "$gap" } },
5704 enter = { width = "$nothing", bg = "$peach" } },
5705 }
5706 end
5707 "##,
5708 )
5709 .unwrap();
5710 let mut core = Core::new();
5711 core.set_inspect(true);
5712 frame(&mut core, &mut ext);
5713 let nodes = core.nodes();
5714 let by = |label: &str| {
5715 nodes
5716 .iter()
5717 .find(|n| n.label.as_deref() == Some(label))
5718 .unwrap()
5719 };
5720 assert_eq!(by("clamp").rect.w, 40.0, "the clamp is the token's 40 px");
5721 assert_eq!(
5722 by("typo").rect.w,
5723 10.0,
5724 "a miss leaves the row at its default"
5725 );
5726 let dl = core.output().0;
5727 let seg = dl
5728 .quads
5729 .iter()
5730 .find(|q| q.kind == kui_core::QuadKind::Segment)
5731 .expect("the line drew");
5732 assert_eq!(seg.color, Color::hex(0xffcc99ff));
5733 assert_eq!(seg.border_w, 6.0, "the stroke is the token's width");
5734 let ws = core.take_warnings();
5735 let mut names: Vec<String> = ws
5736 .iter()
5737 .filter(|w| w.code == kui_core::diag::UNKNOWN_TOKEN)
5738 .map(|w| w.message.clone())
5739 .collect();
5740 names.sort();
5741 assert_eq!(names.len(), 3, "nope, peech, nothing: {ws:?}");
5742 assert!(names.iter().any(|m| m.contains("`$nope`")), "{names:?}");
5743 assert!(names.iter().any(|m| m.contains("`$peech`")), "{names:?}");
5744 assert!(names.iter().any(|m| m.contains("`$nothing`")), "{names:?}");
5745 }
5746
5747 #[test]
5748 fn scripts_move_focus_with_the_verbs() {
5749 let mut ext = LuaExtension::from_source(
5750 "focus",
5751 r#"
5752 function view(env)
5753 if cmd == "set" then env.set_focus(target)
5754 elseif cmd == "blur" then env.blur()
5755 elseif cmd == "next" then env.focus_next()
5756 elseif cmd == "prev" then env.focus_prev() end
5757 cmd = nil
5758 -- `env.focus` is a value the host filled in before view()
5759 -- ran, so it lags a verb called above by a frame;
5760 -- `env.is_focused` is a call and answers about now.
5761 seen_focus = env.focus
5762 seen_live = env.is_focused(target or 0)
5763 seen_window = env.focused
5764 return column { key = "root", pad = 10,
5765 row { key = "a", focusable = true, width = 50, height = 20 },
5766 row { key = "b", focusable = true, width = 50, height = 20 },
5767 row { key = "c", focusable = true, width = 50, height = 20 },
5768 }
5769 end
5770 "#,
5771 )
5772 .unwrap();
5773 let root = Key::ROOT.str("root");
5774 let (a, b, c) = (root.str("a"), root.str("b"), root.str("c"));
5775 let mut core = Core::new();
5776 let cmd = |ext: &LuaExtension, c: &str| ext.lua.globals().set("cmd", c).unwrap();
5777 let seen = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("seen_focus").unwrap();
5778 let live = |ext: &LuaExtension| ext.lua.globals().get::<bool>("seen_live").unwrap();
5779
5780 frame(&mut core, &mut ext);
5782 assert_eq!(core.focus(), None);
5783 assert_eq!(seen(&ext), None, "nil for no focus, not 0");
5784
5785 ext.lua.globals().set("target", b.0 as i64).unwrap();
5787 cmd(&ext, "set");
5788 frame(&mut core, &mut ext);
5789 assert_eq!(core.focus(), Some(b));
5790 assert_eq!(seen(&ext), None, "the value lags a same-frame verb");
5794 assert!(live(&ext), "the query does not");
5795 frame(&mut core, &mut ext);
5796 assert_eq!(
5797 seen(&ext),
5798 Some(b.0 as i64),
5799 "and the next frame carries it"
5800 );
5801
5802 cmd(&ext, "next");
5804 frame(&mut core, &mut ext);
5805 assert_eq!(core.focus(), Some(c));
5806 cmd(&ext, "next");
5807 frame(&mut core, &mut ext);
5808 assert_eq!(core.focus(), Some(a), "wraps");
5809 cmd(&ext, "prev");
5810 frame(&mut core, &mut ext);
5811 assert_eq!(core.focus(), Some(c), "wraps back");
5812
5813 cmd(&ext, "blur");
5814 frame(&mut core, &mut ext);
5815 assert_eq!(core.focus(), None);
5816 frame(&mut core, &mut ext);
5817 assert_eq!(seen(&ext), None);
5818
5819 ext.lua.globals().set("target", "c").unwrap();
5822 cmd(&ext, "set");
5823 frame(&mut core, &mut ext);
5824 assert_eq!(core.focus(), Some(c), "set_focus(\"c\") resolves the label");
5825 assert!(live(&ext), "and is_focused(\"c\") answers about it");
5826 assert!(core.take_warnings().is_empty());
5827 ext.lua.globals().set("target", "nope").unwrap();
5829 cmd(&ext, "set");
5830 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
5831 let e = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
5832 ui.finish();
5833 assert!(
5834 e.contains("no node is keyed \"nope\"") && e.contains("integer key"),
5835 "{e}"
5836 );
5837
5838 assert!(ext.lua.globals().get::<bool>("seen_window").unwrap());
5841 }
5842
5843 #[test]
5846 fn a_shared_label_resolves_to_the_first_and_warns() {
5847 let mut ext = LuaExtension::from_source(
5848 "dup",
5849 r#"
5850 function view(env)
5851 if go then env.set_focus("item"); go = nil end
5852 return column { key = "root",
5853 row { row { key = "item", focusable = true, width = 50, height = 20 } },
5854 row { row { key = "item", focusable = true, width = 50, height = 20 } },
5855 }
5856 end
5857 "#,
5858 )
5859 .unwrap();
5860 let mut core = Core::new();
5861 frame(&mut core, &mut ext);
5862 ext.lua.globals().set("go", true).unwrap();
5863 frame(&mut core, &mut ext);
5864 assert_eq!(
5865 core.focus(),
5866 Some(Key::ROOT.str("root").index(0).str("item")),
5867 "the first, under the auto-keyed first row"
5868 );
5869 let ws = core.take_warnings();
5870 assert_eq!(ws.len(), 1, "{ws:?}");
5871 assert_eq!(ws[0].code, kui_core::diag::AMBIGUOUS_KEY);
5872 assert!(ws[0].message.contains("\"item\""), "{}", ws[0].message);
5873 }
5874
5875 #[test]
5878 fn scripts_open_a_menu_and_hear_the_row() {
5879 let mut ext = LuaExtension::from_source(
5880 "menu",
5881 r#"
5882 frames = 0
5883 function view(env)
5884 frames = frames + 1
5885 if frames == 2 then
5886 opened = env.open_menu("card", 40, 30, {
5887 { role = "copy" },
5888 { role = "separator" },
5889 -- The wire spelling the event reports, and the
5890 -- snake one a script may have learned first.
5891 { role = "selectAll" },
5892 { role = "look_up" },
5893 { label = "Wrap", checked = true },
5894 { label = "Inspect", id = "inspect" },
5895 })
5896 end
5897 return column {
5898 column { key = "card", selectable = true, text("one", { size = 14 }) },
5899 }
5900 end
5901 "#,
5902 )
5903 .unwrap();
5904 let mut core = Core::new();
5905 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5906 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5907 ui.set_origin(OriginId(1));
5908 ext.view(&Slot::root(), &mut ui).unwrap();
5909 ui.finish();
5910 };
5911 frame(&mut core, &mut ext);
5912 frame(&mut core, &mut ext);
5913 assert!(ext.lua.globals().get::<bool>("opened").unwrap());
5914 let roles: Vec<_> = core
5915 .menu()
5916 .expect("open")
5917 .items
5918 .iter()
5919 .map(|i| (i.role, i.checked))
5920 .collect();
5921 assert_eq!(
5922 roles,
5923 [
5924 (kui_core::MenuRole::Copy, false),
5925 (kui_core::MenuRole::Separator, false),
5926 (kui_core::MenuRole::SelectAll, false),
5927 (kui_core::MenuRole::LookUp, false),
5928 (kui_core::MenuRole::Custom, true),
5929 (kui_core::MenuRole::Custom, false),
5930 ]
5931 );
5932 frame(&mut core, &mut ext);
5933 let tree = core.access_tree();
5936 assert_eq!(
5937 tree.nodes
5938 .iter()
5939 .find(|n| n.name.as_deref() == Some("Wrap"))
5940 .expect("the checked row")
5941 .checked,
5942 Some(true)
5943 );
5944 let row = tree
5945 .nodes
5946 .iter()
5947 .find(|n| n.name.as_deref() == Some("Inspect"))
5948 .expect("the menu drew")
5949 .rect;
5950 let at = kui_core::Vec2::new(row.x + row.w / 2.0, row.y + row.h / 2.0);
5951 core.handle_input(InputEvent::CursorMoved(at));
5952 core.handle_input(InputEvent::mouse_down(1));
5953 let events = core.handle_input(InputEvent::mouse_up());
5954 assert_eq!(events.len(), 1, "{events:?}");
5955 assert_eq!(events[0].payload.get_str("item"), Some("inspect"));
5956 assert_eq!(
5957 events[0].origin,
5958 OriginId(1),
5959 "posted with the origin that asked for the menu"
5960 );
5961 }
5962
5963 #[test]
5966 fn scripts_select_and_read_a_scope() {
5967 let mut ext = LuaExtension::from_source(
5968 "sel",
5969 r#"
5970 frames = 0
5971 function view(env)
5972 frames = frames + 1
5973 if frames > 1 then
5974 before = env.selection_text()
5975 took = env.select_all_in("card")
5976 text_out = env.selection_text()
5977 not_a_scope = env.select_all_in("plain")
5978 end
5979 return column {
5980 column { key = "card", selectable = true,
5981 text("one", { size = 14 }),
5982 text("two", { size = 14 }) },
5983 row { key = "plain", width = 10, height = 10 },
5984 }
5985 end
5986 "#,
5987 )
5988 .unwrap();
5989 let mut core = Core::new();
5990 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5991 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5992 ui.set_origin(OriginId(1));
5993 ext.view(&Slot::root(), &mut ui).unwrap();
5994 ui.finish();
5995 };
5996 frame(&mut core, &mut ext);
5997 frame(&mut core, &mut ext);
5998 let g = ext.lua.globals();
5999 assert!(matches!(
6000 g.get::<mlua::Value>("before").unwrap(),
6001 mlua::Value::Nil
6002 ));
6003 assert!(g.get::<bool>("took").unwrap());
6004 assert_eq!(g.get::<String>("text_out").unwrap(), "one\ntwo");
6005 assert!(
6006 !g.get::<bool>("not_a_scope").unwrap(),
6007 "a node that drew no text is not a scope"
6008 );
6009 }
6010
6011 #[test]
6016 fn scripts_map_points_to_bytes_on_a_line_of_runs() {
6017 let mut ext = LuaExtension::from_source(
6018 "hit",
6019 r#"
6020 frames = 0
6021 function view(env)
6022 local mono = { size = 14, family = "mono" }
6023 local w = env.measure_text("M", mono, 0).width
6024 frames = frames + 1
6025 -- A label nothing has declared yet is an error by name
6026 -- (F5), so the first frame declares and the next asks.
6027 if frames > 1 then
6028 hit = env.text_hit("line", 7.2 * w, 5)
6029 seam = env.caret_rect("line", 4)
6030 far = env.text_hit("line", 390, 5)
6031 none = env.caret_rect("plain", 0)
6032 end
6033 cell = w
6034 return column {
6035 row { key = "line",
6036 text("let ", mono),
6037 row { bg = 0x3b5bd455, text("value", mono) },
6038 text(" = 1;", mono) },
6039 row { key = "plain", width = 10, height = 10 },
6040 }
6041 end
6042 "#,
6043 )
6044 .unwrap();
6045 let mut core = Core::new();
6046 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6047 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6048 ui.set_origin(OriginId(1));
6049 ext.view(&Slot::root(), &mut ui).unwrap();
6050 ui.finish();
6051 };
6052 frame(&mut core, &mut ext);
6053 let hit: mlua::Value = ext.lua.globals().get("hit").unwrap();
6054 assert!(
6055 matches!(hit, mlua::Value::Nil),
6056 "nothing drawn before the first frame"
6057 );
6058 frame(&mut core, &mut ext);
6059 let hit: mlua::Table = ext.lua.globals().get("hit").unwrap();
6060 assert_eq!(
6061 hit.get::<usize>("byte").unwrap(),
6062 7,
6063 "the fourth cell of \"value\""
6064 );
6065 assert_eq!(hit.get::<u32>("line").unwrap(), 0);
6066 let cell: f32 = ext.lua.globals().get("cell").unwrap();
6067 let seam: mlua::Table = ext.lua.globals().get("seam").unwrap();
6068 let x: f32 = seam.get("x").unwrap();
6069 assert!((x - 4.0 * cell).abs() < 0.75, "{x} vs {}", 4.0 * cell);
6070 assert_eq!(seam.get::<f32>("w").unwrap(), 0.0);
6071 let far: mlua::Table = ext.lua.globals().get("far").unwrap();
6072 assert_eq!(
6077 far.get::<usize>("byte").unwrap(),
6078 14,
6079 "the end, across the runs"
6080 );
6081 let none: mlua::Value = ext.lua.globals().get("none").unwrap();
6082 assert!(matches!(none, mlua::Value::Nil), "a node that drew no text");
6083 }
6084
6085 #[test]
6089 fn scripts_draw_a_screen_of_cells() {
6090 let mut ext = LuaExtension::from_source(
6091 "term",
6092 r#"
6093 function view(env)
6094 return column { cells { key = "term", rows = 2, cols = 11, size = 14, family = "mono",
6095 lines = { "hello world", " bye" },
6096 runs = { { 1, 2, 3, 0xff0000ff, 0x0000ffff, 1 } },
6097 cursor_at = { 1, 4 }, cursor_shape = "underline" } }
6098 end
6099 "#,
6100 )
6101 .unwrap();
6102 let mut core = Core::new();
6103 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6104 ui.set_origin(OriginId(1));
6105 ext.view(&Slot::root(), &mut ui).unwrap();
6106 ui.finish();
6107 let (dl, _) = core.output();
6108 let glyphs = dl
6109 .quads
6110 .iter()
6111 .filter(|q| q.kind == kui_core::QuadKind::GlyphMask)
6112 .count();
6113 assert_eq!(glyphs, 13, "hello world + bye");
6114 let tree = core.access_tree();
6115 let term = tree
6116 .nodes
6117 .iter()
6118 .find(|n| n.role == kui_core::Role::Terminal)
6119 .expect("a terminal node");
6120 assert_eq!(term.value.as_deref(), Some("hello world\n bye"));
6121 }
6122
6123 #[test]
6129 fn a_lua_grid_selects_by_cell_word_and_row() {
6130 let mut ext = LuaExtension::from_source(
6131 "term",
6132 r#"
6133 function view(env)
6134 said = env.selection_text()
6135 return column { cells { key = "term", rows = 2, cols = 12,
6136 size = 14, family = "mono", line_height = 20,
6137 origin_line = 900, selectable = true,
6138 lines = { "hello world", "bye there" } } }
6139 end
6140 "#,
6141 )
6142 .unwrap();
6143 let mut core = Core::new();
6144 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6145 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6146 ui.set_origin(OriginId(1));
6147 ext.view(&Slot::root(), &mut ui).unwrap();
6148 ui.finish();
6149 };
6150 frame(&mut core, &mut ext);
6151 let said = |ext: &LuaExtension| ext.lua.globals().get::<Option<String>>("said").unwrap();
6152
6153 let w = core
6155 .measure_text(
6156 "M",
6157 &kui_core::TextStyle::new(14.0)
6158 .family(kui_core::FontFamily::Mono)
6159 .line_height(20.0),
6160 None,
6161 )
6162 .width;
6163 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
6164 let click = |core: &mut Core, p: Vec2, clicks: u8| {
6165 core.handle_input(InputEvent::CursorMoved(p));
6166 core.handle_input(InputEvent::MouseDown {
6167 button: kui_core::MouseButton::Primary,
6168 clicks,
6169 });
6170 core.handle_input(InputEvent::MouseUp {
6171 button: kui_core::MouseButton::Primary,
6172 });
6173 };
6174
6175 click(&mut core, at(0, 8), 2);
6176 frame(&mut core, &mut ext);
6177 assert_eq!(said(&ext).as_deref(), Some("world"), "a double click");
6178
6179 click(&mut core, at(1, 1), 3);
6180 frame(&mut core, &mut ext);
6181 assert_eq!(said(&ext).as_deref(), Some("bye there"), "a triple click");
6182 }
6183
6184 #[test]
6190 fn a_grid_selection_reads_back_as_absolute_lines_and_columns() {
6191 let mut ext = LuaExtension::from_source(
6192 "term",
6193 r#"
6194 function view(env)
6195 sel = env.cell_selection()
6196 return column { cells { key = "term", rows = 2, cols = 12,
6197 size = 14, family = "mono", line_height = 20,
6198 origin_line = 900, selectable = true,
6199 lines = { "hello world", "bye there" } } }
6200 end
6201 "#,
6202 )
6203 .unwrap();
6204 let mut core = Core::new();
6205 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6206 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6207 ui.set_origin(OriginId(1));
6208 ext.view(&Slot::root(), &mut ui).unwrap();
6209 ui.finish();
6210 };
6211 frame(&mut core, &mut ext);
6212 assert!(
6213 ext.lua
6214 .globals()
6215 .get::<mlua::Value>("sel")
6216 .unwrap()
6217 .is_nil(),
6218 "nothing selected yet"
6219 );
6220 let w = core
6221 .measure_text(
6222 "M",
6223 &kui_core::TextStyle::new(14.0)
6224 .family(kui_core::FontFamily::Mono)
6225 .line_height(20.0),
6226 None,
6227 )
6228 .width;
6229 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
6230 core.handle_input(InputEvent::CursorMoved(at(1, 4)));
6231 core.handle_input(InputEvent::MouseDown {
6232 button: kui_core::MouseButton::Primary,
6233 clicks: 1,
6234 });
6235 core.handle_input(InputEvent::CursorMoved(at(0, 1)));
6236 core.handle_input(InputEvent::MouseUp {
6237 button: kui_core::MouseButton::Primary,
6238 });
6239 frame(&mut core, &mut ext);
6240 let sel: Table = ext.lua.globals().get("sel").unwrap();
6241 let end = |name: &str| -> (u64, usize) {
6242 let t: Table = sel.get(name).unwrap();
6243 (t.get("line").unwrap(), t.get("col").unwrap())
6244 };
6245 assert_eq!(end("anchor"), (901, 4), "the press, on the second row");
6246 assert_eq!(end("focus"), (900, 1), "the pointer, backwards");
6247 assert_eq!(
6248 sel.get::<i64>("node").unwrap(),
6249 core.key_of("term").unwrap().0 as i64
6250 );
6251 assert!(!sel.get::<bool>("block").unwrap());
6252 }
6253
6254 #[test]
6257 fn spans_carry_their_decorations() {
6258 let mut ext = LuaExtension::from_source(
6259 "deco",
6260 r#"
6261 function view(env)
6262 return row { text({ "let ", { "value", bg = 0x3b5bd455, underline = true },
6263 " = 1;" }, { size = 14, family = "mono" }) }
6264 end
6265 "#,
6266 )
6267 .unwrap();
6268 let mut core = Core::new();
6269 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6270 ui.set_origin(OriginId(1));
6271 ext.view(&Slot::root(), &mut ui).unwrap();
6272 ui.finish();
6273 let (dl, _) = core.output();
6274 let solids = dl
6275 .quads
6276 .iter()
6277 .filter(|q| q.kind == kui_core::QuadKind::Solid)
6278 .count();
6279 assert_eq!(solids, 2, "a background and an underline");
6280 }
6281
6282 #[test]
6287 fn underlines_have_a_colour_and_a_shape_of_their_own() {
6288 let mut ext = LuaExtension::from_source(
6289 "k4",
6290 r#"
6291 function view(env)
6292 return column {
6293 text({ "let ", { "value", underline_color = 0xff0000ff, underline_style = "wavy" } },
6294 { size = 14, family = "mono" }),
6295 text("warn", { size = 14, family = "mono", underline_color = 0x00ff00ff }),
6296 cells { rows = 1, cols = 3, size = 14, family = "mono",
6297 lines = { "abc" }, runs = { { 0, 0, 3, 0, 0, 32, 0xff0000ff } } },
6298 }
6299 end
6300 "#,
6301 )
6302 .unwrap();
6303 let mut core = Core::new();
6304 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6305 ui.set_origin(OriginId(1));
6306 ext.view(&Slot::root(), &mut ui).unwrap();
6307 ui.finish();
6308 let (dl, _) = core.output();
6309 let red = Color::hex(0xff0000ff);
6310 let segs: Vec<_> = dl
6311 .quads
6312 .iter()
6313 .filter(|q| q.kind == kui_core::QuadKind::Segment)
6314 .collect();
6315 assert!(
6316 segs.len() >= 6,
6317 "a wave under the span and an undercurl over the cells: {}",
6318 segs.len()
6319 );
6320 assert!(
6321 segs.iter().all(|q| q.color == red),
6322 "in the underline colour"
6323 );
6324 let solids: Vec<_> = dl
6325 .quads
6326 .iter()
6327 .filter(|q| q.kind == kui_core::QuadKind::Solid)
6328 .collect();
6329 assert_eq!(solids.len(), 1, "the text's solid line, coloured");
6330 assert_eq!(solids[0].color, Color::hex(0x00ff00ff));
6331 let mut bad = LuaExtension::from_source(
6333 "k4bad",
6334 r#"function view(env) return text({ { "x", underline_style = "squiggly" } }) end"#,
6335 )
6336 .unwrap();
6337 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6338 ui.set_origin(OriginId(1));
6339 let err = bad.view(&Slot::root(), &mut ui).unwrap_err();
6340 assert!(err.contains("solid | wavy | dotted"), "{err}");
6341 }
6342
6343 #[test]
6347 fn a_line_takes_a_dash_as_a_length_a_pair_or_four() {
6348 let segments = |view: &str| -> Result<usize, String> {
6349 let mut ext = LuaExtension::from_source("dash", view).unwrap();
6350 let mut core = Core::new();
6351 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6352 ui.set_origin(OriginId(1));
6353 let lowered = ext.view(&Slot::root(), &mut ui);
6354 ui.finish();
6355 lowered?;
6356 let (dl, _) = core.output();
6357 Ok(dl
6358 .quads
6359 .iter()
6360 .filter(|q| q.kind == kui_core::QuadKind::Segment)
6361 .count())
6362 };
6363 let line = |dash: &str| {
6364 format!(
6365 "function view(env) return column {{ width = 200, height = 40,
6366 line {{ from = {{0, 20}}, to = {{100, 20}}, width = 2, {dash} }} }} end"
6367 )
6368 };
6369 assert_eq!(segments(&line("")), Ok(1));
6370 assert_eq!(segments(&line("dash = 5")), Ok(10));
6372 assert_eq!(segments(&line("dash = {6, 4}")), Ok(10));
6373 assert_eq!(segments(&line("dash = {6, 4}, dash_offset = 5")), Ok(10));
6374 assert_eq!(segments(&line("dash = {10, 4, 2, 4}")), Ok(10));
6375 let err = segments(&line("dash = {1, 2, 3}")).unwrap_err();
6376 assert!(err.contains("dash is a length"), "{err}");
6377 }
6378
6379 #[test]
6383 fn features_are_a_text_option() {
6384 let mut ext = LuaExtension::from_source(
6385 "features",
6386 r#"
6387 function view(env)
6388 return row { text("fi ->", { size = 16 }),
6389 text("fi ->", { size = 16, features = "liga=0 calt=0" }) }
6390 end
6391 "#,
6392 )
6393 .unwrap();
6394 let mut core = Core::new();
6395 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6396 ui.set_origin(OriginId(1));
6397 ext.view(&Slot::root(), &mut ui).unwrap();
6398 ui.finish();
6399 assert_eq!(core.text_cache_len(), 2);
6400 }
6401
6402 #[test]
6408 fn a_style_table_shared_by_three_texts_is_three_texts() {
6409 let mut ext = LuaExtension::from_source(
6410 "shared",
6411 r#"
6412 local mono = { size = 14, family = "mono" }
6413 function view(env)
6414 return row { text("a", mono), text("bb", mono), text("ccc", mono) }
6415 end
6416 "#,
6417 )
6418 .unwrap();
6419 let mut core = Core::new();
6420 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6421 ui.set_origin(OriginId(1));
6422 ext.view(&Slot::root(), &mut ui).unwrap();
6423 ui.finish();
6424 let names: Vec<String> = core
6425 .access_tree()
6426 .nodes
6427 .iter()
6428 .filter_map(|n| n.value.clone().or_else(|| n.name.clone()))
6429 .collect();
6430 let joined = names.join("|");
6431 assert!(
6432 joined.contains("a") && joined.contains("bb") && joined.contains("ccc"),
6433 "three different texts, got {joined:?}"
6434 );
6435 assert_eq!(
6436 core.text_cache_len(),
6437 3,
6438 "three strings shaped, not one three times"
6439 );
6440 }
6441
6442 #[test]
6447 fn scripts_slice_a_long_list_from_the_geometry() {
6448 let mut ext = LuaExtension::from_source(
6449 "virtual",
6450 r#"
6451 ROWS, ROW_H, built = 10000, 30, 0
6452 function view(env)
6453 local g = env.scroll_geometry(list_key)
6454 -- No layout yet: fall back to the window for one frame.
6455 local h = g and g.h or 200
6456 local top = g and g.offset.y or 0
6457 local first = math.min(ROWS, math.max(0, math.floor(top / ROW_H)))
6458 local last = math.min(ROWS, math.ceil((top + h) / ROW_H))
6459 local list = { key = "list", width = "grow", height = "grow",
6460 scroll_y = true }
6461 if first > 0 then
6462 list[#list + 1] = row { key = "lead", width = "grow",
6463 height = first * ROW_H }
6464 end
6465 for i = first, last - 1 do
6466 list[#list + 1] = row { key = "row" .. i, width = "grow",
6467 height = ROW_H, bg = 0x282840ff }
6468 end
6469 if last < ROWS then
6470 list[#list + 1] = row { key = "tail", width = "grow",
6471 height = (ROWS - last) * ROW_H }
6472 end
6473 built = last - first
6474 return column(list)
6475 end
6476 "#,
6477 )
6478 .unwrap();
6479 let list = Key::ROOT.str("list");
6480 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
6481 let mut core = Core::new();
6482 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6483 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6484 ui.set_origin(OriginId(1));
6485 ext.view(&Slot::root(), &mut ui).unwrap();
6486 ui.finish();
6487 };
6488
6489 frame(&mut core, &mut ext);
6490 frame(&mut core, &mut ext);
6491 let built: i64 = ext.lua.globals().get("built").unwrap();
6492 assert_eq!(built, 7, "200 / 30 rounded up");
6493
6494 let g = core.scroll_geometry(list).expect("laid out");
6497 assert_eq!(g.content.h, 10_000.0 * 30.0);
6498 core.set_scroll(list, Vec2::new(0.0, 1e9));
6499 frame(&mut core, &mut ext);
6500 frame(&mut core, &mut ext);
6501 assert_eq!(core.scroll_offset(list).y, 10_000.0 * 30.0 - 200.0);
6502 let built: i64 = ext.lua.globals().get("built").unwrap();
6503 assert_eq!(built, 7, "still a screenful at the far end");
6504 }
6505
6506 #[test]
6512 fn the_prelude_virtualizes_a_long_list_in_one_call() {
6513 let mut ext = LuaExtension::from_source(
6514 "virtual",
6515 r#"
6516 ROWS, ROW_H, first_built, built = 10000, 30, -1, 0
6517 function view(env)
6518 built, first_built = 0, -1
6519 return uniform_list(env,
6520 { key = "list", rows = ROWS, row_h = ROW_H,
6521 width = "grow", height = "grow" },
6522 function(i)
6523 built = built + 1
6524 if first_built < 0 then first_built = i end
6525 return column { fill = true, bg = 0x282840ff,
6526 on_click = { kind = "pick", row = i } }
6527 end)
6528 end
6529 "#,
6530 )
6531 .unwrap();
6532 let list = Key::ROOT.str("list");
6533 let mut core = Core::new();
6534 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6535 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6536 ui.set_origin(OriginId(1));
6537 ext.view(&Slot::root(), &mut ui).unwrap();
6538 ui.finish();
6539 };
6540
6541 frame(&mut core, &mut ext);
6542 frame(&mut core, &mut ext);
6543 let built: i64 = ext.lua.globals().get("built").unwrap();
6544 assert_eq!(built, 9, "200 / 30 rounded up, plus two rows of overscan");
6545
6546 let g = core.scroll_geometry(list).expect("laid out");
6549 assert_eq!(g.content.h, 10_000.0 * 30.0);
6550 core.set_scroll(list, Vec2::new(0.0, 300.0 * 30.0));
6551 frame(&mut core, &mut ext);
6552 frame(&mut core, &mut ext);
6553 let first: i64 = ext.lua.globals().get("first_built").unwrap();
6554 assert_eq!(first, 298, "two rows of overscan above row 300");
6555 let built: i64 = ext.lua.globals().get("built").unwrap();
6556 assert_eq!(built, 11, "a screenful with overscan on both sides now");
6557 assert!(
6560 core.access_tree()
6561 .nodes
6562 .iter()
6563 .any(|n| n.key == list.index(300).index(0)),
6564 "row 300 is not keyed by its data index"
6565 );
6566 }
6567
6568 #[test]
6572 fn a_view_names_a_family_and_draws_in_it() {
6573 let mut ext = LuaExtension::from_source(
6574 "fam",
6575 r#"
6576 named, sans, missed = nil, nil, nil
6577 function view(env)
6578 named = env.measure_text("iiiWWW", { family = "Fixture Mono", size = 20 }).width
6579 sans = env.measure_text("iiiWWW", { family = "sans", size = 20 }).width
6580 return column {
6581 text("iiiWWW", { family = "Fixture Mono", size = 20 }),
6582 text("x", { family = "No Such Family 7" }),
6583 }
6584 end
6585 "#,
6586 )
6587 .unwrap();
6588 let mut core = Core::new();
6589 core.add_font_data(kui_core::testing::font_face(
6590 "Fixture Mono",
6591 400,
6592 false,
6593 true,
6594 ))
6595 .unwrap();
6596 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6597 ui.set_origin(OriginId(1));
6598 ext.view(&Slot::root(), &mut ui).unwrap();
6599 ui.finish();
6600 let handle = core.add_system_font("Fixture Mono").unwrap();
6601 let expected = core
6602 .measure_text("iiiWWW", &kui_core::TextStyle::new(20.0).font(handle), None)
6603 .width;
6604 let g = ext.lua.globals();
6605 let (named, sans) = (
6606 g.get::<f32>("named").unwrap(),
6607 g.get::<f32>("sans").unwrap(),
6608 );
6609 assert_eq!(named, expected, "the face the host's handle draws");
6610 assert_ne!(named, sans, "and not sans");
6611 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
6612 assert_eq!(codes, ["unknown-family"]);
6613 }
6614
6615 #[test]
6618 fn the_prelude_reveals_a_row_styles_rows_and_splits() {
6619 let mut ext = LuaExtension::from_source(
6620 "dx22",
6621 r#"
6622 target, scrolled, page = nil, nil, nil
6623 function view(env)
6624 if target then scrolled = reveal_row(env, "list", target, 20) end
6625 page = rows_in_view(env, "list", 20)
6626 zero = rows_in_view(env, "list", 0)
6627 return row { width = 300, height = 100,
6628 uniform_list(env,
6629 { key = "list", rows = 50, row_h = 20, width = 200, height = 100,
6630 row_props = function(i) return { on_click = { kind = "pick", row = i } } end },
6631 function(i) return text("row " .. i) end),
6632 splitter(env, { key = "bar", on_drag = { kind = "split" } }),
6633 column { width = "grow", height = "grow" },
6634 }
6635 end
6636 "#,
6637 )
6638 .unwrap();
6639 let mut core = Core::new();
6640 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6641 let mut ui = core.frame(Size::new(300.0, 100.0), 1.0);
6642 ui.set_origin(OriginId(1));
6643 ext.view(&Slot::root(), &mut ui).unwrap();
6644 ui.finish();
6645 };
6646 frame(&mut core, &mut ext);
6647 frame(&mut core, &mut ext);
6648 assert_eq!(ext.lua.globals().get::<i64>("page").unwrap(), 5);
6649 assert!(
6650 core.take_warnings().is_empty(),
6651 "every prop the three spell is known"
6652 );
6653
6654 ext.lua.globals().set("target", 40).unwrap();
6655 frame(&mut core, &mut ext);
6656 assert!(ext.lua.globals().get::<bool>("scrolled").unwrap());
6657 let list = core.key_of("list").unwrap();
6658 assert_eq!(
6659 core.scroll_offset(list),
6660 Vec2::new(0.0, 760.0),
6661 "row 40 to the middle"
6662 );
6663 core.handle_input(InputEvent::CursorMoved(Vec2::new(10.0, 45.0)));
6666 core.handle_input(InputEvent::mouse_down(1));
6667 let up = core.handle_input(InputEvent::mouse_up());
6668 assert_eq!(up.iter().find_map(|e| e.payload.get_int("row")), Some(40));
6669
6670 core.handle_input(InputEvent::CursorMoved(Vec2::new(202.0, 50.0)));
6672 core.handle_input(InputEvent::mouse_down(1));
6673 core.handle_input(InputEvent::CursorMoved(Vec2::new(225.0, 50.0)));
6674 let end = core.handle_input(InputEvent::mouse_up());
6675 let d = end.iter().find_map(|e| e.drag()).expect("the drag's end");
6676 assert_eq!(d.ratio().x, 0.75);
6677
6678 ext.lua.globals().set("target", 500).unwrap();
6680 frame(&mut core, &mut ext);
6681 assert!(!ext.lua.globals().get::<bool>("scrolled").unwrap());
6682 assert_eq!(core.scroll_offset(list), Vec2::new(0.0, 760.0));
6683 assert_eq!(ext.lua.globals().get::<i64>("zero").unwrap(), 0);
6684 }
6685
6686 #[test]
6690 fn a_uniform_list_that_shrank_lands_in_one_frame() {
6691 let mut ext = LuaExtension::from_source(
6692 "virtual",
6693 r#"
6694 ROWS, first_built = 200, -1
6695 function view(env)
6696 first_built = -1
6697 return uniform_list(env,
6698 { key = "list", rows = ROWS, row_h = 20,
6699 width = "grow", height = "grow" },
6700 function(i)
6701 if first_built < 0 then first_built = i end
6702 return column { fill = true, bg = 0x282840ff }
6703 end)
6704 end
6705 "#,
6706 )
6707 .unwrap();
6708 let list = Key::ROOT.str("list");
6709 let mut core = Core::new();
6710 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6711 let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
6712 ui.set_origin(OriginId(1));
6713 ext.view(&Slot::root(), &mut ui).unwrap();
6714 ui.finish();
6715 };
6716 frame(&mut core, &mut ext);
6717 frame(&mut core, &mut ext);
6718 core.set_scroll(list, Vec2::new(0.0, 3_000.0));
6719 frame(&mut core, &mut ext);
6720 frame(&mut core, &mut ext);
6721 let deep: i64 = ext.lua.globals().get("first_built").unwrap();
6722 assert!(
6723 deep > 100,
6724 "expected to be deep in the list, built from {deep}"
6725 );
6726
6727 ext.lua.globals().set("ROWS", 10).unwrap();
6728 frame(&mut core, &mut ext);
6729 let g = core.scroll_geometry(list).expect("laid out");
6730 assert_eq!(
6731 g.content.h,
6732 10.0 * 20.0,
6733 "the content is the list it has now"
6734 );
6735 frame(&mut core, &mut ext);
6736 let first: i64 = ext.lua.globals().get("first_built").unwrap();
6737 assert_eq!(first, 0, "and every row of it is built");
6738 assert_eq!(core.scroll_offset(list).y, 0.0);
6739 }
6740
6741 const VARIABLE_LIST: &str = r#"
6746 heights = row_heights(1000, 20)
6747 measured = 0
6748 function view(env)
6749 return list(env,
6750 { key = "list", heights = heights, width = "grow", height = "grow",
6751 transition = TRANSITION },
6752 function(i, width)
6753 measured = measured + 1
6754 if i < 100 then return 20 else return 60 end
6755 end,
6756 function(i)
6757 return column { width = "grow", height = "grow", bg = 0x282840ff,
6758 label = "row", on_click = i }
6759 end)
6760 end
6761 "#;
6762
6763 fn variable_frame(core: &mut Core, ext: &mut LuaExtension, t: f64) {
6765 core.set_time(t);
6766 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6767 ui.set_origin(OriginId(1));
6768 ext.view(&Slot::root(), &mut ui).unwrap();
6769 ui.finish();
6770 }
6771
6772 fn row_under(core: &mut Core, y: f32) -> Option<i64> {
6775 core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, y)));
6776 core.handle_input(InputEvent::mouse_down(1));
6777 let evs = core.handle_input(InputEvent::mouse_up());
6778 evs.first()?.payload.as_int()
6779 }
6780
6781 #[test]
6785 fn a_lua_list_keeps_the_row_under_the_pointer_while_the_estimate_moves() {
6786 let mut ext = LuaExtension::from_source("variable", VARIABLE_LIST).unwrap();
6787 let list = Key::ROOT.str("list");
6788 let mut core = Core::new();
6789 variable_frame(&mut core, &mut ext, 0.0);
6790 variable_frame(&mut core, &mut ext, 0.0);
6791 let measured: i64 = ext.lua.globals().get("measured").unwrap();
6792 assert!(
6793 (10..=20).contains(&measured),
6794 "a screenful measured, not the list: {measured}"
6795 );
6796
6797 core.set_scroll(list, Vec2::new(0.0, 5_000.0));
6798 variable_frame(&mut core, &mut ext, 0.0);
6799 let settled = row_under(&mut core, 4.0);
6800 assert!(settled.is_some(), "nothing under the top edge");
6801 for n in 0..4 {
6802 variable_frame(&mut core, &mut ext, 0.0);
6803 assert_eq!(
6804 row_under(&mut core, 4.0),
6805 settled,
6806 "the content slid on frame {n} as the estimate moved"
6807 );
6808 }
6809 let g = core.scroll_geometry(list).expect("laid out");
6810 assert!(
6811 g.content.h > 1000.0 * 20.0 * 1.5,
6812 "the list learned it is longer: {}",
6813 g.content.h
6814 );
6815 }
6816
6817 #[test]
6821 fn a_lua_list_glides_to_the_row_asked_for() {
6822 let mut ext =
6823 LuaExtension::from_source("variable", &format!("TRANSITION = 100\n{VARIABLE_LIST}"))
6824 .unwrap();
6825 let list = Key::ROOT.str("list");
6826 let mut core = Core::new();
6827 let mut t = 0.0;
6828 variable_frame(&mut core, &mut ext, t);
6829 variable_frame(&mut core, &mut ext, t);
6830 let target = 400;
6831 let offset: f32 = ext
6832 .lua
6833 .load(format!("return heights:offset_of({target})"))
6834 .eval()
6835 .unwrap();
6836 core.set_scroll(list, Vec2::new(0.0, offset));
6837 for _ in 0..30 {
6838 t += 1.0 / 60.0;
6839 variable_frame(&mut core, &mut ext, t);
6840 }
6841 assert_eq!(row_under(&mut core, 4.0), Some(target));
6842 }
6843
6844 #[test]
6848 fn a_script_asks_for_a_file_dialog_and_hears_the_answer() {
6849 let mut ext = LuaExtension::from_source(
6850 "files",
6851 r#"
6852 ask, asked, again, waiting, heard = true, nil, nil, nil, nil
6853 function view(env)
6854 if ask then
6855 asked = env.request_files {
6856 mode = "save", title = "Export", file_name = "notes.md",
6857 filters = { { name = "Markdown", extensions = { "md" } } },
6858 tag = "export",
6859 }
6860 again = env.request_files {}
6861 waiting = env.awaiting_files()
6862 ask = false
6863 end
6864 return column {}
6865 end
6866 function on_event(ev)
6867 if ev.kind == "files" then heard = ev.paths[1] .. "|" .. ev.tag end
6868 end
6869 "#,
6870 )
6871 .unwrap();
6872 let mut core = Core::new();
6873 variable_frame(&mut core, &mut ext, 0.0);
6874 let g = ext.lua.globals();
6875 assert!(g.get::<bool>("asked").unwrap(), "the first ask is taken");
6876 assert!(
6877 !g.get::<bool>("again").unwrap(),
6878 "a second while it is out is not"
6879 );
6880 assert!(g.get::<bool>("waiting").unwrap());
6881
6882 let asks = core.take_file_requests();
6883 assert_eq!(asks.len(), 1);
6884 let d = &asks[0];
6885 assert_eq!(d.mode, kui_core::FileDialogMode::Save);
6886 assert_eq!(d.file_name.as_deref(), Some("notes.md"));
6887 assert_eq!(d.filters[0].extensions, vec!["md".to_string()]);
6888
6889 let evs = core.handle_input(InputEvent::Files(vec!["/tmp/notes.md".into()]));
6890 assert_eq!(evs.len(), 1);
6891 assert_eq!(
6892 evs[0].origin,
6893 OriginId(1),
6894 "the answer goes to the script that asked"
6895 );
6896 for e in &evs {
6897 ext.on_event(e);
6898 }
6899 let heard: String = ext.lua.globals().get("heard").unwrap();
6900 assert_eq!(heard, "/tmp/notes.md|export");
6901 }
6902
6903 #[test]
6907 fn a_host_hands_a_script_the_documents_the_os_opened() {
6908 let mut ext = LuaExtension::from_source(
6909 "open",
6910 r#"
6911 heard = nil
6912 function view(env) return column {} end
6913 function on_event(ev)
6914 if ev.kind == "open" then heard = #ev.paths .. "|" .. ev.paths[2] end
6915 end
6916 "#,
6917 )
6918 .unwrap();
6919 let mut core = Core::new();
6920 variable_frame(&mut core, &mut ext, 0.0);
6921 let evs = core.handle_input(InputEvent::Open(vec![
6922 "/tmp/a.txt".into(),
6923 "/tmp/b.md".into(),
6924 ]));
6925 assert_eq!(evs.len(), 1);
6926 assert_eq!(evs[0].origin, OriginId::HOST, "nobody asked: the host's");
6927 assert_eq!(evs[0].key, kui_core::Key::ROOT);
6928 for e in &evs {
6929 ext.on_event(e);
6930 }
6931 let heard: String = ext.lua.globals().get("heard").unwrap();
6932 assert_eq!(heard, "2|/tmp/b.md");
6933 assert!(
6934 core.handle_input(InputEvent::Open(Vec::new())).is_empty(),
6935 "no documents is no event"
6936 );
6937 }
6938
6939 #[test]
6942 fn a_lua_list_names_what_it_is_missing() {
6943 let mut ext = LuaExtension::from_source(
6944 "bad",
6945 r#"
6946 function view(env)
6947 ok, err = pcall(list, env, { key = "list" }, function() return 1 end,
6948 function() return column {} end)
6949 return column {}
6950 end
6951 "#,
6952 )
6953 .unwrap();
6954 let mut core = Core::new();
6955 variable_frame(&mut core, &mut ext, 0.0);
6956 let err: String = ext.lua.globals().get("err").unwrap();
6957 assert!(err.contains("row_heights"), "{err}");
6958 }
6959
6960 #[test]
6964 fn a_query_answers_for_an_undeclared_label_and_a_command_refuses() {
6965 let mut ext = LuaExtension::from_source(
6966 "queries",
6967 r#"
6968 function view(env)
6969 geom = env.scroll_geometry("nothing")
6970 off = env.scroll_offset("nothing")
6971 hovered = env.is_hovered("nothing")
6972 pressed = env.is_pressed("nothing")
6973 focused = env.is_focused("nothing")
6974 hit = env.text_hit("nothing", 1, 1)
6975 caret = env.caret_rect("nothing", 0)
6976 deferred = pcall(function() env.set_scroll("nothing", 0, 0) end)
6977 refused_focus = not pcall(function() env.set_focus("nothing") end)
6978 return column { width = "grow", height = "grow" }
6979 end
6980 "#,
6981 )
6982 .unwrap();
6983 let mut core = Core::new();
6984 let mut ui = core.frame(Size::new(200.0, 100.0), 1.0);
6985 ext.view(&Slot::root(), &mut ui).unwrap();
6986 ui.finish();
6987 let g = ext.lua.globals();
6988 assert_eq!(g.get::<mlua::Value>("geom").unwrap(), mlua::Value::Nil);
6989 assert_eq!(g.get::<mlua::Value>("hit").unwrap(), mlua::Value::Nil);
6990 assert_eq!(g.get::<mlua::Value>("caret").unwrap(), mlua::Value::Nil);
6991 assert!(!g.get::<bool>("hovered").unwrap());
6992 assert!(!g.get::<bool>("pressed").unwrap());
6993 assert!(!g.get::<bool>("focused").unwrap());
6994 let off: Table = g.get("off").unwrap();
6995 assert_eq!(off.get::<f32>("y").unwrap(), 0.0);
6996 assert!(g.get::<bool>("deferred").unwrap());
6999 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
7000 assert_eq!(codes, ["label-without-node"]);
7001 assert!(g.get::<bool>("refused_focus").unwrap());
7002 }
7003
7004 #[test]
7005 fn bad_script_reports_error_not_panic() {
7006 let mut ext = LuaExtension::from_source("bad", "function view() return 5 end").unwrap();
7007 let mut core = Core::new();
7008 let mut ui = core.frame(Size::new(100.0, 100.0), 1.0);
7009 assert!(ext.view(&Slot::root(), &mut ui).is_err());
7010 }
7011}