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