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: mlua::LuaString = t.get("type")?;
1857 let ty = ty.to_str()?;
1858 let ty: &str = &ty;
1859 match ty {
1860 "fill" => build_fill(ui, t),
1861 "devtools_tab" => build_devtools_tab(ui, t),
1862 "row" | "column" | "grid" => {
1863 open_box(ui, t, ty)?;
1864 build_children(ui, t)?;
1865 ui.close();
1866 Ok(())
1867 }
1868 "fragment" => {
1869 open_fragment(ui, t)?;
1870 build_children(ui, t)?;
1871 ui.close();
1872 Ok(())
1873 }
1874 "titlebar" | "tooltip" | "radio_group" => build_holder(ui, t, ty),
1875 _ => build_widget(ui, t, ty),
1876 }
1877}
1878
1879#[inline(never)]
1880fn open_box(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1881 let (mut p, _) = node_props(ui, t, ty == "row", element_of(ty))?;
1882 p.spec.layout.table = ty == "grid";
1884 ui.core().open_from(p, Content::Box);
1885 Ok(())
1886}
1887
1888#[inline(never)]
1889fn open_fragment(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
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, _) = node_props(ui, t, false, element_of("fragment"))?;
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 if ty != "radio_group" {
1916 check_props(ui, t, element_of(ty))?;
1917 }
1918 match ty {
1919 "titlebar" => {
1920 if t.raw_len() > 0 {
1921 with_children(ui, t, |ui, body| widgets::titlebar_with(ui, body))
1922 } else {
1923 let title: String = t.get::<Option<String>>("title")?.unwrap_or_default();
1924 widgets::titlebar(ui, &title);
1925 Ok(())
1926 }
1927 }
1928 "tooltip" => {
1929 if t.raw_len() > 0 {
1930 with_children(ui, t, |ui, body| widgets::tooltip_with(ui, body))
1931 } else {
1932 let value: String = t.get("value")?;
1933 widgets::tooltip(ui, &value);
1934 Ok(())
1935 }
1936 }
1937 "radio_group" => {
1938 let label: String = t.get("label")?;
1941 let (p, _) = node_props(ui, t, false, element_of(ty))?;
1942 let mut result = Ok(());
1943 widgets::radio_group_with(ui, &label, p.spec, |ui| result = build_children(ui, t));
1944 result
1945 }
1946 other => unreachable!("{other} holds no children"),
1947 }
1948}
1949
1950#[inline(never)]
1951fn build_widget(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1952 let mut checked = false;
1956 let built = match ty {
1957 "text" => {
1958 checked = true;
1959 let (p, walked) = node_props(ui, t, false, element_of(ty))?;
1960 let style = p.style;
1961 let spans = match walked.spans {
1965 Some(mlua::Value::Table(spans)) => Some(spans),
1966 Some(_) => t.get::<Option<Table>>("spans")?,
1967 None => None,
1968 };
1969 if let Some(spans) = spans {
1970 let parts = with_refs(ui, |refs| collect_spans(&spans, refs))?;
1971 let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1972 ui.rich_text(&spans, style);
1973 } else if let Some(mlua::Value::String(value)) = walked.value {
1974 ui.text(&value.to_str()?, style);
1975 } else {
1976 let value: String = t.get("value")?;
1977 ui.text(&value, style);
1978 }
1979 Ok(())
1980 }
1981 "image" => {
1982 let id: i64 = t.get("id")?;
1986 let spec = props_of(ui, t, ty, &mut checked)?.for_leaf().spec;
1988 let named = |row: &str, names: &[&str]| -> mlua::Result<usize> {
1989 match t.get::<Option<String>>(row)? {
1990 None => Ok(0),
1991 Some(s) => names
1992 .iter()
1993 .position(|n| *n == s)
1994 .ok_or_else(|| bad(format!("{row} must be one of {}", names.join(", ")))),
1995 }
1996 };
1997 let sampling_names: Vec<&str> =
1998 kui_core::Sampling::ALL.iter().map(|s| s.name()).collect();
1999 let fit_names: Vec<&str> = kui_core::ImageFit::ALL.iter().map(|f| f.name()).collect();
2000 let opts = kui_core::ImageOpts {
2001 sampling: kui_core::Sampling::ALL[named("sampling", &sampling_names)?],
2002 fit: kui_core::ImageFit::ALL[named("fit", &fit_names)?],
2003 };
2004 ui.image_with(kui_core::ImageId::from_ffi(id as u64), opts, spec);
2005 Ok(())
2006 }
2007 "polygon" => {
2008 let p = props_of(ui, t, ty, &mut checked)?;
2011 let Some(list) = t.get::<Option<Table>>("points")? else {
2012 return Err(bad("polygon needs points"));
2013 };
2014 let points: Vec<kui_core::Vec2> = list
2015 .sequence_values::<mlua::Value>()
2016 .map(|v| {
2017 let mlua::Value::Table(pt) = v? else {
2018 return Err(bad("a polygon point is a {x, y} table"));
2019 };
2020 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
2021 })
2022 .collect::<mlua::Result<_>>()?;
2023 ui.core().open_from(p, Content::Polygon(&points));
2024 Ok(())
2025 }
2026 "path" => {
2027 let mut p = props_of(ui, t, ty, &mut checked)?;
2032 if let Some(i) = p.spec.interact.as_deref_mut()
2038 && let Some(tr) = i.transform.as_deref_mut()
2039 {
2040 tr.rotate = 0.0;
2041 if *tr == kui_core::TransformSpec::NONE {
2042 i.transform = None;
2043 }
2044 }
2045 let rule = match t.get::<Option<String>>("fill_rule")? {
2046 Some(name) => kui_core::FillRule::parse(&name)
2047 .ok_or_else(|| bad("fill_rule is \"nonzero\" or \"evenodd\""))?,
2048 None => kui_core::FillRule::NonZero,
2049 };
2050 let width = match t.get::<mlua::Value>("width")? {
2051 mlua::Value::Nil => None,
2052 v => with_refs(ui, |refs| length_of(&v, refs))?,
2053 };
2054 let dash = dash_of(t)?;
2055 let stroke = width.filter(|w| *w > 0.0).map(|w| {
2056 kui_core::Stroke::new(w, p.style.color.unwrap_or(ui.theme().fg)).dashed(dash)
2057 });
2058 let turns = t.get::<Option<f32>>("rotate")?;
2062 let pivot = match t.get::<Option<Table>>("pivot")? {
2063 Some(p) => Some(kui_core::Vec2::new(p.get::<f32>(1)?, p.get::<f32>(2)?)),
2064 None => None,
2065 };
2066 let turn = (turns.is_some() || pivot.is_some()).then_some(kui_core::Turn {
2067 turns: turns.unwrap_or(0.0),
2068 pivot,
2069 });
2070 if let Some(d) = t.get::<Option<String>>("d")? {
2071 ui.core()
2072 .open_from(p, Content::PathD(&d, rule, stroke, turn));
2073 } else if let Some(list) = t.get::<Option<Table>>("ops")? {
2074 let floats: Vec<f32> =
2075 list.sequence_values::<f32>().collect::<mlua::Result<_>>()?;
2076 ui.core()
2077 .open_from(p, Content::PathFlat(&floats, rule, stroke, turn));
2078 } else {
2079 return Err(bad("path needs d or ops"));
2080 }
2081 Ok(())
2082 }
2083 "line" => {
2084 let p = props_of(ui, t, ty, &mut checked)?;
2090 let point = |v: mlua::Value| -> mlua::Result<kui_core::Vec2> {
2091 let mlua::Value::Table(pt) = v else {
2092 return Err(bad("a line point is a {x, y} table"));
2093 };
2094 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
2095 };
2096 let points: Vec<kui_core::Vec2> = match t.get::<Option<Table>>("points")? {
2097 Some(list) => list
2098 .sequence_values::<mlua::Value>()
2099 .map(|v| point(v?))
2100 .collect::<mlua::Result<_>>()?,
2101 None => {
2102 let (Some(from), Some(to)) = (
2103 t.get::<Option<mlua::Value>>("from")?,
2104 t.get::<Option<mlua::Value>>("to")?,
2105 ) else {
2106 return Err(bad("line needs from and to, or points"));
2107 };
2108 vec![point(from)?, point(to)?]
2109 }
2110 };
2111 let width = match t.get::<mlua::Value>("width")? {
2114 mlua::Value::Nil => None,
2115 v => with_refs(ui, |refs| length_of(&v, refs))?,
2116 }
2117 .unwrap_or(1.0);
2118 let stroke_color = p.style.color.unwrap_or(ui.theme().fg);
2120 let mut stroke = kui_core::Stroke::new(width, stroke_color);
2121 stroke.curve = t.get::<Option<bool>>("curve")?.unwrap_or(false);
2122 stroke.dash = dash_of(t)?;
2123 ui.core().open_from(p, Content::Line(&points, stroke));
2124 Ok(())
2125 }
2126 "cells" => {
2127 let p = props_of(ui, t, ty, &mut checked)?;
2133 let rows: usize = t.get::<Option<usize>>("rows")?.unwrap_or(0);
2134 let cols: usize = t.get::<Option<usize>>("cols")?.unwrap_or(0);
2135 if rows == 0 || cols == 0 {
2136 return Err(bad("cells needs rows and cols"));
2137 }
2138 let default_fg = p.style.color.unwrap_or(ui.theme().fg).to_hex();
2139 let mut cells = vec![kui_core::Cell::new(' ', default_fg, 0); rows * cols];
2140 if let Some(lines) = t.get::<Option<Table>>("lines")? {
2141 for (r, line) in lines.sequence_values::<String>().enumerate() {
2142 if r >= rows {
2143 break;
2144 }
2145 for (c, ch) in line?.chars().take(cols).enumerate() {
2146 cells[r * cols + c].ch = ch;
2147 }
2148 }
2149 }
2150 if let Some(runs) = t.get::<Option<Table>>("runs")? {
2151 for run in runs.sequence_values::<Table>() {
2152 let run = run?;
2153 let row: usize = run.get(1)?;
2154 let col: usize = run.get(2)?;
2155 let len: usize = run.get(3)?;
2156 let fg: u32 = run.get::<Option<u32>>(4)?.unwrap_or(0);
2157 let bg: u32 = run.get::<Option<u32>>(5)?.unwrap_or(0);
2158 let flags: u8 = run.get::<Option<u8>>(6)?.unwrap_or(0);
2159 let ul: u32 = run.get::<Option<u32>>(7)?.unwrap_or(0);
2160 if row >= rows {
2161 continue;
2162 }
2163 for c in col..(col + len).min(cols) {
2164 let cell = &mut cells[row * cols + c];
2165 if fg != 0 {
2166 cell.fg = fg;
2167 }
2168 if bg != 0 {
2169 cell.bg = bg;
2170 }
2171 cell.flags |= flags;
2172 if ul != 0 {
2173 cell.ul = ul;
2174 }
2175 }
2176 }
2177 }
2178 let cursor = match t.get::<Option<Table>>("cursor_at")? {
2179 Some(cur) => {
2180 let shape = match t.get::<Option<String>>("cursor_shape")? {
2183 None => kui_core::CellCursor::Block,
2184 Some(s) => kui_core::CellCursor::from_name(&s).ok_or_else(|| {
2185 bad(format!(
2186 "cursor_shape must be {}, not {s:?}",
2187 kui_core::CellCursor::NAMES.join(" | ")
2188 ))
2189 })?,
2190 };
2191 let color = match t.get::<mlua::Value>("cursor_color")? {
2192 mlua::Value::Nil => None,
2193 v => with_refs(ui, |refs| parse_color(&v, refs))?,
2194 }
2195 .unwrap_or(Color::rgb8(0xff, 0xff, 0xff));
2196 Some((cur.get::<usize>(1)?, cur.get::<usize>(2)?, shape, color))
2197 }
2198 None => None,
2199 };
2200 let origin_line = t.get::<Option<u64>>("origin_line")?.unwrap_or(0);
2202 let grid = kui_core::CellGrid {
2203 rows,
2204 cols,
2205 cells: &cells,
2206 style: p.style,
2207 cursor,
2208 origin_line,
2209 };
2210 ui.core().open_from(p, Content::Cells(&grid));
2211 Ok(())
2212 }
2213 "audio" => {
2214 let src: i64 = t.get("src")?;
2217 let mut spec = kui_core::AudioSpec::new(kui_core::SoundId::from_ffi(src as u64));
2218 if let Some(v) = t.get::<Option<f32>>("volume")? {
2219 spec = spec.volume(v);
2220 }
2221 if t.get::<Option<bool>>("loop")?.unwrap_or(false) {
2222 spec = spec.looped();
2223 }
2224 spec = spec.paused(t.get::<Option<bool>>("paused")?.unwrap_or(false));
2225 if t.get::<Option<bool>>("finish")?.unwrap_or(false) {
2226 spec = spec.finish();
2227 }
2228 if let Some(tag) = t.get::<Option<mlua::Value>>("tag")? {
2229 spec.tag = Some(lua_to_value(&tag)?);
2230 }
2231 match t.get::<Option<String>>("key")? {
2232 Some(k) => ui.audio_keyed(&k, spec),
2233 None => ui.audio(spec),
2234 };
2235 Ok(())
2236 }
2237 "input" => {
2238 let label: String = t.get("label")?;
2239 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
2240 widgets::text_input(ui, &label, &initial);
2241 Ok(())
2242 }
2243 "dropdown" => {
2244 let Some(label) = t.get::<Option<String>>("label")?.filter(|l| !l.is_empty()) else {
2248 return Err(bad("dropdown needs a label (its key and accessible name)"));
2249 };
2250 let Some(options) = t.get::<Option<Table>>("options")? else {
2251 return Err(bad(
2252 "dropdown needs options, a list of strings or menu rows",
2253 ));
2254 };
2255 let mut rows = lua_list_to_value(&options)?;
2256 if let Value::List(rows) = &mut rows {
2257 rows.iter_mut().for_each(alias_menu_role);
2258 }
2259 if ui.core().diagnostics() {
2263 for k in kui_core::MenuItem::stray_option_keys(&rows) {
2264 ui.core().warn(kui_core::diag::unknown_menu_item_key(&k));
2265 }
2266 }
2267 let items =
2268 kui_core::MenuItem::options_from_value(&rows).map_err(mlua::Error::runtime)?;
2269 let current = match t.get::<Option<i64>>("current")? {
2270 None => None,
2271 Some(i) if i >= 1 => Some(i as usize - 1),
2272 Some(i) => {
2273 return Err(bad(format!("dropdown current is an index from 1, not {i}")));
2274 }
2275 };
2276 widgets::select_items(ui, &label, &items, current);
2277 Ok(())
2278 }
2279 "edit" => {
2280 let p = props_of(ui, t, ty, &mut checked)?.for_leaf();
2282 let label = match p.key.clone().or(t.get::<Option<String>>("label")?) {
2283 Some(l) => l,
2284 None => return Err(bad("edit needs a key (state is retained by key)")),
2285 };
2286 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
2287 let opts = EditOptions {
2288 style: p.style,
2289 multiline: t.get::<Option<bool>>("multiline")?.unwrap_or(false),
2290 autofocus: t.get::<Option<bool>>("autofocus")?.unwrap_or(false),
2291 wrap: p.wrap,
2292 ..Default::default()
2293 };
2294 ui.text_edit(&label, &initial, &opts, p.spec);
2295 Ok(())
2296 }
2297 "window_buttons" => {
2298 widgets::window_buttons(ui);
2299 Ok(())
2300 }
2301 "menu_bar" => {
2302 let (bar, stray) = menu_bar_of(t)?;
2303 warn_stray_menu_keys(ui.core(), stray);
2304 widgets::menu_bar(ui, bar);
2305 Ok(())
2306 }
2307 "latency_graph" => {
2308 widgets::latency_graph(ui);
2309 Ok(())
2310 }
2311 "latency_hud" => {
2312 let (mut x, mut y) = (Align::End, Align::End);
2313 if let Some(at) = t.get::<Option<Table>>("at")? {
2314 x = parse_align(&at.get::<String>(1)?)?;
2315 y = parse_align(&at.get::<String>(2)?)?;
2316 }
2317 widgets::latency_hud_at(ui, x, y);
2318 Ok(())
2319 }
2320 "button" => {
2321 let label: String = t.get("label")?;
2331 let text: String = t
2332 .get::<Option<String>>("text")?
2333 .unwrap_or_else(|| label.clone());
2334 let key: String = t
2335 .get::<Option<String>>("key")?
2336 .unwrap_or_else(|| label.clone());
2337 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
2338 Some(v) => lua_to_value(&v)?,
2339 None => Value::Null,
2340 };
2341 let mut out = PropsOut::new();
2342 out.spec = widgets::button_spec(&ui.theme(), &ui.metrics())
2343 .on_click(payload)
2344 .label(label.as_str());
2345 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2346 out.apply_tooltip(&hint);
2347 }
2348 with_refs(ui, |refs| {
2349 for name in ["description", "disabled", "accent"] {
2350 let v = t.get::<mlua::Value>(name)?;
2351 if v.is_nil() {
2352 continue;
2353 }
2354 let def = schema::by_snake_name(name).expect("a button row");
2355 if let Some(parsed) =
2356 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2357 {
2358 schema::apply(def, parsed, &mut out).map_err(bad)?;
2359 }
2360 }
2361 Ok(())
2362 })?;
2363 match t.get::<Option<u64>>("index")? {
2366 Some(i) => widgets::button_indexed(ui, i, &text, out.spec, out.tooltip.as_deref()),
2367 None => widgets::button_with(ui, &key, &text, out.spec, out.tooltip.as_deref()),
2368 }
2369 Ok(())
2370 }
2371 "checkbox" | "radio" | "switch" => {
2372 let kind = match ty {
2377 "checkbox" => widgets::Toggle::Checkbox,
2378 "radio" => widgets::Toggle::Radio,
2379 _ => widgets::Toggle::Switch,
2380 };
2381 let label: String = t.get("label")?;
2382 let text: String = t
2383 .get::<Option<String>>("text")?
2384 .unwrap_or_else(|| label.clone());
2385 let key: String = t
2386 .get::<Option<String>>("key")?
2387 .unwrap_or_else(|| label.clone());
2388 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
2389 Some(v) => lua_to_value(&v)?,
2390 None => Value::Null,
2391 };
2392 let mut out = PropsOut::new();
2393 out.spec = widgets::toggle_spec(&ui.metrics())
2394 .on_click(payload)
2395 .label(label.as_str());
2396 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2397 out.apply_tooltip(&hint);
2398 }
2399 apply_named_rows(
2400 ui,
2401 t,
2402 &["description", "disabled", "checked", "mixed"],
2403 &mut out,
2404 )?;
2405 widgets::toggle_with(ui, kind, &key, &text, out.spec, out.tooltip.as_deref());
2406 Ok(())
2407 }
2408 "slider" => {
2409 let label: String = t.get("label")?;
2413 let key: String = t
2414 .get::<Option<String>>("key")?
2415 .unwrap_or_else(|| label.clone());
2416 let mut out = PropsOut::new();
2417 out.spec = widgets::slider_spec(&ui.metrics()).label(label.as_str());
2418 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2419 out.apply_tooltip(&hint);
2420 }
2421 apply_named_rows(
2422 ui,
2423 t,
2424 &[
2425 "description",
2426 "disabled",
2427 "value_now",
2428 "value_min",
2429 "value_max",
2430 "value_step",
2431 "value_text",
2432 "on_change",
2433 "width",
2434 "min_width",
2435 "max_width",
2436 ],
2437 &mut out,
2438 )?;
2439 widgets::slider_with(ui, &key, out.spec, out.tooltip.as_deref());
2440 Ok(())
2441 }
2442 other => Err(mlua::Error::runtime(format!("unknown node type '{other}'"))),
2443 };
2444 if !checked {
2445 check_props(ui, t, element_of(ty))?;
2446 }
2447 built
2448}
2449
2450fn apply_named_rows(
2455 ui: &mut Ui<'_>,
2456 t: &Table,
2457 names: &[&str],
2458 out: &mut PropsOut,
2459) -> mlua::Result<()> {
2460 with_refs(ui, |refs| {
2461 for name in names {
2462 let v = t.get::<mlua::Value>(*name)?;
2463 if v.is_nil() {
2464 continue;
2465 }
2466 let def = schema::by_snake_name(name).expect("a schema row");
2467 if let Some(parsed) =
2468 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2469 {
2470 schema::apply(def, parsed, out).map_err(bad)?;
2471 }
2472 }
2473 Ok(())
2474 })
2475}
2476
2477struct SpanPart {
2478 text: String,
2479 bold: bool,
2480 italic: bool,
2481 underline: bool,
2482 underline_color: Option<Color>,
2485 underline_style: Option<kui_core::UnderlineStyle>,
2486 strikethrough: bool,
2487 color: Option<Color>,
2488 bg: Option<Color>,
2489 bg_radius: f32,
2492 family: Option<kui_core::FontFamily>,
2495 size: Option<f32>,
2497}
2498
2499fn span_of(p: &SpanPart) -> Span<'_> {
2500 let mut s = Span::new(&p.text);
2501 if p.bold {
2502 s = s.bold();
2503 }
2504 if p.italic {
2505 s = s.italic();
2506 }
2507 if p.underline {
2508 s = s.underline();
2509 }
2510 if let Some(c) = p.underline_color {
2511 s = s.underline_color(c);
2512 }
2513 if let Some(st) = p.underline_style {
2514 s = s.underline_style(st);
2515 }
2516 if p.strikethrough {
2517 s = s.strikethrough();
2518 }
2519 if let Some(c) = p.color {
2520 s = s.color(c);
2521 }
2522 if let Some(c) = p.bg {
2523 s = s.bg(c);
2524 }
2525 if p.bg_radius > 0.0 {
2526 s = s.bg_radius(p.bg_radius);
2527 }
2528 if let Some(f) = p.family {
2529 s = s.family(f);
2530 }
2531 if let Some(px) = p.size {
2532 s = s.size(px);
2533 }
2534 s
2535}
2536
2537fn collect_spans(spans: &Table, refs: &mut Refs<'_>) -> mlua::Result<Vec<SpanPart>> {
2539 let mut out = Vec::new();
2540 for item in spans.sequence_values::<mlua::Value>() {
2541 match item? {
2542 mlua::Value::String(s) => out.push(SpanPart {
2543 text: s.to_str()?.to_string(),
2544 bold: false,
2545 italic: false,
2546 underline: false,
2547 underline_color: None,
2548 underline_style: None,
2549 strikethrough: false,
2550 color: None,
2551 bg: None,
2552 bg_radius: 0.0,
2553 family: None,
2554 size: None,
2555 }),
2556 mlua::Value::Table(t) => out.push(span_part(&t, refs)?),
2557 other => {
2558 return Err(bad(format!(
2559 "span must be a string or table, got {}",
2560 other.type_name()
2561 )));
2562 }
2563 }
2564 }
2565 Ok(out)
2566}
2567
2568fn span_part(t: &Table, refs: &mut Refs<'_>) -> mlua::Result<SpanPart> {
2576 let mut text = None;
2577 let (mut color, mut bg, mut underline_color) = (None, None, None);
2578 let (mut underline_style, mut font, mut family) = (None, None, None);
2579 let (mut size, mut bg_radius) = (None, None);
2580 let (mut bold, mut italic, mut underline, mut strikethrough) = (false, false, false, false);
2581 let flag = |v: &mlua::Value| !matches!(v, mlua::Value::Nil | mlua::Value::Boolean(false));
2584 t.for_each::<NodeKey, mlua::Value>(|k, v| {
2585 match &k {
2586 NodeKey::Int(1) => text = Some(v),
2587 NodeKey::Str { .. } => match k.bytes() {
2588 b"color" => color = Some(v),
2589 b"bg" => bg = Some(v),
2590 b"underline_color" => underline_color = Some(v),
2591 b"underline_style" => underline_style = Some(v),
2592 b"font" => font = Some(v),
2593 b"family" => family = Some(v),
2594 b"size" => size = Some(v),
2595 b"bg_radius" => bg_radius = Some(v),
2596 b"bold" => bold = flag(&v),
2597 b"italic" => italic = flag(&v),
2598 b"underline" => underline = flag(&v),
2599 b"strikethrough" => strikethrough = flag(&v),
2600 _ => {}
2601 },
2602 _ => {}
2603 }
2604 Ok(())
2605 })?;
2606 let text = match text {
2607 Some(mlua::Value::String(s)) => s.to_str()?.to_string(),
2608 _ => t
2609 .get::<Option<String>>(1)?
2610 .ok_or_else(|| bad("span table needs its text at [1]"))?,
2611 };
2612 let color = match color {
2613 Some(v) => parse_color(&v, refs)?,
2614 None => None,
2615 };
2616 let bg = match bg {
2617 Some(v) => parse_color(&v, refs)?,
2618 None => None,
2619 };
2620 let underline_color = match underline_color {
2621 Some(v) => parse_color(&v, refs)?,
2622 None => None,
2623 };
2624 let underline_style = match underline_style {
2625 None => None,
2626 Some(_) => {
2627 let name: String = t.get("underline_style")?;
2628 Some(
2629 kui_core::UnderlineStyle::NAMES
2630 .iter()
2631 .position(|n| *n == name)
2632 .map(|i| kui_core::UnderlineStyle::from_index(i as u32))
2633 .ok_or_else(|| {
2634 bad(format!(
2635 "underline_style must be one of {}, got {name:?}",
2636 kui_core::UnderlineStyle::NAMES.join(" | ")
2637 ))
2638 })?,
2639 )
2640 }
2641 };
2642 let family = match (font, family) {
2645 (Some(v), _) => match parse_value(&Kind::Resource, &v, refs)
2646 .map_err(|e| bad(format!("span font: {e}")))?
2647 {
2648 Some(Parsed::Resource(id)) => {
2649 Some(kui_core::FontFamily::Custom(kui_core::FontId::from_ffi(id)))
2650 }
2651 _ => None,
2652 },
2653 (None, Some(v)) => match parse_value(&Kind::Family, &v, refs)
2654 .map_err(|e| bad(format!("span family: {e}")))?
2655 {
2656 Some(Parsed::Family(f)) => Some(f),
2657 _ => None,
2658 },
2659 (None, None) => None,
2660 };
2661 let number = |v: Option<mlua::Value>, key: &str| -> mlua::Result<Option<f32>> {
2662 match v {
2663 None => Ok(None),
2664 Some(v) => match number(&v) {
2665 Some(n) => Ok(Some(n)),
2666 None => t.get::<Option<f32>>(key),
2667 },
2668 }
2669 };
2670 Ok(SpanPart {
2671 family,
2672 size: number(size, "size")?,
2673 text,
2674 bold,
2675 italic,
2676 underline,
2677 underline_color,
2678 underline_style,
2679 strikethrough,
2680 color,
2681 bg,
2682 bg_radius: number(bg_radius, "bg_radius")?.unwrap_or(0.0).max(0.0),
2683 })
2684}
2685
2686pub fn parse_tokens(t: &Table) -> mlua::Result<kui_core::Tokens> {
2696 let mut out = kui_core::Tokens::new();
2697 for pair in t.pairs::<String, mlua::Value>() {
2698 let (k, _) = pair?;
2699 if k != "colors" && k != "lengths" {
2700 return Err(bad(format!("tokens: unknown key {k:?} (colors, lengths)")));
2701 }
2702 }
2703 if let Some(colors) = t.get::<Option<Table>>("colors")? {
2704 let mut entries: Vec<(String, mlua::Value)> = colors
2705 .pairs::<String, mlua::Value>()
2706 .collect::<mlua::Result<_>>()?;
2707 entries.sort_by(|a, b| a.0.cmp(&b.0));
2708 let mut derived: Vec<(String, String, Vec<kui_core::ColorOp>)> = Vec::new();
2710 for (name, v) in entries {
2711 out = match &v {
2712 mlua::Value::Table(t) if t.contains_key("from")? => {
2713 let (from, ops) = parse_recipe(&name, t)?;
2714 derived.push((name, from, ops));
2715 out
2716 }
2717 mlua::Value::Table(halves) => {
2718 let half = |k: &str| -> mlua::Result<Color> {
2719 match halves.get::<mlua::Value>(k)? {
2720 mlua::Value::Nil => Err(bad(format!(
2721 "tokens.colors.{name}: needs both light and dark"
2722 ))),
2723 v => parse_color_value(&v),
2724 }
2725 };
2726 out.color_themed(&name, half("light")?, half("dark")?)
2727 }
2728 v => out.color(
2729 &name,
2730 parse_color_value(v).map_err(|e| bad(format!("tokens.colors.{name}: {e}")))?,
2731 ),
2732 };
2733 }
2734 while !derived.is_empty() {
2737 let ready = derived.iter().position(|(_, from, ops)| {
2738 let known = |s: &str| kui_core::tokens::is_role(s) || out.color_id(s).is_some();
2739 known(from)
2740 && ops.iter().all(|op| match op {
2741 kui_core::ColorOp::Mix(c, _) | kui_core::ColorOp::Readable(c, _) => {
2742 known(c)
2743 }
2744 _ => true,
2745 })
2746 });
2747 let (name, from, ops) = derived.remove(ready.unwrap_or(0));
2748 out = out.derive(&name, &from, ops);
2749 }
2750 }
2751 if let Some(lengths) = t.get::<Option<Table>>("lengths")? {
2752 let mut entries: Vec<(String, mlua::Value)> = lengths
2753 .pairs::<String, mlua::Value>()
2754 .collect::<mlua::Result<_>>()?;
2755 entries.sort_by(|a, b| a.0.cmp(&b.0));
2756 for (name, v) in entries {
2757 let px = number(&v)
2758 .ok_or_else(|| bad(format!("tokens.lengths.{name}: a length is a number")))?;
2759 out = out.length(&name, px);
2760 }
2761 }
2762 Ok(out)
2763}
2764
2765fn parse_recipe(name: &str, t: &Table) -> mlua::Result<(String, Vec<kui_core::ColorOp>)> {
2770 for pair in t.pairs::<String, mlua::Value>() {
2771 let (k, _) = pair?;
2772 if k != "from" && k != "ops" {
2773 return Err(bad(format!(
2774 "tokens.colors.{name}: unknown key {k:?} (from, ops)"
2775 )));
2776 }
2777 }
2778 let from = match t.get::<mlua::Value>("from")? {
2779 mlua::Value::String(s) => s.to_str()?.to_string(),
2780 _ => {
2781 return Err(bad(format!(
2782 "tokens.colors.{name}.from names a colour token or role"
2783 )));
2784 }
2785 };
2786 let ops = match t.get::<mlua::Value>("ops")? {
2787 mlua::Value::Nil => Vec::new(),
2788 mlua::Value::Table(list) => {
2789 let tuples: Vec<Table> = match list.get::<mlua::Value>(1)? {
2791 mlua::Value::String(_) => vec![list],
2792 _ => list
2793 .sequence_values::<Table>()
2794 .collect::<mlua::Result<_>>()?,
2795 };
2796 tuples
2797 .iter()
2798 .map(|op| parse_color_op(name, op))
2799 .collect::<mlua::Result<_>>()?
2800 }
2801 _ => {
2802 return Err(bad(format!(
2803 "tokens.colors.{name}.ops is a list of {{ verb, … }} tuples"
2804 )));
2805 }
2806 };
2807 Ok((from, ops))
2808}
2809
2810fn parse_color_op(name: &str, op: &Table) -> mlua::Result<kui_core::ColorOp> {
2811 let verb = match op.get::<mlua::Value>(1)? {
2812 mlua::Value::String(s) => s.to_str()?.to_string(),
2813 _ => {
2814 return Err(bad(format!(
2815 "tokens.colors.{name}.ops: an op starts with its verb"
2816 )));
2817 }
2818 };
2819 let Some(takes_color) = kui_core::ColorOp::takes_color(&verb) else {
2820 return Err(bad(format!(
2821 "tokens.colors.{name}.ops: unknown verb {verb:?} (lift, darken, raise, alpha, mix, readable)"
2822 )));
2823 };
2824 let arity = if takes_color { 3 } else { 2 };
2825 if op.raw_len() != arity {
2826 return Err(bad(format!(
2827 "tokens.colors.{name}.ops: {verb} takes {} — {{ \"{verb}\", {} }}",
2828 if takes_color {
2829 "a colour and a number"
2830 } else {
2831 "one number"
2832 },
2833 if takes_color { "token, t" } else { "t" }
2834 )));
2835 }
2836 let color = if takes_color {
2837 match op.get::<mlua::Value>(2)? {
2838 mlua::Value::String(s) => Some(s.to_str()?.to_string()),
2839 _ => {
2840 return Err(bad(format!(
2841 "tokens.colors.{name}.ops: {verb}'s colour is a token or role name"
2842 )));
2843 }
2844 }
2845 } else {
2846 None
2847 };
2848 let n = number(&op.get::<mlua::Value>(arity as i64)?).ok_or_else(|| {
2849 bad(format!(
2850 "tokens.colors.{name}.ops: {verb}'s number is a number"
2851 ))
2852 })?;
2853 Ok(kui_core::ColorOp::parse(&verb, color.as_deref(), n).expect("checked above"))
2854}
2855
2856pub type Refs<'a> = kui_core::NameRefs<'a>;
2862
2863fn with_refs<R>(
2867 ui: &mut Ui<'_>,
2868 f: impl FnOnce(&mut Refs<'_>) -> mlua::Result<R>,
2869) -> mlua::Result<R> {
2870 let (r, errors, families) = {
2871 let mut refs = Refs::new(ui.core().token_lookup());
2872 let r = f(&mut refs);
2873 (r, refs.take_missed(), refs.take_missed_families())
2874 };
2875 for e in errors {
2876 ui.core().warn_unknown_token(&e);
2877 }
2878 for name in families {
2879 ui.core().warn_unknown_family(&name);
2880 }
2881 r
2882}
2883
2884fn reference(v: &mlua::Value) -> mlua::Result<Option<String>> {
2886 if let mlua::Value::String(s) = v
2887 && let Some(name) = kui_core::tokens::reference(&s.to_str()?)
2888 {
2889 return Ok(Some(name.to_string()));
2890 }
2891 Ok(None)
2892}
2893
2894fn dash_of(t: &Table) -> mlua::Result<kui_core::Dash> {
2898 let offset = t.get::<Option<f32>>("dash_offset")?.unwrap_or(0.0);
2899 let lengths: Vec<f32> = match t.get::<mlua::Value>("dash")? {
2900 mlua::Value::Nil => return Ok(kui_core::Dash::SOLID),
2901 mlua::Value::Table(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
2902 mlua::Value::Integer(n) => vec![n as f32],
2903 mlua::Value::Number(n) => vec![n as f32],
2904 _ => Vec::new(),
2905 };
2906 kui_core::Dash::of(&lengths)
2907 .map(|d| d.offset(offset))
2908 .ok_or_else(|| bad("dash is a length, {mark, gap} or {mark, gap, mark, gap}"))
2909}
2910
2911fn length_of(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<f32>> {
2913 if let Some(name) = reference(v)? {
2914 return Ok(refs.length(&name));
2915 }
2916 number(v)
2917 .map(Some)
2918 .ok_or_else(|| bad("expected a number or a \"$token\""))
2919}
2920
2921pub fn parse_props(t: &Table, is_row: bool, refs: &mut Refs<'_>) -> mlua::Result<PropsOut> {
2929 parse_node(t, is_row, refs, Check::Off).map(|(out, _)| out)
2930}
2931
2932#[derive(Default)]
2938struct Walked {
2939 unknown: Vec<NodeKey>,
2940 value: Option<mlua::Value>,
2941 spans: Option<mlua::Value>,
2942}
2943
2944#[derive(Clone)]
2952enum NodeKey {
2953 Str {
2955 len: u8,
2956 buf: [u8; NodeKey::INLINE],
2957 },
2958 Long(String),
2961 Int(i64),
2962 Other,
2963}
2964
2965impl NodeKey {
2966 const INLINE: usize = 30;
2967
2968 fn of_bytes(b: &[u8]) -> Self {
2969 if b.len() <= Self::INLINE {
2970 let mut buf = [0; Self::INLINE];
2971 buf[..b.len()].copy_from_slice(b);
2972 NodeKey::Str {
2973 len: b.len() as u8,
2974 buf,
2975 }
2976 } else {
2977 NodeKey::Long(String::from_utf8_lossy(b).into_owned())
2978 }
2979 }
2980
2981 fn bytes(&self) -> &[u8] {
2983 match self {
2984 NodeKey::Str { len, buf } => &buf[..*len as usize],
2985 _ => &[],
2986 }
2987 }
2988
2989 fn name(&self) -> String {
2991 match self {
2992 NodeKey::Str { .. } => String::from_utf8_lossy(self.bytes()).into_owned(),
2993 NodeKey::Long(name) => name.clone(),
2994 NodeKey::Int(i) => i.to_string(),
2995 NodeKey::Other => "<a key>".into(),
2996 }
2997 }
2998}
2999
3000impl mlua::FromLua for NodeKey {
3001 fn from_lua(value: mlua::Value, _: &Lua) -> mlua::Result<Self> {
3002 Ok(match value {
3003 mlua::Value::String(s) => NodeKey::of_bytes(&s.as_bytes()),
3004 mlua::Value::Integer(i) => NodeKey::Int(i),
3005 _ => NodeKey::Other,
3006 })
3007 }
3008
3009 unsafe fn from_stack(idx: std::ffi::c_int, lua: &mlua::state::RawLua) -> mlua::Result<Self> {
3010 use mlua::ffi;
3011 let state = lua.state();
3012 unsafe {
3017 Ok(match ffi::lua_type(state, idx) {
3018 ffi::LUA_TSTRING => {
3019 let mut len = 0;
3020 let ptr = ffi::lua_tolstring(state, idx, &mut len);
3021 NodeKey::of_bytes(std::slice::from_raw_parts(ptr as *const u8, len))
3022 }
3023 ffi::LUA_TNUMBER if ffi::lua_isinteger(state, idx) != 0 => {
3024 NodeKey::Int(ffi::lua_tointeger(state, idx))
3025 }
3026 _ => NodeKey::Other,
3027 })
3028 }
3029 }
3030}
3031
3032#[derive(Clone, Copy, PartialEq, Eq)]
3038enum Check {
3039 Off,
3040 Unclaimed,
3041 All,
3042}
3043
3044enum Item {
3047 Pad,
3048 Border,
3049 Overflow(u32),
3050 Float,
3051 KeyFocus,
3052 Key,
3053 Index,
3054 RowCount,
3055 Tooltip,
3056 Row(&'static schema::PropDef),
3057}
3058
3059fn parse_node(
3068 t: &Table,
3069 is_row: bool,
3070 refs: &mut Refs<'_>,
3071 check: Check,
3072) -> mlua::Result<(PropsOut, Walked)> {
3073 let mut out = PropsOut::new();
3074 if is_row {
3075 out.spec = kui_core::NodeSpec::row();
3076 }
3077 let mut walked = Walked::default();
3078 let mut size = None;
3079 let mut radius = None;
3080 let mut items: Vec<(Item, mlua::Value)> = Vec::with_capacity(8);
3081 t.for_each::<NodeKey, mlua::Value>(|k, v| {
3082 if !matches!(k, NodeKey::Str { .. } | NodeKey::Long(_)) {
3083 return Ok(());
3084 }
3085 if check == Check::All && k.bytes() != b"type" {
3086 walked.unknown.push(k.clone());
3087 }
3088 let item = match k.bytes() {
3089 b"size" => {
3090 size = Some(v);
3091 return Ok(());
3092 }
3093 b"radius" => {
3094 radius = Some(v);
3095 return Ok(());
3096 }
3097 b"pad" => Item::Pad,
3098 b"border" => Item::Border,
3099 b"clip" => Item::Overflow(kui_core::OVERFLOW_CLIP),
3100 b"scroll_x" => Item::Overflow(kui_core::OVERFLOW_SCROLL_X),
3101 b"scroll" | b"scroll_y" => Item::Overflow(kui_core::OVERFLOW_SCROLL_Y),
3102 b"float" => Item::Float,
3103 b"key_focus" => Item::KeyFocus,
3104 b"key" => Item::Key,
3105 b"index" => Item::Index,
3106 b"row_count" => Item::RowCount,
3107 b"tooltip" => Item::Tooltip,
3108 name => {
3109 let name = schema::LUA_ALIASES
3113 .iter()
3114 .find(|(alias, _)| alias.as_bytes() == name)
3115 .map_or(name, |(_, real)| real.as_bytes());
3116 match schema::by_snake_bytes(name) {
3117 Some(def) => Item::Row(def),
3118 None => {
3119 match name {
3120 b"value" => walked.value = Some(v),
3121 b"spans" => walked.spans = Some(v),
3122 _ => {}
3123 }
3124 if check == Check::Unclaimed && name != b"type" {
3126 walked.unknown.push(k);
3127 }
3128 return Ok(());
3129 }
3130 }
3131 }
3132 };
3133 items.push((item, v));
3134 Ok(())
3135 })?;
3136 if let Some(v) = size
3137 && let Some(size) = length_of(&v, refs)?
3138 {
3139 out.style = kui_core::TextStyle::new(size);
3140 }
3141 if let Some(v) = radius
3142 && let Some(r) = length_of(&v, refs)?
3143 {
3144 out.with_spec(|s| s.radius(r));
3145 }
3146 let mut overflow = 0;
3150 for (item, v) in items {
3151 match item {
3152 Item::Pad => {
3153 let pad = parse_pad(&v, refs)?;
3154 out.apply_pad(pad);
3155 }
3156 Item::Border => {
3157 let b = match v {
3158 mlua::Value::Table(b) => b,
3159 _ => return Err(bad("border must be a table {w=, color=}")),
3160 };
3161 let w = length_of(&b.get::<mlua::Value>("w")?, refs)?.unwrap_or(0.0);
3162 let c = parse_color(&b.get::<mlua::Value>("color")?, refs)?
3163 .unwrap_or(Color::TRANSPARENT);
3164 out.with_spec(|s| s.border(w, c));
3165 }
3166 Item::Overflow(flag) => overflow |= bit(&v, flag),
3167 Item::Float => {
3168 let cfg = parse_float(&v)?;
3169 out.with_spec(|s| s.float(cfg));
3170 }
3171 Item::KeyFocus => out.key_focus = truthy(&v),
3172 Item::Key => {
3173 let mlua::Value::String(s) = &v else {
3174 return Err(bad("key must be a string"));
3175 };
3176 out.key = Some(s.to_str()?.to_string());
3177 }
3178 Item::Index => {
3179 let Some(i) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
3180 return Err(bad("index must be a number (the row's data index)"));
3181 };
3182 out.index = Some(i.max(0.0) as u64);
3183 }
3184 Item::RowCount => {
3185 let Some(n) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
3186 return Err(bad(
3187 "row_count must be a number (how many indexed rows the list has)",
3188 ));
3189 };
3190 out.row_count = Some(n.max(0.0) as u64);
3191 }
3192 Item::Tooltip => {
3193 let mlua::Value::String(s) = &v else {
3194 return Err(bad("tooltip must be a string"));
3195 };
3196 out.apply_tooltip(s.to_str()?.as_ref());
3197 }
3198 Item::Row(def) => {
3199 if let Some(parsed) = parse_value(&def.kind, &v, refs)
3200 .map_err(|e| bad(format!("{}: {e}", schema::snake_case(def.name))))?
3201 {
3202 schema::apply(def, parsed, &mut out).map_err(bad)?;
3203 }
3204 }
3205 }
3206 }
3207 out.with_spec(|s| s.overflow_bits(overflow));
3208 Ok((out, walked))
3209}
3210
3211fn props_of(ui: &mut Ui<'_>, t: &Table, ty: &str, checked: &mut bool) -> mlua::Result<PropsOut> {
3214 *checked = true;
3215 node_props(ui, t, false, element_of(ty)).map(|(out, _)| out)
3216}
3217
3218fn node_props(
3223 ui: &mut Ui<'_>,
3224 t: &Table,
3225 is_row: bool,
3226 element: &str,
3227) -> mlua::Result<(PropsOut, Walked)> {
3228 let check = if !ui.core().diagnostics() {
3229 Check::Off
3230 } else if schema::element_rows(element, schema::Spelling::Snake).is_some() {
3231 Check::All
3232 } else {
3233 Check::Unclaimed
3234 };
3235 let (out, walked) = with_refs(ui, |refs| parse_node(t, is_row, refs, check))?;
3236 for k in &walked.unknown {
3237 let name = k.name();
3238 if !schema::known_prop(element, &name, schema::Spelling::Snake) {
3239 let w = kui_core::diag::unknown_prop(element, &name, schema::Spelling::Snake);
3240 ui.core().warn(w);
3241 }
3242 }
3243 Ok((out, walked))
3244}
3245
3246fn truthy(v: &mlua::Value) -> bool {
3247 matches!(v, mlua::Value::Boolean(true))
3248}
3249
3250fn bit(v: &mlua::Value, bit: u32) -> u32 {
3252 if truthy(v) { bit } else { 0 }
3253}
3254
3255fn number(v: &mlua::Value) -> Option<f32> {
3256 match v {
3257 mlua::Value::Number(n) => Some(*n as f32),
3258 mlua::Value::Integer(n) => Some(*n as f32),
3259 _ => None,
3260 }
3261}
3262
3263fn parse_value(kind: &Kind, v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Parsed>> {
3265 Ok(Some(match kind {
3266 Kind::F32 => match length_of(v, refs)? {
3267 Some(px) => Parsed::F32(px),
3268 None => return Ok(None),
3269 },
3270 Kind::Color => match parse_color(v, refs)? {
3271 Some(c) => Parsed::Color(c),
3272 None => return Ok(None),
3273 },
3274 Kind::Flag => {
3275 if truthy(v) {
3276 Parsed::Flag
3277 } else {
3278 return Ok(None);
3279 }
3280 }
3281 Kind::Enum(names) => {
3282 let mlua::Value::String(s) = v else {
3283 return Err(bad(format!("expected one of {names:?}")));
3284 };
3285 Parsed::Enum(schema::enum_index(names, &s.to_str()?).map_err(bad)?)
3286 }
3287 Kind::Sizing => match parse_sizing(v, refs)? {
3288 Some(s) => Parsed::Sizing(s),
3289 None => return Ok(None),
3290 },
3291 Kind::Min | Kind::Max => Parsed::Bound(match v {
3296 v if reference(v)?.is_some() => match length_of(v, refs)? {
3297 Some(px) => kui_core::Bound::Px(px),
3298 None => return Ok(None),
3299 },
3300 mlua::Value::String(s) => {
3303 let s = s.to_str()?;
3304 let b = if matches!(kind, Kind::Min) {
3305 schema::min_str(&s)
3306 } else {
3307 schema::max_str(&s)
3308 };
3309 match kept(b)? {
3310 Some(b) => b,
3311 None => return Ok(None),
3312 }
3313 }
3314 mlua::Value::Table(_) => match kept(kui_core::calc::bound_value(&size_value(v)?))? {
3315 Some(b) => b,
3316 None => return Ok(None),
3317 },
3318 v => kui_core::Bound::Px(
3319 number(v).ok_or_else(|| bad("expected a number, a string or a size table"))?,
3320 ),
3321 }),
3322 Kind::Msg | Kind::Tag => Parsed::Msg(lua_to_value(v)?),
3323 Kind::Str => {
3324 let mlua::Value::String(s) = v else {
3325 return Err(bad("expected a string"));
3326 };
3327 Parsed::Str(s.to_str()?.to_string())
3328 }
3329 Kind::Family => {
3332 let mlua::Value::String(s) = v else {
3333 return Err(bad("expected a family name"));
3334 };
3335 Parsed::Family(refs.family(&s.to_str()?))
3336 }
3337 Kind::Resource => match v {
3338 mlua::Value::Integer(n) => Parsed::Resource(*n as u64),
3339 mlua::Value::Number(n) => Parsed::Resource(*n as u64),
3340 _ => return Err(bad("expected a resource handle (integer)")),
3341 },
3342 Kind::Keyframes => Parsed::Keyframes(
3345 kui_core::keyframes::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
3346 ),
3347 Kind::Enter => {
3348 Parsed::Enter(kui_core::enter::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?)
3349 }
3350 Kind::Gradient => Parsed::Gradient(
3351 kui_core::gradient::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
3352 ),
3353 }))
3354}
3355
3356fn parse_color_value(v: &mlua::Value) -> mlua::Result<Color> {
3359 match v {
3360 mlua::Value::Integer(n) => Ok(schema::color_num(*n as u32)),
3361 mlua::Value::Number(n) => Ok(schema::color_num(*n as u32)),
3362 mlua::Value::String(s) => schema::color_hex_str(&s.to_str()?).map_err(bad),
3363 _ => Err(bad("color must be a 0xRRGGBBAA integer or \"#hex\" string")),
3364 }
3365}
3366
3367fn parse_color(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Color>> {
3369 if let Some(name) = reference(v)? {
3370 return Ok(refs.color(&name));
3371 }
3372 parse_color_value(v)
3373 .map(Some)
3374 .map_err(|_| bad("color must be a 0xRRGGBBAA integer, a \"#hex\" string or a \"$token\""))
3375}
3376
3377fn size_value(v: &mlua::Value) -> mlua::Result<Value> {
3382 fn check(v: &Value) -> mlua::Result<()> {
3383 match v {
3384 Value::Map(m) => {
3385 for (k, x) in m {
3386 if k == "percent" {
3387 return Err(bad("a percentage is { pct = n } in Lua"));
3388 }
3389 check(x)?;
3390 }
3391 Ok(())
3392 }
3393 Value::List(xs) => xs.iter().try_for_each(check),
3394 _ => Ok(()),
3395 }
3396 }
3397 let value = lua_to_value(v)?;
3398 check(&value)?;
3399 Ok(value)
3400}
3401
3402fn kept<T>(r: Result<T, String>) -> mlua::Result<Option<T>> {
3406 match r {
3407 Ok(v) => Ok(Some(v)),
3408 Err(e) if kui_core::calc::is_full(&e) => Ok(None),
3409 Err(e) => Err(bad(e)),
3410 }
3411}
3412
3413fn parse_sizing(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Sizing>> {
3414 if let Some(name) = reference(v)? {
3415 return Ok(refs.length(&name).map(Sizing::Fixed));
3416 }
3417 Ok(Some(match v {
3418 mlua::Value::Number(n) => Sizing::Fixed(*n as f32),
3419 mlua::Value::Integer(n) => Sizing::Fixed(*n as f32),
3420 mlua::Value::String(s) => match kept(schema::sizing_str(&s.to_str()?))? {
3421 Some(s) => s,
3422 None => return Ok(None),
3423 },
3424 mlua::Value::Table(t) => {
3425 if let Some(p) = t.get::<Option<f32>>("pct")? {
3426 Sizing::Percent(p / 100.0)
3427 } else if let Some(f) = t.get::<Option<f32>>("grow")? {
3428 Sizing::Grow(f)
3429 } else {
3430 match kept(kui_core::calc::sizing_value(&size_value(v)?)).map_err(|e| {
3433 bad(format!(
3434 "sizing table needs pct, grow or a size expression: {e}"
3435 ))
3436 })? {
3437 Some(s) => s,
3438 None => return Ok(None),
3439 }
3440 }
3441 }
3442 _ => return Err(bad("invalid sizing value")),
3443 }))
3444}
3445
3446fn parse_pad(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<PadShorthand> {
3450 match v {
3451 mlua::Value::Table(t) => {
3452 let mut edges: [Option<mlua::Value>; 7] = Default::default();
3455 t.for_each::<NodeKey, mlua::Value>(|k, v| {
3456 let at = match k.bytes() {
3457 b"all" => 0,
3458 b"x" => 1,
3459 b"y" => 2,
3460 b"l" => 3,
3461 b"r" => 4,
3462 b"t" => 5,
3463 b"b" => 6,
3464 _ => return Ok(()),
3465 };
3466 edges[at] = Some(v);
3467 Ok(())
3468 })?;
3469 let mut edge = |i: usize| -> mlua::Result<Option<f32>> {
3470 match edges[i].take() {
3471 None => Ok(None),
3472 Some(v) => length_of(&v, refs),
3473 }
3474 };
3475 Ok(PadShorthand {
3476 all: edge(0)?,
3477 x: edge(1)?,
3478 y: edge(2)?,
3479 l: edge(3)?,
3480 r: edge(4)?,
3481 t: edge(5)?,
3482 b: edge(6)?,
3483 })
3484 }
3485 v => Ok(PadShorthand {
3486 all: length_of(v, refs).map_err(|_| bad("invalid padding value"))?,
3487 ..PadShorthand::default()
3488 }),
3489 }
3490}
3491
3492fn parse_align(s: &str) -> mlua::Result<Align> {
3493 schema::enum_index(schema::ALIGNS, s)
3494 .map(schema::align_idx)
3495 .map_err(bad)
3496}
3497
3498fn float_preset(name: &str) -> mlua::Result<FloatConfig> {
3501 FloatConfig::preset(name).ok_or_else(|| {
3502 bad(format!(
3503 "bad float preset '{name}' (one of {})",
3504 kui_core::FLOAT_PRESETS.join(" | ")
3505 ))
3506 })
3507}
3508
3509fn parse_attach(f: &Table, key: &str) -> mlua::Result<Option<(Align, Align)>> {
3511 let Some(at) = f.get::<Option<Table>>(key)? else {
3512 return Ok(None);
3513 };
3514 Ok(Some((
3515 parse_align(&at.get::<String>(1)?)?,
3516 parse_align(&at.get::<String>(2)?)?,
3517 )))
3518}
3519
3520fn parse_float(v: &mlua::Value) -> mlua::Result<FloatConfig> {
3521 let f = match v {
3522 mlua::Value::String(s) => return float_preset(&s.to_str()?),
3523 mlua::Value::Table(f) => f,
3524 _ => return Err(bad("float must be a preset string or a table")),
3525 };
3526 let self_at = match parse_attach(f, "self")? {
3529 Some(at) => Some(at),
3530 None => parse_attach(f, "self_at")?,
3531 };
3532 Ok(FloatConfig::build(
3533 match f.get::<Option<String>>("anchor")? {
3534 Some(name) => float_preset(&name)?,
3535 None => FloatConfig::parent(),
3536 },
3537 parse_attach(f, "at")?,
3538 self_at,
3539 f.get::<Option<f32>>("dx")?,
3540 f.get::<Option<f32>>("dy")?,
3541 f.get::<Option<bool>>("fit")?.unwrap_or(false),
3542 f.get::<Option<bool>>("clip")?.unwrap_or(false),
3543 ))
3544}
3545
3546pub const MAX_VALUE_DEPTH: usize = 64;
3553
3554pub fn lua_to_value(v: &mlua::Value) -> mlua::Result<Value> {
3572 to_value(
3573 v,
3574 &mut ValuePath {
3575 depth: 0,
3576 seen: Vec::new(),
3577 },
3578 )
3579}
3580
3581const VALUE_TRACKED_PAST: usize = 8;
3587
3588struct ValuePath {
3591 depth: usize,
3592 seen: Vec<*const std::ffi::c_void>,
3593}
3594
3595fn to_value(v: &mlua::Value, path: &mut ValuePath) -> mlua::Result<Value> {
3599 Ok(match v {
3600 mlua::Value::Nil => Value::Null,
3601 mlua::Value::Boolean(b) => Value::Bool(*b),
3602 mlua::Value::Integer(i) => Value::Int(*i),
3603 mlua::Value::Number(n) => Value::Float(*n),
3604 mlua::Value::String(s) => Value::Str(s.to_str()?.to_string()),
3605 mlua::Value::Table(t) => {
3606 let tracked = path.depth >= VALUE_TRACKED_PAST;
3607 if tracked {
3608 let at = t.to_pointer();
3609 if path.seen.contains(&at) {
3610 return Err(bad("a table that holds itself cannot be a value"));
3611 }
3612 path.seen.push(at);
3613 }
3614 if path.depth >= MAX_VALUE_DEPTH {
3615 return Err(bad(format!("a value nested past {MAX_VALUE_DEPTH} tables")));
3616 }
3617 path.depth += 1;
3618 let len = t.raw_len();
3619 let value = if len > 0 {
3620 let mut list = Vec::with_capacity(len);
3621 for item in t.sequence_values::<mlua::Value>() {
3622 list.push(to_value(&item?, path)?);
3623 }
3624 Value::List(list)
3625 } else {
3626 let mut map = Vec::new();
3627 for pair in t.pairs::<String, mlua::Value>() {
3628 let (k, v) = pair?;
3629 map.push((k, to_value(&v, path)?));
3630 }
3631 Value::Map(map)
3632 };
3633 path.depth -= 1;
3634 if tracked {
3635 path.seen.pop();
3636 }
3637 value
3638 }
3639 other => {
3640 return Err(mlua::Error::runtime(format!(
3641 "cannot convert {} to event payload",
3642 other.type_name()
3643 )));
3644 }
3645 })
3646}
3647
3648pub fn value_to_lua(lua: &Lua, v: &Value) -> mlua::Result<mlua::Value> {
3651 Ok(match v {
3652 Value::Null => mlua::Value::Nil,
3653 Value::Bool(b) => mlua::Value::Boolean(*b),
3654 Value::Int(i) => mlua::Value::Integer(*i),
3655 Value::Float(f) => mlua::Value::Number(*f),
3656 Value::Str(s) => mlua::Value::String(lua.create_string(s)?),
3657 Value::List(items) => {
3658 let t = lua.create_table_with_capacity(items.len(), 0)?;
3659 for (i, item) in items.iter().enumerate() {
3660 t.set(i + 1, value_to_lua(lua, item)?)?;
3661 }
3662 mlua::Value::Table(t)
3663 }
3664 Value::Map(entries) => {
3665 let t = lua.create_table_with_capacity(0, entries.len())?;
3666 for (k, v) in entries {
3667 t.set(k.as_str(), value_to_lua(lua, v)?)?;
3668 }
3669 mlua::Value::Table(t)
3670 }
3671 })
3672}
3673
3674#[cfg(test)]
3675mod tests {
3676 use super::*;
3677 use kui_core::{
3678 Core, Edges, FontFamily, InputEvent, NodeSpec, OriginId, Rect, Size, TextStyle, Vec2,
3679 WindowButton, WindowId,
3680 };
3681
3682 #[test]
3683 fn value_round_trips_through_lua() {
3684 let lua = Lua::new();
3685 let original = Value::map([
3686 ("kind", "inc".into()),
3687 ("by", Value::Int(2)),
3688 (
3689 "weights",
3690 Value::List(vec![Value::Float(0.5), Value::Float(1.5)]),
3691 ),
3692 ("enabled", Value::Bool(true)),
3693 ]);
3694 let lua_v = value_to_lua(&lua, &original).unwrap();
3695 let back = lua_to_value(&lua_v).unwrap();
3696 assert_eq!(back.get_str("kind"), Some("inc"));
3697 assert_eq!(back.get_int("by"), Some(2));
3698 assert_eq!(back.get_bool("enabled"), Some(true));
3699 match back.get("weights") {
3700 Some(Value::List(items)) => assert_eq!(items.len(), 2),
3701 other => panic!("expected list, got {other:?}"),
3702 }
3703 }
3704
3705 fn test_refs() -> Refs<'static> {
3709 let core: &'static Core = Box::leak(Box::new(Core::new()));
3710 Refs::new(core.token_lookup())
3711 }
3712
3713 fn eval_table(lua: &Lua, src: &str) -> Table {
3714 lua.load(src).eval().unwrap()
3715 }
3716
3717 #[test]
3719 fn schema_props_match_the_rust_builder() {
3720 let lua = Lua::new();
3721 let t = eval_table(
3722 &lua,
3723 r##"{
3724 width = "grow", height = {pct = 50},
3725 min_width = 10, max_width = 500, min_height = 5, max_height = 300,
3726 pad = {l = 1, r = 2, t = 3, b = 4}, gap = 8,
3727 main_align = "center", cross_align = "end", center = false,
3728 bg = "#14161e", radius = 6, border = {w = 1, color = 0x2a2d3aff},
3729 clip = true, scroll = true, scroll_x = true,
3730 float = {anchor = "viewport", at = {"end", "end"}, self_at = {"end", "end"},
3731 dx = -8, dy = -8, fit = true},
3732 hoverable = true, window = "close",
3733 on_click = {kind = "hit"}, on_drag = "d", on_key = 7, key_up = true,
3734 modal = "dlg", on_context_menu = {kind = "menu"},
3735 initial_focus = true,
3736 key = "panel", key_focus = true,
3737 }"##,
3738 );
3739 let p = parse_props(&t, true, &mut test_refs()).unwrap();
3740 let expected = NodeSpec::row()
3741 .grow_width()
3742 .height(Sizing::Percent(0.5))
3743 .min_width(10.0)
3744 .max_width(500.0)
3745 .min_height(5.0)
3746 .max_height(300.0)
3747 .padding(Edges {
3748 l: 1.0,
3749 r: 2.0,
3750 t: 3.0,
3751 b: 4.0,
3752 })
3753 .gap(8.0)
3754 .main_align(Align::Center)
3755 .cross_align(Align::End)
3756 .bg(Color::hex(0x14161eff))
3757 .radius(6.0)
3758 .border(1.0, Color::hex(0x2a2d3aff))
3759 .clip()
3760 .scroll_y()
3761 .scroll_x()
3762 .float(
3763 FloatConfig::viewport()
3764 .inside(Align::End, Align::End)
3765 .offset(-8.0, -8.0)
3766 .fit(),
3767 )
3768 .hoverable()
3769 .window_button(WindowButton::Close)
3770 .on_click(Value::map([("kind", "hit".into())]))
3771 .on_drag("d")
3772 .on_key(Value::Int(7))
3773 .key_up()
3774 .modal("dlg")
3775 .initial_focus()
3776 .on_context_menu(Value::map([("kind", "menu".into())]));
3777 assert_eq!(p.spec, expected);
3778 assert_eq!(p.key.as_deref(), Some("panel"));
3779 assert!(p.key_focus);
3780 }
3781
3782 #[test]
3785 fn a_min_is_a_number_or_fit() {
3786 let lua = Lua::new();
3787 let t = eval_table(&lua, r#"{ min_width = "fit", min_height = 3 }"#);
3788 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3789 let expected = NodeSpec::column()
3790 .min_width(kui_core::Min::FIT)
3791 .min_height(3.0);
3792 assert_eq!(p.spec, expected);
3793 let t = eval_table(&lua, r#"{ min_width = "grow" }"#);
3794 let err = parse_props(&t, false, &mut test_refs())
3795 .unwrap_err()
3796 .to_string();
3797 assert!(err.contains("bad min"), "{err}");
3798 }
3799
3800 #[test]
3804 fn the_lua_composites_resolve_the_way_the_core_says() {
3805 let lua = Lua::new();
3806 let t = eval_table(
3807 &lua,
3808 r#"{ pad = { all = 4, x = 10, b = 1 },
3809 float = { anchor = "below", dx = 6 } }"#,
3810 );
3811 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3812 assert_eq!(
3813 p.spec.layout.padding,
3814 Edges {
3815 l: 10.0,
3816 r: 10.0,
3817 t: 4.0,
3818 b: 1.0,
3819 }
3820 );
3821 assert_eq!(
3822 p.spec.layout.float,
3823 Some(FloatConfig::build(
3824 FloatConfig::below(),
3825 None,
3826 None,
3827 Some(6.0),
3828 None,
3829 false,
3830 false
3831 ))
3832 );
3833
3834 let by_self = eval_table(&lua, r#"{ float = { self = {"end", "start"} } }"#);
3836 let by_self_at = eval_table(&lua, r#"{ float = { self_at = {"end", "start"} } }"#);
3837 assert_eq!(
3838 parse_props(&by_self, false, &mut test_refs())
3839 .unwrap()
3840 .spec
3841 .layout
3842 .float,
3843 parse_props(&by_self_at, false, &mut test_refs())
3844 .unwrap()
3845 .spec
3846 .layout
3847 .float
3848 );
3849
3850 let bad_preset = eval_table(&lua, r#"{ float = "beneath" }"#);
3853 let e = parse_props(&bad_preset, false, &mut test_refs())
3854 .unwrap_err()
3855 .to_string();
3856 assert!(e.contains("beneath") && e.contains("below"), "{e}");
3857 }
3858
3859 #[test]
3860 fn text_style_props_match_the_rust_builder() {
3861 let lua = Lua::new();
3862 let t = eval_table(
3863 &lua,
3864 r#"{ size = 20, line_height = 30, color = 0x73d98cff, family = "mono" }"#,
3865 );
3866 let style = parse_props(&t, false, &mut test_refs()).unwrap().style;
3867 assert_eq!(
3868 style,
3869 TextStyle::new(20.0)
3870 .line_height(30.0)
3871 .color(Color::hex(0x73d98cff))
3872 .family(FontFamily::Mono)
3873 );
3874 let t = eval_table(&lua, r#"{ wrap = "none", max_lines = 2, ellipsis = true }"#);
3875 assert_eq!(
3876 parse_props(&t, false, &mut test_refs()).unwrap().style,
3877 TextStyle::default().nowrap().max_lines(2).ellipsis()
3878 );
3879 let t = eval_table(&lua, r##"{ color = "#fff", size = 12 }"##);
3882 assert_eq!(
3883 parse_props(&t, false, &mut test_refs()).unwrap().style,
3884 TextStyle::new(12.0).color(Color::hex(0xffffffff))
3885 );
3886 }
3887
3888 #[test]
3889 fn bad_values_name_the_prop() {
3890 let lua = Lua::new();
3891 let t = eval_table(&lua, r#"{ main_align = "middle" }"#);
3892 let e = parse_props(&t, false, &mut test_refs())
3893 .unwrap_err()
3894 .to_string();
3895 assert!(e.contains("main_align"), "{e}");
3896 assert!(e.contains("middle"), "{e}");
3897 }
3898
3899 fn frame(core: &mut Core, ext: &mut LuaExtension) -> usize {
3900 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3901 ui.set_origin(OriginId(1));
3902 ext.view(&Slot::root(), &mut ui).unwrap();
3903 ui.finish();
3904 core.output().0.quads.len()
3905 }
3906
3907 #[test]
3908 fn script_view_builds_ir_nodes() {
3909 let mut ext = LuaExtension::from_source(
3910 "test",
3911 r#"
3912 count = 41
3913 function view()
3914 return column { gap = 8, pad = 16, bg = 0x10121aff,
3915 text("count: " .. count, { size = 20 }),
3916 button { label = "bump", on_click = { kind = "bump" } },
3917 }
3918 end
3919 function on_event(ev)
3920 if ev.kind == "bump" then count = count + 1 end
3921 end
3922 "#,
3923 )
3924 .unwrap();
3925
3926 let mut core = Core::new();
3927 let quads = frame(&mut core, &mut ext);
3928 assert!(quads > 10, "expected panel/button/glyph quads, got {quads}");
3930
3931 ext.on_event(&UiEvent {
3933 origin: OriginId(1),
3934 key: Key::ROOT,
3935 payload: Value::map([("kind", "bump".into())]),
3936 window: WindowId::MAIN,
3937 slot: None,
3938 });
3939 let count: i64 = ext.lua.globals().get("count").unwrap();
3940 assert_eq!(count, 42);
3941 }
3942
3943 #[test]
3949 fn a_devtools_tab_calls_its_view_only_while_on_show() {
3950 let mut ext = LuaExtension::from_source(
3951 "test",
3952 r#"
3953 calls = 0
3954 function view(env)
3955 return column { gap = 8,
3956 text("app"),
3957 devtools_tab { name = "syntax", label = "Tree-sitter", view = function()
3958 calls = calls + 1
3959 return column { text("from lua"),
3960 button { label = "jump", on_click = { kind = "jump" } } }
3961 end },
3962 devtools_tab { name = "plug", label = "Plugin", slot = "ts/panel" },
3963 devtools_tab { name = "bad", label = "Bad" },
3964 }
3965 end
3966 "#,
3967 )
3968 .unwrap();
3969 let mut core = Core::new();
3970 core.set_devtools(true);
3971 core.set_devtools_dock(kui_core::DevtoolsDock::Right);
3972 core.set_inspect(true);
3973 frame(&mut core, &mut ext);
3974 frame(&mut core, &mut ext);
3975 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3976 assert_eq!(calls, 0, "not on show: the view was not called");
3977 let ws = core.take_warnings();
3978 assert_eq!(
3979 ws.iter()
3980 .filter(|w| w.code == kui_core::diag::BAD_DEVTOOLS_TAB)
3981 .count(),
3982 1,
3983 "the tab with neither form warns once: {ws:?}"
3984 );
3985 let chord = || {
3988 kui_core::InputEvent::KeyDown(kui_core::KeyPress::new(
3989 kui_core::KeyCode::Char('N'),
3990 kui_core::KeyMods::NONE.with_ctrl().with_shift(),
3991 ))
3992 };
3993 core.handle_input(chord());
3994 core.handle_input(chord());
3995 frame(&mut core, &mut ext);
3996 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3997 assert_eq!(calls, 1, "on show: called once a frame");
3998 frame(&mut core, &mut ext);
3999 let body = core
4000 .nodes()
4001 .iter()
4002 .find(|n| n.label.as_deref() == Some("kui-devtools/tab/syntax"))
4003 .map(|n| n.rect)
4004 .expect("the body");
4005 let jump = core
4006 .nodes()
4007 .iter()
4008 .find(|n| n.label.as_deref() == Some("jump"))
4009 .map(|n| n.rect)
4010 .expect("the script's button");
4011 assert!(
4012 jump.x >= body.x && jump.x + jump.w <= body.x + body.w,
4013 "{jump:?} in {body:?}"
4014 );
4015 assert!(
4016 core.nodes()
4017 .iter()
4018 .any(|n| n.text.as_deref() == Some("from lua")),
4019 "the script's text painted"
4020 );
4021 let evs = kui_core::testing::click_at(&mut core, jump.x + 2.0, jump.y + 2.0);
4022 assert_eq!(evs.len(), 1);
4023 assert_eq!(evs[0].origin, OriginId(1), "the script's own event");
4024 assert_eq!(evs[0].kind(), Some("jump"));
4025 }
4026
4027 #[test]
4032 fn the_root_table_declares_windows() {
4033 use kui_core::{WindowCommand, WindowConfig};
4034 let mut ext = LuaExtension::from_source(
4035 "windows",
4036 r#"
4037 function view(env)
4038 return column {
4039 windows = { { name = "palette", width = 400, height = 300,
4040 activates = false }, "tools" },
4041 text("main"),
4042 }
4043 end
4044 "#,
4045 )
4046 .unwrap();
4047 let mut core = Core::new();
4048 frame(&mut core, &mut ext);
4049 let cmds = core.take_window_commands();
4050 assert_eq!(cmds.len(), 2, "{cmds:?}");
4051 assert_eq!(
4052 cmds[0],
4053 WindowCommand::Open {
4054 id: WindowId(1),
4055 owner: WindowId::MAIN,
4056 origin: OriginId(1),
4057 config: WindowConfig {
4058 size: Size::new(400.0, 300.0),
4059 activates: false,
4060 ..WindowConfig::default()
4061 },
4062 }
4063 );
4064 assert_eq!(
4065 cmds[1],
4066 WindowCommand::Open {
4067 id: WindowId(2),
4068 owner: WindowId::MAIN,
4069 origin: OriginId(1),
4070 config: WindowConfig::default(),
4071 }
4072 );
4073 frame(&mut core, &mut ext);
4075 assert!(core.take_window_commands().is_empty());
4076 assert!(core.take_warnings().is_empty());
4077 }
4078
4079 #[test]
4083 fn a_windows_entry_declares_a_popup() {
4084 use kui_core::{Rect, WindowCommand, WindowConfig, WindowKind};
4085 let mut ext = LuaExtension::from_source(
4086 "windows-popup",
4087 r#"
4088 function view(env)
4089 return column {
4090 windows = { { name = "menu", kind = "popup",
4091 width = 160, height = 320,
4092 anchor = { x = 12, y = 40, w = 160, h = 24 } } },
4093 text("main"),
4094 }
4095 end
4096 "#,
4097 )
4098 .unwrap();
4099 let mut core = Core::new();
4100 frame(&mut core, &mut ext);
4101 assert_eq!(
4102 core.take_window_commands(),
4103 vec![WindowCommand::Open {
4104 id: WindowId(1),
4105 owner: WindowId::MAIN,
4106 origin: OriginId(1),
4107 config: WindowConfig {
4108 kind: WindowKind::Popup,
4109 size: Size::new(160.0, 320.0),
4110 activates: false,
4111 anchor: Rect::new(12.0, 40.0, 160.0, 24.0),
4112 },
4113 }]
4114 );
4115 }
4116
4117 #[test]
4122 fn a_windows_entry_cannot_name_an_unknown_kind() {
4123 let mut ext = LuaExtension::from_source(
4124 "windows-kind",
4125 r#"
4126 function view(env)
4127 return column {
4128 windows = { { name = "palette", kind = "sheet" } },
4129 text("main"),
4130 }
4131 end
4132 "#,
4133 )
4134 .unwrap();
4135 let mut core = Core::new();
4136 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
4137 ui.set_origin(OriginId(1));
4138 let err = ext.view(&Slot::root(), &mut ui).unwrap_err();
4139 assert!(err.contains("sheet"), "{err}");
4140 assert!(err.contains("popup"), "{err}");
4141 }
4142
4143 #[test]
4147 fn a_button_takes_an_index_and_a_cursor_shape_is_refused() {
4148 let mut ext = LuaExtension::from_source(
4149 "rows",
4150 r#"
4151 from = 0
4152 function view(env)
4153 return column {
4154 button { label = "Open", index = from, on_click = { row = from } },
4155 button { label = "Open", index = from + 1, on_click = { row = from + 1 } },
4156 button { label = "Keyed", key = "named", index = 7, on_click = "k" },
4157 }
4158 end
4159 "#,
4160 )
4161 .unwrap();
4162 let mut core = Core::new();
4163 core.set_diagnostics(true);
4164 let frame = |core: &mut Core, ext: &mut LuaExtension| {
4165 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4166 ui.set_origin(OriginId(1));
4167 ext.view(&Slot::root(), &mut ui).unwrap();
4168 ui.finish();
4169 };
4170 frame(&mut core, &mut ext);
4171 assert!(core.take_warnings().is_empty(), "index is a button row");
4172 let buttons = |core: &mut Core| -> Vec<Key> {
4173 core.access_tree()
4174 .nodes
4175 .iter()
4176 .filter(|n| n.role == kui_core::Role::Button)
4177 .map(|n| n.key)
4178 .collect()
4179 };
4180 let before = buttons(&mut core);
4181 assert_eq!(before.len(), 3, "two rows with the same text are two nodes");
4182 assert!(
4183 core.key_of("named").is_none(),
4184 "declared beside `key`, the index wins"
4185 );
4186 ext.lua.globals().set("from", 1).unwrap();
4187 frame(&mut core, &mut ext);
4188 let after = buttons(&mut core);
4189 assert_eq!(
4190 after[0], before[1],
4191 "row 1 keeps its key as it moves up the list"
4192 );
4193
4194 let mut ext = LuaExtension::from_source(
4195 "term",
4196 r#"
4197 function view(env)
4198 return column { cells { key = "term", rows = 1, cols = 4, size = 14,
4199 lines = { "abcd" }, cursor_at = { 1, 1 }, cursor_shape = "blob" } }
4200 end
4201 "#,
4202 )
4203 .unwrap();
4204 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4205 ui.set_origin(OriginId(1));
4206 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4207 assert!(
4208 err.contains("block | bar | underline") && err.contains("blob"),
4209 "{err}"
4210 );
4211 }
4212
4213 #[test]
4216 fn option_as_alt_is_a_side_by_name() {
4217 let mut core = Core::new();
4218 let mut ext = LuaExtension::from_source(
4219 "keys",
4220 r#"
4221 function view(env)
4222 return column { option_as_alt = "left", text("x") }
4223 end
4224 "#,
4225 )
4226 .unwrap();
4227 frame(&mut core, &mut ext);
4228 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
4229
4230 let mut ext = LuaExtension::from_source(
4231 "keys",
4232 r#"
4233 function view(env)
4234 return column { option_as_alt = "meta", text("x") }
4235 end
4236 "#,
4237 )
4238 .unwrap();
4239 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4240 ui.set_origin(OriginId(1));
4241 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4242 assert!(
4243 err.contains("option_as_alt") && err.contains("\"both\"") && err.contains("meta"),
4244 "{err}"
4245 );
4246 }
4247
4248 #[test]
4252 fn a_value_that_holds_itself_or_nests_too_deep_is_refused() {
4253 let lua = Lua::new();
4254 let read = |src: &str| lua_to_value(&lua.load(src).eval::<mlua::Value>().unwrap());
4255 let err = |src: &str| read(src).unwrap_err().to_string();
4256 assert!(err("local t = {}; t[1] = t; return t").contains("holds itself"));
4257 assert!(err("local t = {}; t.me = { t }; return t").contains("holds itself"));
4258 let shared = read("local l = { 1, 2 }; return { a = l, b = l }").unwrap();
4259 assert_eq!(shared.get("a"), shared.get("b"));
4260 let nested = |n: usize| format!("local t = 1; for _ = 1, {n} do t = {{ t }} end; return t");
4261 assert!(read(&nested(MAX_VALUE_DEPTH)).is_ok());
4262 assert!(err(&nested(MAX_VALUE_DEPTH + 1)).contains("nested past 64"));
4263 assert!(err(&nested(100_000)).contains("nested past 64"));
4265 }
4266
4267 #[test]
4271 fn a_view_that_holds_itself_or_nests_too_deep_is_refused() {
4272 let run = |body: &str| {
4273 let mut core = Core::new();
4274 let mut ext =
4275 LuaExtension::from_source("deep", &format!("function view(env)\n{body}\nend"))
4276 .unwrap();
4277 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4278 ui.set_origin(OriginId(1));
4279 let built = ext.view(&Slot::root(), &mut ui);
4280 ui.finish();
4281 built
4282 };
4283 let err = run("local t = column {}; t[1] = row { t }; return t").unwrap_err();
4284 assert!(err.contains("holds itself"), "{err}");
4285 let nested = |n: usize| {
4286 format!(
4287 "local t = text('x')
4288 for i = 2, {n} do
4289 if i % 2 == 0 then t = radio_group {{ label = 'g', t }}
4290 elseif i % 3 == 0 then t = tooltip {{ t }}
4291 else t = column {{ t }} end
4292 end
4293 return t"
4294 )
4295 };
4296 run(&nested(MAX_VIEW_DEPTH)).unwrap();
4297 let err = run(&nested(MAX_VIEW_DEPTH + 1)).unwrap_err();
4298 assert!(err.contains("nested past 128"), "{err}");
4299 run(&nested(8)).unwrap();
4301 }
4302
4303 #[test]
4307 fn option_as_alt_none_leaves_the_hosts_side() {
4308 let mut core = Core::new();
4309 let mut ext = LuaExtension::from_source(
4310 "keys",
4311 r#"
4312 function view(env)
4313 return column { option_as_alt = "none", text("x") }
4314 end
4315 "#,
4316 )
4317 .unwrap();
4318 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4319 ui.option_as_alt(kui_core::OptionAsAlt::Left);
4320 ui.set_origin(OriginId(1));
4321 ext.view(&Slot::root(), &mut ui).unwrap();
4322 ui.finish();
4323 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
4324 }
4325
4326 #[test]
4328 fn every_node_type_lowers() {
4329 let mut ext = LuaExtension::from_source(
4330 "all",
4331 r##"
4332 function view(env)
4333 return column { gap = 4, window_title = "all nodes", always_on_top = true,
4334 secure_input = true, option_as_alt = "right", ime_off = true,
4335 titlebar { text("custom title"), window_buttons() },
4336 titlebar { title = "plain title" },
4337 text({ "same IR as ", { "Rust", bold = true, color = "#73d98c" },
4338 { " — flatter", italic = true } }, { size = 13 }),
4339 edit { key = "note", initial = "hello", size = 14, width = 200,
4340 multiline = true },
4341 input { label = "name", initial = "" },
4342 dropdown { label = "language", options = { "English", "Deutsch" }, current = 1 },
4343 row { tooltip = "hover hint", pad = 4, text("badge") },
4344 row { hoverable = true, text("legend"), tooltip("always shown") },
4345 row { text("rich tip"), tooltip { text("a"), text("b") } },
4346 latency_graph(),
4347 latency_hud { at = { "start", "end" } },
4348 button { label = "ok", on_click = "ok" },
4349 }
4350 end
4351 "##,
4352 )
4353 .unwrap();
4354 let mut core = Core::new();
4355 let quads = frame(&mut core, &mut ext);
4356 assert!(quads > 60, "got {quads} quads");
4357 assert_eq!(core.window_title(), Some("all nodes"));
4358 assert!(core.always_on_top());
4359 assert!(core.secure_input());
4360 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Right);
4361 assert!(core.ime_off());
4362 let unknown: Vec<String> = core
4366 .take_warnings()
4367 .into_iter()
4368 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4369 .map(|w| w.message)
4370 .collect();
4371 assert!(unknown.is_empty(), "{unknown:#?}");
4372 }
4373
4374 #[test]
4377 fn unknown_props_warn_once_in_lua_spelling() {
4378 let mut ext = LuaExtension::from_source(
4379 "typos",
4380 r#"
4381 function view(env)
4382 return column { pad = 8,
4383 row { hoverBg = 0x333333ff, width = 10, height = 10 },
4384 row { hoverBg = 0x333333ff, width = 10, height = 10 },
4385 row { colour = 0x333333ff, width = 10, height = 10 },
4386 }
4387 end
4388 "#,
4389 )
4390 .unwrap();
4391 let mut core = Core::new();
4392 frame(&mut core, &mut ext);
4393 let mut warned: Vec<String> = core
4394 .take_warnings()
4395 .into_iter()
4396 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4397 .map(|w| w.message)
4398 .collect();
4399 warned.sort();
4400 assert_eq!(warned.len(), 2, "one per name, not per node: {warned:#?}");
4401 assert!(
4402 warned[1].contains("`hoverBg` is not a prop of box")
4403 && warned[1].contains("did you mean `hover_bg`?"),
4404 "{warned:#?}"
4405 );
4406 assert!(warned[0].contains("`colour`") && !warned[0].contains("did you mean"));
4408 frame(&mut core, &mut ext);
4410 assert!(core.take_warnings().is_empty());
4411 }
4412
4413 #[test]
4417 fn a_text_warns_about_the_rows_it_does_not_read() {
4418 let mut ext = LuaExtension::from_source(
4419 "textrows",
4420 r#"
4421 function view(env)
4422 return column { pad = 8,
4423 text("hi", { live = "polite", label = "x", on_click = "go", size = 14, line_height = 20, max_lines = 2 }),
4424 text({ "a", { "b", bold = true, bg = 0x00ff00ff } }, { color = 0xff0000ff }),
4425 }
4426 end
4427 "#,
4428 )
4429 .unwrap();
4430 let mut core = Core::new();
4431 frame(&mut core, &mut ext);
4432 let mut warned: Vec<String> = core
4433 .take_warnings()
4434 .into_iter()
4435 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4436 .map(|w| w.message)
4437 .collect();
4438 warned.sort();
4439 assert_eq!(warned.len(), 3, "{warned:#?}");
4440 for (w, name) in warned.iter().zip(["label", "live", "on_click"]) {
4441 assert!(
4442 w.contains(&format!("`{name}`")) && w.contains("not one text reads"),
4443 "{w}"
4444 );
4445 assert!(w.contains("`max_lines`"), "names the rows it reads: {w}");
4446 }
4447 }
4448
4449 #[test]
4456 fn size_and_radius_land_first_whatever_the_order() {
4457 let lua = Lua::new();
4458 for src in [
4459 "return { radius = 8, radius_tl = 2, size = 20, color = 0xff0000ff, line_height = 30 }",
4460 "return { line_height = 30, color = 0xff0000ff, size = 20, radius_tl = 2, radius = 8 }",
4461 ] {
4462 let t = eval_table(&lua, src);
4463 let p = parse_props(&t, false, &mut test_refs()).unwrap();
4464 assert_eq!(p.spec.style.radius, [2.0, 8.0, 8.0, 8.0], "{src}");
4465 assert_eq!(p.style.size, 20.0, "{src}");
4466 assert_eq!(p.style.line_height, 30.0, "the row over size's own: {src}");
4467 assert_eq!(p.style.color, Some(Color::hex(0xff0000ff)), "{src}");
4468 }
4469 }
4470
4471 #[test]
4476 fn the_folded_check_warns_as_the_walk_did() {
4477 let mut ext = LuaExtension::from_source(
4478 "folded",
4479 r#"
4480 function view(env)
4481 return column { bogus_box = 1,
4482 row { bogus_row = 1 },
4483 grid { bogus_grid = 1 },
4484 text("t", { bogus_text = 1, on_click = "go" }),
4485 image { id = 0, bogus_image = 1 },
4486 }
4487 end
4488 "#,
4489 )
4490 .unwrap();
4491 let mut core = Core::new();
4492 frame(&mut core, &mut ext);
4493 let mut warned: Vec<String> = core
4494 .take_warnings()
4495 .into_iter()
4496 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
4497 .map(|w| {
4498 [
4499 "bogus_box",
4500 "bogus_row",
4501 "bogus_grid",
4502 "bogus_text",
4503 "on_click",
4504 "bogus_image",
4505 ]
4506 .into_iter()
4507 .find(|k| w.message.contains(&format!("`{k}`")))
4508 .unwrap_or("?")
4509 .to_string()
4510 })
4511 .collect();
4512 warned.sort();
4513 assert_eq!(
4514 warned,
4515 [
4516 "bogus_box",
4517 "bogus_grid",
4518 "bogus_image",
4519 "bogus_row",
4520 "bogus_text",
4521 "on_click"
4522 ]
4523 );
4524 let mut core = Core::new();
4526 core.set_diagnostics(false);
4527 frame(&mut core, &mut ext);
4528 assert!(core.take_warnings().is_empty());
4529 }
4530
4531 #[test]
4534 fn the_repeat_alias_does_not_warn() {
4535 let mut ext = LuaExtension::from_source(
4536 "alias",
4537 r#"
4538 function view(env)
4539 return column {
4540 row { width = 10, height = 10, keyframes = { { bg = 0x000000ff } },
4541 transition = 100, direction = "alternate" },
4542 }
4543 end
4544 "#,
4545 )
4546 .unwrap();
4547 let mut core = Core::new();
4548 frame(&mut core, &mut ext);
4549 assert!(core.take_warnings().is_empty());
4550 }
4551
4552 #[test]
4554 fn unknown_props_stay_quiet_with_diagnostics_off() {
4555 let mut ext = LuaExtension::from_source(
4556 "quiet",
4557 r#"
4558 function view(env)
4559 return column { hoverBg = 0x333333ff }
4560 end
4561 "#,
4562 )
4563 .unwrap();
4564 let mut core = Core::new();
4565 core.set_diagnostics(false);
4566 frame(&mut core, &mut ext);
4567 assert!(core.take_warnings().is_empty());
4568 }
4569
4570 #[test]
4573 fn tooltip_prop_is_hover_gated() {
4574 let mut ext = LuaExtension::from_source(
4575 "tip",
4576 r#"
4577 function view(env)
4578 return column { pad = 10,
4579 row { width = 100, height = 40, bg = 0x333333ff, tooltip = "a long hint" },
4580 }
4581 end
4582 "#,
4583 )
4584 .unwrap();
4585 let mut core = Core::new();
4586 let idle = frame(&mut core, &mut ext);
4587 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
4588 let hovered = frame(&mut core, &mut ext);
4589 assert!(
4590 hovered > idle + 5,
4591 "hover should add the tooltip's quads ({idle} -> {hovered})"
4592 );
4593 core.handle_input(InputEvent::CursorLeft);
4594 assert_eq!(frame(&mut core, &mut ext), idle);
4595 }
4596
4597 #[test]
4603 fn the_stock_button_admits_the_access_rows() {
4604 let mut ext = LuaExtension::from_source(
4605 "button",
4606 r#"
4607 function view(env)
4608 return column { pad = 10, gap = 4,
4609 button { label = "go", on_click = "go", description = "Starts the run" },
4610 button { label = "Stop the run", text = "stop", on_click = "stop",
4611 disabled = true, tooltip = "Nothing is running" },
4612 button { label = "x", on_click = "x", radius = 12, hoverBg = 0x333333ff },
4613 }
4614 end
4615 "#,
4616 )
4617 .unwrap();
4618 let mut core = Core::new();
4619 frame(&mut core, &mut ext);
4620 let tree = core.access_tree().clone();
4621 let named = |n: &str| {
4622 tree.nodes
4623 .iter()
4624 .find(|node| node.name.as_deref() == Some(n))
4625 };
4626 let go = named("go").expect("the button, named by its label");
4627 assert_eq!(go.role, kui_core::Role::Button);
4628 assert_eq!(go.description.as_deref(), Some("Starts the run"));
4629 assert!(!go.disabled);
4630 let stop = named("Stop the run").expect("named past its text");
4631 assert_eq!(stop.description.as_deref(), Some("Nothing is running"));
4632 assert!(stop.disabled);
4633 assert_eq!(tree.nodes.len(), 4, "window and three buttons");
4634 let ws = core.take_warnings();
4635 assert_eq!(ws.len(), 2, "{ws:?}");
4636 let radius = ws
4637 .iter()
4638 .find(|w| w.message.contains("`radius`"))
4639 .expect("a row the button does not read");
4640 assert!(
4641 radius
4642 .message
4643 .contains("is a prop, but not one button reads")
4644 );
4645 assert!(radius.message.contains("`description`"));
4646 let hover = ws
4649 .iter()
4650 .find(|w| w.message.contains("hoverBg"))
4651 .expect("a misspelling");
4652 assert!(hover.message.contains("is not a prop of button"));
4653 assert!(!hover.message.contains("did you mean"));
4654 }
4655
4656 #[test]
4661 fn an_accent_button_takes_the_colour_the_host_pushed() {
4662 let mut ext = LuaExtension::from_source(
4663 "accent",
4664 r#"
4665 function view(env)
4666 return column { pad = 10,
4667 button { label = "go", on_click = "go", accent = true },
4668 }
4669 end
4670 "#,
4671 )
4672 .unwrap();
4673 let mut bg = |core: &mut Core| {
4674 frame(core, &mut ext);
4675 core.output().0.quads[0].color
4676 };
4677 let mut core = Core::new();
4678 assert_eq!(
4679 bg(&mut core),
4680 kui_core::Color::rgb8(0x3b, 0x5b, 0xd4),
4681 "no accent pushed, the stock button"
4682 );
4683 assert!(
4684 core.take_warnings().is_empty(),
4685 "and a row the button reads"
4686 );
4687
4688 let accent = kui_core::Color::hex(0x007affff);
4689 core.env.system.accent = Some(accent);
4690 assert_eq!(bg(&mut core), accent);
4691 }
4692
4693 #[test]
4697 fn semantics_reach_the_access_tree() {
4698 let mut ext = LuaExtension::from_source(
4699 "a11y",
4700 r#"
4701 function view(env)
4702 return column { pad = 10,
4703 row { key = "save", on_click = "save", label = "Save", width = 20, height = 20 },
4704 row { key = "check", role = "checkbox", checked = true, text("Remember") },
4705 row { key = "vol", role = "slider", label = "Volume",
4706 value_now = 3, value_min = 0, value_max = 10 },
4707 row { key = "art", role = "none", on_click = "art", text("Art") },
4708 row { key = "tip", tooltip = "more here", on_click = "t", text("Tip") },
4709 row { key = "tabs", role = "tabList",
4710 row { key = "t0", role = "tab", text("General") },
4711 row { key = "t1", role = "tab", selected = true, text("Network") },
4712 },
4713 row { key = "adv", on_click = "adv", expanded = "collapsed",
4714 label = "Advanced" },
4715 }
4716 end
4717 "#,
4718 )
4719 .unwrap();
4720 let mut core = Core::new();
4721 frame(&mut core, &mut ext);
4722 let tree = core.access_tree().clone();
4723 let named = |n: &str| {
4724 tree.nodes
4725 .iter()
4726 .find(|node| node.name.as_deref() == Some(n))
4727 .cloned()
4728 };
4729 let save = named("Save").expect("labelled button");
4730 assert_eq!(save.role, kui_core::Role::Button);
4731 assert_eq!(save.origin, OriginId(1));
4732 let check = named("Remember").expect("checkbox named by its text");
4733 assert_eq!(check.role, kui_core::Role::Checkbox);
4734 assert_eq!(check.checked, Some(true));
4735 let vol = named("Volume").expect("slider");
4736 assert_eq!(
4737 (vol.number, vol.min, vol.max),
4738 (Some(3.0), Some(0.0), Some(10.0))
4739 );
4740 assert!(named("Art").is_none(), "role none hides a would-be button");
4741 let tip = named("Tip").expect("button");
4742 assert_eq!(tip.description.as_deref(), Some("more here"));
4743 let (t0, t1) = (named("General").unwrap(), named("Network").unwrap());
4746 assert_eq!((t0.selected, t1.selected), (Some(false), Some(true)));
4747 assert_eq!((t0.pos_in_set, t1.pos_in_set), (Some(0), Some(1)));
4748 let tabs = tree
4749 .nodes
4750 .iter()
4751 .find(|n| n.role == kui_core::Role::TabList)
4752 .expect("tab list");
4753 assert_eq!(tabs.set_size, Some(2));
4754 let adv = named("Advanced").expect("disclosure");
4756 assert_eq!(adv.expanded, Some(false));
4757
4758 let evs = core.handle_input(InputEvent::Access(kui_core::AccessRequest {
4759 key: save.key,
4760 action: kui_core::AccessAction::Click,
4761 value: None,
4762 anchor: None,
4763 focus: None,
4764 }));
4765 assert_eq!(evs.len(), 1);
4766 assert_eq!(evs[0].origin, OriginId(1));
4767 assert_eq!(evs[0].payload.as_str(), Some("save"));
4768 }
4769
4770 #[test]
4774 fn a_custom_editor_in_lua_reaches_the_access_tree() {
4775 let mut ext = LuaExtension::from_source(
4776 "ed",
4777 r#"
4778 function view(env)
4779 return column { key = "ed", role = "multilineTextInput", label = "Doc",
4780 on_key = "keys",
4781 row { role = "line", selection_anchor = 1, text("ab"), text("cd") },
4782 row { role = "line", caret = 2, text("ef") },
4783 }
4784 end
4785 "#,
4786 )
4787 .unwrap();
4788 let mut core = Core::new();
4789 frame(&mut core, &mut ext);
4790 let tree = core.access_tree().clone();
4791 let ed = tree
4792 .nodes
4793 .iter()
4794 .find(|n| n.name.as_deref() == Some("Doc"))
4795 .expect("the sink is the editor")
4796 .clone();
4797 assert_eq!(ed.role, kui_core::Role::MultilineTextInput);
4798 assert_eq!(ed.value.as_deref(), Some("abcd\nef"));
4799 assert_eq!(ed.runs.len(), 3);
4800 assert_eq!(ed.runs[1].text, "cd\n");
4801 assert_eq!(ed.caret, Some(7));
4802 assert_eq!(ed.selection, Some((1, 7)));
4803 let (a, f) = (ed.anchor.unwrap(), ed.focus.unwrap());
4804 assert_eq!((a.run, a.character), (ed.runs[0].key, 1));
4805 assert_eq!((f.run, f.character), (ed.runs[2].key, 2));
4806
4807 let evs = core.handle_input(InputEvent::Access(
4808 kui_core::AccessRequest::new(ed.key, kui_core::AccessAction::SetTextSelection)
4809 .with_selection(
4810 kui_core::TextPos {
4811 run: ed.runs[1].key,
4812 character: 1,
4813 },
4814 kui_core::TextPos {
4815 run: ed.runs[2].key,
4816 character: 0,
4817 },
4818 ),
4819 ));
4820 assert_eq!(evs.len(), 1);
4821 assert_eq!(evs[0].origin, OriginId(1));
4822 let p = &evs[0].payload;
4823 assert_eq!(p.get_str("action"), Some("setTextSelection"));
4824 let at = |k: &str, f: &str| p.get(k).and_then(|v| v.get(f)).and_then(Value::as_int);
4825 assert_eq!(
4826 (at("anchor", "line"), at("anchor", "offset")),
4827 (Some(0), Some(3))
4828 );
4829 assert_eq!(
4830 (at("focus", "line"), at("focus", "offset")),
4831 (Some(1), Some(0))
4832 );
4833 assert_eq!(p.get_str("tag"), Some("keys"));
4834 }
4835
4836 #[test]
4839 fn edit_text_round_trips_through_events() {
4840 let mut ext = LuaExtension::from_source(
4841 "edit",
4842 r#"
4843 pending = nil
4844 seen = nil
4845 by_label = nil
4846 window = nil
4847 function view(env)
4848 if pending then seen = env.edit_text(pending) end
4849 -- AR26: by the label its `key` declares, like every
4850 -- query beside it; a label nothing declared is nil.
4851 by_label = env.edit_text("note")
4852 nothing = env.edit_text("nope")
4853 return column {
4854 edit { key = "note", initial = "hi", autofocus = true, width = 200 },
4855 }
4856 end
4857 function on_event(ev)
4858 if ev.kind == "changed" then pending = ev.node_key; window = ev.window end
4859 end
4860 "#,
4861 )
4862 .unwrap();
4863 let mut core = Core::new();
4864 frame(&mut core, &mut ext);
4865 let events = core.handle_input(InputEvent::Text("!".into()));
4866 assert_eq!(
4867 events.len(),
4868 1,
4869 "typing into the autofocused editor emits one event"
4870 );
4871 assert_eq!(events[0].kind(), Some("changed"));
4872 for ev in &events {
4873 ext.on_event(ev);
4874 }
4875 frame(&mut core, &mut ext);
4876 let seen: Option<String> = ext.lua.globals().get("seen").unwrap();
4878 assert_eq!(seen.as_deref(), Some("hi!"));
4879 let by_label: Option<String> = ext.lua.globals().get("by_label").unwrap();
4880 assert_eq!(by_label.as_deref(), Some("hi!"), "the same text by label");
4881 let nothing: Option<String> = ext.lua.globals().get("nothing").unwrap();
4882 assert_eq!(nothing, None);
4883 let window: Option<i64> = ext.lua.globals().get("window").unwrap();
4886 assert_eq!(window, Some(0));
4887 }
4888
4889 #[test]
4894 fn a_dropdown_opens_the_cores_menu_and_hears_the_choice() {
4895 let mut ext = LuaExtension::from_source(
4896 "dd",
4897 r#"
4898 current = 1
4899 heard = {}
4900 function view(env)
4901 return column { pad = 10,
4902 dropdown { label = "language",
4903 options = { "English", "Deutsch", { label = "Latin", id = "la" } },
4904 current = current },
4905 }
4906 end
4907 function on_event(ev)
4908 heard[#heard + 1] = ev.kind
4909 if ev.kind == "menu" then
4910 if ev.item == "Deutsch" then current = 2 end
4911 if ev.item == "la" then current = 3 end
4912 end
4913 end
4914 "#,
4915 )
4916 .unwrap();
4917 let mut core = Core::new();
4918 frame(&mut core, &mut ext);
4919 let field = core
4920 .key_of("language")
4921 .expect("the field is keyed by its label");
4922 let node = |core: &mut Core| {
4923 core.access_tree()
4924 .nodes
4925 .iter()
4926 .find(|n| n.key == field)
4927 .cloned()
4928 .unwrap()
4929 };
4930 assert_eq!(node(&mut core).description.as_deref(), Some("English"));
4931 let events = core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4932 field,
4933 kui_core::AccessAction::Click,
4934 )));
4935 assert!(
4936 events.is_empty(),
4937 "the field's click is the core's: {events:?}"
4938 );
4939 let menu = core.menu().expect("the menu opened");
4940 assert_eq!(menu.target, field);
4941 assert_eq!(menu.items.len(), 3);
4942 assert!(menu.items[0].checked);
4943 frame(&mut core, &mut ext);
4944 let events = core.activate_menu_item(2).expect("the row is enabled");
4946 assert_eq!(events.len(), 1);
4947 for ev in &events {
4948 ext.on_event(ev);
4949 }
4950 frame(&mut core, &mut ext);
4951 assert_eq!(node(&mut core).description.as_deref(), Some("Latin"));
4952 let heard: Vec<String> = ext
4953 .lua
4954 .globals()
4955 .get::<Table>("heard")
4956 .unwrap()
4957 .sequence_values()
4958 .collect::<mlua::Result<_>>()
4959 .unwrap();
4960 assert_eq!(heard, vec!["menu".to_string()]);
4961 assert!(core.menu().is_none());
4962 let mut ext = LuaExtension::from_source(
4964 "bad",
4965 r#"function view(env) return dropdown { label = "x" } end"#,
4966 )
4967 .unwrap();
4968 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4969 ui.set_origin(OriginId(1));
4970 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4971 assert!(err.contains("needs options"), "{err}");
4972 ui.finish();
4973 let mut ext = LuaExtension::from_source(
4974 "bad",
4975 r#"function view(env) return dropdown { label = "x", options = { "a" }, current = 0 } end"#,
4976 )
4977 .unwrap();
4978 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4979 ui.set_origin(OriginId(1));
4980 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4981 assert!(err.contains("index from 1"), "{err}");
4982 ui.finish();
4983 let refused = |core: &mut Core, src: &str| {
4986 let mut ext = LuaExtension::from_source("bad", src).unwrap();
4987 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4988 ui.set_origin(OriginId(1));
4989 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4990 ui.finish();
4991 err
4992 };
4993 let err = refused(
4994 &mut core,
4995 r#"function view(env) return dropdown { options = { "a" } } end"#,
4996 );
4997 assert!(err.contains("dropdown needs a label"), "{err}");
4998 let err = refused(
4999 &mut core,
5000 r#"function view(env) return dropdown { label = "x", options = {} } end"#,
5001 );
5002 assert!(err.contains("at least one option"), "{err}");
5003 }
5004
5005 #[test]
5014 fn a_stray_key_inside_a_submenu_warns_and_an_option_takes_no_items() {
5015 let mut ext = LuaExtension::from_source(
5016 "sub",
5017 r#"
5018 function view(env)
5019 return column {
5020 menu_bar { menu = { { label = "View", items = {
5021 { label = "Sort by", items = { { label = "Name", disabled = true } } },
5022 } } } },
5023 dropdown { label = "sort",
5024 options = { "Name", { label = "Date", items = { { label = "Newest" } } } } },
5025 }
5026 end
5027 "#,
5028 )
5029 .unwrap();
5030 let mut core = Core::new();
5031 frame(&mut core, &mut ext);
5032 let warned = core.take_warnings();
5033 let messages: Vec<&str> = warned.iter().map(|w| w.message.as_str()).collect();
5034 assert_eq!(warned.len(), 2, "{messages:?}");
5035 assert!(
5036 messages[0].contains("`disabled` is not a key of a menu item"),
5037 "{messages:?}"
5038 );
5039 assert!(
5040 messages[1].contains("`items` on a select's option is dropped"),
5041 "{messages:?}"
5042 );
5043 let field = core.key_of("sort").unwrap();
5045 core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
5046 field,
5047 kui_core::AccessAction::Click,
5048 )));
5049 let menu = core.menu().expect("the dropdown opened");
5050 assert_eq!(menu.items.len(), 2);
5051 assert!(
5052 menu.items.iter().all(|i| !i.has_submenu()),
5053 "{:?}",
5054 menu.items
5055 );
5056 }
5057
5058 #[test]
5059 fn a_dropdowns_bad_rows_and_current_are_warned_and_a_disabled_option_is_refused() {
5060 let mut ext = LuaExtension::from_source(
5061 "dd",
5062 r#"
5063 current = 4
5064 function view(env)
5065 return column { pad = 10,
5066 dropdown { label = "language",
5067 options = { "English", { role = "separator" },
5068 { label = "Latin", id = "la", disabled = true } },
5069 current = current },
5070 }
5071 end
5072 "#,
5073 )
5074 .unwrap();
5075 let mut core = Core::new();
5076 frame(&mut core, &mut ext);
5077 let field = core.key_of("language").unwrap();
5078 let node = |core: &mut Core| {
5079 core.access_tree()
5080 .nodes
5081 .iter()
5082 .find(|n| n.key == field)
5083 .cloned()
5084 .unwrap()
5085 };
5086 assert_eq!(node(&mut core).description.as_deref(), Some(""));
5087 let warned = core.take_warnings();
5088 let codes: Vec<&str> = warned.iter().map(|w| w.code).collect();
5089 assert_eq!(
5090 codes,
5091 [
5092 kui_core::diag::UNKNOWN_PROP,
5093 kui_core::diag::SELECT_CURRENT_IGNORED
5094 ],
5095 "{warned:?}"
5096 );
5097 assert!(
5098 warned[0]
5099 .message
5100 .contains("`disabled` is not a key of a menu item")
5101 && warned[0].message.contains("`enabled: false`"),
5102 "{}",
5103 warned[0].message
5104 );
5105 assert_eq!(warned[1].key, field);
5106 assert!(
5107 warned[1].message.contains("names option 3 counted from 0")
5108 && warned[1].message.contains("the field has 3 options"),
5109 "{}",
5110 warned[1].message
5111 );
5112 ext.lua.globals().set("current", 2).unwrap();
5114 let mut core = Core::new();
5115 frame(&mut core, &mut ext);
5116 let warned = core.take_warnings();
5117 assert!(
5118 warned
5119 .iter()
5120 .any(|w| w.code == kui_core::diag::SELECT_CURRENT_IGNORED
5121 && w.message
5122 .contains("option 1 counted from 0, which is a separator")),
5123 "{warned:?}"
5124 );
5125 let mut ext = LuaExtension::from_source(
5128 "dd",
5129 r#"
5130 function view(env)
5131 return column { pad = 10,
5132 dropdown { label = "language",
5133 options = { "English", { label = "Latin", id = "la", enabled = false } },
5134 current = 1 },
5135 }
5136 end
5137 "#,
5138 )
5139 .unwrap();
5140 let mut core = Core::new();
5141 frame(&mut core, &mut ext);
5142 let field = core.key_of("language").unwrap();
5143 core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
5144 field,
5145 kui_core::AccessAction::Click,
5146 )));
5147 assert!(core.menu().is_some());
5148 assert_eq!(core.activate_menu_item(1), None, "refused");
5149 assert!(core.menu().is_some(), "the menu stays open");
5150 let events = core
5151 .activate_menu_item(0)
5152 .expect("the enabled row is taken");
5153 assert_eq!(events.len(), 1);
5154 assert!(core.menu().is_none());
5155 assert!(core.take_warnings().is_empty());
5156 }
5157
5158 #[test]
5161 fn a_grid_lines_its_rows_cells_up() {
5162 let mut ext = LuaExtension::from_source(
5163 "grid",
5164 r#"
5165 function view(env)
5166 return grid { key = "t", width = 300,
5167 row { key = "r1", width = "grow", gap = 8,
5168 text("ab", { size = 12 }),
5169 column { key = "b1", width = 10, height = 10, bg = 0xff0000ff },
5170 column { key = "c1", width = "grow", height = 10, bg = 0x00ff00ff },
5171 },
5172 row { key = "r2", width = "grow", gap = 8,
5173 text("abcdef", { size = 12 }),
5174 column { key = "b2", width = 50, height = 10, bg = 0xff0000ff },
5175 column { key = "c2", width = 20, height = 10, bg = 0x00ff00ff },
5176 },
5177 }
5178 end
5179 "#,
5180 )
5181 .unwrap();
5182 let mut core = Core::new();
5183 core.set_inspect(true);
5184 frame(&mut core, &mut ext);
5185 let rect = |core: &mut Core, label: &str| {
5186 let key = core.key_of(label).unwrap_or_else(|| panic!("{label}"));
5187 core.nodes()
5188 .iter()
5189 .find(|n| n.key == key)
5190 .map(|n| n.rect)
5191 .unwrap_or_else(|| panic!("{label}"))
5192 };
5193 let (b1, b2) = (rect(&mut core, "b1"), rect(&mut core, "b2"));
5194 let (c1, c2) = (rect(&mut core, "c1"), rect(&mut core, "c2"));
5195 assert_eq!(
5196 b1.x, b2.x,
5197 "the fixed column starts after the longest label"
5198 );
5199 assert_eq!(b1.w, 50.0, "the fixed column is its widest cell");
5200 assert_eq!(b2.w, 50.0);
5201 assert_eq!(c1.x, c2.x);
5202 assert_eq!(c1.w, c2.w, "the grow column is one width in both rows");
5203 assert_eq!(c1.x + c1.w, 300.0, "and it takes the rest");
5204 let t = core.key_of("t").unwrap();
5205 assert!(
5206 core.nodes().iter().any(|n| n.key == t && n.table),
5207 "the grid is a table"
5208 );
5209 assert!(core.take_warnings().is_empty());
5210 }
5211
5212 #[test]
5217 fn set_edit_text_by_label_seeds_the_editor_the_next_frame_declares() {
5218 let mut ext = LuaExtension::from_source(
5219 "edit",
5220 r#"
5221 frames = 0
5222 function view(env)
5223 frames = frames + 1
5224 if frames == 1 then return column {} end
5225 if frames == 2 then
5226 env.set_edit_text("note", "from the model")
5227 end
5228 return column {
5229 edit { key = "note", initial = "ignored", width = 200 },
5230 }
5231 end
5232 "#,
5233 )
5234 .unwrap();
5235 let mut core = Core::new();
5236 frame(&mut core, &mut ext);
5240 frame(&mut core, &mut ext);
5241 let key = core.key_of("note").expect("the view declared the editor");
5242 assert_eq!(core.edit_text(key).as_deref(), Some("from the model"));
5243 let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
5244 assert!(!codes.contains(&"edit-text-without-editor"), "{codes:?}");
5245 core.set_focus(Some(key));
5248 core.handle_input(InputEvent::Text("!".into()));
5249 frame(&mut core, &mut ext);
5250 assert_eq!(core.edit_text(key).as_deref(), Some("from the model!"));
5251 }
5252
5253 #[test]
5258 fn a_lua_key_sink_hears_press_and_release() {
5259 use kui_core::{KeyCode, KeyMods, KeyPress};
5260 let mut ext = LuaExtension::from_source(
5261 "game",
5262 r#"
5263 log = {}
5264 function view(env)
5265 return column { key = "world", on_key = "keys", key_up = true,
5266 key_focus = true, width = 400, height = 300 }
5267 end
5268 function on_event(ev)
5269 if ev.kind == "key" then
5270 log[#log + 1] = ev.phase .. ":" .. ev.code ..
5271 "@" .. ev.physical ..
5272 ":" .. tostring(ev.text) .. ":" .. tostring(ev.tag)
5273 end
5274 end
5275 "#,
5276 )
5277 .unwrap();
5278 let mut core = Core::new();
5279 frame(&mut core, &mut ext);
5280 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
5281 for e in core.handle_input(ev) {
5282 ext.on_event(&e);
5283 }
5284 };
5285 let w = || KeyPress::new(KeyCode::Char('w'), KeyMods::default()).with_text("w");
5286 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
5287 feed(&mut core, &mut ext, InputEvent::KeyUp(w()));
5288 let ru = || {
5293 KeyPress::from_layout(KeyCode::Char('ц'), KeyCode::Char('w'), KeyMods::default())
5294 .with_text("ц")
5295 };
5296 feed(&mut core, &mut ext, InputEvent::KeyDown(ru()));
5297 feed(&mut core, &mut ext, InputEvent::KeyUp(ru()));
5298 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
5300 core.set_focus(None);
5301 for e in core.take_pending_events() {
5302 ext.on_event(&e);
5303 }
5304 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
5305 assert_eq!(
5306 log,
5307 [
5308 "down:w@w:w:keys",
5309 "up:w@w:nil:keys",
5311 "down:w@w:ц:keys",
5314 "up:w@w:nil:keys",
5315 "down:w@w:w:keys",
5316 "up:w@w:nil:keys",
5317 ]
5318 );
5319 }
5320
5321 #[test]
5327 fn a_path_keeps_the_node_scale_beside_its_own_rotate() {
5328 let mut ext = LuaExtension::from_source(
5329 "p",
5330 r#"
5331 function view(env)
5332 return column {
5333 path { key = "p", d = "M0 0 H10 V10 H0 Z", bg = 0xffffffff,
5334 rotate = 0.25, scale = 2 },
5335 }
5336 end
5337 "#,
5338 )
5339 .unwrap();
5340 let mut core = Core::new();
5341 core.set_inspect(true);
5342 frame(&mut core, &mut ext);
5343 frame(&mut core, &mut ext);
5344 let node = core
5345 .nodes()
5346 .iter()
5347 .find(|n| n.kind == kui_core::NodeKind::Path)
5348 .cloned()
5349 .expect("the path");
5350 assert_eq!(node.scale, 2.0, "the node's scale stays");
5351 assert_eq!(node.rotate, 0.0, "the turn is the path's own");
5352 }
5353
5354 #[test]
5355 fn a_script_reads_the_frame_clock() {
5356 let mut ext = LuaExtension::from_source(
5357 "clock",
5358 r#"
5359 function view(env)
5360 if env.now < 2 then
5361 return column { key = "toast", width = 10, height = 10 }
5362 end
5363 return column {}
5364 end
5365 "#,
5366 )
5367 .unwrap();
5368 let mut core = Core::new();
5369 core.set_time(1.5);
5370 frame(&mut core, &mut ext);
5371 assert!(core.key_of("toast").is_some(), "before the deadline");
5372 core.set_time(2.5);
5373 frame(&mut core, &mut ext);
5374 assert!(core.key_of("toast").is_none(), "after it");
5375 }
5376
5377 #[test]
5380 fn a_script_asks_for_a_frame_at_a_time() {
5381 let mut ext = LuaExtension::from_source(
5382 "toast",
5383 r#"
5384 function view(env)
5385 env.request_frame_at(env.now + 3)
5386 return column { key = "toast", width = 10, height = 10 }
5387 end
5388 "#,
5389 )
5390 .unwrap();
5391 let mut core = Core::new();
5392 core.set_time(1.0);
5393 frame(&mut core, &mut ext);
5394 assert_eq!(core.next_frame_at(), Some(4.0));
5395 assert!(!core.animating());
5396 }
5397
5398 #[test]
5401 fn a_script_names_the_exit_at_the_removal() {
5402 let mut ext = LuaExtension::from_source(
5403 "throw",
5404 r##"
5405 function view(env)
5406 if env.now < 1 then
5407 return column { pad = 20,
5408 column { key = "card", width = 40, height = 40, bg = "#ffffff",
5409 transition = 1000, easing = "linear", exit = { opacity = 0 } } }
5410 end
5411 if env.now < 1.1 then env.exit_with("card", { dx = 400, opacity = 0 }) end
5412 return column { pad = 20 }
5413 end
5414 "##,
5415 )
5416 .unwrap();
5417 let mut core = Core::new();
5418 core.set_time(0.0);
5419 frame(&mut core, &mut ext);
5420 core.set_time(1.0);
5421 frame(&mut core, &mut ext);
5422 core.set_time(1.5);
5423 frame(&mut core, &mut ext);
5424 let xs: Vec<f32> = kui_core::testing::solids(&mut core)
5425 .iter()
5426 .map(|q| q.rect.x)
5427 .collect();
5428 assert!(
5429 xs.iter().any(|x| (x - 220.0).abs() < 1.0),
5430 "halfway along the throw: {xs:?}"
5431 );
5432 }
5433
5434 #[test]
5438 fn a_lua_editor_draws_its_caret_on_the_phase_the_host_sets() {
5439 let mut ext = LuaExtension::from_source(
5440 "ed",
5441 r#"
5442 function view(env)
5443 local caret = env.caret_visible and row { key = "caret", width = 2, height = 16 } or nil
5444 return column { key = "editor", on_key = "ed", key_focus = true,
5445 role = "multilineTextInput", label = "buf",
5446 row { role = "line", caret = 3, text("let value", { family = "mono", size = 14 }), caret },
5447 }
5448 end
5449 "#,
5450 )
5451 .unwrap();
5452 let mut core = Core::new();
5453 frame(&mut core, &mut ext);
5454 assert!(
5455 core.key_of("caret").is_some(),
5456 "the on phase draws the caret"
5457 );
5458 assert!(core.has_caret(), "the caret row is a caret to blink");
5459 core.set_caret_visible(false);
5460 frame(&mut core, &mut ext);
5461 assert!(core.key_of("caret").is_none(), "the off phase draws none");
5462 assert!(
5463 core.has_caret(),
5464 "and the row stays, so the clock stays armed"
5465 );
5466 core.set_caret_visible(true);
5467 frame(&mut core, &mut ext);
5468 assert!(core.key_of("caret").is_some());
5469 }
5470
5471 #[test]
5476 fn a_lua_editors_solid_caret_arms_no_clock() {
5477 let mut ext = LuaExtension::from_source(
5478 "ed",
5479 r#"
5480 function view(env)
5481 return column { key = "editor", on_key = "ed", key_focus = true,
5482 role = "multilineTextInput", label = "buf",
5483 row { role = "line", caret = 3, caret_solid = true,
5484 text("let value", { family = "mono", size = 14 }) },
5485 }
5486 end
5487 "#,
5488 )
5489 .unwrap();
5490 let mut core = Core::new();
5491 frame(&mut core, &mut ext);
5492 assert!(!core.has_caret(), "a solid caret is not a caret to blink");
5493 assert!(core.ime_rect().is_some(), "and still the IME's anchor");
5494 let editor = core.key_of("editor").unwrap();
5495 assert_eq!(core.access_tree().get(editor).unwrap().caret, Some(3));
5496 assert!(
5497 core.warnings_raised().is_empty(),
5498 "{:?}",
5499 core.warnings_raised()
5500 );
5501 }
5502
5503 #[test]
5509 fn a_lua_editor_yanks_pastes_and_hears_where_a_press_landed() {
5510 use kui_core::testing::{press, release};
5511 use kui_core::{KeyCode, KeyMods, KeyPress, MenuAction, Vec2};
5512 let mut ext = LuaExtension::from_source(
5513 "ed",
5514 r#"
5515 yank = nil
5516 paste = false
5517 log = {}
5518 function view(env)
5519 if yank then env.set_clipboard(yank, nil); yank = nil end
5520 if secret then env.set_clipboard_secret(secret); secret = nil end
5521 -- Asked on every view until the answer lands: the core
5522 -- takes one ask at a time (AR34), so this is one paste.
5523 if paste then env.request_paste() end
5524 return column { key = "editor", on_key = "ed", on_drag = "sel",
5525 key_focus = true, role = "multilineTextInput", label = "buf",
5526 width = 400, height = 300,
5527 row { role = "line", height = 20, text("hello world", { family = "mono", size = 14 }) },
5528 row { role = "line", height = 20, text("second", { family = "mono", size = 14 }) },
5529 }
5530 end
5531 function on_event(ev)
5532 if ev.kind == "key" and ev.code == "y" then yank = "hello world" end
5533 if ev.kind == "key" and ev.code == "s" then secret = "hunter2" end
5534 if ev.kind == "key" and ev.code == "p" then paste = true end
5535 if ev.kind == "text" then
5536 paste = false
5537 local marks = (ev.concealed and ":concealed" or "") .. (ev.transient and ":transient" or "")
5538 log[#log + 1] = "text:" .. ev.text .. marks
5539 end
5540 if ev.kind == "drag" then
5541 log[#log + 1] = ev.phase .. ":" .. ev.line .. ":" .. ev.byte .. ":" .. ev.clicks
5542 end
5543 end
5544 "#,
5545 )
5546 .unwrap();
5547 let mut core = Core::new();
5548 frame(&mut core, &mut ext);
5549 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
5550 for e in core.handle_input(ev) {
5551 ext.on_event(&e);
5552 }
5553 };
5554 let key = |c| KeyPress::new(KeyCode::Char(c), KeyMods::default());
5555 feed(&mut core, &mut ext, InputEvent::KeyDown(key('y')));
5556 frame(&mut core, &mut ext);
5557 assert_eq!(
5558 core.take_menu_actions(),
5559 vec![MenuAction::SetClipboard {
5560 text: "hello world".into(),
5561 html: None
5562 }]
5563 );
5564 feed(&mut core, &mut ext, InputEvent::KeyDown(key('s')));
5566 frame(&mut core, &mut ext);
5567 assert_eq!(
5568 core.take_menu_actions(),
5569 vec![MenuAction::SetClipboardSecret {
5570 text: "hunter2".into()
5571 }]
5572 );
5573 feed(&mut core, &mut ext, InputEvent::KeyDown(key('p')));
5574 frame(&mut core, &mut ext);
5575 frame(&mut core, &mut ext);
5576 assert_eq!(
5577 core.take_menu_actions(),
5578 vec![MenuAction::Paste],
5579 "two views asked, one paste queued"
5580 );
5581 assert!(core.awaiting_paste());
5582 feed(
5584 &mut core,
5585 &mut ext,
5586 InputEvent::Commit("from the clipboard".into()),
5587 );
5588 assert!(!core.awaiting_paste());
5589 frame(&mut core, &mut ext);
5590 assert!(
5591 core.take_menu_actions().is_empty(),
5592 "answered: the script stopped asking"
5593 );
5594 feed(
5596 &mut core,
5597 &mut ext,
5598 InputEvent::Paste {
5599 text: "s3cret".into(),
5600 marks: kui_core::ClipboardMarks::SECRET,
5601 },
5602 );
5603 for e in press(&mut core, Vec2::new(390.0, 30.0)) {
5605 ext.on_event(&e);
5606 }
5607 for e in release(&mut core) {
5608 ext.on_event(&e);
5609 }
5610 for e in core.handle_input(InputEvent::mouse_down(2)) {
5611 ext.on_event(&e);
5612 }
5613 for e in release(&mut core) {
5614 ext.on_event(&e);
5615 }
5616 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
5617 assert_eq!(
5618 log,
5619 [
5620 "text:from the clipboard",
5621 "text:s3cret:concealed:transient",
5622 "start:1:6:1",
5623 "end:1:6:1",
5624 "start:1:6:2",
5625 "end:1:6:2",
5626 ]
5627 );
5628 }
5629
5630 #[test]
5634 fn audio_nodes_drive_playback_commands() {
5635 use kui_core::AudioCommand;
5636 let mut ext = LuaExtension::from_source(
5637 "audio",
5638 r#"
5639 playing = true
5640 function view(env)
5641 local items = {}
5642 if playing then
5643 items[1] = audio { src = SOUND, loop = true, volume = 0.5,
5644 key = "music", tag = { kind = "music" } }
5645 end
5646 return column { table.unpack(items) }
5647 end
5648 "#,
5649 )
5650 .unwrap();
5651 let mut core = Core::new();
5652 let sound = core.add_sound(vec![0; 8]);
5653 ext.lua
5654 .globals()
5655 .set("SOUND", sound.to_ffi() as i64)
5656 .unwrap();
5657 frame(&mut core, &mut ext);
5658 let cmds = core.take_audio_commands();
5659 assert!(
5660 matches!(
5661 cmds.as_slice(),
5662 [AudioCommand::Play { sound: s, looped: true, volume, .. }]
5663 if *s == sound && *volume == 0.5
5664 ),
5665 "{cmds:?}"
5666 );
5667 frame(&mut core, &mut ext);
5668 assert!(
5669 core.take_audio_commands().is_empty(),
5670 "re-declaring is silent"
5671 );
5672 ext.lua.globals().set("playing", false).unwrap();
5673 frame(&mut core, &mut ext);
5674 assert!(matches!(
5675 core.take_audio_commands().as_slice(),
5676 [AudioCommand::Stop { .. }]
5677 ));
5678 }
5679
5680 #[test]
5684 fn measure_and_layout_events_reach_scripts() {
5685 let mut ext = LuaExtension::from_source(
5686 "measure",
5687 r#"
5688 seen = nil
5689 function view(env)
5690 local plain = env.measure_text("hello world", { size = 14 })
5691 local node = env.measure_text(text("hello world", { size = 14 }))
5692 local rich = env.measure_text({ "hello ", { "world", bold = true } }, { size = 14 })
5693 local narrow = env.measure_text("hello world", { size = 14 }, plain.width / 2)
5694 assert(plain.width > 0 and plain.lines == 1)
5695 assert(node.width == plain.width and node.height == plain.height)
5696 assert(rich.width > 0)
5697 assert(narrow.lines > 1 and narrow.width <= plain.width / 2 + 0.5)
5698 return column {
5699 row { key = "panel", width = plain.width, height = 20, on_layout = "panel" },
5700 }
5701 end
5702 function on_event(ev)
5703 if ev.kind == "layout" then seen = ev end
5704 end
5705 "#,
5706 )
5707 .unwrap();
5708 let mut core = Core::new();
5709 frame(&mut core, &mut ext);
5710 let evs = core.take_pending_events();
5711 assert_eq!(evs.len(), 1, "one layout event on first sight");
5712 for ev in &evs {
5713 ext.on_event(ev);
5714 }
5715 let seen: Table = ext.lua.globals().get("seen").unwrap();
5716 assert_eq!(seen.get::<String>("tag").unwrap(), "panel");
5717 assert_eq!(seen.get::<f64>("h").unwrap(), 20.0);
5718 assert!(seen.get::<f64>("w").unwrap() > 0.0);
5719 assert_eq!(seen.get::<i64>("node_key").unwrap(), evs[0].key.0 as i64);
5720 frame(&mut core, &mut ext);
5722 assert!(core.take_pending_events().is_empty());
5723 }
5724
5725 #[test]
5729 fn scripts_queue_window_size_and_focus_requests() {
5730 let mut ext = LuaExtension::from_source(
5731 "win",
5732 r#"
5733 function view(env)
5734 env.set_window_size(env.window.id, 640, 480)
5735 env.focus_window(env.window.id)
5736 return column { width = "grow", height = "grow" }
5737 end
5738 "#,
5739 )
5740 .unwrap();
5741 let mut core = Core::new();
5742 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5743 ui.set_origin(OriginId(1));
5744 ext.view(&Slot::root(), &mut ui).unwrap();
5745 ui.finish();
5746 assert_eq!(
5747 core.take_window_commands(),
5748 vec![
5749 kui_core::WindowCommand::SetSize {
5750 window: WindowId::MAIN,
5751 size: Size::new(640.0, 480.0),
5752 },
5753 kui_core::WindowCommand::Focus(WindowId::MAIN),
5754 ]
5755 );
5756 assert!(core.take_window_commands().is_empty());
5757 assert_eq!(core.viewport(), Size::new(400.0, 200.0));
5758 }
5759
5760 #[test]
5766 fn scripts_reveal_and_move_scroll_offsets() {
5767 let mut ext = LuaExtension::from_source(
5768 "scroll",
5769 r#"
5770 rows, want, jump, seen = 20, nil, nil, nil
5771 function view(env)
5772 if want then env.reveal(want) end
5773 if jump then env.set_scroll(jump[1], jump[2], jump[3]) end
5774 want, jump = nil, nil
5775 local list = { key = "list", width = "grow", height = "grow",
5776 scroll_y = true }
5777 for i = 0, rows - 1 do
5778 list[#list + 1] = row { key = "row" .. i, width = "grow",
5779 height = 30, bg = 0x282840ff }
5780 end
5781 seen = env.scroll_offset(list_key)
5782 return column(list)
5783 end
5784 "#,
5785 )
5786 .unwrap();
5787 let list = Key::ROOT.str("list");
5789 let row = |i: usize| list.str(&format!("row{i}"));
5790 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
5791 let mut core = Core::new();
5792 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5793 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5794 ui.set_origin(OriginId(1));
5795 ext.view(&Slot::root(), &mut ui).unwrap();
5796 ui.finish();
5797 };
5798
5799 frame(&mut core, &mut ext);
5800 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
5801
5802 ext.lua.globals().set("want", row(15).0 as i64).unwrap();
5805 frame(&mut core, &mut ext);
5806 let after = core.scroll_offset(list);
5807 assert!(after.y > 0.0, "reveal moved nothing: {after:?}");
5808 assert!(after.y <= 15.0 * 30.0, "scrolled past row 15: {after:?}");
5809 frame(&mut core, &mut ext);
5811 assert_eq!(core.scroll_offset(list), after);
5812
5813 let seen: Table = ext.lua.globals().get("seen").unwrap();
5815 assert_eq!(seen.get::<f32>("y").unwrap(), after.y);
5816 assert_eq!(seen.get::<f32>("x").unwrap(), 0.0);
5817
5818 let jump = |ext: &LuaExtension, y: f64| {
5820 let t = ext.lua.create_table().unwrap();
5821 t.set(1, list.0 as i64).unwrap();
5822 t.set(2, 0.0).unwrap();
5823 t.set(3, y).unwrap();
5824 ext.lua.globals().set("jump", t).unwrap();
5825 };
5826 jump(&ext, 1e9);
5827 frame(&mut core, &mut ext);
5828 assert_eq!(core.scroll_offset(list).y, 400.0);
5829 jump(&ext, -1e9);
5830 frame(&mut core, &mut ext);
5831 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
5832
5833 jump(&ext, 120.0);
5835 frame(&mut core, &mut ext);
5836 ext.lua
5837 .globals()
5838 .set("want", Key::ROOT.str("ghost").0 as i64)
5839 .unwrap();
5840 frame(&mut core, &mut ext);
5841 assert_eq!(core.scroll_offset(list).y, 120.0);
5842 frame(&mut core, &mut ext);
5843 assert_eq!(core.scroll_offset(list).y, 120.0);
5844 }
5845
5846 #[test]
5850 fn scripts_read_the_pressed_state() {
5851 let mut ext = LuaExtension::from_source(
5852 "press",
5853 r#"
5854 function view(env)
5855 pressed = env.is_pressed(btn_key)
5856 hovered = env.is_hovered(btn_key)
5857 return column { key = "root", pad = 10,
5858 row { key = "btn", width = 100, height = 40,
5859 bg = 0x333333ff, on_click = "hit" },
5860 }
5861 end
5862 "#,
5863 )
5864 .unwrap();
5865 let btn = Key::ROOT.str("root").str("btn");
5866 ext.lua.globals().set("btn_key", btn.0 as i64).unwrap();
5867 let mut core = Core::new();
5868 let pressed = |ext: &LuaExtension| ext.lua.globals().get::<bool>("pressed").unwrap();
5869 let hovered = |ext: &LuaExtension| ext.lua.globals().get::<bool>("hovered").unwrap();
5870
5871 frame(&mut core, &mut ext);
5872 assert!(!pressed(&ext), "idle");
5873
5874 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
5876 frame(&mut core, &mut ext);
5877 assert!(hovered(&ext) && !pressed(&ext), "hover is not press");
5878
5879 core.handle_input(InputEvent::MouseDown {
5880 button: kui_core::MouseButton::Primary,
5881 clicks: 1,
5882 });
5883 frame(&mut core, &mut ext);
5884 assert!(pressed(&ext), "held down");
5885
5886 core.handle_input(InputEvent::MouseUp {
5890 button: kui_core::MouseButton::Primary,
5891 });
5892 frame(&mut core, &mut ext);
5893 assert!(!pressed(&ext), "released");
5894 }
5895
5896 #[test]
5901 fn scripts_read_the_drop_target() {
5902 let mut ext = LuaExtension::from_source(
5903 "drop",
5904 r#"
5905 function view(env)
5906 over = env.is_drop_target(zone_key)
5907 target = env.drop_target()
5908 return column { key = "root", pad = 10,
5909 row { key = "zone", width = 100, height = 40,
5910 bg = 0x333333ff, drop_bg = 0x335533ff, on_drop = "files" },
5911 }
5912 end
5913 "#,
5914 )
5915 .unwrap();
5916 let zone = Key::ROOT.str("root").str("zone");
5917 ext.lua.globals().set("zone_key", zone.0 as i64).unwrap();
5918 let mut core = Core::new();
5919 let over = |ext: &LuaExtension| ext.lua.globals().get::<bool>("over").unwrap();
5920 let target = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("target").unwrap();
5921
5922 frame(&mut core, &mut ext);
5923 assert!(!over(&ext) && target(&ext).is_none(), "idle");
5924
5925 let paths = vec!["/drop/1.txt".to_string()];
5926 let evs = core.handle_input(InputEvent::DragFiles {
5927 paths: paths.clone(),
5928 at: Vec2::new(50.0, 30.0),
5929 });
5930 assert_eq!(evs[0].payload.get_str("tag"), Some("files"));
5931 frame(&mut core, &mut ext);
5932 assert!(over(&ext), "the files are over the zone");
5933 assert_eq!(target(&ext), Some(zone.0 as i64));
5934 let (dl, _) = core.output();
5935 let lit = dl
5936 .quads
5937 .iter()
5938 .find(|q| q.rect.w == 100.0)
5939 .map(|q| q.color)
5940 .expect("zone quad");
5941 assert!(
5942 (lit.g - 0x55 as f32 / 255.0).abs() < 0.01,
5943 "drop_bg painted"
5944 );
5945
5946 core.handle_input(InputEvent::DropFiles {
5947 paths,
5948 at: Vec2::new(50.0, 30.0),
5949 });
5950 frame(&mut core, &mut ext);
5951 assert!(
5952 !over(&ext) && target(&ext).is_none(),
5953 "a drop ends the hover"
5954 );
5955 }
5956
5957 #[test]
5968 fn the_env_table_is_the_documented_env_shape() {
5969 let mut ext = LuaExtension::from_source(
5970 "env",
5971 r#"
5972 function view(env)
5973 values, calls = {}, {}
5974 for k, v in pairs(env) do
5975 if type(v) == "function" then calls[#calls + 1] = k
5976 elseif type(v) == "table" then
5977 for wk in pairs(v) do values[#values + 1] = k .. "." .. wk end
5978 else values[#values + 1] = k end
5979 end
5980 return column { key = "root",
5981 row { key = "a", focusable = true, width = 50, height = 20 },
5982 column { key = "dock", focus_region = true,
5983 row { key = "d", focusable = true, width = 50, height = 20 },
5984 },
5985 }
5986 end
5987 "#,
5988 )
5989 .unwrap();
5990 let mut core = Core::new();
5991 core.env.refresh_hz = Some(60.0);
5996 core.env.system.accent = Some(kui_core::Color::hex(0x3b82f6ff));
5997 core.env.system.locale = kui_core::Locale::new("en-US");
5998 core.env.window.native_controls = Some(Rect::new(0.0, 0.0, 78.0, 28.0));
5999 frame(&mut core, &mut ext);
6000 core.set_focus(Some(Key::ROOT.str("root").str("dock").str("d")));
6001 frame(&mut core, &mut ext);
6002
6003 let sorted = |name: &str| -> Vec<String> {
6004 let mut v: Vec<String> = ext.lua.globals().get(name).unwrap();
6005 v.sort();
6006 v
6007 };
6008 let mut documented: Vec<String> = kui_core::schema::ENV_FIELDS
6009 .iter()
6010 .flat_map(|f| f.lua.iter().map(|k| (*k).to_string()))
6011 .collect();
6012 documented.extend(
6018 kui_core::schema::THEME_ROLES
6019 .iter()
6020 .map(|r| format!("theme.{}", r.name)),
6021 );
6022 documented.push("theme.appearance".into());
6023 documented.push("theme.disabled_opacity".into());
6024 documented.extend(
6027 kui_core::schema::METRIC_ROLES
6028 .iter()
6029 .map(|r| format!("metrics.{}", r.name)),
6030 );
6031 documented.push("tokens.colors".into());
6034 documented.push("tokens.lengths".into());
6035 documented.sort();
6036 assert_eq!(
6037 sorted("values"),
6038 documented,
6039 "env's value keys and schema::ENV_FIELDS + THEME_ROLES + METRIC_ROLES + tokens disagree"
6040 );
6041 let mut doors: Vec<String> = kui_core::schema::DOORS
6048 .iter()
6049 .filter_map(|d| match d.lua {
6050 kui_core::schema::Cell::Is(name) => Some(name.to_string()),
6051 _ => None,
6052 })
6053 .collect();
6054 doors.sort();
6055 assert_eq!(
6056 sorted("calls"),
6057 doors,
6058 "env's queries and verbs and schema::DOORS's Lua column disagree; update env_table's doc too"
6059 );
6060 }
6061
6062 #[test]
6067 fn a_script_declares_tokens_and_references_them_by_name() {
6068 let mut ext = LuaExtension::from_source(
6069 "tokens",
6070 r##"
6071 tokens = {
6072 colors = { peach = "#ffcc99", ink = { light = "#111111", dark = "#eeeeee" } },
6073 lengths = { side_w = 132, gap = 6 },
6074 }
6075 function view(env)
6076 seen = env.tokens
6077 return column { pad = "$gap", gap = "$gap",
6078 row { key = "a", width = "$side_w", height = 20, bg = "$peach",
6079 border = { w = "$gap", color = "$ink" }, radius = "$radius" },
6080 row { key = "b", width = 20, height = "$side_w", bg = "$surface",
6081 pad = { l = "$gap", r = 2 } },
6082 text({ "x", { "y", color = "$peach", bg = "$ink" } }, { size = "$gap", color = "$peach" }),
6083 }
6084 end
6085 "##,
6086 )
6087 .unwrap();
6088 let mut core = Core::new();
6089 frame(&mut core, &mut ext);
6090 assert!(core.take_warnings().is_empty());
6091 let peach = Color::hex(0xffcc99ff);
6092 let paper = Color::hex(0xeeeeeeff);
6093 let radius = core.metrics().radius;
6094 let surface = core.theme().surface;
6095 let dl = core.output().0;
6096 let a = dl
6097 .quads
6098 .iter()
6099 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
6100 .expect("the peach box");
6101 assert_eq!(a.rect.w, 132.0, "width from a length token");
6102 assert_eq!(a.border_w, 6.0);
6103 assert_eq!(
6104 a.border_color, paper,
6105 "the dark half on an unknown appearance"
6106 );
6107 assert_eq!(a.radius[0], radius, "$radius is the metric");
6108 let b = dl
6109 .quads
6110 .iter()
6111 .find(|q| q.color == surface && q.rect.h == 132.0)
6112 .expect("the $surface box, 132 tall");
6113 assert_eq!(b.rect.w, 20.0);
6114 let seen: Table = ext.lua.globals().get("seen").unwrap();
6115 let colors: Table = seen.get("colors").unwrap();
6116 let lengths: Table = seen.get("lengths").unwrap();
6117 assert_eq!(colors.get::<u32>("peach").unwrap(), 0xffcc99ff);
6118 assert_eq!(colors.get::<u32>("ink").unwrap(), 0xeeeeeeff);
6119 assert_eq!(lengths.get::<f32>("side_w").unwrap(), 132.0);
6120 core.set_system(kui_core::SystemEnv {
6122 appearance: kui_core::Appearance::Light,
6123 ..Default::default()
6124 });
6125 frame(&mut core, &mut ext);
6126 let a = core
6127 .output()
6128 .0
6129 .quads
6130 .iter()
6131 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
6132 .unwrap();
6133 assert_eq!(a.border_color, Color::hex(0x111111ff));
6134 }
6135
6136 #[test]
6142 fn a_script_derives_a_token_from_another() {
6143 let mut ext = LuaExtension::from_source(
6144 "derived",
6145 r##"
6146 tokens = {
6147 colors = {
6148 peach = "#ffcc99",
6149 z_lit = { from = "peach", ops = { "lift", 0.5 } },
6150 a_deep = { from = "z_lit", ops = { { "alpha", 0.5 }, { "mix", "peach", 0.0 } } },
6151 up = { from = "surface", ops = { { "raise", 0.25 } } },
6152 bad = { from = "nothing" },
6153 },
6154 }
6155 function view(env)
6156 seen = env.tokens.colors
6157 return column {
6158 row { key = "a", width = 20, height = 20, bg = "$a_deep" },
6159 row { key = "b", width = 20, height = 20, bg = "$bad" },
6160 }
6161 end
6162 "##,
6163 )
6164 .unwrap();
6165 let mut core = Core::new();
6166 frame(&mut core, &mut ext);
6167 let warnings = core.take_warnings();
6168 let codes: Vec<&str> = warnings.iter().map(|w| w.code).collect();
6169 assert_eq!(codes, ["unknown-token"], "{warnings:?}");
6170 assert!(
6171 warnings[0].message.contains("`$bad`") && warnings[0].message.contains("`$nothing`")
6172 );
6173 let seen: Table = ext.lua.globals().get("seen").unwrap();
6174 assert_eq!(seen.get::<u32>("z_lit").unwrap(), 0xffe6ccff);
6175 assert_eq!(seen.get::<u32>("a_deep").unwrap(), 0xffe6cc80);
6176 let surface = core.theme().surface;
6177 assert_eq!(
6178 seen.get::<u32>("up").unwrap(),
6179 surface.mix(Color::WHITE, 0.25).to_hex(),
6180 "a role source, raised toward the dark base's front"
6181 );
6182 assert!(seen.get::<mlua::Value>("bad").unwrap().is_nil());
6183 let dl = core.output().0;
6184 assert!(
6185 dl.quads.iter().any(|q| q.color.to_hex() == 0xffe6cc80),
6186 "$a_deep painted the derived colour"
6187 );
6188
6189 let refused = |colors: &str| {
6191 LuaExtension::from_source(
6192 "bad",
6193 &format!(
6194 "tokens = {{ colors = {{ {colors} }} }}\nfunction view() return column {{}} end"
6195 ),
6196 )
6197 .err()
6198 .map(|e| e.to_string())
6199 .unwrap_or_default()
6200 };
6201 assert!(
6202 refused(r#"x = { from = "peach", ops = { { "glow", 1 } } }"#)
6203 .contains("unknown verb \"glow\"")
6204 );
6205 assert!(
6206 refused(r#"x = { from = "peach", ops = { { "mix", 0.5 } } }"#)
6207 .contains("mix takes a colour and a number")
6208 );
6209 assert!(
6210 refused(r#"x = { from = "peach", ops = { { "lift", "lots" } } }"#)
6211 .contains("number is a number")
6212 );
6213 assert!(refused(r#"x = { from = 3 }"#).contains("from names a colour token or role"));
6214 assert!(refused(r#"x = { from = "peach", glow = 1 }"#).contains("unknown key \"glow\""));
6215 }
6216
6217 #[test]
6222 fn a_scripts_tokens_sit_over_the_hosts() {
6223 let mut ext = LuaExtension::from_source(
6224 "guest",
6225 r##"
6226 tokens = { colors = { grey = "#808080" } }
6227 function view(env)
6228 first = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
6229 if redeclare then
6230 env.set_tokens { colors = { peach = "#ffaa77" }, lengths = { w = 40 } }
6231 end
6232 second = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
6233 return column { row { key = "a", width = redeclare and "$w" or 10, height = 10, bg = "$peach" } }
6234 end
6235 "##,
6236 )
6237 .unwrap();
6238 let mut core = Core::new();
6239 core.set_tokens(kui_core::Tokens::new().color("peach", Color::hex(0xffcc99ff)));
6240 frame(&mut core, &mut ext);
6241 let first: Table = ext.lua.globals().get("first").unwrap();
6242 assert_eq!(
6243 first.get::<u32>("peach").unwrap(),
6244 0xffcc99ff,
6245 "the host's peach"
6246 );
6247 assert_eq!(
6248 first.get::<u32>("grey").unwrap(),
6249 0x808080ff,
6250 "its own grey"
6251 );
6252 assert!(
6253 core.token_lookup().color("grey").is_err(),
6254 "the guest's grey is not the host's"
6255 );
6256 assert!(core.take_warnings().is_empty());
6257
6258 ext.lua.globals().set("redeclare", true).unwrap();
6259 frame(&mut core, &mut ext);
6260 let second: Table = ext.lua.globals().get("second").unwrap();
6261 assert_eq!(
6262 second.get::<u32>("peach").unwrap(),
6263 0xffaa77ff,
6264 "its own peach now"
6265 );
6266 assert!(
6267 second.get::<Option<u32>>("grey").unwrap().is_none(),
6268 "replaced whole"
6269 );
6270 let a = core
6271 .output()
6272 .0
6273 .quads
6274 .iter()
6275 .find(|q| q.color == Color::hex(0xffaa77ff))
6276 .expect("painted the script's peach");
6277 assert_eq!(a.rect.w, 40.0);
6278 assert_eq!(
6279 core.token_lookup().color("peach"),
6280 Ok(Color::hex(0xffcc99ff)),
6281 "the host's table did not move"
6282 );
6283 }
6284
6285 #[test]
6288 fn a_missing_token_warns_and_paints_nothing() {
6289 let mut ext = LuaExtension::from_source(
6290 "missing",
6291 r##"
6292 tokens = { colors = { surface = "#ff0000", peach = "#ffcc99" }, lengths = { gap = 6 } }
6293 function view(env)
6294 return column {
6295 row { key = "a", width = "$gap", height = 10, bg = "$peech", pad = "$peach" },
6296 row { key = "b", width = "$peach", height = 10, bg = "$gap" },
6297 text("still here", { color = "$peech", size = "$gap" }),
6298 text("still here", { size = 6 }),
6299 text("still here"),
6300 }
6301 end
6302 "##,
6303 )
6304 .unwrap();
6305 let mut core = Core::new();
6306 core.set_inspect(true);
6307 frame(&mut core, &mut ext);
6308 frame(&mut core, &mut ext);
6309 let ws = core.take_warnings();
6310 let mut codes: Vec<&str> = ws.iter().map(|w| w.code).collect();
6311 codes.sort();
6312 assert_eq!(
6313 codes,
6314 [
6315 kui_core::diag::RESERVED_TOKEN,
6316 kui_core::diag::UNKNOWN_TOKEN,
6317 kui_core::diag::UNKNOWN_TOKEN,
6318 kui_core::diag::UNKNOWN_TOKEN,
6319 ],
6320 "surface refused once; peech, peach-as-length and gap-as-colour once each: {ws:?}"
6321 );
6322 assert!(
6323 ws.iter().any(|w| w
6324 .message
6325 .contains("`$gap` is a length token, and this slot takes a color")),
6326 "{ws:?}"
6327 );
6328 let fg = core.theme().fg;
6332 let dl = core.output().0;
6333 assert!(
6334 dl.quads
6335 .iter()
6336 .all(|q| q.kind != kui_core::QuadKind::Solid || q.color.a == 0.0)
6337 );
6338 let glyphs: Vec<_> = dl
6339 .quads
6340 .iter()
6341 .filter(|q| {
6342 matches!(
6343 q.kind,
6344 kui_core::QuadKind::GlyphMask | kui_core::QuadKind::GlyphSubpixel
6345 )
6346 })
6347 .collect();
6348 assert!(!glyphs.is_empty(), "the text painted");
6349 assert!(glyphs.iter().all(|q| q.color == fg), "in the foreground");
6350 let heights: Vec<f32> = core
6355 .nodes()
6356 .iter()
6357 .filter(|n| n.text.as_deref() == Some("still here"))
6358 .map(|n| n.rect.h)
6359 .collect();
6360 assert_eq!(heights.len(), 3, "{heights:?}");
6361 assert!(heights[0] > 0.0, "{heights:?}");
6362 assert_eq!(heights[0], heights[1], "$gap is the literal 6: {heights:?}");
6363 assert!(heights[2] > heights[0], "and not the default: {heights:?}");
6364 }
6365
6366 #[test]
6371 fn a_gradient_stop_that_misses_is_left_out() {
6372 let mut ext = LuaExtension::from_source(
6373 "gradient",
6374 r##"
6375 tokens = { colors = { peach = "#ffcc99" } }
6376 function view(env)
6377 return column {
6378 row { key = "three", width = 40, height = 10,
6379 gradient = { stops = { "$peach", "$peech", "#0000ff" } } },
6380 row { key = "two", width = 40, height = 10, bg = "#000000",
6381 gradient = { stops = { "$peach", "$peech" } } },
6382 }
6383 end
6384 "##,
6385 )
6386 .unwrap();
6387 let mut core = Core::new();
6388 frame(&mut core, &mut ext);
6389 frame(&mut core, &mut ext);
6390 let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
6391 assert_eq!(codes, [kui_core::diag::UNKNOWN_TOKEN], "once, for the name");
6392 let dl = core.output().0;
6393 let images: Vec<_> = dl
6394 .quads
6395 .iter()
6396 .filter(|q| q.kind == kui_core::QuadKind::Image)
6397 .collect();
6398 assert_eq!(images.len(), 1, "the three-stop row's two");
6399 assert!(
6400 images[0].rect.y < 10.0,
6401 "on the first row: {:?}",
6402 images[0].rect
6403 );
6404 let black = dl
6405 .quads
6406 .iter()
6407 .filter(|q| q.kind == kui_core::QuadKind::Solid && q.color == Color::hex(0x000000ff))
6408 .count();
6409 assert_eq!(black, 1, "the second row's bg");
6410 }
6411
6412 #[test]
6417 fn a_token_reaches_a_min_a_stroke_and_a_stop_and_misses_by_leaving_the_slot() {
6418 let mut ext = LuaExtension::from_source(
6419 "everywhere",
6420 r##"
6421 tokens = { colors = { peach = "#ffcc99" }, lengths = { gap = 6, wide = 40 } }
6422 function view(env)
6423 return column { pad = 4,
6424 row { key = "clamp", width = 10, height = 10, min_width = "$wide" },
6425 row { key = "typo", width = 10, height = 10, min_width = "$nope" },
6426 line { key = "stroke", from = { 0, 0 }, to = { 30, 0 }, width = "$gap", color = "$peach" },
6427 row { key = "anim", width = 10, height = 10, transition = 100,
6428 keyframes = { { bg = "$peach", width = "$wide" }, { bg = "$peech", radius = "$gap" } },
6429 enter = { width = "$nothing", bg = "$peach" } },
6430 }
6431 end
6432 "##,
6433 )
6434 .unwrap();
6435 let mut core = Core::new();
6436 core.set_inspect(true);
6437 frame(&mut core, &mut ext);
6438 let nodes = core.nodes();
6439 let by = |label: &str| {
6440 nodes
6441 .iter()
6442 .find(|n| n.label.as_deref() == Some(label))
6443 .unwrap()
6444 };
6445 assert_eq!(by("clamp").rect.w, 40.0, "the clamp is the token's 40 px");
6446 assert_eq!(
6447 by("typo").rect.w,
6448 10.0,
6449 "a miss leaves the row at its default"
6450 );
6451 let dl = core.output().0;
6452 let seg = dl
6453 .quads
6454 .iter()
6455 .find(|q| q.kind == kui_core::QuadKind::Segment)
6456 .expect("the line drew");
6457 assert_eq!(seg.color, Color::hex(0xffcc99ff));
6458 assert_eq!(seg.border_w, 6.0, "the stroke is the token's width");
6459 let ws = core.take_warnings();
6460 let mut names: Vec<String> = ws
6461 .iter()
6462 .filter(|w| w.code == kui_core::diag::UNKNOWN_TOKEN)
6463 .map(|w| w.message.clone())
6464 .collect();
6465 names.sort();
6466 assert_eq!(names.len(), 3, "nope, peech, nothing: {ws:?}");
6467 assert!(names.iter().any(|m| m.contains("`$nope`")), "{names:?}");
6468 assert!(names.iter().any(|m| m.contains("`$peech`")), "{names:?}");
6469 assert!(names.iter().any(|m| m.contains("`$nothing`")), "{names:?}");
6470 }
6471
6472 #[test]
6473 fn scripts_move_focus_with_the_verbs() {
6474 let mut ext = LuaExtension::from_source(
6475 "focus",
6476 r#"
6477 function view(env)
6478 if cmd == "set" then env.set_focus(target)
6479 elseif cmd == "blur" then env.blur()
6480 elseif cmd == "next" then env.focus_next()
6481 elseif cmd == "prev" then env.focus_prev() end
6482 cmd = nil
6483 -- `env.focus` is a value the host filled in before view()
6484 -- ran, so it lags a verb called above by a frame;
6485 -- `env.is_focused` is a call and answers about now.
6486 seen_focus = env.focus
6487 seen_live = env.is_focused(target or 0)
6488 seen_window = env.focused
6489 return column { key = "root", pad = 10,
6490 row { key = "a", focusable = true, width = 50, height = 20 },
6491 row { key = "b", focusable = true, width = 50, height = 20 },
6492 row { key = "c", focusable = true, width = 50, height = 20 },
6493 }
6494 end
6495 "#,
6496 )
6497 .unwrap();
6498 let root = Key::ROOT.str("root");
6499 let (a, b, c) = (root.str("a"), root.str("b"), root.str("c"));
6500 let mut core = Core::new();
6501 let cmd = |ext: &LuaExtension, c: &str| ext.lua.globals().set("cmd", c).unwrap();
6502 let seen = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("seen_focus").unwrap();
6503 let live = |ext: &LuaExtension| ext.lua.globals().get::<bool>("seen_live").unwrap();
6504
6505 frame(&mut core, &mut ext);
6507 assert_eq!(core.focus(), None);
6508 assert_eq!(seen(&ext), None, "nil for no focus, not 0");
6509
6510 ext.lua.globals().set("target", b.0 as i64).unwrap();
6512 cmd(&ext, "set");
6513 frame(&mut core, &mut ext);
6514 assert_eq!(core.focus(), Some(b));
6515 assert_eq!(seen(&ext), None, "the value lags a same-frame verb");
6519 assert!(live(&ext), "the query does not");
6520 frame(&mut core, &mut ext);
6521 assert_eq!(
6522 seen(&ext),
6523 Some(b.0 as i64),
6524 "and the next frame carries it"
6525 );
6526
6527 cmd(&ext, "next");
6529 frame(&mut core, &mut ext);
6530 assert_eq!(core.focus(), Some(c));
6531 cmd(&ext, "next");
6532 frame(&mut core, &mut ext);
6533 assert_eq!(core.focus(), Some(a), "wraps");
6534 cmd(&ext, "prev");
6535 frame(&mut core, &mut ext);
6536 assert_eq!(core.focus(), Some(c), "wraps back");
6537
6538 cmd(&ext, "blur");
6539 frame(&mut core, &mut ext);
6540 assert_eq!(core.focus(), None);
6541 frame(&mut core, &mut ext);
6542 assert_eq!(seen(&ext), None);
6543
6544 ext.lua.globals().set("target", "c").unwrap();
6547 cmd(&ext, "set");
6548 frame(&mut core, &mut ext);
6549 assert_eq!(core.focus(), Some(c), "set_focus(\"c\") resolves the label");
6550 assert!(live(&ext), "and is_focused(\"c\") answers about it");
6551 assert!(core.take_warnings().is_empty());
6552 ext.lua.globals().set("target", "nope").unwrap();
6554 cmd(&ext, "set");
6555 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
6556 let e = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
6557 ui.finish();
6558 assert!(
6559 e.contains("no node is keyed \"nope\"") && e.contains("integer key"),
6560 "{e}"
6561 );
6562
6563 assert!(ext.lua.globals().get::<bool>("seen_window").unwrap());
6566 }
6567
6568 #[test]
6571 fn a_shared_label_resolves_to_the_first_and_warns() {
6572 let mut ext = LuaExtension::from_source(
6573 "dup",
6574 r#"
6575 function view(env)
6576 if go then env.set_focus("item"); go = nil end
6577 return column { key = "root",
6578 row { row { key = "item", focusable = true, width = 50, height = 20 } },
6579 row { row { key = "item", focusable = true, width = 50, height = 20 } },
6580 }
6581 end
6582 "#,
6583 )
6584 .unwrap();
6585 let mut core = Core::new();
6586 frame(&mut core, &mut ext);
6587 ext.lua.globals().set("go", true).unwrap();
6588 frame(&mut core, &mut ext);
6589 assert_eq!(
6590 core.focus(),
6591 Some(Key::ROOT.str("root").index(0).str("item")),
6592 "the first, under the auto-keyed first row"
6593 );
6594 let ws = core.take_warnings();
6595 assert_eq!(ws.len(), 1, "{ws:?}");
6596 assert_eq!(ws[0].code, kui_core::diag::AMBIGUOUS_KEY);
6597 assert!(ws[0].message.contains("\"item\""), "{}", ws[0].message);
6598 }
6599
6600 #[test]
6603 fn scripts_open_a_menu_and_hear_the_row() {
6604 let mut ext = LuaExtension::from_source(
6605 "menu",
6606 r#"
6607 frames = 0
6608 function view(env)
6609 frames = frames + 1
6610 if frames == 2 then
6611 opened = env.open_menu("card", 40, 30, {
6612 { role = "copy" },
6613 { role = "separator" },
6614 -- The wire spelling the event reports, and the
6615 -- snake one a script may have learned first.
6616 { role = "selectAll" },
6617 { role = "look_up" },
6618 { label = "Wrap", checked = true },
6619 { label = "Inspect", id = "inspect" },
6620 })
6621 end
6622 return column {
6623 column { key = "card", selectable = true, text("one", { size = 14 }) },
6624 }
6625 end
6626 "#,
6627 )
6628 .unwrap();
6629 let mut core = Core::new();
6630 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6631 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6632 ui.set_origin(OriginId(1));
6633 ext.view(&Slot::root(), &mut ui).unwrap();
6634 ui.finish();
6635 };
6636 frame(&mut core, &mut ext);
6637 frame(&mut core, &mut ext);
6638 assert!(ext.lua.globals().get::<bool>("opened").unwrap());
6639 let roles: Vec<_> = core
6640 .menu()
6641 .expect("open")
6642 .items
6643 .iter()
6644 .map(|i| (i.role, i.checked))
6645 .collect();
6646 assert_eq!(
6647 roles,
6648 [
6649 (kui_core::MenuRole::Copy, false),
6650 (kui_core::MenuRole::Separator, false),
6651 (kui_core::MenuRole::SelectAll, false),
6652 (kui_core::MenuRole::LookUp, false),
6653 (kui_core::MenuRole::Custom, true),
6654 (kui_core::MenuRole::Custom, false),
6655 ]
6656 );
6657 frame(&mut core, &mut ext);
6658 let tree = core.access_tree();
6661 assert_eq!(
6662 tree.nodes
6663 .iter()
6664 .find(|n| n.name.as_deref() == Some("Wrap"))
6665 .expect("the checked row")
6666 .checked,
6667 Some(true)
6668 );
6669 let row = tree
6670 .nodes
6671 .iter()
6672 .find(|n| n.name.as_deref() == Some("Inspect"))
6673 .expect("the menu drew")
6674 .rect;
6675 let at = kui_core::Vec2::new(row.x + row.w / 2.0, row.y + row.h / 2.0);
6676 core.handle_input(InputEvent::CursorMoved(at));
6677 core.handle_input(InputEvent::mouse_down(1));
6678 let events = core.handle_input(InputEvent::mouse_up());
6679 assert_eq!(events.len(), 1, "{events:?}");
6680 assert_eq!(events[0].payload.get_str("item"), Some("inspect"));
6681 assert_eq!(
6682 events[0].origin,
6683 OriginId(1),
6684 "posted with the origin that asked for the menu"
6685 );
6686 }
6687
6688 #[test]
6691 fn scripts_select_and_read_a_scope() {
6692 let mut ext = LuaExtension::from_source(
6693 "sel",
6694 r#"
6695 frames = 0
6696 function view(env)
6697 frames = frames + 1
6698 if frames > 1 then
6699 before = env.selection_text()
6700 took = env.select_all_in("card")
6701 text_out = env.selection_text()
6702 not_a_scope = env.select_all_in("plain")
6703 end
6704 return column {
6705 column { key = "card", selectable = true,
6706 text("one", { size = 14 }),
6707 text("two", { size = 14 }) },
6708 row { key = "plain", width = 10, height = 10 },
6709 }
6710 end
6711 "#,
6712 )
6713 .unwrap();
6714 let mut core = Core::new();
6715 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6716 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6717 ui.set_origin(OriginId(1));
6718 ext.view(&Slot::root(), &mut ui).unwrap();
6719 ui.finish();
6720 };
6721 frame(&mut core, &mut ext);
6722 frame(&mut core, &mut ext);
6723 let g = ext.lua.globals();
6724 assert!(matches!(
6725 g.get::<mlua::Value>("before").unwrap(),
6726 mlua::Value::Nil
6727 ));
6728 assert!(g.get::<bool>("took").unwrap());
6729 assert_eq!(g.get::<String>("text_out").unwrap(), "one\ntwo");
6730 assert!(
6731 !g.get::<bool>("not_a_scope").unwrap(),
6732 "a node that drew no text is not a scope"
6733 );
6734 }
6735
6736 #[test]
6741 fn scripts_map_points_to_bytes_on_a_line_of_runs() {
6742 let mut ext = LuaExtension::from_source(
6743 "hit",
6744 r#"
6745 frames = 0
6746 function view(env)
6747 local mono = { size = 14, family = "mono" }
6748 local w = env.measure_text("M", mono, 0).width
6749 frames = frames + 1
6750 -- A label nothing has declared yet is an error by name
6751 -- (F5), so the first frame declares and the next asks.
6752 if frames > 1 then
6753 hit = env.text_hit("line", 7.2 * w, 5)
6754 seam = env.caret_rect("line", 4)
6755 far = env.text_hit("line", 390, 5)
6756 none = env.caret_rect("plain", 0)
6757 end
6758 cell = w
6759 return column {
6760 row { key = "line",
6761 text("let ", mono),
6762 row { bg = 0x3b5bd455, text("value", mono) },
6763 text(" = 1;", mono) },
6764 row { key = "plain", width = 10, height = 10 },
6765 }
6766 end
6767 "#,
6768 )
6769 .unwrap();
6770 let mut core = Core::new();
6771 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6772 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6773 ui.set_origin(OriginId(1));
6774 ext.view(&Slot::root(), &mut ui).unwrap();
6775 ui.finish();
6776 };
6777 frame(&mut core, &mut ext);
6778 let hit: mlua::Value = ext.lua.globals().get("hit").unwrap();
6779 assert!(
6780 matches!(hit, mlua::Value::Nil),
6781 "nothing drawn before the first frame"
6782 );
6783 frame(&mut core, &mut ext);
6784 let hit: mlua::Table = ext.lua.globals().get("hit").unwrap();
6785 assert_eq!(
6786 hit.get::<usize>("byte").unwrap(),
6787 7,
6788 "the fourth cell of \"value\""
6789 );
6790 assert_eq!(hit.get::<u32>("line").unwrap(), 0);
6791 let cell: f32 = ext.lua.globals().get("cell").unwrap();
6792 let seam: mlua::Table = ext.lua.globals().get("seam").unwrap();
6793 let x: f32 = seam.get("x").unwrap();
6794 assert!((x - 4.0 * cell).abs() < 0.75, "{x} vs {}", 4.0 * cell);
6795 assert_eq!(seam.get::<f32>("w").unwrap(), 0.0);
6796 let far: mlua::Table = ext.lua.globals().get("far").unwrap();
6797 assert_eq!(
6802 far.get::<usize>("byte").unwrap(),
6803 14,
6804 "the end, across the runs"
6805 );
6806 let none: mlua::Value = ext.lua.globals().get("none").unwrap();
6807 assert!(matches!(none, mlua::Value::Nil), "a node that drew no text");
6808 }
6809
6810 #[test]
6814 fn scripts_draw_a_screen_of_cells() {
6815 let mut ext = LuaExtension::from_source(
6816 "term",
6817 r#"
6818 function view(env)
6819 return column { cells { key = "term", rows = 2, cols = 11, size = 14, family = "mono",
6820 lines = { "hello world", " bye" },
6821 runs = { { 1, 2, 3, 0xff0000ff, 0x0000ffff, 1 } },
6822 cursor_at = { 1, 4 }, cursor_shape = "underline" } }
6823 end
6824 "#,
6825 )
6826 .unwrap();
6827 let mut core = Core::new();
6828 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6829 ui.set_origin(OriginId(1));
6830 ext.view(&Slot::root(), &mut ui).unwrap();
6831 ui.finish();
6832 let (dl, _) = core.output();
6833 let glyphs = dl
6834 .quads
6835 .iter()
6836 .filter(|q| q.kind == kui_core::QuadKind::GlyphMask)
6837 .count();
6838 assert_eq!(glyphs, 13, "hello world + bye");
6839 let tree = core.access_tree();
6840 let term = tree
6841 .nodes
6842 .iter()
6843 .find(|n| n.role == kui_core::Role::Terminal)
6844 .expect("a terminal node");
6845 assert_eq!(term.value.as_deref(), Some("hello world\n bye"));
6846 }
6847
6848 #[test]
6854 fn a_lua_grid_selects_by_cell_word_and_row() {
6855 let mut ext = LuaExtension::from_source(
6856 "term",
6857 r#"
6858 function view(env)
6859 said = env.selection_text()
6860 return column { cells { key = "term", rows = 2, cols = 12,
6861 size = 14, family = "mono", line_height = 20,
6862 origin_line = 900, selectable = true,
6863 lines = { "hello world", "bye there" } } }
6864 end
6865 "#,
6866 )
6867 .unwrap();
6868 let mut core = Core::new();
6869 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6870 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6871 ui.set_origin(OriginId(1));
6872 ext.view(&Slot::root(), &mut ui).unwrap();
6873 ui.finish();
6874 };
6875 frame(&mut core, &mut ext);
6876 let said = |ext: &LuaExtension| ext.lua.globals().get::<Option<String>>("said").unwrap();
6877
6878 let w = core
6880 .measure_text(
6881 "M",
6882 &kui_core::TextStyle::new(14.0)
6883 .family(kui_core::FontFamily::Mono)
6884 .line_height(20.0),
6885 None,
6886 )
6887 .width;
6888 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
6889 let click = |core: &mut Core, p: Vec2, clicks: u8| {
6890 core.handle_input(InputEvent::CursorMoved(p));
6891 core.handle_input(InputEvent::MouseDown {
6892 button: kui_core::MouseButton::Primary,
6893 clicks,
6894 });
6895 core.handle_input(InputEvent::MouseUp {
6896 button: kui_core::MouseButton::Primary,
6897 });
6898 };
6899
6900 click(&mut core, at(0, 8), 2);
6901 frame(&mut core, &mut ext);
6902 assert_eq!(said(&ext).as_deref(), Some("world"), "a double click");
6903
6904 click(&mut core, at(1, 1), 3);
6905 frame(&mut core, &mut ext);
6906 assert_eq!(said(&ext).as_deref(), Some("bye there"), "a triple click");
6907 }
6908
6909 #[test]
6915 fn a_grid_selection_reads_back_as_absolute_lines_and_columns() {
6916 let mut ext = LuaExtension::from_source(
6917 "term",
6918 r#"
6919 function view(env)
6920 sel = env.cell_selection()
6921 return column { cells { key = "term", rows = 2, cols = 12,
6922 size = 14, family = "mono", line_height = 20,
6923 origin_line = 900, selectable = true,
6924 lines = { "hello world", "bye there" } } }
6925 end
6926 "#,
6927 )
6928 .unwrap();
6929 let mut core = Core::new();
6930 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6931 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6932 ui.set_origin(OriginId(1));
6933 ext.view(&Slot::root(), &mut ui).unwrap();
6934 ui.finish();
6935 };
6936 frame(&mut core, &mut ext);
6937 assert!(
6938 ext.lua
6939 .globals()
6940 .get::<mlua::Value>("sel")
6941 .unwrap()
6942 .is_nil(),
6943 "nothing selected yet"
6944 );
6945 let w = core
6946 .measure_text(
6947 "M",
6948 &kui_core::TextStyle::new(14.0)
6949 .family(kui_core::FontFamily::Mono)
6950 .line_height(20.0),
6951 None,
6952 )
6953 .width;
6954 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
6955 core.handle_input(InputEvent::CursorMoved(at(1, 4)));
6956 core.handle_input(InputEvent::MouseDown {
6957 button: kui_core::MouseButton::Primary,
6958 clicks: 1,
6959 });
6960 core.handle_input(InputEvent::CursorMoved(at(0, 1)));
6961 core.handle_input(InputEvent::MouseUp {
6962 button: kui_core::MouseButton::Primary,
6963 });
6964 frame(&mut core, &mut ext);
6965 let sel: Table = ext.lua.globals().get("sel").unwrap();
6966 let end = |name: &str| -> (u64, usize) {
6967 let t: Table = sel.get(name).unwrap();
6968 (t.get("line").unwrap(), t.get("col").unwrap())
6969 };
6970 assert_eq!(end("anchor"), (901, 4), "the press, on the second row");
6971 assert_eq!(end("focus"), (900, 1), "the pointer, backwards");
6972 assert_eq!(
6973 sel.get::<i64>("node").unwrap(),
6974 core.key_of("term").unwrap().0 as i64
6975 );
6976 assert!(!sel.get::<bool>("block").unwrap());
6977 }
6978
6979 #[test]
6982 fn spans_carry_their_decorations() {
6983 let mut ext = LuaExtension::from_source(
6984 "deco",
6985 r#"
6986 function view(env)
6987 return row { text({ "let ", { "value", bg = 0x3b5bd455, underline = true },
6988 " = 1;" }, { size = 14, family = "mono" }) }
6989 end
6990 "#,
6991 )
6992 .unwrap();
6993 let mut core = Core::new();
6994 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6995 ui.set_origin(OriginId(1));
6996 ext.view(&Slot::root(), &mut ui).unwrap();
6997 ui.finish();
6998 let (dl, _) = core.output();
6999 let solids = dl
7000 .quads
7001 .iter()
7002 .filter(|q| q.kind == kui_core::QuadKind::Solid)
7003 .count();
7004 assert_eq!(solids, 2, "a background and an underline");
7005 }
7006
7007 #[test]
7012 fn underlines_have_a_colour_and_a_shape_of_their_own() {
7013 let mut ext = LuaExtension::from_source(
7014 "k4",
7015 r#"
7016 function view(env)
7017 return column {
7018 text({ "let ", { "value", underline_color = 0xff0000ff, underline_style = "wavy" } },
7019 { size = 14, family = "mono" }),
7020 text("warn", { size = 14, family = "mono", underline_color = 0x00ff00ff }),
7021 cells { rows = 1, cols = 3, size = 14, family = "mono",
7022 lines = { "abc" }, runs = { { 0, 0, 3, 0, 0, 32, 0xff0000ff } } },
7023 }
7024 end
7025 "#,
7026 )
7027 .unwrap();
7028 let mut core = Core::new();
7029 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7030 ui.set_origin(OriginId(1));
7031 ext.view(&Slot::root(), &mut ui).unwrap();
7032 ui.finish();
7033 let (dl, _) = core.output();
7034 let red = Color::hex(0xff0000ff);
7035 let segs: Vec<_> = dl
7036 .quads
7037 .iter()
7038 .filter(|q| q.kind == kui_core::QuadKind::Segment)
7039 .collect();
7040 assert!(
7041 segs.len() >= 6,
7042 "a wave under the span and an undercurl over the cells: {}",
7043 segs.len()
7044 );
7045 assert!(
7046 segs.iter().all(|q| q.color == red),
7047 "in the underline colour"
7048 );
7049 let solids: Vec<_> = dl
7050 .quads
7051 .iter()
7052 .filter(|q| q.kind == kui_core::QuadKind::Solid)
7053 .collect();
7054 assert_eq!(solids.len(), 1, "the text's solid line, coloured");
7055 assert_eq!(solids[0].color, Color::hex(0x00ff00ff));
7056 let mut bad = LuaExtension::from_source(
7058 "k4bad",
7059 r#"function view(env) return text({ { "x", underline_style = "squiggly" } }) end"#,
7060 )
7061 .unwrap();
7062 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7063 ui.set_origin(OriginId(1));
7064 let err = bad.view(&Slot::root(), &mut ui).unwrap_err();
7065 assert!(err.contains("solid | wavy | dotted"), "{err}");
7066 }
7067
7068 #[test]
7072 fn a_line_takes_a_dash_as_a_length_a_pair_or_four() {
7073 let segments = |view: &str| -> Result<usize, String> {
7074 let mut ext = LuaExtension::from_source("dash", view).unwrap();
7075 let mut core = Core::new();
7076 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7077 ui.set_origin(OriginId(1));
7078 let lowered = ext.view(&Slot::root(), &mut ui);
7079 ui.finish();
7080 lowered?;
7081 let (dl, _) = core.output();
7082 Ok(dl
7083 .quads
7084 .iter()
7085 .filter(|q| q.kind == kui_core::QuadKind::Segment)
7086 .count())
7087 };
7088 let line = |dash: &str| {
7089 format!(
7090 "function view(env) return column {{ width = 200, height = 40,
7091 line {{ from = {{0, 20}}, to = {{100, 20}}, width = 2, {dash} }} }} end"
7092 )
7093 };
7094 assert_eq!(segments(&line("")), Ok(1));
7095 assert_eq!(segments(&line("dash = 5")), Ok(10));
7097 assert_eq!(segments(&line("dash = {6, 4}")), Ok(10));
7098 assert_eq!(segments(&line("dash = {6, 4}, dash_offset = 5")), Ok(10));
7099 assert_eq!(segments(&line("dash = {10, 4, 2, 4}")), Ok(10));
7100 let err = segments(&line("dash = {1, 2, 3}")).unwrap_err();
7101 assert!(err.contains("dash is a length"), "{err}");
7102 }
7103
7104 #[test]
7108 fn features_are_a_text_option() {
7109 let mut ext = LuaExtension::from_source(
7110 "features",
7111 r#"
7112 function view(env)
7113 return row { text("fi ->", { size = 16 }),
7114 text("fi ->", { size = 16, features = "liga=0 calt=0" }) }
7115 end
7116 "#,
7117 )
7118 .unwrap();
7119 let mut core = Core::new();
7120 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
7121 ui.set_origin(OriginId(1));
7122 ext.view(&Slot::root(), &mut ui).unwrap();
7123 ui.finish();
7124 assert_eq!(core.text_cache_len(), 2);
7125 }
7126
7127 #[test]
7133 fn a_style_table_shared_by_three_texts_is_three_texts() {
7134 let mut ext = LuaExtension::from_source(
7135 "shared",
7136 r#"
7137 local mono = { size = 14, family = "mono" }
7138 function view(env)
7139 return row { text("a", mono), text("bb", mono), text("ccc", mono) }
7140 end
7141 "#,
7142 )
7143 .unwrap();
7144 let mut core = Core::new();
7145 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
7146 ui.set_origin(OriginId(1));
7147 ext.view(&Slot::root(), &mut ui).unwrap();
7148 ui.finish();
7149 let names: Vec<String> = core
7150 .access_tree()
7151 .nodes
7152 .iter()
7153 .filter_map(|n| n.value.clone().or_else(|| n.name.clone()))
7154 .collect();
7155 let joined = names.join("|");
7156 assert!(
7157 joined.contains("a") && joined.contains("bb") && joined.contains("ccc"),
7158 "three different texts, got {joined:?}"
7159 );
7160 assert_eq!(
7161 core.text_cache_len(),
7162 3,
7163 "three strings shaped, not one three times"
7164 );
7165 }
7166
7167 #[test]
7172 fn scripts_slice_a_long_list_from_the_geometry() {
7173 let mut ext = LuaExtension::from_source(
7174 "virtual",
7175 r#"
7176 ROWS, ROW_H, built = 10000, 30, 0
7177 function view(env)
7178 local g = env.scroll_geometry(list_key)
7179 -- No layout yet: fall back to the window for one frame.
7180 local h = g and g.h or 200
7181 local top = g and g.offset.y or 0
7182 local first = math.min(ROWS, math.max(0, math.floor(top / ROW_H)))
7183 local last = math.min(ROWS, math.ceil((top + h) / ROW_H))
7184 local list = { key = "list", width = "grow", height = "grow",
7185 scroll_y = true }
7186 if first > 0 then
7187 list[#list + 1] = row { key = "lead", width = "grow",
7188 height = first * ROW_H }
7189 end
7190 for i = first, last - 1 do
7191 list[#list + 1] = row { key = "row" .. i, width = "grow",
7192 height = ROW_H, bg = 0x282840ff }
7193 end
7194 if last < ROWS then
7195 list[#list + 1] = row { key = "tail", width = "grow",
7196 height = (ROWS - last) * ROW_H }
7197 end
7198 built = last - first
7199 return column(list)
7200 end
7201 "#,
7202 )
7203 .unwrap();
7204 let list = Key::ROOT.str("list");
7205 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
7206 let mut core = Core::new();
7207 let frame = |core: &mut Core, ext: &mut LuaExtension| {
7208 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7209 ui.set_origin(OriginId(1));
7210 ext.view(&Slot::root(), &mut ui).unwrap();
7211 ui.finish();
7212 };
7213
7214 frame(&mut core, &mut ext);
7215 frame(&mut core, &mut ext);
7216 let built: i64 = ext.lua.globals().get("built").unwrap();
7217 assert_eq!(built, 7, "200 / 30 rounded up");
7218
7219 let g = core.scroll_geometry(list).expect("laid out");
7222 assert_eq!(g.content.h, 10_000.0 * 30.0);
7223 core.set_scroll(list, Vec2::new(0.0, 1e9));
7224 frame(&mut core, &mut ext);
7225 frame(&mut core, &mut ext);
7226 assert_eq!(core.scroll_offset(list).y, 10_000.0 * 30.0 - 200.0);
7227 let built: i64 = ext.lua.globals().get("built").unwrap();
7228 assert_eq!(built, 7, "still a screenful at the far end");
7229 }
7230
7231 #[test]
7237 fn the_prelude_virtualizes_a_long_list_in_one_call() {
7238 let mut ext = LuaExtension::from_source(
7239 "virtual",
7240 r#"
7241 ROWS, ROW_H, first_built, built = 10000, 30, -1, 0
7242 function view(env)
7243 built, first_built = 0, -1
7244 return uniform_list(env,
7245 { key = "list", rows = ROWS, row_h = ROW_H,
7246 width = "grow", height = "grow" },
7247 function(i)
7248 built = built + 1
7249 if first_built < 0 then first_built = i end
7250 return column { fill = true, bg = 0x282840ff,
7251 on_click = { kind = "pick", row = i } }
7252 end)
7253 end
7254 "#,
7255 )
7256 .unwrap();
7257 let list = Key::ROOT.str("list");
7258 let mut core = Core::new();
7259 let frame = |core: &mut Core, ext: &mut LuaExtension| {
7260 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7261 ui.set_origin(OriginId(1));
7262 ext.view(&Slot::root(), &mut ui).unwrap();
7263 ui.finish();
7264 };
7265
7266 frame(&mut core, &mut ext);
7267 frame(&mut core, &mut ext);
7268 let built: i64 = ext.lua.globals().get("built").unwrap();
7269 assert_eq!(built, 9, "200 / 30 rounded up, plus two rows of overscan");
7270
7271 let g = core.scroll_geometry(list).expect("laid out");
7274 assert_eq!(g.content.h, 10_000.0 * 30.0);
7275 core.set_scroll(list, Vec2::new(0.0, 300.0 * 30.0));
7276 frame(&mut core, &mut ext);
7277 frame(&mut core, &mut ext);
7278 let first: i64 = ext.lua.globals().get("first_built").unwrap();
7279 assert_eq!(first, 298, "two rows of overscan above row 300");
7280 let built: i64 = ext.lua.globals().get("built").unwrap();
7281 assert_eq!(built, 11, "a screenful with overscan on both sides now");
7282 assert!(
7285 core.access_tree()
7286 .nodes
7287 .iter()
7288 .any(|n| n.key == list.index(300).index(0)),
7289 "row 300 is not keyed by its data index"
7290 );
7291 }
7292
7293 #[test]
7297 fn a_view_names_a_family_and_draws_in_it() {
7298 let mut ext = LuaExtension::from_source(
7299 "fam",
7300 r#"
7301 named, sans, missed = nil, nil, nil
7302 function view(env)
7303 named = env.measure_text("iiiWWW", { family = "Fixture Mono", size = 20 }).width
7304 sans = env.measure_text("iiiWWW", { family = "sans", size = 20 }).width
7305 return column {
7306 text("iiiWWW", { family = "Fixture Mono", size = 20 }),
7307 text("x", { family = "No Such Family 7" }),
7308 }
7309 end
7310 "#,
7311 )
7312 .unwrap();
7313 let mut core = Core::new();
7314 core.add_font_data(kui_core::testing::font_face(
7315 "Fixture Mono",
7316 400,
7317 false,
7318 true,
7319 ))
7320 .unwrap();
7321 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
7322 ui.set_origin(OriginId(1));
7323 ext.view(&Slot::root(), &mut ui).unwrap();
7324 ui.finish();
7325 let handle = core.add_system_font("Fixture Mono").unwrap();
7326 let expected = core
7327 .measure_text("iiiWWW", &kui_core::TextStyle::new(20.0).font(handle), None)
7328 .width;
7329 let g = ext.lua.globals();
7330 let (named, sans) = (
7331 g.get::<f32>("named").unwrap(),
7332 g.get::<f32>("sans").unwrap(),
7333 );
7334 assert_eq!(named, expected, "the face the host's handle draws");
7335 assert_ne!(named, sans, "and not sans");
7336 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
7337 assert_eq!(codes, ["unknown-family"]);
7338 }
7339
7340 #[test]
7343 fn the_prelude_reveals_a_row_styles_rows_and_splits() {
7344 let mut ext = LuaExtension::from_source(
7345 "dx22",
7346 r#"
7347 target, scrolled, page = nil, nil, nil
7348 function view(env)
7349 if target then scrolled = reveal_row(env, "list", target, 20) end
7350 page = rows_in_view(env, "list", 20)
7351 zero = rows_in_view(env, "list", 0)
7352 return row { width = 300, height = 100,
7353 uniform_list(env,
7354 { key = "list", rows = 50, row_h = 20, width = 200, height = 100,
7355 row_props = function(i) return { on_click = { kind = "pick", row = i } } end },
7356 function(i) return text("row " .. i) end),
7357 splitter(env, { key = "bar", on_drag = { kind = "split" } }),
7358 column { width = "grow", height = "grow" },
7359 }
7360 end
7361 "#,
7362 )
7363 .unwrap();
7364 let mut core = Core::new();
7365 let frame = |core: &mut Core, ext: &mut LuaExtension| {
7366 let mut ui = core.frame(Size::new(300.0, 100.0), 1.0);
7367 ui.set_origin(OriginId(1));
7368 ext.view(&Slot::root(), &mut ui).unwrap();
7369 ui.finish();
7370 };
7371 frame(&mut core, &mut ext);
7372 frame(&mut core, &mut ext);
7373 assert_eq!(ext.lua.globals().get::<i64>("page").unwrap(), 5);
7374 assert!(
7375 core.take_warnings().is_empty(),
7376 "every prop the three spell is known"
7377 );
7378
7379 ext.lua.globals().set("target", 40).unwrap();
7380 frame(&mut core, &mut ext);
7381 assert!(ext.lua.globals().get::<bool>("scrolled").unwrap());
7382 let list = core.key_of("list").unwrap();
7383 assert_eq!(
7384 core.scroll_offset(list),
7385 Vec2::new(0.0, 760.0),
7386 "row 40 to the middle"
7387 );
7388 core.handle_input(InputEvent::CursorMoved(Vec2::new(10.0, 45.0)));
7391 core.handle_input(InputEvent::mouse_down(1));
7392 let up = core.handle_input(InputEvent::mouse_up());
7393 assert_eq!(up.iter().find_map(|e| e.payload.get_int("row")), Some(40));
7394
7395 core.handle_input(InputEvent::CursorMoved(Vec2::new(202.0, 50.0)));
7397 core.handle_input(InputEvent::mouse_down(1));
7398 core.handle_input(InputEvent::CursorMoved(Vec2::new(225.0, 50.0)));
7399 let end = core.handle_input(InputEvent::mouse_up());
7400 let d = end.iter().find_map(|e| e.drag()).expect("the drag's end");
7401 assert_eq!(d.ratio().x, 0.75);
7402
7403 ext.lua.globals().set("target", 500).unwrap();
7405 frame(&mut core, &mut ext);
7406 assert!(!ext.lua.globals().get::<bool>("scrolled").unwrap());
7407 assert_eq!(core.scroll_offset(list), Vec2::new(0.0, 760.0));
7408 assert_eq!(ext.lua.globals().get::<i64>("zero").unwrap(), 0);
7409 }
7410
7411 #[test]
7415 fn a_uniform_list_that_shrank_lands_in_one_frame() {
7416 let mut ext = LuaExtension::from_source(
7417 "virtual",
7418 r#"
7419 ROWS, first_built = 200, -1
7420 function view(env)
7421 first_built = -1
7422 return uniform_list(env,
7423 { key = "list", rows = ROWS, row_h = 20,
7424 width = "grow", height = "grow" },
7425 function(i)
7426 if first_built < 0 then first_built = i end
7427 return column { fill = true, bg = 0x282840ff }
7428 end)
7429 end
7430 "#,
7431 )
7432 .unwrap();
7433 let list = Key::ROOT.str("list");
7434 let mut core = Core::new();
7435 let frame = |core: &mut Core, ext: &mut LuaExtension| {
7436 let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
7437 ui.set_origin(OriginId(1));
7438 ext.view(&Slot::root(), &mut ui).unwrap();
7439 ui.finish();
7440 };
7441 frame(&mut core, &mut ext);
7442 frame(&mut core, &mut ext);
7443 core.set_scroll(list, Vec2::new(0.0, 3_000.0));
7444 frame(&mut core, &mut ext);
7445 frame(&mut core, &mut ext);
7446 let deep: i64 = ext.lua.globals().get("first_built").unwrap();
7447 assert!(
7448 deep > 100,
7449 "expected to be deep in the list, built from {deep}"
7450 );
7451
7452 ext.lua.globals().set("ROWS", 10).unwrap();
7453 frame(&mut core, &mut ext);
7454 let g = core.scroll_geometry(list).expect("laid out");
7455 assert_eq!(
7456 g.content.h,
7457 10.0 * 20.0,
7458 "the content is the list it has now"
7459 );
7460 frame(&mut core, &mut ext);
7461 let first: i64 = ext.lua.globals().get("first_built").unwrap();
7462 assert_eq!(first, 0, "and every row of it is built");
7463 assert_eq!(core.scroll_offset(list).y, 0.0);
7464 }
7465
7466 const VARIABLE_LIST: &str = r#"
7471 heights = row_heights(1000, 20)
7472 measured = 0
7473 function view(env)
7474 return list(env,
7475 { key = "list", heights = heights, width = "grow", height = "grow",
7476 transition = TRANSITION },
7477 function(i, width)
7478 measured = measured + 1
7479 if i < 100 then return 20 else return 60 end
7480 end,
7481 function(i)
7482 return column { width = "grow", height = "grow", bg = 0x282840ff,
7483 label = "row", on_click = i }
7484 end)
7485 end
7486 "#;
7487
7488 fn variable_frame(core: &mut Core, ext: &mut LuaExtension, t: f64) {
7490 core.set_time(t);
7491 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
7492 ui.set_origin(OriginId(1));
7493 ext.view(&Slot::root(), &mut ui).unwrap();
7494 ui.finish();
7495 }
7496
7497 fn row_under(core: &mut Core, y: f32) -> Option<i64> {
7500 core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, y)));
7501 core.handle_input(InputEvent::mouse_down(1));
7502 let evs = core.handle_input(InputEvent::mouse_up());
7503 evs.first()?.payload.as_int()
7504 }
7505
7506 #[test]
7510 fn a_lua_list_keeps_the_row_under_the_pointer_while_the_estimate_moves() {
7511 let mut ext = LuaExtension::from_source("variable", VARIABLE_LIST).unwrap();
7512 let list = Key::ROOT.str("list");
7513 let mut core = Core::new();
7514 variable_frame(&mut core, &mut ext, 0.0);
7515 variable_frame(&mut core, &mut ext, 0.0);
7516 let measured: i64 = ext.lua.globals().get("measured").unwrap();
7517 assert!(
7518 (10..=20).contains(&measured),
7519 "a screenful measured, not the list: {measured}"
7520 );
7521
7522 core.set_scroll(list, Vec2::new(0.0, 5_000.0));
7523 variable_frame(&mut core, &mut ext, 0.0);
7524 let settled = row_under(&mut core, 4.0);
7525 assert!(settled.is_some(), "nothing under the top edge");
7526 for n in 0..4 {
7527 variable_frame(&mut core, &mut ext, 0.0);
7528 assert_eq!(
7529 row_under(&mut core, 4.0),
7530 settled,
7531 "the content slid on frame {n} as the estimate moved"
7532 );
7533 }
7534 let g = core.scroll_geometry(list).expect("laid out");
7535 assert!(
7536 g.content.h > 1000.0 * 20.0 * 1.5,
7537 "the list learned it is longer: {}",
7538 g.content.h
7539 );
7540 }
7541
7542 #[test]
7546 fn a_lua_list_glides_to_the_row_asked_for() {
7547 let mut ext =
7548 LuaExtension::from_source("variable", &format!("TRANSITION = 100\n{VARIABLE_LIST}"))
7549 .unwrap();
7550 let list = Key::ROOT.str("list");
7551 let mut core = Core::new();
7552 let mut t = 0.0;
7553 variable_frame(&mut core, &mut ext, t);
7554 variable_frame(&mut core, &mut ext, t);
7555 let target = 400;
7556 let offset: f32 = ext
7557 .lua
7558 .load(format!("return heights:offset_of({target})"))
7559 .eval()
7560 .unwrap();
7561 core.set_scroll(list, Vec2::new(0.0, offset));
7562 for _ in 0..30 {
7563 t += 1.0 / 60.0;
7564 variable_frame(&mut core, &mut ext, t);
7565 }
7566 assert_eq!(row_under(&mut core, 4.0), Some(target));
7567 }
7568
7569 #[test]
7573 fn a_script_asks_for_a_file_dialog_and_hears_the_answer() {
7574 let mut ext = LuaExtension::from_source(
7575 "files",
7576 r#"
7577 ask, asked, again, waiting, heard = true, nil, nil, nil, nil
7578 function view(env)
7579 if ask then
7580 asked = env.request_files {
7581 mode = "save", title = "Export", file_name = "notes.md",
7582 filters = { { name = "Markdown", extensions = { "md" } } },
7583 tag = "export",
7584 }
7585 again = env.request_files {}
7586 waiting = env.awaiting_files()
7587 ask = false
7588 end
7589 return column {}
7590 end
7591 function on_event(ev)
7592 if ev.kind == "files" then heard = ev.paths[1] .. "|" .. ev.tag end
7593 end
7594 "#,
7595 )
7596 .unwrap();
7597 let mut core = Core::new();
7598 variable_frame(&mut core, &mut ext, 0.0);
7599 let g = ext.lua.globals();
7600 assert!(g.get::<bool>("asked").unwrap(), "the first ask is taken");
7601 assert!(
7602 !g.get::<bool>("again").unwrap(),
7603 "a second while it is out is not"
7604 );
7605 assert!(g.get::<bool>("waiting").unwrap());
7606
7607 let asks = core.take_file_requests();
7608 assert_eq!(asks.len(), 1);
7609 let d = &asks[0];
7610 assert_eq!(d.mode, kui_core::FileDialogMode::Save);
7611 assert_eq!(d.file_name.as_deref(), Some("notes.md"));
7612 assert_eq!(d.filters[0].extensions, vec!["md".to_string()]);
7613
7614 let evs = core.handle_input(InputEvent::Files(vec!["/tmp/notes.md".into()]));
7615 assert_eq!(evs.len(), 1);
7616 assert_eq!(
7617 evs[0].origin,
7618 OriginId(1),
7619 "the answer goes to the script that asked"
7620 );
7621 for e in &evs {
7622 ext.on_event(e);
7623 }
7624 let heard: String = ext.lua.globals().get("heard").unwrap();
7625 assert_eq!(heard, "/tmp/notes.md|export");
7626 }
7627
7628 #[test]
7632 fn a_host_hands_a_script_the_documents_the_os_opened() {
7633 let mut ext = LuaExtension::from_source(
7634 "open",
7635 r#"
7636 heard = nil
7637 function view(env) return column {} end
7638 function on_event(ev)
7639 if ev.kind == "open" then heard = #ev.paths .. "|" .. ev.paths[2] end
7640 end
7641 "#,
7642 )
7643 .unwrap();
7644 let mut core = Core::new();
7645 variable_frame(&mut core, &mut ext, 0.0);
7646 let evs = core.handle_input(InputEvent::Open(vec![
7647 "/tmp/a.txt".into(),
7648 "/tmp/b.md".into(),
7649 ]));
7650 assert_eq!(evs.len(), 1);
7651 assert_eq!(evs[0].origin, OriginId::HOST, "nobody asked: the host's");
7652 assert_eq!(evs[0].key, kui_core::Key::ROOT);
7653 for e in &evs {
7654 ext.on_event(e);
7655 }
7656 let heard: String = ext.lua.globals().get("heard").unwrap();
7657 assert_eq!(heard, "2|/tmp/b.md");
7658 assert!(
7659 core.handle_input(InputEvent::Open(Vec::new())).is_empty(),
7660 "no documents is no event"
7661 );
7662 }
7663
7664 #[test]
7667 fn a_lua_list_names_what_it_is_missing() {
7668 let mut ext = LuaExtension::from_source(
7669 "bad",
7670 r#"
7671 function view(env)
7672 ok, err = pcall(list, env, { key = "list" }, function() return 1 end,
7673 function() return column {} end)
7674 return column {}
7675 end
7676 "#,
7677 )
7678 .unwrap();
7679 let mut core = Core::new();
7680 variable_frame(&mut core, &mut ext, 0.0);
7681 let err: String = ext.lua.globals().get("err").unwrap();
7682 assert!(err.contains("row_heights"), "{err}");
7683 }
7684
7685 #[test]
7689 fn a_query_answers_for_an_undeclared_label_and_a_command_refuses() {
7690 let mut ext = LuaExtension::from_source(
7691 "queries",
7692 r#"
7693 function view(env)
7694 geom = env.scroll_geometry("nothing")
7695 off = env.scroll_offset("nothing")
7696 hovered = env.is_hovered("nothing")
7697 pressed = env.is_pressed("nothing")
7698 focused = env.is_focused("nothing")
7699 hit = env.text_hit("nothing", 1, 1)
7700 caret = env.caret_rect("nothing", 0)
7701 deferred = pcall(function() env.set_scroll("nothing", 0, 0) end)
7702 refused_focus = not pcall(function() env.set_focus("nothing") end)
7703 return column { width = "grow", height = "grow" }
7704 end
7705 "#,
7706 )
7707 .unwrap();
7708 let mut core = Core::new();
7709 let mut ui = core.frame(Size::new(200.0, 100.0), 1.0);
7710 ext.view(&Slot::root(), &mut ui).unwrap();
7711 ui.finish();
7712 let g = ext.lua.globals();
7713 assert_eq!(g.get::<mlua::Value>("geom").unwrap(), mlua::Value::Nil);
7714 assert_eq!(g.get::<mlua::Value>("hit").unwrap(), mlua::Value::Nil);
7715 assert_eq!(g.get::<mlua::Value>("caret").unwrap(), mlua::Value::Nil);
7716 assert!(!g.get::<bool>("hovered").unwrap());
7717 assert!(!g.get::<bool>("pressed").unwrap());
7718 assert!(!g.get::<bool>("focused").unwrap());
7719 let off: Table = g.get("off").unwrap();
7720 assert_eq!(off.get::<f32>("y").unwrap(), 0.0);
7721 assert!(g.get::<bool>("deferred").unwrap());
7724 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
7725 assert_eq!(codes, ["label-without-node"]);
7726 assert!(g.get::<bool>("refused_focus").unwrap());
7727 }
7728
7729 #[test]
7730 fn bad_script_reports_error_not_panic() {
7731 let mut ext = LuaExtension::from_source("bad", "function view() return 5 end").unwrap();
7732 let mut core = Core::new();
7733 let mut ui = core.frame(Size::new(100.0, 100.0), 1.0);
7734 assert!(ext.view(&Slot::root(), &mut ui).is_err());
7735 }
7736}