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