1use kui_core::schema::{self, Kind, Parsed, PropsOut};
287use kui_core::{
288 Align, Color, Content, EditOptions, Extension, FloatConfig, Key, PadShorthand, Sizing, Slot,
289 Span, Ui, UiEvent, Value, WindowConfig, widgets,
290};
291use mlua::{Lua, Table};
292
293mod meta;
294mod rows;
295pub use meta::luals_meta;
296
297const PRELUDE: &str = include_str!("prelude.lua");
298
299pub struct LuaExtension {
337 lua: Lua,
338 name: String,
339 slots: Vec<String>,
344 loaded: std::cell::RefCell<Vec<Loaded>>,
349 slot_names: std::collections::HashMap<kui_core::Key, String>,
353 tokens: Option<kui_core::Tokens>,
358}
359
360struct Loaded {
363 namespace: String,
364 path: std::path::PathBuf,
365 origin: kui_core::OriginId,
366}
367
368impl LuaExtension {
369 pub fn from_source(name: impl Into<String>, source: &str) -> mlua::Result<Self> {
393 let lua = Lua::new();
394 rows::register(&lua)?;
395 lua.load(PRELUDE).set_name("kui:prelude").exec()?;
396 let name = name.into();
397 lua.load(source).set_name(&name).exec()?;
398 let slots = match lua.globals().get::<mlua::Value>("slots")? {
399 mlua::Value::Nil => Vec::new(),
400 mlua::Value::Table(t) => t.sequence_values::<String>().collect::<mlua::Result<_>>()?,
401 other => {
402 return Err(mlua::Error::runtime(format!(
403 "`slots` must be a list of slot names, not {}",
404 other.type_name()
405 )));
406 }
407 };
408 let tokens = match lua.globals().get::<mlua::Value>("tokens")? {
409 mlua::Value::Nil => None,
410 mlua::Value::Table(t) => Some(parse_tokens(&t)?),
411 other => {
412 return Err(mlua::Error::runtime(format!(
413 "`tokens` must be a table {{ colors = …, lengths = … }}, not {}",
414 other.type_name()
415 )));
416 }
417 };
418 Ok(Self {
419 lua,
420 name,
421 slots,
422 loaded: Default::default(),
423 slot_names: Default::default(),
424 tokens,
425 })
426 }
427
428 fn loaded_as(&self, origin: kui_core::OriginId) -> Option<String> {
431 self.loaded
432 .borrow()
433 .iter()
434 .find(|l| l.origin == origin)
435 .map(|l| l.namespace.clone())
436 }
437
438 pub fn lua(&self) -> &Lua {
442 &self.lua
443 }
444
445 pub fn from_file(path: impl AsRef<std::path::Path>) -> mlua::Result<Self> {
447 let path = path.as_ref();
448 let source = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
449 let name = path
450 .file_name()
451 .map_or_else(|| "lua".into(), |n| n.to_string_lossy().into_owned());
452 Self::from_source(name, &source)
453 }
454}
455
456impl Extension for LuaExtension {
457 fn name(&self) -> &str {
458 &self.name
459 }
460
461 fn slots(&self) -> &[String] {
462 &self.slots
463 }
464
465 fn view(&mut self, slot: &Slot<'_>, ui: &mut Ui<'_>) -> Result<(), String> {
466 let view: mlua::Function = self
467 .lua
468 .globals()
469 .get("view")
470 .map_err(|_| "script defines no view()".to_string())?;
471 let slot_table = slot_table(&self.lua, slot).map_err(|e| format!("slot: {e}"))?;
477 self.slot_names
478 .entry(slot.key)
479 .or_insert_with(|| slot.full_name());
480 let origin = ui.core().origin();
484 if let Some(t) = &self.tokens
485 && !ui.core().tokens_declared(origin)
486 {
487 ui.set_tokens(t.clone());
488 }
489 let root: Table = {
494 let frame = std::cell::RefCell::new(&mut *ui);
495 self.lua
496 .scope(|scope| {
497 let env = env_table(&self.lua, scope, &frame, &self.loaded)?;
498 view.call((env, slot_table))
499 })
500 .map_err(|e| format!("view(): {e}"))?
501 };
502 if let Ok(Some(title)) = root.get::<Option<String>>("window_title") {
504 ui.window_title(&title);
505 }
506 if let Ok(Some(true)) = root.get::<Option<bool>>("always_on_top") {
507 ui.always_on_top(true);
508 }
509 if let Ok(Some(true)) = root.get::<Option<bool>>("secure_input") {
512 ui.secure_input(true);
513 }
514 if let Some(name) = root
521 .get::<Option<String>>("option_as_alt")
522 .map_err(|e| format!("option_as_alt: {e}"))?
523 {
524 let v = kui_core::OptionAsAlt::from_name(&name).ok_or_else(|| {
525 format!(
526 "option_as_alt: expected \"none\", \"left\", \"right\" or \"both\", got {name:?}"
527 )
528 })?;
529 if v != kui_core::OptionAsAlt::None {
530 ui.option_as_alt(v);
531 }
532 }
533 if let Ok(Some(true)) = root.get::<Option<bool>>("ime_off") {
536 ui.ime_off(true);
537 }
538 declare_windows(ui, &root).map_err(|e| format!("windows: {e}"))?;
539 build_node(ui, &root).map_err(|e| format!("view table: {e}"))
540 }
541
542 fn on_event(&mut self, ev: &UiEvent) -> Vec<Value> {
543 let Ok(f) = self.lua.globals().get::<mlua::Function>("on_event") else {
544 return Vec::new();
545 };
546 let payload = value_to_lua(&self.lua, &ev.payload).and_then(|p| {
547 if let mlua::Value::Table(t) = &p
551 && !t.contains_key("node_key")?
552 {
553 t.set("node_key", ev.key.0 as i64)?;
554 t.set("window", ev.window.0)?;
559 if let Some(name) = ev.slot.and_then(|k| self.slot_names.get(&k)) {
565 t.set("slot", name.as_str())?;
566 }
567 if let Some(ns) = self.loaded_as(ev.origin) {
575 t.set("from", ns)?;
576 }
577 }
578 Ok(p)
579 });
580 match payload.and_then(|p| f.call::<mlua::Value>(p)) {
585 Ok(mlua::Value::Nil) => Vec::new(),
586 Ok(v) => match lua_to_value(&v) {
587 Ok(Value::List(replies)) => replies,
588 Ok(reply) => vec![reply],
589 Err(e) => {
590 eprintln!("kui-lua: '{}' on_event reply: {e}", self.name);
591 Vec::new()
592 }
593 },
594 Err(e) => {
595 eprintln!("kui-lua: '{}' on_event error: {e}", self.name);
596 Vec::new()
597 }
598 }
599 }
600}
601
602fn slot_table(lua: &Lua, slot: &Slot<'_>) -> mlua::Result<Table> {
609 let t = lua.create_table()?;
610 t.set("name", slot.name)?;
611 t.set("namespace", slot.namespace)?;
612 t.set("key", slot.key.0 as i64)?;
613 if !matches!(slot.params, Value::Null) {
614 t.set("params", value_to_lua(lua, slot.params)?)?;
615 }
616 Ok(t)
617}
618
619fn key_arg(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Key> {
625 match v {
626 mlua::Value::Integer(i) => Ok(Key(i as u64)),
627 mlua::Value::String(s) => {
628 let label = s.to_str()?;
629 ui.key_of(&label).ok_or_else(|| {
630 mlua::Error::runtime(format!(
631 "no node is keyed {:?}: pass the label a `key` field declared in this or the last \
632 frame, or the integer key an event carried",
633 &*label
634 ))
635 })
636 }
637 other => Err(mlua::Error::runtime(format!(
638 "a node key is an integer or a declared label, not {}",
639 other.type_name()
640 ))),
641 }
642}
643
644fn key_query(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Option<Key>> {
655 match v {
656 mlua::Value::Integer(i) => Ok(Some(Key(i as u64))),
657 mlua::Value::String(s) => Ok(ui.key_of(&s.to_str()?)),
658 other => Err(mlua::Error::runtime(format!(
659 "a node key is an integer or a declared label, not {}",
660 other.type_name()
661 ))),
662 }
663}
664
665fn lua_list_to_value(t: &Table) -> mlua::Result<Value> {
668 let mut items = Vec::with_capacity(t.raw_len());
669 for item in t.sequence_values::<mlua::Value>() {
670 items.push(lua_to_value(&item?)?);
671 }
672 Ok(Value::List(items))
673}
674
675fn alias_menu_role(row: &mut Value) {
680 let Value::Map(fields) = row else { return };
681 for (k, v) in fields.iter_mut() {
682 if k == "role"
683 && let Value::Str(name) = v
684 {
685 match name.as_str() {
686 "select_all" => *name = kui_core::MenuRole::SelectAll.name().to_string(),
687 "look_up" => *name = kui_core::MenuRole::LookUp.name().to_string(),
688 _ => {}
689 }
690 }
691 if k == "items"
693 && let Value::List(rows) = v
694 {
695 rows.iter_mut().for_each(alias_menu_role);
696 }
697 }
698}
699
700fn menu_items(t: &mlua::Table) -> mlua::Result<(Vec<kui_core::MenuItem>, Vec<String>)> {
703 let mut rows = lua_list_to_value(t)?;
704 if let Value::List(rows) = &mut rows {
705 rows.iter_mut().for_each(alias_menu_role);
706 }
707 let items = kui_core::MenuItem::list_from_value(&rows).map_err(mlua::Error::runtime)?;
708 Ok((items, kui_core::MenuItem::stray_keys(&rows)))
709}
710
711fn warn_stray_menu_keys(core: &mut kui_core::Core, keys: Vec<String>) {
713 if core.diagnostics() {
714 for k in keys {
715 core.warn(kui_core::diag::unknown_menu_item_key(&k));
716 }
717 }
718}
719
720fn env_table<'scope, 'env: 'scope>(
769 lua: &Lua,
770 scope: &'scope mlua::Scope<'scope, 'env>,
771 ui: &'env std::cell::RefCell<&'env mut Ui<'_>>,
772 loaded: &'env std::cell::RefCell<Vec<Loaded>>,
773) -> mlua::Result<Table> {
774 let (facts, theme, metrics) = {
775 let ui = ui.borrow();
776 (ui.env_facts(), ui.theme(), ui.metrics())
777 };
778 let t = lua.create_table()?;
779 t.set("tokens", tokens_table(lua, &ui.borrow())?)?;
785 for row in kui_core::schema::ENV_FIELDS {
798 let value = (row.get)(&facts);
799 if value == Value::Null {
800 continue;
801 }
802 if row.name == "window.native_controls" {
803 let win = t
804 .get::<Option<Table>>("window")?
805 .unwrap_or(lua.create_table()?);
806 let f = |k: &str| value.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
807 win.set("controls_w", f("x") + f("w"))?;
808 win.set("controls_h", f("y") + f("h"))?;
809 t.set("window", win)?;
810 continue;
811 }
812 for path in row.lua {
813 let lua_value = value_to_lua(lua, &value)?;
814 match path.split_once('.') {
815 None => t.set(*path, lua_value)?,
816 Some((head, leaf)) => {
817 let sub = t.get::<Option<Table>>(head)?.unwrap_or(lua.create_table()?);
818 sub.set(leaf, lua_value)?;
819 t.set(head, sub)?;
820 }
821 }
822 }
823 }
824 let th = lua.create_table()?;
833 for role in kui_core::schema::THEME_ROLES {
834 th.set(role.name, (role.get)(&theme).to_hex())?;
835 }
836 th.set("appearance", theme.appearance.name())?;
837 th.set("disabled_opacity", theme.disabled_opacity)?;
838 t.set("theme", th)?;
839 let mt = lua.create_table()?;
844 for role in kui_core::schema::METRIC_ROLES {
845 mt.set(role.name, (role.get)(&metrics))?;
846 }
847 t.set("metrics", mt)?;
848 t.set(
854 "edit_text",
855 scope.create_function(move |_, key: mlua::Value| {
856 let mut ui = ui.borrow_mut();
857 let Some(key) = key_query(&mut ui, key)? else {
858 return Ok(None);
859 };
860 Ok(ui.edit_text(key))
861 })?,
862 )?;
863 t.set(
872 "set_edit_text",
873 scope.create_function(move |_, (key, text): (mlua::Value, String)| {
874 let mut ui = ui.borrow_mut();
875 match key {
876 mlua::Value::String(label) => {
877 ui.set_edit_text_by_label(&label.to_str()?, &text);
878 }
879 other => {
880 let key = key_arg(&mut ui, other)?;
881 ui.set_edit_text(key, &text);
882 }
883 }
884 Ok(())
885 })?,
886 )?;
887 t.set(
890 "is_focused",
891 scope.create_function(move |_, key: mlua::Value| {
892 let mut ui = ui.borrow_mut();
893 let Some(key) = key_query(&mut ui, key)? else {
894 return Ok(false);
895 };
896 Ok(ui.is_focused(key))
897 })?,
898 )?;
899 t.set(
900 "is_hovered",
901 scope.create_function(move |_, key: mlua::Value| {
902 let mut ui = ui.borrow_mut();
903 let Some(key) = key_query(&mut ui, key)? else {
904 return Ok(false);
905 };
906 Ok(ui.is_hovered(key))
907 })?,
908 )?;
909 t.set(
913 "is_pressed",
914 scope.create_function(move |_, key: mlua::Value| {
915 let mut ui = ui.borrow_mut();
916 let Some(key) = key_query(&mut ui, key)? else {
917 return Ok(false);
918 };
919 Ok(ui.is_pressed(key))
920 })?,
921 )?;
922 t.set(
925 "is_drop_target",
926 scope.create_function(move |_, key: mlua::Value| {
927 let mut ui = ui.borrow_mut();
928 let Some(key) = key_query(&mut ui, key)? else {
929 return Ok(false);
930 };
931 Ok(ui.is_drop_target(key))
932 })?,
933 )?;
934 t.set(
937 "drop_target",
938 scope.create_function(move |_, ()| {
939 let ui = ui.borrow();
940 Ok(ui.drop_target().map(|k| k.0 as i64))
941 })?,
942 )?;
943 t.set(
954 "request_frame_at",
955 scope.create_function(move |_, at: f64| {
956 ui.borrow_mut().request_frame_at(at);
957 Ok(())
958 })?,
959 )?;
960 t.set(
965 "slot_fill",
966 scope.create_function(move |_, name: String| {
967 Ok(ui.borrow_mut().core().slot_fill(&name).map(|f| f.name()))
968 })?,
969 )?;
970 t.set(
974 "exit_with",
975 scope.create_function(move |_, (key, exit): (mlua::Value, mlua::Value)| {
976 let mut ui = ui.borrow_mut();
977 let key = key_arg(&mut ui, key)?;
978 let e = kui_core::enter::parse(&lua_to_value(&exit)?)
979 .map_err(|m| mlua::Error::runtime(format!("exit_with: {m}")))?;
980 ui.exit_with(key, e);
981 Ok(())
982 })?,
983 )?;
984 t.set(
985 "set_focus",
986 scope.create_function(move |_, key: mlua::Value| {
987 let mut ui = ui.borrow_mut();
988 let key = key_arg(&mut ui, key)?;
989 ui.focus(key);
990 Ok(())
991 })?,
992 )?;
993 t.set(
994 "blur",
995 scope.create_function(move |_, ()| {
996 ui.borrow_mut().blur();
997 Ok(())
998 })?,
999 )?;
1000 t.set(
1007 "focus_next",
1008 scope.create_function(move |_, ()| {
1009 ui.borrow_mut().focus_next();
1010 Ok(())
1011 })?,
1012 )?;
1013 t.set(
1014 "focus_prev",
1015 scope.create_function(move |_, ()| {
1016 ui.borrow_mut().focus_prev();
1017 Ok(())
1018 })?,
1019 )?;
1020 t.set(
1026 "focus_region",
1027 scope.create_function(move |_, key: mlua::Value| {
1028 let mut ui = ui.borrow_mut();
1029 let key = match key {
1030 mlua::Value::Nil => None,
1031 v => Some(key_arg(&mut ui, v)?),
1032 };
1033 ui.focus_region(key);
1034 Ok(())
1035 })?,
1036 )?;
1037 t.set(
1048 "announce",
1049 scope.create_function(move |_, (text, live): (String, Option<String>)| {
1050 let live = live.unwrap_or_else(|| "polite".to_string());
1051 let Some(i) = schema::LIVE.iter().position(|v| *v == live) else {
1052 return Err(mlua::Error::runtime(format!(
1053 "bad politeness {live:?} (one of {})",
1054 schema::LIVE.join(" | ")
1055 )));
1056 };
1057 ui.borrow_mut()
1058 .announce(&text, kui_core::Live::from_index(i));
1059 Ok(())
1060 })?,
1061 )?;
1062 t.set(
1076 "reveal",
1077 scope.create_function(move |_, key: mlua::Value| {
1078 let mut ui = ui.borrow_mut();
1079 match key {
1080 mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
1081 ui.reveal_label(&s.to_str()?);
1082 }
1083 key => {
1084 let key = key_arg(&mut ui, key)?;
1085 ui.reveal(key);
1086 }
1087 }
1088 Ok(())
1089 })?,
1090 )?;
1091 t.set(
1095 "scroll_offset",
1096 scope.create_function(move |lua, key: mlua::Value| {
1097 let mut ui = ui.borrow_mut();
1098 let off = match key_query(&mut ui, key)? {
1099 Some(key) => ui.scroll_offset(key),
1100 None => kui_core::Vec2::ZERO,
1101 };
1102 let r = lua.create_table()?;
1103 r.set("x", off.x)?;
1104 r.set("y", off.y)?;
1105 Ok(r)
1106 })?,
1107 )?;
1108 t.set(
1123 "text_hit",
1124 scope.create_function(move |lua, (key, x, y): (mlua::Value, f32, f32)| {
1125 let mut ui = ui.borrow_mut();
1126 let Some(key) = key_query(&mut ui, key)? else {
1127 return Ok(mlua::Value::Nil);
1128 };
1129 let Some(h) = ui.text_hit(key, kui_core::Vec2::new(x, y)) else {
1130 return Ok(mlua::Value::Nil);
1131 };
1132 value_to_lua(lua, &h.to_value())
1133 })?,
1134 )?;
1135 t.set(
1143 "open_menu",
1144 scope.create_function(
1145 move |_, (key, x, y, items): (mlua::Value, f32, f32, mlua::Table)| {
1146 let mut ui = ui.borrow_mut();
1147 let Some(target) = key_query(&mut ui, key)? else {
1148 return Ok(false);
1149 };
1150 let (items, stray) = menu_items(&items)?;
1151 warn_stray_menu_keys(ui.core(), stray);
1152 ui.open_menu(kui_core::Menu::new(
1153 target,
1154 kui_core::Vec2::new(x, y),
1155 items,
1156 ));
1157 Ok(true)
1158 },
1159 )?,
1160 )?;
1161 t.set(
1163 "close_menu",
1164 scope.create_function(move |_, ()| {
1165 let mut ui = ui.borrow_mut();
1166 Ok(ui.close_menu())
1167 })?,
1168 )?;
1169 t.set(
1174 "selection_text",
1175 scope.create_function(move |_, ()| {
1176 let ui = ui.borrow();
1177 Ok(ui.selection_text())
1178 })?,
1179 )?;
1180 t.set(
1186 "request_copy",
1187 scope.create_function(move |_, ()| {
1188 let mut ui = ui.borrow_mut();
1189 Ok(match ui.request_copy() {
1190 kui_core::CopyRequest::Ready(text) => (Some(text), false),
1191 kui_core::CopyRequest::Asked => (None, true),
1192 kui_core::CopyRequest::Nothing => (None, false),
1193 })
1194 })?,
1195 )?;
1196 t.set(
1199 "answer_selection_range",
1200 scope.create_function(move |_, text: String| {
1201 let mut ui = ui.borrow_mut();
1202 Ok(ui.answer_selection_range(&text))
1203 })?,
1204 )?;
1205 t.set(
1211 "set_clipboard",
1212 scope.create_function(move |_, (text, html): (String, Option<String>)| {
1213 ui.borrow_mut().set_clipboard(text, html);
1214 Ok(())
1215 })?,
1216 )?;
1217 t.set(
1221 "set_clipboard_secret",
1222 scope.create_function(move |_, text: String| {
1223 ui.borrow_mut().set_clipboard_secret(text);
1224 Ok(())
1225 })?,
1226 )?;
1227 t.set(
1228 "request_paste",
1229 scope.create_function(move |_, ()| {
1230 ui.borrow_mut().request_paste();
1231 Ok(())
1232 })?,
1233 )?;
1234 t.set(
1235 "awaiting_paste",
1236 scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_paste()))?,
1237 )?;
1238 t.set(
1244 "request_files",
1245 scope.create_function(move |_, opts: Option<mlua::Value>| {
1246 let v = match &opts {
1247 Some(o) => lua_to_value(o)?,
1248 None => Value::Null,
1249 };
1250 let dialog = kui_core::FileDialog::from_value(&v)
1251 .map_err(|e| mlua::Error::runtime(format!("request_files: {e}")))?;
1252 Ok(ui.borrow_mut().request_files(dialog))
1253 })?,
1254 )?;
1255 t.set(
1256 "awaiting_files",
1257 scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_files()))?,
1258 )?;
1259 t.set(
1266 "selection_ends",
1267 scope.create_function(move |lua, ()| {
1268 let ui = ui.borrow();
1269 let Some((a, f)) = ui.selection_ends() else {
1270 return Ok(mlua::Value::Nil);
1271 };
1272 let end = |e: kui_core::RangeEnd| -> mlua::Result<mlua::Table> {
1273 let t = lua.create_table()?;
1274 if let Some(r) = e.row {
1275 t.set("index", r)?;
1276 }
1277 t.set("byte", e.byte)?;
1278 Ok(t)
1279 };
1280 let out = lua.create_table()?;
1281 out.set("anchor", end(a)?)?;
1282 out.set("focus", end(f)?)?;
1283 Ok(mlua::Value::Table(out))
1284 })?,
1285 )?;
1286 t.set(
1294 "cell_selection",
1295 scope.create_function(move |lua, ()| {
1296 let ui = ui.borrow();
1297 match ui.cell_selection() {
1298 Some(sel) => value_to_lua(lua, &sel.to_value(kui_core::Handles::INT)),
1299 None => Ok(mlua::Value::Nil),
1300 }
1301 })?,
1302 )?;
1303 t.set(
1307 "selection_html",
1308 scope.create_function(move |_, ()| {
1309 let ui = ui.borrow();
1310 Ok(ui.selection_html())
1311 })?,
1312 )?;
1313 t.set(
1318 "select_all_in",
1319 scope.create_function(move |_, key: mlua::Value| {
1320 let mut ui = ui.borrow_mut();
1321 let Some(key) = key_query(&mut ui, key)? else {
1322 return Ok(false);
1323 };
1324 Ok(ui.select_all_in(key))
1325 })?,
1326 )?;
1327 t.set(
1330 "clear_selection",
1331 scope.create_function(move |_, ()| {
1332 let mut ui = ui.borrow_mut();
1333 Ok(ui.clear_selection())
1334 })?,
1335 )?;
1336 t.set(
1340 "caret_rect",
1341 scope.create_function(move |lua, (key, byte): (mlua::Value, usize)| {
1342 let mut ui = ui.borrow_mut();
1343 let Some(key) = key_query(&mut ui, key)? else {
1344 return Ok(mlua::Value::Nil);
1345 };
1346 let Some(r) = ui.caret_rect(key, byte) else {
1347 return Ok(mlua::Value::Nil);
1348 };
1349 value_to_lua(lua, &r.to_value())
1350 })?,
1351 )?;
1352 t.set(
1353 "scroll_geometry",
1354 scope.create_function(move |lua, key: mlua::Value| {
1355 let mut ui = ui.borrow_mut();
1356 let Some(key) = key_query(&mut ui, key)? else {
1357 return Ok(mlua::Value::Nil);
1358 };
1359 let Some(g) = ui.scroll_geometry(key) else {
1360 return Ok(mlua::Value::Nil);
1361 };
1362 value_to_lua(lua, &g.to_value())
1364 })?,
1365 )?;
1366 t.set(
1369 "layout_of",
1370 scope.create_function(move |lua, key: mlua::Value| {
1371 let mut ui = ui.borrow_mut();
1372 let Some(key) = key_query(&mut ui, key)? else {
1373 return Ok(mlua::Value::Nil);
1374 };
1375 let Some(r) = ui.layout_of(key) else {
1376 return Ok(mlua::Value::Nil);
1377 };
1378 value_to_lua(lua, &r.to_value())
1379 })?,
1380 )?;
1381 t.set(
1384 "set_scroll",
1385 scope.create_function(move |_, (key, x, y): (mlua::Value, f32, f32)| {
1386 let mut ui = ui.borrow_mut();
1387 let at = kui_core::Vec2::new(x, y);
1388 match key {
1391 mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
1392 ui.set_scroll_label(&s.to_str()?, at);
1393 }
1394 key => {
1395 let key = key_arg(&mut ui, key)?;
1396 ui.set_scroll(key, at);
1397 }
1398 }
1399 Ok(())
1400 })?,
1401 )?;
1402 t.set(
1407 "shift_scroll",
1408 scope.create_function(move |_, (key, drawn, target): (mlua::Value, f32, f32)| {
1409 let mut ui = ui.borrow_mut();
1410 if let Some(key) = key_query(&mut ui, key)? {
1411 ui.shift_scroll(
1412 key,
1413 kui_core::Vec2::new(0.0, drawn),
1414 kui_core::Vec2::new(0.0, target),
1415 );
1416 }
1417 Ok(())
1418 })?,
1419 )?;
1420 t.set(
1428 "set_window_size",
1429 scope.create_function(move |_, (window, w, h): (i64, f32, f32)| {
1430 ui.borrow_mut()
1431 .set_window_size(kui_core::WindowId(window as u32), kui_core::Size::new(w, h));
1432 Ok(())
1433 })?,
1434 )?;
1435 let env = t.clone();
1440 t.set(
1441 "set_tokens",
1442 scope.create_function(move |lua, decl: Table| {
1443 let tokens = parse_tokens(&decl)?;
1444 ui.borrow_mut().set_tokens(tokens);
1445 env.set("tokens", tokens_table(lua, &ui.borrow())?)
1446 })?,
1447 )?;
1448 t.set(
1449 "focus_window",
1450 scope.create_function(move |_, window: i64| {
1451 ui.borrow_mut()
1452 .focus_window(kui_core::WindowId(window as u32));
1453 Ok(())
1454 })?,
1455 )?;
1456 t.set(
1457 "measure_text",
1458 scope.create_function(
1459 move |lua, (s, opts, max_w): (mlua::Value, Option<Table>, Option<f32>)| {
1460 let mut guard = ui.borrow_mut();
1461 let m = measure_from_lua(&mut guard, &s, opts.as_ref(), max_w)?;
1462 value_to_lua(lua, &m.to_value())
1463 },
1464 )?,
1465 )?;
1466 t.set(
1483 "add_extension",
1484 scope.create_function(move |_, (namespace, path): (String, String)| {
1485 let path = std::path::PathBuf::from(path);
1486 if let Some(prev) = loaded.borrow().iter().find(|l| l.namespace == namespace) {
1487 return Ok(if prev.path == path {
1488 (Some(true), None)
1490 } else {
1491 (
1492 None,
1493 Some(format!(
1494 "`{namespace}` is already {} in this script; a second plugin needs a \
1495 second namespace",
1496 prev.path.display()
1497 )),
1498 )
1499 });
1500 }
1501 let ext = match unsafe { kui_ffi::CExtension::open(&path) } {
1505 Ok(ext) => ext,
1506 Err(e) => return Ok((None, Some(e))),
1507 };
1508 let origin = match ui.borrow_mut().add_extension(&namespace, Box::new(ext)) {
1509 Ok(origin) => origin,
1510 Err(e) => return Ok((None, Some(e))),
1511 };
1512 loaded.borrow_mut().push(Loaded {
1513 namespace,
1514 path,
1515 origin,
1516 });
1517 Ok((Some(true), None))
1518 })?,
1519 )?;
1520 t.set(
1523 "extension_namespaces",
1524 scope.create_function(move |lua, ()| {
1525 lua.create_sequence_from(loaded.borrow().iter().map(|l| l.namespace.clone()))
1526 })?,
1527 )?;
1528 Ok(t)
1529}
1530
1531fn tokens_table(lua: &Lua, ui: &Ui<'_>) -> mlua::Result<Table> {
1543 let look = ui.tokens();
1544 let tokens = lua.create_table()?;
1545 let colors = lua.create_table()?;
1546 for (name, c) in look.colors() {
1547 colors.set(name, c.to_hex())?;
1548 }
1549 let lengths = lua.create_table()?;
1550 for (name, v) in look.lengths() {
1551 lengths.set(name, v)?;
1552 }
1553 tokens.set("colors", colors)?;
1554 tokens.set("lengths", lengths)?;
1555 Ok(tokens)
1556}
1557
1558fn measure_from_lua(
1559 ui: &mut Ui<'_>,
1560 s: &mlua::Value,
1561 opts: Option<&Table>,
1562 max_w: Option<f32>,
1563) -> mlua::Result<kui_core::TextMetrics> {
1564 let style = match opts {
1565 Some(t) => with_refs(ui, |refs| parse_props(t, false, refs))?.style,
1566 None => kui_core::TextStyle::default(),
1567 };
1568 let measure_spans = |ui: &mut Ui<'_>, spans: &Table, style: &kui_core::TextStyle| {
1569 let parts = with_refs(ui, |refs| collect_spans(spans, refs))?;
1570 let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1571 Ok(ui.measure_rich_text(&spans, style, max_w))
1572 };
1573 match s {
1574 mlua::Value::String(s) => Ok(ui.measure_text(&s.to_str()?, &style, max_w)),
1575 mlua::Value::Table(t) => {
1576 if t.get::<Option<String>>("type")?.as_deref() == Some("text") {
1577 let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
1578 match t.get::<Option<Table>>("spans")? {
1579 Some(spans) => measure_spans(ui, &spans, &style),
1580 None => {
1581 let value: String = t.get("value")?;
1582 Ok(ui.measure_text(&value, &style, max_w))
1583 }
1584 }
1585 } else {
1586 measure_spans(ui, t, &style)
1587 }
1588 }
1589 other => Err(bad(format!(
1590 "measure_text: expected a string, a span list or a text node, got {}",
1591 other.type_name()
1592 ))),
1593 }
1594}
1595
1596fn bad(msg: impl std::fmt::Display) -> mlua::Error {
1600 mlua::Error::runtime(msg.to_string())
1601}
1602
1603fn build_children(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1604 for child in t.sequence_values::<Table>() {
1605 build_node(ui, &child?)?;
1606 }
1607 Ok(())
1608}
1609
1610fn with_children(
1613 ui: &mut Ui<'_>,
1614 t: &Table,
1615 widget: impl FnOnce(&mut Ui<'_>, &mut dyn FnMut(&mut Ui<'_>)),
1616) -> mlua::Result<()> {
1617 let mut result = Ok(());
1618 widget(ui, &mut |ui| result = build_children(ui, t));
1619 result
1620}
1621
1622fn element_of(ty: &str) -> &str {
1626 match ty {
1627 "row" | "column" => "box",
1628 "grid" => "table",
1629 "input" => "edit",
1630 "dropdown" => "select",
1631 "radio_group" => "radioGroup",
1632 "window_buttons" => "windowButtons",
1633 "menu_bar" => "menuBar",
1634 "latency_graph" | "latency_hud" => "latencyGraph",
1635 other => other,
1636 }
1637}
1638
1639fn menu_bar_of(t: &Table) -> mlua::Result<(kui_core::MenuBar, Vec<String>)> {
1640 let Some(list) = t.get::<Option<Table>>("menu")? else {
1641 return Ok((kui_core::MenuBar::default(), Vec::new()));
1642 };
1643 let mut menus = lua_list_to_value(&list)?;
1644 if let Value::List(menus) = &mut menus {
1645 for menu in menus.iter_mut() {
1646 let Value::Map(fields) = menu else { continue };
1647 for (k, items) in fields.iter_mut() {
1648 if k != "items" {
1649 continue;
1650 }
1651 if matches!(items, Value::Map(m) if m.is_empty()) {
1653 *items = Value::List(Vec::new());
1654 }
1655 if let Value::List(rows) = items {
1656 rows.iter_mut().for_each(alias_menu_role);
1657 }
1658 }
1659 }
1660 }
1661 let bar = kui_core::MenuBar::from_value(&menus).map_err(mlua::Error::runtime)?;
1662 Ok((bar, kui_core::MenuBar::stray_keys(&menus)))
1663}
1664
1665fn declare_windows(ui: &mut Ui<'_>, root: &Table) -> mlua::Result<()> {
1666 let Some(list) = root.get::<Option<Table>>("windows")? else {
1667 return Ok(());
1668 };
1669 for entry in list.sequence_values::<mlua::Value>() {
1673 let v = lua_to_value(&entry?)?;
1674 let (name, cfg) = WindowConfig::from_value(&v).map_err(mlua::Error::runtime)?;
1675 ui.window(&name, cfg);
1676 }
1677 Ok(())
1678}
1679
1680fn check_props(ui: &mut Ui<'_>, t: &Table, element: &str) -> mlua::Result<()> {
1684 if !ui.core().diagnostics() {
1685 return Ok(());
1686 }
1687 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1688 let (k, _) = pair?;
1689 let mlua::Value::String(k) = k else { continue };
1690 let name = k.to_str()?;
1691 if name.as_ref() == "type" || schema::known_prop(element, &name, schema::Spelling::Snake) {
1693 continue;
1694 }
1695 let w = kui_core::diag::unknown_prop(element, &name, schema::Spelling::Snake);
1696 ui.core().warn(w);
1697 }
1698 Ok(())
1699}
1700
1701fn build_fill(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1711 let name: String = t
1712 .get::<Option<String>>("name")?
1713 .filter(|n| !n.is_empty())
1714 .ok_or_else(|| bad("fill needs a name (\"namespace/slot\")"))?;
1715 if !name.contains(kui_core::NAMESPACE_SEPARATOR) {
1716 return Err(bad(format!(
1717 "bad slot name {name:?} (a full \"namespace/slot\")"
1718 )));
1719 }
1720 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1721 let (k, _) = pair?;
1722 let mlua::Value::String(k) = k else { continue };
1723 let k = k.to_str()?;
1724 if !matches!(k.as_ref(), "type" | "name" | "params" | "keep" | "replay") {
1725 return Err(bad(format!(
1726 "fill takes name, params, keep and replay, not {:?} — it is a position, not a box",
1727 k.as_ref()
1728 )));
1729 }
1730 }
1731 let params = match t.get::<mlua::Value>("params")? {
1732 mlua::Value::Nil => Value::Null,
1733 v => lua_to_value(&v)?,
1734 };
1735 let keep: bool = t.get::<Option<bool>>("keep")?.unwrap_or(false);
1740 let replay: bool = t.get::<Option<bool>>("replay")?.unwrap_or(false);
1741 if keep && replay {
1742 return Err(bad("fill takes keep or replay, not both"));
1743 }
1744 if replay {
1745 ui.slot_replay(&name, ¶ms);
1746 } else if keep {
1747 ui.slot_kept(&name, ¶ms);
1748 } else {
1749 ui.slot_with(&name, ¶ms);
1750 }
1751 Ok(())
1752}
1753
1754fn build_devtools_tab(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1763 let name: String = t.get::<Option<String>>("name")?.unwrap_or_default();
1764 let refuse = |ui: &mut Ui<'_>, why: &str| {
1765 ui.core().warn(kui_core::diag::bad_devtools_tab(&name, why));
1766 Ok(())
1767 };
1768 if name.is_empty() {
1769 return refuse(ui, "it needs a name (its identity)");
1770 }
1771 let label: String = t
1772 .get::<Option<String>>("label")?
1773 .unwrap_or_else(|| name.clone());
1774 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1775 let (k, _) = pair?;
1776 let mlua::Value::String(k) = k else { continue };
1777 let k = k.to_str()?;
1778 if !matches!(k.as_ref(), "type" | "name" | "label" | "slot" | "view") {
1779 return refuse(
1780 ui,
1781 &format!(
1782 "it takes name, label and slot or view, not {:?}",
1783 k.as_ref()
1784 ),
1785 );
1786 }
1787 }
1788 let slot = t.get::<Option<String>>("slot")?;
1789 let view = t.get::<mlua::Value>("view")?;
1790 match (slot, view) {
1791 (Some(slot), mlua::Value::Nil) => {
1792 if !slot.contains(kui_core::NAMESPACE_SEPARATOR) {
1793 return refuse(
1794 ui,
1795 &format!("bad slot {slot:?} (a full \"namespace/slot\")"),
1796 );
1797 }
1798 ui.devtools_tab(&name, &label, &slot);
1799 Ok(())
1800 }
1801 (None, mlua::Value::Function(view)) => {
1802 let mut result = Ok(());
1803 ui.devtools_tab_with(&name, &label, |ui| {
1804 result = view
1805 .call::<Table>(())
1806 .and_then(|tree| build_node(ui, &tree));
1807 });
1808 result
1809 }
1810 (Some(_), mlua::Value::Function(_)) => refuse(ui, "it takes a slot or a view, not both"),
1811 (None, mlua::Value::Nil) => refuse(ui, "it needs a slot or a view function"),
1812 (_, other) => refuse(
1813 ui,
1814 &format!("view is a {}, not a function", other.type_name()),
1815 ),
1816 }
1817}
1818
1819pub const MAX_VIEW_DEPTH: usize = 128;
1823
1824thread_local! {
1825 static VIEW_PATH: std::cell::RefCell<Vec<*const std::ffi::c_void>> =
1829 const { std::cell::RefCell::new(Vec::new()) };
1830}
1831
1832fn build_node(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1833 let at = t.to_pointer();
1834 VIEW_PATH.with_borrow_mut(|path| {
1835 if path.contains(&at) {
1836 return Err(bad("a view node that holds itself"));
1837 }
1838 if path.len() >= MAX_VIEW_DEPTH {
1839 return Err(bad(format!("a view nested past {MAX_VIEW_DEPTH} nodes")));
1840 }
1841 path.push(at);
1842 Ok(())
1843 })?;
1844 let built = build_one(ui, t);
1845 VIEW_PATH.with_borrow_mut(|path| path.pop());
1846 built
1847}
1848
1849fn build_one(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1856 let ty: String = t.get("type")?;
1857 match ty.as_str() {
1858 "fill" => build_fill(ui, t),
1859 "devtools_tab" => build_devtools_tab(ui, t),
1860 "row" | "column" | "grid" => {
1861 open_box(ui, t, &ty)?;
1862 build_children(ui, t)?;
1863 ui.close();
1864 Ok(())
1865 }
1866 "fragment" => {
1867 open_fragment(ui, t)?;
1868 build_children(ui, t)?;
1869 ui.close();
1870 Ok(())
1871 }
1872 "titlebar" | "tooltip" | "radio_group" => build_holder(ui, t, &ty),
1873 _ => build_widget(ui, t, &ty),
1874 }
1875}
1876
1877#[inline(never)]
1878fn open_box(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1879 check_props(ui, t, element_of(ty))?;
1880 let mut p = with_refs(ui, |refs| parse_props(t, ty == "row", refs))?;
1881 p.spec.layout.table = ty == "grid";
1883 ui.core().open_from(p, Content::Box);
1884 Ok(())
1885}
1886
1887#[inline(never)]
1888fn open_fragment(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1889 check_props(ui, t, element_of("fragment"))?;
1890 let id: i64 = t.get("id")?;
1895 let image: Option<i64> = t.get("image")?;
1896 let params: Vec<f32> = match t.get::<Option<Table>>("params")? {
1897 Some(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
1898 None => Vec::new(),
1899 };
1900 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1901 let frag = kui_core::FragmentRef {
1902 id: kui_core::FragmentId::from_ffi(id as u64),
1903 image: image
1904 .filter(|i| *i != 0)
1905 .map(|i| kui_core::ImageId::from_ffi(i as u64)),
1906 };
1907 ui.core().open_from(p, Content::Fragment(frag, ¶ms));
1908 Ok(())
1909}
1910
1911#[inline(never)]
1914fn build_holder(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1915 check_props(ui, t, element_of(ty))?;
1916 match ty {
1917 "titlebar" => {
1918 if t.raw_len() > 0 {
1919 with_children(ui, t, |ui, body| widgets::titlebar_with(ui, body))
1920 } else {
1921 let title: String = t.get::<Option<String>>("title")?.unwrap_or_default();
1922 widgets::titlebar(ui, &title);
1923 Ok(())
1924 }
1925 }
1926 "tooltip" => {
1927 if t.raw_len() > 0 {
1928 with_children(ui, t, |ui, body| widgets::tooltip_with(ui, body))
1929 } else {
1930 let value: String = t.get("value")?;
1931 widgets::tooltip(ui, &value);
1932 Ok(())
1933 }
1934 }
1935 "radio_group" => {
1936 let label: String = t.get("label")?;
1939 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1940 let mut result = Ok(());
1941 widgets::radio_group_with(ui, &label, p.spec, |ui| result = build_children(ui, t));
1942 result
1943 }
1944 other => unreachable!("{other} holds no children"),
1945 }
1946}
1947
1948#[inline(never)]
1949fn build_widget(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1950 check_props(ui, t, element_of(ty))?;
1951 match ty {
1952 "text" => {
1953 let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
1954 if let Some(spans) = t.get::<Option<Table>>("spans")? {
1955 let parts = with_refs(ui, |refs| collect_spans(&spans, refs))?;
1956 let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1957 ui.rich_text(&spans, style);
1958 } else {
1959 let value: String = t.get("value")?;
1960 ui.text(&value, style);
1961 }
1962 Ok(())
1963 }
1964 "image" => {
1965 let id: i64 = t.get("id")?;
1969 let spec = with_refs(ui, |refs| parse_props(t, false, refs))?
1971 .for_leaf()
1972 .spec;
1973 let named = |row: &str, names: &[&str]| -> mlua::Result<usize> {
1974 match t.get::<Option<String>>(row)? {
1975 None => Ok(0),
1976 Some(s) => names
1977 .iter()
1978 .position(|n| *n == s)
1979 .ok_or_else(|| bad(format!("{row} must be one of {}", names.join(", ")))),
1980 }
1981 };
1982 let sampling_names: Vec<&str> =
1983 kui_core::Sampling::ALL.iter().map(|s| s.name()).collect();
1984 let fit_names: Vec<&str> = kui_core::ImageFit::ALL.iter().map(|f| f.name()).collect();
1985 let opts = kui_core::ImageOpts {
1986 sampling: kui_core::Sampling::ALL[named("sampling", &sampling_names)?],
1987 fit: kui_core::ImageFit::ALL[named("fit", &fit_names)?],
1988 };
1989 ui.image_with(kui_core::ImageId::from_ffi(id as u64), opts, spec);
1990 Ok(())
1991 }
1992 "polygon" => {
1993 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1996 let Some(list) = t.get::<Option<Table>>("points")? else {
1997 return Err(bad("polygon needs points"));
1998 };
1999 let points: Vec<kui_core::Vec2> = list
2000 .sequence_values::<mlua::Value>()
2001 .map(|v| {
2002 let mlua::Value::Table(pt) = v? else {
2003 return Err(bad("a polygon point is a {x, y} table"));
2004 };
2005 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
2006 })
2007 .collect::<mlua::Result<_>>()?;
2008 ui.core().open_from(p, Content::Polygon(&points));
2009 Ok(())
2010 }
2011 "path" => {
2012 let mut p = with_refs(ui, |refs| parse_props(t, false, refs))?;
2017 if let Some(i) = p.spec.interact.as_deref_mut()
2023 && let Some(tr) = i.transform.as_deref_mut()
2024 {
2025 tr.rotate = 0.0;
2026 if *tr == kui_core::TransformSpec::NONE {
2027 i.transform = None;
2028 }
2029 }
2030 let rule = match t.get::<Option<String>>("fill_rule")? {
2031 Some(name) => kui_core::FillRule::parse(&name)
2032 .ok_or_else(|| bad("fill_rule is \"nonzero\" or \"evenodd\""))?,
2033 None => kui_core::FillRule::NonZero,
2034 };
2035 let width = match t.get::<mlua::Value>("width")? {
2036 mlua::Value::Nil => None,
2037 v => with_refs(ui, |refs| length_of(&v, refs))?,
2038 };
2039 let dash = dash_of(t)?;
2040 let stroke = width.filter(|w| *w > 0.0).map(|w| {
2041 kui_core::Stroke::new(w, p.style.color.unwrap_or(ui.theme().fg)).dashed(dash)
2042 });
2043 let turns = t.get::<Option<f32>>("rotate")?;
2047 let pivot = match t.get::<Option<Table>>("pivot")? {
2048 Some(p) => Some(kui_core::Vec2::new(p.get::<f32>(1)?, p.get::<f32>(2)?)),
2049 None => None,
2050 };
2051 let turn = (turns.is_some() || pivot.is_some()).then_some(kui_core::Turn {
2052 turns: turns.unwrap_or(0.0),
2053 pivot,
2054 });
2055 if let Some(d) = t.get::<Option<String>>("d")? {
2056 ui.core()
2057 .open_from(p, Content::PathD(&d, rule, stroke, turn));
2058 } else if let Some(list) = t.get::<Option<Table>>("ops")? {
2059 let floats: Vec<f32> =
2060 list.sequence_values::<f32>().collect::<mlua::Result<_>>()?;
2061 ui.core()
2062 .open_from(p, Content::PathFlat(&floats, rule, stroke, turn));
2063 } else {
2064 return Err(bad("path needs d or ops"));
2065 }
2066 Ok(())
2067 }
2068 "line" => {
2069 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
2075 let point = |v: mlua::Value| -> mlua::Result<kui_core::Vec2> {
2076 let mlua::Value::Table(pt) = v else {
2077 return Err(bad("a line point is a {x, y} table"));
2078 };
2079 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
2080 };
2081 let points: Vec<kui_core::Vec2> = match t.get::<Option<Table>>("points")? {
2082 Some(list) => list
2083 .sequence_values::<mlua::Value>()
2084 .map(|v| point(v?))
2085 .collect::<mlua::Result<_>>()?,
2086 None => {
2087 let (Some(from), Some(to)) = (
2088 t.get::<Option<mlua::Value>>("from")?,
2089 t.get::<Option<mlua::Value>>("to")?,
2090 ) else {
2091 return Err(bad("line needs from and to, or points"));
2092 };
2093 vec![point(from)?, point(to)?]
2094 }
2095 };
2096 let width = match t.get::<mlua::Value>("width")? {
2099 mlua::Value::Nil => None,
2100 v => with_refs(ui, |refs| length_of(&v, refs))?,
2101 }
2102 .unwrap_or(1.0);
2103 let stroke_color = p.style.color.unwrap_or(ui.theme().fg);
2105 let mut stroke = kui_core::Stroke::new(width, stroke_color);
2106 stroke.curve = t.get::<Option<bool>>("curve")?.unwrap_or(false);
2107 stroke.dash = dash_of(t)?;
2108 ui.core().open_from(p, Content::Line(&points, stroke));
2109 Ok(())
2110 }
2111 "cells" => {
2112 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
2118 let rows: usize = t.get::<Option<usize>>("rows")?.unwrap_or(0);
2119 let cols: usize = t.get::<Option<usize>>("cols")?.unwrap_or(0);
2120 if rows == 0 || cols == 0 {
2121 return Err(bad("cells needs rows and cols"));
2122 }
2123 let default_fg = p.style.color.unwrap_or(ui.theme().fg).to_hex();
2124 let mut cells = vec![kui_core::Cell::new(' ', default_fg, 0); rows * cols];
2125 if let Some(lines) = t.get::<Option<Table>>("lines")? {
2126 for (r, line) in lines.sequence_values::<String>().enumerate() {
2127 if r >= rows {
2128 break;
2129 }
2130 for (c, ch) in line?.chars().take(cols).enumerate() {
2131 cells[r * cols + c].ch = ch;
2132 }
2133 }
2134 }
2135 if let Some(runs) = t.get::<Option<Table>>("runs")? {
2136 for run in runs.sequence_values::<Table>() {
2137 let run = run?;
2138 let row: usize = run.get(1)?;
2139 let col: usize = run.get(2)?;
2140 let len: usize = run.get(3)?;
2141 let fg: u32 = run.get::<Option<u32>>(4)?.unwrap_or(0);
2142 let bg: u32 = run.get::<Option<u32>>(5)?.unwrap_or(0);
2143 let flags: u8 = run.get::<Option<u8>>(6)?.unwrap_or(0);
2144 let ul: u32 = run.get::<Option<u32>>(7)?.unwrap_or(0);
2145 if row >= rows {
2146 continue;
2147 }
2148 for c in col..(col + len).min(cols) {
2149 let cell = &mut cells[row * cols + c];
2150 if fg != 0 {
2151 cell.fg = fg;
2152 }
2153 if bg != 0 {
2154 cell.bg = bg;
2155 }
2156 cell.flags |= flags;
2157 if ul != 0 {
2158 cell.ul = ul;
2159 }
2160 }
2161 }
2162 }
2163 let cursor = match t.get::<Option<Table>>("cursor_at")? {
2164 Some(cur) => {
2165 let shape = match t.get::<Option<String>>("cursor_shape")? {
2168 None => kui_core::CellCursor::Block,
2169 Some(s) => kui_core::CellCursor::from_name(&s).ok_or_else(|| {
2170 bad(format!(
2171 "cursor_shape must be {}, not {s:?}",
2172 kui_core::CellCursor::NAMES.join(" | ")
2173 ))
2174 })?,
2175 };
2176 let color = match t.get::<mlua::Value>("cursor_color")? {
2177 mlua::Value::Nil => None,
2178 v => with_refs(ui, |refs| parse_color(&v, refs))?,
2179 }
2180 .unwrap_or(Color::rgb8(0xff, 0xff, 0xff));
2181 Some((cur.get::<usize>(1)?, cur.get::<usize>(2)?, shape, color))
2182 }
2183 None => None,
2184 };
2185 let origin_line = t.get::<Option<u64>>("origin_line")?.unwrap_or(0);
2187 let grid = kui_core::CellGrid {
2188 rows,
2189 cols,
2190 cells: &cells,
2191 style: p.style,
2192 cursor,
2193 origin_line,
2194 };
2195 ui.core().open_from(p, Content::Cells(&grid));
2196 Ok(())
2197 }
2198 "audio" => {
2199 let src: i64 = t.get("src")?;
2202 let mut spec = kui_core::AudioSpec::new(kui_core::SoundId::from_ffi(src as u64));
2203 if let Some(v) = t.get::<Option<f32>>("volume")? {
2204 spec = spec.volume(v);
2205 }
2206 if t.get::<Option<bool>>("loop")?.unwrap_or(false) {
2207 spec = spec.looped();
2208 }
2209 spec = spec.paused(t.get::<Option<bool>>("paused")?.unwrap_or(false));
2210 if t.get::<Option<bool>>("finish")?.unwrap_or(false) {
2211 spec = spec.finish();
2212 }
2213 if let Some(tag) = t.get::<Option<mlua::Value>>("tag")? {
2214 spec.tag = Some(lua_to_value(&tag)?);
2215 }
2216 match t.get::<Option<String>>("key")? {
2217 Some(k) => ui.audio_keyed(&k, spec),
2218 None => ui.audio(spec),
2219 };
2220 Ok(())
2221 }
2222 "input" => {
2223 let label: String = t.get("label")?;
2224 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
2225 widgets::text_input(ui, &label, &initial);
2226 Ok(())
2227 }
2228 "dropdown" => {
2229 let Some(label) = t.get::<Option<String>>("label")?.filter(|l| !l.is_empty()) else {
2233 return Err(bad("dropdown needs a label (its key and accessible name)"));
2234 };
2235 let Some(options) = t.get::<Option<Table>>("options")? else {
2236 return Err(bad(
2237 "dropdown needs options, a list of strings or menu rows",
2238 ));
2239 };
2240 let mut rows = lua_list_to_value(&options)?;
2241 if let Value::List(rows) = &mut rows {
2242 rows.iter_mut().for_each(alias_menu_role);
2243 }
2244 if ui.core().diagnostics() {
2248 for k in kui_core::MenuItem::stray_option_keys(&rows) {
2249 ui.core().warn(kui_core::diag::unknown_menu_item_key(&k));
2250 }
2251 }
2252 let items =
2253 kui_core::MenuItem::options_from_value(&rows).map_err(mlua::Error::runtime)?;
2254 let current = match t.get::<Option<i64>>("current")? {
2255 None => None,
2256 Some(i) if i >= 1 => Some(i as usize - 1),
2257 Some(i) => {
2258 return Err(bad(format!("dropdown current is an index from 1, not {i}")));
2259 }
2260 };
2261 widgets::select_items(ui, &label, &items, current);
2262 Ok(())
2263 }
2264 "edit" => {
2265 let p = with_refs(ui, |refs| parse_props(t, false, refs))?.for_leaf();
2267 let label = match p.key.clone().or(t.get::<Option<String>>("label")?) {
2268 Some(l) => l,
2269 None => return Err(bad("edit needs a key (state is retained by key)")),
2270 };
2271 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
2272 let opts = EditOptions {
2273 style: p.style,
2274 multiline: t.get::<Option<bool>>("multiline")?.unwrap_or(false),
2275 autofocus: t.get::<Option<bool>>("autofocus")?.unwrap_or(false),
2276 wrap: p.wrap,
2277 ..Default::default()
2278 };
2279 ui.text_edit(&label, &initial, &opts, p.spec);
2280 Ok(())
2281 }
2282 "window_buttons" => {
2283 widgets::window_buttons(ui);
2284 Ok(())
2285 }
2286 "menu_bar" => {
2287 let (bar, stray) = menu_bar_of(t)?;
2288 warn_stray_menu_keys(ui.core(), stray);
2289 widgets::menu_bar(ui, bar);
2290 Ok(())
2291 }
2292 "latency_graph" => {
2293 widgets::latency_graph(ui);
2294 Ok(())
2295 }
2296 "latency_hud" => {
2297 let (mut x, mut y) = (Align::End, Align::End);
2298 if let Some(at) = t.get::<Option<Table>>("at")? {
2299 x = parse_align(&at.get::<String>(1)?)?;
2300 y = parse_align(&at.get::<String>(2)?)?;
2301 }
2302 widgets::latency_hud_at(ui, x, y);
2303 Ok(())
2304 }
2305 "button" => {
2306 let label: String = t.get("label")?;
2316 let text: String = t
2317 .get::<Option<String>>("text")?
2318 .unwrap_or_else(|| label.clone());
2319 let key: String = t
2320 .get::<Option<String>>("key")?
2321 .unwrap_or_else(|| label.clone());
2322 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
2323 Some(v) => lua_to_value(&v)?,
2324 None => Value::Null,
2325 };
2326 let mut out = PropsOut::new();
2327 out.spec = widgets::button_spec(&ui.theme(), &ui.metrics())
2328 .on_click(payload)
2329 .label(label.as_str());
2330 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2331 out.apply_tooltip(&hint);
2332 }
2333 with_refs(ui, |refs| {
2334 for name in ["description", "disabled", "accent"] {
2335 let v = t.get::<mlua::Value>(name)?;
2336 if v.is_nil() {
2337 continue;
2338 }
2339 let def = schema::by_snake_name(name).expect("a button row");
2340 if let Some(parsed) =
2341 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2342 {
2343 schema::apply(def, parsed, &mut out).map_err(bad)?;
2344 }
2345 }
2346 Ok(())
2347 })?;
2348 match t.get::<Option<u64>>("index")? {
2351 Some(i) => widgets::button_indexed(ui, i, &text, out.spec, out.tooltip.as_deref()),
2352 None => widgets::button_with(ui, &key, &text, out.spec, out.tooltip.as_deref()),
2353 }
2354 Ok(())
2355 }
2356 "checkbox" | "radio" | "switch" => {
2357 let kind = match ty {
2362 "checkbox" => widgets::Toggle::Checkbox,
2363 "radio" => widgets::Toggle::Radio,
2364 _ => widgets::Toggle::Switch,
2365 };
2366 let label: String = t.get("label")?;
2367 let text: String = t
2368 .get::<Option<String>>("text")?
2369 .unwrap_or_else(|| label.clone());
2370 let key: String = t
2371 .get::<Option<String>>("key")?
2372 .unwrap_or_else(|| label.clone());
2373 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
2374 Some(v) => lua_to_value(&v)?,
2375 None => Value::Null,
2376 };
2377 let mut out = PropsOut::new();
2378 out.spec = widgets::toggle_spec(&ui.metrics())
2379 .on_click(payload)
2380 .label(label.as_str());
2381 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2382 out.apply_tooltip(&hint);
2383 }
2384 apply_named_rows(
2385 ui,
2386 t,
2387 &["description", "disabled", "checked", "mixed"],
2388 &mut out,
2389 )?;
2390 widgets::toggle_with(ui, kind, &key, &text, out.spec, out.tooltip.as_deref());
2391 Ok(())
2392 }
2393 "slider" => {
2394 let label: String = t.get("label")?;
2398 let key: String = t
2399 .get::<Option<String>>("key")?
2400 .unwrap_or_else(|| label.clone());
2401 let mut out = PropsOut::new();
2402 out.spec = widgets::slider_spec(&ui.metrics()).label(label.as_str());
2403 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2404 out.apply_tooltip(&hint);
2405 }
2406 apply_named_rows(
2407 ui,
2408 t,
2409 &[
2410 "description",
2411 "disabled",
2412 "value_now",
2413 "value_min",
2414 "value_max",
2415 "value_step",
2416 "value_text",
2417 "on_change",
2418 "width",
2419 "min_width",
2420 "max_width",
2421 ],
2422 &mut out,
2423 )?;
2424 widgets::slider_with(ui, &key, out.spec, out.tooltip.as_deref());
2425 Ok(())
2426 }
2427 other => Err(mlua::Error::runtime(format!("unknown node type '{other}'"))),
2428 }
2429}
2430
2431fn apply_named_rows(
2436 ui: &mut Ui<'_>,
2437 t: &Table,
2438 names: &[&str],
2439 out: &mut PropsOut,
2440) -> mlua::Result<()> {
2441 with_refs(ui, |refs| {
2442 for name in names {
2443 let v = t.get::<mlua::Value>(*name)?;
2444 if v.is_nil() {
2445 continue;
2446 }
2447 let def = schema::by_snake_name(name).expect("a schema row");
2448 if let Some(parsed) =
2449 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2450 {
2451 schema::apply(def, parsed, out).map_err(bad)?;
2452 }
2453 }
2454 Ok(())
2455 })
2456}
2457
2458struct SpanPart {
2459 text: String,
2460 bold: bool,
2461 italic: bool,
2462 underline: bool,
2463 underline_color: Option<Color>,
2466 underline_style: Option<kui_core::UnderlineStyle>,
2467 strikethrough: bool,
2468 color: Option<Color>,
2469 bg: Option<Color>,
2470 bg_radius: f32,
2473 family: Option<kui_core::FontFamily>,
2476 size: Option<f32>,
2478}
2479
2480fn span_of(p: &SpanPart) -> Span<'_> {
2481 let mut s = Span::new(&p.text);
2482 if p.bold {
2483 s = s.bold();
2484 }
2485 if p.italic {
2486 s = s.italic();
2487 }
2488 if p.underline {
2489 s = s.underline();
2490 }
2491 if let Some(c) = p.underline_color {
2492 s = s.underline_color(c);
2493 }
2494 if let Some(st) = p.underline_style {
2495 s = s.underline_style(st);
2496 }
2497 if p.strikethrough {
2498 s = s.strikethrough();
2499 }
2500 if let Some(c) = p.color {
2501 s = s.color(c);
2502 }
2503 if let Some(c) = p.bg {
2504 s = s.bg(c);
2505 }
2506 if p.bg_radius > 0.0 {
2507 s = s.bg_radius(p.bg_radius);
2508 }
2509 if let Some(f) = p.family {
2510 s = s.family(f);
2511 }
2512 if let Some(px) = p.size {
2513 s = s.size(px);
2514 }
2515 s
2516}
2517
2518fn collect_spans(spans: &Table, refs: &mut Refs<'_>) -> mlua::Result<Vec<SpanPart>> {
2520 let mut out = Vec::new();
2521 for item in spans.sequence_values::<mlua::Value>() {
2522 match item? {
2523 mlua::Value::String(s) => out.push(SpanPart {
2524 text: s.to_str()?.to_string(),
2525 bold: false,
2526 italic: false,
2527 underline: false,
2528 underline_color: None,
2529 underline_style: None,
2530 strikethrough: false,
2531 color: None,
2532 bg: None,
2533 bg_radius: 0.0,
2534 family: None,
2535 size: None,
2536 }),
2537 mlua::Value::Table(t) => {
2538 let text: String = t
2539 .get::<Option<String>>(1)?
2540 .ok_or_else(|| bad("span table needs its text at [1]"))?;
2541 let color = match t.get::<mlua::Value>("color")? {
2542 mlua::Value::Nil => None,
2543 v => parse_color(&v, refs)?,
2544 };
2545 let bg = match t.get::<mlua::Value>("bg")? {
2546 mlua::Value::Nil => None,
2547 v => parse_color(&v, refs)?,
2548 };
2549 let underline_color = match t.get::<mlua::Value>("underline_color")? {
2550 mlua::Value::Nil => None,
2551 v => parse_color(&v, refs)?,
2552 };
2553 let underline_style = match t.get::<Option<String>>("underline_style")? {
2554 None => None,
2555 Some(name) => Some(
2556 kui_core::UnderlineStyle::NAMES
2557 .iter()
2558 .position(|n| *n == name)
2559 .map(|i| kui_core::UnderlineStyle::from_index(i as u32))
2560 .ok_or_else(|| {
2561 bad(format!(
2562 "underline_style must be one of {}, got {name:?}",
2563 kui_core::UnderlineStyle::NAMES.join(" | ")
2564 ))
2565 })?,
2566 ),
2567 };
2568 let family = match t.get::<mlua::Value>("font")? {
2571 mlua::Value::Nil => match t.get::<mlua::Value>("family")? {
2572 mlua::Value::Nil => None,
2573 v => match parse_value(&Kind::Family, &v, refs)
2574 .map_err(|e| bad(format!("span family: {e}")))?
2575 {
2576 Some(Parsed::Family(f)) => Some(f),
2577 _ => None,
2578 },
2579 },
2580 v => match parse_value(&Kind::Resource, &v, refs)
2581 .map_err(|e| bad(format!("span font: {e}")))?
2582 {
2583 Some(Parsed::Resource(id)) => {
2584 Some(kui_core::FontFamily::Custom(kui_core::FontId::from_ffi(id)))
2585 }
2586 _ => None,
2587 },
2588 };
2589 out.push(SpanPart {
2590 family,
2591 size: t.get::<Option<f32>>("size")?,
2592 text,
2593 bold: t.get::<Option<bool>>("bold")?.unwrap_or(false),
2594 italic: t.get::<Option<bool>>("italic")?.unwrap_or(false),
2595 underline: t.get::<Option<bool>>("underline")?.unwrap_or(false),
2596 underline_color,
2597 underline_style,
2598 strikethrough: t.get::<Option<bool>>("strikethrough")?.unwrap_or(false),
2599 color,
2600 bg,
2601 bg_radius: t.get::<Option<f32>>("bg_radius")?.unwrap_or(0.0).max(0.0),
2602 });
2603 }
2604 other => {
2605 return Err(bad(format!(
2606 "span must be a string or table, got {}",
2607 other.type_name()
2608 )));
2609 }
2610 }
2611 }
2612 Ok(out)
2613}
2614
2615pub fn parse_tokens(t: &Table) -> mlua::Result<kui_core::Tokens> {
2625 let mut out = kui_core::Tokens::new();
2626 for pair in t.pairs::<String, mlua::Value>() {
2627 let (k, _) = pair?;
2628 if k != "colors" && k != "lengths" {
2629 return Err(bad(format!("tokens: unknown key {k:?} (colors, lengths)")));
2630 }
2631 }
2632 if let Some(colors) = t.get::<Option<Table>>("colors")? {
2633 let mut entries: Vec<(String, mlua::Value)> = colors
2634 .pairs::<String, mlua::Value>()
2635 .collect::<mlua::Result<_>>()?;
2636 entries.sort_by(|a, b| a.0.cmp(&b.0));
2637 let mut derived: Vec<(String, String, Vec<kui_core::ColorOp>)> = Vec::new();
2639 for (name, v) in entries {
2640 out = match &v {
2641 mlua::Value::Table(t) if t.contains_key("from")? => {
2642 let (from, ops) = parse_recipe(&name, t)?;
2643 derived.push((name, from, ops));
2644 out
2645 }
2646 mlua::Value::Table(halves) => {
2647 let half = |k: &str| -> mlua::Result<Color> {
2648 match halves.get::<mlua::Value>(k)? {
2649 mlua::Value::Nil => Err(bad(format!(
2650 "tokens.colors.{name}: needs both light and dark"
2651 ))),
2652 v => parse_color_value(&v),
2653 }
2654 };
2655 out.color_themed(&name, half("light")?, half("dark")?)
2656 }
2657 v => out.color(
2658 &name,
2659 parse_color_value(v).map_err(|e| bad(format!("tokens.colors.{name}: {e}")))?,
2660 ),
2661 };
2662 }
2663 while !derived.is_empty() {
2666 let ready = derived.iter().position(|(_, from, ops)| {
2667 let known = |s: &str| kui_core::tokens::is_role(s) || out.color_id(s).is_some();
2668 known(from)
2669 && ops.iter().all(|op| match op {
2670 kui_core::ColorOp::Mix(c, _) | kui_core::ColorOp::Readable(c, _) => {
2671 known(c)
2672 }
2673 _ => true,
2674 })
2675 });
2676 let (name, from, ops) = derived.remove(ready.unwrap_or(0));
2677 out = out.derive(&name, &from, ops);
2678 }
2679 }
2680 if let Some(lengths) = t.get::<Option<Table>>("lengths")? {
2681 let mut entries: Vec<(String, mlua::Value)> = lengths
2682 .pairs::<String, mlua::Value>()
2683 .collect::<mlua::Result<_>>()?;
2684 entries.sort_by(|a, b| a.0.cmp(&b.0));
2685 for (name, v) in entries {
2686 let px = number(&v)
2687 .ok_or_else(|| bad(format!("tokens.lengths.{name}: a length is a number")))?;
2688 out = out.length(&name, px);
2689 }
2690 }
2691 Ok(out)
2692}
2693
2694fn parse_recipe(name: &str, t: &Table) -> mlua::Result<(String, Vec<kui_core::ColorOp>)> {
2699 for pair in t.pairs::<String, mlua::Value>() {
2700 let (k, _) = pair?;
2701 if k != "from" && k != "ops" {
2702 return Err(bad(format!(
2703 "tokens.colors.{name}: unknown key {k:?} (from, ops)"
2704 )));
2705 }
2706 }
2707 let from = match t.get::<mlua::Value>("from")? {
2708 mlua::Value::String(s) => s.to_str()?.to_string(),
2709 _ => {
2710 return Err(bad(format!(
2711 "tokens.colors.{name}.from names a colour token or role"
2712 )));
2713 }
2714 };
2715 let ops = match t.get::<mlua::Value>("ops")? {
2716 mlua::Value::Nil => Vec::new(),
2717 mlua::Value::Table(list) => {
2718 let tuples: Vec<Table> = match list.get::<mlua::Value>(1)? {
2720 mlua::Value::String(_) => vec![list],
2721 _ => list
2722 .sequence_values::<Table>()
2723 .collect::<mlua::Result<_>>()?,
2724 };
2725 tuples
2726 .iter()
2727 .map(|op| parse_color_op(name, op))
2728 .collect::<mlua::Result<_>>()?
2729 }
2730 _ => {
2731 return Err(bad(format!(
2732 "tokens.colors.{name}.ops is a list of {{ verb, … }} tuples"
2733 )));
2734 }
2735 };
2736 Ok((from, ops))
2737}
2738
2739fn parse_color_op(name: &str, op: &Table) -> mlua::Result<kui_core::ColorOp> {
2740 let verb = match op.get::<mlua::Value>(1)? {
2741 mlua::Value::String(s) => s.to_str()?.to_string(),
2742 _ => {
2743 return Err(bad(format!(
2744 "tokens.colors.{name}.ops: an op starts with its verb"
2745 )));
2746 }
2747 };
2748 let Some(takes_color) = kui_core::ColorOp::takes_color(&verb) else {
2749 return Err(bad(format!(
2750 "tokens.colors.{name}.ops: unknown verb {verb:?} (lift, darken, raise, alpha, mix, readable)"
2751 )));
2752 };
2753 let arity = if takes_color { 3 } else { 2 };
2754 if op.raw_len() != arity {
2755 return Err(bad(format!(
2756 "tokens.colors.{name}.ops: {verb} takes {} — {{ \"{verb}\", {} }}",
2757 if takes_color {
2758 "a colour and a number"
2759 } else {
2760 "one number"
2761 },
2762 if takes_color { "token, t" } else { "t" }
2763 )));
2764 }
2765 let color = if takes_color {
2766 match op.get::<mlua::Value>(2)? {
2767 mlua::Value::String(s) => Some(s.to_str()?.to_string()),
2768 _ => {
2769 return Err(bad(format!(
2770 "tokens.colors.{name}.ops: {verb}'s colour is a token or role name"
2771 )));
2772 }
2773 }
2774 } else {
2775 None
2776 };
2777 let n = number(&op.get::<mlua::Value>(arity as i64)?).ok_or_else(|| {
2778 bad(format!(
2779 "tokens.colors.{name}.ops: {verb}'s number is a number"
2780 ))
2781 })?;
2782 Ok(kui_core::ColorOp::parse(&verb, color.as_deref(), n).expect("checked above"))
2783}
2784
2785pub type Refs<'a> = kui_core::NameRefs<'a>;
2791
2792fn with_refs<R>(
2796 ui: &mut Ui<'_>,
2797 f: impl FnOnce(&mut Refs<'_>) -> mlua::Result<R>,
2798) -> mlua::Result<R> {
2799 let (r, errors, families) = {
2800 let mut refs = Refs::new(ui.core().token_lookup());
2801 let r = f(&mut refs);
2802 (r, refs.take_missed(), refs.take_missed_families())
2803 };
2804 for e in errors {
2805 ui.core().warn_unknown_token(&e);
2806 }
2807 for name in families {
2808 ui.core().warn_unknown_family(&name);
2809 }
2810 r
2811}
2812
2813fn reference(v: &mlua::Value) -> mlua::Result<Option<String>> {
2815 if let mlua::Value::String(s) = v
2816 && let Some(name) = kui_core::tokens::reference(&s.to_str()?)
2817 {
2818 return Ok(Some(name.to_string()));
2819 }
2820 Ok(None)
2821}
2822
2823fn dash_of(t: &Table) -> mlua::Result<kui_core::Dash> {
2827 let offset = t.get::<Option<f32>>("dash_offset")?.unwrap_or(0.0);
2828 let lengths: Vec<f32> = match t.get::<mlua::Value>("dash")? {
2829 mlua::Value::Nil => return Ok(kui_core::Dash::SOLID),
2830 mlua::Value::Table(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
2831 mlua::Value::Integer(n) => vec![n as f32],
2832 mlua::Value::Number(n) => vec![n as f32],
2833 _ => Vec::new(),
2834 };
2835 kui_core::Dash::of(&lengths)
2836 .map(|d| d.offset(offset))
2837 .ok_or_else(|| bad("dash is a length, {mark, gap} or {mark, gap, mark, gap}"))
2838}
2839
2840fn length_of(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<f32>> {
2842 if let Some(name) = reference(v)? {
2843 return Ok(refs.length(&name));
2844 }
2845 number(v)
2846 .map(Some)
2847 .ok_or_else(|| bad("expected a number or a \"$token\""))
2848}
2849
2850pub fn parse_props(t: &Table, is_row: bool, refs: &mut Refs<'_>) -> mlua::Result<PropsOut> {
2858 let mut out = PropsOut::new();
2859 if is_row {
2860 out.spec = kui_core::NodeSpec::row();
2861 }
2862 match t.get::<mlua::Value>("size")? {
2863 mlua::Value::Nil => {}
2864 v => {
2865 if let Some(size) = length_of(&v, refs)? {
2866 out.style = kui_core::TextStyle::new(size);
2867 }
2868 }
2869 }
2870 match t.get::<mlua::Value>("radius")? {
2873 mlua::Value::Nil => {}
2874 v => {
2875 if let Some(r) = length_of(&v, refs)? {
2876 out.with_spec(|s| s.radius(r));
2877 }
2878 }
2879 }
2880 let mut overflow = 0;
2884 for pair in t.pairs::<mlua::Value, mlua::Value>() {
2885 let (k, v) = pair?;
2886 let mlua::Value::String(k) = k else { continue };
2887 let k = k.to_str()?;
2888 match k.as_ref() {
2889 "size" | "radius" => {}
2890 "pad" => {
2891 let pad = parse_pad(&v, refs)?;
2892 out.apply_pad(pad);
2893 }
2894 "border" => {
2895 let b = match v {
2896 mlua::Value::Table(b) => b,
2897 _ => return Err(bad("border must be a table {w=, color=}")),
2898 };
2899 let w = length_of(&b.get::<mlua::Value>("w")?, refs)?.unwrap_or(0.0);
2900 let c = parse_color(&b.get::<mlua::Value>("color")?, refs)?
2901 .unwrap_or(Color::TRANSPARENT);
2902 out.with_spec(|s| s.border(w, c));
2903 }
2904 "clip" => overflow |= bit(&v, kui_core::OVERFLOW_CLIP),
2905 "scroll_x" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_X),
2906 "scroll" | "scroll_y" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_Y),
2907 "float" => {
2908 let cfg = parse_float(&v)?;
2909 out.with_spec(|s| s.float(cfg));
2910 }
2911 "key_focus" => out.key_focus = truthy(&v),
2912 "key" => {
2913 let mlua::Value::String(s) = &v else {
2914 return Err(bad("key must be a string"));
2915 };
2916 out.key = Some(s.to_str()?.to_string());
2917 }
2918 "index" => {
2919 let Some(i) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
2920 return Err(bad("index must be a number (the row's data index)"));
2921 };
2922 out.index = Some(i.max(0.0) as u64);
2923 }
2924 "row_count" => {
2925 let Some(n) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
2926 return Err(bad(
2927 "row_count must be a number (how many indexed rows the list has)",
2928 ));
2929 };
2930 out.row_count = Some(n.max(0.0) as u64);
2931 }
2932 "tooltip" => {
2933 let mlua::Value::String(s) = &v else {
2934 return Err(bad("tooltip must be a string"));
2935 };
2936 out.apply_tooltip(s.to_str()?.as_ref());
2937 }
2938 name => {
2939 let name = schema::lua_alias(name).unwrap_or(name);
2943 let Some(def) = schema::by_snake_name(name) else {
2944 continue;
2945 };
2946 if let Some(parsed) =
2947 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2948 {
2949 schema::apply(def, parsed, &mut out).map_err(bad)?;
2950 }
2951 }
2952 }
2953 }
2954 out.with_spec(|s| s.overflow_bits(overflow));
2955 Ok(out)
2956}
2957
2958fn truthy(v: &mlua::Value) -> bool {
2959 matches!(v, mlua::Value::Boolean(true))
2960}
2961
2962fn bit(v: &mlua::Value, bit: u32) -> u32 {
2964 if truthy(v) { bit } else { 0 }
2965}
2966
2967fn number(v: &mlua::Value) -> Option<f32> {
2968 match v {
2969 mlua::Value::Number(n) => Some(*n as f32),
2970 mlua::Value::Integer(n) => Some(*n as f32),
2971 _ => None,
2972 }
2973}
2974
2975fn parse_value(kind: &Kind, v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Parsed>> {
2977 Ok(Some(match kind {
2978 Kind::F32 => match length_of(v, refs)? {
2979 Some(px) => Parsed::F32(px),
2980 None => return Ok(None),
2981 },
2982 Kind::Color => match parse_color(v, refs)? {
2983 Some(c) => Parsed::Color(c),
2984 None => return Ok(None),
2985 },
2986 Kind::Flag => {
2987 if truthy(v) {
2988 Parsed::Flag
2989 } else {
2990 return Ok(None);
2991 }
2992 }
2993 Kind::Enum(names) => {
2994 let mlua::Value::String(s) = v else {
2995 return Err(bad(format!("expected one of {names:?}")));
2996 };
2997 Parsed::Enum(schema::enum_index(names, &s.to_str()?).map_err(bad)?)
2998 }
2999 Kind::Sizing => match parse_sizing(v, refs)? {
3000 Some(s) => Parsed::Sizing(s),
3001 None => return Ok(None),
3002 },
3003 Kind::Min | Kind::Max => Parsed::Bound(match v {
3008 v if reference(v)?.is_some() => match length_of(v, refs)? {
3009 Some(px) => kui_core::Bound::Px(px),
3010 None => return Ok(None),
3011 },
3012 mlua::Value::String(s) => {
3015 let s = s.to_str()?;
3016 let b = if matches!(kind, Kind::Min) {
3017 schema::min_str(&s)
3018 } else {
3019 schema::max_str(&s)
3020 };
3021 match kept(b)? {
3022 Some(b) => b,
3023 None => return Ok(None),
3024 }
3025 }
3026 mlua::Value::Table(_) => match kept(kui_core::calc::bound_value(&size_value(v)?))? {
3027 Some(b) => b,
3028 None => return Ok(None),
3029 },
3030 v => kui_core::Bound::Px(
3031 number(v).ok_or_else(|| bad("expected a number, a string or a size table"))?,
3032 ),
3033 }),
3034 Kind::Msg | Kind::Tag => Parsed::Msg(lua_to_value(v)?),
3035 Kind::Str => {
3036 let mlua::Value::String(s) = v else {
3037 return Err(bad("expected a string"));
3038 };
3039 Parsed::Str(s.to_str()?.to_string())
3040 }
3041 Kind::Family => {
3044 let mlua::Value::String(s) = v else {
3045 return Err(bad("expected a family name"));
3046 };
3047 Parsed::Family(refs.family(&s.to_str()?))
3048 }
3049 Kind::Resource => match v {
3050 mlua::Value::Integer(n) => Parsed::Resource(*n as u64),
3051 mlua::Value::Number(n) => Parsed::Resource(*n as u64),
3052 _ => return Err(bad("expected a resource handle (integer)")),
3053 },
3054 Kind::Keyframes => Parsed::Keyframes(
3057 kui_core::keyframes::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
3058 ),
3059 Kind::Enter => {
3060 Parsed::Enter(kui_core::enter::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?)
3061 }
3062 Kind::Gradient => Parsed::Gradient(
3063 kui_core::gradient::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
3064 ),
3065 }))
3066}
3067
3068fn parse_color_value(v: &mlua::Value) -> mlua::Result<Color> {
3071 match v {
3072 mlua::Value::Integer(n) => Ok(schema::color_num(*n as u32)),
3073 mlua::Value::Number(n) => Ok(schema::color_num(*n as u32)),
3074 mlua::Value::String(s) => schema::color_hex_str(&s.to_str()?).map_err(bad),
3075 _ => Err(bad("color must be a 0xRRGGBBAA integer or \"#hex\" string")),
3076 }
3077}
3078
3079fn parse_color(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Color>> {
3081 if let Some(name) = reference(v)? {
3082 return Ok(refs.color(&name));
3083 }
3084 parse_color_value(v)
3085 .map(Some)
3086 .map_err(|_| bad("color must be a 0xRRGGBBAA integer, a \"#hex\" string or a \"$token\""))
3087}
3088
3089fn size_value(v: &mlua::Value) -> mlua::Result<Value> {
3094 fn check(v: &Value) -> mlua::Result<()> {
3095 match v {
3096 Value::Map(m) => {
3097 for (k, x) in m {
3098 if k == "percent" {
3099 return Err(bad("a percentage is { pct = n } in Lua"));
3100 }
3101 check(x)?;
3102 }
3103 Ok(())
3104 }
3105 Value::List(xs) => xs.iter().try_for_each(check),
3106 _ => Ok(()),
3107 }
3108 }
3109 let value = lua_to_value(v)?;
3110 check(&value)?;
3111 Ok(value)
3112}
3113
3114fn kept<T>(r: Result<T, String>) -> mlua::Result<Option<T>> {
3118 match r {
3119 Ok(v) => Ok(Some(v)),
3120 Err(e) if kui_core::calc::is_full(&e) => Ok(None),
3121 Err(e) => Err(bad(e)),
3122 }
3123}
3124
3125fn parse_sizing(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Sizing>> {
3126 if let Some(name) = reference(v)? {
3127 return Ok(refs.length(&name).map(Sizing::Fixed));
3128 }
3129 Ok(Some(match v {
3130 mlua::Value::Number(n) => Sizing::Fixed(*n as f32),
3131 mlua::Value::Integer(n) => Sizing::Fixed(*n as f32),
3132 mlua::Value::String(s) => match kept(schema::sizing_str(&s.to_str()?))? {
3133 Some(s) => s,
3134 None => return Ok(None),
3135 },
3136 mlua::Value::Table(t) => {
3137 if let Some(p) = t.get::<Option<f32>>("pct")? {
3138 Sizing::Percent(p / 100.0)
3139 } else if let Some(f) = t.get::<Option<f32>>("grow")? {
3140 Sizing::Grow(f)
3141 } else {
3142 match kept(kui_core::calc::sizing_value(&size_value(v)?)).map_err(|e| {
3145 bad(format!(
3146 "sizing table needs pct, grow or a size expression: {e}"
3147 ))
3148 })? {
3149 Some(s) => s,
3150 None => return Ok(None),
3151 }
3152 }
3153 }
3154 _ => return Err(bad("invalid sizing value")),
3155 }))
3156}
3157
3158fn parse_pad(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<PadShorthand> {
3162 match v {
3163 mlua::Value::Table(t) => {
3164 let mut edge = |k: &str| -> mlua::Result<Option<f32>> {
3165 match t.get::<mlua::Value>(k)? {
3166 mlua::Value::Nil => Ok(None),
3167 v => length_of(&v, refs),
3168 }
3169 };
3170 Ok(PadShorthand {
3171 all: edge("all")?,
3172 x: edge("x")?,
3173 y: edge("y")?,
3174 l: edge("l")?,
3175 r: edge("r")?,
3176 t: edge("t")?,
3177 b: edge("b")?,
3178 })
3179 }
3180 v => Ok(PadShorthand {
3181 all: length_of(v, refs).map_err(|_| bad("invalid padding value"))?,
3182 ..PadShorthand::default()
3183 }),
3184 }
3185}
3186
3187fn parse_align(s: &str) -> mlua::Result<Align> {
3188 schema::enum_index(schema::ALIGNS, s)
3189 .map(schema::align_idx)
3190 .map_err(bad)
3191}
3192
3193fn float_preset(name: &str) -> mlua::Result<FloatConfig> {
3196 FloatConfig::preset(name).ok_or_else(|| {
3197 bad(format!(
3198 "bad float preset '{name}' (one of {})",
3199 kui_core::FLOAT_PRESETS.join(" | ")
3200 ))
3201 })
3202}
3203
3204fn parse_attach(f: &Table, key: &str) -> mlua::Result<Option<(Align, Align)>> {
3206 let Some(at) = f.get::<Option<Table>>(key)? else {
3207 return Ok(None);
3208 };
3209 Ok(Some((
3210 parse_align(&at.get::<String>(1)?)?,
3211 parse_align(&at.get::<String>(2)?)?,
3212 )))
3213}
3214
3215fn parse_float(v: &mlua::Value) -> mlua::Result<FloatConfig> {
3216 let f = match v {
3217 mlua::Value::String(s) => return float_preset(&s.to_str()?),
3218 mlua::Value::Table(f) => f,
3219 _ => return Err(bad("float must be a preset string or a table")),
3220 };
3221 let self_at = match parse_attach(f, "self")? {
3224 Some(at) => Some(at),
3225 None => parse_attach(f, "self_at")?,
3226 };
3227 Ok(FloatConfig::build(
3228 match f.get::<Option<String>>("anchor")? {
3229 Some(name) => float_preset(&name)?,
3230 None => FloatConfig::parent(),
3231 },
3232 parse_attach(f, "at")?,
3233 self_at,
3234 f.get::<Option<f32>>("dx")?,
3235 f.get::<Option<f32>>("dy")?,
3236 f.get::<Option<bool>>("fit")?.unwrap_or(false),
3237 f.get::<Option<bool>>("clip")?.unwrap_or(false),
3238 ))
3239}
3240
3241pub const MAX_VALUE_DEPTH: usize = 64;
3248
3249pub fn lua_to_value(v: &mlua::Value) -> mlua::Result<Value> {
3267 to_value(
3268 v,
3269 &mut ValuePath {
3270 depth: 0,
3271 seen: Vec::new(),
3272 },
3273 )
3274}
3275
3276const VALUE_TRACKED_PAST: usize = 8;
3282
3283struct ValuePath {
3286 depth: usize,
3287 seen: Vec<*const std::ffi::c_void>,
3288}
3289
3290fn to_value(v: &mlua::Value, path: &mut ValuePath) -> mlua::Result<Value> {
3294 Ok(match v {
3295 mlua::Value::Nil => Value::Null,
3296 mlua::Value::Boolean(b) => Value::Bool(*b),
3297 mlua::Value::Integer(i) => Value::Int(*i),
3298 mlua::Value::Number(n) => Value::Float(*n),
3299 mlua::Value::String(s) => Value::Str(s.to_str()?.to_string()),
3300 mlua::Value::Table(t) => {
3301 let tracked = path.depth >= VALUE_TRACKED_PAST;
3302 if tracked {
3303 let at = t.to_pointer();
3304 if path.seen.contains(&at) {
3305 return Err(bad("a table that holds itself cannot be a value"));
3306 }
3307 path.seen.push(at);
3308 }
3309 if path.depth >= MAX_VALUE_DEPTH {
3310 return Err(bad(format!("a value nested past {MAX_VALUE_DEPTH} tables")));
3311 }
3312 path.depth += 1;
3313 let len = t.raw_len();
3314 let value = if len > 0 {
3315 let mut list = Vec::with_capacity(len);
3316 for item in t.sequence_values::<mlua::Value>() {
3317 list.push(to_value(&item?, path)?);
3318 }
3319 Value::List(list)
3320 } else {
3321 let mut map = Vec::new();
3322 for pair in t.pairs::<String, mlua::Value>() {
3323 let (k, v) = pair?;
3324 map.push((k, to_value(&v, path)?));
3325 }
3326 Value::Map(map)
3327 };
3328 path.depth -= 1;
3329 if tracked {
3330 path.seen.pop();
3331 }
3332 value
3333 }
3334 other => {
3335 return Err(mlua::Error::runtime(format!(
3336 "cannot convert {} to event payload",
3337 other.type_name()
3338 )));
3339 }
3340 })
3341}
3342
3343pub fn value_to_lua(lua: &Lua, v: &Value) -> mlua::Result<mlua::Value> {
3346 Ok(match v {
3347 Value::Null => mlua::Value::Nil,
3348 Value::Bool(b) => mlua::Value::Boolean(*b),
3349 Value::Int(i) => mlua::Value::Integer(*i),
3350 Value::Float(f) => mlua::Value::Number(*f),
3351 Value::Str(s) => mlua::Value::String(lua.create_string(s)?),
3352 Value::List(items) => {
3353 let t = lua.create_table_with_capacity(items.len(), 0)?;
3354 for (i, item) in items.iter().enumerate() {
3355 t.set(i + 1, value_to_lua(lua, item)?)?;
3356 }
3357 mlua::Value::Table(t)
3358 }
3359 Value::Map(entries) => {
3360 let t = lua.create_table_with_capacity(0, entries.len())?;
3361 for (k, v) in entries {
3362 t.set(k.as_str(), value_to_lua(lua, v)?)?;
3363 }
3364 mlua::Value::Table(t)
3365 }
3366 })
3367}
3368
3369#[cfg(test)]
3370mod tests {
3371 use super::*;
3372 use kui_core::{
3373 Core, Edges, FontFamily, InputEvent, NodeSpec, OriginId, Rect, Size, TextStyle, Vec2,
3374 WindowButton, WindowId,
3375 };
3376
3377 #[test]
3378 fn value_round_trips_through_lua() {
3379 let lua = Lua::new();
3380 let original = Value::map([
3381 ("kind", "inc".into()),
3382 ("by", Value::Int(2)),
3383 (
3384 "weights",
3385 Value::List(vec![Value::Float(0.5), Value::Float(1.5)]),
3386 ),
3387 ("enabled", Value::Bool(true)),
3388 ]);
3389 let lua_v = value_to_lua(&lua, &original).unwrap();
3390 let back = lua_to_value(&lua_v).unwrap();
3391 assert_eq!(back.get_str("kind"), Some("inc"));
3392 assert_eq!(back.get_int("by"), Some(2));
3393 assert_eq!(back.get_bool("enabled"), Some(true));
3394 match back.get("weights") {
3395 Some(Value::List(items)) => assert_eq!(items.len(), 2),
3396 other => panic!("expected list, got {other:?}"),
3397 }
3398 }
3399
3400 fn test_refs() -> Refs<'static> {
3404 let core: &'static Core = Box::leak(Box::new(Core::new()));
3405 Refs::new(core.token_lookup())
3406 }
3407
3408 fn eval_table(lua: &Lua, src: &str) -> Table {
3409 lua.load(src).eval().unwrap()
3410 }
3411
3412 #[test]
3414 fn schema_props_match_the_rust_builder() {
3415 let lua = Lua::new();
3416 let t = eval_table(
3417 &lua,
3418 r##"{
3419 width = "grow", height = {pct = 50},
3420 min_width = 10, max_width = 500, min_height = 5, max_height = 300,
3421 pad = {l = 1, r = 2, t = 3, b = 4}, gap = 8,
3422 main_align = "center", cross_align = "end", center = false,
3423 bg = "#14161e", radius = 6, border = {w = 1, color = 0x2a2d3aff},
3424 clip = true, scroll = true, scroll_x = true,
3425 float = {anchor = "viewport", at = {"end", "end"}, self_at = {"end", "end"},
3426 dx = -8, dy = -8, fit = true},
3427 hoverable = true, window = "close",
3428 on_click = {kind = "hit"}, on_drag = "d", on_key = 7, key_up = true,
3429 modal = "dlg", on_context_menu = {kind = "menu"},
3430 initial_focus = true,
3431 key = "panel", key_focus = true,
3432 }"##,
3433 );
3434 let p = parse_props(&t, true, &mut test_refs()).unwrap();
3435 let expected = NodeSpec::row()
3436 .grow_width()
3437 .height(Sizing::Percent(0.5))
3438 .min_width(10.0)
3439 .max_width(500.0)
3440 .min_height(5.0)
3441 .max_height(300.0)
3442 .padding(Edges {
3443 l: 1.0,
3444 r: 2.0,
3445 t: 3.0,
3446 b: 4.0,
3447 })
3448 .gap(8.0)
3449 .main_align(Align::Center)
3450 .cross_align(Align::End)
3451 .bg(Color::hex(0x14161eff))
3452 .radius(6.0)
3453 .border(1.0, Color::hex(0x2a2d3aff))
3454 .clip()
3455 .scroll_y()
3456 .scroll_x()
3457 .float(
3458 FloatConfig::viewport()
3459 .inside(Align::End, Align::End)
3460 .offset(-8.0, -8.0)
3461 .fit(),
3462 )
3463 .hoverable()
3464 .window_button(WindowButton::Close)
3465 .on_click(Value::map([("kind", "hit".into())]))
3466 .on_drag("d")
3467 .on_key(Value::Int(7))
3468 .key_up()
3469 .modal("dlg")
3470 .initial_focus()
3471 .on_context_menu(Value::map([("kind", "menu".into())]));
3472 assert_eq!(p.spec, expected);
3473 assert_eq!(p.key.as_deref(), Some("panel"));
3474 assert!(p.key_focus);
3475 }
3476
3477 #[test]
3480 fn a_min_is_a_number_or_fit() {
3481 let lua = Lua::new();
3482 let t = eval_table(&lua, r#"{ min_width = "fit", min_height = 3 }"#);
3483 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3484 let expected = NodeSpec::column()
3485 .min_width(kui_core::Min::FIT)
3486 .min_height(3.0);
3487 assert_eq!(p.spec, expected);
3488 let t = eval_table(&lua, r#"{ min_width = "grow" }"#);
3489 let err = parse_props(&t, false, &mut test_refs())
3490 .unwrap_err()
3491 .to_string();
3492 assert!(err.contains("bad min"), "{err}");
3493 }
3494
3495 #[test]
3499 fn the_lua_composites_resolve_the_way_the_core_says() {
3500 let lua = Lua::new();
3501 let t = eval_table(
3502 &lua,
3503 r#"{ pad = { all = 4, x = 10, b = 1 },
3504 float = { anchor = "below", dx = 6 } }"#,
3505 );
3506 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3507 assert_eq!(
3508 p.spec.layout.padding,
3509 Edges {
3510 l: 10.0,
3511 r: 10.0,
3512 t: 4.0,
3513 b: 1.0,
3514 }
3515 );
3516 assert_eq!(
3517 p.spec.layout.float,
3518 Some(FloatConfig::build(
3519 FloatConfig::below(),
3520 None,
3521 None,
3522 Some(6.0),
3523 None,
3524 false,
3525 false
3526 ))
3527 );
3528
3529 let by_self = eval_table(&lua, r#"{ float = { self = {"end", "start"} } }"#);
3531 let by_self_at = eval_table(&lua, r#"{ float = { self_at = {"end", "start"} } }"#);
3532 assert_eq!(
3533 parse_props(&by_self, false, &mut test_refs())
3534 .unwrap()
3535 .spec
3536 .layout
3537 .float,
3538 parse_props(&by_self_at, false, &mut test_refs())
3539 .unwrap()
3540 .spec
3541 .layout
3542 .float
3543 );
3544
3545 let bad_preset = eval_table(&lua, r#"{ float = "beneath" }"#);
3548 let e = parse_props(&bad_preset, false, &mut test_refs())
3549 .unwrap_err()
3550 .to_string();
3551 assert!(e.contains("beneath") && e.contains("below"), "{e}");
3552 }
3553
3554 #[test]
3555 fn text_style_props_match_the_rust_builder() {
3556 let lua = Lua::new();
3557 let t = eval_table(
3558 &lua,
3559 r#"{ size = 20, line_height = 30, color = 0x73d98cff, family = "mono" }"#,
3560 );
3561 let style = parse_props(&t, false, &mut test_refs()).unwrap().style;
3562 assert_eq!(
3563 style,
3564 TextStyle::new(20.0)
3565 .line_height(30.0)
3566 .color(Color::hex(0x73d98cff))
3567 .family(FontFamily::Mono)
3568 );
3569 let t = eval_table(&lua, r#"{ wrap = "none", max_lines = 2, ellipsis = true }"#);
3570 assert_eq!(
3571 parse_props(&t, false, &mut test_refs()).unwrap().style,
3572 TextStyle::default().nowrap().max_lines(2).ellipsis()
3573 );
3574 let t = eval_table(&lua, r##"{ color = "#fff", size = 12 }"##);
3577 assert_eq!(
3578 parse_props(&t, false, &mut test_refs()).unwrap().style,
3579 TextStyle::new(12.0).color(Color::hex(0xffffffff))
3580 );
3581 }
3582
3583 #[test]
3584 fn bad_values_name_the_prop() {
3585 let lua = Lua::new();
3586 let t = eval_table(&lua, r#"{ main_align = "middle" }"#);
3587 let e = parse_props(&t, false, &mut test_refs())
3588 .unwrap_err()
3589 .to_string();
3590 assert!(e.contains("main_align"), "{e}");
3591 assert!(e.contains("middle"), "{e}");
3592 }
3593
3594 fn frame(core: &mut Core, ext: &mut LuaExtension) -> usize {
3595 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3596 ui.set_origin(OriginId(1));
3597 ext.view(&Slot::root(), &mut ui).unwrap();
3598 ui.finish();
3599 core.output().0.quads.len()
3600 }
3601
3602 #[test]
3603 fn script_view_builds_ir_nodes() {
3604 let mut ext = LuaExtension::from_source(
3605 "test",
3606 r#"
3607 count = 41
3608 function view()
3609 return column { gap = 8, pad = 16, bg = 0x10121aff,
3610 text("count: " .. count, { size = 20 }),
3611 button { label = "bump", on_click = { kind = "bump" } },
3612 }
3613 end
3614 function on_event(ev)
3615 if ev.kind == "bump" then count = count + 1 end
3616 end
3617 "#,
3618 )
3619 .unwrap();
3620
3621 let mut core = Core::new();
3622 let quads = frame(&mut core, &mut ext);
3623 assert!(quads > 10, "expected panel/button/glyph quads, got {quads}");
3625
3626 ext.on_event(&UiEvent {
3628 origin: OriginId(1),
3629 key: Key::ROOT,
3630 payload: Value::map([("kind", "bump".into())]),
3631 window: WindowId::MAIN,
3632 slot: None,
3633 });
3634 let count: i64 = ext.lua.globals().get("count").unwrap();
3635 assert_eq!(count, 42);
3636 }
3637
3638 #[test]
3644 fn a_devtools_tab_calls_its_view_only_while_on_show() {
3645 let mut ext = LuaExtension::from_source(
3646 "test",
3647 r#"
3648 calls = 0
3649 function view(env)
3650 return column { gap = 8,
3651 text("app"),
3652 devtools_tab { name = "syntax", label = "Tree-sitter", view = function()
3653 calls = calls + 1
3654 return column { text("from lua"),
3655 button { label = "jump", on_click = { kind = "jump" } } }
3656 end },
3657 devtools_tab { name = "plug", label = "Plugin", slot = "ts/panel" },
3658 devtools_tab { name = "bad", label = "Bad" },
3659 }
3660 end
3661 "#,
3662 )
3663 .unwrap();
3664 let mut core = Core::new();
3665 core.set_devtools(true);
3666 core.set_devtools_dock(kui_core::DevtoolsDock::Right);
3667 core.set_inspect(true);
3668 frame(&mut core, &mut ext);
3669 frame(&mut core, &mut ext);
3670 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3671 assert_eq!(calls, 0, "not on show: the view was not called");
3672 let ws = core.take_warnings();
3673 assert_eq!(
3674 ws.iter()
3675 .filter(|w| w.code == kui_core::diag::BAD_DEVTOOLS_TAB)
3676 .count(),
3677 1,
3678 "the tab with neither form warns once: {ws:?}"
3679 );
3680 let chord = || {
3683 kui_core::InputEvent::KeyDown(kui_core::KeyPress::new(
3684 kui_core::KeyCode::Char('N'),
3685 kui_core::KeyMods::NONE.with_ctrl().with_shift(),
3686 ))
3687 };
3688 core.handle_input(chord());
3689 core.handle_input(chord());
3690 frame(&mut core, &mut ext);
3691 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3692 assert_eq!(calls, 1, "on show: called once a frame");
3693 frame(&mut core, &mut ext);
3694 let body = core
3695 .nodes()
3696 .iter()
3697 .find(|n| n.label.as_deref() == Some("kui-devtools/tab/syntax"))
3698 .map(|n| n.rect)
3699 .expect("the body");
3700 let jump = core
3701 .nodes()
3702 .iter()
3703 .find(|n| n.label.as_deref() == Some("jump"))
3704 .map(|n| n.rect)
3705 .expect("the script's button");
3706 assert!(
3707 jump.x >= body.x && jump.x + jump.w <= body.x + body.w,
3708 "{jump:?} in {body:?}"
3709 );
3710 assert!(
3711 core.nodes()
3712 .iter()
3713 .any(|n| n.text.as_deref() == Some("from lua")),
3714 "the script's text painted"
3715 );
3716 let evs = kui_core::testing::click_at(&mut core, jump.x + 2.0, jump.y + 2.0);
3717 assert_eq!(evs.len(), 1);
3718 assert_eq!(evs[0].origin, OriginId(1), "the script's own event");
3719 assert_eq!(evs[0].kind(), Some("jump"));
3720 }
3721
3722 #[test]
3727 fn the_root_table_declares_windows() {
3728 use kui_core::{WindowCommand, WindowConfig};
3729 let mut ext = LuaExtension::from_source(
3730 "windows",
3731 r#"
3732 function view(env)
3733 return column {
3734 windows = { { name = "palette", width = 400, height = 300,
3735 activates = false }, "tools" },
3736 text("main"),
3737 }
3738 end
3739 "#,
3740 )
3741 .unwrap();
3742 let mut core = Core::new();
3743 frame(&mut core, &mut ext);
3744 let cmds = core.take_window_commands();
3745 assert_eq!(cmds.len(), 2, "{cmds:?}");
3746 assert_eq!(
3747 cmds[0],
3748 WindowCommand::Open {
3749 id: WindowId(1),
3750 owner: WindowId::MAIN,
3751 origin: OriginId(1),
3752 config: WindowConfig {
3753 size: Size::new(400.0, 300.0),
3754 activates: false,
3755 ..WindowConfig::default()
3756 },
3757 }
3758 );
3759 assert_eq!(
3760 cmds[1],
3761 WindowCommand::Open {
3762 id: WindowId(2),
3763 owner: WindowId::MAIN,
3764 origin: OriginId(1),
3765 config: WindowConfig::default(),
3766 }
3767 );
3768 frame(&mut core, &mut ext);
3770 assert!(core.take_window_commands().is_empty());
3771 assert!(core.take_warnings().is_empty());
3772 }
3773
3774 #[test]
3778 fn a_windows_entry_declares_a_popup() {
3779 use kui_core::{Rect, WindowCommand, WindowConfig, WindowKind};
3780 let mut ext = LuaExtension::from_source(
3781 "windows-popup",
3782 r#"
3783 function view(env)
3784 return column {
3785 windows = { { name = "menu", kind = "popup",
3786 width = 160, height = 320,
3787 anchor = { x = 12, y = 40, w = 160, h = 24 } } },
3788 text("main"),
3789 }
3790 end
3791 "#,
3792 )
3793 .unwrap();
3794 let mut core = Core::new();
3795 frame(&mut core, &mut ext);
3796 assert_eq!(
3797 core.take_window_commands(),
3798 vec![WindowCommand::Open {
3799 id: WindowId(1),
3800 owner: WindowId::MAIN,
3801 origin: OriginId(1),
3802 config: WindowConfig {
3803 kind: WindowKind::Popup,
3804 size: Size::new(160.0, 320.0),
3805 activates: false,
3806 anchor: Rect::new(12.0, 40.0, 160.0, 24.0),
3807 },
3808 }]
3809 );
3810 }
3811
3812 #[test]
3817 fn a_windows_entry_cannot_name_an_unknown_kind() {
3818 let mut ext = LuaExtension::from_source(
3819 "windows-kind",
3820 r#"
3821 function view(env)
3822 return column {
3823 windows = { { name = "palette", kind = "sheet" } },
3824 text("main"),
3825 }
3826 end
3827 "#,
3828 )
3829 .unwrap();
3830 let mut core = Core::new();
3831 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3832 ui.set_origin(OriginId(1));
3833 let err = ext.view(&Slot::root(), &mut ui).unwrap_err();
3834 assert!(err.contains("sheet"), "{err}");
3835 assert!(err.contains("popup"), "{err}");
3836 }
3837
3838 #[test]
3842 fn a_button_takes_an_index_and_a_cursor_shape_is_refused() {
3843 let mut ext = LuaExtension::from_source(
3844 "rows",
3845 r#"
3846 from = 0
3847 function view(env)
3848 return column {
3849 button { label = "Open", index = from, on_click = { row = from } },
3850 button { label = "Open", index = from + 1, on_click = { row = from + 1 } },
3851 button { label = "Keyed", key = "named", index = 7, on_click = "k" },
3852 }
3853 end
3854 "#,
3855 )
3856 .unwrap();
3857 let mut core = Core::new();
3858 core.set_diagnostics(true);
3859 let frame = |core: &mut Core, ext: &mut LuaExtension| {
3860 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3861 ui.set_origin(OriginId(1));
3862 ext.view(&Slot::root(), &mut ui).unwrap();
3863 ui.finish();
3864 };
3865 frame(&mut core, &mut ext);
3866 assert!(core.take_warnings().is_empty(), "index is a button row");
3867 let buttons = |core: &mut Core| -> Vec<Key> {
3868 core.access_tree()
3869 .nodes
3870 .iter()
3871 .filter(|n| n.role == kui_core::Role::Button)
3872 .map(|n| n.key)
3873 .collect()
3874 };
3875 let before = buttons(&mut core);
3876 assert_eq!(before.len(), 3, "two rows with the same text are two nodes");
3877 assert!(
3878 core.key_of("named").is_none(),
3879 "declared beside `key`, the index wins"
3880 );
3881 ext.lua.globals().set("from", 1).unwrap();
3882 frame(&mut core, &mut ext);
3883 let after = buttons(&mut core);
3884 assert_eq!(
3885 after[0], before[1],
3886 "row 1 keeps its key as it moves up the list"
3887 );
3888
3889 let mut ext = LuaExtension::from_source(
3890 "term",
3891 r#"
3892 function view(env)
3893 return column { cells { key = "term", rows = 1, cols = 4, size = 14,
3894 lines = { "abcd" }, cursor_at = { 1, 1 }, cursor_shape = "blob" } }
3895 end
3896 "#,
3897 )
3898 .unwrap();
3899 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3900 ui.set_origin(OriginId(1));
3901 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
3902 assert!(
3903 err.contains("block | bar | underline") && err.contains("blob"),
3904 "{err}"
3905 );
3906 }
3907
3908 #[test]
3911 fn option_as_alt_is_a_side_by_name() {
3912 let mut core = Core::new();
3913 let mut ext = LuaExtension::from_source(
3914 "keys",
3915 r#"
3916 function view(env)
3917 return column { option_as_alt = "left", text("x") }
3918 end
3919 "#,
3920 )
3921 .unwrap();
3922 frame(&mut core, &mut ext);
3923 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
3924
3925 let mut ext = LuaExtension::from_source(
3926 "keys",
3927 r#"
3928 function view(env)
3929 return column { option_as_alt = "meta", text("x") }
3930 end
3931 "#,
3932 )
3933 .unwrap();
3934 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3935 ui.set_origin(OriginId(1));
3936 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
3937 assert!(
3938 err.contains("option_as_alt") && err.contains("\"both\"") && err.contains("meta"),
3939 "{err}"
3940 );
3941 }
3942
3943 #[test]
3947 fn a_value_that_holds_itself_or_nests_too_deep_is_refused() {
3948 let lua = Lua::new();
3949 let read = |src: &str| lua_to_value(&lua.load(src).eval::<mlua::Value>().unwrap());
3950 let err = |src: &str| read(src).unwrap_err().to_string();
3951 assert!(err("local t = {}; t[1] = t; return t").contains("holds itself"));
3952 assert!(err("local t = {}; t.me = { t }; return t").contains("holds itself"));
3953 let shared = read("local l = { 1, 2 }; return { a = l, b = l }").unwrap();
3954 assert_eq!(shared.get("a"), shared.get("b"));
3955 let nested = |n: usize| format!("local t = 1; for _ = 1, {n} do t = {{ t }} end; return t");
3956 assert!(read(&nested(MAX_VALUE_DEPTH)).is_ok());
3957 assert!(err(&nested(MAX_VALUE_DEPTH + 1)).contains("nested past 64"));
3958 assert!(err(&nested(100_000)).contains("nested past 64"));
3960 }
3961
3962 #[test]
3966 fn a_view_that_holds_itself_or_nests_too_deep_is_refused() {
3967 let run = |body: &str| {
3968 let mut core = Core::new();
3969 let mut ext =
3970 LuaExtension::from_source("deep", &format!("function view(env)\n{body}\nend"))
3971 .unwrap();
3972 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3973 ui.set_origin(OriginId(1));
3974 let built = ext.view(&Slot::root(), &mut ui);
3975 ui.finish();
3976 built
3977 };
3978 let err = run("local t = column {}; t[1] = row { t }; return t").unwrap_err();
3979 assert!(err.contains("holds itself"), "{err}");
3980 let nested = |n: usize| {
3981 format!(
3982 "local t = text('x')
3983 for i = 2, {n} do
3984 if i % 2 == 0 then t = radio_group {{ label = 'g', t }}
3985 elseif i % 3 == 0 then t = tooltip {{ t }}
3986 else t = column {{ t }} end
3987 end
3988 return t"
3989 )
3990 };
3991 run(&nested(MAX_VIEW_DEPTH)).unwrap();
3992 let err = run(&nested(MAX_VIEW_DEPTH + 1)).unwrap_err();
3993 assert!(err.contains("nested past 128"), "{err}");
3994 run(&nested(8)).unwrap();
3996 }
3997
3998 #[test]
4002 fn option_as_alt_none_leaves_the_hosts_side() {
4003 let mut core = Core::new();
4004 let mut ext = LuaExtension::from_source(
4005 "keys",
4006 r#"
4007 function view(env)
4008 return column { option_as_alt = "none", text("x") }
4009 end
4010 "#,
4011 )
4012 .unwrap();
4013 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4014 ui.option_as_alt(kui_core::OptionAsAlt::Left);
4015 ui.set_origin(OriginId(1));
4016 ext.view(&Slot::root(), &mut ui).unwrap();
4017 ui.finish();
4018 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
4019 }
4020
4021 #[test]
4023 fn every_node_type_lowers() {
4024 let mut ext = LuaExtension::from_source(
4025 "all",
4026 r##"
4027 function view(env)
4028 return column { gap = 4, window_title = "all nodes", always_on_top = true,
4029 secure_input = true, option_as_alt = "right", ime_off = true,
4030 titlebar { text("custom title"), window_buttons() },
4031 titlebar { title = "plain title" },
4032 text({ "same IR as ", { "Rust", bold = true, color = "#73d98c" },
4033 { " — flatter", italic = true } }, { size = 13 }),
4034 edit { key = "note", initial = "hello", size = 14, width = 200,
4035 multiline = true },
4036 input { label = "name", initial = "" },
4037 dropdown { label = "language", options = { "English", "Deutsch" }, current = 1 },
4038 row { tooltip = "hover hint", pad = 4, text("badge") },
4039 row { hoverable = true, text("legend"), tooltip("always shown") },
4040 row { text("rich tip"), tooltip { text("a"), text("b") } },
4041 latency_graph(),
4042 latency_hud { at = { "start", "end" } },
4043 button { label = "ok", on_click = "ok" },
4044 }
4045 end
4046 "##,
4047 )
4048 .unwrap();
4049 let mut core = Core::new();
4050 let quads = frame(&mut core, &mut ext);
4051 assert!(quads > 60, "got {quads} quads");
4052 assert_eq!(core.window_title(), Some("all nodes"));
4053 assert!(core.always_on_top());
4054 assert!(core.secure_input());
4055 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Right);
4056 assert!(core.ime_off());
4057 let unknown: Vec<String> = core
4061 .take_warnings()
4062 .into_iter()
4063 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4064 .map(|w| w.message)
4065 .collect();
4066 assert!(unknown.is_empty(), "{unknown:#?}");
4067 }
4068
4069 #[test]
4072 fn unknown_props_warn_once_in_lua_spelling() {
4073 let mut ext = LuaExtension::from_source(
4074 "typos",
4075 r#"
4076 function view(env)
4077 return column { pad = 8,
4078 row { hoverBg = 0x333333ff, width = 10, height = 10 },
4079 row { hoverBg = 0x333333ff, width = 10, height = 10 },
4080 row { colour = 0x333333ff, width = 10, height = 10 },
4081 }
4082 end
4083 "#,
4084 )
4085 .unwrap();
4086 let mut core = Core::new();
4087 frame(&mut core, &mut ext);
4088 let mut warned: Vec<String> = core
4089 .take_warnings()
4090 .into_iter()
4091 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4092 .map(|w| w.message)
4093 .collect();
4094 warned.sort();
4095 assert_eq!(warned.len(), 2, "one per name, not per node: {warned:#?}");
4096 assert!(
4097 warned[1].contains("`hoverBg` is not a prop of box")
4098 && warned[1].contains("did you mean `hover_bg`?"),
4099 "{warned:#?}"
4100 );
4101 assert!(warned[0].contains("`colour`") && !warned[0].contains("did you mean"));
4103 frame(&mut core, &mut ext);
4105 assert!(core.take_warnings().is_empty());
4106 }
4107
4108 #[test]
4112 fn a_text_warns_about_the_rows_it_does_not_read() {
4113 let mut ext = LuaExtension::from_source(
4114 "textrows",
4115 r#"
4116 function view(env)
4117 return column { pad = 8,
4118 text("hi", { live = "polite", label = "x", on_click = "go", size = 14, line_height = 20, max_lines = 2 }),
4119 text({ "a", { "b", bold = true, bg = 0x00ff00ff } }, { color = 0xff0000ff }),
4120 }
4121 end
4122 "#,
4123 )
4124 .unwrap();
4125 let mut core = Core::new();
4126 frame(&mut core, &mut ext);
4127 let mut warned: Vec<String> = core
4128 .take_warnings()
4129 .into_iter()
4130 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4131 .map(|w| w.message)
4132 .collect();
4133 warned.sort();
4134 assert_eq!(warned.len(), 3, "{warned:#?}");
4135 for (w, name) in warned.iter().zip(["label", "live", "on_click"]) {
4136 assert!(
4137 w.contains(&format!("`{name}`")) && w.contains("not one text reads"),
4138 "{w}"
4139 );
4140 assert!(w.contains("`max_lines`"), "names the rows it reads: {w}");
4141 }
4142 }
4143
4144 #[test]
4147 fn the_repeat_alias_does_not_warn() {
4148 let mut ext = LuaExtension::from_source(
4149 "alias",
4150 r#"
4151 function view(env)
4152 return column {
4153 row { width = 10, height = 10, keyframes = { { bg = 0x000000ff } },
4154 transition = 100, direction = "alternate" },
4155 }
4156 end
4157 "#,
4158 )
4159 .unwrap();
4160 let mut core = Core::new();
4161 frame(&mut core, &mut ext);
4162 assert!(core.take_warnings().is_empty());
4163 }
4164
4165 #[test]
4167 fn unknown_props_stay_quiet_with_diagnostics_off() {
4168 let mut ext = LuaExtension::from_source(
4169 "quiet",
4170 r#"
4171 function view(env)
4172 return column { hoverBg = 0x333333ff }
4173 end
4174 "#,
4175 )
4176 .unwrap();
4177 let mut core = Core::new();
4178 core.set_diagnostics(false);
4179 frame(&mut core, &mut ext);
4180 assert!(core.take_warnings().is_empty());
4181 }
4182
4183 #[test]
4186 fn tooltip_prop_is_hover_gated() {
4187 let mut ext = LuaExtension::from_source(
4188 "tip",
4189 r#"
4190 function view(env)
4191 return column { pad = 10,
4192 row { width = 100, height = 40, bg = 0x333333ff, tooltip = "a long hint" },
4193 }
4194 end
4195 "#,
4196 )
4197 .unwrap();
4198 let mut core = Core::new();
4199 let idle = frame(&mut core, &mut ext);
4200 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
4201 let hovered = frame(&mut core, &mut ext);
4202 assert!(
4203 hovered > idle + 5,
4204 "hover should add the tooltip's quads ({idle} -> {hovered})"
4205 );
4206 core.handle_input(InputEvent::CursorLeft);
4207 assert_eq!(frame(&mut core, &mut ext), idle);
4208 }
4209
4210 #[test]
4216 fn the_stock_button_admits_the_access_rows() {
4217 let mut ext = LuaExtension::from_source(
4218 "button",
4219 r#"
4220 function view(env)
4221 return column { pad = 10, gap = 4,
4222 button { label = "go", on_click = "go", description = "Starts the run" },
4223 button { label = "Stop the run", text = "stop", on_click = "stop",
4224 disabled = true, tooltip = "Nothing is running" },
4225 button { label = "x", on_click = "x", radius = 12, hoverBg = 0x333333ff },
4226 }
4227 end
4228 "#,
4229 )
4230 .unwrap();
4231 let mut core = Core::new();
4232 frame(&mut core, &mut ext);
4233 let tree = core.access_tree().clone();
4234 let named = |n: &str| {
4235 tree.nodes
4236 .iter()
4237 .find(|node| node.name.as_deref() == Some(n))
4238 };
4239 let go = named("go").expect("the button, named by its label");
4240 assert_eq!(go.role, kui_core::Role::Button);
4241 assert_eq!(go.description.as_deref(), Some("Starts the run"));
4242 assert!(!go.disabled);
4243 let stop = named("Stop the run").expect("named past its text");
4244 assert_eq!(stop.description.as_deref(), Some("Nothing is running"));
4245 assert!(stop.disabled);
4246 assert_eq!(tree.nodes.len(), 4, "window and three buttons");
4247 let ws = core.take_warnings();
4248 assert_eq!(ws.len(), 2, "{ws:?}");
4249 let radius = ws
4250 .iter()
4251 .find(|w| w.message.contains("`radius`"))
4252 .expect("a row the button does not read");
4253 assert!(
4254 radius
4255 .message
4256 .contains("is a prop, but not one button reads")
4257 );
4258 assert!(radius.message.contains("`description`"));
4259 let hover = ws
4262 .iter()
4263 .find(|w| w.message.contains("hoverBg"))
4264 .expect("a misspelling");
4265 assert!(hover.message.contains("is not a prop of button"));
4266 assert!(!hover.message.contains("did you mean"));
4267 }
4268
4269 #[test]
4274 fn an_accent_button_takes_the_colour_the_host_pushed() {
4275 let mut ext = LuaExtension::from_source(
4276 "accent",
4277 r#"
4278 function view(env)
4279 return column { pad = 10,
4280 button { label = "go", on_click = "go", accent = true },
4281 }
4282 end
4283 "#,
4284 )
4285 .unwrap();
4286 let mut bg = |core: &mut Core| {
4287 frame(core, &mut ext);
4288 core.output().0.quads[0].color
4289 };
4290 let mut core = Core::new();
4291 assert_eq!(
4292 bg(&mut core),
4293 kui_core::Color::rgb8(0x3b, 0x5b, 0xd4),
4294 "no accent pushed, the stock button"
4295 );
4296 assert!(
4297 core.take_warnings().is_empty(),
4298 "and a row the button reads"
4299 );
4300
4301 let accent = kui_core::Color::hex(0x007affff);
4302 core.env.system.accent = Some(accent);
4303 assert_eq!(bg(&mut core), accent);
4304 }
4305
4306 #[test]
4310 fn semantics_reach_the_access_tree() {
4311 let mut ext = LuaExtension::from_source(
4312 "a11y",
4313 r#"
4314 function view(env)
4315 return column { pad = 10,
4316 row { key = "save", on_click = "save", label = "Save", width = 20, height = 20 },
4317 row { key = "check", role = "checkbox", checked = true, text("Remember") },
4318 row { key = "vol", role = "slider", label = "Volume",
4319 value_now = 3, value_min = 0, value_max = 10 },
4320 row { key = "art", role = "none", on_click = "art", text("Art") },
4321 row { key = "tip", tooltip = "more here", on_click = "t", text("Tip") },
4322 row { key = "tabs", role = "tabList",
4323 row { key = "t0", role = "tab", text("General") },
4324 row { key = "t1", role = "tab", selected = true, text("Network") },
4325 },
4326 row { key = "adv", on_click = "adv", expanded = "collapsed",
4327 label = "Advanced" },
4328 }
4329 end
4330 "#,
4331 )
4332 .unwrap();
4333 let mut core = Core::new();
4334 frame(&mut core, &mut ext);
4335 let tree = core.access_tree().clone();
4336 let named = |n: &str| {
4337 tree.nodes
4338 .iter()
4339 .find(|node| node.name.as_deref() == Some(n))
4340 .cloned()
4341 };
4342 let save = named("Save").expect("labelled button");
4343 assert_eq!(save.role, kui_core::Role::Button);
4344 assert_eq!(save.origin, OriginId(1));
4345 let check = named("Remember").expect("checkbox named by its text");
4346 assert_eq!(check.role, kui_core::Role::Checkbox);
4347 assert_eq!(check.checked, Some(true));
4348 let vol = named("Volume").expect("slider");
4349 assert_eq!(
4350 (vol.number, vol.min, vol.max),
4351 (Some(3.0), Some(0.0), Some(10.0))
4352 );
4353 assert!(named("Art").is_none(), "role none hides a would-be button");
4354 let tip = named("Tip").expect("button");
4355 assert_eq!(tip.description.as_deref(), Some("more here"));
4356 let (t0, t1) = (named("General").unwrap(), named("Network").unwrap());
4359 assert_eq!((t0.selected, t1.selected), (Some(false), Some(true)));
4360 assert_eq!((t0.pos_in_set, t1.pos_in_set), (Some(0), Some(1)));
4361 let tabs = tree
4362 .nodes
4363 .iter()
4364 .find(|n| n.role == kui_core::Role::TabList)
4365 .expect("tab list");
4366 assert_eq!(tabs.set_size, Some(2));
4367 let adv = named("Advanced").expect("disclosure");
4369 assert_eq!(adv.expanded, Some(false));
4370
4371 let evs = core.handle_input(InputEvent::Access(kui_core::AccessRequest {
4372 key: save.key,
4373 action: kui_core::AccessAction::Click,
4374 value: None,
4375 anchor: None,
4376 focus: None,
4377 }));
4378 assert_eq!(evs.len(), 1);
4379 assert_eq!(evs[0].origin, OriginId(1));
4380 assert_eq!(evs[0].payload.as_str(), Some("save"));
4381 }
4382
4383 #[test]
4387 fn a_custom_editor_in_lua_reaches_the_access_tree() {
4388 let mut ext = LuaExtension::from_source(
4389 "ed",
4390 r#"
4391 function view(env)
4392 return column { key = "ed", role = "multilineTextInput", label = "Doc",
4393 on_key = "keys",
4394 row { role = "line", selection_anchor = 1, text("ab"), text("cd") },
4395 row { role = "line", caret = 2, text("ef") },
4396 }
4397 end
4398 "#,
4399 )
4400 .unwrap();
4401 let mut core = Core::new();
4402 frame(&mut core, &mut ext);
4403 let tree = core.access_tree().clone();
4404 let ed = tree
4405 .nodes
4406 .iter()
4407 .find(|n| n.name.as_deref() == Some("Doc"))
4408 .expect("the sink is the editor")
4409 .clone();
4410 assert_eq!(ed.role, kui_core::Role::MultilineTextInput);
4411 assert_eq!(ed.value.as_deref(), Some("abcd\nef"));
4412 assert_eq!(ed.runs.len(), 3);
4413 assert_eq!(ed.runs[1].text, "cd\n");
4414 assert_eq!(ed.caret, Some(7));
4415 assert_eq!(ed.selection, Some((1, 7)));
4416 let (a, f) = (ed.anchor.unwrap(), ed.focus.unwrap());
4417 assert_eq!((a.run, a.character), (ed.runs[0].key, 1));
4418 assert_eq!((f.run, f.character), (ed.runs[2].key, 2));
4419
4420 let evs = core.handle_input(InputEvent::Access(
4421 kui_core::AccessRequest::new(ed.key, kui_core::AccessAction::SetTextSelection)
4422 .with_selection(
4423 kui_core::TextPos {
4424 run: ed.runs[1].key,
4425 character: 1,
4426 },
4427 kui_core::TextPos {
4428 run: ed.runs[2].key,
4429 character: 0,
4430 },
4431 ),
4432 ));
4433 assert_eq!(evs.len(), 1);
4434 assert_eq!(evs[0].origin, OriginId(1));
4435 let p = &evs[0].payload;
4436 assert_eq!(p.get_str("action"), Some("setTextSelection"));
4437 let at = |k: &str, f: &str| p.get(k).and_then(|v| v.get(f)).and_then(Value::as_int);
4438 assert_eq!(
4439 (at("anchor", "line"), at("anchor", "offset")),
4440 (Some(0), Some(3))
4441 );
4442 assert_eq!(
4443 (at("focus", "line"), at("focus", "offset")),
4444 (Some(1), Some(0))
4445 );
4446 assert_eq!(p.get_str("tag"), Some("keys"));
4447 }
4448
4449 #[test]
4452 fn edit_text_round_trips_through_events() {
4453 let mut ext = LuaExtension::from_source(
4454 "edit",
4455 r#"
4456 pending = nil
4457 seen = nil
4458 by_label = nil
4459 window = nil
4460 function view(env)
4461 if pending then seen = env.edit_text(pending) end
4462 -- AR26: by the label its `key` declares, like every
4463 -- query beside it; a label nothing declared is nil.
4464 by_label = env.edit_text("note")
4465 nothing = env.edit_text("nope")
4466 return column {
4467 edit { key = "note", initial = "hi", autofocus = true, width = 200 },
4468 }
4469 end
4470 function on_event(ev)
4471 if ev.kind == "changed" then pending = ev.node_key; window = ev.window end
4472 end
4473 "#,
4474 )
4475 .unwrap();
4476 let mut core = Core::new();
4477 frame(&mut core, &mut ext);
4478 let events = core.handle_input(InputEvent::Text("!".into()));
4479 assert_eq!(
4480 events.len(),
4481 1,
4482 "typing into the autofocused editor emits one event"
4483 );
4484 assert_eq!(events[0].kind(), Some("changed"));
4485 for ev in &events {
4486 ext.on_event(ev);
4487 }
4488 frame(&mut core, &mut ext);
4489 let seen: Option<String> = ext.lua.globals().get("seen").unwrap();
4491 assert_eq!(seen.as_deref(), Some("hi!"));
4492 let by_label: Option<String> = ext.lua.globals().get("by_label").unwrap();
4493 assert_eq!(by_label.as_deref(), Some("hi!"), "the same text by label");
4494 let nothing: Option<String> = ext.lua.globals().get("nothing").unwrap();
4495 assert_eq!(nothing, None);
4496 let window: Option<i64> = ext.lua.globals().get("window").unwrap();
4499 assert_eq!(window, Some(0));
4500 }
4501
4502 #[test]
4507 fn a_dropdown_opens_the_cores_menu_and_hears_the_choice() {
4508 let mut ext = LuaExtension::from_source(
4509 "dd",
4510 r#"
4511 current = 1
4512 heard = {}
4513 function view(env)
4514 return column { pad = 10,
4515 dropdown { label = "language",
4516 options = { "English", "Deutsch", { label = "Latin", id = "la" } },
4517 current = current },
4518 }
4519 end
4520 function on_event(ev)
4521 heard[#heard + 1] = ev.kind
4522 if ev.kind == "menu" then
4523 if ev.item == "Deutsch" then current = 2 end
4524 if ev.item == "la" then current = 3 end
4525 end
4526 end
4527 "#,
4528 )
4529 .unwrap();
4530 let mut core = Core::new();
4531 frame(&mut core, &mut ext);
4532 let field = core
4533 .key_of("language")
4534 .expect("the field is keyed by its label");
4535 let node = |core: &mut Core| {
4536 core.access_tree()
4537 .nodes
4538 .iter()
4539 .find(|n| n.key == field)
4540 .cloned()
4541 .unwrap()
4542 };
4543 assert_eq!(node(&mut core).description.as_deref(), Some("English"));
4544 let events = core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4545 field,
4546 kui_core::AccessAction::Click,
4547 )));
4548 assert!(
4549 events.is_empty(),
4550 "the field's click is the core's: {events:?}"
4551 );
4552 let menu = core.menu().expect("the menu opened");
4553 assert_eq!(menu.target, field);
4554 assert_eq!(menu.items.len(), 3);
4555 assert!(menu.items[0].checked);
4556 frame(&mut core, &mut ext);
4557 let events = core.activate_menu_item(2).expect("the row is enabled");
4559 assert_eq!(events.len(), 1);
4560 for ev in &events {
4561 ext.on_event(ev);
4562 }
4563 frame(&mut core, &mut ext);
4564 assert_eq!(node(&mut core).description.as_deref(), Some("Latin"));
4565 let heard: Vec<String> = ext
4566 .lua
4567 .globals()
4568 .get::<Table>("heard")
4569 .unwrap()
4570 .sequence_values()
4571 .collect::<mlua::Result<_>>()
4572 .unwrap();
4573 assert_eq!(heard, vec!["menu".to_string()]);
4574 assert!(core.menu().is_none());
4575 let mut ext = LuaExtension::from_source(
4577 "bad",
4578 r#"function view(env) return dropdown { label = "x" } end"#,
4579 )
4580 .unwrap();
4581 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4582 ui.set_origin(OriginId(1));
4583 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4584 assert!(err.contains("needs options"), "{err}");
4585 ui.finish();
4586 let mut ext = LuaExtension::from_source(
4587 "bad",
4588 r#"function view(env) return dropdown { label = "x", options = { "a" }, current = 0 } end"#,
4589 )
4590 .unwrap();
4591 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4592 ui.set_origin(OriginId(1));
4593 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4594 assert!(err.contains("index from 1"), "{err}");
4595 ui.finish();
4596 let refused = |core: &mut Core, src: &str| {
4599 let mut ext = LuaExtension::from_source("bad", src).unwrap();
4600 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4601 ui.set_origin(OriginId(1));
4602 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4603 ui.finish();
4604 err
4605 };
4606 let err = refused(
4607 &mut core,
4608 r#"function view(env) return dropdown { options = { "a" } } end"#,
4609 );
4610 assert!(err.contains("dropdown needs a label"), "{err}");
4611 let err = refused(
4612 &mut core,
4613 r#"function view(env) return dropdown { label = "x", options = {} } end"#,
4614 );
4615 assert!(err.contains("at least one option"), "{err}");
4616 }
4617
4618 #[test]
4627 fn a_stray_key_inside_a_submenu_warns_and_an_option_takes_no_items() {
4628 let mut ext = LuaExtension::from_source(
4629 "sub",
4630 r#"
4631 function view(env)
4632 return column {
4633 menu_bar { menu = { { label = "View", items = {
4634 { label = "Sort by", items = { { label = "Name", disabled = true } } },
4635 } } } },
4636 dropdown { label = "sort",
4637 options = { "Name", { label = "Date", items = { { label = "Newest" } } } } },
4638 }
4639 end
4640 "#,
4641 )
4642 .unwrap();
4643 let mut core = Core::new();
4644 frame(&mut core, &mut ext);
4645 let warned = core.take_warnings();
4646 let messages: Vec<&str> = warned.iter().map(|w| w.message.as_str()).collect();
4647 assert_eq!(warned.len(), 2, "{messages:?}");
4648 assert!(
4649 messages[0].contains("`disabled` is not a key of a menu item"),
4650 "{messages:?}"
4651 );
4652 assert!(
4653 messages[1].contains("`items` on a select's option is dropped"),
4654 "{messages:?}"
4655 );
4656 let field = core.key_of("sort").unwrap();
4658 core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4659 field,
4660 kui_core::AccessAction::Click,
4661 )));
4662 let menu = core.menu().expect("the dropdown opened");
4663 assert_eq!(menu.items.len(), 2);
4664 assert!(
4665 menu.items.iter().all(|i| !i.has_submenu()),
4666 "{:?}",
4667 menu.items
4668 );
4669 }
4670
4671 #[test]
4672 fn a_dropdowns_bad_rows_and_current_are_warned_and_a_disabled_option_is_refused() {
4673 let mut ext = LuaExtension::from_source(
4674 "dd",
4675 r#"
4676 current = 4
4677 function view(env)
4678 return column { pad = 10,
4679 dropdown { label = "language",
4680 options = { "English", { role = "separator" },
4681 { label = "Latin", id = "la", disabled = true } },
4682 current = current },
4683 }
4684 end
4685 "#,
4686 )
4687 .unwrap();
4688 let mut core = Core::new();
4689 frame(&mut core, &mut ext);
4690 let field = core.key_of("language").unwrap();
4691 let node = |core: &mut Core| {
4692 core.access_tree()
4693 .nodes
4694 .iter()
4695 .find(|n| n.key == field)
4696 .cloned()
4697 .unwrap()
4698 };
4699 assert_eq!(node(&mut core).description.as_deref(), Some(""));
4700 let warned = core.take_warnings();
4701 let codes: Vec<&str> = warned.iter().map(|w| w.code).collect();
4702 assert_eq!(
4703 codes,
4704 [
4705 kui_core::diag::UNKNOWN_PROP,
4706 kui_core::diag::SELECT_CURRENT_IGNORED
4707 ],
4708 "{warned:?}"
4709 );
4710 assert!(
4711 warned[0]
4712 .message
4713 .contains("`disabled` is not a key of a menu item")
4714 && warned[0].message.contains("`enabled: false`"),
4715 "{}",
4716 warned[0].message
4717 );
4718 assert_eq!(warned[1].key, field);
4719 assert!(
4720 warned[1].message.contains("names option 3 counted from 0")
4721 && warned[1].message.contains("the field has 3 options"),
4722 "{}",
4723 warned[1].message
4724 );
4725 ext.lua.globals().set("current", 2).unwrap();
4727 let mut core = Core::new();
4728 frame(&mut core, &mut ext);
4729 let warned = core.take_warnings();
4730 assert!(
4731 warned
4732 .iter()
4733 .any(|w| w.code == kui_core::diag::SELECT_CURRENT_IGNORED
4734 && w.message
4735 .contains("option 1 counted from 0, which is a separator")),
4736 "{warned:?}"
4737 );
4738 let mut ext = LuaExtension::from_source(
4741 "dd",
4742 r#"
4743 function view(env)
4744 return column { pad = 10,
4745 dropdown { label = "language",
4746 options = { "English", { label = "Latin", id = "la", enabled = false } },
4747 current = 1 },
4748 }
4749 end
4750 "#,
4751 )
4752 .unwrap();
4753 let mut core = Core::new();
4754 frame(&mut core, &mut ext);
4755 let field = core.key_of("language").unwrap();
4756 core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4757 field,
4758 kui_core::AccessAction::Click,
4759 )));
4760 assert!(core.menu().is_some());
4761 assert_eq!(core.activate_menu_item(1), None, "refused");
4762 assert!(core.menu().is_some(), "the menu stays open");
4763 let events = core
4764 .activate_menu_item(0)
4765 .expect("the enabled row is taken");
4766 assert_eq!(events.len(), 1);
4767 assert!(core.menu().is_none());
4768 assert!(core.take_warnings().is_empty());
4769 }
4770
4771 #[test]
4774 fn a_grid_lines_its_rows_cells_up() {
4775 let mut ext = LuaExtension::from_source(
4776 "grid",
4777 r#"
4778 function view(env)
4779 return grid { key = "t", width = 300,
4780 row { key = "r1", width = "grow", gap = 8,
4781 text("ab", { size = 12 }),
4782 column { key = "b1", width = 10, height = 10, bg = 0xff0000ff },
4783 column { key = "c1", width = "grow", height = 10, bg = 0x00ff00ff },
4784 },
4785 row { key = "r2", width = "grow", gap = 8,
4786 text("abcdef", { size = 12 }),
4787 column { key = "b2", width = 50, height = 10, bg = 0xff0000ff },
4788 column { key = "c2", width = 20, height = 10, bg = 0x00ff00ff },
4789 },
4790 }
4791 end
4792 "#,
4793 )
4794 .unwrap();
4795 let mut core = Core::new();
4796 core.set_inspect(true);
4797 frame(&mut core, &mut ext);
4798 let rect = |core: &mut Core, label: &str| {
4799 let key = core.key_of(label).unwrap_or_else(|| panic!("{label}"));
4800 core.nodes()
4801 .iter()
4802 .find(|n| n.key == key)
4803 .map(|n| n.rect)
4804 .unwrap_or_else(|| panic!("{label}"))
4805 };
4806 let (b1, b2) = (rect(&mut core, "b1"), rect(&mut core, "b2"));
4807 let (c1, c2) = (rect(&mut core, "c1"), rect(&mut core, "c2"));
4808 assert_eq!(
4809 b1.x, b2.x,
4810 "the fixed column starts after the longest label"
4811 );
4812 assert_eq!(b1.w, 50.0, "the fixed column is its widest cell");
4813 assert_eq!(b2.w, 50.0);
4814 assert_eq!(c1.x, c2.x);
4815 assert_eq!(c1.w, c2.w, "the grow column is one width in both rows");
4816 assert_eq!(c1.x + c1.w, 300.0, "and it takes the rest");
4817 let t = core.key_of("t").unwrap();
4818 assert!(
4819 core.nodes().iter().any(|n| n.key == t && n.table),
4820 "the grid is a table"
4821 );
4822 assert!(core.take_warnings().is_empty());
4823 }
4824
4825 #[test]
4830 fn set_edit_text_by_label_seeds_the_editor_the_next_frame_declares() {
4831 let mut ext = LuaExtension::from_source(
4832 "edit",
4833 r#"
4834 frames = 0
4835 function view(env)
4836 frames = frames + 1
4837 if frames == 1 then return column {} end
4838 if frames == 2 then
4839 env.set_edit_text("note", "from the model")
4840 end
4841 return column {
4842 edit { key = "note", initial = "ignored", width = 200 },
4843 }
4844 end
4845 "#,
4846 )
4847 .unwrap();
4848 let mut core = Core::new();
4849 frame(&mut core, &mut ext);
4853 frame(&mut core, &mut ext);
4854 let key = core.key_of("note").expect("the view declared the editor");
4855 assert_eq!(core.edit_text(key).as_deref(), Some("from the model"));
4856 let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
4857 assert!(!codes.contains(&"edit-text-without-editor"), "{codes:?}");
4858 core.set_focus(Some(key));
4861 core.handle_input(InputEvent::Text("!".into()));
4862 frame(&mut core, &mut ext);
4863 assert_eq!(core.edit_text(key).as_deref(), Some("from the model!"));
4864 }
4865
4866 #[test]
4871 fn a_lua_key_sink_hears_press_and_release() {
4872 use kui_core::{KeyCode, KeyMods, KeyPress};
4873 let mut ext = LuaExtension::from_source(
4874 "game",
4875 r#"
4876 log = {}
4877 function view(env)
4878 return column { key = "world", on_key = "keys", key_up = true,
4879 key_focus = true, width = 400, height = 300 }
4880 end
4881 function on_event(ev)
4882 if ev.kind == "key" then
4883 log[#log + 1] = ev.phase .. ":" .. ev.code ..
4884 "@" .. ev.physical ..
4885 ":" .. tostring(ev.text) .. ":" .. tostring(ev.tag)
4886 end
4887 end
4888 "#,
4889 )
4890 .unwrap();
4891 let mut core = Core::new();
4892 frame(&mut core, &mut ext);
4893 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
4894 for e in core.handle_input(ev) {
4895 ext.on_event(&e);
4896 }
4897 };
4898 let w = || KeyPress::new(KeyCode::Char('w'), KeyMods::default()).with_text("w");
4899 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
4900 feed(&mut core, &mut ext, InputEvent::KeyUp(w()));
4901 let ru = || {
4906 KeyPress::from_layout(KeyCode::Char('ц'), KeyCode::Char('w'), KeyMods::default())
4907 .with_text("ц")
4908 };
4909 feed(&mut core, &mut ext, InputEvent::KeyDown(ru()));
4910 feed(&mut core, &mut ext, InputEvent::KeyUp(ru()));
4911 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
4913 core.set_focus(None);
4914 for e in core.take_pending_events() {
4915 ext.on_event(&e);
4916 }
4917 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
4918 assert_eq!(
4919 log,
4920 [
4921 "down:w@w:w:keys",
4922 "up:w@w:nil:keys",
4924 "down:w@w:ц:keys",
4927 "up:w@w:nil:keys",
4928 "down:w@w:w:keys",
4929 "up:w@w:nil:keys",
4930 ]
4931 );
4932 }
4933
4934 #[test]
4940 fn a_path_keeps_the_node_scale_beside_its_own_rotate() {
4941 let mut ext = LuaExtension::from_source(
4942 "p",
4943 r#"
4944 function view(env)
4945 return column {
4946 path { key = "p", d = "M0 0 H10 V10 H0 Z", bg = 0xffffffff,
4947 rotate = 0.25, scale = 2 },
4948 }
4949 end
4950 "#,
4951 )
4952 .unwrap();
4953 let mut core = Core::new();
4954 core.set_inspect(true);
4955 frame(&mut core, &mut ext);
4956 frame(&mut core, &mut ext);
4957 let node = core
4958 .nodes()
4959 .iter()
4960 .find(|n| n.kind == kui_core::NodeKind::Path)
4961 .cloned()
4962 .expect("the path");
4963 assert_eq!(node.scale, 2.0, "the node's scale stays");
4964 assert_eq!(node.rotate, 0.0, "the turn is the path's own");
4965 }
4966
4967 #[test]
4968 fn a_script_reads_the_frame_clock() {
4969 let mut ext = LuaExtension::from_source(
4970 "clock",
4971 r#"
4972 function view(env)
4973 if env.now < 2 then
4974 return column { key = "toast", width = 10, height = 10 }
4975 end
4976 return column {}
4977 end
4978 "#,
4979 )
4980 .unwrap();
4981 let mut core = Core::new();
4982 core.set_time(1.5);
4983 frame(&mut core, &mut ext);
4984 assert!(core.key_of("toast").is_some(), "before the deadline");
4985 core.set_time(2.5);
4986 frame(&mut core, &mut ext);
4987 assert!(core.key_of("toast").is_none(), "after it");
4988 }
4989
4990 #[test]
4993 fn a_script_asks_for_a_frame_at_a_time() {
4994 let mut ext = LuaExtension::from_source(
4995 "toast",
4996 r#"
4997 function view(env)
4998 env.request_frame_at(env.now + 3)
4999 return column { key = "toast", width = 10, height = 10 }
5000 end
5001 "#,
5002 )
5003 .unwrap();
5004 let mut core = Core::new();
5005 core.set_time(1.0);
5006 frame(&mut core, &mut ext);
5007 assert_eq!(core.next_frame_at(), Some(4.0));
5008 assert!(!core.animating());
5009 }
5010
5011 #[test]
5014 fn a_script_names_the_exit_at_the_removal() {
5015 let mut ext = LuaExtension::from_source(
5016 "throw",
5017 r##"
5018 function view(env)
5019 if env.now < 1 then
5020 return column { pad = 20,
5021 column { key = "card", width = 40, height = 40, bg = "#ffffff",
5022 transition = 1000, easing = "linear", exit = { opacity = 0 } } }
5023 end
5024 if env.now < 1.1 then env.exit_with("card", { dx = 400, opacity = 0 }) end
5025 return column { pad = 20 }
5026 end
5027 "##,
5028 )
5029 .unwrap();
5030 let mut core = Core::new();
5031 core.set_time(0.0);
5032 frame(&mut core, &mut ext);
5033 core.set_time(1.0);
5034 frame(&mut core, &mut ext);
5035 core.set_time(1.5);
5036 frame(&mut core, &mut ext);
5037 let xs: Vec<f32> = kui_core::testing::solids(&mut core)
5038 .iter()
5039 .map(|q| q.rect.x)
5040 .collect();
5041 assert!(
5042 xs.iter().any(|x| (x - 220.0).abs() < 1.0),
5043 "halfway along the throw: {xs:?}"
5044 );
5045 }
5046
5047 #[test]
5051 fn a_lua_editor_draws_its_caret_on_the_phase_the_host_sets() {
5052 let mut ext = LuaExtension::from_source(
5053 "ed",
5054 r#"
5055 function view(env)
5056 local caret = env.caret_visible and row { key = "caret", width = 2, height = 16 } or nil
5057 return column { key = "editor", on_key = "ed", key_focus = true,
5058 role = "multilineTextInput", label = "buf",
5059 row { role = "line", caret = 3, text("let value", { family = "mono", size = 14 }), caret },
5060 }
5061 end
5062 "#,
5063 )
5064 .unwrap();
5065 let mut core = Core::new();
5066 frame(&mut core, &mut ext);
5067 assert!(
5068 core.key_of("caret").is_some(),
5069 "the on phase draws the caret"
5070 );
5071 assert!(core.has_caret(), "the caret row is a caret to blink");
5072 core.set_caret_visible(false);
5073 frame(&mut core, &mut ext);
5074 assert!(core.key_of("caret").is_none(), "the off phase draws none");
5075 assert!(
5076 core.has_caret(),
5077 "and the row stays, so the clock stays armed"
5078 );
5079 core.set_caret_visible(true);
5080 frame(&mut core, &mut ext);
5081 assert!(core.key_of("caret").is_some());
5082 }
5083
5084 #[test]
5089 fn a_lua_editors_solid_caret_arms_no_clock() {
5090 let mut ext = LuaExtension::from_source(
5091 "ed",
5092 r#"
5093 function view(env)
5094 return column { key = "editor", on_key = "ed", key_focus = true,
5095 role = "multilineTextInput", label = "buf",
5096 row { role = "line", caret = 3, caret_solid = true,
5097 text("let value", { family = "mono", size = 14 }) },
5098 }
5099 end
5100 "#,
5101 )
5102 .unwrap();
5103 let mut core = Core::new();
5104 frame(&mut core, &mut ext);
5105 assert!(!core.has_caret(), "a solid caret is not a caret to blink");
5106 assert!(core.ime_rect().is_some(), "and still the IME's anchor");
5107 let editor = core.key_of("editor").unwrap();
5108 assert_eq!(core.access_tree().get(editor).unwrap().caret, Some(3));
5109 assert!(
5110 core.warnings_raised().is_empty(),
5111 "{:?}",
5112 core.warnings_raised()
5113 );
5114 }
5115
5116 #[test]
5122 fn a_lua_editor_yanks_pastes_and_hears_where_a_press_landed() {
5123 use kui_core::testing::{press, release};
5124 use kui_core::{KeyCode, KeyMods, KeyPress, MenuAction, Vec2};
5125 let mut ext = LuaExtension::from_source(
5126 "ed",
5127 r#"
5128 yank = nil
5129 paste = false
5130 log = {}
5131 function view(env)
5132 if yank then env.set_clipboard(yank, nil); yank = nil end
5133 if secret then env.set_clipboard_secret(secret); secret = nil end
5134 -- Asked on every view until the answer lands: the core
5135 -- takes one ask at a time (AR34), so this is one paste.
5136 if paste then env.request_paste() end
5137 return column { key = "editor", on_key = "ed", on_drag = "sel",
5138 key_focus = true, role = "multilineTextInput", label = "buf",
5139 width = 400, height = 300,
5140 row { role = "line", height = 20, text("hello world", { family = "mono", size = 14 }) },
5141 row { role = "line", height = 20, text("second", { family = "mono", size = 14 }) },
5142 }
5143 end
5144 function on_event(ev)
5145 if ev.kind == "key" and ev.code == "y" then yank = "hello world" end
5146 if ev.kind == "key" and ev.code == "s" then secret = "hunter2" end
5147 if ev.kind == "key" and ev.code == "p" then paste = true end
5148 if ev.kind == "text" then
5149 paste = false
5150 local marks = (ev.concealed and ":concealed" or "") .. (ev.transient and ":transient" or "")
5151 log[#log + 1] = "text:" .. ev.text .. marks
5152 end
5153 if ev.kind == "drag" then
5154 log[#log + 1] = ev.phase .. ":" .. ev.line .. ":" .. ev.byte .. ":" .. ev.clicks
5155 end
5156 end
5157 "#,
5158 )
5159 .unwrap();
5160 let mut core = Core::new();
5161 frame(&mut core, &mut ext);
5162 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
5163 for e in core.handle_input(ev) {
5164 ext.on_event(&e);
5165 }
5166 };
5167 let key = |c| KeyPress::new(KeyCode::Char(c), KeyMods::default());
5168 feed(&mut core, &mut ext, InputEvent::KeyDown(key('y')));
5169 frame(&mut core, &mut ext);
5170 assert_eq!(
5171 core.take_menu_actions(),
5172 vec![MenuAction::SetClipboard {
5173 text: "hello world".into(),
5174 html: None
5175 }]
5176 );
5177 feed(&mut core, &mut ext, InputEvent::KeyDown(key('s')));
5179 frame(&mut core, &mut ext);
5180 assert_eq!(
5181 core.take_menu_actions(),
5182 vec![MenuAction::SetClipboardSecret {
5183 text: "hunter2".into()
5184 }]
5185 );
5186 feed(&mut core, &mut ext, InputEvent::KeyDown(key('p')));
5187 frame(&mut core, &mut ext);
5188 frame(&mut core, &mut ext);
5189 assert_eq!(
5190 core.take_menu_actions(),
5191 vec![MenuAction::Paste],
5192 "two views asked, one paste queued"
5193 );
5194 assert!(core.awaiting_paste());
5195 feed(
5197 &mut core,
5198 &mut ext,
5199 InputEvent::Commit("from the clipboard".into()),
5200 );
5201 assert!(!core.awaiting_paste());
5202 frame(&mut core, &mut ext);
5203 assert!(
5204 core.take_menu_actions().is_empty(),
5205 "answered: the script stopped asking"
5206 );
5207 feed(
5209 &mut core,
5210 &mut ext,
5211 InputEvent::Paste {
5212 text: "s3cret".into(),
5213 marks: kui_core::ClipboardMarks::SECRET,
5214 },
5215 );
5216 for e in press(&mut core, Vec2::new(390.0, 30.0)) {
5218 ext.on_event(&e);
5219 }
5220 for e in release(&mut core) {
5221 ext.on_event(&e);
5222 }
5223 for e in core.handle_input(InputEvent::mouse_down(2)) {
5224 ext.on_event(&e);
5225 }
5226 for e in release(&mut core) {
5227 ext.on_event(&e);
5228 }
5229 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
5230 assert_eq!(
5231 log,
5232 [
5233 "text:from the clipboard",
5234 "text:s3cret:concealed:transient",
5235 "start:1:6:1",
5236 "end:1:6:1",
5237 "start:1:6:2",
5238 "end:1:6:2",
5239 ]
5240 );
5241 }
5242
5243 #[test]
5247 fn audio_nodes_drive_playback_commands() {
5248 use kui_core::AudioCommand;
5249 let mut ext = LuaExtension::from_source(
5250 "audio",
5251 r#"
5252 playing = true
5253 function view(env)
5254 local items = {}
5255 if playing then
5256 items[1] = audio { src = SOUND, loop = true, volume = 0.5,
5257 key = "music", tag = { kind = "music" } }
5258 end
5259 return column { table.unpack(items) }
5260 end
5261 "#,
5262 )
5263 .unwrap();
5264 let mut core = Core::new();
5265 let sound = core.add_sound(vec![0; 8]);
5266 ext.lua
5267 .globals()
5268 .set("SOUND", sound.to_ffi() as i64)
5269 .unwrap();
5270 frame(&mut core, &mut ext);
5271 let cmds = core.take_audio_commands();
5272 assert!(
5273 matches!(
5274 cmds.as_slice(),
5275 [AudioCommand::Play { sound: s, looped: true, volume, .. }]
5276 if *s == sound && *volume == 0.5
5277 ),
5278 "{cmds:?}"
5279 );
5280 frame(&mut core, &mut ext);
5281 assert!(
5282 core.take_audio_commands().is_empty(),
5283 "re-declaring is silent"
5284 );
5285 ext.lua.globals().set("playing", false).unwrap();
5286 frame(&mut core, &mut ext);
5287 assert!(matches!(
5288 core.take_audio_commands().as_slice(),
5289 [AudioCommand::Stop { .. }]
5290 ));
5291 }
5292
5293 #[test]
5297 fn measure_and_layout_events_reach_scripts() {
5298 let mut ext = LuaExtension::from_source(
5299 "measure",
5300 r#"
5301 seen = nil
5302 function view(env)
5303 local plain = env.measure_text("hello world", { size = 14 })
5304 local node = env.measure_text(text("hello world", { size = 14 }))
5305 local rich = env.measure_text({ "hello ", { "world", bold = true } }, { size = 14 })
5306 local narrow = env.measure_text("hello world", { size = 14 }, plain.width / 2)
5307 assert(plain.width > 0 and plain.lines == 1)
5308 assert(node.width == plain.width and node.height == plain.height)
5309 assert(rich.width > 0)
5310 assert(narrow.lines > 1 and narrow.width <= plain.width / 2 + 0.5)
5311 return column {
5312 row { key = "panel", width = plain.width, height = 20, on_layout = "panel" },
5313 }
5314 end
5315 function on_event(ev)
5316 if ev.kind == "layout" then seen = ev end
5317 end
5318 "#,
5319 )
5320 .unwrap();
5321 let mut core = Core::new();
5322 frame(&mut core, &mut ext);
5323 let evs = core.take_pending_events();
5324 assert_eq!(evs.len(), 1, "one layout event on first sight");
5325 for ev in &evs {
5326 ext.on_event(ev);
5327 }
5328 let seen: Table = ext.lua.globals().get("seen").unwrap();
5329 assert_eq!(seen.get::<String>("tag").unwrap(), "panel");
5330 assert_eq!(seen.get::<f64>("h").unwrap(), 20.0);
5331 assert!(seen.get::<f64>("w").unwrap() > 0.0);
5332 assert_eq!(seen.get::<i64>("node_key").unwrap(), evs[0].key.0 as i64);
5333 frame(&mut core, &mut ext);
5335 assert!(core.take_pending_events().is_empty());
5336 }
5337
5338 #[test]
5342 fn scripts_queue_window_size_and_focus_requests() {
5343 let mut ext = LuaExtension::from_source(
5344 "win",
5345 r#"
5346 function view(env)
5347 env.set_window_size(env.window.id, 640, 480)
5348 env.focus_window(env.window.id)
5349 return column { width = "grow", height = "grow" }
5350 end
5351 "#,
5352 )
5353 .unwrap();
5354 let mut core = Core::new();
5355 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5356 ui.set_origin(OriginId(1));
5357 ext.view(&Slot::root(), &mut ui).unwrap();
5358 ui.finish();
5359 assert_eq!(
5360 core.take_window_commands(),
5361 vec![
5362 kui_core::WindowCommand::SetSize {
5363 window: WindowId::MAIN,
5364 size: Size::new(640.0, 480.0),
5365 },
5366 kui_core::WindowCommand::Focus(WindowId::MAIN),
5367 ]
5368 );
5369 assert!(core.take_window_commands().is_empty());
5370 assert_eq!(core.viewport(), Size::new(400.0, 200.0));
5371 }
5372
5373 #[test]
5379 fn scripts_reveal_and_move_scroll_offsets() {
5380 let mut ext = LuaExtension::from_source(
5381 "scroll",
5382 r#"
5383 rows, want, jump, seen = 20, nil, nil, nil
5384 function view(env)
5385 if want then env.reveal(want) end
5386 if jump then env.set_scroll(jump[1], jump[2], jump[3]) end
5387 want, jump = nil, nil
5388 local list = { key = "list", width = "grow", height = "grow",
5389 scroll_y = true }
5390 for i = 0, rows - 1 do
5391 list[#list + 1] = row { key = "row" .. i, width = "grow",
5392 height = 30, bg = 0x282840ff }
5393 end
5394 seen = env.scroll_offset(list_key)
5395 return column(list)
5396 end
5397 "#,
5398 )
5399 .unwrap();
5400 let list = Key::ROOT.str("list");
5402 let row = |i: usize| list.str(&format!("row{i}"));
5403 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
5404 let mut core = Core::new();
5405 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5406 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5407 ui.set_origin(OriginId(1));
5408 ext.view(&Slot::root(), &mut ui).unwrap();
5409 ui.finish();
5410 };
5411
5412 frame(&mut core, &mut ext);
5413 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
5414
5415 ext.lua.globals().set("want", row(15).0 as i64).unwrap();
5418 frame(&mut core, &mut ext);
5419 let after = core.scroll_offset(list);
5420 assert!(after.y > 0.0, "reveal moved nothing: {after:?}");
5421 assert!(after.y <= 15.0 * 30.0, "scrolled past row 15: {after:?}");
5422 frame(&mut core, &mut ext);
5424 assert_eq!(core.scroll_offset(list), after);
5425
5426 let seen: Table = ext.lua.globals().get("seen").unwrap();
5428 assert_eq!(seen.get::<f32>("y").unwrap(), after.y);
5429 assert_eq!(seen.get::<f32>("x").unwrap(), 0.0);
5430
5431 let jump = |ext: &LuaExtension, y: f64| {
5433 let t = ext.lua.create_table().unwrap();
5434 t.set(1, list.0 as i64).unwrap();
5435 t.set(2, 0.0).unwrap();
5436 t.set(3, y).unwrap();
5437 ext.lua.globals().set("jump", t).unwrap();
5438 };
5439 jump(&ext, 1e9);
5440 frame(&mut core, &mut ext);
5441 assert_eq!(core.scroll_offset(list).y, 400.0);
5442 jump(&ext, -1e9);
5443 frame(&mut core, &mut ext);
5444 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
5445
5446 jump(&ext, 120.0);
5448 frame(&mut core, &mut ext);
5449 ext.lua
5450 .globals()
5451 .set("want", Key::ROOT.str("ghost").0 as i64)
5452 .unwrap();
5453 frame(&mut core, &mut ext);
5454 assert_eq!(core.scroll_offset(list).y, 120.0);
5455 frame(&mut core, &mut ext);
5456 assert_eq!(core.scroll_offset(list).y, 120.0);
5457 }
5458
5459 #[test]
5463 fn scripts_read_the_pressed_state() {
5464 let mut ext = LuaExtension::from_source(
5465 "press",
5466 r#"
5467 function view(env)
5468 pressed = env.is_pressed(btn_key)
5469 hovered = env.is_hovered(btn_key)
5470 return column { key = "root", pad = 10,
5471 row { key = "btn", width = 100, height = 40,
5472 bg = 0x333333ff, on_click = "hit" },
5473 }
5474 end
5475 "#,
5476 )
5477 .unwrap();
5478 let btn = Key::ROOT.str("root").str("btn");
5479 ext.lua.globals().set("btn_key", btn.0 as i64).unwrap();
5480 let mut core = Core::new();
5481 let pressed = |ext: &LuaExtension| ext.lua.globals().get::<bool>("pressed").unwrap();
5482 let hovered = |ext: &LuaExtension| ext.lua.globals().get::<bool>("hovered").unwrap();
5483
5484 frame(&mut core, &mut ext);
5485 assert!(!pressed(&ext), "idle");
5486
5487 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
5489 frame(&mut core, &mut ext);
5490 assert!(hovered(&ext) && !pressed(&ext), "hover is not press");
5491
5492 core.handle_input(InputEvent::MouseDown {
5493 button: kui_core::MouseButton::Primary,
5494 clicks: 1,
5495 });
5496 frame(&mut core, &mut ext);
5497 assert!(pressed(&ext), "held down");
5498
5499 core.handle_input(InputEvent::MouseUp {
5503 button: kui_core::MouseButton::Primary,
5504 });
5505 frame(&mut core, &mut ext);
5506 assert!(!pressed(&ext), "released");
5507 }
5508
5509 #[test]
5514 fn scripts_read_the_drop_target() {
5515 let mut ext = LuaExtension::from_source(
5516 "drop",
5517 r#"
5518 function view(env)
5519 over = env.is_drop_target(zone_key)
5520 target = env.drop_target()
5521 return column { key = "root", pad = 10,
5522 row { key = "zone", width = 100, height = 40,
5523 bg = 0x333333ff, drop_bg = 0x335533ff, on_drop = "files" },
5524 }
5525 end
5526 "#,
5527 )
5528 .unwrap();
5529 let zone = Key::ROOT.str("root").str("zone");
5530 ext.lua.globals().set("zone_key", zone.0 as i64).unwrap();
5531 let mut core = Core::new();
5532 let over = |ext: &LuaExtension| ext.lua.globals().get::<bool>("over").unwrap();
5533 let target = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("target").unwrap();
5534
5535 frame(&mut core, &mut ext);
5536 assert!(!over(&ext) && target(&ext).is_none(), "idle");
5537
5538 let paths = vec!["/drop/1.txt".to_string()];
5539 let evs = core.handle_input(InputEvent::DragFiles {
5540 paths: paths.clone(),
5541 at: Vec2::new(50.0, 30.0),
5542 });
5543 assert_eq!(evs[0].payload.get_str("tag"), Some("files"));
5544 frame(&mut core, &mut ext);
5545 assert!(over(&ext), "the files are over the zone");
5546 assert_eq!(target(&ext), Some(zone.0 as i64));
5547 let (dl, _) = core.output();
5548 let lit = dl
5549 .quads
5550 .iter()
5551 .find(|q| q.rect.w == 100.0)
5552 .map(|q| q.color)
5553 .expect("zone quad");
5554 assert!(
5555 (lit.g - 0x55 as f32 / 255.0).abs() < 0.01,
5556 "drop_bg painted"
5557 );
5558
5559 core.handle_input(InputEvent::DropFiles {
5560 paths,
5561 at: Vec2::new(50.0, 30.0),
5562 });
5563 frame(&mut core, &mut ext);
5564 assert!(
5565 !over(&ext) && target(&ext).is_none(),
5566 "a drop ends the hover"
5567 );
5568 }
5569
5570 #[test]
5581 fn the_env_table_is_the_documented_env_shape() {
5582 let mut ext = LuaExtension::from_source(
5583 "env",
5584 r#"
5585 function view(env)
5586 values, calls = {}, {}
5587 for k, v in pairs(env) do
5588 if type(v) == "function" then calls[#calls + 1] = k
5589 elseif type(v) == "table" then
5590 for wk in pairs(v) do values[#values + 1] = k .. "." .. wk end
5591 else values[#values + 1] = k end
5592 end
5593 return column { key = "root",
5594 row { key = "a", focusable = true, width = 50, height = 20 },
5595 column { key = "dock", focus_region = true,
5596 row { key = "d", focusable = true, width = 50, height = 20 },
5597 },
5598 }
5599 end
5600 "#,
5601 )
5602 .unwrap();
5603 let mut core = Core::new();
5604 core.env.refresh_hz = Some(60.0);
5609 core.env.system.accent = Some(kui_core::Color::hex(0x3b82f6ff));
5610 core.env.system.locale = kui_core::Locale::new("en-US");
5611 core.env.window.native_controls = Some(Rect::new(0.0, 0.0, 78.0, 28.0));
5612 frame(&mut core, &mut ext);
5613 core.set_focus(Some(Key::ROOT.str("root").str("dock").str("d")));
5614 frame(&mut core, &mut ext);
5615
5616 let sorted = |name: &str| -> Vec<String> {
5617 let mut v: Vec<String> = ext.lua.globals().get(name).unwrap();
5618 v.sort();
5619 v
5620 };
5621 let mut documented: Vec<String> = kui_core::schema::ENV_FIELDS
5622 .iter()
5623 .flat_map(|f| f.lua.iter().map(|k| (*k).to_string()))
5624 .collect();
5625 documented.extend(
5631 kui_core::schema::THEME_ROLES
5632 .iter()
5633 .map(|r| format!("theme.{}", r.name)),
5634 );
5635 documented.push("theme.appearance".into());
5636 documented.push("theme.disabled_opacity".into());
5637 documented.extend(
5640 kui_core::schema::METRIC_ROLES
5641 .iter()
5642 .map(|r| format!("metrics.{}", r.name)),
5643 );
5644 documented.push("tokens.colors".into());
5647 documented.push("tokens.lengths".into());
5648 documented.sort();
5649 assert_eq!(
5650 sorted("values"),
5651 documented,
5652 "env's value keys and schema::ENV_FIELDS + THEME_ROLES + METRIC_ROLES + tokens disagree"
5653 );
5654 let mut doors: Vec<String> = kui_core::schema::DOORS
5661 .iter()
5662 .filter_map(|d| match d.lua {
5663 kui_core::schema::Cell::Is(name) => Some(name.to_string()),
5664 _ => None,
5665 })
5666 .collect();
5667 doors.sort();
5668 assert_eq!(
5669 sorted("calls"),
5670 doors,
5671 "env's queries and verbs and schema::DOORS's Lua column disagree; update env_table's doc too"
5672 );
5673 }
5674
5675 #[test]
5680 fn a_script_declares_tokens_and_references_them_by_name() {
5681 let mut ext = LuaExtension::from_source(
5682 "tokens",
5683 r##"
5684 tokens = {
5685 colors = { peach = "#ffcc99", ink = { light = "#111111", dark = "#eeeeee" } },
5686 lengths = { side_w = 132, gap = 6 },
5687 }
5688 function view(env)
5689 seen = env.tokens
5690 return column { pad = "$gap", gap = "$gap",
5691 row { key = "a", width = "$side_w", height = 20, bg = "$peach",
5692 border = { w = "$gap", color = "$ink" }, radius = "$radius" },
5693 row { key = "b", width = 20, height = "$side_w", bg = "$surface",
5694 pad = { l = "$gap", r = 2 } },
5695 text({ "x", { "y", color = "$peach", bg = "$ink" } }, { size = "$gap", color = "$peach" }),
5696 }
5697 end
5698 "##,
5699 )
5700 .unwrap();
5701 let mut core = Core::new();
5702 frame(&mut core, &mut ext);
5703 assert!(core.take_warnings().is_empty());
5704 let peach = Color::hex(0xffcc99ff);
5705 let paper = Color::hex(0xeeeeeeff);
5706 let radius = core.metrics().radius;
5707 let surface = core.theme().surface;
5708 let dl = core.output().0;
5709 let a = dl
5710 .quads
5711 .iter()
5712 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
5713 .expect("the peach box");
5714 assert_eq!(a.rect.w, 132.0, "width from a length token");
5715 assert_eq!(a.border_w, 6.0);
5716 assert_eq!(
5717 a.border_color, paper,
5718 "the dark half on an unknown appearance"
5719 );
5720 assert_eq!(a.radius[0], radius, "$radius is the metric");
5721 let b = dl
5722 .quads
5723 .iter()
5724 .find(|q| q.color == surface && q.rect.h == 132.0)
5725 .expect("the $surface box, 132 tall");
5726 assert_eq!(b.rect.w, 20.0);
5727 let seen: Table = ext.lua.globals().get("seen").unwrap();
5728 let colors: Table = seen.get("colors").unwrap();
5729 let lengths: Table = seen.get("lengths").unwrap();
5730 assert_eq!(colors.get::<u32>("peach").unwrap(), 0xffcc99ff);
5731 assert_eq!(colors.get::<u32>("ink").unwrap(), 0xeeeeeeff);
5732 assert_eq!(lengths.get::<f32>("side_w").unwrap(), 132.0);
5733 core.set_system(kui_core::SystemEnv {
5735 appearance: kui_core::Appearance::Light,
5736 ..Default::default()
5737 });
5738 frame(&mut core, &mut ext);
5739 let a = core
5740 .output()
5741 .0
5742 .quads
5743 .iter()
5744 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
5745 .unwrap();
5746 assert_eq!(a.border_color, Color::hex(0x111111ff));
5747 }
5748
5749 #[test]
5755 fn a_script_derives_a_token_from_another() {
5756 let mut ext = LuaExtension::from_source(
5757 "derived",
5758 r##"
5759 tokens = {
5760 colors = {
5761 peach = "#ffcc99",
5762 z_lit = { from = "peach", ops = { "lift", 0.5 } },
5763 a_deep = { from = "z_lit", ops = { { "alpha", 0.5 }, { "mix", "peach", 0.0 } } },
5764 up = { from = "surface", ops = { { "raise", 0.25 } } },
5765 bad = { from = "nothing" },
5766 },
5767 }
5768 function view(env)
5769 seen = env.tokens.colors
5770 return column {
5771 row { key = "a", width = 20, height = 20, bg = "$a_deep" },
5772 row { key = "b", width = 20, height = 20, bg = "$bad" },
5773 }
5774 end
5775 "##,
5776 )
5777 .unwrap();
5778 let mut core = Core::new();
5779 frame(&mut core, &mut ext);
5780 let warnings = core.take_warnings();
5781 let codes: Vec<&str> = warnings.iter().map(|w| w.code).collect();
5782 assert_eq!(codes, ["unknown-token"], "{warnings:?}");
5783 assert!(
5784 warnings[0].message.contains("`$bad`") && warnings[0].message.contains("`$nothing`")
5785 );
5786 let seen: Table = ext.lua.globals().get("seen").unwrap();
5787 assert_eq!(seen.get::<u32>("z_lit").unwrap(), 0xffe6ccff);
5788 assert_eq!(seen.get::<u32>("a_deep").unwrap(), 0xffe6cc80);
5789 let surface = core.theme().surface;
5790 assert_eq!(
5791 seen.get::<u32>("up").unwrap(),
5792 surface.mix(Color::WHITE, 0.25).to_hex(),
5793 "a role source, raised toward the dark base's front"
5794 );
5795 assert!(seen.get::<mlua::Value>("bad").unwrap().is_nil());
5796 let dl = core.output().0;
5797 assert!(
5798 dl.quads.iter().any(|q| q.color.to_hex() == 0xffe6cc80),
5799 "$a_deep painted the derived colour"
5800 );
5801
5802 let refused = |colors: &str| {
5804 LuaExtension::from_source(
5805 "bad",
5806 &format!(
5807 "tokens = {{ colors = {{ {colors} }} }}\nfunction view() return column {{}} end"
5808 ),
5809 )
5810 .err()
5811 .map(|e| e.to_string())
5812 .unwrap_or_default()
5813 };
5814 assert!(
5815 refused(r#"x = { from = "peach", ops = { { "glow", 1 } } }"#)
5816 .contains("unknown verb \"glow\"")
5817 );
5818 assert!(
5819 refused(r#"x = { from = "peach", ops = { { "mix", 0.5 } } }"#)
5820 .contains("mix takes a colour and a number")
5821 );
5822 assert!(
5823 refused(r#"x = { from = "peach", ops = { { "lift", "lots" } } }"#)
5824 .contains("number is a number")
5825 );
5826 assert!(refused(r#"x = { from = 3 }"#).contains("from names a colour token or role"));
5827 assert!(refused(r#"x = { from = "peach", glow = 1 }"#).contains("unknown key \"glow\""));
5828 }
5829
5830 #[test]
5835 fn a_scripts_tokens_sit_over_the_hosts() {
5836 let mut ext = LuaExtension::from_source(
5837 "guest",
5838 r##"
5839 tokens = { colors = { grey = "#808080" } }
5840 function view(env)
5841 first = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
5842 if redeclare then
5843 env.set_tokens { colors = { peach = "#ffaa77" }, lengths = { w = 40 } }
5844 end
5845 second = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
5846 return column { row { key = "a", width = redeclare and "$w" or 10, height = 10, bg = "$peach" } }
5847 end
5848 "##,
5849 )
5850 .unwrap();
5851 let mut core = Core::new();
5852 core.set_tokens(kui_core::Tokens::new().color("peach", Color::hex(0xffcc99ff)));
5853 frame(&mut core, &mut ext);
5854 let first: Table = ext.lua.globals().get("first").unwrap();
5855 assert_eq!(
5856 first.get::<u32>("peach").unwrap(),
5857 0xffcc99ff,
5858 "the host's peach"
5859 );
5860 assert_eq!(
5861 first.get::<u32>("grey").unwrap(),
5862 0x808080ff,
5863 "its own grey"
5864 );
5865 assert!(
5866 core.token_lookup().color("grey").is_err(),
5867 "the guest's grey is not the host's"
5868 );
5869 assert!(core.take_warnings().is_empty());
5870
5871 ext.lua.globals().set("redeclare", true).unwrap();
5872 frame(&mut core, &mut ext);
5873 let second: Table = ext.lua.globals().get("second").unwrap();
5874 assert_eq!(
5875 second.get::<u32>("peach").unwrap(),
5876 0xffaa77ff,
5877 "its own peach now"
5878 );
5879 assert!(
5880 second.get::<Option<u32>>("grey").unwrap().is_none(),
5881 "replaced whole"
5882 );
5883 let a = core
5884 .output()
5885 .0
5886 .quads
5887 .iter()
5888 .find(|q| q.color == Color::hex(0xffaa77ff))
5889 .expect("painted the script's peach");
5890 assert_eq!(a.rect.w, 40.0);
5891 assert_eq!(
5892 core.token_lookup().color("peach"),
5893 Ok(Color::hex(0xffcc99ff)),
5894 "the host's table did not move"
5895 );
5896 }
5897
5898 #[test]
5901 fn a_missing_token_warns_and_paints_nothing() {
5902 let mut ext = LuaExtension::from_source(
5903 "missing",
5904 r##"
5905 tokens = { colors = { surface = "#ff0000", peach = "#ffcc99" }, lengths = { gap = 6 } }
5906 function view(env)
5907 return column {
5908 row { key = "a", width = "$gap", height = 10, bg = "$peech", pad = "$peach" },
5909 row { key = "b", width = "$peach", height = 10, bg = "$gap" },
5910 text("still here", { color = "$peech", size = "$gap" }),
5911 text("still here", { size = 6 }),
5912 text("still here"),
5913 }
5914 end
5915 "##,
5916 )
5917 .unwrap();
5918 let mut core = Core::new();
5919 core.set_inspect(true);
5920 frame(&mut core, &mut ext);
5921 frame(&mut core, &mut ext);
5922 let ws = core.take_warnings();
5923 let mut codes: Vec<&str> = ws.iter().map(|w| w.code).collect();
5924 codes.sort();
5925 assert_eq!(
5926 codes,
5927 [
5928 kui_core::diag::RESERVED_TOKEN,
5929 kui_core::diag::UNKNOWN_TOKEN,
5930 kui_core::diag::UNKNOWN_TOKEN,
5931 kui_core::diag::UNKNOWN_TOKEN,
5932 ],
5933 "surface refused once; peech, peach-as-length and gap-as-colour once each: {ws:?}"
5934 );
5935 assert!(
5936 ws.iter().any(|w| w
5937 .message
5938 .contains("`$gap` is a length token, and this slot takes a color")),
5939 "{ws:?}"
5940 );
5941 let fg = core.theme().fg;
5945 let dl = core.output().0;
5946 assert!(
5947 dl.quads
5948 .iter()
5949 .all(|q| q.kind != kui_core::QuadKind::Solid || q.color.a == 0.0)
5950 );
5951 let glyphs: Vec<_> = dl
5952 .quads
5953 .iter()
5954 .filter(|q| {
5955 matches!(
5956 q.kind,
5957 kui_core::QuadKind::GlyphMask | kui_core::QuadKind::GlyphSubpixel
5958 )
5959 })
5960 .collect();
5961 assert!(!glyphs.is_empty(), "the text painted");
5962 assert!(glyphs.iter().all(|q| q.color == fg), "in the foreground");
5963 let heights: Vec<f32> = core
5968 .nodes()
5969 .iter()
5970 .filter(|n| n.text.as_deref() == Some("still here"))
5971 .map(|n| n.rect.h)
5972 .collect();
5973 assert_eq!(heights.len(), 3, "{heights:?}");
5974 assert!(heights[0] > 0.0, "{heights:?}");
5975 assert_eq!(heights[0], heights[1], "$gap is the literal 6: {heights:?}");
5976 assert!(heights[2] > heights[0], "and not the default: {heights:?}");
5977 }
5978
5979 #[test]
5984 fn a_gradient_stop_that_misses_is_left_out() {
5985 let mut ext = LuaExtension::from_source(
5986 "gradient",
5987 r##"
5988 tokens = { colors = { peach = "#ffcc99" } }
5989 function view(env)
5990 return column {
5991 row { key = "three", width = 40, height = 10,
5992 gradient = { stops = { "$peach", "$peech", "#0000ff" } } },
5993 row { key = "two", width = 40, height = 10, bg = "#000000",
5994 gradient = { stops = { "$peach", "$peech" } } },
5995 }
5996 end
5997 "##,
5998 )
5999 .unwrap();
6000 let mut core = Core::new();
6001 frame(&mut core, &mut ext);
6002 frame(&mut core, &mut ext);
6003 let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
6004 assert_eq!(codes, [kui_core::diag::UNKNOWN_TOKEN], "once, for the name");
6005 let dl = core.output().0;
6006 let images: Vec<_> = dl
6007 .quads
6008 .iter()
6009 .filter(|q| q.kind == kui_core::QuadKind::Image)
6010 .collect();
6011 assert_eq!(images.len(), 1, "the three-stop row's two");
6012 assert!(
6013 images[0].rect.y < 10.0,
6014 "on the first row: {:?}",
6015 images[0].rect
6016 );
6017 let black = dl
6018 .quads
6019 .iter()
6020 .filter(|q| q.kind == kui_core::QuadKind::Solid && q.color == Color::hex(0x000000ff))
6021 .count();
6022 assert_eq!(black, 1, "the second row's bg");
6023 }
6024
6025 #[test]
6030 fn a_token_reaches_a_min_a_stroke_and_a_stop_and_misses_by_leaving_the_slot() {
6031 let mut ext = LuaExtension::from_source(
6032 "everywhere",
6033 r##"
6034 tokens = { colors = { peach = "#ffcc99" }, lengths = { gap = 6, wide = 40 } }
6035 function view(env)
6036 return column { pad = 4,
6037 row { key = "clamp", width = 10, height = 10, min_width = "$wide" },
6038 row { key = "typo", width = 10, height = 10, min_width = "$nope" },
6039 line { key = "stroke", from = { 0, 0 }, to = { 30, 0 }, width = "$gap", color = "$peach" },
6040 row { key = "anim", width = 10, height = 10, transition = 100,
6041 keyframes = { { bg = "$peach", width = "$wide" }, { bg = "$peech", radius = "$gap" } },
6042 enter = { width = "$nothing", bg = "$peach" } },
6043 }
6044 end
6045 "##,
6046 )
6047 .unwrap();
6048 let mut core = Core::new();
6049 core.set_inspect(true);
6050 frame(&mut core, &mut ext);
6051 let nodes = core.nodes();
6052 let by = |label: &str| {
6053 nodes
6054 .iter()
6055 .find(|n| n.label.as_deref() == Some(label))
6056 .unwrap()
6057 };
6058 assert_eq!(by("clamp").rect.w, 40.0, "the clamp is the token's 40 px");
6059 assert_eq!(
6060 by("typo").rect.w,
6061 10.0,
6062 "a miss leaves the row at its default"
6063 );
6064 let dl = core.output().0;
6065 let seg = dl
6066 .quads
6067 .iter()
6068 .find(|q| q.kind == kui_core::QuadKind::Segment)
6069 .expect("the line drew");
6070 assert_eq!(seg.color, Color::hex(0xffcc99ff));
6071 assert_eq!(seg.border_w, 6.0, "the stroke is the token's width");
6072 let ws = core.take_warnings();
6073 let mut names: Vec<String> = ws
6074 .iter()
6075 .filter(|w| w.code == kui_core::diag::UNKNOWN_TOKEN)
6076 .map(|w| w.message.clone())
6077 .collect();
6078 names.sort();
6079 assert_eq!(names.len(), 3, "nope, peech, nothing: {ws:?}");
6080 assert!(names.iter().any(|m| m.contains("`$nope`")), "{names:?}");
6081 assert!(names.iter().any(|m| m.contains("`$peech`")), "{names:?}");
6082 assert!(names.iter().any(|m| m.contains("`$nothing`")), "{names:?}");
6083 }
6084
6085 #[test]
6086 fn scripts_move_focus_with_the_verbs() {
6087 let mut ext = LuaExtension::from_source(
6088 "focus",
6089 r#"
6090 function view(env)
6091 if cmd == "set" then env.set_focus(target)
6092 elseif cmd == "blur" then env.blur()
6093 elseif cmd == "next" then env.focus_next()
6094 elseif cmd == "prev" then env.focus_prev() end
6095 cmd = nil
6096 -- `env.focus` is a value the host filled in before view()
6097 -- ran, so it lags a verb called above by a frame;
6098 -- `env.is_focused` is a call and answers about now.
6099 seen_focus = env.focus
6100 seen_live = env.is_focused(target or 0)
6101 seen_window = env.focused
6102 return column { key = "root", pad = 10,
6103 row { key = "a", focusable = true, width = 50, height = 20 },
6104 row { key = "b", focusable = true, width = 50, height = 20 },
6105 row { key = "c", focusable = true, width = 50, height = 20 },
6106 }
6107 end
6108 "#,
6109 )
6110 .unwrap();
6111 let root = Key::ROOT.str("root");
6112 let (a, b, c) = (root.str("a"), root.str("b"), root.str("c"));
6113 let mut core = Core::new();
6114 let cmd = |ext: &LuaExtension, c: &str| ext.lua.globals().set("cmd", c).unwrap();
6115 let seen = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("seen_focus").unwrap();
6116 let live = |ext: &LuaExtension| ext.lua.globals().get::<bool>("seen_live").unwrap();
6117
6118 frame(&mut core, &mut ext);
6120 assert_eq!(core.focus(), None);
6121 assert_eq!(seen(&ext), None, "nil for no focus, not 0");
6122
6123 ext.lua.globals().set("target", b.0 as i64).unwrap();
6125 cmd(&ext, "set");
6126 frame(&mut core, &mut ext);
6127 assert_eq!(core.focus(), Some(b));
6128 assert_eq!(seen(&ext), None, "the value lags a same-frame verb");
6132 assert!(live(&ext), "the query does not");
6133 frame(&mut core, &mut ext);
6134 assert_eq!(
6135 seen(&ext),
6136 Some(b.0 as i64),
6137 "and the next frame carries it"
6138 );
6139
6140 cmd(&ext, "next");
6142 frame(&mut core, &mut ext);
6143 assert_eq!(core.focus(), Some(c));
6144 cmd(&ext, "next");
6145 frame(&mut core, &mut ext);
6146 assert_eq!(core.focus(), Some(a), "wraps");
6147 cmd(&ext, "prev");
6148 frame(&mut core, &mut ext);
6149 assert_eq!(core.focus(), Some(c), "wraps back");
6150
6151 cmd(&ext, "blur");
6152 frame(&mut core, &mut ext);
6153 assert_eq!(core.focus(), None);
6154 frame(&mut core, &mut ext);
6155 assert_eq!(seen(&ext), None);
6156
6157 ext.lua.globals().set("target", "c").unwrap();
6160 cmd(&ext, "set");
6161 frame(&mut core, &mut ext);
6162 assert_eq!(core.focus(), Some(c), "set_focus(\"c\") resolves the label");
6163 assert!(live(&ext), "and is_focused(\"c\") answers about it");
6164 assert!(core.take_warnings().is_empty());
6165 ext.lua.globals().set("target", "nope").unwrap();
6167 cmd(&ext, "set");
6168 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
6169 let e = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
6170 ui.finish();
6171 assert!(
6172 e.contains("no node is keyed \"nope\"") && e.contains("integer key"),
6173 "{e}"
6174 );
6175
6176 assert!(ext.lua.globals().get::<bool>("seen_window").unwrap());
6179 }
6180
6181 #[test]
6184 fn a_shared_label_resolves_to_the_first_and_warns() {
6185 let mut ext = LuaExtension::from_source(
6186 "dup",
6187 r#"
6188 function view(env)
6189 if go then env.set_focus("item"); go = nil end
6190 return column { key = "root",
6191 row { row { key = "item", focusable = true, width = 50, height = 20 } },
6192 row { row { key = "item", focusable = true, width = 50, height = 20 } },
6193 }
6194 end
6195 "#,
6196 )
6197 .unwrap();
6198 let mut core = Core::new();
6199 frame(&mut core, &mut ext);
6200 ext.lua.globals().set("go", true).unwrap();
6201 frame(&mut core, &mut ext);
6202 assert_eq!(
6203 core.focus(),
6204 Some(Key::ROOT.str("root").index(0).str("item")),
6205 "the first, under the auto-keyed first row"
6206 );
6207 let ws = core.take_warnings();
6208 assert_eq!(ws.len(), 1, "{ws:?}");
6209 assert_eq!(ws[0].code, kui_core::diag::AMBIGUOUS_KEY);
6210 assert!(ws[0].message.contains("\"item\""), "{}", ws[0].message);
6211 }
6212
6213 #[test]
6216 fn scripts_open_a_menu_and_hear_the_row() {
6217 let mut ext = LuaExtension::from_source(
6218 "menu",
6219 r#"
6220 frames = 0
6221 function view(env)
6222 frames = frames + 1
6223 if frames == 2 then
6224 opened = env.open_menu("card", 40, 30, {
6225 { role = "copy" },
6226 { role = "separator" },
6227 -- The wire spelling the event reports, and the
6228 -- snake one a script may have learned first.
6229 { role = "selectAll" },
6230 { role = "look_up" },
6231 { label = "Wrap", checked = true },
6232 { label = "Inspect", id = "inspect" },
6233 })
6234 end
6235 return column {
6236 column { key = "card", selectable = true, text("one", { size = 14 }) },
6237 }
6238 end
6239 "#,
6240 )
6241 .unwrap();
6242 let mut core = Core::new();
6243 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6244 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6245 ui.set_origin(OriginId(1));
6246 ext.view(&Slot::root(), &mut ui).unwrap();
6247 ui.finish();
6248 };
6249 frame(&mut core, &mut ext);
6250 frame(&mut core, &mut ext);
6251 assert!(ext.lua.globals().get::<bool>("opened").unwrap());
6252 let roles: Vec<_> = core
6253 .menu()
6254 .expect("open")
6255 .items
6256 .iter()
6257 .map(|i| (i.role, i.checked))
6258 .collect();
6259 assert_eq!(
6260 roles,
6261 [
6262 (kui_core::MenuRole::Copy, false),
6263 (kui_core::MenuRole::Separator, false),
6264 (kui_core::MenuRole::SelectAll, false),
6265 (kui_core::MenuRole::LookUp, false),
6266 (kui_core::MenuRole::Custom, true),
6267 (kui_core::MenuRole::Custom, false),
6268 ]
6269 );
6270 frame(&mut core, &mut ext);
6271 let tree = core.access_tree();
6274 assert_eq!(
6275 tree.nodes
6276 .iter()
6277 .find(|n| n.name.as_deref() == Some("Wrap"))
6278 .expect("the checked row")
6279 .checked,
6280 Some(true)
6281 );
6282 let row = tree
6283 .nodes
6284 .iter()
6285 .find(|n| n.name.as_deref() == Some("Inspect"))
6286 .expect("the menu drew")
6287 .rect;
6288 let at = kui_core::Vec2::new(row.x + row.w / 2.0, row.y + row.h / 2.0);
6289 core.handle_input(InputEvent::CursorMoved(at));
6290 core.handle_input(InputEvent::mouse_down(1));
6291 let events = core.handle_input(InputEvent::mouse_up());
6292 assert_eq!(events.len(), 1, "{events:?}");
6293 assert_eq!(events[0].payload.get_str("item"), Some("inspect"));
6294 assert_eq!(
6295 events[0].origin,
6296 OriginId(1),
6297 "posted with the origin that asked for the menu"
6298 );
6299 }
6300
6301 #[test]
6304 fn scripts_select_and_read_a_scope() {
6305 let mut ext = LuaExtension::from_source(
6306 "sel",
6307 r#"
6308 frames = 0
6309 function view(env)
6310 frames = frames + 1
6311 if frames > 1 then
6312 before = env.selection_text()
6313 took = env.select_all_in("card")
6314 text_out = env.selection_text()
6315 not_a_scope = env.select_all_in("plain")
6316 end
6317 return column {
6318 column { key = "card", selectable = true,
6319 text("one", { size = 14 }),
6320 text("two", { size = 14 }) },
6321 row { key = "plain", width = 10, height = 10 },
6322 }
6323 end
6324 "#,
6325 )
6326 .unwrap();
6327 let mut core = Core::new();
6328 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6329 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6330 ui.set_origin(OriginId(1));
6331 ext.view(&Slot::root(), &mut ui).unwrap();
6332 ui.finish();
6333 };
6334 frame(&mut core, &mut ext);
6335 frame(&mut core, &mut ext);
6336 let g = ext.lua.globals();
6337 assert!(matches!(
6338 g.get::<mlua::Value>("before").unwrap(),
6339 mlua::Value::Nil
6340 ));
6341 assert!(g.get::<bool>("took").unwrap());
6342 assert_eq!(g.get::<String>("text_out").unwrap(), "one\ntwo");
6343 assert!(
6344 !g.get::<bool>("not_a_scope").unwrap(),
6345 "a node that drew no text is not a scope"
6346 );
6347 }
6348
6349 #[test]
6354 fn scripts_map_points_to_bytes_on_a_line_of_runs() {
6355 let mut ext = LuaExtension::from_source(
6356 "hit",
6357 r#"
6358 frames = 0
6359 function view(env)
6360 local mono = { size = 14, family = "mono" }
6361 local w = env.measure_text("M", mono, 0).width
6362 frames = frames + 1
6363 -- A label nothing has declared yet is an error by name
6364 -- (F5), so the first frame declares and the next asks.
6365 if frames > 1 then
6366 hit = env.text_hit("line", 7.2 * w, 5)
6367 seam = env.caret_rect("line", 4)
6368 far = env.text_hit("line", 390, 5)
6369 none = env.caret_rect("plain", 0)
6370 end
6371 cell = w
6372 return column {
6373 row { key = "line",
6374 text("let ", mono),
6375 row { bg = 0x3b5bd455, text("value", mono) },
6376 text(" = 1;", mono) },
6377 row { key = "plain", width = 10, height = 10 },
6378 }
6379 end
6380 "#,
6381 )
6382 .unwrap();
6383 let mut core = Core::new();
6384 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6385 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6386 ui.set_origin(OriginId(1));
6387 ext.view(&Slot::root(), &mut ui).unwrap();
6388 ui.finish();
6389 };
6390 frame(&mut core, &mut ext);
6391 let hit: mlua::Value = ext.lua.globals().get("hit").unwrap();
6392 assert!(
6393 matches!(hit, mlua::Value::Nil),
6394 "nothing drawn before the first frame"
6395 );
6396 frame(&mut core, &mut ext);
6397 let hit: mlua::Table = ext.lua.globals().get("hit").unwrap();
6398 assert_eq!(
6399 hit.get::<usize>("byte").unwrap(),
6400 7,
6401 "the fourth cell of \"value\""
6402 );
6403 assert_eq!(hit.get::<u32>("line").unwrap(), 0);
6404 let cell: f32 = ext.lua.globals().get("cell").unwrap();
6405 let seam: mlua::Table = ext.lua.globals().get("seam").unwrap();
6406 let x: f32 = seam.get("x").unwrap();
6407 assert!((x - 4.0 * cell).abs() < 0.75, "{x} vs {}", 4.0 * cell);
6408 assert_eq!(seam.get::<f32>("w").unwrap(), 0.0);
6409 let far: mlua::Table = ext.lua.globals().get("far").unwrap();
6410 assert_eq!(
6415 far.get::<usize>("byte").unwrap(),
6416 14,
6417 "the end, across the runs"
6418 );
6419 let none: mlua::Value = ext.lua.globals().get("none").unwrap();
6420 assert!(matches!(none, mlua::Value::Nil), "a node that drew no text");
6421 }
6422
6423 #[test]
6427 fn scripts_draw_a_screen_of_cells() {
6428 let mut ext = LuaExtension::from_source(
6429 "term",
6430 r#"
6431 function view(env)
6432 return column { cells { key = "term", rows = 2, cols = 11, size = 14, family = "mono",
6433 lines = { "hello world", " bye" },
6434 runs = { { 1, 2, 3, 0xff0000ff, 0x0000ffff, 1 } },
6435 cursor_at = { 1, 4 }, cursor_shape = "underline" } }
6436 end
6437 "#,
6438 )
6439 .unwrap();
6440 let mut core = Core::new();
6441 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6442 ui.set_origin(OriginId(1));
6443 ext.view(&Slot::root(), &mut ui).unwrap();
6444 ui.finish();
6445 let (dl, _) = core.output();
6446 let glyphs = dl
6447 .quads
6448 .iter()
6449 .filter(|q| q.kind == kui_core::QuadKind::GlyphMask)
6450 .count();
6451 assert_eq!(glyphs, 13, "hello world + bye");
6452 let tree = core.access_tree();
6453 let term = tree
6454 .nodes
6455 .iter()
6456 .find(|n| n.role == kui_core::Role::Terminal)
6457 .expect("a terminal node");
6458 assert_eq!(term.value.as_deref(), Some("hello world\n bye"));
6459 }
6460
6461 #[test]
6467 fn a_lua_grid_selects_by_cell_word_and_row() {
6468 let mut ext = LuaExtension::from_source(
6469 "term",
6470 r#"
6471 function view(env)
6472 said = env.selection_text()
6473 return column { cells { key = "term", rows = 2, cols = 12,
6474 size = 14, family = "mono", line_height = 20,
6475 origin_line = 900, selectable = true,
6476 lines = { "hello world", "bye there" } } }
6477 end
6478 "#,
6479 )
6480 .unwrap();
6481 let mut core = Core::new();
6482 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6483 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6484 ui.set_origin(OriginId(1));
6485 ext.view(&Slot::root(), &mut ui).unwrap();
6486 ui.finish();
6487 };
6488 frame(&mut core, &mut ext);
6489 let said = |ext: &LuaExtension| ext.lua.globals().get::<Option<String>>("said").unwrap();
6490
6491 let w = core
6493 .measure_text(
6494 "M",
6495 &kui_core::TextStyle::new(14.0)
6496 .family(kui_core::FontFamily::Mono)
6497 .line_height(20.0),
6498 None,
6499 )
6500 .width;
6501 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
6502 let click = |core: &mut Core, p: Vec2, clicks: u8| {
6503 core.handle_input(InputEvent::CursorMoved(p));
6504 core.handle_input(InputEvent::MouseDown {
6505 button: kui_core::MouseButton::Primary,
6506 clicks,
6507 });
6508 core.handle_input(InputEvent::MouseUp {
6509 button: kui_core::MouseButton::Primary,
6510 });
6511 };
6512
6513 click(&mut core, at(0, 8), 2);
6514 frame(&mut core, &mut ext);
6515 assert_eq!(said(&ext).as_deref(), Some("world"), "a double click");
6516
6517 click(&mut core, at(1, 1), 3);
6518 frame(&mut core, &mut ext);
6519 assert_eq!(said(&ext).as_deref(), Some("bye there"), "a triple click");
6520 }
6521
6522 #[test]
6528 fn a_grid_selection_reads_back_as_absolute_lines_and_columns() {
6529 let mut ext = LuaExtension::from_source(
6530 "term",
6531 r#"
6532 function view(env)
6533 sel = env.cell_selection()
6534 return column { cells { key = "term", rows = 2, cols = 12,
6535 size = 14, family = "mono", line_height = 20,
6536 origin_line = 900, selectable = true,
6537 lines = { "hello world", "bye there" } } }
6538 end
6539 "#,
6540 )
6541 .unwrap();
6542 let mut core = Core::new();
6543 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6544 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6545 ui.set_origin(OriginId(1));
6546 ext.view(&Slot::root(), &mut ui).unwrap();
6547 ui.finish();
6548 };
6549 frame(&mut core, &mut ext);
6550 assert!(
6551 ext.lua
6552 .globals()
6553 .get::<mlua::Value>("sel")
6554 .unwrap()
6555 .is_nil(),
6556 "nothing selected yet"
6557 );
6558 let w = core
6559 .measure_text(
6560 "M",
6561 &kui_core::TextStyle::new(14.0)
6562 .family(kui_core::FontFamily::Mono)
6563 .line_height(20.0),
6564 None,
6565 )
6566 .width;
6567 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
6568 core.handle_input(InputEvent::CursorMoved(at(1, 4)));
6569 core.handle_input(InputEvent::MouseDown {
6570 button: kui_core::MouseButton::Primary,
6571 clicks: 1,
6572 });
6573 core.handle_input(InputEvent::CursorMoved(at(0, 1)));
6574 core.handle_input(InputEvent::MouseUp {
6575 button: kui_core::MouseButton::Primary,
6576 });
6577 frame(&mut core, &mut ext);
6578 let sel: Table = ext.lua.globals().get("sel").unwrap();
6579 let end = |name: &str| -> (u64, usize) {
6580 let t: Table = sel.get(name).unwrap();
6581 (t.get("line").unwrap(), t.get("col").unwrap())
6582 };
6583 assert_eq!(end("anchor"), (901, 4), "the press, on the second row");
6584 assert_eq!(end("focus"), (900, 1), "the pointer, backwards");
6585 assert_eq!(
6586 sel.get::<i64>("node").unwrap(),
6587 core.key_of("term").unwrap().0 as i64
6588 );
6589 assert!(!sel.get::<bool>("block").unwrap());
6590 }
6591
6592 #[test]
6595 fn spans_carry_their_decorations() {
6596 let mut ext = LuaExtension::from_source(
6597 "deco",
6598 r#"
6599 function view(env)
6600 return row { text({ "let ", { "value", bg = 0x3b5bd455, underline = true },
6601 " = 1;" }, { size = 14, family = "mono" }) }
6602 end
6603 "#,
6604 )
6605 .unwrap();
6606 let mut core = Core::new();
6607 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6608 ui.set_origin(OriginId(1));
6609 ext.view(&Slot::root(), &mut ui).unwrap();
6610 ui.finish();
6611 let (dl, _) = core.output();
6612 let solids = dl
6613 .quads
6614 .iter()
6615 .filter(|q| q.kind == kui_core::QuadKind::Solid)
6616 .count();
6617 assert_eq!(solids, 2, "a background and an underline");
6618 }
6619
6620 #[test]
6625 fn underlines_have_a_colour_and_a_shape_of_their_own() {
6626 let mut ext = LuaExtension::from_source(
6627 "k4",
6628 r#"
6629 function view(env)
6630 return column {
6631 text({ "let ", { "value", underline_color = 0xff0000ff, underline_style = "wavy" } },
6632 { size = 14, family = "mono" }),
6633 text("warn", { size = 14, family = "mono", underline_color = 0x00ff00ff }),
6634 cells { rows = 1, cols = 3, size = 14, family = "mono",
6635 lines = { "abc" }, runs = { { 0, 0, 3, 0, 0, 32, 0xff0000ff } } },
6636 }
6637 end
6638 "#,
6639 )
6640 .unwrap();
6641 let mut core = Core::new();
6642 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6643 ui.set_origin(OriginId(1));
6644 ext.view(&Slot::root(), &mut ui).unwrap();
6645 ui.finish();
6646 let (dl, _) = core.output();
6647 let red = Color::hex(0xff0000ff);
6648 let segs: Vec<_> = dl
6649 .quads
6650 .iter()
6651 .filter(|q| q.kind == kui_core::QuadKind::Segment)
6652 .collect();
6653 assert!(
6654 segs.len() >= 6,
6655 "a wave under the span and an undercurl over the cells: {}",
6656 segs.len()
6657 );
6658 assert!(
6659 segs.iter().all(|q| q.color == red),
6660 "in the underline colour"
6661 );
6662 let solids: Vec<_> = dl
6663 .quads
6664 .iter()
6665 .filter(|q| q.kind == kui_core::QuadKind::Solid)
6666 .collect();
6667 assert_eq!(solids.len(), 1, "the text's solid line, coloured");
6668 assert_eq!(solids[0].color, Color::hex(0x00ff00ff));
6669 let mut bad = LuaExtension::from_source(
6671 "k4bad",
6672 r#"function view(env) return text({ { "x", underline_style = "squiggly" } }) end"#,
6673 )
6674 .unwrap();
6675 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6676 ui.set_origin(OriginId(1));
6677 let err = bad.view(&Slot::root(), &mut ui).unwrap_err();
6678 assert!(err.contains("solid | wavy | dotted"), "{err}");
6679 }
6680
6681 #[test]
6685 fn a_line_takes_a_dash_as_a_length_a_pair_or_four() {
6686 let segments = |view: &str| -> Result<usize, String> {
6687 let mut ext = LuaExtension::from_source("dash", view).unwrap();
6688 let mut core = Core::new();
6689 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6690 ui.set_origin(OriginId(1));
6691 let lowered = ext.view(&Slot::root(), &mut ui);
6692 ui.finish();
6693 lowered?;
6694 let (dl, _) = core.output();
6695 Ok(dl
6696 .quads
6697 .iter()
6698 .filter(|q| q.kind == kui_core::QuadKind::Segment)
6699 .count())
6700 };
6701 let line = |dash: &str| {
6702 format!(
6703 "function view(env) return column {{ width = 200, height = 40,
6704 line {{ from = {{0, 20}}, to = {{100, 20}}, width = 2, {dash} }} }} end"
6705 )
6706 };
6707 assert_eq!(segments(&line("")), Ok(1));
6708 assert_eq!(segments(&line("dash = 5")), Ok(10));
6710 assert_eq!(segments(&line("dash = {6, 4}")), Ok(10));
6711 assert_eq!(segments(&line("dash = {6, 4}, dash_offset = 5")), Ok(10));
6712 assert_eq!(segments(&line("dash = {10, 4, 2, 4}")), Ok(10));
6713 let err = segments(&line("dash = {1, 2, 3}")).unwrap_err();
6714 assert!(err.contains("dash is a length"), "{err}");
6715 }
6716
6717 #[test]
6721 fn features_are_a_text_option() {
6722 let mut ext = LuaExtension::from_source(
6723 "features",
6724 r#"
6725 function view(env)
6726 return row { text("fi ->", { size = 16 }),
6727 text("fi ->", { size = 16, features = "liga=0 calt=0" }) }
6728 end
6729 "#,
6730 )
6731 .unwrap();
6732 let mut core = Core::new();
6733 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6734 ui.set_origin(OriginId(1));
6735 ext.view(&Slot::root(), &mut ui).unwrap();
6736 ui.finish();
6737 assert_eq!(core.text_cache_len(), 2);
6738 }
6739
6740 #[test]
6746 fn a_style_table_shared_by_three_texts_is_three_texts() {
6747 let mut ext = LuaExtension::from_source(
6748 "shared",
6749 r#"
6750 local mono = { size = 14, family = "mono" }
6751 function view(env)
6752 return row { text("a", mono), text("bb", mono), text("ccc", mono) }
6753 end
6754 "#,
6755 )
6756 .unwrap();
6757 let mut core = Core::new();
6758 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6759 ui.set_origin(OriginId(1));
6760 ext.view(&Slot::root(), &mut ui).unwrap();
6761 ui.finish();
6762 let names: Vec<String> = core
6763 .access_tree()
6764 .nodes
6765 .iter()
6766 .filter_map(|n| n.value.clone().or_else(|| n.name.clone()))
6767 .collect();
6768 let joined = names.join("|");
6769 assert!(
6770 joined.contains("a") && joined.contains("bb") && joined.contains("ccc"),
6771 "three different texts, got {joined:?}"
6772 );
6773 assert_eq!(
6774 core.text_cache_len(),
6775 3,
6776 "three strings shaped, not one three times"
6777 );
6778 }
6779
6780 #[test]
6785 fn scripts_slice_a_long_list_from_the_geometry() {
6786 let mut ext = LuaExtension::from_source(
6787 "virtual",
6788 r#"
6789 ROWS, ROW_H, built = 10000, 30, 0
6790 function view(env)
6791 local g = env.scroll_geometry(list_key)
6792 -- No layout yet: fall back to the window for one frame.
6793 local h = g and g.h or 200
6794 local top = g and g.offset.y or 0
6795 local first = math.min(ROWS, math.max(0, math.floor(top / ROW_H)))
6796 local last = math.min(ROWS, math.ceil((top + h) / ROW_H))
6797 local list = { key = "list", width = "grow", height = "grow",
6798 scroll_y = true }
6799 if first > 0 then
6800 list[#list + 1] = row { key = "lead", width = "grow",
6801 height = first * ROW_H }
6802 end
6803 for i = first, last - 1 do
6804 list[#list + 1] = row { key = "row" .. i, width = "grow",
6805 height = ROW_H, bg = 0x282840ff }
6806 end
6807 if last < ROWS then
6808 list[#list + 1] = row { key = "tail", width = "grow",
6809 height = (ROWS - last) * ROW_H }
6810 end
6811 built = last - first
6812 return column(list)
6813 end
6814 "#,
6815 )
6816 .unwrap();
6817 let list = Key::ROOT.str("list");
6818 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
6819 let mut core = Core::new();
6820 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6821 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6822 ui.set_origin(OriginId(1));
6823 ext.view(&Slot::root(), &mut ui).unwrap();
6824 ui.finish();
6825 };
6826
6827 frame(&mut core, &mut ext);
6828 frame(&mut core, &mut ext);
6829 let built: i64 = ext.lua.globals().get("built").unwrap();
6830 assert_eq!(built, 7, "200 / 30 rounded up");
6831
6832 let g = core.scroll_geometry(list).expect("laid out");
6835 assert_eq!(g.content.h, 10_000.0 * 30.0);
6836 core.set_scroll(list, Vec2::new(0.0, 1e9));
6837 frame(&mut core, &mut ext);
6838 frame(&mut core, &mut ext);
6839 assert_eq!(core.scroll_offset(list).y, 10_000.0 * 30.0 - 200.0);
6840 let built: i64 = ext.lua.globals().get("built").unwrap();
6841 assert_eq!(built, 7, "still a screenful at the far end");
6842 }
6843
6844 #[test]
6850 fn the_prelude_virtualizes_a_long_list_in_one_call() {
6851 let mut ext = LuaExtension::from_source(
6852 "virtual",
6853 r#"
6854 ROWS, ROW_H, first_built, built = 10000, 30, -1, 0
6855 function view(env)
6856 built, first_built = 0, -1
6857 return uniform_list(env,
6858 { key = "list", rows = ROWS, row_h = ROW_H,
6859 width = "grow", height = "grow" },
6860 function(i)
6861 built = built + 1
6862 if first_built < 0 then first_built = i end
6863 return column { fill = true, bg = 0x282840ff,
6864 on_click = { kind = "pick", row = i } }
6865 end)
6866 end
6867 "#,
6868 )
6869 .unwrap();
6870 let list = Key::ROOT.str("list");
6871 let mut core = Core::new();
6872 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6873 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6874 ui.set_origin(OriginId(1));
6875 ext.view(&Slot::root(), &mut ui).unwrap();
6876 ui.finish();
6877 };
6878
6879 frame(&mut core, &mut ext);
6880 frame(&mut core, &mut ext);
6881 let built: i64 = ext.lua.globals().get("built").unwrap();
6882 assert_eq!(built, 9, "200 / 30 rounded up, plus two rows of overscan");
6883
6884 let g = core.scroll_geometry(list).expect("laid out");
6887 assert_eq!(g.content.h, 10_000.0 * 30.0);
6888 core.set_scroll(list, Vec2::new(0.0, 300.0 * 30.0));
6889 frame(&mut core, &mut ext);
6890 frame(&mut core, &mut ext);
6891 let first: i64 = ext.lua.globals().get("first_built").unwrap();
6892 assert_eq!(first, 298, "two rows of overscan above row 300");
6893 let built: i64 = ext.lua.globals().get("built").unwrap();
6894 assert_eq!(built, 11, "a screenful with overscan on both sides now");
6895 assert!(
6898 core.access_tree()
6899 .nodes
6900 .iter()
6901 .any(|n| n.key == list.index(300).index(0)),
6902 "row 300 is not keyed by its data index"
6903 );
6904 }
6905
6906 #[test]
6910 fn a_view_names_a_family_and_draws_in_it() {
6911 let mut ext = LuaExtension::from_source(
6912 "fam",
6913 r#"
6914 named, sans, missed = nil, nil, nil
6915 function view(env)
6916 named = env.measure_text("iiiWWW", { family = "Fixture Mono", size = 20 }).width
6917 sans = env.measure_text("iiiWWW", { family = "sans", size = 20 }).width
6918 return column {
6919 text("iiiWWW", { family = "Fixture Mono", size = 20 }),
6920 text("x", { family = "No Such Family 7" }),
6921 }
6922 end
6923 "#,
6924 )
6925 .unwrap();
6926 let mut core = Core::new();
6927 core.add_font_data(kui_core::testing::font_face(
6928 "Fixture Mono",
6929 400,
6930 false,
6931 true,
6932 ))
6933 .unwrap();
6934 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6935 ui.set_origin(OriginId(1));
6936 ext.view(&Slot::root(), &mut ui).unwrap();
6937 ui.finish();
6938 let handle = core.add_system_font("Fixture Mono").unwrap();
6939 let expected = core
6940 .measure_text("iiiWWW", &kui_core::TextStyle::new(20.0).font(handle), None)
6941 .width;
6942 let g = ext.lua.globals();
6943 let (named, sans) = (
6944 g.get::<f32>("named").unwrap(),
6945 g.get::<f32>("sans").unwrap(),
6946 );
6947 assert_eq!(named, expected, "the face the host's handle draws");
6948 assert_ne!(named, sans, "and not sans");
6949 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
6950 assert_eq!(codes, ["unknown-family"]);
6951 }
6952
6953 #[test]
6956 fn the_prelude_reveals_a_row_styles_rows_and_splits() {
6957 let mut ext = LuaExtension::from_source(
6958 "dx22",
6959 r#"
6960 target, scrolled, page = nil, nil, nil
6961 function view(env)
6962 if target then scrolled = reveal_row(env, "list", target, 20) end
6963 page = rows_in_view(env, "list", 20)
6964 zero = rows_in_view(env, "list", 0)
6965 return row { width = 300, height = 100,
6966 uniform_list(env,
6967 { key = "list", rows = 50, row_h = 20, width = 200, height = 100,
6968 row_props = function(i) return { on_click = { kind = "pick", row = i } } end },
6969 function(i) return text("row " .. i) end),
6970 splitter(env, { key = "bar", on_drag = { kind = "split" } }),
6971 column { width = "grow", height = "grow" },
6972 }
6973 end
6974 "#,
6975 )
6976 .unwrap();
6977 let mut core = Core::new();
6978 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6979 let mut ui = core.frame(Size::new(300.0, 100.0), 1.0);
6980 ui.set_origin(OriginId(1));
6981 ext.view(&Slot::root(), &mut ui).unwrap();
6982 ui.finish();
6983 };
6984 frame(&mut core, &mut ext);
6985 frame(&mut core, &mut ext);
6986 assert_eq!(ext.lua.globals().get::<i64>("page").unwrap(), 5);
6987 assert!(
6988 core.take_warnings().is_empty(),
6989 "every prop the three spell is known"
6990 );
6991
6992 ext.lua.globals().set("target", 40).unwrap();
6993 frame(&mut core, &mut ext);
6994 assert!(ext.lua.globals().get::<bool>("scrolled").unwrap());
6995 let list = core.key_of("list").unwrap();
6996 assert_eq!(
6997 core.scroll_offset(list),
6998 Vec2::new(0.0, 760.0),
6999 "row 40 to the middle"
7000 );
7001 core.handle_input(InputEvent::CursorMoved(Vec2::new(10.0, 45.0)));
7004 core.handle_input(InputEvent::mouse_down(1));
7005 let up = core.handle_input(InputEvent::mouse_up());
7006 assert_eq!(up.iter().find_map(|e| e.payload.get_int("row")), Some(40));
7007
7008 core.handle_input(InputEvent::CursorMoved(Vec2::new(202.0, 50.0)));
7010 core.handle_input(InputEvent::mouse_down(1));
7011 core.handle_input(InputEvent::CursorMoved(Vec2::new(225.0, 50.0)));
7012 let end = core.handle_input(InputEvent::mouse_up());
7013 let d = end.iter().find_map(|e| e.drag()).expect("the drag's end");
7014 assert_eq!(d.ratio().x, 0.75);
7015
7016 ext.lua.globals().set("target", 500).unwrap();
7018 frame(&mut core, &mut ext);
7019 assert!(!ext.lua.globals().get::<bool>("scrolled").unwrap());
7020 assert_eq!(core.scroll_offset(list), Vec2::new(0.0, 760.0));
7021 assert_eq!(ext.lua.globals().get::<i64>("zero").unwrap(), 0);
7022 }
7023
7024 #[test]
7028 fn a_uniform_list_that_shrank_lands_in_one_frame() {
7029 let mut ext = LuaExtension::from_source(
7030 "virtual",
7031 r#"
7032 ROWS, first_built = 200, -1
7033 function view(env)
7034 first_built = -1
7035 return uniform_list(env,
7036 { key = "list", rows = ROWS, row_h = 20,
7037 width = "grow", height = "grow" },
7038 function(i)
7039 if first_built < 0 then first_built = i end
7040 return column { fill = true, bg = 0x282840ff }
7041 end)
7042 end
7043 "#,
7044 )
7045 .unwrap();
7046 let list = Key::ROOT.str("list");
7047 let mut core = Core::new();
7048 let frame = |core: &mut Core, ext: &mut LuaExtension| {
7049 let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
7050 ui.set_origin(OriginId(1));
7051 ext.view(&Slot::root(), &mut ui).unwrap();
7052 ui.finish();
7053 };
7054 frame(&mut core, &mut ext);
7055 frame(&mut core, &mut ext);
7056 core.set_scroll(list, Vec2::new(0.0, 3_000.0));
7057 frame(&mut core, &mut ext);
7058 frame(&mut core, &mut ext);
7059 let deep: i64 = ext.lua.globals().get("first_built").unwrap();
7060 assert!(
7061 deep > 100,
7062 "expected to be deep in the list, built from {deep}"
7063 );
7064
7065 ext.lua.globals().set("ROWS", 10).unwrap();
7066 frame(&mut core, &mut ext);
7067 let g = core.scroll_geometry(list).expect("laid out");
7068 assert_eq!(
7069 g.content.h,
7070 10.0 * 20.0,
7071 "the content is the list it has now"
7072 );
7073 frame(&mut core, &mut ext);
7074 let first: i64 = ext.lua.globals().get("first_built").unwrap();
7075 assert_eq!(first, 0, "and every row of it is built");
7076 assert_eq!(core.scroll_offset(list).y, 0.0);
7077 }
7078
7079 const VARIABLE_LIST: &str = r#"
7084 heights = row_heights(1000, 20)
7085 measured = 0
7086 function view(env)
7087 return list(env,
7088 { key = "list", heights = heights, width = "grow", height = "grow",
7089 transition = TRANSITION },
7090 function(i, width)
7091 measured = measured + 1
7092 if i < 100 then return 20 else return 60 end
7093 end,
7094 function(i)
7095 return column { width = "grow", height = "grow", bg = 0x282840ff,
7096 label = "row", on_click = i }
7097 end)
7098 end
7099 "#;
7100
7101 fn variable_frame(core: &mut Core, ext: &mut LuaExtension, t: f64) {
7103 core.set_time(t);
7104 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7105 ui.set_origin(OriginId(1));
7106 ext.view(&Slot::root(), &mut ui).unwrap();
7107 ui.finish();
7108 }
7109
7110 fn row_under(core: &mut Core, y: f32) -> Option<i64> {
7113 core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, y)));
7114 core.handle_input(InputEvent::mouse_down(1));
7115 let evs = core.handle_input(InputEvent::mouse_up());
7116 evs.first()?.payload.as_int()
7117 }
7118
7119 #[test]
7123 fn a_lua_list_keeps_the_row_under_the_pointer_while_the_estimate_moves() {
7124 let mut ext = LuaExtension::from_source("variable", VARIABLE_LIST).unwrap();
7125 let list = Key::ROOT.str("list");
7126 let mut core = Core::new();
7127 variable_frame(&mut core, &mut ext, 0.0);
7128 variable_frame(&mut core, &mut ext, 0.0);
7129 let measured: i64 = ext.lua.globals().get("measured").unwrap();
7130 assert!(
7131 (10..=20).contains(&measured),
7132 "a screenful measured, not the list: {measured}"
7133 );
7134
7135 core.set_scroll(list, Vec2::new(0.0, 5_000.0));
7136 variable_frame(&mut core, &mut ext, 0.0);
7137 let settled = row_under(&mut core, 4.0);
7138 assert!(settled.is_some(), "nothing under the top edge");
7139 for n in 0..4 {
7140 variable_frame(&mut core, &mut ext, 0.0);
7141 assert_eq!(
7142 row_under(&mut core, 4.0),
7143 settled,
7144 "the content slid on frame {n} as the estimate moved"
7145 );
7146 }
7147 let g = core.scroll_geometry(list).expect("laid out");
7148 assert!(
7149 g.content.h > 1000.0 * 20.0 * 1.5,
7150 "the list learned it is longer: {}",
7151 g.content.h
7152 );
7153 }
7154
7155 #[test]
7159 fn a_lua_list_glides_to_the_row_asked_for() {
7160 let mut ext =
7161 LuaExtension::from_source("variable", &format!("TRANSITION = 100\n{VARIABLE_LIST}"))
7162 .unwrap();
7163 let list = Key::ROOT.str("list");
7164 let mut core = Core::new();
7165 let mut t = 0.0;
7166 variable_frame(&mut core, &mut ext, t);
7167 variable_frame(&mut core, &mut ext, t);
7168 let target = 400;
7169 let offset: f32 = ext
7170 .lua
7171 .load(format!("return heights:offset_of({target})"))
7172 .eval()
7173 .unwrap();
7174 core.set_scroll(list, Vec2::new(0.0, offset));
7175 for _ in 0..30 {
7176 t += 1.0 / 60.0;
7177 variable_frame(&mut core, &mut ext, t);
7178 }
7179 assert_eq!(row_under(&mut core, 4.0), Some(target));
7180 }
7181
7182 #[test]
7186 fn a_script_asks_for_a_file_dialog_and_hears_the_answer() {
7187 let mut ext = LuaExtension::from_source(
7188 "files",
7189 r#"
7190 ask, asked, again, waiting, heard = true, nil, nil, nil, nil
7191 function view(env)
7192 if ask then
7193 asked = env.request_files {
7194 mode = "save", title = "Export", file_name = "notes.md",
7195 filters = { { name = "Markdown", extensions = { "md" } } },
7196 tag = "export",
7197 }
7198 again = env.request_files {}
7199 waiting = env.awaiting_files()
7200 ask = false
7201 end
7202 return column {}
7203 end
7204 function on_event(ev)
7205 if ev.kind == "files" then heard = ev.paths[1] .. "|" .. ev.tag end
7206 end
7207 "#,
7208 )
7209 .unwrap();
7210 let mut core = Core::new();
7211 variable_frame(&mut core, &mut ext, 0.0);
7212 let g = ext.lua.globals();
7213 assert!(g.get::<bool>("asked").unwrap(), "the first ask is taken");
7214 assert!(
7215 !g.get::<bool>("again").unwrap(),
7216 "a second while it is out is not"
7217 );
7218 assert!(g.get::<bool>("waiting").unwrap());
7219
7220 let asks = core.take_file_requests();
7221 assert_eq!(asks.len(), 1);
7222 let d = &asks[0];
7223 assert_eq!(d.mode, kui_core::FileDialogMode::Save);
7224 assert_eq!(d.file_name.as_deref(), Some("notes.md"));
7225 assert_eq!(d.filters[0].extensions, vec!["md".to_string()]);
7226
7227 let evs = core.handle_input(InputEvent::Files(vec!["/tmp/notes.md".into()]));
7228 assert_eq!(evs.len(), 1);
7229 assert_eq!(
7230 evs[0].origin,
7231 OriginId(1),
7232 "the answer goes to the script that asked"
7233 );
7234 for e in &evs {
7235 ext.on_event(e);
7236 }
7237 let heard: String = ext.lua.globals().get("heard").unwrap();
7238 assert_eq!(heard, "/tmp/notes.md|export");
7239 }
7240
7241 #[test]
7245 fn a_host_hands_a_script_the_documents_the_os_opened() {
7246 let mut ext = LuaExtension::from_source(
7247 "open",
7248 r#"
7249 heard = nil
7250 function view(env) return column {} end
7251 function on_event(ev)
7252 if ev.kind == "open" then heard = #ev.paths .. "|" .. ev.paths[2] end
7253 end
7254 "#,
7255 )
7256 .unwrap();
7257 let mut core = Core::new();
7258 variable_frame(&mut core, &mut ext, 0.0);
7259 let evs = core.handle_input(InputEvent::Open(vec![
7260 "/tmp/a.txt".into(),
7261 "/tmp/b.md".into(),
7262 ]));
7263 assert_eq!(evs.len(), 1);
7264 assert_eq!(evs[0].origin, OriginId::HOST, "nobody asked: the host's");
7265 assert_eq!(evs[0].key, kui_core::Key::ROOT);
7266 for e in &evs {
7267 ext.on_event(e);
7268 }
7269 let heard: String = ext.lua.globals().get("heard").unwrap();
7270 assert_eq!(heard, "2|/tmp/b.md");
7271 assert!(
7272 core.handle_input(InputEvent::Open(Vec::new())).is_empty(),
7273 "no documents is no event"
7274 );
7275 }
7276
7277 #[test]
7280 fn a_lua_list_names_what_it_is_missing() {
7281 let mut ext = LuaExtension::from_source(
7282 "bad",
7283 r#"
7284 function view(env)
7285 ok, err = pcall(list, env, { key = "list" }, function() return 1 end,
7286 function() return column {} end)
7287 return column {}
7288 end
7289 "#,
7290 )
7291 .unwrap();
7292 let mut core = Core::new();
7293 variable_frame(&mut core, &mut ext, 0.0);
7294 let err: String = ext.lua.globals().get("err").unwrap();
7295 assert!(err.contains("row_heights"), "{err}");
7296 }
7297
7298 #[test]
7302 fn a_query_answers_for_an_undeclared_label_and_a_command_refuses() {
7303 let mut ext = LuaExtension::from_source(
7304 "queries",
7305 r#"
7306 function view(env)
7307 geom = env.scroll_geometry("nothing")
7308 off = env.scroll_offset("nothing")
7309 hovered = env.is_hovered("nothing")
7310 pressed = env.is_pressed("nothing")
7311 focused = env.is_focused("nothing")
7312 hit = env.text_hit("nothing", 1, 1)
7313 caret = env.caret_rect("nothing", 0)
7314 deferred = pcall(function() env.set_scroll("nothing", 0, 0) end)
7315 refused_focus = not pcall(function() env.set_focus("nothing") end)
7316 return column { width = "grow", height = "grow" }
7317 end
7318 "#,
7319 )
7320 .unwrap();
7321 let mut core = Core::new();
7322 let mut ui = core.frame(Size::new(200.0, 100.0), 1.0);
7323 ext.view(&Slot::root(), &mut ui).unwrap();
7324 ui.finish();
7325 let g = ext.lua.globals();
7326 assert_eq!(g.get::<mlua::Value>("geom").unwrap(), mlua::Value::Nil);
7327 assert_eq!(g.get::<mlua::Value>("hit").unwrap(), mlua::Value::Nil);
7328 assert_eq!(g.get::<mlua::Value>("caret").unwrap(), mlua::Value::Nil);
7329 assert!(!g.get::<bool>("hovered").unwrap());
7330 assert!(!g.get::<bool>("pressed").unwrap());
7331 assert!(!g.get::<bool>("focused").unwrap());
7332 let off: Table = g.get("off").unwrap();
7333 assert_eq!(off.get::<f32>("y").unwrap(), 0.0);
7334 assert!(g.get::<bool>("deferred").unwrap());
7337 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
7338 assert_eq!(codes, ["label-without-node"]);
7339 assert!(g.get::<bool>("refused_focus").unwrap());
7340 }
7341
7342 #[test]
7343 fn bad_script_reports_error_not_panic() {
7344 let mut ext = LuaExtension::from_source("bad", "function view() return 5 end").unwrap();
7345 let mut core = Core::new();
7346 let mut ui = core.frame(Size::new(100.0, 100.0), 1.0);
7347 assert!(ext.view(&Slot::root(), &mut ui).is_err());
7348 }
7349}