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