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