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