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