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