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 if let Ok(Some(true)) = root.get::<Option<bool>>("ime_off") {
529 ui.ime_off(true);
530 }
531 declare_windows(ui, &root).map_err(|e| format!("windows: {e}"))?;
532 build_node(ui, &root).map_err(|e| format!("view table: {e}"))
533 }
534
535 fn on_event(&mut self, ev: &UiEvent) -> Vec<Value> {
536 let Ok(f) = self.lua.globals().get::<mlua::Function>("on_event") else {
537 return Vec::new();
538 };
539 let payload = value_to_lua(&self.lua, &ev.payload).and_then(|p| {
540 if let mlua::Value::Table(t) = &p
544 && !t.contains_key("node_key")?
545 {
546 t.set("node_key", ev.key.0 as i64)?;
547 t.set("window", ev.window.0)?;
552 if let Some(name) = ev.slot.and_then(|k| self.slot_names.get(&k)) {
558 t.set("slot", name.as_str())?;
559 }
560 if let Some(ns) = self.loaded_as(ev.origin) {
568 t.set("from", ns)?;
569 }
570 }
571 Ok(p)
572 });
573 match payload.and_then(|p| f.call::<mlua::Value>(p)) {
578 Ok(mlua::Value::Nil) => Vec::new(),
579 Ok(v) => match lua_to_value(&v) {
580 Ok(Value::List(replies)) => replies,
581 Ok(reply) => vec![reply],
582 Err(e) => {
583 eprintln!("kui-lua: '{}' on_event reply: {e}", self.name);
584 Vec::new()
585 }
586 },
587 Err(e) => {
588 eprintln!("kui-lua: '{}' on_event error: {e}", self.name);
589 Vec::new()
590 }
591 }
592 }
593}
594
595fn slot_table(lua: &Lua, slot: &Slot<'_>) -> mlua::Result<Table> {
602 let t = lua.create_table()?;
603 t.set("name", slot.name)?;
604 t.set("namespace", slot.namespace)?;
605 t.set("key", slot.key.0 as i64)?;
606 if !matches!(slot.params, Value::Null) {
607 t.set("params", value_to_lua(lua, slot.params)?)?;
608 }
609 Ok(t)
610}
611
612fn key_arg(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Key> {
618 match v {
619 mlua::Value::Integer(i) => Ok(Key(i as u64)),
620 mlua::Value::String(s) => {
621 let label = s.to_str()?;
622 ui.key_of(&label).ok_or_else(|| {
623 mlua::Error::runtime(format!(
624 "no node is keyed {:?}: pass the label a `key` field declared in this or the last \
625 frame, or the integer key an event carried",
626 &*label
627 ))
628 })
629 }
630 other => Err(mlua::Error::runtime(format!(
631 "a node key is an integer or a declared label, not {}",
632 other.type_name()
633 ))),
634 }
635}
636
637fn key_query(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Option<Key>> {
648 match v {
649 mlua::Value::Integer(i) => Ok(Some(Key(i as u64))),
650 mlua::Value::String(s) => Ok(ui.key_of(&s.to_str()?)),
651 other => Err(mlua::Error::runtime(format!(
652 "a node key is an integer or a declared label, not {}",
653 other.type_name()
654 ))),
655 }
656}
657
658fn lua_list_to_value(t: &Table) -> mlua::Result<Value> {
661 let mut items = Vec::with_capacity(t.raw_len());
662 for item in t.sequence_values::<mlua::Value>() {
663 items.push(lua_to_value(&item?)?);
664 }
665 Ok(Value::List(items))
666}
667
668fn alias_menu_role(row: &mut Value) {
673 let Value::Map(fields) = row else { return };
674 for (k, v) in fields.iter_mut() {
675 if k == "role"
676 && let Value::Str(name) = v
677 {
678 match name.as_str() {
679 "select_all" => *name = kui_core::MenuRole::SelectAll.name().to_string(),
680 "look_up" => *name = kui_core::MenuRole::LookUp.name().to_string(),
681 _ => {}
682 }
683 }
684 }
685}
686
687fn menu_items(t: &mlua::Table) -> mlua::Result<Vec<kui_core::MenuItem>> {
690 let mut rows = lua_list_to_value(t)?;
691 if let Value::List(rows) = &mut rows {
692 rows.iter_mut().for_each(alias_menu_role);
693 }
694 kui_core::MenuItem::list_from_value(&rows).map_err(mlua::Error::runtime)
695}
696
697fn env_table<'scope, 'env: 'scope>(
744 lua: &Lua,
745 scope: &'scope mlua::Scope<'scope, 'env>,
746 ui: &'env std::cell::RefCell<&'env mut Ui<'_>>,
747 loaded: &'env std::cell::RefCell<Vec<Loaded>>,
748) -> mlua::Result<Table> {
749 let (facts, theme, metrics) = {
750 let ui = ui.borrow();
751 (ui.env_facts(), ui.theme(), ui.metrics())
752 };
753 let t = lua.create_table()?;
754 t.set("tokens", tokens_table(lua, &ui.borrow())?)?;
760 for row in kui_core::schema::ENV_FIELDS {
773 let value = (row.get)(&facts);
774 if value == Value::Null {
775 continue;
776 }
777 if row.name == "window.native_controls" {
778 let win = t
779 .get::<Option<Table>>("window")?
780 .unwrap_or(lua.create_table()?);
781 let f = |k: &str| value.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
782 win.set("controls_w", f("x") + f("w"))?;
783 win.set("controls_h", f("y") + f("h"))?;
784 t.set("window", win)?;
785 continue;
786 }
787 for path in row.lua {
788 let lua_value = value_to_lua(lua, &value)?;
789 match path.split_once('.') {
790 None => t.set(*path, lua_value)?,
791 Some((head, leaf)) => {
792 let sub = t.get::<Option<Table>>(head)?.unwrap_or(lua.create_table()?);
793 sub.set(leaf, lua_value)?;
794 t.set(head, sub)?;
795 }
796 }
797 }
798 }
799 let th = lua.create_table()?;
808 for role in kui_core::schema::THEME_ROLES {
809 th.set(role.name, (role.get)(&theme).to_hex())?;
810 }
811 th.set("appearance", theme.appearance.name())?;
812 th.set("disabled_opacity", theme.disabled_opacity)?;
813 t.set("theme", th)?;
814 let mt = lua.create_table()?;
819 for role in kui_core::schema::METRIC_ROLES {
820 mt.set(role.name, (role.get)(&metrics))?;
821 }
822 t.set("metrics", mt)?;
823 t.set(
829 "edit_text",
830 scope.create_function(move |_, key: mlua::Value| {
831 let mut ui = ui.borrow_mut();
832 let Some(key) = key_query(&mut ui, key)? else {
833 return Ok(None);
834 };
835 Ok(ui.edit_text(key))
836 })?,
837 )?;
838 t.set(
847 "set_edit_text",
848 scope.create_function(move |_, (key, text): (mlua::Value, String)| {
849 let mut ui = ui.borrow_mut();
850 match key {
851 mlua::Value::String(label) => {
852 ui.set_edit_text_by_label(&label.to_str()?, &text);
853 }
854 other => {
855 let key = key_arg(&mut ui, other)?;
856 ui.set_edit_text(key, &text);
857 }
858 }
859 Ok(())
860 })?,
861 )?;
862 t.set(
865 "is_focused",
866 scope.create_function(move |_, key: mlua::Value| {
867 let mut ui = ui.borrow_mut();
868 let Some(key) = key_query(&mut ui, key)? else {
869 return Ok(false);
870 };
871 Ok(ui.is_focused(key))
872 })?,
873 )?;
874 t.set(
875 "is_hovered",
876 scope.create_function(move |_, key: mlua::Value| {
877 let mut ui = ui.borrow_mut();
878 let Some(key) = key_query(&mut ui, key)? else {
879 return Ok(false);
880 };
881 Ok(ui.is_hovered(key))
882 })?,
883 )?;
884 t.set(
888 "is_pressed",
889 scope.create_function(move |_, key: mlua::Value| {
890 let mut ui = ui.borrow_mut();
891 let Some(key) = key_query(&mut ui, key)? else {
892 return Ok(false);
893 };
894 Ok(ui.is_pressed(key))
895 })?,
896 )?;
897 t.set(
900 "is_drop_target",
901 scope.create_function(move |_, key: mlua::Value| {
902 let mut ui = ui.borrow_mut();
903 let Some(key) = key_query(&mut ui, key)? else {
904 return Ok(false);
905 };
906 Ok(ui.is_drop_target(key))
907 })?,
908 )?;
909 t.set(
912 "drop_target",
913 scope.create_function(move |_, ()| {
914 let ui = ui.borrow();
915 Ok(ui.drop_target().map(|k| k.0 as i64))
916 })?,
917 )?;
918 t.set(
927 "set_focus",
928 scope.create_function(move |_, key: mlua::Value| {
929 let mut ui = ui.borrow_mut();
930 let key = key_arg(&mut ui, key)?;
931 ui.focus(key);
932 Ok(())
933 })?,
934 )?;
935 t.set(
936 "blur",
937 scope.create_function(move |_, ()| {
938 ui.borrow_mut().blur();
939 Ok(())
940 })?,
941 )?;
942 t.set(
949 "focus_next",
950 scope.create_function(move |_, ()| {
951 ui.borrow_mut().focus_next();
952 Ok(())
953 })?,
954 )?;
955 t.set(
956 "focus_prev",
957 scope.create_function(move |_, ()| {
958 ui.borrow_mut().focus_prev();
959 Ok(())
960 })?,
961 )?;
962 t.set(
968 "focus_region",
969 scope.create_function(move |_, key: mlua::Value| {
970 let mut ui = ui.borrow_mut();
971 let key = match key {
972 mlua::Value::Nil => None,
973 v => Some(key_arg(&mut ui, v)?),
974 };
975 ui.focus_region(key);
976 Ok(())
977 })?,
978 )?;
979 t.set(
990 "announce",
991 scope.create_function(move |_, (text, live): (String, Option<String>)| {
992 let live = live.unwrap_or_else(|| "polite".to_string());
993 let Some(i) = schema::LIVE.iter().position(|v| *v == live) else {
994 return Err(mlua::Error::runtime(format!(
995 "bad politeness {live:?} (one of {})",
996 schema::LIVE.join(" | ")
997 )));
998 };
999 ui.borrow_mut()
1000 .announce(&text, kui_core::Live::from_index(i));
1001 Ok(())
1002 })?,
1003 )?;
1004 t.set(
1018 "reveal",
1019 scope.create_function(move |_, key: mlua::Value| {
1020 let mut ui = ui.borrow_mut();
1021 match key {
1022 mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
1023 ui.reveal_label(&s.to_str()?);
1024 }
1025 key => {
1026 let key = key_arg(&mut ui, key)?;
1027 ui.reveal(key);
1028 }
1029 }
1030 Ok(())
1031 })?,
1032 )?;
1033 t.set(
1037 "scroll_offset",
1038 scope.create_function(move |lua, key: mlua::Value| {
1039 let mut ui = ui.borrow_mut();
1040 let off = match key_query(&mut ui, key)? {
1041 Some(key) => ui.scroll_offset(key),
1042 None => kui_core::Vec2::ZERO,
1043 };
1044 let r = lua.create_table()?;
1045 r.set("x", off.x)?;
1046 r.set("y", off.y)?;
1047 Ok(r)
1048 })?,
1049 )?;
1050 t.set(
1065 "text_hit",
1066 scope.create_function(move |lua, (key, x, y): (mlua::Value, f32, f32)| {
1067 let mut ui = ui.borrow_mut();
1068 let Some(key) = key_query(&mut ui, key)? else {
1069 return Ok(mlua::Value::Nil);
1070 };
1071 let Some(h) = ui.text_hit(key, kui_core::Vec2::new(x, y)) else {
1072 return Ok(mlua::Value::Nil);
1073 };
1074 value_to_lua(lua, &h.to_value())
1075 })?,
1076 )?;
1077 t.set(
1085 "open_menu",
1086 scope.create_function(
1087 move |_, (key, x, y, items): (mlua::Value, f32, f32, mlua::Table)| {
1088 let mut ui = ui.borrow_mut();
1089 let Some(target) = key_query(&mut ui, key)? else {
1090 return Ok(false);
1091 };
1092 let items = menu_items(&items)?;
1093 ui.open_menu(kui_core::Menu::new(
1094 target,
1095 kui_core::Vec2::new(x, y),
1096 items,
1097 ));
1098 Ok(true)
1099 },
1100 )?,
1101 )?;
1102 t.set(
1104 "close_menu",
1105 scope.create_function(move |_, ()| {
1106 let mut ui = ui.borrow_mut();
1107 Ok(ui.close_menu())
1108 })?,
1109 )?;
1110 t.set(
1115 "selection_text",
1116 scope.create_function(move |_, ()| {
1117 let ui = ui.borrow();
1118 Ok(ui.selection_text())
1119 })?,
1120 )?;
1121 t.set(
1127 "request_copy",
1128 scope.create_function(move |_, ()| {
1129 let mut ui = ui.borrow_mut();
1130 Ok(match ui.request_copy() {
1131 kui_core::CopyRequest::Ready(text) => (Some(text), false),
1132 kui_core::CopyRequest::Asked => (None, true),
1133 kui_core::CopyRequest::Nothing => (None, false),
1134 })
1135 })?,
1136 )?;
1137 t.set(
1140 "answer_selection_range",
1141 scope.create_function(move |_, text: String| {
1142 let mut ui = ui.borrow_mut();
1143 Ok(ui.answer_selection_range(&text))
1144 })?,
1145 )?;
1146 t.set(
1152 "set_clipboard",
1153 scope.create_function(move |_, (text, html): (String, Option<String>)| {
1154 ui.borrow_mut().set_clipboard(text, html);
1155 Ok(())
1156 })?,
1157 )?;
1158 t.set(
1162 "set_clipboard_secret",
1163 scope.create_function(move |_, text: String| {
1164 ui.borrow_mut().set_clipboard_secret(text);
1165 Ok(())
1166 })?,
1167 )?;
1168 t.set(
1169 "request_paste",
1170 scope.create_function(move |_, ()| {
1171 ui.borrow_mut().request_paste();
1172 Ok(())
1173 })?,
1174 )?;
1175 t.set(
1176 "awaiting_paste",
1177 scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_paste()))?,
1178 )?;
1179 t.set(
1185 "request_files",
1186 scope.create_function(move |_, opts: Option<mlua::Value>| {
1187 let v = match &opts {
1188 Some(o) => lua_to_value(o)?,
1189 None => Value::Null,
1190 };
1191 let dialog = kui_core::FileDialog::from_value(&v)
1192 .map_err(|e| mlua::Error::runtime(format!("request_files: {e}")))?;
1193 Ok(ui.borrow_mut().request_files(dialog))
1194 })?,
1195 )?;
1196 t.set(
1197 "awaiting_files",
1198 scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_files()))?,
1199 )?;
1200 t.set(
1207 "selection_ends",
1208 scope.create_function(move |lua, ()| {
1209 let ui = ui.borrow();
1210 let Some((a, f)) = ui.selection_ends() else {
1211 return Ok(mlua::Value::Nil);
1212 };
1213 let end = |e: kui_core::RangeEnd| -> mlua::Result<mlua::Table> {
1214 let t = lua.create_table()?;
1215 if let Some(r) = e.row {
1216 t.set("index", r)?;
1217 }
1218 t.set("byte", e.byte)?;
1219 Ok(t)
1220 };
1221 let out = lua.create_table()?;
1222 out.set("anchor", end(a)?)?;
1223 out.set("focus", end(f)?)?;
1224 Ok(mlua::Value::Table(out))
1225 })?,
1226 )?;
1227 t.set(
1235 "cell_selection",
1236 scope.create_function(move |lua, ()| {
1237 let ui = ui.borrow();
1238 match ui.cell_selection() {
1239 Some(sel) => value_to_lua(lua, &sel.to_value(kui_core::Handles::INT)),
1240 None => Ok(mlua::Value::Nil),
1241 }
1242 })?,
1243 )?;
1244 t.set(
1248 "selection_html",
1249 scope.create_function(move |_, ()| {
1250 let ui = ui.borrow();
1251 Ok(ui.selection_html())
1252 })?,
1253 )?;
1254 t.set(
1259 "select_all_in",
1260 scope.create_function(move |_, key: mlua::Value| {
1261 let mut ui = ui.borrow_mut();
1262 let Some(key) = key_query(&mut ui, key)? else {
1263 return Ok(false);
1264 };
1265 Ok(ui.select_all_in(key))
1266 })?,
1267 )?;
1268 t.set(
1271 "clear_selection",
1272 scope.create_function(move |_, ()| {
1273 let mut ui = ui.borrow_mut();
1274 Ok(ui.clear_selection())
1275 })?,
1276 )?;
1277 t.set(
1281 "caret_rect",
1282 scope.create_function(move |lua, (key, byte): (mlua::Value, usize)| {
1283 let mut ui = ui.borrow_mut();
1284 let Some(key) = key_query(&mut ui, key)? else {
1285 return Ok(mlua::Value::Nil);
1286 };
1287 let Some(r) = ui.caret_rect(key, byte) else {
1288 return Ok(mlua::Value::Nil);
1289 };
1290 value_to_lua(lua, &r.to_value())
1291 })?,
1292 )?;
1293 t.set(
1294 "scroll_geometry",
1295 scope.create_function(move |lua, key: mlua::Value| {
1296 let mut ui = ui.borrow_mut();
1297 let Some(key) = key_query(&mut ui, key)? else {
1298 return Ok(mlua::Value::Nil);
1299 };
1300 let Some(g) = ui.scroll_geometry(key) else {
1301 return Ok(mlua::Value::Nil);
1302 };
1303 value_to_lua(lua, &g.to_value())
1305 })?,
1306 )?;
1307 t.set(
1310 "layout_of",
1311 scope.create_function(move |lua, key: mlua::Value| {
1312 let mut ui = ui.borrow_mut();
1313 let Some(key) = key_query(&mut ui, key)? else {
1314 return Ok(mlua::Value::Nil);
1315 };
1316 let Some(r) = ui.layout_of(key) else {
1317 return Ok(mlua::Value::Nil);
1318 };
1319 value_to_lua(lua, &r.to_value())
1320 })?,
1321 )?;
1322 t.set(
1325 "set_scroll",
1326 scope.create_function(move |_, (key, x, y): (mlua::Value, f32, f32)| {
1327 let mut ui = ui.borrow_mut();
1328 let at = kui_core::Vec2::new(x, y);
1329 match key {
1332 mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
1333 ui.set_scroll_label(&s.to_str()?, at);
1334 }
1335 key => {
1336 let key = key_arg(&mut ui, key)?;
1337 ui.set_scroll(key, at);
1338 }
1339 }
1340 Ok(())
1341 })?,
1342 )?;
1343 t.set(
1348 "shift_scroll",
1349 scope.create_function(move |_, (key, drawn, target): (mlua::Value, f32, f32)| {
1350 let mut ui = ui.borrow_mut();
1351 if let Some(key) = key_query(&mut ui, key)? {
1352 ui.shift_scroll(
1353 key,
1354 kui_core::Vec2::new(0.0, drawn),
1355 kui_core::Vec2::new(0.0, target),
1356 );
1357 }
1358 Ok(())
1359 })?,
1360 )?;
1361 t.set(
1369 "set_window_size",
1370 scope.create_function(move |_, (window, w, h): (i64, f32, f32)| {
1371 ui.borrow_mut()
1372 .set_window_size(kui_core::WindowId(window as u32), kui_core::Size::new(w, h));
1373 Ok(())
1374 })?,
1375 )?;
1376 let env = t.clone();
1381 t.set(
1382 "set_tokens",
1383 scope.create_function(move |lua, decl: Table| {
1384 let tokens = parse_tokens(&decl)?;
1385 ui.borrow_mut().set_tokens(tokens);
1386 env.set("tokens", tokens_table(lua, &ui.borrow())?)
1387 })?,
1388 )?;
1389 t.set(
1390 "focus_window",
1391 scope.create_function(move |_, window: i64| {
1392 ui.borrow_mut()
1393 .focus_window(kui_core::WindowId(window as u32));
1394 Ok(())
1395 })?,
1396 )?;
1397 t.set(
1398 "measure_text",
1399 scope.create_function(
1400 move |lua, (s, opts, max_w): (mlua::Value, Option<Table>, Option<f32>)| {
1401 let mut guard = ui.borrow_mut();
1402 let m = measure_from_lua(&mut guard, &s, opts.as_ref(), max_w)?;
1403 value_to_lua(lua, &m.to_value())
1404 },
1405 )?,
1406 )?;
1407 t.set(
1424 "add_extension",
1425 scope.create_function(move |_, (namespace, path): (String, String)| {
1426 let path = std::path::PathBuf::from(path);
1427 if let Some(prev) = loaded.borrow().iter().find(|l| l.namespace == namespace) {
1428 return Ok(if prev.path == path {
1429 (Some(true), None)
1431 } else {
1432 (
1433 None,
1434 Some(format!(
1435 "`{namespace}` is already {} in this script; a second plugin needs a \
1436 second namespace",
1437 prev.path.display()
1438 )),
1439 )
1440 });
1441 }
1442 let ext = match unsafe { kui_ffi::CExtension::open(&path) } {
1446 Ok(ext) => ext,
1447 Err(e) => return Ok((None, Some(e))),
1448 };
1449 let origin = match ui.borrow_mut().add_extension(&namespace, Box::new(ext)) {
1450 Ok(origin) => origin,
1451 Err(e) => return Ok((None, Some(e))),
1452 };
1453 loaded.borrow_mut().push(Loaded {
1454 namespace,
1455 path,
1456 origin,
1457 });
1458 Ok((Some(true), None))
1459 })?,
1460 )?;
1461 t.set(
1464 "extension_namespaces",
1465 scope.create_function(move |lua, ()| {
1466 lua.create_sequence_from(loaded.borrow().iter().map(|l| l.namespace.clone()))
1467 })?,
1468 )?;
1469 Ok(t)
1470}
1471
1472fn tokens_table(lua: &Lua, ui: &Ui<'_>) -> mlua::Result<Table> {
1484 let look = ui.tokens();
1485 let tokens = lua.create_table()?;
1486 let colors = lua.create_table()?;
1487 for (name, c) in look.colors() {
1488 colors.set(name, c.to_hex())?;
1489 }
1490 let lengths = lua.create_table()?;
1491 for (name, v) in look.lengths() {
1492 lengths.set(name, v)?;
1493 }
1494 tokens.set("colors", colors)?;
1495 tokens.set("lengths", lengths)?;
1496 Ok(tokens)
1497}
1498
1499fn measure_from_lua(
1500 ui: &mut Ui<'_>,
1501 s: &mlua::Value,
1502 opts: Option<&Table>,
1503 max_w: Option<f32>,
1504) -> mlua::Result<kui_core::TextMetrics> {
1505 let style = match opts {
1506 Some(t) => with_refs(ui, |refs| parse_props(t, false, refs))?.style,
1507 None => kui_core::TextStyle::default(),
1508 };
1509 let measure_spans = |ui: &mut Ui<'_>, spans: &Table, style: &kui_core::TextStyle| {
1510 let parts = with_refs(ui, |refs| collect_spans(spans, refs))?;
1511 let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1512 Ok(ui.measure_rich_text(&spans, style, max_w))
1513 };
1514 match s {
1515 mlua::Value::String(s) => Ok(ui.measure_text(&s.to_str()?, &style, max_w)),
1516 mlua::Value::Table(t) => {
1517 if t.get::<Option<String>>("type")?.as_deref() == Some("text") {
1518 let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
1519 match t.get::<Option<Table>>("spans")? {
1520 Some(spans) => measure_spans(ui, &spans, &style),
1521 None => {
1522 let value: String = t.get("value")?;
1523 Ok(ui.measure_text(&value, &style, max_w))
1524 }
1525 }
1526 } else {
1527 measure_spans(ui, t, &style)
1528 }
1529 }
1530 other => Err(bad(format!(
1531 "measure_text: expected a string, a span list or a text node, got {}",
1532 other.type_name()
1533 ))),
1534 }
1535}
1536
1537fn bad(msg: impl std::fmt::Display) -> mlua::Error {
1541 mlua::Error::runtime(msg.to_string())
1542}
1543
1544fn build_children(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1545 for child in t.sequence_values::<Table>() {
1546 build_node(ui, &child?)?;
1547 }
1548 Ok(())
1549}
1550
1551fn with_children(
1554 ui: &mut Ui<'_>,
1555 t: &Table,
1556 widget: impl FnOnce(&mut Ui<'_>, &mut dyn FnMut(&mut Ui<'_>)),
1557) -> mlua::Result<()> {
1558 let mut result = Ok(());
1559 widget(ui, &mut |ui| result = build_children(ui, t));
1560 result
1561}
1562
1563fn element_of(ty: &str) -> &str {
1567 match ty {
1568 "row" | "column" => "box",
1569 "grid" => "table",
1570 "input" => "edit",
1571 "dropdown" => "select",
1572 "radio_group" => "radioGroup",
1573 "window_buttons" => "windowButtons",
1574 "menu_bar" => "menuBar",
1575 "latency_graph" | "latency_hud" => "latencyGraph",
1576 other => other,
1577 }
1578}
1579
1580fn menu_bar_of(t: &Table) -> mlua::Result<kui_core::MenuBar> {
1581 let Some(list) = t.get::<Option<Table>>("menu")? else {
1582 return Ok(kui_core::MenuBar::default());
1583 };
1584 let mut menus = lua_list_to_value(&list)?;
1585 if let Value::List(menus) = &mut menus {
1586 for menu in menus.iter_mut() {
1587 let Value::Map(fields) = menu else { continue };
1588 for (k, items) in fields.iter_mut() {
1589 if k != "items" {
1590 continue;
1591 }
1592 if matches!(items, Value::Map(m) if m.is_empty()) {
1594 *items = Value::List(Vec::new());
1595 }
1596 if let Value::List(rows) = items {
1597 rows.iter_mut().for_each(alias_menu_role);
1598 }
1599 }
1600 }
1601 }
1602 kui_core::MenuBar::from_value(&menus).map_err(mlua::Error::runtime)
1603}
1604
1605fn declare_windows(ui: &mut Ui<'_>, root: &Table) -> mlua::Result<()> {
1606 let Some(list) = root.get::<Option<Table>>("windows")? else {
1607 return Ok(());
1608 };
1609 for entry in list.sequence_values::<mlua::Value>() {
1613 let v = lua_to_value(&entry?)?;
1614 let (name, cfg) = WindowConfig::from_value(&v).map_err(mlua::Error::runtime)?;
1615 ui.window(&name, cfg);
1616 }
1617 Ok(())
1618}
1619
1620fn check_props(ui: &mut Ui<'_>, t: &Table, element: &str) -> mlua::Result<()> {
1624 if !ui.core().diagnostics() {
1625 return Ok(());
1626 }
1627 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1628 let (k, _) = pair?;
1629 let mlua::Value::String(k) = k else { continue };
1630 let name = k.to_str()?;
1631 if name.as_ref() == "type" || schema::known_prop(element, &name, schema::Spelling::Snake) {
1633 continue;
1634 }
1635 let w = kui_core::diag::unknown_prop(element, &name, schema::Spelling::Snake);
1636 ui.core().warn(w);
1637 }
1638 Ok(())
1639}
1640
1641fn build_fill(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1651 let name: String = t
1652 .get::<Option<String>>("name")?
1653 .filter(|n| !n.is_empty())
1654 .ok_or_else(|| bad("fill needs a name (\"namespace/slot\")"))?;
1655 if !name.contains(kui_core::NAMESPACE_SEPARATOR) {
1656 return Err(bad(format!(
1657 "bad slot name {name:?} (a full \"namespace/slot\")"
1658 )));
1659 }
1660 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1661 let (k, _) = pair?;
1662 let mlua::Value::String(k) = k else { continue };
1663 let k = k.to_str()?;
1664 if !matches!(k.as_ref(), "type" | "name" | "params") {
1665 return Err(bad(format!(
1666 "fill takes name and params, not {:?} — it is a position, not a box",
1667 k.as_ref()
1668 )));
1669 }
1670 }
1671 let params = match t.get::<mlua::Value>("params")? {
1672 mlua::Value::Nil => Value::Null,
1673 v => lua_to_value(&v)?,
1674 };
1675 ui.slot_with(&name, ¶ms);
1676 Ok(())
1677}
1678
1679fn build_devtools_tab(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1688 let name: String = t.get::<Option<String>>("name")?.unwrap_or_default();
1689 let refuse = |ui: &mut Ui<'_>, why: &str| {
1690 ui.core().warn(kui_core::diag::bad_devtools_tab(&name, why));
1691 Ok(())
1692 };
1693 if name.is_empty() {
1694 return refuse(ui, "it needs a name (its identity)");
1695 }
1696 let label: String = t
1697 .get::<Option<String>>("label")?
1698 .unwrap_or_else(|| name.clone());
1699 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1700 let (k, _) = pair?;
1701 let mlua::Value::String(k) = k else { continue };
1702 let k = k.to_str()?;
1703 if !matches!(k.as_ref(), "type" | "name" | "label" | "slot" | "view") {
1704 return refuse(
1705 ui,
1706 &format!(
1707 "it takes name, label and slot or view, not {:?}",
1708 k.as_ref()
1709 ),
1710 );
1711 }
1712 }
1713 let slot = t.get::<Option<String>>("slot")?;
1714 let view = t.get::<mlua::Value>("view")?;
1715 match (slot, view) {
1716 (Some(slot), mlua::Value::Nil) => {
1717 if !slot.contains(kui_core::NAMESPACE_SEPARATOR) {
1718 return refuse(
1719 ui,
1720 &format!("bad slot {slot:?} (a full \"namespace/slot\")"),
1721 );
1722 }
1723 ui.devtools_tab(&name, &label, &slot);
1724 Ok(())
1725 }
1726 (None, mlua::Value::Function(view)) => {
1727 let mut result = Ok(());
1728 ui.devtools_tab_with(&name, &label, |ui| {
1729 result = view
1730 .call::<Table>(())
1731 .and_then(|tree| build_node(ui, &tree));
1732 });
1733 result
1734 }
1735 (Some(_), mlua::Value::Function(_)) => refuse(ui, "it takes a slot or a view, not both"),
1736 (None, mlua::Value::Nil) => refuse(ui, "it needs a slot or a view function"),
1737 (_, other) => refuse(
1738 ui,
1739 &format!("view is a {}, not a function", other.type_name()),
1740 ),
1741 }
1742}
1743
1744pub const MAX_VIEW_DEPTH: usize = 128;
1748
1749thread_local! {
1750 static VIEW_PATH: std::cell::RefCell<Vec<*const std::ffi::c_void>> =
1754 const { std::cell::RefCell::new(Vec::new()) };
1755}
1756
1757fn build_node(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1758 let at = t.to_pointer();
1759 VIEW_PATH.with_borrow_mut(|path| {
1760 if path.contains(&at) {
1761 return Err(bad("a view node that holds itself"));
1762 }
1763 if path.len() >= MAX_VIEW_DEPTH {
1764 return Err(bad(format!("a view nested past {MAX_VIEW_DEPTH} nodes")));
1765 }
1766 path.push(at);
1767 Ok(())
1768 })?;
1769 let built = build_one(ui, t);
1770 VIEW_PATH.with_borrow_mut(|path| path.pop());
1771 built
1772}
1773
1774fn build_one(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1781 let ty: String = t.get("type")?;
1782 match ty.as_str() {
1783 "fill" => build_fill(ui, t),
1784 "devtools_tab" => build_devtools_tab(ui, t),
1785 "row" | "column" | "grid" => {
1786 open_box(ui, t, &ty)?;
1787 build_children(ui, t)?;
1788 ui.close();
1789 Ok(())
1790 }
1791 "fragment" => {
1792 open_fragment(ui, t)?;
1793 build_children(ui, t)?;
1794 ui.close();
1795 Ok(())
1796 }
1797 "titlebar" | "tooltip" | "radio_group" => build_holder(ui, t, &ty),
1798 _ => build_widget(ui, t, &ty),
1799 }
1800}
1801
1802#[inline(never)]
1803fn open_box(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1804 check_props(ui, t, element_of(ty))?;
1805 let mut p = with_refs(ui, |refs| parse_props(t, ty == "row", refs))?;
1806 p.spec.layout.table = ty == "grid";
1808 ui.core().open_from(p, Content::Box);
1809 Ok(())
1810}
1811
1812#[inline(never)]
1813fn open_fragment(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1814 check_props(ui, t, element_of("fragment"))?;
1815 let id: i64 = t.get("id")?;
1820 let image: Option<i64> = t.get("image")?;
1821 let params: Vec<f32> = match t.get::<Option<Table>>("params")? {
1822 Some(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
1823 None => Vec::new(),
1824 };
1825 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1826 let frag = kui_core::FragmentRef {
1827 id: kui_core::FragmentId::from_ffi(id as u64),
1828 image: image
1829 .filter(|i| *i != 0)
1830 .map(|i| kui_core::ImageId::from_ffi(i as u64)),
1831 };
1832 ui.core().open_from(p, Content::Fragment(frag, ¶ms));
1833 Ok(())
1834}
1835
1836#[inline(never)]
1839fn build_holder(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1840 check_props(ui, t, element_of(ty))?;
1841 match ty {
1842 "titlebar" => {
1843 if t.raw_len() > 0 {
1844 with_children(ui, t, |ui, body| widgets::titlebar_with(ui, body))
1845 } else {
1846 let title: String = t.get::<Option<String>>("title")?.unwrap_or_default();
1847 widgets::titlebar(ui, &title);
1848 Ok(())
1849 }
1850 }
1851 "tooltip" => {
1852 if t.raw_len() > 0 {
1853 with_children(ui, t, |ui, body| widgets::tooltip_with(ui, body))
1854 } else {
1855 let value: String = t.get("value")?;
1856 widgets::tooltip(ui, &value);
1857 Ok(())
1858 }
1859 }
1860 "radio_group" => {
1861 let label: String = t.get("label")?;
1864 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1865 let mut result = Ok(());
1866 widgets::radio_group_with(ui, &label, p.spec, |ui| result = build_children(ui, t));
1867 result
1868 }
1869 other => unreachable!("{other} holds no children"),
1870 }
1871}
1872
1873#[inline(never)]
1874fn build_widget(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1875 check_props(ui, t, element_of(ty))?;
1876 match ty {
1877 "text" => {
1878 let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
1879 if let Some(spans) = t.get::<Option<Table>>("spans")? {
1880 let parts = with_refs(ui, |refs| collect_spans(&spans, refs))?;
1881 let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1882 ui.rich_text(&spans, style);
1883 } else {
1884 let value: String = t.get("value")?;
1885 ui.text(&value, style);
1886 }
1887 Ok(())
1888 }
1889 "image" => {
1890 let id: i64 = t.get("id")?;
1894 let spec = with_refs(ui, |refs| parse_props(t, false, refs))?
1896 .for_leaf()
1897 .spec;
1898 let named = |row: &str, names: &[&str]| -> mlua::Result<usize> {
1899 match t.get::<Option<String>>(row)? {
1900 None => Ok(0),
1901 Some(s) => names
1902 .iter()
1903 .position(|n| *n == s)
1904 .ok_or_else(|| bad(format!("{row} must be one of {}", names.join(", ")))),
1905 }
1906 };
1907 let sampling_names: Vec<&str> =
1908 kui_core::Sampling::ALL.iter().map(|s| s.name()).collect();
1909 let fit_names: Vec<&str> = kui_core::ImageFit::ALL.iter().map(|f| f.name()).collect();
1910 let opts = kui_core::ImageOpts {
1911 sampling: kui_core::Sampling::ALL[named("sampling", &sampling_names)?],
1912 fit: kui_core::ImageFit::ALL[named("fit", &fit_names)?],
1913 };
1914 ui.image_with(kui_core::ImageId::from_ffi(id as u64), opts, spec);
1915 Ok(())
1916 }
1917 "polygon" => {
1918 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1921 let Some(list) = t.get::<Option<Table>>("points")? else {
1922 return Err(bad("polygon needs points"));
1923 };
1924 let points: Vec<kui_core::Vec2> = list
1925 .sequence_values::<mlua::Value>()
1926 .map(|v| {
1927 let mlua::Value::Table(pt) = v? else {
1928 return Err(bad("a polygon point is a {x, y} table"));
1929 };
1930 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
1931 })
1932 .collect::<mlua::Result<_>>()?;
1933 ui.core().open_from(p, Content::Polygon(&points));
1934 Ok(())
1935 }
1936 "path" => {
1937 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1942 let rule = match t.get::<Option<String>>("fill_rule")? {
1943 Some(name) => kui_core::FillRule::parse(&name)
1944 .ok_or_else(|| bad("fill_rule is \"nonzero\" or \"evenodd\""))?,
1945 None => kui_core::FillRule::NonZero,
1946 };
1947 let width = match t.get::<mlua::Value>("width")? {
1948 mlua::Value::Nil => None,
1949 v => with_refs(ui, |refs| length_of(&v, refs))?,
1950 };
1951 let dash = dash_of(t)?;
1952 let stroke = width.filter(|w| *w > 0.0).map(|w| {
1953 kui_core::Stroke::new(w, p.style.color.unwrap_or(ui.theme().fg)).dashed(dash)
1954 });
1955 let turns = t.get::<Option<f32>>("rotate")?;
1959 let pivot = match t.get::<Option<Table>>("pivot")? {
1960 Some(p) => Some(kui_core::Vec2::new(p.get::<f32>(1)?, p.get::<f32>(2)?)),
1961 None => None,
1962 };
1963 let turn = (turns.is_some() || pivot.is_some()).then_some(kui_core::Turn {
1964 turns: turns.unwrap_or(0.0),
1965 pivot,
1966 });
1967 if let Some(d) = t.get::<Option<String>>("d")? {
1968 ui.core()
1969 .open_from(p, Content::PathD(&d, rule, stroke, turn));
1970 } else if let Some(list) = t.get::<Option<Table>>("ops")? {
1971 let floats: Vec<f32> =
1972 list.sequence_values::<f32>().collect::<mlua::Result<_>>()?;
1973 ui.core()
1974 .open_from(p, Content::PathFlat(&floats, rule, stroke, turn));
1975 } else {
1976 return Err(bad("path needs d or ops"));
1977 }
1978 Ok(())
1979 }
1980 "line" => {
1981 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1987 let point = |v: mlua::Value| -> mlua::Result<kui_core::Vec2> {
1988 let mlua::Value::Table(pt) = v else {
1989 return Err(bad("a line point is a {x, y} table"));
1990 };
1991 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
1992 };
1993 let points: Vec<kui_core::Vec2> = match t.get::<Option<Table>>("points")? {
1994 Some(list) => list
1995 .sequence_values::<mlua::Value>()
1996 .map(|v| point(v?))
1997 .collect::<mlua::Result<_>>()?,
1998 None => {
1999 let (Some(from), Some(to)) = (
2000 t.get::<Option<mlua::Value>>("from")?,
2001 t.get::<Option<mlua::Value>>("to")?,
2002 ) else {
2003 return Err(bad("line needs from and to, or points"));
2004 };
2005 vec![point(from)?, point(to)?]
2006 }
2007 };
2008 let width = match t.get::<mlua::Value>("width")? {
2011 mlua::Value::Nil => None,
2012 v => with_refs(ui, |refs| length_of(&v, refs))?,
2013 }
2014 .unwrap_or(1.0);
2015 let stroke_color = p.style.color.unwrap_or(ui.theme().fg);
2017 let mut stroke = kui_core::Stroke::new(width, stroke_color);
2018 stroke.curve = t.get::<Option<bool>>("curve")?.unwrap_or(false);
2019 stroke.dash = dash_of(t)?;
2020 ui.core().open_from(p, Content::Line(&points, stroke));
2021 Ok(())
2022 }
2023 "cells" => {
2024 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
2030 let rows: usize = t.get::<Option<usize>>("rows")?.unwrap_or(0);
2031 let cols: usize = t.get::<Option<usize>>("cols")?.unwrap_or(0);
2032 if rows == 0 || cols == 0 {
2033 return Err(bad("cells needs rows and cols"));
2034 }
2035 let default_fg = p.style.color.unwrap_or(ui.theme().fg).to_hex();
2036 let mut cells = vec![kui_core::Cell::new(' ', default_fg, 0); rows * cols];
2037 if let Some(lines) = t.get::<Option<Table>>("lines")? {
2038 for (r, line) in lines.sequence_values::<String>().enumerate() {
2039 if r >= rows {
2040 break;
2041 }
2042 for (c, ch) in line?.chars().take(cols).enumerate() {
2043 cells[r * cols + c].ch = ch;
2044 }
2045 }
2046 }
2047 if let Some(runs) = t.get::<Option<Table>>("runs")? {
2048 for run in runs.sequence_values::<Table>() {
2049 let run = run?;
2050 let row: usize = run.get(1)?;
2051 let col: usize = run.get(2)?;
2052 let len: usize = run.get(3)?;
2053 let fg: u32 = run.get::<Option<u32>>(4)?.unwrap_or(0);
2054 let bg: u32 = run.get::<Option<u32>>(5)?.unwrap_or(0);
2055 let flags: u8 = run.get::<Option<u8>>(6)?.unwrap_or(0);
2056 let ul: u32 = run.get::<Option<u32>>(7)?.unwrap_or(0);
2057 if row >= rows {
2058 continue;
2059 }
2060 for c in col..(col + len).min(cols) {
2061 let cell = &mut cells[row * cols + c];
2062 if fg != 0 {
2063 cell.fg = fg;
2064 }
2065 if bg != 0 {
2066 cell.bg = bg;
2067 }
2068 cell.flags |= flags;
2069 if ul != 0 {
2070 cell.ul = ul;
2071 }
2072 }
2073 }
2074 }
2075 let cursor = match t.get::<Option<Table>>("cursor_at")? {
2076 Some(cur) => {
2077 let shape = match t.get::<Option<String>>("cursor_shape")? {
2080 None => kui_core::CellCursor::Block,
2081 Some(s) => kui_core::CellCursor::from_name(&s).ok_or_else(|| {
2082 bad(format!(
2083 "cursor_shape must be {}, not {s:?}",
2084 kui_core::CellCursor::NAMES.join(" | ")
2085 ))
2086 })?,
2087 };
2088 let color = match t.get::<mlua::Value>("cursor_color")? {
2089 mlua::Value::Nil => None,
2090 v => with_refs(ui, |refs| parse_color(&v, refs))?,
2091 }
2092 .unwrap_or(Color::rgb8(0xff, 0xff, 0xff));
2093 Some((cur.get::<usize>(1)?, cur.get::<usize>(2)?, shape, color))
2094 }
2095 None => None,
2096 };
2097 let origin_line = t.get::<Option<u64>>("origin_line")?.unwrap_or(0);
2099 let grid = kui_core::CellGrid {
2100 rows,
2101 cols,
2102 cells: &cells,
2103 style: p.style,
2104 cursor,
2105 origin_line,
2106 };
2107 ui.core().open_from(p, Content::Cells(&grid));
2108 Ok(())
2109 }
2110 "audio" => {
2111 let src: i64 = t.get("src")?;
2114 let mut spec = kui_core::AudioSpec::new(kui_core::SoundId::from_ffi(src as u64));
2115 if let Some(v) = t.get::<Option<f32>>("volume")? {
2116 spec = spec.volume(v);
2117 }
2118 if t.get::<Option<bool>>("loop")?.unwrap_or(false) {
2119 spec = spec.looped();
2120 }
2121 spec = spec.paused(t.get::<Option<bool>>("paused")?.unwrap_or(false));
2122 if t.get::<Option<bool>>("finish")?.unwrap_or(false) {
2123 spec = spec.finish();
2124 }
2125 if let Some(tag) = t.get::<Option<mlua::Value>>("tag")? {
2126 spec.tag = Some(lua_to_value(&tag)?);
2127 }
2128 match t.get::<Option<String>>("key")? {
2129 Some(k) => ui.audio_keyed(&k, spec),
2130 None => ui.audio(spec),
2131 };
2132 Ok(())
2133 }
2134 "input" => {
2135 let label: String = t.get("label")?;
2136 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
2137 widgets::text_input(ui, &label, &initial);
2138 Ok(())
2139 }
2140 "dropdown" => {
2141 let Some(label) = t.get::<Option<String>>("label")?.filter(|l| !l.is_empty()) else {
2145 return Err(bad("dropdown needs a label (its key and accessible name)"));
2146 };
2147 let Some(options) = t.get::<Option<Table>>("options")? else {
2148 return Err(bad(
2149 "dropdown needs options, a list of strings or menu rows",
2150 ));
2151 };
2152 let mut rows = lua_list_to_value(&options)?;
2153 if let Value::List(rows) = &mut rows {
2154 rows.iter_mut().for_each(alias_menu_role);
2155 if ui.core().diagnostics() {
2159 for row in rows.iter() {
2160 let Value::Map(fields) = row else { continue };
2161 for (k, _) in fields.iter() {
2162 if !kui_core::MenuItem::KEYS.contains(&k.as_str()) {
2163 ui.core().warn(kui_core::diag::unknown_menu_item_key(k));
2164 }
2165 }
2166 }
2167 }
2168 }
2169 let items =
2170 kui_core::MenuItem::options_from_value(&rows).map_err(mlua::Error::runtime)?;
2171 let current = match t.get::<Option<i64>>("current")? {
2172 None => None,
2173 Some(i) if i >= 1 => Some(i as usize - 1),
2174 Some(i) => {
2175 return Err(bad(format!("dropdown current is an index from 1, not {i}")));
2176 }
2177 };
2178 widgets::select_items(ui, &label, &items, current);
2179 Ok(())
2180 }
2181 "edit" => {
2182 let p = with_refs(ui, |refs| parse_props(t, false, refs))?.for_leaf();
2184 let label = match p.key.clone().or(t.get::<Option<String>>("label")?) {
2185 Some(l) => l,
2186 None => return Err(bad("edit needs a key (state is retained by key)")),
2187 };
2188 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
2189 let opts = EditOptions {
2190 style: p.style,
2191 multiline: t.get::<Option<bool>>("multiline")?.unwrap_or(false),
2192 autofocus: t.get::<Option<bool>>("autofocus")?.unwrap_or(false),
2193 wrap: p.wrap,
2194 ..Default::default()
2195 };
2196 ui.text_edit(&label, &initial, &opts, p.spec);
2197 Ok(())
2198 }
2199 "window_buttons" => {
2200 widgets::window_buttons(ui);
2201 Ok(())
2202 }
2203 "menu_bar" => {
2204 widgets::menu_bar(ui, menu_bar_of(t)?);
2205 Ok(())
2206 }
2207 "latency_graph" => {
2208 widgets::latency_graph(ui);
2209 Ok(())
2210 }
2211 "latency_hud" => {
2212 let (mut x, mut y) = (Align::End, Align::End);
2213 if let Some(at) = t.get::<Option<Table>>("at")? {
2214 x = parse_align(&at.get::<String>(1)?)?;
2215 y = parse_align(&at.get::<String>(2)?)?;
2216 }
2217 widgets::latency_hud_at(ui, x, y);
2218 Ok(())
2219 }
2220 "button" => {
2221 let label: String = t.get("label")?;
2231 let text: String = t
2232 .get::<Option<String>>("text")?
2233 .unwrap_or_else(|| label.clone());
2234 let key: String = t
2235 .get::<Option<String>>("key")?
2236 .unwrap_or_else(|| label.clone());
2237 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
2238 Some(v) => lua_to_value(&v)?,
2239 None => Value::Null,
2240 };
2241 let mut out = PropsOut::new();
2242 out.spec = widgets::button_spec(&ui.theme(), &ui.metrics())
2243 .on_click(payload)
2244 .label(label.as_str());
2245 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2246 out.apply_tooltip(&hint);
2247 }
2248 with_refs(ui, |refs| {
2249 for name in ["description", "disabled", "accent"] {
2250 let v = t.get::<mlua::Value>(name)?;
2251 if v.is_nil() {
2252 continue;
2253 }
2254 let def = schema::by_snake_name(name).expect("a button row");
2255 if let Some(parsed) =
2256 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2257 {
2258 schema::apply(def, parsed, &mut out).map_err(bad)?;
2259 }
2260 }
2261 Ok(())
2262 })?;
2263 match t.get::<Option<u64>>("index")? {
2266 Some(i) => widgets::button_indexed(ui, i, &text, out.spec, out.tooltip.as_deref()),
2267 None => widgets::button_with(ui, &key, &text, out.spec, out.tooltip.as_deref()),
2268 }
2269 Ok(())
2270 }
2271 "checkbox" | "radio" | "switch" => {
2272 let kind = match ty {
2277 "checkbox" => widgets::Toggle::Checkbox,
2278 "radio" => widgets::Toggle::Radio,
2279 _ => widgets::Toggle::Switch,
2280 };
2281 let label: String = t.get("label")?;
2282 let text: String = t
2283 .get::<Option<String>>("text")?
2284 .unwrap_or_else(|| label.clone());
2285 let key: String = t
2286 .get::<Option<String>>("key")?
2287 .unwrap_or_else(|| label.clone());
2288 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
2289 Some(v) => lua_to_value(&v)?,
2290 None => Value::Null,
2291 };
2292 let mut out = PropsOut::new();
2293 out.spec = widgets::toggle_spec(&ui.metrics())
2294 .on_click(payload)
2295 .label(label.as_str());
2296 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2297 out.apply_tooltip(&hint);
2298 }
2299 apply_named_rows(
2300 ui,
2301 t,
2302 &["description", "disabled", "checked", "mixed"],
2303 &mut out,
2304 )?;
2305 widgets::toggle_with(ui, kind, &key, &text, out.spec, out.tooltip.as_deref());
2306 Ok(())
2307 }
2308 "slider" => {
2309 let label: String = t.get("label")?;
2313 let key: String = t
2314 .get::<Option<String>>("key")?
2315 .unwrap_or_else(|| label.clone());
2316 let mut out = PropsOut::new();
2317 out.spec = widgets::slider_spec(&ui.metrics()).label(label.as_str());
2318 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2319 out.apply_tooltip(&hint);
2320 }
2321 apply_named_rows(
2322 ui,
2323 t,
2324 &[
2325 "description",
2326 "disabled",
2327 "value_now",
2328 "value_min",
2329 "value_max",
2330 "value_step",
2331 "value_text",
2332 "on_change",
2333 "width",
2334 "min_width",
2335 "max_width",
2336 ],
2337 &mut out,
2338 )?;
2339 widgets::slider_with(ui, &key, out.spec, out.tooltip.as_deref());
2340 Ok(())
2341 }
2342 other => Err(mlua::Error::runtime(format!("unknown node type '{other}'"))),
2343 }
2344}
2345
2346fn apply_named_rows(
2351 ui: &mut Ui<'_>,
2352 t: &Table,
2353 names: &[&str],
2354 out: &mut PropsOut,
2355) -> mlua::Result<()> {
2356 with_refs(ui, |refs| {
2357 for name in names {
2358 let v = t.get::<mlua::Value>(*name)?;
2359 if v.is_nil() {
2360 continue;
2361 }
2362 let def = schema::by_snake_name(name).expect("a schema row");
2363 if let Some(parsed) =
2364 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2365 {
2366 schema::apply(def, parsed, out).map_err(bad)?;
2367 }
2368 }
2369 Ok(())
2370 })
2371}
2372
2373struct SpanPart {
2374 text: String,
2375 bold: bool,
2376 italic: bool,
2377 underline: bool,
2378 underline_color: Option<Color>,
2381 underline_style: Option<kui_core::UnderlineStyle>,
2382 strikethrough: bool,
2383 color: Option<Color>,
2384 bg: Option<Color>,
2385 bg_radius: f32,
2388}
2389
2390fn span_of(p: &SpanPart) -> Span<'_> {
2391 let mut s = Span::new(&p.text);
2392 if p.bold {
2393 s = s.bold();
2394 }
2395 if p.italic {
2396 s = s.italic();
2397 }
2398 if p.underline {
2399 s = s.underline();
2400 }
2401 if let Some(c) = p.underline_color {
2402 s = s.underline_color(c);
2403 }
2404 if let Some(st) = p.underline_style {
2405 s = s.underline_style(st);
2406 }
2407 if p.strikethrough {
2408 s = s.strikethrough();
2409 }
2410 if let Some(c) = p.color {
2411 s = s.color(c);
2412 }
2413 if let Some(c) = p.bg {
2414 s = s.bg(c);
2415 }
2416 if p.bg_radius > 0.0 {
2417 s = s.bg_radius(p.bg_radius);
2418 }
2419 s
2420}
2421
2422fn collect_spans(spans: &Table, refs: &mut Refs<'_>) -> mlua::Result<Vec<SpanPart>> {
2424 let mut out = Vec::new();
2425 for item in spans.sequence_values::<mlua::Value>() {
2426 match item? {
2427 mlua::Value::String(s) => out.push(SpanPart {
2428 text: s.to_str()?.to_string(),
2429 bold: false,
2430 italic: false,
2431 underline: false,
2432 underline_color: None,
2433 underline_style: None,
2434 strikethrough: false,
2435 color: None,
2436 bg: None,
2437 bg_radius: 0.0,
2438 }),
2439 mlua::Value::Table(t) => {
2440 let text: String = t
2441 .get::<Option<String>>(1)?
2442 .ok_or_else(|| bad("span table needs its text at [1]"))?;
2443 let color = match t.get::<mlua::Value>("color")? {
2444 mlua::Value::Nil => None,
2445 v => parse_color(&v, refs)?,
2446 };
2447 let bg = match t.get::<mlua::Value>("bg")? {
2448 mlua::Value::Nil => None,
2449 v => parse_color(&v, refs)?,
2450 };
2451 let underline_color = match t.get::<mlua::Value>("underline_color")? {
2452 mlua::Value::Nil => None,
2453 v => parse_color(&v, refs)?,
2454 };
2455 let underline_style = match t.get::<Option<String>>("underline_style")? {
2456 None => None,
2457 Some(name) => Some(
2458 kui_core::UnderlineStyle::NAMES
2459 .iter()
2460 .position(|n| *n == name)
2461 .map(|i| kui_core::UnderlineStyle::from_index(i as u32))
2462 .ok_or_else(|| {
2463 bad(format!(
2464 "underline_style must be one of {}, got {name:?}",
2465 kui_core::UnderlineStyle::NAMES.join(" | ")
2466 ))
2467 })?,
2468 ),
2469 };
2470 out.push(SpanPart {
2471 text,
2472 bold: t.get::<Option<bool>>("bold")?.unwrap_or(false),
2473 italic: t.get::<Option<bool>>("italic")?.unwrap_or(false),
2474 underline: t.get::<Option<bool>>("underline")?.unwrap_or(false),
2475 underline_color,
2476 underline_style,
2477 strikethrough: t.get::<Option<bool>>("strikethrough")?.unwrap_or(false),
2478 color,
2479 bg,
2480 bg_radius: t.get::<Option<f32>>("bg_radius")?.unwrap_or(0.0).max(0.0),
2481 });
2482 }
2483 other => {
2484 return Err(bad(format!(
2485 "span must be a string or table, got {}",
2486 other.type_name()
2487 )));
2488 }
2489 }
2490 }
2491 Ok(out)
2492}
2493
2494pub fn parse_tokens(t: &Table) -> mlua::Result<kui_core::Tokens> {
2504 let mut out = kui_core::Tokens::new();
2505 for pair in t.pairs::<String, mlua::Value>() {
2506 let (k, _) = pair?;
2507 if k != "colors" && k != "lengths" {
2508 return Err(bad(format!("tokens: unknown key {k:?} (colors, lengths)")));
2509 }
2510 }
2511 if let Some(colors) = t.get::<Option<Table>>("colors")? {
2512 let mut entries: Vec<(String, mlua::Value)> = colors
2513 .pairs::<String, mlua::Value>()
2514 .collect::<mlua::Result<_>>()?;
2515 entries.sort_by(|a, b| a.0.cmp(&b.0));
2516 let mut derived: Vec<(String, String, Vec<kui_core::ColorOp>)> = Vec::new();
2518 for (name, v) in entries {
2519 out = match &v {
2520 mlua::Value::Table(t) if t.contains_key("from")? => {
2521 let (from, ops) = parse_recipe(&name, t)?;
2522 derived.push((name, from, ops));
2523 out
2524 }
2525 mlua::Value::Table(halves) => {
2526 let half = |k: &str| -> mlua::Result<Color> {
2527 match halves.get::<mlua::Value>(k)? {
2528 mlua::Value::Nil => Err(bad(format!(
2529 "tokens.colors.{name}: needs both light and dark"
2530 ))),
2531 v => parse_color_value(&v),
2532 }
2533 };
2534 out.color_themed(&name, half("light")?, half("dark")?)
2535 }
2536 v => out.color(
2537 &name,
2538 parse_color_value(v).map_err(|e| bad(format!("tokens.colors.{name}: {e}")))?,
2539 ),
2540 };
2541 }
2542 while !derived.is_empty() {
2545 let ready = derived.iter().position(|(_, from, ops)| {
2546 let known = |s: &str| kui_core::tokens::is_role(s) || out.color_id(s).is_some();
2547 known(from)
2548 && ops.iter().all(|op| match op {
2549 kui_core::ColorOp::Mix(c, _) | kui_core::ColorOp::Readable(c, _) => {
2550 known(c)
2551 }
2552 _ => true,
2553 })
2554 });
2555 let (name, from, ops) = derived.remove(ready.unwrap_or(0));
2556 out = out.derive(&name, &from, ops);
2557 }
2558 }
2559 if let Some(lengths) = t.get::<Option<Table>>("lengths")? {
2560 let mut entries: Vec<(String, mlua::Value)> = lengths
2561 .pairs::<String, mlua::Value>()
2562 .collect::<mlua::Result<_>>()?;
2563 entries.sort_by(|a, b| a.0.cmp(&b.0));
2564 for (name, v) in entries {
2565 let px = number(&v)
2566 .ok_or_else(|| bad(format!("tokens.lengths.{name}: a length is a number")))?;
2567 out = out.length(&name, px);
2568 }
2569 }
2570 Ok(out)
2571}
2572
2573fn parse_recipe(name: &str, t: &Table) -> mlua::Result<(String, Vec<kui_core::ColorOp>)> {
2578 for pair in t.pairs::<String, mlua::Value>() {
2579 let (k, _) = pair?;
2580 if k != "from" && k != "ops" {
2581 return Err(bad(format!(
2582 "tokens.colors.{name}: unknown key {k:?} (from, ops)"
2583 )));
2584 }
2585 }
2586 let from = match t.get::<mlua::Value>("from")? {
2587 mlua::Value::String(s) => s.to_str()?.to_string(),
2588 _ => {
2589 return Err(bad(format!(
2590 "tokens.colors.{name}.from names a colour token or role"
2591 )));
2592 }
2593 };
2594 let ops = match t.get::<mlua::Value>("ops")? {
2595 mlua::Value::Nil => Vec::new(),
2596 mlua::Value::Table(list) => {
2597 let tuples: Vec<Table> = match list.get::<mlua::Value>(1)? {
2599 mlua::Value::String(_) => vec![list],
2600 _ => list
2601 .sequence_values::<Table>()
2602 .collect::<mlua::Result<_>>()?,
2603 };
2604 tuples
2605 .iter()
2606 .map(|op| parse_color_op(name, op))
2607 .collect::<mlua::Result<_>>()?
2608 }
2609 _ => {
2610 return Err(bad(format!(
2611 "tokens.colors.{name}.ops is a list of {{ verb, … }} tuples"
2612 )));
2613 }
2614 };
2615 Ok((from, ops))
2616}
2617
2618fn parse_color_op(name: &str, op: &Table) -> mlua::Result<kui_core::ColorOp> {
2619 let verb = match op.get::<mlua::Value>(1)? {
2620 mlua::Value::String(s) => s.to_str()?.to_string(),
2621 _ => {
2622 return Err(bad(format!(
2623 "tokens.colors.{name}.ops: an op starts with its verb"
2624 )));
2625 }
2626 };
2627 let Some(takes_color) = kui_core::ColorOp::takes_color(&verb) else {
2628 return Err(bad(format!(
2629 "tokens.colors.{name}.ops: unknown verb {verb:?} (lift, darken, raise, alpha, mix, readable)"
2630 )));
2631 };
2632 let arity = if takes_color { 3 } else { 2 };
2633 if op.raw_len() != arity {
2634 return Err(bad(format!(
2635 "tokens.colors.{name}.ops: {verb} takes {} — {{ \"{verb}\", {} }}",
2636 if takes_color {
2637 "a colour and a number"
2638 } else {
2639 "one number"
2640 },
2641 if takes_color { "token, t" } else { "t" }
2642 )));
2643 }
2644 let color = if takes_color {
2645 match op.get::<mlua::Value>(2)? {
2646 mlua::Value::String(s) => Some(s.to_str()?.to_string()),
2647 _ => {
2648 return Err(bad(format!(
2649 "tokens.colors.{name}.ops: {verb}'s colour is a token or role name"
2650 )));
2651 }
2652 }
2653 } else {
2654 None
2655 };
2656 let n = number(&op.get::<mlua::Value>(arity as i64)?).ok_or_else(|| {
2657 bad(format!(
2658 "tokens.colors.{name}.ops: {verb}'s number is a number"
2659 ))
2660 })?;
2661 Ok(kui_core::ColorOp::parse(&verb, color.as_deref(), n).expect("checked above"))
2662}
2663
2664pub type Refs<'a> = kui_core::NameRefs<'a>;
2670
2671fn with_refs<R>(
2675 ui: &mut Ui<'_>,
2676 f: impl FnOnce(&mut Refs<'_>) -> mlua::Result<R>,
2677) -> mlua::Result<R> {
2678 let (r, errors, families) = {
2679 let mut refs = Refs::new(ui.core().token_lookup());
2680 let r = f(&mut refs);
2681 (r, refs.take_missed(), refs.take_missed_families())
2682 };
2683 for e in errors {
2684 ui.core().warn_unknown_token(&e);
2685 }
2686 for name in families {
2687 ui.core().warn_unknown_family(&name);
2688 }
2689 r
2690}
2691
2692fn reference(v: &mlua::Value) -> mlua::Result<Option<String>> {
2694 if let mlua::Value::String(s) = v
2695 && let Some(name) = kui_core::tokens::reference(&s.to_str()?)
2696 {
2697 return Ok(Some(name.to_string()));
2698 }
2699 Ok(None)
2700}
2701
2702fn dash_of(t: &Table) -> mlua::Result<kui_core::Dash> {
2706 let offset = t.get::<Option<f32>>("dash_offset")?.unwrap_or(0.0);
2707 let lengths: Vec<f32> = match t.get::<mlua::Value>("dash")? {
2708 mlua::Value::Nil => return Ok(kui_core::Dash::SOLID),
2709 mlua::Value::Table(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
2710 mlua::Value::Integer(n) => vec![n as f32],
2711 mlua::Value::Number(n) => vec![n as f32],
2712 _ => Vec::new(),
2713 };
2714 kui_core::Dash::of(&lengths)
2715 .map(|d| d.offset(offset))
2716 .ok_or_else(|| bad("dash is a length, {mark, gap} or {mark, gap, mark, gap}"))
2717}
2718
2719fn length_of(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<f32>> {
2721 if let Some(name) = reference(v)? {
2722 return Ok(refs.length(&name));
2723 }
2724 number(v)
2725 .map(Some)
2726 .ok_or_else(|| bad("expected a number or a \"$token\""))
2727}
2728
2729pub fn parse_props(t: &Table, is_row: bool, refs: &mut Refs<'_>) -> mlua::Result<PropsOut> {
2737 let mut out = PropsOut::new();
2738 if is_row {
2739 out.spec = kui_core::NodeSpec::row();
2740 }
2741 match t.get::<mlua::Value>("size")? {
2742 mlua::Value::Nil => {}
2743 v => {
2744 if let Some(size) = length_of(&v, refs)? {
2745 out.style = kui_core::TextStyle::new(size);
2746 }
2747 }
2748 }
2749 match t.get::<mlua::Value>("radius")? {
2752 mlua::Value::Nil => {}
2753 v => {
2754 if let Some(r) = length_of(&v, refs)? {
2755 out.with_spec(|s| s.radius(r));
2756 }
2757 }
2758 }
2759 let mut overflow = 0;
2763 for pair in t.pairs::<mlua::Value, mlua::Value>() {
2764 let (k, v) = pair?;
2765 let mlua::Value::String(k) = k else { continue };
2766 let k = k.to_str()?;
2767 match k.as_ref() {
2768 "size" | "radius" => {}
2769 "pad" => {
2770 let pad = parse_pad(&v, refs)?;
2771 out.apply_pad(pad);
2772 }
2773 "border" => {
2774 let b = match v {
2775 mlua::Value::Table(b) => b,
2776 _ => return Err(bad("border must be a table {w=, color=}")),
2777 };
2778 let w = length_of(&b.get::<mlua::Value>("w")?, refs)?.unwrap_or(0.0);
2779 let c = parse_color(&b.get::<mlua::Value>("color")?, refs)?
2780 .unwrap_or(Color::TRANSPARENT);
2781 out.with_spec(|s| s.border(w, c));
2782 }
2783 "clip" => overflow |= bit(&v, kui_core::OVERFLOW_CLIP),
2784 "scroll_x" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_X),
2785 "scroll" | "scroll_y" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_Y),
2786 "float" => {
2787 let cfg = parse_float(&v)?;
2788 out.with_spec(|s| s.float(cfg));
2789 }
2790 "key_focus" => out.key_focus = truthy(&v),
2791 "key" => {
2792 let mlua::Value::String(s) = &v else {
2793 return Err(bad("key must be a string"));
2794 };
2795 out.key = Some(s.to_str()?.to_string());
2796 }
2797 "index" => {
2798 let Some(i) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
2799 return Err(bad("index must be a number (the row's data index)"));
2800 };
2801 out.index = Some(i.max(0.0) as u64);
2802 }
2803 "row_count" => {
2804 let Some(n) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
2805 return Err(bad(
2806 "row_count must be a number (how many indexed rows the list has)",
2807 ));
2808 };
2809 out.row_count = Some(n.max(0.0) as u64);
2810 }
2811 "tooltip" => {
2812 let mlua::Value::String(s) = &v else {
2813 return Err(bad("tooltip must be a string"));
2814 };
2815 out.apply_tooltip(s.to_str()?.as_ref());
2816 }
2817 name => {
2818 let name = schema::lua_alias(name).unwrap_or(name);
2822 let Some(def) = schema::by_snake_name(name) else {
2823 continue;
2824 };
2825 if let Some(parsed) =
2826 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2827 {
2828 schema::apply(def, parsed, &mut out).map_err(bad)?;
2829 }
2830 }
2831 }
2832 }
2833 out.with_spec(|s| s.overflow_bits(overflow));
2834 Ok(out)
2835}
2836
2837fn truthy(v: &mlua::Value) -> bool {
2838 matches!(v, mlua::Value::Boolean(true))
2839}
2840
2841fn bit(v: &mlua::Value, bit: u32) -> u32 {
2843 if truthy(v) { bit } else { 0 }
2844}
2845
2846fn number(v: &mlua::Value) -> Option<f32> {
2847 match v {
2848 mlua::Value::Number(n) => Some(*n as f32),
2849 mlua::Value::Integer(n) => Some(*n as f32),
2850 _ => None,
2851 }
2852}
2853
2854fn parse_value(kind: &Kind, v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Parsed>> {
2856 Ok(Some(match kind {
2857 Kind::F32 => match length_of(v, refs)? {
2858 Some(px) => Parsed::F32(px),
2859 None => return Ok(None),
2860 },
2861 Kind::Color => match parse_color(v, refs)? {
2862 Some(c) => Parsed::Color(c),
2863 None => return Ok(None),
2864 },
2865 Kind::Flag => {
2866 if truthy(v) {
2867 Parsed::Flag
2868 } else {
2869 return Ok(None);
2870 }
2871 }
2872 Kind::Enum(names) => {
2873 let mlua::Value::String(s) = v else {
2874 return Err(bad(format!("expected one of {names:?}")));
2875 };
2876 Parsed::Enum(schema::enum_index(names, &s.to_str()?).map_err(bad)?)
2877 }
2878 Kind::Sizing => match parse_sizing(v, refs)? {
2879 Some(s) => Parsed::Sizing(s),
2880 None => return Ok(None),
2881 },
2882 Kind::Min | Kind::Max => Parsed::Bound(match v {
2887 v if reference(v)?.is_some() => match length_of(v, refs)? {
2888 Some(px) => kui_core::Bound::Px(px),
2889 None => return Ok(None),
2890 },
2891 mlua::Value::String(s) => {
2894 let s = s.to_str()?;
2895 let b = if matches!(kind, Kind::Min) {
2896 schema::min_str(&s)
2897 } else {
2898 schema::max_str(&s)
2899 };
2900 match kept(b)? {
2901 Some(b) => b,
2902 None => return Ok(None),
2903 }
2904 }
2905 mlua::Value::Table(_) => match kept(kui_core::calc::bound_value(&size_value(v)?))? {
2906 Some(b) => b,
2907 None => return Ok(None),
2908 },
2909 v => kui_core::Bound::Px(
2910 number(v).ok_or_else(|| bad("expected a number, a string or a size table"))?,
2911 ),
2912 }),
2913 Kind::Msg | Kind::Tag => Parsed::Msg(lua_to_value(v)?),
2914 Kind::Str => {
2915 let mlua::Value::String(s) = v else {
2916 return Err(bad("expected a string"));
2917 };
2918 Parsed::Str(s.to_str()?.to_string())
2919 }
2920 Kind::Family => {
2923 let mlua::Value::String(s) = v else {
2924 return Err(bad("expected a family name"));
2925 };
2926 Parsed::Family(refs.family(&s.to_str()?))
2927 }
2928 Kind::Resource => match v {
2929 mlua::Value::Integer(n) => Parsed::Resource(*n as u64),
2930 mlua::Value::Number(n) => Parsed::Resource(*n as u64),
2931 _ => return Err(bad("expected a resource handle (integer)")),
2932 },
2933 Kind::Keyframes => Parsed::Keyframes(
2936 kui_core::keyframes::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
2937 ),
2938 Kind::Enter => {
2939 Parsed::Enter(kui_core::enter::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?)
2940 }
2941 Kind::Gradient => Parsed::Gradient(
2942 kui_core::gradient::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
2943 ),
2944 }))
2945}
2946
2947fn parse_color_value(v: &mlua::Value) -> mlua::Result<Color> {
2950 match v {
2951 mlua::Value::Integer(n) => Ok(schema::color_num(*n as u32)),
2952 mlua::Value::Number(n) => Ok(schema::color_num(*n as u32)),
2953 mlua::Value::String(s) => schema::color_hex_str(&s.to_str()?).map_err(bad),
2954 _ => Err(bad("color must be a 0xRRGGBBAA integer or \"#hex\" string")),
2955 }
2956}
2957
2958fn parse_color(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Color>> {
2960 if let Some(name) = reference(v)? {
2961 return Ok(refs.color(&name));
2962 }
2963 parse_color_value(v)
2964 .map(Some)
2965 .map_err(|_| bad("color must be a 0xRRGGBBAA integer, a \"#hex\" string or a \"$token\""))
2966}
2967
2968fn size_value(v: &mlua::Value) -> mlua::Result<Value> {
2973 fn check(v: &Value) -> mlua::Result<()> {
2974 match v {
2975 Value::Map(m) => {
2976 for (k, x) in m {
2977 if k == "percent" {
2978 return Err(bad("a percentage is { pct = n } in Lua"));
2979 }
2980 check(x)?;
2981 }
2982 Ok(())
2983 }
2984 Value::List(xs) => xs.iter().try_for_each(check),
2985 _ => Ok(()),
2986 }
2987 }
2988 let value = lua_to_value(v)?;
2989 check(&value)?;
2990 Ok(value)
2991}
2992
2993fn kept<T>(r: Result<T, String>) -> mlua::Result<Option<T>> {
2997 match r {
2998 Ok(v) => Ok(Some(v)),
2999 Err(e) if kui_core::calc::is_full(&e) => Ok(None),
3000 Err(e) => Err(bad(e)),
3001 }
3002}
3003
3004fn parse_sizing(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Sizing>> {
3005 if let Some(name) = reference(v)? {
3006 return Ok(refs.length(&name).map(Sizing::Fixed));
3007 }
3008 Ok(Some(match v {
3009 mlua::Value::Number(n) => Sizing::Fixed(*n as f32),
3010 mlua::Value::Integer(n) => Sizing::Fixed(*n as f32),
3011 mlua::Value::String(s) => match kept(schema::sizing_str(&s.to_str()?))? {
3012 Some(s) => s,
3013 None => return Ok(None),
3014 },
3015 mlua::Value::Table(t) => {
3016 if let Some(p) = t.get::<Option<f32>>("pct")? {
3017 Sizing::Percent(p / 100.0)
3018 } else if let Some(f) = t.get::<Option<f32>>("grow")? {
3019 Sizing::Grow(f)
3020 } else {
3021 match kept(kui_core::calc::sizing_value(&size_value(v)?)).map_err(|e| {
3024 bad(format!(
3025 "sizing table needs pct, grow or a size expression: {e}"
3026 ))
3027 })? {
3028 Some(s) => s,
3029 None => return Ok(None),
3030 }
3031 }
3032 }
3033 _ => return Err(bad("invalid sizing value")),
3034 }))
3035}
3036
3037fn parse_pad(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<PadShorthand> {
3041 match v {
3042 mlua::Value::Table(t) => {
3043 let mut edge = |k: &str| -> mlua::Result<Option<f32>> {
3044 match t.get::<mlua::Value>(k)? {
3045 mlua::Value::Nil => Ok(None),
3046 v => length_of(&v, refs),
3047 }
3048 };
3049 Ok(PadShorthand {
3050 all: edge("all")?,
3051 x: edge("x")?,
3052 y: edge("y")?,
3053 l: edge("l")?,
3054 r: edge("r")?,
3055 t: edge("t")?,
3056 b: edge("b")?,
3057 })
3058 }
3059 v => Ok(PadShorthand {
3060 all: length_of(v, refs).map_err(|_| bad("invalid padding value"))?,
3061 ..PadShorthand::default()
3062 }),
3063 }
3064}
3065
3066fn parse_align(s: &str) -> mlua::Result<Align> {
3067 schema::enum_index(schema::ALIGNS, s)
3068 .map(schema::align_idx)
3069 .map_err(bad)
3070}
3071
3072fn float_preset(name: &str) -> mlua::Result<FloatConfig> {
3075 FloatConfig::preset(name).ok_or_else(|| {
3076 bad(format!(
3077 "bad float preset '{name}' (one of {})",
3078 kui_core::FLOAT_PRESETS.join(" | ")
3079 ))
3080 })
3081}
3082
3083fn parse_attach(f: &Table, key: &str) -> mlua::Result<Option<(Align, Align)>> {
3085 let Some(at) = f.get::<Option<Table>>(key)? else {
3086 return Ok(None);
3087 };
3088 Ok(Some((
3089 parse_align(&at.get::<String>(1)?)?,
3090 parse_align(&at.get::<String>(2)?)?,
3091 )))
3092}
3093
3094fn parse_float(v: &mlua::Value) -> mlua::Result<FloatConfig> {
3095 let f = match v {
3096 mlua::Value::String(s) => return float_preset(&s.to_str()?),
3097 mlua::Value::Table(f) => f,
3098 _ => return Err(bad("float must be a preset string or a table")),
3099 };
3100 let self_at = match parse_attach(f, "self")? {
3103 Some(at) => Some(at),
3104 None => parse_attach(f, "self_at")?,
3105 };
3106 Ok(FloatConfig::build(
3107 match f.get::<Option<String>>("anchor")? {
3108 Some(name) => float_preset(&name)?,
3109 None => FloatConfig::parent(),
3110 },
3111 parse_attach(f, "at")?,
3112 self_at,
3113 f.get::<Option<f32>>("dx")?,
3114 f.get::<Option<f32>>("dy")?,
3115 f.get::<Option<bool>>("fit")?.unwrap_or(false),
3116 f.get::<Option<bool>>("clip")?.unwrap_or(false),
3117 ))
3118}
3119
3120pub const MAX_VALUE_DEPTH: usize = 64;
3127
3128pub fn lua_to_value(v: &mlua::Value) -> mlua::Result<Value> {
3146 to_value(
3147 v,
3148 &mut ValuePath {
3149 depth: 0,
3150 seen: Vec::new(),
3151 },
3152 )
3153}
3154
3155const VALUE_TRACKED_PAST: usize = 8;
3161
3162struct ValuePath {
3165 depth: usize,
3166 seen: Vec<*const std::ffi::c_void>,
3167}
3168
3169fn to_value(v: &mlua::Value, path: &mut ValuePath) -> mlua::Result<Value> {
3173 Ok(match v {
3174 mlua::Value::Nil => Value::Null,
3175 mlua::Value::Boolean(b) => Value::Bool(*b),
3176 mlua::Value::Integer(i) => Value::Int(*i),
3177 mlua::Value::Number(n) => Value::Float(*n),
3178 mlua::Value::String(s) => Value::Str(s.to_str()?.to_string()),
3179 mlua::Value::Table(t) => {
3180 let tracked = path.depth >= VALUE_TRACKED_PAST;
3181 if tracked {
3182 let at = t.to_pointer();
3183 if path.seen.contains(&at) {
3184 return Err(bad("a table that holds itself cannot be a value"));
3185 }
3186 path.seen.push(at);
3187 }
3188 if path.depth >= MAX_VALUE_DEPTH {
3189 return Err(bad(format!("a value nested past {MAX_VALUE_DEPTH} tables")));
3190 }
3191 path.depth += 1;
3192 let len = t.raw_len();
3193 let value = if len > 0 {
3194 let mut list = Vec::with_capacity(len);
3195 for item in t.sequence_values::<mlua::Value>() {
3196 list.push(to_value(&item?, path)?);
3197 }
3198 Value::List(list)
3199 } else {
3200 let mut map = Vec::new();
3201 for pair in t.pairs::<String, mlua::Value>() {
3202 let (k, v) = pair?;
3203 map.push((k, to_value(&v, path)?));
3204 }
3205 Value::Map(map)
3206 };
3207 path.depth -= 1;
3208 if tracked {
3209 path.seen.pop();
3210 }
3211 value
3212 }
3213 other => {
3214 return Err(mlua::Error::runtime(format!(
3215 "cannot convert {} to event payload",
3216 other.type_name()
3217 )));
3218 }
3219 })
3220}
3221
3222pub fn value_to_lua(lua: &Lua, v: &Value) -> mlua::Result<mlua::Value> {
3225 Ok(match v {
3226 Value::Null => mlua::Value::Nil,
3227 Value::Bool(b) => mlua::Value::Boolean(*b),
3228 Value::Int(i) => mlua::Value::Integer(*i),
3229 Value::Float(f) => mlua::Value::Number(*f),
3230 Value::Str(s) => mlua::Value::String(lua.create_string(s)?),
3231 Value::List(items) => {
3232 let t = lua.create_table_with_capacity(items.len(), 0)?;
3233 for (i, item) in items.iter().enumerate() {
3234 t.set(i + 1, value_to_lua(lua, item)?)?;
3235 }
3236 mlua::Value::Table(t)
3237 }
3238 Value::Map(entries) => {
3239 let t = lua.create_table_with_capacity(0, entries.len())?;
3240 for (k, v) in entries {
3241 t.set(k.as_str(), value_to_lua(lua, v)?)?;
3242 }
3243 mlua::Value::Table(t)
3244 }
3245 })
3246}
3247
3248#[cfg(test)]
3249mod tests {
3250 use super::*;
3251 use kui_core::{
3252 Core, Edges, FontFamily, InputEvent, NodeSpec, OriginId, Rect, Size, TextStyle, Vec2,
3253 WindowButton, WindowId,
3254 };
3255
3256 #[test]
3257 fn value_round_trips_through_lua() {
3258 let lua = Lua::new();
3259 let original = Value::map([
3260 ("kind", "inc".into()),
3261 ("by", Value::Int(2)),
3262 (
3263 "weights",
3264 Value::List(vec![Value::Float(0.5), Value::Float(1.5)]),
3265 ),
3266 ("enabled", Value::Bool(true)),
3267 ]);
3268 let lua_v = value_to_lua(&lua, &original).unwrap();
3269 let back = lua_to_value(&lua_v).unwrap();
3270 assert_eq!(back.get_str("kind"), Some("inc"));
3271 assert_eq!(back.get_int("by"), Some(2));
3272 assert_eq!(back.get_bool("enabled"), Some(true));
3273 match back.get("weights") {
3274 Some(Value::List(items)) => assert_eq!(items.len(), 2),
3275 other => panic!("expected list, got {other:?}"),
3276 }
3277 }
3278
3279 fn test_refs() -> Refs<'static> {
3283 let core: &'static Core = Box::leak(Box::new(Core::new()));
3284 Refs::new(core.token_lookup())
3285 }
3286
3287 fn eval_table(lua: &Lua, src: &str) -> Table {
3288 lua.load(src).eval().unwrap()
3289 }
3290
3291 #[test]
3293 fn schema_props_match_the_rust_builder() {
3294 let lua = Lua::new();
3295 let t = eval_table(
3296 &lua,
3297 r##"{
3298 width = "grow", height = {pct = 50},
3299 min_width = 10, max_width = 500, min_height = 5, max_height = 300,
3300 pad = {l = 1, r = 2, t = 3, b = 4}, gap = 8,
3301 main_align = "center", cross_align = "end", center = false,
3302 bg = "#14161e", radius = 6, border = {w = 1, color = 0x2a2d3aff},
3303 clip = true, scroll = true, scroll_x = true,
3304 float = {anchor = "viewport", at = {"end", "end"}, self_at = {"end", "end"},
3305 dx = -8, dy = -8, fit = true},
3306 hoverable = true, window = "close",
3307 on_click = {kind = "hit"}, on_drag = "d", on_key = 7, key_up = true,
3308 modal = "dlg", on_context_menu = {kind = "menu"},
3309 initial_focus = true,
3310 key = "panel", key_focus = true,
3311 }"##,
3312 );
3313 let p = parse_props(&t, true, &mut test_refs()).unwrap();
3314 let expected = NodeSpec::row()
3315 .grow_width()
3316 .height(Sizing::Percent(0.5))
3317 .min_width(10.0)
3318 .max_width(500.0)
3319 .min_height(5.0)
3320 .max_height(300.0)
3321 .padding(Edges {
3322 l: 1.0,
3323 r: 2.0,
3324 t: 3.0,
3325 b: 4.0,
3326 })
3327 .gap(8.0)
3328 .main_align(Align::Center)
3329 .cross_align(Align::End)
3330 .bg(Color::hex(0x14161eff))
3331 .radius(6.0)
3332 .border(1.0, Color::hex(0x2a2d3aff))
3333 .clip()
3334 .scroll_y()
3335 .scroll_x()
3336 .float(
3337 FloatConfig::viewport()
3338 .inside(Align::End, Align::End)
3339 .offset(-8.0, -8.0)
3340 .fit(),
3341 )
3342 .hoverable()
3343 .window_button(WindowButton::Close)
3344 .on_click(Value::map([("kind", "hit".into())]))
3345 .on_drag("d")
3346 .on_key(Value::Int(7))
3347 .key_up()
3348 .modal("dlg")
3349 .initial_focus()
3350 .on_context_menu(Value::map([("kind", "menu".into())]));
3351 assert_eq!(p.spec, expected);
3352 assert_eq!(p.key.as_deref(), Some("panel"));
3353 assert!(p.key_focus);
3354 }
3355
3356 #[test]
3359 fn a_min_is_a_number_or_fit() {
3360 let lua = Lua::new();
3361 let t = eval_table(&lua, r#"{ min_width = "fit", min_height = 3 }"#);
3362 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3363 let expected = NodeSpec::column()
3364 .min_width(kui_core::Min::FIT)
3365 .min_height(3.0);
3366 assert_eq!(p.spec, expected);
3367 let t = eval_table(&lua, r#"{ min_width = "grow" }"#);
3368 let err = parse_props(&t, false, &mut test_refs())
3369 .unwrap_err()
3370 .to_string();
3371 assert!(err.contains("bad min"), "{err}");
3372 }
3373
3374 #[test]
3378 fn the_lua_composites_resolve_the_way_the_core_says() {
3379 let lua = Lua::new();
3380 let t = eval_table(
3381 &lua,
3382 r#"{ pad = { all = 4, x = 10, b = 1 },
3383 float = { anchor = "below", dx = 6 } }"#,
3384 );
3385 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3386 assert_eq!(
3387 p.spec.layout.padding,
3388 Edges {
3389 l: 10.0,
3390 r: 10.0,
3391 t: 4.0,
3392 b: 1.0,
3393 }
3394 );
3395 assert_eq!(
3396 p.spec.layout.float,
3397 Some(FloatConfig::build(
3398 FloatConfig::below(),
3399 None,
3400 None,
3401 Some(6.0),
3402 None,
3403 false,
3404 false
3405 ))
3406 );
3407
3408 let by_self = eval_table(&lua, r#"{ float = { self = {"end", "start"} } }"#);
3410 let by_self_at = eval_table(&lua, r#"{ float = { self_at = {"end", "start"} } }"#);
3411 assert_eq!(
3412 parse_props(&by_self, false, &mut test_refs())
3413 .unwrap()
3414 .spec
3415 .layout
3416 .float,
3417 parse_props(&by_self_at, false, &mut test_refs())
3418 .unwrap()
3419 .spec
3420 .layout
3421 .float
3422 );
3423
3424 let bad_preset = eval_table(&lua, r#"{ float = "beneath" }"#);
3427 let e = parse_props(&bad_preset, false, &mut test_refs())
3428 .unwrap_err()
3429 .to_string();
3430 assert!(e.contains("beneath") && e.contains("below"), "{e}");
3431 }
3432
3433 #[test]
3434 fn text_style_props_match_the_rust_builder() {
3435 let lua = Lua::new();
3436 let t = eval_table(
3437 &lua,
3438 r#"{ size = 20, line_height = 30, color = 0x73d98cff, family = "mono" }"#,
3439 );
3440 let style = parse_props(&t, false, &mut test_refs()).unwrap().style;
3441 assert_eq!(
3442 style,
3443 TextStyle::new(20.0)
3444 .line_height(30.0)
3445 .color(Color::hex(0x73d98cff))
3446 .family(FontFamily::Mono)
3447 );
3448 let t = eval_table(&lua, r#"{ wrap = "none", max_lines = 2, ellipsis = true }"#);
3449 assert_eq!(
3450 parse_props(&t, false, &mut test_refs()).unwrap().style,
3451 TextStyle::default().nowrap().max_lines(2).ellipsis()
3452 );
3453 let t = eval_table(&lua, r##"{ color = "#fff", size = 12 }"##);
3456 assert_eq!(
3457 parse_props(&t, false, &mut test_refs()).unwrap().style,
3458 TextStyle::new(12.0).color(Color::hex(0xffffffff))
3459 );
3460 }
3461
3462 #[test]
3463 fn bad_values_name_the_prop() {
3464 let lua = Lua::new();
3465 let t = eval_table(&lua, r#"{ main_align = "middle" }"#);
3466 let e = parse_props(&t, false, &mut test_refs())
3467 .unwrap_err()
3468 .to_string();
3469 assert!(e.contains("main_align"), "{e}");
3470 assert!(e.contains("middle"), "{e}");
3471 }
3472
3473 fn frame(core: &mut Core, ext: &mut LuaExtension) -> usize {
3474 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3475 ui.set_origin(OriginId(1));
3476 ext.view(&Slot::root(), &mut ui).unwrap();
3477 ui.finish();
3478 core.output().0.quads.len()
3479 }
3480
3481 #[test]
3482 fn script_view_builds_ir_nodes() {
3483 let mut ext = LuaExtension::from_source(
3484 "test",
3485 r#"
3486 count = 41
3487 function view()
3488 return column { gap = 8, pad = 16, bg = 0x10121aff,
3489 text("count: " .. count, { size = 20 }),
3490 button { label = "bump", on_click = { kind = "bump" } },
3491 }
3492 end
3493 function on_event(ev)
3494 if ev.kind == "bump" then count = count + 1 end
3495 end
3496 "#,
3497 )
3498 .unwrap();
3499
3500 let mut core = Core::new();
3501 let quads = frame(&mut core, &mut ext);
3502 assert!(quads > 10, "expected panel/button/glyph quads, got {quads}");
3504
3505 ext.on_event(&UiEvent {
3507 origin: OriginId(1),
3508 key: Key::ROOT,
3509 payload: Value::map([("kind", "bump".into())]),
3510 window: WindowId::MAIN,
3511 slot: None,
3512 });
3513 let count: i64 = ext.lua.globals().get("count").unwrap();
3514 assert_eq!(count, 42);
3515 }
3516
3517 #[test]
3523 fn a_devtools_tab_calls_its_view_only_while_on_show() {
3524 let mut ext = LuaExtension::from_source(
3525 "test",
3526 r#"
3527 calls = 0
3528 function view(env)
3529 return column { gap = 8,
3530 text("app"),
3531 devtools_tab { name = "syntax", label = "Tree-sitter", view = function()
3532 calls = calls + 1
3533 return column { text("from lua"),
3534 button { label = "jump", on_click = { kind = "jump" } } }
3535 end },
3536 devtools_tab { name = "plug", label = "Plugin", slot = "ts/panel" },
3537 devtools_tab { name = "bad", label = "Bad" },
3538 }
3539 end
3540 "#,
3541 )
3542 .unwrap();
3543 let mut core = Core::new();
3544 core.set_devtools(true);
3545 core.set_devtools_dock(kui_core::DevtoolsDock::Right);
3546 core.set_inspect(true);
3547 frame(&mut core, &mut ext);
3548 frame(&mut core, &mut ext);
3549 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3550 assert_eq!(calls, 0, "not on show: the view was not called");
3551 let ws = core.take_warnings();
3552 assert_eq!(
3553 ws.iter()
3554 .filter(|w| w.code == kui_core::diag::BAD_DEVTOOLS_TAB)
3555 .count(),
3556 1,
3557 "the tab with neither form warns once: {ws:?}"
3558 );
3559 let chord = || {
3562 kui_core::InputEvent::KeyDown(kui_core::KeyPress::new(
3563 kui_core::KeyCode::Char('N'),
3564 kui_core::KeyMods::NONE.with_ctrl().with_shift(),
3565 ))
3566 };
3567 core.handle_input(chord());
3568 core.handle_input(chord());
3569 frame(&mut core, &mut ext);
3570 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3571 assert_eq!(calls, 1, "on show: called once a frame");
3572 frame(&mut core, &mut ext);
3573 let body = core
3574 .nodes()
3575 .iter()
3576 .find(|n| n.label.as_deref() == Some("kui-devtools/tab/syntax"))
3577 .map(|n| n.rect)
3578 .expect("the body");
3579 let jump = core
3580 .nodes()
3581 .iter()
3582 .find(|n| n.label.as_deref() == Some("jump"))
3583 .map(|n| n.rect)
3584 .expect("the script's button");
3585 assert!(
3586 jump.x >= body.x && jump.x + jump.w <= body.x + body.w,
3587 "{jump:?} in {body:?}"
3588 );
3589 assert!(
3590 core.nodes()
3591 .iter()
3592 .any(|n| n.text.as_deref() == Some("from lua")),
3593 "the script's text painted"
3594 );
3595 let evs = kui_core::testing::click_at(&mut core, jump.x + 2.0, jump.y + 2.0);
3596 assert_eq!(evs.len(), 1);
3597 assert_eq!(evs[0].origin, OriginId(1), "the script's own event");
3598 assert_eq!(evs[0].kind(), Some("jump"));
3599 }
3600
3601 #[test]
3606 fn the_root_table_declares_windows() {
3607 use kui_core::{WindowCommand, WindowConfig};
3608 let mut ext = LuaExtension::from_source(
3609 "windows",
3610 r#"
3611 function view(env)
3612 return column {
3613 windows = { { name = "palette", width = 400, height = 300,
3614 activates = false }, "tools" },
3615 text("main"),
3616 }
3617 end
3618 "#,
3619 )
3620 .unwrap();
3621 let mut core = Core::new();
3622 frame(&mut core, &mut ext);
3623 let cmds = core.take_window_commands();
3624 assert_eq!(cmds.len(), 2, "{cmds:?}");
3625 assert_eq!(
3626 cmds[0],
3627 WindowCommand::Open {
3628 id: WindowId(1),
3629 owner: WindowId::MAIN,
3630 origin: OriginId(1),
3631 config: WindowConfig {
3632 size: Size::new(400.0, 300.0),
3633 activates: false,
3634 ..WindowConfig::default()
3635 },
3636 }
3637 );
3638 assert_eq!(
3639 cmds[1],
3640 WindowCommand::Open {
3641 id: WindowId(2),
3642 owner: WindowId::MAIN,
3643 origin: OriginId(1),
3644 config: WindowConfig::default(),
3645 }
3646 );
3647 frame(&mut core, &mut ext);
3649 assert!(core.take_window_commands().is_empty());
3650 assert!(core.take_warnings().is_empty());
3651 }
3652
3653 #[test]
3657 fn a_windows_entry_declares_a_popup() {
3658 use kui_core::{Rect, WindowCommand, WindowConfig, WindowKind};
3659 let mut ext = LuaExtension::from_source(
3660 "windows-popup",
3661 r#"
3662 function view(env)
3663 return column {
3664 windows = { { name = "menu", kind = "popup",
3665 width = 160, height = 320,
3666 anchor = { x = 12, y = 40, w = 160, h = 24 } } },
3667 text("main"),
3668 }
3669 end
3670 "#,
3671 )
3672 .unwrap();
3673 let mut core = Core::new();
3674 frame(&mut core, &mut ext);
3675 assert_eq!(
3676 core.take_window_commands(),
3677 vec![WindowCommand::Open {
3678 id: WindowId(1),
3679 owner: WindowId::MAIN,
3680 origin: OriginId(1),
3681 config: WindowConfig {
3682 kind: WindowKind::Popup,
3683 size: Size::new(160.0, 320.0),
3684 activates: false,
3685 anchor: Rect::new(12.0, 40.0, 160.0, 24.0),
3686 },
3687 }]
3688 );
3689 }
3690
3691 #[test]
3696 fn a_windows_entry_cannot_name_an_unknown_kind() {
3697 let mut ext = LuaExtension::from_source(
3698 "windows-kind",
3699 r#"
3700 function view(env)
3701 return column {
3702 windows = { { name = "palette", kind = "sheet" } },
3703 text("main"),
3704 }
3705 end
3706 "#,
3707 )
3708 .unwrap();
3709 let mut core = Core::new();
3710 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3711 ui.set_origin(OriginId(1));
3712 let err = ext.view(&Slot::root(), &mut ui).unwrap_err();
3713 assert!(err.contains("sheet"), "{err}");
3714 assert!(err.contains("popup"), "{err}");
3715 }
3716
3717 #[test]
3721 fn a_button_takes_an_index_and_a_cursor_shape_is_refused() {
3722 let mut ext = LuaExtension::from_source(
3723 "rows",
3724 r#"
3725 from = 0
3726 function view(env)
3727 return column {
3728 button { label = "Open", index = from, on_click = { row = from } },
3729 button { label = "Open", index = from + 1, on_click = { row = from + 1 } },
3730 button { label = "Keyed", key = "named", index = 7, on_click = "k" },
3731 }
3732 end
3733 "#,
3734 )
3735 .unwrap();
3736 let mut core = Core::new();
3737 core.set_diagnostics(true);
3738 let frame = |core: &mut Core, ext: &mut LuaExtension| {
3739 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3740 ui.set_origin(OriginId(1));
3741 ext.view(&Slot::root(), &mut ui).unwrap();
3742 ui.finish();
3743 };
3744 frame(&mut core, &mut ext);
3745 assert!(core.take_warnings().is_empty(), "index is a button row");
3746 let buttons = |core: &mut Core| -> Vec<Key> {
3747 core.access_tree()
3748 .nodes
3749 .iter()
3750 .filter(|n| n.role == kui_core::Role::Button)
3751 .map(|n| n.key)
3752 .collect()
3753 };
3754 let before = buttons(&mut core);
3755 assert_eq!(before.len(), 3, "two rows with the same text are two nodes");
3756 assert!(
3757 core.key_of("named").is_none(),
3758 "declared beside `key`, the index wins"
3759 );
3760 ext.lua.globals().set("from", 1).unwrap();
3761 frame(&mut core, &mut ext);
3762 let after = buttons(&mut core);
3763 assert_eq!(
3764 after[0], before[1],
3765 "row 1 keeps its key as it moves up the list"
3766 );
3767
3768 let mut ext = LuaExtension::from_source(
3769 "term",
3770 r#"
3771 function view(env)
3772 return column { cells { key = "term", rows = 1, cols = 4, size = 14,
3773 lines = { "abcd" }, cursor_at = { 1, 1 }, cursor_shape = "blob" } }
3774 end
3775 "#,
3776 )
3777 .unwrap();
3778 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3779 ui.set_origin(OriginId(1));
3780 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
3781 assert!(
3782 err.contains("block | bar | underline") && err.contains("blob"),
3783 "{err}"
3784 );
3785 }
3786
3787 #[test]
3790 fn option_as_alt_is_a_side_by_name() {
3791 let mut core = Core::new();
3792 let mut ext = LuaExtension::from_source(
3793 "keys",
3794 r#"
3795 function view(env)
3796 return column { option_as_alt = "left", text("x") }
3797 end
3798 "#,
3799 )
3800 .unwrap();
3801 frame(&mut core, &mut ext);
3802 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
3803
3804 let mut ext = LuaExtension::from_source(
3805 "keys",
3806 r#"
3807 function view(env)
3808 return column { option_as_alt = "meta", text("x") }
3809 end
3810 "#,
3811 )
3812 .unwrap();
3813 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3814 ui.set_origin(OriginId(1));
3815 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
3816 assert!(
3817 err.contains("option_as_alt") && err.contains("\"both\"") && err.contains("meta"),
3818 "{err}"
3819 );
3820 }
3821
3822 #[test]
3826 fn a_value_that_holds_itself_or_nests_too_deep_is_refused() {
3827 let lua = Lua::new();
3828 let read = |src: &str| lua_to_value(&lua.load(src).eval::<mlua::Value>().unwrap());
3829 let err = |src: &str| read(src).unwrap_err().to_string();
3830 assert!(err("local t = {}; t[1] = t; return t").contains("holds itself"));
3831 assert!(err("local t = {}; t.me = { t }; return t").contains("holds itself"));
3832 let shared = read("local l = { 1, 2 }; return { a = l, b = l }").unwrap();
3833 assert_eq!(shared.get("a"), shared.get("b"));
3834 let nested = |n: usize| format!("local t = 1; for _ = 1, {n} do t = {{ t }} end; return t");
3835 assert!(read(&nested(MAX_VALUE_DEPTH)).is_ok());
3836 assert!(err(&nested(MAX_VALUE_DEPTH + 1)).contains("nested past 64"));
3837 assert!(err(&nested(100_000)).contains("nested past 64"));
3839 }
3840
3841 #[test]
3845 fn a_view_that_holds_itself_or_nests_too_deep_is_refused() {
3846 let run = |body: &str| {
3847 let mut core = Core::new();
3848 let mut ext =
3849 LuaExtension::from_source("deep", &format!("function view(env)\n{body}\nend"))
3850 .unwrap();
3851 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3852 ui.set_origin(OriginId(1));
3853 let built = ext.view(&Slot::root(), &mut ui);
3854 ui.finish();
3855 built
3856 };
3857 let err = run("local t = column {}; t[1] = row { t }; return t").unwrap_err();
3858 assert!(err.contains("holds itself"), "{err}");
3859 let nested = |n: usize| {
3860 format!(
3861 "local t = text('x')
3862 for i = 2, {n} do
3863 if i % 2 == 0 then t = radio_group {{ label = 'g', t }}
3864 elseif i % 3 == 0 then t = tooltip {{ t }}
3865 else t = column {{ t }} end
3866 end
3867 return t"
3868 )
3869 };
3870 run(&nested(MAX_VIEW_DEPTH)).unwrap();
3871 let err = run(&nested(MAX_VIEW_DEPTH + 1)).unwrap_err();
3872 assert!(err.contains("nested past 128"), "{err}");
3873 run(&nested(8)).unwrap();
3875 }
3876
3877 #[test]
3881 fn option_as_alt_none_leaves_the_hosts_side() {
3882 let mut core = Core::new();
3883 let mut ext = LuaExtension::from_source(
3884 "keys",
3885 r#"
3886 function view(env)
3887 return column { option_as_alt = "none", text("x") }
3888 end
3889 "#,
3890 )
3891 .unwrap();
3892 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3893 ui.option_as_alt(kui_core::OptionAsAlt::Left);
3894 ui.set_origin(OriginId(1));
3895 ext.view(&Slot::root(), &mut ui).unwrap();
3896 ui.finish();
3897 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
3898 }
3899
3900 #[test]
3902 fn every_node_type_lowers() {
3903 let mut ext = LuaExtension::from_source(
3904 "all",
3905 r##"
3906 function view(env)
3907 return column { gap = 4, window_title = "all nodes", always_on_top = true,
3908 secure_input = true, option_as_alt = "right", ime_off = true,
3909 titlebar { text("custom title"), window_buttons() },
3910 titlebar { title = "plain title" },
3911 text({ "same IR as ", { "Rust", bold = true, color = "#73d98c" },
3912 { " — flatter", italic = true } }, { size = 13 }),
3913 edit { key = "note", initial = "hello", size = 14, width = 200,
3914 multiline = true },
3915 input { label = "name", initial = "" },
3916 dropdown { label = "language", options = { "English", "Deutsch" }, current = 1 },
3917 row { tooltip = "hover hint", pad = 4, text("badge") },
3918 row { hoverable = true, text("legend"), tooltip("always shown") },
3919 row { text("rich tip"), tooltip { text("a"), text("b") } },
3920 latency_graph(),
3921 latency_hud { at = { "start", "end" } },
3922 button { label = "ok", on_click = "ok" },
3923 }
3924 end
3925 "##,
3926 )
3927 .unwrap();
3928 let mut core = Core::new();
3929 let quads = frame(&mut core, &mut ext);
3930 assert!(quads > 60, "got {quads} quads");
3931 assert_eq!(core.window_title(), Some("all nodes"));
3932 assert!(core.always_on_top());
3933 assert!(core.secure_input());
3934 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Right);
3935 assert!(core.ime_off());
3936 let unknown: Vec<String> = core
3940 .take_warnings()
3941 .into_iter()
3942 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
3943 .map(|w| w.message)
3944 .collect();
3945 assert!(unknown.is_empty(), "{unknown:#?}");
3946 }
3947
3948 #[test]
3951 fn unknown_props_warn_once_in_lua_spelling() {
3952 let mut ext = LuaExtension::from_source(
3953 "typos",
3954 r#"
3955 function view(env)
3956 return column { pad = 8,
3957 row { hoverBg = 0x333333ff, width = 10, height = 10 },
3958 row { hoverBg = 0x333333ff, width = 10, height = 10 },
3959 row { colour = 0x333333ff, width = 10, height = 10 },
3960 }
3961 end
3962 "#,
3963 )
3964 .unwrap();
3965 let mut core = Core::new();
3966 frame(&mut core, &mut ext);
3967 let mut warned: Vec<String> = core
3968 .take_warnings()
3969 .into_iter()
3970 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
3971 .map(|w| w.message)
3972 .collect();
3973 warned.sort();
3974 assert_eq!(warned.len(), 2, "one per name, not per node: {warned:#?}");
3975 assert!(
3976 warned[1].contains("`hoverBg` is not a prop of box")
3977 && warned[1].contains("did you mean `hover_bg`?"),
3978 "{warned:#?}"
3979 );
3980 assert!(warned[0].contains("`colour`") && !warned[0].contains("did you mean"));
3982 frame(&mut core, &mut ext);
3984 assert!(core.take_warnings().is_empty());
3985 }
3986
3987 #[test]
3991 fn a_text_warns_about_the_rows_it_does_not_read() {
3992 let mut ext = LuaExtension::from_source(
3993 "textrows",
3994 r#"
3995 function view(env)
3996 return column { pad = 8,
3997 text("hi", { live = "polite", label = "x", on_click = "go", size = 14, line_height = 20, max_lines = 2 }),
3998 text({ "a", { "b", bold = true, bg = 0x00ff00ff } }, { color = 0xff0000ff }),
3999 }
4000 end
4001 "#,
4002 )
4003 .unwrap();
4004 let mut core = Core::new();
4005 frame(&mut core, &mut ext);
4006 let mut warned: Vec<String> = core
4007 .take_warnings()
4008 .into_iter()
4009 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4010 .map(|w| w.message)
4011 .collect();
4012 warned.sort();
4013 assert_eq!(warned.len(), 3, "{warned:#?}");
4014 for (w, name) in warned.iter().zip(["label", "live", "on_click"]) {
4015 assert!(
4016 w.contains(&format!("`{name}`")) && w.contains("not one text reads"),
4017 "{w}"
4018 );
4019 assert!(w.contains("`max_lines`"), "names the rows it reads: {w}");
4020 }
4021 }
4022
4023 #[test]
4026 fn the_repeat_alias_does_not_warn() {
4027 let mut ext = LuaExtension::from_source(
4028 "alias",
4029 r#"
4030 function view(env)
4031 return column {
4032 row { width = 10, height = 10, keyframes = { { bg = 0x000000ff } },
4033 transition = 100, direction = "alternate" },
4034 }
4035 end
4036 "#,
4037 )
4038 .unwrap();
4039 let mut core = Core::new();
4040 frame(&mut core, &mut ext);
4041 assert!(core.take_warnings().is_empty());
4042 }
4043
4044 #[test]
4046 fn unknown_props_stay_quiet_with_diagnostics_off() {
4047 let mut ext = LuaExtension::from_source(
4048 "quiet",
4049 r#"
4050 function view(env)
4051 return column { hoverBg = 0x333333ff }
4052 end
4053 "#,
4054 )
4055 .unwrap();
4056 let mut core = Core::new();
4057 core.set_diagnostics(false);
4058 frame(&mut core, &mut ext);
4059 assert!(core.take_warnings().is_empty());
4060 }
4061
4062 #[test]
4065 fn tooltip_prop_is_hover_gated() {
4066 let mut ext = LuaExtension::from_source(
4067 "tip",
4068 r#"
4069 function view(env)
4070 return column { pad = 10,
4071 row { width = 100, height = 40, bg = 0x333333ff, tooltip = "a long hint" },
4072 }
4073 end
4074 "#,
4075 )
4076 .unwrap();
4077 let mut core = Core::new();
4078 let idle = frame(&mut core, &mut ext);
4079 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
4080 let hovered = frame(&mut core, &mut ext);
4081 assert!(
4082 hovered > idle + 5,
4083 "hover should add the tooltip's quads ({idle} -> {hovered})"
4084 );
4085 core.handle_input(InputEvent::CursorLeft);
4086 assert_eq!(frame(&mut core, &mut ext), idle);
4087 }
4088
4089 #[test]
4095 fn the_stock_button_admits_the_access_rows() {
4096 let mut ext = LuaExtension::from_source(
4097 "button",
4098 r#"
4099 function view(env)
4100 return column { pad = 10, gap = 4,
4101 button { label = "go", on_click = "go", description = "Starts the run" },
4102 button { label = "Stop the run", text = "stop", on_click = "stop",
4103 disabled = true, tooltip = "Nothing is running" },
4104 button { label = "x", on_click = "x", radius = 12, hoverBg = 0x333333ff },
4105 }
4106 end
4107 "#,
4108 )
4109 .unwrap();
4110 let mut core = Core::new();
4111 frame(&mut core, &mut ext);
4112 let tree = core.access_tree().clone();
4113 let named = |n: &str| {
4114 tree.nodes
4115 .iter()
4116 .find(|node| node.name.as_deref() == Some(n))
4117 };
4118 let go = named("go").expect("the button, named by its label");
4119 assert_eq!(go.role, kui_core::Role::Button);
4120 assert_eq!(go.description.as_deref(), Some("Starts the run"));
4121 assert!(!go.disabled);
4122 let stop = named("Stop the run").expect("named past its text");
4123 assert_eq!(stop.description.as_deref(), Some("Nothing is running"));
4124 assert!(stop.disabled);
4125 assert_eq!(tree.nodes.len(), 4, "window and three buttons");
4126 let ws = core.take_warnings();
4127 assert_eq!(ws.len(), 2, "{ws:?}");
4128 let radius = ws
4129 .iter()
4130 .find(|w| w.message.contains("`radius`"))
4131 .expect("a row the button does not read");
4132 assert!(
4133 radius
4134 .message
4135 .contains("is a prop, but not one button reads")
4136 );
4137 assert!(radius.message.contains("`description`"));
4138 let hover = ws
4141 .iter()
4142 .find(|w| w.message.contains("hoverBg"))
4143 .expect("a misspelling");
4144 assert!(hover.message.contains("is not a prop of button"));
4145 assert!(!hover.message.contains("did you mean"));
4146 }
4147
4148 #[test]
4153 fn an_accent_button_takes_the_colour_the_host_pushed() {
4154 let mut ext = LuaExtension::from_source(
4155 "accent",
4156 r#"
4157 function view(env)
4158 return column { pad = 10,
4159 button { label = "go", on_click = "go", accent = true },
4160 }
4161 end
4162 "#,
4163 )
4164 .unwrap();
4165 let mut bg = |core: &mut Core| {
4166 frame(core, &mut ext);
4167 core.output().0.quads[0].color
4168 };
4169 let mut core = Core::new();
4170 assert_eq!(
4171 bg(&mut core),
4172 kui_core::Color::rgb8(0x3b, 0x5b, 0xd4),
4173 "no accent pushed, the stock button"
4174 );
4175 assert!(
4176 core.take_warnings().is_empty(),
4177 "and a row the button reads"
4178 );
4179
4180 let accent = kui_core::Color::hex(0x007affff);
4181 core.env.system.accent = Some(accent);
4182 assert_eq!(bg(&mut core), accent);
4183 }
4184
4185 #[test]
4189 fn semantics_reach_the_access_tree() {
4190 let mut ext = LuaExtension::from_source(
4191 "a11y",
4192 r#"
4193 function view(env)
4194 return column { pad = 10,
4195 row { key = "save", on_click = "save", label = "Save", width = 20, height = 20 },
4196 row { key = "check", role = "checkbox", checked = true, text("Remember") },
4197 row { key = "vol", role = "slider", label = "Volume",
4198 value_now = 3, value_min = 0, value_max = 10 },
4199 row { key = "art", role = "none", on_click = "art", text("Art") },
4200 row { key = "tip", tooltip = "more here", on_click = "t", text("Tip") },
4201 row { key = "tabs", role = "tabList",
4202 row { key = "t0", role = "tab", text("General") },
4203 row { key = "t1", role = "tab", selected = true, text("Network") },
4204 },
4205 row { key = "adv", on_click = "adv", expanded = "collapsed",
4206 label = "Advanced" },
4207 }
4208 end
4209 "#,
4210 )
4211 .unwrap();
4212 let mut core = Core::new();
4213 frame(&mut core, &mut ext);
4214 let tree = core.access_tree().clone();
4215 let named = |n: &str| {
4216 tree.nodes
4217 .iter()
4218 .find(|node| node.name.as_deref() == Some(n))
4219 .cloned()
4220 };
4221 let save = named("Save").expect("labelled button");
4222 assert_eq!(save.role, kui_core::Role::Button);
4223 assert_eq!(save.origin, OriginId(1));
4224 let check = named("Remember").expect("checkbox named by its text");
4225 assert_eq!(check.role, kui_core::Role::Checkbox);
4226 assert_eq!(check.checked, Some(true));
4227 let vol = named("Volume").expect("slider");
4228 assert_eq!(
4229 (vol.number, vol.min, vol.max),
4230 (Some(3.0), Some(0.0), Some(10.0))
4231 );
4232 assert!(named("Art").is_none(), "role none hides a would-be button");
4233 let tip = named("Tip").expect("button");
4234 assert_eq!(tip.description.as_deref(), Some("more here"));
4235 let (t0, t1) = (named("General").unwrap(), named("Network").unwrap());
4238 assert_eq!((t0.selected, t1.selected), (Some(false), Some(true)));
4239 assert_eq!((t0.pos_in_set, t1.pos_in_set), (Some(0), Some(1)));
4240 let tabs = tree
4241 .nodes
4242 .iter()
4243 .find(|n| n.role == kui_core::Role::TabList)
4244 .expect("tab list");
4245 assert_eq!(tabs.set_size, Some(2));
4246 let adv = named("Advanced").expect("disclosure");
4248 assert_eq!(adv.expanded, Some(false));
4249
4250 let evs = core.handle_input(InputEvent::Access(kui_core::AccessRequest {
4251 key: save.key,
4252 action: kui_core::AccessAction::Click,
4253 value: None,
4254 anchor: None,
4255 focus: None,
4256 }));
4257 assert_eq!(evs.len(), 1);
4258 assert_eq!(evs[0].origin, OriginId(1));
4259 assert_eq!(evs[0].payload.as_str(), Some("save"));
4260 }
4261
4262 #[test]
4266 fn a_custom_editor_in_lua_reaches_the_access_tree() {
4267 let mut ext = LuaExtension::from_source(
4268 "ed",
4269 r#"
4270 function view(env)
4271 return column { key = "ed", role = "multilineTextInput", label = "Doc",
4272 on_key = "keys",
4273 row { role = "line", selection_anchor = 1, text("ab"), text("cd") },
4274 row { role = "line", caret = 2, text("ef") },
4275 }
4276 end
4277 "#,
4278 )
4279 .unwrap();
4280 let mut core = Core::new();
4281 frame(&mut core, &mut ext);
4282 let tree = core.access_tree().clone();
4283 let ed = tree
4284 .nodes
4285 .iter()
4286 .find(|n| n.name.as_deref() == Some("Doc"))
4287 .expect("the sink is the editor")
4288 .clone();
4289 assert_eq!(ed.role, kui_core::Role::MultilineTextInput);
4290 assert_eq!(ed.value.as_deref(), Some("abcd\nef"));
4291 assert_eq!(ed.runs.len(), 3);
4292 assert_eq!(ed.runs[1].text, "cd\n");
4293 assert_eq!(ed.caret, Some(7));
4294 assert_eq!(ed.selection, Some((1, 7)));
4295 let (a, f) = (ed.anchor.unwrap(), ed.focus.unwrap());
4296 assert_eq!((a.run, a.character), (ed.runs[0].key, 1));
4297 assert_eq!((f.run, f.character), (ed.runs[2].key, 2));
4298
4299 let evs = core.handle_input(InputEvent::Access(
4300 kui_core::AccessRequest::new(ed.key, kui_core::AccessAction::SetTextSelection)
4301 .with_selection(
4302 kui_core::TextPos {
4303 run: ed.runs[1].key,
4304 character: 1,
4305 },
4306 kui_core::TextPos {
4307 run: ed.runs[2].key,
4308 character: 0,
4309 },
4310 ),
4311 ));
4312 assert_eq!(evs.len(), 1);
4313 assert_eq!(evs[0].origin, OriginId(1));
4314 let p = &evs[0].payload;
4315 assert_eq!(p.get_str("action"), Some("setTextSelection"));
4316 let at = |k: &str, f: &str| p.get(k).and_then(|v| v.get(f)).and_then(Value::as_int);
4317 assert_eq!(
4318 (at("anchor", "line"), at("anchor", "offset")),
4319 (Some(0), Some(3))
4320 );
4321 assert_eq!(
4322 (at("focus", "line"), at("focus", "offset")),
4323 (Some(1), Some(0))
4324 );
4325 assert_eq!(p.get_str("tag"), Some("keys"));
4326 }
4327
4328 #[test]
4331 fn edit_text_round_trips_through_events() {
4332 let mut ext = LuaExtension::from_source(
4333 "edit",
4334 r#"
4335 pending = nil
4336 seen = nil
4337 by_label = nil
4338 window = nil
4339 function view(env)
4340 if pending then seen = env.edit_text(pending) end
4341 -- AR26: by the label its `key` declares, like every
4342 -- query beside it; a label nothing declared is nil.
4343 by_label = env.edit_text("note")
4344 nothing = env.edit_text("nope")
4345 return column {
4346 edit { key = "note", initial = "hi", autofocus = true, width = 200 },
4347 }
4348 end
4349 function on_event(ev)
4350 if ev.kind == "changed" then pending = ev.node_key; window = ev.window end
4351 end
4352 "#,
4353 )
4354 .unwrap();
4355 let mut core = Core::new();
4356 frame(&mut core, &mut ext);
4357 let events = core.handle_input(InputEvent::Text("!".into()));
4358 assert_eq!(
4359 events.len(),
4360 1,
4361 "typing into the autofocused editor emits one event"
4362 );
4363 assert_eq!(events[0].kind(), Some("changed"));
4364 for ev in &events {
4365 ext.on_event(ev);
4366 }
4367 frame(&mut core, &mut ext);
4368 let seen: Option<String> = ext.lua.globals().get("seen").unwrap();
4370 assert_eq!(seen.as_deref(), Some("hi!"));
4371 let by_label: Option<String> = ext.lua.globals().get("by_label").unwrap();
4372 assert_eq!(by_label.as_deref(), Some("hi!"), "the same text by label");
4373 let nothing: Option<String> = ext.lua.globals().get("nothing").unwrap();
4374 assert_eq!(nothing, None);
4375 let window: Option<i64> = ext.lua.globals().get("window").unwrap();
4378 assert_eq!(window, Some(0));
4379 }
4380
4381 #[test]
4386 fn a_dropdown_opens_the_cores_menu_and_hears_the_choice() {
4387 let mut ext = LuaExtension::from_source(
4388 "dd",
4389 r#"
4390 current = 1
4391 heard = {}
4392 function view(env)
4393 return column { pad = 10,
4394 dropdown { label = "language",
4395 options = { "English", "Deutsch", { label = "Latin", id = "la" } },
4396 current = current },
4397 }
4398 end
4399 function on_event(ev)
4400 heard[#heard + 1] = ev.kind
4401 if ev.kind == "menu" then
4402 if ev.item == "Deutsch" then current = 2 end
4403 if ev.item == "la" then current = 3 end
4404 end
4405 end
4406 "#,
4407 )
4408 .unwrap();
4409 let mut core = Core::new();
4410 frame(&mut core, &mut ext);
4411 let field = core
4412 .key_of("language")
4413 .expect("the field is keyed by its label");
4414 let node = |core: &mut Core| {
4415 core.access_tree()
4416 .nodes
4417 .iter()
4418 .find(|n| n.key == field)
4419 .cloned()
4420 .unwrap()
4421 };
4422 assert_eq!(node(&mut core).description.as_deref(), Some("English"));
4423 let events = core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4424 field,
4425 kui_core::AccessAction::Click,
4426 )));
4427 assert!(
4428 events.is_empty(),
4429 "the field's click is the core's: {events:?}"
4430 );
4431 let menu = core.menu().expect("the menu opened");
4432 assert_eq!(menu.target, field);
4433 assert_eq!(menu.items.len(), 3);
4434 assert!(menu.items[0].checked);
4435 frame(&mut core, &mut ext);
4436 let events = core.activate_menu_item(2).expect("the row is enabled");
4438 assert_eq!(events.len(), 1);
4439 for ev in &events {
4440 ext.on_event(ev);
4441 }
4442 frame(&mut core, &mut ext);
4443 assert_eq!(node(&mut core).description.as_deref(), Some("Latin"));
4444 let heard: Vec<String> = ext
4445 .lua
4446 .globals()
4447 .get::<Table>("heard")
4448 .unwrap()
4449 .sequence_values()
4450 .collect::<mlua::Result<_>>()
4451 .unwrap();
4452 assert_eq!(heard, vec!["menu".to_string()]);
4453 assert!(core.menu().is_none());
4454 let mut ext = LuaExtension::from_source(
4456 "bad",
4457 r#"function view(env) return dropdown { label = "x" } 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("needs options"), "{err}");
4464 ui.finish();
4465 let mut ext = LuaExtension::from_source(
4466 "bad",
4467 r#"function view(env) return dropdown { label = "x", options = { "a" }, current = 0 } end"#,
4468 )
4469 .unwrap();
4470 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4471 ui.set_origin(OriginId(1));
4472 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4473 assert!(err.contains("index from 1"), "{err}");
4474 ui.finish();
4475 let refused = |core: &mut Core, src: &str| {
4478 let mut ext = LuaExtension::from_source("bad", src).unwrap();
4479 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4480 ui.set_origin(OriginId(1));
4481 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4482 ui.finish();
4483 err
4484 };
4485 let err = refused(
4486 &mut core,
4487 r#"function view(env) return dropdown { options = { "a" } } end"#,
4488 );
4489 assert!(err.contains("dropdown needs a label"), "{err}");
4490 let err = refused(
4491 &mut core,
4492 r#"function view(env) return dropdown { label = "x", options = {} } end"#,
4493 );
4494 assert!(err.contains("at least one option"), "{err}");
4495 }
4496
4497 #[test]
4503 fn a_dropdowns_bad_rows_and_current_are_warned_and_a_disabled_option_is_refused() {
4504 let mut ext = LuaExtension::from_source(
4505 "dd",
4506 r#"
4507 current = 4
4508 function view(env)
4509 return column { pad = 10,
4510 dropdown { label = "language",
4511 options = { "English", { role = "separator" },
4512 { label = "Latin", id = "la", disabled = true } },
4513 current = current },
4514 }
4515 end
4516 "#,
4517 )
4518 .unwrap();
4519 let mut core = Core::new();
4520 frame(&mut core, &mut ext);
4521 let field = core.key_of("language").unwrap();
4522 let node = |core: &mut Core| {
4523 core.access_tree()
4524 .nodes
4525 .iter()
4526 .find(|n| n.key == field)
4527 .cloned()
4528 .unwrap()
4529 };
4530 assert_eq!(node(&mut core).description.as_deref(), Some(""));
4531 let warned = core.take_warnings();
4532 let codes: Vec<&str> = warned.iter().map(|w| w.code).collect();
4533 assert_eq!(
4534 codes,
4535 [
4536 kui_core::diag::UNKNOWN_PROP,
4537 kui_core::diag::SELECT_CURRENT_IGNORED
4538 ],
4539 "{warned:?}"
4540 );
4541 assert!(
4542 warned[0]
4543 .message
4544 .contains("`disabled` is not a key of a menu item")
4545 && warned[0].message.contains("`enabled: false`"),
4546 "{}",
4547 warned[0].message
4548 );
4549 assert_eq!(warned[1].key, field);
4550 assert!(
4551 warned[1].message.contains("names option 3 counted from 0")
4552 && warned[1].message.contains("the field has 3 options"),
4553 "{}",
4554 warned[1].message
4555 );
4556 ext.lua.globals().set("current", 2).unwrap();
4558 let mut core = Core::new();
4559 frame(&mut core, &mut ext);
4560 let warned = core.take_warnings();
4561 assert!(
4562 warned
4563 .iter()
4564 .any(|w| w.code == kui_core::diag::SELECT_CURRENT_IGNORED
4565 && w.message
4566 .contains("option 1 counted from 0, which is a separator")),
4567 "{warned:?}"
4568 );
4569 let mut ext = LuaExtension::from_source(
4572 "dd",
4573 r#"
4574 function view(env)
4575 return column { pad = 10,
4576 dropdown { label = "language",
4577 options = { "English", { label = "Latin", id = "la", enabled = false } },
4578 current = 1 },
4579 }
4580 end
4581 "#,
4582 )
4583 .unwrap();
4584 let mut core = Core::new();
4585 frame(&mut core, &mut ext);
4586 let field = core.key_of("language").unwrap();
4587 core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4588 field,
4589 kui_core::AccessAction::Click,
4590 )));
4591 assert!(core.menu().is_some());
4592 assert_eq!(core.activate_menu_item(1), None, "refused");
4593 assert!(core.menu().is_some(), "the menu stays open");
4594 let events = core
4595 .activate_menu_item(0)
4596 .expect("the enabled row is taken");
4597 assert_eq!(events.len(), 1);
4598 assert!(core.menu().is_none());
4599 assert!(core.take_warnings().is_empty());
4600 }
4601
4602 #[test]
4605 fn a_grid_lines_its_rows_cells_up() {
4606 let mut ext = LuaExtension::from_source(
4607 "grid",
4608 r#"
4609 function view(env)
4610 return grid { key = "t", width = 300,
4611 row { key = "r1", width = "grow", gap = 8,
4612 text("ab", { size = 12 }),
4613 column { key = "b1", width = 10, height = 10, bg = 0xff0000ff },
4614 column { key = "c1", width = "grow", height = 10, bg = 0x00ff00ff },
4615 },
4616 row { key = "r2", width = "grow", gap = 8,
4617 text("abcdef", { size = 12 }),
4618 column { key = "b2", width = 50, height = 10, bg = 0xff0000ff },
4619 column { key = "c2", width = 20, height = 10, bg = 0x00ff00ff },
4620 },
4621 }
4622 end
4623 "#,
4624 )
4625 .unwrap();
4626 let mut core = Core::new();
4627 core.set_inspect(true);
4628 frame(&mut core, &mut ext);
4629 let rect = |core: &mut Core, label: &str| {
4630 let key = core.key_of(label).unwrap_or_else(|| panic!("{label}"));
4631 core.nodes()
4632 .iter()
4633 .find(|n| n.key == key)
4634 .map(|n| n.rect)
4635 .unwrap_or_else(|| panic!("{label}"))
4636 };
4637 let (b1, b2) = (rect(&mut core, "b1"), rect(&mut core, "b2"));
4638 let (c1, c2) = (rect(&mut core, "c1"), rect(&mut core, "c2"));
4639 assert_eq!(
4640 b1.x, b2.x,
4641 "the fixed column starts after the longest label"
4642 );
4643 assert_eq!(b1.w, 50.0, "the fixed column is its widest cell");
4644 assert_eq!(b2.w, 50.0);
4645 assert_eq!(c1.x, c2.x);
4646 assert_eq!(c1.w, c2.w, "the grow column is one width in both rows");
4647 assert_eq!(c1.x + c1.w, 300.0, "and it takes the rest");
4648 let t = core.key_of("t").unwrap();
4649 assert!(
4650 core.nodes().iter().any(|n| n.key == t && n.table),
4651 "the grid is a table"
4652 );
4653 assert!(core.take_warnings().is_empty());
4654 }
4655
4656 #[test]
4661 fn set_edit_text_by_label_seeds_the_editor_the_next_frame_declares() {
4662 let mut ext = LuaExtension::from_source(
4663 "edit",
4664 r#"
4665 frames = 0
4666 function view(env)
4667 frames = frames + 1
4668 if frames == 1 then return column {} end
4669 if frames == 2 then
4670 env.set_edit_text("note", "from the model")
4671 end
4672 return column {
4673 edit { key = "note", initial = "ignored", width = 200 },
4674 }
4675 end
4676 "#,
4677 )
4678 .unwrap();
4679 let mut core = Core::new();
4680 frame(&mut core, &mut ext);
4684 frame(&mut core, &mut ext);
4685 let key = core.key_of("note").expect("the view declared the editor");
4686 assert_eq!(core.edit_text(key).as_deref(), Some("from the model"));
4687 let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
4688 assert!(!codes.contains(&"edit-text-without-editor"), "{codes:?}");
4689 core.set_focus(Some(key));
4692 core.handle_input(InputEvent::Text("!".into()));
4693 frame(&mut core, &mut ext);
4694 assert_eq!(core.edit_text(key).as_deref(), Some("from the model!"));
4695 }
4696
4697 #[test]
4702 fn a_lua_key_sink_hears_press_and_release() {
4703 use kui_core::{KeyCode, KeyMods, KeyPress};
4704 let mut ext = LuaExtension::from_source(
4705 "game",
4706 r#"
4707 log = {}
4708 function view(env)
4709 return column { key = "world", on_key = "keys", key_up = true,
4710 key_focus = true, width = 400, height = 300 }
4711 end
4712 function on_event(ev)
4713 if ev.kind == "key" then
4714 log[#log + 1] = ev.phase .. ":" .. ev.code ..
4715 "@" .. ev.physical ..
4716 ":" .. tostring(ev.text) .. ":" .. tostring(ev.tag)
4717 end
4718 end
4719 "#,
4720 )
4721 .unwrap();
4722 let mut core = Core::new();
4723 frame(&mut core, &mut ext);
4724 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
4725 for e in core.handle_input(ev) {
4726 ext.on_event(&e);
4727 }
4728 };
4729 let w = || KeyPress::new(KeyCode::Char('w'), KeyMods::default()).with_text("w");
4730 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
4731 feed(&mut core, &mut ext, InputEvent::KeyUp(w()));
4732 let ru = || {
4737 KeyPress::from_layout(KeyCode::Char('ц'), KeyCode::Char('w'), KeyMods::default())
4738 .with_text("ц")
4739 };
4740 feed(&mut core, &mut ext, InputEvent::KeyDown(ru()));
4741 feed(&mut core, &mut ext, InputEvent::KeyUp(ru()));
4742 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
4744 core.set_focus(None);
4745 for e in core.take_pending_events() {
4746 ext.on_event(&e);
4747 }
4748 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
4749 assert_eq!(
4750 log,
4751 [
4752 "down:w@w:w:keys",
4753 "up:w@w:nil:keys",
4755 "down:w@w:ц:keys",
4758 "up:w@w:nil:keys",
4759 "down:w@w:w:keys",
4760 "up:w@w:nil:keys",
4761 ]
4762 );
4763 }
4764
4765 #[test]
4769 fn a_lua_editor_draws_its_caret_on_the_phase_the_host_sets() {
4770 let mut ext = LuaExtension::from_source(
4771 "ed",
4772 r#"
4773 function view(env)
4774 local caret = env.caret_visible and row { key = "caret", width = 2, height = 16 } or nil
4775 return column { key = "editor", on_key = "ed", key_focus = true,
4776 role = "multilineTextInput", label = "buf",
4777 row { role = "line", caret = 3, text("let value", { family = "mono", size = 14 }), caret },
4778 }
4779 end
4780 "#,
4781 )
4782 .unwrap();
4783 let mut core = Core::new();
4784 frame(&mut core, &mut ext);
4785 assert!(
4786 core.key_of("caret").is_some(),
4787 "the on phase draws the caret"
4788 );
4789 assert!(core.has_caret(), "the caret row is a caret to blink");
4790 core.set_caret_visible(false);
4791 frame(&mut core, &mut ext);
4792 assert!(core.key_of("caret").is_none(), "the off phase draws none");
4793 assert!(
4794 core.has_caret(),
4795 "and the row stays, so the clock stays armed"
4796 );
4797 core.set_caret_visible(true);
4798 frame(&mut core, &mut ext);
4799 assert!(core.key_of("caret").is_some());
4800 }
4801
4802 #[test]
4807 fn a_lua_editors_solid_caret_arms_no_clock() {
4808 let mut ext = LuaExtension::from_source(
4809 "ed",
4810 r#"
4811 function view(env)
4812 return column { key = "editor", on_key = "ed", key_focus = true,
4813 role = "multilineTextInput", label = "buf",
4814 row { role = "line", caret = 3, caret_solid = true,
4815 text("let value", { family = "mono", size = 14 }) },
4816 }
4817 end
4818 "#,
4819 )
4820 .unwrap();
4821 let mut core = Core::new();
4822 frame(&mut core, &mut ext);
4823 assert!(!core.has_caret(), "a solid caret is not a caret to blink");
4824 assert!(core.ime_rect().is_some(), "and still the IME's anchor");
4825 let editor = core.key_of("editor").unwrap();
4826 assert_eq!(core.access_tree().get(editor).unwrap().caret, Some(3));
4827 assert!(
4828 core.warnings_raised().is_empty(),
4829 "{:?}",
4830 core.warnings_raised()
4831 );
4832 }
4833
4834 #[test]
4840 fn a_lua_editor_yanks_pastes_and_hears_where_a_press_landed() {
4841 use kui_core::testing::{press, release};
4842 use kui_core::{KeyCode, KeyMods, KeyPress, MenuAction, Vec2};
4843 let mut ext = LuaExtension::from_source(
4844 "ed",
4845 r#"
4846 yank = nil
4847 paste = false
4848 log = {}
4849 function view(env)
4850 if yank then env.set_clipboard(yank, nil); yank = nil end
4851 if secret then env.set_clipboard_secret(secret); secret = nil end
4852 -- Asked on every view until the answer lands: the core
4853 -- takes one ask at a time (AR34), so this is one paste.
4854 if paste then env.request_paste() end
4855 return column { key = "editor", on_key = "ed", on_drag = "sel",
4856 key_focus = true, role = "multilineTextInput", label = "buf",
4857 width = 400, height = 300,
4858 row { role = "line", height = 20, text("hello world", { family = "mono", size = 14 }) },
4859 row { role = "line", height = 20, text("second", { family = "mono", size = 14 }) },
4860 }
4861 end
4862 function on_event(ev)
4863 if ev.kind == "key" and ev.code == "y" then yank = "hello world" end
4864 if ev.kind == "key" and ev.code == "s" then secret = "hunter2" end
4865 if ev.kind == "key" and ev.code == "p" then paste = true end
4866 if ev.kind == "text" then
4867 paste = false
4868 local marks = (ev.concealed and ":concealed" or "") .. (ev.transient and ":transient" or "")
4869 log[#log + 1] = "text:" .. ev.text .. marks
4870 end
4871 if ev.kind == "drag" then
4872 log[#log + 1] = ev.phase .. ":" .. ev.line .. ":" .. ev.byte .. ":" .. ev.clicks
4873 end
4874 end
4875 "#,
4876 )
4877 .unwrap();
4878 let mut core = Core::new();
4879 frame(&mut core, &mut ext);
4880 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
4881 for e in core.handle_input(ev) {
4882 ext.on_event(&e);
4883 }
4884 };
4885 let key = |c| KeyPress::new(KeyCode::Char(c), KeyMods::default());
4886 feed(&mut core, &mut ext, InputEvent::KeyDown(key('y')));
4887 frame(&mut core, &mut ext);
4888 assert_eq!(
4889 core.take_menu_actions(),
4890 vec![MenuAction::SetClipboard {
4891 text: "hello world".into(),
4892 html: None
4893 }]
4894 );
4895 feed(&mut core, &mut ext, InputEvent::KeyDown(key('s')));
4897 frame(&mut core, &mut ext);
4898 assert_eq!(
4899 core.take_menu_actions(),
4900 vec![MenuAction::SetClipboardSecret {
4901 text: "hunter2".into()
4902 }]
4903 );
4904 feed(&mut core, &mut ext, InputEvent::KeyDown(key('p')));
4905 frame(&mut core, &mut ext);
4906 frame(&mut core, &mut ext);
4907 assert_eq!(
4908 core.take_menu_actions(),
4909 vec![MenuAction::Paste],
4910 "two views asked, one paste queued"
4911 );
4912 assert!(core.awaiting_paste());
4913 feed(
4915 &mut core,
4916 &mut ext,
4917 InputEvent::Commit("from the clipboard".into()),
4918 );
4919 assert!(!core.awaiting_paste());
4920 frame(&mut core, &mut ext);
4921 assert!(
4922 core.take_menu_actions().is_empty(),
4923 "answered: the script stopped asking"
4924 );
4925 feed(
4927 &mut core,
4928 &mut ext,
4929 InputEvent::Paste {
4930 text: "s3cret".into(),
4931 marks: kui_core::ClipboardMarks::SECRET,
4932 },
4933 );
4934 for e in press(&mut core, Vec2::new(390.0, 30.0)) {
4936 ext.on_event(&e);
4937 }
4938 for e in release(&mut core) {
4939 ext.on_event(&e);
4940 }
4941 for e in core.handle_input(InputEvent::mouse_down(2)) {
4942 ext.on_event(&e);
4943 }
4944 for e in release(&mut core) {
4945 ext.on_event(&e);
4946 }
4947 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
4948 assert_eq!(
4949 log,
4950 [
4951 "text:from the clipboard",
4952 "text:s3cret:concealed:transient",
4953 "start:1:6:1",
4954 "end:1:6:1",
4955 "start:1:6:2",
4956 "end:1:6:2",
4957 ]
4958 );
4959 }
4960
4961 #[test]
4965 fn audio_nodes_drive_playback_commands() {
4966 use kui_core::AudioCommand;
4967 let mut ext = LuaExtension::from_source(
4968 "audio",
4969 r#"
4970 playing = true
4971 function view(env)
4972 local items = {}
4973 if playing then
4974 items[1] = audio { src = SOUND, loop = true, volume = 0.5,
4975 key = "music", tag = { kind = "music" } }
4976 end
4977 return column { table.unpack(items) }
4978 end
4979 "#,
4980 )
4981 .unwrap();
4982 let mut core = Core::new();
4983 let sound = core.add_sound(vec![0; 8]);
4984 ext.lua
4985 .globals()
4986 .set("SOUND", sound.to_ffi() as i64)
4987 .unwrap();
4988 frame(&mut core, &mut ext);
4989 let cmds = core.take_audio_commands();
4990 assert!(
4991 matches!(
4992 cmds.as_slice(),
4993 [AudioCommand::Play { sound: s, looped: true, volume, .. }]
4994 if *s == sound && *volume == 0.5
4995 ),
4996 "{cmds:?}"
4997 );
4998 frame(&mut core, &mut ext);
4999 assert!(
5000 core.take_audio_commands().is_empty(),
5001 "re-declaring is silent"
5002 );
5003 ext.lua.globals().set("playing", false).unwrap();
5004 frame(&mut core, &mut ext);
5005 assert!(matches!(
5006 core.take_audio_commands().as_slice(),
5007 [AudioCommand::Stop { .. }]
5008 ));
5009 }
5010
5011 #[test]
5015 fn measure_and_layout_events_reach_scripts() {
5016 let mut ext = LuaExtension::from_source(
5017 "measure",
5018 r#"
5019 seen = nil
5020 function view(env)
5021 local plain = env.measure_text("hello world", { size = 14 })
5022 local node = env.measure_text(text("hello world", { size = 14 }))
5023 local rich = env.measure_text({ "hello ", { "world", bold = true } }, { size = 14 })
5024 local narrow = env.measure_text("hello world", { size = 14 }, plain.width / 2)
5025 assert(plain.width > 0 and plain.lines == 1)
5026 assert(node.width == plain.width and node.height == plain.height)
5027 assert(rich.width > 0)
5028 assert(narrow.lines > 1 and narrow.width <= plain.width / 2 + 0.5)
5029 return column {
5030 row { key = "panel", width = plain.width, height = 20, on_layout = "panel" },
5031 }
5032 end
5033 function on_event(ev)
5034 if ev.kind == "layout" then seen = ev end
5035 end
5036 "#,
5037 )
5038 .unwrap();
5039 let mut core = Core::new();
5040 frame(&mut core, &mut ext);
5041 let evs = core.take_pending_events();
5042 assert_eq!(evs.len(), 1, "one layout event on first sight");
5043 for ev in &evs {
5044 ext.on_event(ev);
5045 }
5046 let seen: Table = ext.lua.globals().get("seen").unwrap();
5047 assert_eq!(seen.get::<String>("tag").unwrap(), "panel");
5048 assert_eq!(seen.get::<f64>("h").unwrap(), 20.0);
5049 assert!(seen.get::<f64>("w").unwrap() > 0.0);
5050 assert_eq!(seen.get::<i64>("node_key").unwrap(), evs[0].key.0 as i64);
5051 frame(&mut core, &mut ext);
5053 assert!(core.take_pending_events().is_empty());
5054 }
5055
5056 #[test]
5060 fn scripts_queue_window_size_and_focus_requests() {
5061 let mut ext = LuaExtension::from_source(
5062 "win",
5063 r#"
5064 function view(env)
5065 env.set_window_size(env.window.id, 640, 480)
5066 env.focus_window(env.window.id)
5067 return column { width = "grow", height = "grow" }
5068 end
5069 "#,
5070 )
5071 .unwrap();
5072 let mut core = Core::new();
5073 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5074 ui.set_origin(OriginId(1));
5075 ext.view(&Slot::root(), &mut ui).unwrap();
5076 ui.finish();
5077 assert_eq!(
5078 core.take_window_commands(),
5079 vec![
5080 kui_core::WindowCommand::SetSize {
5081 window: WindowId::MAIN,
5082 size: Size::new(640.0, 480.0),
5083 },
5084 kui_core::WindowCommand::Focus(WindowId::MAIN),
5085 ]
5086 );
5087 assert!(core.take_window_commands().is_empty());
5088 assert_eq!(core.viewport(), Size::new(400.0, 200.0));
5089 }
5090
5091 #[test]
5097 fn scripts_reveal_and_move_scroll_offsets() {
5098 let mut ext = LuaExtension::from_source(
5099 "scroll",
5100 r#"
5101 rows, want, jump, seen = 20, nil, nil, nil
5102 function view(env)
5103 if want then env.reveal(want) end
5104 if jump then env.set_scroll(jump[1], jump[2], jump[3]) end
5105 want, jump = nil, nil
5106 local list = { key = "list", width = "grow", height = "grow",
5107 scroll_y = true }
5108 for i = 0, rows - 1 do
5109 list[#list + 1] = row { key = "row" .. i, width = "grow",
5110 height = 30, bg = 0x282840ff }
5111 end
5112 seen = env.scroll_offset(list_key)
5113 return column(list)
5114 end
5115 "#,
5116 )
5117 .unwrap();
5118 let list = Key::ROOT.str("list");
5120 let row = |i: usize| list.str(&format!("row{i}"));
5121 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
5122 let mut core = Core::new();
5123 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5124 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5125 ui.set_origin(OriginId(1));
5126 ext.view(&Slot::root(), &mut ui).unwrap();
5127 ui.finish();
5128 };
5129
5130 frame(&mut core, &mut ext);
5131 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
5132
5133 ext.lua.globals().set("want", row(15).0 as i64).unwrap();
5136 frame(&mut core, &mut ext);
5137 let after = core.scroll_offset(list);
5138 assert!(after.y > 0.0, "reveal moved nothing: {after:?}");
5139 assert!(after.y <= 15.0 * 30.0, "scrolled past row 15: {after:?}");
5140 frame(&mut core, &mut ext);
5142 assert_eq!(core.scroll_offset(list), after);
5143
5144 let seen: Table = ext.lua.globals().get("seen").unwrap();
5146 assert_eq!(seen.get::<f32>("y").unwrap(), after.y);
5147 assert_eq!(seen.get::<f32>("x").unwrap(), 0.0);
5148
5149 let jump = |ext: &LuaExtension, y: f64| {
5151 let t = ext.lua.create_table().unwrap();
5152 t.set(1, list.0 as i64).unwrap();
5153 t.set(2, 0.0).unwrap();
5154 t.set(3, y).unwrap();
5155 ext.lua.globals().set("jump", t).unwrap();
5156 };
5157 jump(&ext, 1e9);
5158 frame(&mut core, &mut ext);
5159 assert_eq!(core.scroll_offset(list).y, 400.0);
5160 jump(&ext, -1e9);
5161 frame(&mut core, &mut ext);
5162 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
5163
5164 jump(&ext, 120.0);
5166 frame(&mut core, &mut ext);
5167 ext.lua
5168 .globals()
5169 .set("want", Key::ROOT.str("ghost").0 as i64)
5170 .unwrap();
5171 frame(&mut core, &mut ext);
5172 assert_eq!(core.scroll_offset(list).y, 120.0);
5173 frame(&mut core, &mut ext);
5174 assert_eq!(core.scroll_offset(list).y, 120.0);
5175 }
5176
5177 #[test]
5181 fn scripts_read_the_pressed_state() {
5182 let mut ext = LuaExtension::from_source(
5183 "press",
5184 r#"
5185 function view(env)
5186 pressed = env.is_pressed(btn_key)
5187 hovered = env.is_hovered(btn_key)
5188 return column { key = "root", pad = 10,
5189 row { key = "btn", width = 100, height = 40,
5190 bg = 0x333333ff, on_click = "hit" },
5191 }
5192 end
5193 "#,
5194 )
5195 .unwrap();
5196 let btn = Key::ROOT.str("root").str("btn");
5197 ext.lua.globals().set("btn_key", btn.0 as i64).unwrap();
5198 let mut core = Core::new();
5199 let pressed = |ext: &LuaExtension| ext.lua.globals().get::<bool>("pressed").unwrap();
5200 let hovered = |ext: &LuaExtension| ext.lua.globals().get::<bool>("hovered").unwrap();
5201
5202 frame(&mut core, &mut ext);
5203 assert!(!pressed(&ext), "idle");
5204
5205 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
5207 frame(&mut core, &mut ext);
5208 assert!(hovered(&ext) && !pressed(&ext), "hover is not press");
5209
5210 core.handle_input(InputEvent::MouseDown {
5211 button: kui_core::MouseButton::Primary,
5212 clicks: 1,
5213 });
5214 frame(&mut core, &mut ext);
5215 assert!(pressed(&ext), "held down");
5216
5217 core.handle_input(InputEvent::MouseUp {
5221 button: kui_core::MouseButton::Primary,
5222 });
5223 frame(&mut core, &mut ext);
5224 assert!(!pressed(&ext), "released");
5225 }
5226
5227 #[test]
5232 fn scripts_read_the_drop_target() {
5233 let mut ext = LuaExtension::from_source(
5234 "drop",
5235 r#"
5236 function view(env)
5237 over = env.is_drop_target(zone_key)
5238 target = env.drop_target()
5239 return column { key = "root", pad = 10,
5240 row { key = "zone", width = 100, height = 40,
5241 bg = 0x333333ff, drop_bg = 0x335533ff, on_drop = "files" },
5242 }
5243 end
5244 "#,
5245 )
5246 .unwrap();
5247 let zone = Key::ROOT.str("root").str("zone");
5248 ext.lua.globals().set("zone_key", zone.0 as i64).unwrap();
5249 let mut core = Core::new();
5250 let over = |ext: &LuaExtension| ext.lua.globals().get::<bool>("over").unwrap();
5251 let target = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("target").unwrap();
5252
5253 frame(&mut core, &mut ext);
5254 assert!(!over(&ext) && target(&ext).is_none(), "idle");
5255
5256 let paths = vec!["/drop/1.txt".to_string()];
5257 let evs = core.handle_input(InputEvent::DragFiles {
5258 paths: paths.clone(),
5259 at: Vec2::new(50.0, 30.0),
5260 });
5261 assert_eq!(evs[0].payload.get_str("tag"), Some("files"));
5262 frame(&mut core, &mut ext);
5263 assert!(over(&ext), "the files are over the zone");
5264 assert_eq!(target(&ext), Some(zone.0 as i64));
5265 let (dl, _) = core.output();
5266 let lit = dl
5267 .quads
5268 .iter()
5269 .find(|q| q.rect.w == 100.0)
5270 .map(|q| q.color)
5271 .expect("zone quad");
5272 assert!(
5273 (lit.g - 0x55 as f32 / 255.0).abs() < 0.01,
5274 "drop_bg painted"
5275 );
5276
5277 core.handle_input(InputEvent::DropFiles {
5278 paths,
5279 at: Vec2::new(50.0, 30.0),
5280 });
5281 frame(&mut core, &mut ext);
5282 assert!(
5283 !over(&ext) && target(&ext).is_none(),
5284 "a drop ends the hover"
5285 );
5286 }
5287
5288 #[test]
5299 fn the_env_table_is_the_documented_env_shape() {
5300 let mut ext = LuaExtension::from_source(
5301 "env",
5302 r#"
5303 function view(env)
5304 values, calls = {}, {}
5305 for k, v in pairs(env) do
5306 if type(v) == "function" then calls[#calls + 1] = k
5307 elseif type(v) == "table" then
5308 for wk in pairs(v) do values[#values + 1] = k .. "." .. wk end
5309 else values[#values + 1] = k end
5310 end
5311 return column { key = "root",
5312 row { key = "a", focusable = true, width = 50, height = 20 },
5313 column { key = "dock", focus_region = true,
5314 row { key = "d", focusable = true, width = 50, height = 20 },
5315 },
5316 }
5317 end
5318 "#,
5319 )
5320 .unwrap();
5321 let mut core = Core::new();
5322 core.env.refresh_hz = Some(60.0);
5327 core.env.system.accent = Some(kui_core::Color::hex(0x3b82f6ff));
5328 core.env.system.locale = kui_core::Locale::new("en-US");
5329 core.env.window.native_controls = Some(Rect::new(0.0, 0.0, 78.0, 28.0));
5330 frame(&mut core, &mut ext);
5331 core.set_focus(Some(Key::ROOT.str("root").str("dock").str("d")));
5332 frame(&mut core, &mut ext);
5333
5334 let sorted = |name: &str| -> Vec<String> {
5335 let mut v: Vec<String> = ext.lua.globals().get(name).unwrap();
5336 v.sort();
5337 v
5338 };
5339 let mut documented: Vec<String> = kui_core::schema::ENV_FIELDS
5340 .iter()
5341 .flat_map(|f| f.lua.iter().map(|k| (*k).to_string()))
5342 .collect();
5343 documented.extend(
5349 kui_core::schema::THEME_ROLES
5350 .iter()
5351 .map(|r| format!("theme.{}", r.name)),
5352 );
5353 documented.push("theme.appearance".into());
5354 documented.push("theme.disabled_opacity".into());
5355 documented.extend(
5358 kui_core::schema::METRIC_ROLES
5359 .iter()
5360 .map(|r| format!("metrics.{}", r.name)),
5361 );
5362 documented.push("tokens.colors".into());
5365 documented.push("tokens.lengths".into());
5366 documented.sort();
5367 assert_eq!(
5368 sorted("values"),
5369 documented,
5370 "env's value keys and schema::ENV_FIELDS + THEME_ROLES + METRIC_ROLES + tokens disagree"
5371 );
5372 let mut doors: Vec<String> = kui_core::schema::DOORS
5379 .iter()
5380 .filter_map(|d| match d.lua {
5381 kui_core::schema::Cell::Is(name) => Some(name.to_string()),
5382 _ => None,
5383 })
5384 .collect();
5385 doors.sort();
5386 assert_eq!(
5387 sorted("calls"),
5388 doors,
5389 "env's queries and verbs and schema::DOORS's Lua column disagree; update env_table's doc too"
5390 );
5391 }
5392
5393 #[test]
5398 fn a_script_declares_tokens_and_references_them_by_name() {
5399 let mut ext = LuaExtension::from_source(
5400 "tokens",
5401 r##"
5402 tokens = {
5403 colors = { peach = "#ffcc99", ink = { light = "#111111", dark = "#eeeeee" } },
5404 lengths = { side_w = 132, gap = 6 },
5405 }
5406 function view(env)
5407 seen = env.tokens
5408 return column { pad = "$gap", gap = "$gap",
5409 row { key = "a", width = "$side_w", height = 20, bg = "$peach",
5410 border = { w = "$gap", color = "$ink" }, radius = "$radius" },
5411 row { key = "b", width = 20, height = "$side_w", bg = "$surface",
5412 pad = { l = "$gap", r = 2 } },
5413 text({ "x", { "y", color = "$peach", bg = "$ink" } }, { size = "$gap", color = "$peach" }),
5414 }
5415 end
5416 "##,
5417 )
5418 .unwrap();
5419 let mut core = Core::new();
5420 frame(&mut core, &mut ext);
5421 assert!(core.take_warnings().is_empty());
5422 let peach = Color::hex(0xffcc99ff);
5423 let paper = Color::hex(0xeeeeeeff);
5424 let radius = core.metrics().radius;
5425 let surface = core.theme().surface;
5426 let dl = core.output().0;
5427 let a = dl
5428 .quads
5429 .iter()
5430 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
5431 .expect("the peach box");
5432 assert_eq!(a.rect.w, 132.0, "width from a length token");
5433 assert_eq!(a.border_w, 6.0);
5434 assert_eq!(
5435 a.border_color, paper,
5436 "the dark half on an unknown appearance"
5437 );
5438 assert_eq!(a.radius[0], radius, "$radius is the metric");
5439 let b = dl
5440 .quads
5441 .iter()
5442 .find(|q| q.color == surface && q.rect.h == 132.0)
5443 .expect("the $surface box, 132 tall");
5444 assert_eq!(b.rect.w, 20.0);
5445 let seen: Table = ext.lua.globals().get("seen").unwrap();
5446 let colors: Table = seen.get("colors").unwrap();
5447 let lengths: Table = seen.get("lengths").unwrap();
5448 assert_eq!(colors.get::<u32>("peach").unwrap(), 0xffcc99ff);
5449 assert_eq!(colors.get::<u32>("ink").unwrap(), 0xeeeeeeff);
5450 assert_eq!(lengths.get::<f32>("side_w").unwrap(), 132.0);
5451 core.set_system(kui_core::SystemEnv {
5453 appearance: kui_core::Appearance::Light,
5454 ..Default::default()
5455 });
5456 frame(&mut core, &mut ext);
5457 let a = core
5458 .output()
5459 .0
5460 .quads
5461 .iter()
5462 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
5463 .unwrap();
5464 assert_eq!(a.border_color, Color::hex(0x111111ff));
5465 }
5466
5467 #[test]
5473 fn a_script_derives_a_token_from_another() {
5474 let mut ext = LuaExtension::from_source(
5475 "derived",
5476 r##"
5477 tokens = {
5478 colors = {
5479 peach = "#ffcc99",
5480 z_lit = { from = "peach", ops = { "lift", 0.5 } },
5481 a_deep = { from = "z_lit", ops = { { "alpha", 0.5 }, { "mix", "peach", 0.0 } } },
5482 up = { from = "surface", ops = { { "raise", 0.25 } } },
5483 bad = { from = "nothing" },
5484 },
5485 }
5486 function view(env)
5487 seen = env.tokens.colors
5488 return column {
5489 row { key = "a", width = 20, height = 20, bg = "$a_deep" },
5490 row { key = "b", width = 20, height = 20, bg = "$bad" },
5491 }
5492 end
5493 "##,
5494 )
5495 .unwrap();
5496 let mut core = Core::new();
5497 frame(&mut core, &mut ext);
5498 let warnings = core.take_warnings();
5499 let codes: Vec<&str> = warnings.iter().map(|w| w.code).collect();
5500 assert_eq!(codes, ["unknown-token"], "{warnings:?}");
5501 assert!(
5502 warnings[0].message.contains("`$bad`") && warnings[0].message.contains("`$nothing`")
5503 );
5504 let seen: Table = ext.lua.globals().get("seen").unwrap();
5505 assert_eq!(seen.get::<u32>("z_lit").unwrap(), 0xffe6ccff);
5506 assert_eq!(seen.get::<u32>("a_deep").unwrap(), 0xffe6cc80);
5507 let surface = core.theme().surface;
5508 assert_eq!(
5509 seen.get::<u32>("up").unwrap(),
5510 surface.mix(Color::WHITE, 0.25).to_hex(),
5511 "a role source, raised toward the dark base's front"
5512 );
5513 assert!(seen.get::<mlua::Value>("bad").unwrap().is_nil());
5514 let dl = core.output().0;
5515 assert!(
5516 dl.quads.iter().any(|q| q.color.to_hex() == 0xffe6cc80),
5517 "$a_deep painted the derived colour"
5518 );
5519
5520 let refused = |colors: &str| {
5522 LuaExtension::from_source(
5523 "bad",
5524 &format!(
5525 "tokens = {{ colors = {{ {colors} }} }}\nfunction view() return column {{}} end"
5526 ),
5527 )
5528 .err()
5529 .map(|e| e.to_string())
5530 .unwrap_or_default()
5531 };
5532 assert!(
5533 refused(r#"x = { from = "peach", ops = { { "glow", 1 } } }"#)
5534 .contains("unknown verb \"glow\"")
5535 );
5536 assert!(
5537 refused(r#"x = { from = "peach", ops = { { "mix", 0.5 } } }"#)
5538 .contains("mix takes a colour and a number")
5539 );
5540 assert!(
5541 refused(r#"x = { from = "peach", ops = { { "lift", "lots" } } }"#)
5542 .contains("number is a number")
5543 );
5544 assert!(refused(r#"x = { from = 3 }"#).contains("from names a colour token or role"));
5545 assert!(refused(r#"x = { from = "peach", glow = 1 }"#).contains("unknown key \"glow\""));
5546 }
5547
5548 #[test]
5553 fn a_scripts_tokens_sit_over_the_hosts() {
5554 let mut ext = LuaExtension::from_source(
5555 "guest",
5556 r##"
5557 tokens = { colors = { grey = "#808080" } }
5558 function view(env)
5559 first = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
5560 if redeclare then
5561 env.set_tokens { colors = { peach = "#ffaa77" }, lengths = { w = 40 } }
5562 end
5563 second = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
5564 return column { row { key = "a", width = redeclare and "$w" or 10, height = 10, bg = "$peach" } }
5565 end
5566 "##,
5567 )
5568 .unwrap();
5569 let mut core = Core::new();
5570 core.set_tokens(kui_core::Tokens::new().color("peach", Color::hex(0xffcc99ff)));
5571 frame(&mut core, &mut ext);
5572 let first: Table = ext.lua.globals().get("first").unwrap();
5573 assert_eq!(
5574 first.get::<u32>("peach").unwrap(),
5575 0xffcc99ff,
5576 "the host's peach"
5577 );
5578 assert_eq!(
5579 first.get::<u32>("grey").unwrap(),
5580 0x808080ff,
5581 "its own grey"
5582 );
5583 assert!(
5584 core.token_lookup().color("grey").is_err(),
5585 "the guest's grey is not the host's"
5586 );
5587 assert!(core.take_warnings().is_empty());
5588
5589 ext.lua.globals().set("redeclare", true).unwrap();
5590 frame(&mut core, &mut ext);
5591 let second: Table = ext.lua.globals().get("second").unwrap();
5592 assert_eq!(
5593 second.get::<u32>("peach").unwrap(),
5594 0xffaa77ff,
5595 "its own peach now"
5596 );
5597 assert!(
5598 second.get::<Option<u32>>("grey").unwrap().is_none(),
5599 "replaced whole"
5600 );
5601 let a = core
5602 .output()
5603 .0
5604 .quads
5605 .iter()
5606 .find(|q| q.color == Color::hex(0xffaa77ff))
5607 .expect("painted the script's peach");
5608 assert_eq!(a.rect.w, 40.0);
5609 assert_eq!(
5610 core.token_lookup().color("peach"),
5611 Ok(Color::hex(0xffcc99ff)),
5612 "the host's table did not move"
5613 );
5614 }
5615
5616 #[test]
5619 fn a_missing_token_warns_and_paints_nothing() {
5620 let mut ext = LuaExtension::from_source(
5621 "missing",
5622 r##"
5623 tokens = { colors = { surface = "#ff0000", peach = "#ffcc99" }, lengths = { gap = 6 } }
5624 function view(env)
5625 return column {
5626 row { key = "a", width = "$gap", height = 10, bg = "$peech", pad = "$peach" },
5627 row { key = "b", width = "$peach", height = 10, bg = "$gap" },
5628 text("still here", { color = "$peech", size = "$gap" }),
5629 text("still here", { size = 6 }),
5630 text("still here"),
5631 }
5632 end
5633 "##,
5634 )
5635 .unwrap();
5636 let mut core = Core::new();
5637 core.set_inspect(true);
5638 frame(&mut core, &mut ext);
5639 frame(&mut core, &mut ext);
5640 let ws = core.take_warnings();
5641 let mut codes: Vec<&str> = ws.iter().map(|w| w.code).collect();
5642 codes.sort();
5643 assert_eq!(
5644 codes,
5645 [
5646 kui_core::diag::RESERVED_TOKEN,
5647 kui_core::diag::UNKNOWN_TOKEN,
5648 kui_core::diag::UNKNOWN_TOKEN,
5649 kui_core::diag::UNKNOWN_TOKEN,
5650 ],
5651 "surface refused once; peech, peach-as-length and gap-as-colour once each: {ws:?}"
5652 );
5653 assert!(
5654 ws.iter().any(|w| w
5655 .message
5656 .contains("`$gap` is a length token, and this slot takes a color")),
5657 "{ws:?}"
5658 );
5659 let fg = core.theme().fg;
5663 let dl = core.output().0;
5664 assert!(
5665 dl.quads
5666 .iter()
5667 .all(|q| q.kind != kui_core::QuadKind::Solid || q.color.a == 0.0)
5668 );
5669 let glyphs: Vec<_> = dl
5670 .quads
5671 .iter()
5672 .filter(|q| {
5673 matches!(
5674 q.kind,
5675 kui_core::QuadKind::GlyphMask | kui_core::QuadKind::GlyphSubpixel
5676 )
5677 })
5678 .collect();
5679 assert!(!glyphs.is_empty(), "the text painted");
5680 assert!(glyphs.iter().all(|q| q.color == fg), "in the foreground");
5681 let heights: Vec<f32> = core
5686 .nodes()
5687 .iter()
5688 .filter(|n| n.text.as_deref() == Some("still here"))
5689 .map(|n| n.rect.h)
5690 .collect();
5691 assert_eq!(heights.len(), 3, "{heights:?}");
5692 assert!(heights[0] > 0.0, "{heights:?}");
5693 assert_eq!(heights[0], heights[1], "$gap is the literal 6: {heights:?}");
5694 assert!(heights[2] > heights[0], "and not the default: {heights:?}");
5695 }
5696
5697 #[test]
5702 fn a_gradient_stop_that_misses_is_left_out() {
5703 let mut ext = LuaExtension::from_source(
5704 "gradient",
5705 r##"
5706 tokens = { colors = { peach = "#ffcc99" } }
5707 function view(env)
5708 return column {
5709 row { key = "three", width = 40, height = 10,
5710 gradient = { stops = { "$peach", "$peech", "#0000ff" } } },
5711 row { key = "two", width = 40, height = 10, bg = "#000000",
5712 gradient = { stops = { "$peach", "$peech" } } },
5713 }
5714 end
5715 "##,
5716 )
5717 .unwrap();
5718 let mut core = Core::new();
5719 frame(&mut core, &mut ext);
5720 frame(&mut core, &mut ext);
5721 let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
5722 assert_eq!(codes, [kui_core::diag::UNKNOWN_TOKEN], "once, for the name");
5723 let dl = core.output().0;
5724 let images: Vec<_> = dl
5725 .quads
5726 .iter()
5727 .filter(|q| q.kind == kui_core::QuadKind::Image)
5728 .collect();
5729 assert_eq!(images.len(), 1, "the three-stop row's two");
5730 assert!(
5731 images[0].rect.y < 10.0,
5732 "on the first row: {:?}",
5733 images[0].rect
5734 );
5735 let black = dl
5736 .quads
5737 .iter()
5738 .filter(|q| q.kind == kui_core::QuadKind::Solid && q.color == Color::hex(0x000000ff))
5739 .count();
5740 assert_eq!(black, 1, "the second row's bg");
5741 }
5742
5743 #[test]
5748 fn a_token_reaches_a_min_a_stroke_and_a_stop_and_misses_by_leaving_the_slot() {
5749 let mut ext = LuaExtension::from_source(
5750 "everywhere",
5751 r##"
5752 tokens = { colors = { peach = "#ffcc99" }, lengths = { gap = 6, wide = 40 } }
5753 function view(env)
5754 return column { pad = 4,
5755 row { key = "clamp", width = 10, height = 10, min_width = "$wide" },
5756 row { key = "typo", width = 10, height = 10, min_width = "$nope" },
5757 line { key = "stroke", from = { 0, 0 }, to = { 30, 0 }, width = "$gap", color = "$peach" },
5758 row { key = "anim", width = 10, height = 10, transition = 100,
5759 keyframes = { { bg = "$peach", width = "$wide" }, { bg = "$peech", radius = "$gap" } },
5760 enter = { width = "$nothing", bg = "$peach" } },
5761 }
5762 end
5763 "##,
5764 )
5765 .unwrap();
5766 let mut core = Core::new();
5767 core.set_inspect(true);
5768 frame(&mut core, &mut ext);
5769 let nodes = core.nodes();
5770 let by = |label: &str| {
5771 nodes
5772 .iter()
5773 .find(|n| n.label.as_deref() == Some(label))
5774 .unwrap()
5775 };
5776 assert_eq!(by("clamp").rect.w, 40.0, "the clamp is the token's 40 px");
5777 assert_eq!(
5778 by("typo").rect.w,
5779 10.0,
5780 "a miss leaves the row at its default"
5781 );
5782 let dl = core.output().0;
5783 let seg = dl
5784 .quads
5785 .iter()
5786 .find(|q| q.kind == kui_core::QuadKind::Segment)
5787 .expect("the line drew");
5788 assert_eq!(seg.color, Color::hex(0xffcc99ff));
5789 assert_eq!(seg.border_w, 6.0, "the stroke is the token's width");
5790 let ws = core.take_warnings();
5791 let mut names: Vec<String> = ws
5792 .iter()
5793 .filter(|w| w.code == kui_core::diag::UNKNOWN_TOKEN)
5794 .map(|w| w.message.clone())
5795 .collect();
5796 names.sort();
5797 assert_eq!(names.len(), 3, "nope, peech, nothing: {ws:?}");
5798 assert!(names.iter().any(|m| m.contains("`$nope`")), "{names:?}");
5799 assert!(names.iter().any(|m| m.contains("`$peech`")), "{names:?}");
5800 assert!(names.iter().any(|m| m.contains("`$nothing`")), "{names:?}");
5801 }
5802
5803 #[test]
5804 fn scripts_move_focus_with_the_verbs() {
5805 let mut ext = LuaExtension::from_source(
5806 "focus",
5807 r#"
5808 function view(env)
5809 if cmd == "set" then env.set_focus(target)
5810 elseif cmd == "blur" then env.blur()
5811 elseif cmd == "next" then env.focus_next()
5812 elseif cmd == "prev" then env.focus_prev() end
5813 cmd = nil
5814 -- `env.focus` is a value the host filled in before view()
5815 -- ran, so it lags a verb called above by a frame;
5816 -- `env.is_focused` is a call and answers about now.
5817 seen_focus = env.focus
5818 seen_live = env.is_focused(target or 0)
5819 seen_window = env.focused
5820 return column { key = "root", pad = 10,
5821 row { key = "a", focusable = true, width = 50, height = 20 },
5822 row { key = "b", focusable = true, width = 50, height = 20 },
5823 row { key = "c", focusable = true, width = 50, height = 20 },
5824 }
5825 end
5826 "#,
5827 )
5828 .unwrap();
5829 let root = Key::ROOT.str("root");
5830 let (a, b, c) = (root.str("a"), root.str("b"), root.str("c"));
5831 let mut core = Core::new();
5832 let cmd = |ext: &LuaExtension, c: &str| ext.lua.globals().set("cmd", c).unwrap();
5833 let seen = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("seen_focus").unwrap();
5834 let live = |ext: &LuaExtension| ext.lua.globals().get::<bool>("seen_live").unwrap();
5835
5836 frame(&mut core, &mut ext);
5838 assert_eq!(core.focus(), None);
5839 assert_eq!(seen(&ext), None, "nil for no focus, not 0");
5840
5841 ext.lua.globals().set("target", b.0 as i64).unwrap();
5843 cmd(&ext, "set");
5844 frame(&mut core, &mut ext);
5845 assert_eq!(core.focus(), Some(b));
5846 assert_eq!(seen(&ext), None, "the value lags a same-frame verb");
5850 assert!(live(&ext), "the query does not");
5851 frame(&mut core, &mut ext);
5852 assert_eq!(
5853 seen(&ext),
5854 Some(b.0 as i64),
5855 "and the next frame carries it"
5856 );
5857
5858 cmd(&ext, "next");
5860 frame(&mut core, &mut ext);
5861 assert_eq!(core.focus(), Some(c));
5862 cmd(&ext, "next");
5863 frame(&mut core, &mut ext);
5864 assert_eq!(core.focus(), Some(a), "wraps");
5865 cmd(&ext, "prev");
5866 frame(&mut core, &mut ext);
5867 assert_eq!(core.focus(), Some(c), "wraps back");
5868
5869 cmd(&ext, "blur");
5870 frame(&mut core, &mut ext);
5871 assert_eq!(core.focus(), None);
5872 frame(&mut core, &mut ext);
5873 assert_eq!(seen(&ext), None);
5874
5875 ext.lua.globals().set("target", "c").unwrap();
5878 cmd(&ext, "set");
5879 frame(&mut core, &mut ext);
5880 assert_eq!(core.focus(), Some(c), "set_focus(\"c\") resolves the label");
5881 assert!(live(&ext), "and is_focused(\"c\") answers about it");
5882 assert!(core.take_warnings().is_empty());
5883 ext.lua.globals().set("target", "nope").unwrap();
5885 cmd(&ext, "set");
5886 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
5887 let e = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
5888 ui.finish();
5889 assert!(
5890 e.contains("no node is keyed \"nope\"") && e.contains("integer key"),
5891 "{e}"
5892 );
5893
5894 assert!(ext.lua.globals().get::<bool>("seen_window").unwrap());
5897 }
5898
5899 #[test]
5902 fn a_shared_label_resolves_to_the_first_and_warns() {
5903 let mut ext = LuaExtension::from_source(
5904 "dup",
5905 r#"
5906 function view(env)
5907 if go then env.set_focus("item"); go = nil end
5908 return column { key = "root",
5909 row { row { key = "item", focusable = true, width = 50, height = 20 } },
5910 row { row { key = "item", focusable = true, width = 50, height = 20 } },
5911 }
5912 end
5913 "#,
5914 )
5915 .unwrap();
5916 let mut core = Core::new();
5917 frame(&mut core, &mut ext);
5918 ext.lua.globals().set("go", true).unwrap();
5919 frame(&mut core, &mut ext);
5920 assert_eq!(
5921 core.focus(),
5922 Some(Key::ROOT.str("root").index(0).str("item")),
5923 "the first, under the auto-keyed first row"
5924 );
5925 let ws = core.take_warnings();
5926 assert_eq!(ws.len(), 1, "{ws:?}");
5927 assert_eq!(ws[0].code, kui_core::diag::AMBIGUOUS_KEY);
5928 assert!(ws[0].message.contains("\"item\""), "{}", ws[0].message);
5929 }
5930
5931 #[test]
5934 fn scripts_open_a_menu_and_hear_the_row() {
5935 let mut ext = LuaExtension::from_source(
5936 "menu",
5937 r#"
5938 frames = 0
5939 function view(env)
5940 frames = frames + 1
5941 if frames == 2 then
5942 opened = env.open_menu("card", 40, 30, {
5943 { role = "copy" },
5944 { role = "separator" },
5945 -- The wire spelling the event reports, and the
5946 -- snake one a script may have learned first.
5947 { role = "selectAll" },
5948 { role = "look_up" },
5949 { label = "Wrap", checked = true },
5950 { label = "Inspect", id = "inspect" },
5951 })
5952 end
5953 return column {
5954 column { key = "card", selectable = true, text("one", { size = 14 }) },
5955 }
5956 end
5957 "#,
5958 )
5959 .unwrap();
5960 let mut core = Core::new();
5961 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5962 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5963 ui.set_origin(OriginId(1));
5964 ext.view(&Slot::root(), &mut ui).unwrap();
5965 ui.finish();
5966 };
5967 frame(&mut core, &mut ext);
5968 frame(&mut core, &mut ext);
5969 assert!(ext.lua.globals().get::<bool>("opened").unwrap());
5970 let roles: Vec<_> = core
5971 .menu()
5972 .expect("open")
5973 .items
5974 .iter()
5975 .map(|i| (i.role, i.checked))
5976 .collect();
5977 assert_eq!(
5978 roles,
5979 [
5980 (kui_core::MenuRole::Copy, false),
5981 (kui_core::MenuRole::Separator, false),
5982 (kui_core::MenuRole::SelectAll, false),
5983 (kui_core::MenuRole::LookUp, false),
5984 (kui_core::MenuRole::Custom, true),
5985 (kui_core::MenuRole::Custom, false),
5986 ]
5987 );
5988 frame(&mut core, &mut ext);
5989 let tree = core.access_tree();
5992 assert_eq!(
5993 tree.nodes
5994 .iter()
5995 .find(|n| n.name.as_deref() == Some("Wrap"))
5996 .expect("the checked row")
5997 .checked,
5998 Some(true)
5999 );
6000 let row = tree
6001 .nodes
6002 .iter()
6003 .find(|n| n.name.as_deref() == Some("Inspect"))
6004 .expect("the menu drew")
6005 .rect;
6006 let at = kui_core::Vec2::new(row.x + row.w / 2.0, row.y + row.h / 2.0);
6007 core.handle_input(InputEvent::CursorMoved(at));
6008 core.handle_input(InputEvent::mouse_down(1));
6009 let events = core.handle_input(InputEvent::mouse_up());
6010 assert_eq!(events.len(), 1, "{events:?}");
6011 assert_eq!(events[0].payload.get_str("item"), Some("inspect"));
6012 assert_eq!(
6013 events[0].origin,
6014 OriginId(1),
6015 "posted with the origin that asked for the menu"
6016 );
6017 }
6018
6019 #[test]
6022 fn scripts_select_and_read_a_scope() {
6023 let mut ext = LuaExtension::from_source(
6024 "sel",
6025 r#"
6026 frames = 0
6027 function view(env)
6028 frames = frames + 1
6029 if frames > 1 then
6030 before = env.selection_text()
6031 took = env.select_all_in("card")
6032 text_out = env.selection_text()
6033 not_a_scope = env.select_all_in("plain")
6034 end
6035 return column {
6036 column { key = "card", selectable = true,
6037 text("one", { size = 14 }),
6038 text("two", { size = 14 }) },
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 frame(&mut core, &mut ext);
6054 let g = ext.lua.globals();
6055 assert!(matches!(
6056 g.get::<mlua::Value>("before").unwrap(),
6057 mlua::Value::Nil
6058 ));
6059 assert!(g.get::<bool>("took").unwrap());
6060 assert_eq!(g.get::<String>("text_out").unwrap(), "one\ntwo");
6061 assert!(
6062 !g.get::<bool>("not_a_scope").unwrap(),
6063 "a node that drew no text is not a scope"
6064 );
6065 }
6066
6067 #[test]
6072 fn scripts_map_points_to_bytes_on_a_line_of_runs() {
6073 let mut ext = LuaExtension::from_source(
6074 "hit",
6075 r#"
6076 frames = 0
6077 function view(env)
6078 local mono = { size = 14, family = "mono" }
6079 local w = env.measure_text("M", mono, 0).width
6080 frames = frames + 1
6081 -- A label nothing has declared yet is an error by name
6082 -- (F5), so the first frame declares and the next asks.
6083 if frames > 1 then
6084 hit = env.text_hit("line", 7.2 * w, 5)
6085 seam = env.caret_rect("line", 4)
6086 far = env.text_hit("line", 390, 5)
6087 none = env.caret_rect("plain", 0)
6088 end
6089 cell = w
6090 return column {
6091 row { key = "line",
6092 text("let ", mono),
6093 row { bg = 0x3b5bd455, text("value", mono) },
6094 text(" = 1;", mono) },
6095 row { key = "plain", width = 10, height = 10 },
6096 }
6097 end
6098 "#,
6099 )
6100 .unwrap();
6101 let mut core = Core::new();
6102 let frame = |core: &mut Core, ext: &mut LuaExtension| {
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 };
6108 frame(&mut core, &mut ext);
6109 let hit: mlua::Value = ext.lua.globals().get("hit").unwrap();
6110 assert!(
6111 matches!(hit, mlua::Value::Nil),
6112 "nothing drawn before the first frame"
6113 );
6114 frame(&mut core, &mut ext);
6115 let hit: mlua::Table = ext.lua.globals().get("hit").unwrap();
6116 assert_eq!(
6117 hit.get::<usize>("byte").unwrap(),
6118 7,
6119 "the fourth cell of \"value\""
6120 );
6121 assert_eq!(hit.get::<u32>("line").unwrap(), 0);
6122 let cell: f32 = ext.lua.globals().get("cell").unwrap();
6123 let seam: mlua::Table = ext.lua.globals().get("seam").unwrap();
6124 let x: f32 = seam.get("x").unwrap();
6125 assert!((x - 4.0 * cell).abs() < 0.75, "{x} vs {}", 4.0 * cell);
6126 assert_eq!(seam.get::<f32>("w").unwrap(), 0.0);
6127 let far: mlua::Table = ext.lua.globals().get("far").unwrap();
6128 assert_eq!(
6133 far.get::<usize>("byte").unwrap(),
6134 14,
6135 "the end, across the runs"
6136 );
6137 let none: mlua::Value = ext.lua.globals().get("none").unwrap();
6138 assert!(matches!(none, mlua::Value::Nil), "a node that drew no text");
6139 }
6140
6141 #[test]
6145 fn scripts_draw_a_screen_of_cells() {
6146 let mut ext = LuaExtension::from_source(
6147 "term",
6148 r#"
6149 function view(env)
6150 return column { cells { key = "term", rows = 2, cols = 11, size = 14, family = "mono",
6151 lines = { "hello world", " bye" },
6152 runs = { { 1, 2, 3, 0xff0000ff, 0x0000ffff, 1 } },
6153 cursor_at = { 1, 4 }, cursor_shape = "underline" } }
6154 end
6155 "#,
6156 )
6157 .unwrap();
6158 let mut core = Core::new();
6159 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6160 ui.set_origin(OriginId(1));
6161 ext.view(&Slot::root(), &mut ui).unwrap();
6162 ui.finish();
6163 let (dl, _) = core.output();
6164 let glyphs = dl
6165 .quads
6166 .iter()
6167 .filter(|q| q.kind == kui_core::QuadKind::GlyphMask)
6168 .count();
6169 assert_eq!(glyphs, 13, "hello world + bye");
6170 let tree = core.access_tree();
6171 let term = tree
6172 .nodes
6173 .iter()
6174 .find(|n| n.role == kui_core::Role::Terminal)
6175 .expect("a terminal node");
6176 assert_eq!(term.value.as_deref(), Some("hello world\n bye"));
6177 }
6178
6179 #[test]
6185 fn a_lua_grid_selects_by_cell_word_and_row() {
6186 let mut ext = LuaExtension::from_source(
6187 "term",
6188 r#"
6189 function view(env)
6190 said = env.selection_text()
6191 return column { cells { key = "term", rows = 2, cols = 12,
6192 size = 14, family = "mono", line_height = 20,
6193 origin_line = 900, selectable = true,
6194 lines = { "hello world", "bye there" } } }
6195 end
6196 "#,
6197 )
6198 .unwrap();
6199 let mut core = Core::new();
6200 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6201 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6202 ui.set_origin(OriginId(1));
6203 ext.view(&Slot::root(), &mut ui).unwrap();
6204 ui.finish();
6205 };
6206 frame(&mut core, &mut ext);
6207 let said = |ext: &LuaExtension| ext.lua.globals().get::<Option<String>>("said").unwrap();
6208
6209 let w = core
6211 .measure_text(
6212 "M",
6213 &kui_core::TextStyle::new(14.0)
6214 .family(kui_core::FontFamily::Mono)
6215 .line_height(20.0),
6216 None,
6217 )
6218 .width;
6219 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
6220 let click = |core: &mut Core, p: Vec2, clicks: u8| {
6221 core.handle_input(InputEvent::CursorMoved(p));
6222 core.handle_input(InputEvent::MouseDown {
6223 button: kui_core::MouseButton::Primary,
6224 clicks,
6225 });
6226 core.handle_input(InputEvent::MouseUp {
6227 button: kui_core::MouseButton::Primary,
6228 });
6229 };
6230
6231 click(&mut core, at(0, 8), 2);
6232 frame(&mut core, &mut ext);
6233 assert_eq!(said(&ext).as_deref(), Some("world"), "a double click");
6234
6235 click(&mut core, at(1, 1), 3);
6236 frame(&mut core, &mut ext);
6237 assert_eq!(said(&ext).as_deref(), Some("bye there"), "a triple click");
6238 }
6239
6240 #[test]
6246 fn a_grid_selection_reads_back_as_absolute_lines_and_columns() {
6247 let mut ext = LuaExtension::from_source(
6248 "term",
6249 r#"
6250 function view(env)
6251 sel = env.cell_selection()
6252 return column { cells { key = "term", rows = 2, cols = 12,
6253 size = 14, family = "mono", line_height = 20,
6254 origin_line = 900, selectable = true,
6255 lines = { "hello world", "bye there" } } }
6256 end
6257 "#,
6258 )
6259 .unwrap();
6260 let mut core = Core::new();
6261 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6262 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6263 ui.set_origin(OriginId(1));
6264 ext.view(&Slot::root(), &mut ui).unwrap();
6265 ui.finish();
6266 };
6267 frame(&mut core, &mut ext);
6268 assert!(
6269 ext.lua
6270 .globals()
6271 .get::<mlua::Value>("sel")
6272 .unwrap()
6273 .is_nil(),
6274 "nothing selected yet"
6275 );
6276 let w = core
6277 .measure_text(
6278 "M",
6279 &kui_core::TextStyle::new(14.0)
6280 .family(kui_core::FontFamily::Mono)
6281 .line_height(20.0),
6282 None,
6283 )
6284 .width;
6285 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
6286 core.handle_input(InputEvent::CursorMoved(at(1, 4)));
6287 core.handle_input(InputEvent::MouseDown {
6288 button: kui_core::MouseButton::Primary,
6289 clicks: 1,
6290 });
6291 core.handle_input(InputEvent::CursorMoved(at(0, 1)));
6292 core.handle_input(InputEvent::MouseUp {
6293 button: kui_core::MouseButton::Primary,
6294 });
6295 frame(&mut core, &mut ext);
6296 let sel: Table = ext.lua.globals().get("sel").unwrap();
6297 let end = |name: &str| -> (u64, usize) {
6298 let t: Table = sel.get(name).unwrap();
6299 (t.get("line").unwrap(), t.get("col").unwrap())
6300 };
6301 assert_eq!(end("anchor"), (901, 4), "the press, on the second row");
6302 assert_eq!(end("focus"), (900, 1), "the pointer, backwards");
6303 assert_eq!(
6304 sel.get::<i64>("node").unwrap(),
6305 core.key_of("term").unwrap().0 as i64
6306 );
6307 assert!(!sel.get::<bool>("block").unwrap());
6308 }
6309
6310 #[test]
6313 fn spans_carry_their_decorations() {
6314 let mut ext = LuaExtension::from_source(
6315 "deco",
6316 r#"
6317 function view(env)
6318 return row { text({ "let ", { "value", bg = 0x3b5bd455, underline = true },
6319 " = 1;" }, { size = 14, family = "mono" }) }
6320 end
6321 "#,
6322 )
6323 .unwrap();
6324 let mut core = Core::new();
6325 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6326 ui.set_origin(OriginId(1));
6327 ext.view(&Slot::root(), &mut ui).unwrap();
6328 ui.finish();
6329 let (dl, _) = core.output();
6330 let solids = dl
6331 .quads
6332 .iter()
6333 .filter(|q| q.kind == kui_core::QuadKind::Solid)
6334 .count();
6335 assert_eq!(solids, 2, "a background and an underline");
6336 }
6337
6338 #[test]
6343 fn underlines_have_a_colour_and_a_shape_of_their_own() {
6344 let mut ext = LuaExtension::from_source(
6345 "k4",
6346 r#"
6347 function view(env)
6348 return column {
6349 text({ "let ", { "value", underline_color = 0xff0000ff, underline_style = "wavy" } },
6350 { size = 14, family = "mono" }),
6351 text("warn", { size = 14, family = "mono", underline_color = 0x00ff00ff }),
6352 cells { rows = 1, cols = 3, size = 14, family = "mono",
6353 lines = { "abc" }, runs = { { 0, 0, 3, 0, 0, 32, 0xff0000ff } } },
6354 }
6355 end
6356 "#,
6357 )
6358 .unwrap();
6359 let mut core = Core::new();
6360 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6361 ui.set_origin(OriginId(1));
6362 ext.view(&Slot::root(), &mut ui).unwrap();
6363 ui.finish();
6364 let (dl, _) = core.output();
6365 let red = Color::hex(0xff0000ff);
6366 let segs: Vec<_> = dl
6367 .quads
6368 .iter()
6369 .filter(|q| q.kind == kui_core::QuadKind::Segment)
6370 .collect();
6371 assert!(
6372 segs.len() >= 6,
6373 "a wave under the span and an undercurl over the cells: {}",
6374 segs.len()
6375 );
6376 assert!(
6377 segs.iter().all(|q| q.color == red),
6378 "in the underline colour"
6379 );
6380 let solids: Vec<_> = dl
6381 .quads
6382 .iter()
6383 .filter(|q| q.kind == kui_core::QuadKind::Solid)
6384 .collect();
6385 assert_eq!(solids.len(), 1, "the text's solid line, coloured");
6386 assert_eq!(solids[0].color, Color::hex(0x00ff00ff));
6387 let mut bad = LuaExtension::from_source(
6389 "k4bad",
6390 r#"function view(env) return text({ { "x", underline_style = "squiggly" } }) end"#,
6391 )
6392 .unwrap();
6393 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6394 ui.set_origin(OriginId(1));
6395 let err = bad.view(&Slot::root(), &mut ui).unwrap_err();
6396 assert!(err.contains("solid | wavy | dotted"), "{err}");
6397 }
6398
6399 #[test]
6403 fn a_line_takes_a_dash_as_a_length_a_pair_or_four() {
6404 let segments = |view: &str| -> Result<usize, String> {
6405 let mut ext = LuaExtension::from_source("dash", view).unwrap();
6406 let mut core = Core::new();
6407 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6408 ui.set_origin(OriginId(1));
6409 let lowered = ext.view(&Slot::root(), &mut ui);
6410 ui.finish();
6411 lowered?;
6412 let (dl, _) = core.output();
6413 Ok(dl
6414 .quads
6415 .iter()
6416 .filter(|q| q.kind == kui_core::QuadKind::Segment)
6417 .count())
6418 };
6419 let line = |dash: &str| {
6420 format!(
6421 "function view(env) return column {{ width = 200, height = 40,
6422 line {{ from = {{0, 20}}, to = {{100, 20}}, width = 2, {dash} }} }} end"
6423 )
6424 };
6425 assert_eq!(segments(&line("")), Ok(1));
6426 assert_eq!(segments(&line("dash = 5")), Ok(10));
6428 assert_eq!(segments(&line("dash = {6, 4}")), Ok(10));
6429 assert_eq!(segments(&line("dash = {6, 4}, dash_offset = 5")), Ok(10));
6430 assert_eq!(segments(&line("dash = {10, 4, 2, 4}")), Ok(10));
6431 let err = segments(&line("dash = {1, 2, 3}")).unwrap_err();
6432 assert!(err.contains("dash is a length"), "{err}");
6433 }
6434
6435 #[test]
6439 fn features_are_a_text_option() {
6440 let mut ext = LuaExtension::from_source(
6441 "features",
6442 r#"
6443 function view(env)
6444 return row { text("fi ->", { size = 16 }),
6445 text("fi ->", { size = 16, features = "liga=0 calt=0" }) }
6446 end
6447 "#,
6448 )
6449 .unwrap();
6450 let mut core = Core::new();
6451 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6452 ui.set_origin(OriginId(1));
6453 ext.view(&Slot::root(), &mut ui).unwrap();
6454 ui.finish();
6455 assert_eq!(core.text_cache_len(), 2);
6456 }
6457
6458 #[test]
6464 fn a_style_table_shared_by_three_texts_is_three_texts() {
6465 let mut ext = LuaExtension::from_source(
6466 "shared",
6467 r#"
6468 local mono = { size = 14, family = "mono" }
6469 function view(env)
6470 return row { text("a", mono), text("bb", mono), text("ccc", mono) }
6471 end
6472 "#,
6473 )
6474 .unwrap();
6475 let mut core = Core::new();
6476 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6477 ui.set_origin(OriginId(1));
6478 ext.view(&Slot::root(), &mut ui).unwrap();
6479 ui.finish();
6480 let names: Vec<String> = core
6481 .access_tree()
6482 .nodes
6483 .iter()
6484 .filter_map(|n| n.value.clone().or_else(|| n.name.clone()))
6485 .collect();
6486 let joined = names.join("|");
6487 assert!(
6488 joined.contains("a") && joined.contains("bb") && joined.contains("ccc"),
6489 "three different texts, got {joined:?}"
6490 );
6491 assert_eq!(
6492 core.text_cache_len(),
6493 3,
6494 "three strings shaped, not one three times"
6495 );
6496 }
6497
6498 #[test]
6503 fn scripts_slice_a_long_list_from_the_geometry() {
6504 let mut ext = LuaExtension::from_source(
6505 "virtual",
6506 r#"
6507 ROWS, ROW_H, built = 10000, 30, 0
6508 function view(env)
6509 local g = env.scroll_geometry(list_key)
6510 -- No layout yet: fall back to the window for one frame.
6511 local h = g and g.h or 200
6512 local top = g and g.offset.y or 0
6513 local first = math.min(ROWS, math.max(0, math.floor(top / ROW_H)))
6514 local last = math.min(ROWS, math.ceil((top + h) / ROW_H))
6515 local list = { key = "list", width = "grow", height = "grow",
6516 scroll_y = true }
6517 if first > 0 then
6518 list[#list + 1] = row { key = "lead", width = "grow",
6519 height = first * ROW_H }
6520 end
6521 for i = first, last - 1 do
6522 list[#list + 1] = row { key = "row" .. i, width = "grow",
6523 height = ROW_H, bg = 0x282840ff }
6524 end
6525 if last < ROWS then
6526 list[#list + 1] = row { key = "tail", width = "grow",
6527 height = (ROWS - last) * ROW_H }
6528 end
6529 built = last - first
6530 return column(list)
6531 end
6532 "#,
6533 )
6534 .unwrap();
6535 let list = Key::ROOT.str("list");
6536 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
6537 let mut core = Core::new();
6538 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6539 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6540 ui.set_origin(OriginId(1));
6541 ext.view(&Slot::root(), &mut ui).unwrap();
6542 ui.finish();
6543 };
6544
6545 frame(&mut core, &mut ext);
6546 frame(&mut core, &mut ext);
6547 let built: i64 = ext.lua.globals().get("built").unwrap();
6548 assert_eq!(built, 7, "200 / 30 rounded up");
6549
6550 let g = core.scroll_geometry(list).expect("laid out");
6553 assert_eq!(g.content.h, 10_000.0 * 30.0);
6554 core.set_scroll(list, Vec2::new(0.0, 1e9));
6555 frame(&mut core, &mut ext);
6556 frame(&mut core, &mut ext);
6557 assert_eq!(core.scroll_offset(list).y, 10_000.0 * 30.0 - 200.0);
6558 let built: i64 = ext.lua.globals().get("built").unwrap();
6559 assert_eq!(built, 7, "still a screenful at the far end");
6560 }
6561
6562 #[test]
6568 fn the_prelude_virtualizes_a_long_list_in_one_call() {
6569 let mut ext = LuaExtension::from_source(
6570 "virtual",
6571 r#"
6572 ROWS, ROW_H, first_built, built = 10000, 30, -1, 0
6573 function view(env)
6574 built, first_built = 0, -1
6575 return uniform_list(env,
6576 { key = "list", rows = ROWS, row_h = ROW_H,
6577 width = "grow", height = "grow" },
6578 function(i)
6579 built = built + 1
6580 if first_built < 0 then first_built = i end
6581 return column { fill = true, bg = 0x282840ff,
6582 on_click = { kind = "pick", row = i } }
6583 end)
6584 end
6585 "#,
6586 )
6587 .unwrap();
6588 let list = Key::ROOT.str("list");
6589 let mut core = Core::new();
6590 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6591 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6592 ui.set_origin(OriginId(1));
6593 ext.view(&Slot::root(), &mut ui).unwrap();
6594 ui.finish();
6595 };
6596
6597 frame(&mut core, &mut ext);
6598 frame(&mut core, &mut ext);
6599 let built: i64 = ext.lua.globals().get("built").unwrap();
6600 assert_eq!(built, 9, "200 / 30 rounded up, plus two rows of overscan");
6601
6602 let g = core.scroll_geometry(list).expect("laid out");
6605 assert_eq!(g.content.h, 10_000.0 * 30.0);
6606 core.set_scroll(list, Vec2::new(0.0, 300.0 * 30.0));
6607 frame(&mut core, &mut ext);
6608 frame(&mut core, &mut ext);
6609 let first: i64 = ext.lua.globals().get("first_built").unwrap();
6610 assert_eq!(first, 298, "two rows of overscan above row 300");
6611 let built: i64 = ext.lua.globals().get("built").unwrap();
6612 assert_eq!(built, 11, "a screenful with overscan on both sides now");
6613 assert!(
6616 core.access_tree()
6617 .nodes
6618 .iter()
6619 .any(|n| n.key == list.index(300).index(0)),
6620 "row 300 is not keyed by its data index"
6621 );
6622 }
6623
6624 #[test]
6628 fn a_view_names_a_family_and_draws_in_it() {
6629 let mut ext = LuaExtension::from_source(
6630 "fam",
6631 r#"
6632 named, sans, missed = nil, nil, nil
6633 function view(env)
6634 named = env.measure_text("iiiWWW", { family = "Fixture Mono", size = 20 }).width
6635 sans = env.measure_text("iiiWWW", { family = "sans", size = 20 }).width
6636 return column {
6637 text("iiiWWW", { family = "Fixture Mono", size = 20 }),
6638 text("x", { family = "No Such Family 7" }),
6639 }
6640 end
6641 "#,
6642 )
6643 .unwrap();
6644 let mut core = Core::new();
6645 core.add_font_data(kui_core::testing::font_face(
6646 "Fixture Mono",
6647 400,
6648 false,
6649 true,
6650 ))
6651 .unwrap();
6652 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6653 ui.set_origin(OriginId(1));
6654 ext.view(&Slot::root(), &mut ui).unwrap();
6655 ui.finish();
6656 let handle = core.add_system_font("Fixture Mono").unwrap();
6657 let expected = core
6658 .measure_text("iiiWWW", &kui_core::TextStyle::new(20.0).font(handle), None)
6659 .width;
6660 let g = ext.lua.globals();
6661 let (named, sans) = (
6662 g.get::<f32>("named").unwrap(),
6663 g.get::<f32>("sans").unwrap(),
6664 );
6665 assert_eq!(named, expected, "the face the host's handle draws");
6666 assert_ne!(named, sans, "and not sans");
6667 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
6668 assert_eq!(codes, ["unknown-family"]);
6669 }
6670
6671 #[test]
6674 fn the_prelude_reveals_a_row_styles_rows_and_splits() {
6675 let mut ext = LuaExtension::from_source(
6676 "dx22",
6677 r#"
6678 target, scrolled, page = nil, nil, nil
6679 function view(env)
6680 if target then scrolled = reveal_row(env, "list", target, 20) end
6681 page = rows_in_view(env, "list", 20)
6682 zero = rows_in_view(env, "list", 0)
6683 return row { width = 300, height = 100,
6684 uniform_list(env,
6685 { key = "list", rows = 50, row_h = 20, width = 200, height = 100,
6686 row_props = function(i) return { on_click = { kind = "pick", row = i } } end },
6687 function(i) return text("row " .. i) end),
6688 splitter(env, { key = "bar", on_drag = { kind = "split" } }),
6689 column { width = "grow", height = "grow" },
6690 }
6691 end
6692 "#,
6693 )
6694 .unwrap();
6695 let mut core = Core::new();
6696 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6697 let mut ui = core.frame(Size::new(300.0, 100.0), 1.0);
6698 ui.set_origin(OriginId(1));
6699 ext.view(&Slot::root(), &mut ui).unwrap();
6700 ui.finish();
6701 };
6702 frame(&mut core, &mut ext);
6703 frame(&mut core, &mut ext);
6704 assert_eq!(ext.lua.globals().get::<i64>("page").unwrap(), 5);
6705 assert!(
6706 core.take_warnings().is_empty(),
6707 "every prop the three spell is known"
6708 );
6709
6710 ext.lua.globals().set("target", 40).unwrap();
6711 frame(&mut core, &mut ext);
6712 assert!(ext.lua.globals().get::<bool>("scrolled").unwrap());
6713 let list = core.key_of("list").unwrap();
6714 assert_eq!(
6715 core.scroll_offset(list),
6716 Vec2::new(0.0, 760.0),
6717 "row 40 to the middle"
6718 );
6719 core.handle_input(InputEvent::CursorMoved(Vec2::new(10.0, 45.0)));
6722 core.handle_input(InputEvent::mouse_down(1));
6723 let up = core.handle_input(InputEvent::mouse_up());
6724 assert_eq!(up.iter().find_map(|e| e.payload.get_int("row")), Some(40));
6725
6726 core.handle_input(InputEvent::CursorMoved(Vec2::new(202.0, 50.0)));
6728 core.handle_input(InputEvent::mouse_down(1));
6729 core.handle_input(InputEvent::CursorMoved(Vec2::new(225.0, 50.0)));
6730 let end = core.handle_input(InputEvent::mouse_up());
6731 let d = end.iter().find_map(|e| e.drag()).expect("the drag's end");
6732 assert_eq!(d.ratio().x, 0.75);
6733
6734 ext.lua.globals().set("target", 500).unwrap();
6736 frame(&mut core, &mut ext);
6737 assert!(!ext.lua.globals().get::<bool>("scrolled").unwrap());
6738 assert_eq!(core.scroll_offset(list), Vec2::new(0.0, 760.0));
6739 assert_eq!(ext.lua.globals().get::<i64>("zero").unwrap(), 0);
6740 }
6741
6742 #[test]
6746 fn a_uniform_list_that_shrank_lands_in_one_frame() {
6747 let mut ext = LuaExtension::from_source(
6748 "virtual",
6749 r#"
6750 ROWS, first_built = 200, -1
6751 function view(env)
6752 first_built = -1
6753 return uniform_list(env,
6754 { key = "list", rows = ROWS, row_h = 20,
6755 width = "grow", height = "grow" },
6756 function(i)
6757 if first_built < 0 then first_built = i end
6758 return column { fill = true, bg = 0x282840ff }
6759 end)
6760 end
6761 "#,
6762 )
6763 .unwrap();
6764 let list = Key::ROOT.str("list");
6765 let mut core = Core::new();
6766 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6767 let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
6768 ui.set_origin(OriginId(1));
6769 ext.view(&Slot::root(), &mut ui).unwrap();
6770 ui.finish();
6771 };
6772 frame(&mut core, &mut ext);
6773 frame(&mut core, &mut ext);
6774 core.set_scroll(list, Vec2::new(0.0, 3_000.0));
6775 frame(&mut core, &mut ext);
6776 frame(&mut core, &mut ext);
6777 let deep: i64 = ext.lua.globals().get("first_built").unwrap();
6778 assert!(
6779 deep > 100,
6780 "expected to be deep in the list, built from {deep}"
6781 );
6782
6783 ext.lua.globals().set("ROWS", 10).unwrap();
6784 frame(&mut core, &mut ext);
6785 let g = core.scroll_geometry(list).expect("laid out");
6786 assert_eq!(
6787 g.content.h,
6788 10.0 * 20.0,
6789 "the content is the list it has now"
6790 );
6791 frame(&mut core, &mut ext);
6792 let first: i64 = ext.lua.globals().get("first_built").unwrap();
6793 assert_eq!(first, 0, "and every row of it is built");
6794 assert_eq!(core.scroll_offset(list).y, 0.0);
6795 }
6796
6797 const VARIABLE_LIST: &str = r#"
6802 heights = row_heights(1000, 20)
6803 measured = 0
6804 function view(env)
6805 return list(env,
6806 { key = "list", heights = heights, width = "grow", height = "grow",
6807 transition = TRANSITION },
6808 function(i, width)
6809 measured = measured + 1
6810 if i < 100 then return 20 else return 60 end
6811 end,
6812 function(i)
6813 return column { width = "grow", height = "grow", bg = 0x282840ff,
6814 label = "row", on_click = i }
6815 end)
6816 end
6817 "#;
6818
6819 fn variable_frame(core: &mut Core, ext: &mut LuaExtension, t: f64) {
6821 core.set_time(t);
6822 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6823 ui.set_origin(OriginId(1));
6824 ext.view(&Slot::root(), &mut ui).unwrap();
6825 ui.finish();
6826 }
6827
6828 fn row_under(core: &mut Core, y: f32) -> Option<i64> {
6831 core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, y)));
6832 core.handle_input(InputEvent::mouse_down(1));
6833 let evs = core.handle_input(InputEvent::mouse_up());
6834 evs.first()?.payload.as_int()
6835 }
6836
6837 #[test]
6841 fn a_lua_list_keeps_the_row_under_the_pointer_while_the_estimate_moves() {
6842 let mut ext = LuaExtension::from_source("variable", VARIABLE_LIST).unwrap();
6843 let list = Key::ROOT.str("list");
6844 let mut core = Core::new();
6845 variable_frame(&mut core, &mut ext, 0.0);
6846 variable_frame(&mut core, &mut ext, 0.0);
6847 let measured: i64 = ext.lua.globals().get("measured").unwrap();
6848 assert!(
6849 (10..=20).contains(&measured),
6850 "a screenful measured, not the list: {measured}"
6851 );
6852
6853 core.set_scroll(list, Vec2::new(0.0, 5_000.0));
6854 variable_frame(&mut core, &mut ext, 0.0);
6855 let settled = row_under(&mut core, 4.0);
6856 assert!(settled.is_some(), "nothing under the top edge");
6857 for n in 0..4 {
6858 variable_frame(&mut core, &mut ext, 0.0);
6859 assert_eq!(
6860 row_under(&mut core, 4.0),
6861 settled,
6862 "the content slid on frame {n} as the estimate moved"
6863 );
6864 }
6865 let g = core.scroll_geometry(list).expect("laid out");
6866 assert!(
6867 g.content.h > 1000.0 * 20.0 * 1.5,
6868 "the list learned it is longer: {}",
6869 g.content.h
6870 );
6871 }
6872
6873 #[test]
6877 fn a_lua_list_glides_to_the_row_asked_for() {
6878 let mut ext =
6879 LuaExtension::from_source("variable", &format!("TRANSITION = 100\n{VARIABLE_LIST}"))
6880 .unwrap();
6881 let list = Key::ROOT.str("list");
6882 let mut core = Core::new();
6883 let mut t = 0.0;
6884 variable_frame(&mut core, &mut ext, t);
6885 variable_frame(&mut core, &mut ext, t);
6886 let target = 400;
6887 let offset: f32 = ext
6888 .lua
6889 .load(format!("return heights:offset_of({target})"))
6890 .eval()
6891 .unwrap();
6892 core.set_scroll(list, Vec2::new(0.0, offset));
6893 for _ in 0..30 {
6894 t += 1.0 / 60.0;
6895 variable_frame(&mut core, &mut ext, t);
6896 }
6897 assert_eq!(row_under(&mut core, 4.0), Some(target));
6898 }
6899
6900 #[test]
6904 fn a_script_asks_for_a_file_dialog_and_hears_the_answer() {
6905 let mut ext = LuaExtension::from_source(
6906 "files",
6907 r#"
6908 ask, asked, again, waiting, heard = true, nil, nil, nil, nil
6909 function view(env)
6910 if ask then
6911 asked = env.request_files {
6912 mode = "save", title = "Export", file_name = "notes.md",
6913 filters = { { name = "Markdown", extensions = { "md" } } },
6914 tag = "export",
6915 }
6916 again = env.request_files {}
6917 waiting = env.awaiting_files()
6918 ask = false
6919 end
6920 return column {}
6921 end
6922 function on_event(ev)
6923 if ev.kind == "files" then heard = ev.paths[1] .. "|" .. ev.tag end
6924 end
6925 "#,
6926 )
6927 .unwrap();
6928 let mut core = Core::new();
6929 variable_frame(&mut core, &mut ext, 0.0);
6930 let g = ext.lua.globals();
6931 assert!(g.get::<bool>("asked").unwrap(), "the first ask is taken");
6932 assert!(
6933 !g.get::<bool>("again").unwrap(),
6934 "a second while it is out is not"
6935 );
6936 assert!(g.get::<bool>("waiting").unwrap());
6937
6938 let asks = core.take_file_requests();
6939 assert_eq!(asks.len(), 1);
6940 let d = &asks[0];
6941 assert_eq!(d.mode, kui_core::FileDialogMode::Save);
6942 assert_eq!(d.file_name.as_deref(), Some("notes.md"));
6943 assert_eq!(d.filters[0].extensions, vec!["md".to_string()]);
6944
6945 let evs = core.handle_input(InputEvent::Files(vec!["/tmp/notes.md".into()]));
6946 assert_eq!(evs.len(), 1);
6947 assert_eq!(
6948 evs[0].origin,
6949 OriginId(1),
6950 "the answer goes to the script that asked"
6951 );
6952 for e in &evs {
6953 ext.on_event(e);
6954 }
6955 let heard: String = ext.lua.globals().get("heard").unwrap();
6956 assert_eq!(heard, "/tmp/notes.md|export");
6957 }
6958
6959 #[test]
6963 fn a_host_hands_a_script_the_documents_the_os_opened() {
6964 let mut ext = LuaExtension::from_source(
6965 "open",
6966 r#"
6967 heard = nil
6968 function view(env) return column {} end
6969 function on_event(ev)
6970 if ev.kind == "open" then heard = #ev.paths .. "|" .. ev.paths[2] end
6971 end
6972 "#,
6973 )
6974 .unwrap();
6975 let mut core = Core::new();
6976 variable_frame(&mut core, &mut ext, 0.0);
6977 let evs = core.handle_input(InputEvent::Open(vec![
6978 "/tmp/a.txt".into(),
6979 "/tmp/b.md".into(),
6980 ]));
6981 assert_eq!(evs.len(), 1);
6982 assert_eq!(evs[0].origin, OriginId::HOST, "nobody asked: the host's");
6983 assert_eq!(evs[0].key, kui_core::Key::ROOT);
6984 for e in &evs {
6985 ext.on_event(e);
6986 }
6987 let heard: String = ext.lua.globals().get("heard").unwrap();
6988 assert_eq!(heard, "2|/tmp/b.md");
6989 assert!(
6990 core.handle_input(InputEvent::Open(Vec::new())).is_empty(),
6991 "no documents is no event"
6992 );
6993 }
6994
6995 #[test]
6998 fn a_lua_list_names_what_it_is_missing() {
6999 let mut ext = LuaExtension::from_source(
7000 "bad",
7001 r#"
7002 function view(env)
7003 ok, err = pcall(list, env, { key = "list" }, function() return 1 end,
7004 function() return column {} end)
7005 return column {}
7006 end
7007 "#,
7008 )
7009 .unwrap();
7010 let mut core = Core::new();
7011 variable_frame(&mut core, &mut ext, 0.0);
7012 let err: String = ext.lua.globals().get("err").unwrap();
7013 assert!(err.contains("row_heights"), "{err}");
7014 }
7015
7016 #[test]
7020 fn a_query_answers_for_an_undeclared_label_and_a_command_refuses() {
7021 let mut ext = LuaExtension::from_source(
7022 "queries",
7023 r#"
7024 function view(env)
7025 geom = env.scroll_geometry("nothing")
7026 off = env.scroll_offset("nothing")
7027 hovered = env.is_hovered("nothing")
7028 pressed = env.is_pressed("nothing")
7029 focused = env.is_focused("nothing")
7030 hit = env.text_hit("nothing", 1, 1)
7031 caret = env.caret_rect("nothing", 0)
7032 deferred = pcall(function() env.set_scroll("nothing", 0, 0) end)
7033 refused_focus = not pcall(function() env.set_focus("nothing") end)
7034 return column { width = "grow", height = "grow" }
7035 end
7036 "#,
7037 )
7038 .unwrap();
7039 let mut core = Core::new();
7040 let mut ui = core.frame(Size::new(200.0, 100.0), 1.0);
7041 ext.view(&Slot::root(), &mut ui).unwrap();
7042 ui.finish();
7043 let g = ext.lua.globals();
7044 assert_eq!(g.get::<mlua::Value>("geom").unwrap(), mlua::Value::Nil);
7045 assert_eq!(g.get::<mlua::Value>("hit").unwrap(), mlua::Value::Nil);
7046 assert_eq!(g.get::<mlua::Value>("caret").unwrap(), mlua::Value::Nil);
7047 assert!(!g.get::<bool>("hovered").unwrap());
7048 assert!(!g.get::<bool>("pressed").unwrap());
7049 assert!(!g.get::<bool>("focused").unwrap());
7050 let off: Table = g.get("off").unwrap();
7051 assert_eq!(off.get::<f32>("y").unwrap(), 0.0);
7052 assert!(g.get::<bool>("deferred").unwrap());
7055 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
7056 assert_eq!(codes, ["label-without-node"]);
7057 assert!(g.get::<bool>("refused_focus").unwrap());
7058 }
7059
7060 #[test]
7061 fn bad_script_reports_error_not_panic() {
7062 let mut ext = LuaExtension::from_source("bad", "function view() return 5 end").unwrap();
7063 let mut core = Core::new();
7064 let mut ui = core.frame(Size::new(100.0, 100.0), 1.0);
7065 assert!(ext.view(&Slot::root(), &mut ui).is_err());
7066 }
7067}