1use kui_core::schema::{self, Kind, Parsed, PropsOut};
89use kui_core::{
90 Align, Color, Content, EditOptions, Extension, FloatConfig, Key, PadShorthand, Sizing, Slot,
91 Span, Ui, UiEvent, Value, WindowConfig, widgets,
92};
93use mlua::{Lua, Table};
94
95mod meta;
96mod rows;
97pub use meta::luals_meta;
98
99const PRELUDE: &str = include_str!("prelude.lua");
100
101pub struct LuaExtension {
102 lua: Lua,
103 name: String,
104 slots: Vec<String>,
109 loaded: std::cell::RefCell<Vec<Loaded>>,
114 slot_names: std::collections::HashMap<kui_core::Key, String>,
119 tokens: Option<kui_core::Tokens>,
124}
125
126struct Loaded {
129 namespace: String,
130 path: std::path::PathBuf,
131 origin: kui_core::OriginId,
132}
133
134impl LuaExtension {
135 pub fn from_source(name: impl Into<String>, source: &str) -> mlua::Result<Self> {
136 let lua = Lua::new();
137 rows::register(&lua)?;
138 lua.load(PRELUDE).set_name("kui:prelude").exec()?;
139 let name = name.into();
140 lua.load(source).set_name(&name).exec()?;
141 let slots = match lua.globals().get::<mlua::Value>("slots")? {
142 mlua::Value::Nil => Vec::new(),
143 mlua::Value::Table(t) => t.sequence_values::<String>().collect::<mlua::Result<_>>()?,
144 other => {
145 return Err(mlua::Error::runtime(format!(
146 "`slots` must be a list of slot names, not {}",
147 other.type_name()
148 )));
149 }
150 };
151 let tokens = match lua.globals().get::<mlua::Value>("tokens")? {
152 mlua::Value::Nil => None,
153 mlua::Value::Table(t) => Some(parse_tokens(&t)?),
154 other => {
155 return Err(mlua::Error::runtime(format!(
156 "`tokens` must be a table {{ colors = …, lengths = … }}, not {}",
157 other.type_name()
158 )));
159 }
160 };
161 Ok(Self {
162 lua,
163 name,
164 slots,
165 loaded: Default::default(),
166 slot_names: Default::default(),
167 tokens,
168 })
169 }
170
171 fn loaded_as(&self, origin: kui_core::OriginId) -> Option<String> {
174 self.loaded
175 .borrow()
176 .iter()
177 .find(|l| l.origin == origin)
178 .map(|l| l.namespace.clone())
179 }
180
181 pub fn lua(&self) -> &Lua {
186 &self.lua
187 }
188
189 pub fn from_file(path: impl AsRef<std::path::Path>) -> mlua::Result<Self> {
190 let path = path.as_ref();
191 let source = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
192 let name = path
193 .file_name()
194 .map_or_else(|| "lua".into(), |n| n.to_string_lossy().into_owned());
195 Self::from_source(name, &source)
196 }
197}
198
199impl Extension for LuaExtension {
200 fn name(&self) -> &str {
201 &self.name
202 }
203
204 fn slots(&self) -> &[String] {
205 &self.slots
206 }
207
208 fn view(&mut self, slot: &Slot<'_>, ui: &mut Ui<'_>) -> Result<(), String> {
209 let view: mlua::Function = self
210 .lua
211 .globals()
212 .get("view")
213 .map_err(|_| "script defines no view()".to_string())?;
214 let slot_table = slot_table(&self.lua, slot).map_err(|e| format!("slot: {e}"))?;
220 self.slot_names
221 .entry(slot.key)
222 .or_insert_with(|| slot.full_name());
223 let origin = ui.core().origin();
227 if let Some(t) = &self.tokens
228 && !ui.core().tokens_declared(origin)
229 {
230 ui.set_tokens(t.clone());
231 }
232 let root: Table = {
237 let frame = std::cell::RefCell::new(&mut *ui);
238 self.lua
239 .scope(|scope| {
240 let env = env_table(&self.lua, scope, &frame, &self.loaded)?;
241 view.call((env, slot_table))
242 })
243 .map_err(|e| format!("view(): {e}"))?
244 };
245 if let Ok(Some(title)) = root.get::<Option<String>>("window_title") {
247 ui.window_title(&title);
248 }
249 if let Ok(Some(true)) = root.get::<Option<bool>>("always_on_top") {
250 ui.always_on_top(true);
251 }
252 if let Ok(Some(true)) = root.get::<Option<bool>>("secure_input") {
255 ui.secure_input(true);
256 }
257 if let Some(name) = root
264 .get::<Option<String>>("option_as_alt")
265 .map_err(|e| format!("option_as_alt: {e}"))?
266 {
267 let v = kui_core::OptionAsAlt::from_name(&name).ok_or_else(|| {
268 format!(
269 "option_as_alt: expected \"none\", \"left\", \"right\" or \"both\", got {name:?}"
270 )
271 })?;
272 if v != kui_core::OptionAsAlt::None {
273 ui.option_as_alt(v);
274 }
275 }
276 declare_windows(ui, &root).map_err(|e| format!("windows: {e}"))?;
277 build_node(ui, &root).map_err(|e| format!("view table: {e}"))
278 }
279
280 fn on_event(&mut self, ev: &UiEvent) -> Vec<Value> {
281 let Ok(f) = self.lua.globals().get::<mlua::Function>("on_event") else {
282 return Vec::new();
283 };
284 let payload = value_to_lua(&self.lua, &ev.payload).and_then(|p| {
285 if let mlua::Value::Table(t) = &p
289 && !t.contains_key("node_key")?
290 {
291 t.set("node_key", ev.key.0 as i64)?;
292 t.set("window", ev.window.0)?;
297 if let Some(name) = ev.slot.and_then(|k| self.slot_names.get(&k)) {
303 t.set("slot", name.as_str())?;
304 }
305 if let Some(ns) = self.loaded_as(ev.origin) {
313 t.set("from", ns)?;
314 }
315 }
316 Ok(p)
317 });
318 match payload.and_then(|p| f.call::<mlua::Value>(p)) {
323 Ok(mlua::Value::Nil) => Vec::new(),
324 Ok(v) => match lua_to_value(&v) {
325 Ok(Value::List(replies)) => replies,
326 Ok(reply) => vec![reply],
327 Err(e) => {
328 eprintln!("kui-lua: '{}' on_event reply: {e}", self.name);
329 Vec::new()
330 }
331 },
332 Err(e) => {
333 eprintln!("kui-lua: '{}' on_event error: {e}", self.name);
334 Vec::new()
335 }
336 }
337 }
338}
339
340fn slot_table(lua: &Lua, slot: &Slot<'_>) -> mlua::Result<Table> {
347 let t = lua.create_table()?;
348 t.set("name", slot.name)?;
349 t.set("namespace", slot.namespace)?;
350 t.set("key", slot.key.0 as i64)?;
351 if !matches!(slot.params, Value::Null) {
352 t.set("params", value_to_lua(lua, slot.params)?)?;
353 }
354 Ok(t)
355}
356
357fn key_arg(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Key> {
363 match v {
364 mlua::Value::Integer(i) => Ok(Key(i as u64)),
365 mlua::Value::String(s) => {
366 let label = s.to_str()?;
367 ui.key_of(&label).ok_or_else(|| {
368 mlua::Error::runtime(format!(
369 "no node is keyed {:?}: pass the label a `key` field declared in this or the last \
370 frame, or the integer key an event carried",
371 &*label
372 ))
373 })
374 }
375 other => Err(mlua::Error::runtime(format!(
376 "a node key is an integer or a declared label, not {}",
377 other.type_name()
378 ))),
379 }
380}
381
382fn key_query(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Option<Key>> {
393 match v {
394 mlua::Value::Integer(i) => Ok(Some(Key(i as u64))),
395 mlua::Value::String(s) => Ok(ui.key_of(&s.to_str()?)),
396 other => Err(mlua::Error::runtime(format!(
397 "a node key is an integer or a declared label, not {}",
398 other.type_name()
399 ))),
400 }
401}
402
403fn lua_list_to_value(t: &Table) -> mlua::Result<Value> {
406 let mut items = Vec::with_capacity(t.raw_len());
407 for item in t.sequence_values::<mlua::Value>() {
408 items.push(lua_to_value(&item?)?);
409 }
410 Ok(Value::List(items))
411}
412
413fn alias_menu_role(row: &mut Value) {
418 let Value::Map(fields) = row else { return };
419 for (k, v) in fields.iter_mut() {
420 if k == "role"
421 && let Value::Str(name) = v
422 {
423 match name.as_str() {
424 "select_all" => *name = kui_core::MenuRole::SelectAll.name().to_string(),
425 "look_up" => *name = kui_core::MenuRole::LookUp.name().to_string(),
426 _ => {}
427 }
428 }
429 }
430}
431
432fn menu_items(t: &mlua::Table) -> mlua::Result<Vec<kui_core::MenuItem>> {
435 let mut rows = lua_list_to_value(t)?;
436 if let Value::List(rows) = &mut rows {
437 rows.iter_mut().for_each(alias_menu_role);
438 }
439 kui_core::MenuItem::list_from_value(&rows).map_err(mlua::Error::runtime)
440}
441
442fn env_table<'scope, 'env: 'scope>(
489 lua: &Lua,
490 scope: &'scope mlua::Scope<'scope, 'env>,
491 ui: &'env std::cell::RefCell<&'env mut Ui<'_>>,
492 loaded: &'env std::cell::RefCell<Vec<Loaded>>,
493) -> mlua::Result<Table> {
494 let (facts, theme, metrics) = {
495 let ui = ui.borrow();
496 (ui.env_facts(), ui.theme(), ui.metrics())
497 };
498 let t = lua.create_table()?;
499 t.set("tokens", tokens_table(lua, &ui.borrow())?)?;
505 for row in kui_core::schema::ENV_FIELDS {
518 let value = (row.get)(&facts);
519 if value == Value::Null {
520 continue;
521 }
522 if row.name == "window.native_controls" {
523 let win = t
524 .get::<Option<Table>>("window")?
525 .unwrap_or(lua.create_table()?);
526 let f = |k: &str| value.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
527 win.set("controls_w", f("x") + f("w"))?;
528 win.set("controls_h", f("y") + f("h"))?;
529 t.set("window", win)?;
530 continue;
531 }
532 for path in row.lua {
533 let lua_value = value_to_lua(lua, &value)?;
534 match path.split_once('.') {
535 None => t.set(*path, lua_value)?,
536 Some((head, leaf)) => {
537 let sub = t.get::<Option<Table>>(head)?.unwrap_or(lua.create_table()?);
538 sub.set(leaf, lua_value)?;
539 t.set(head, sub)?;
540 }
541 }
542 }
543 }
544 let th = lua.create_table()?;
553 for role in kui_core::schema::THEME_ROLES {
554 th.set(role.name, (role.get)(&theme).to_hex())?;
555 }
556 th.set("appearance", theme.appearance.name())?;
557 th.set("disabled_opacity", theme.disabled_opacity)?;
558 t.set("theme", th)?;
559 let mt = lua.create_table()?;
564 for role in kui_core::schema::METRIC_ROLES {
565 mt.set(role.name, (role.get)(&metrics))?;
566 }
567 t.set("metrics", mt)?;
568 t.set(
574 "edit_text",
575 scope.create_function(move |_, key: mlua::Value| {
576 let mut ui = ui.borrow_mut();
577 let Some(key) = key_query(&mut ui, key)? else {
578 return Ok(None);
579 };
580 Ok(ui.edit_text(key))
581 })?,
582 )?;
583 t.set(
592 "set_edit_text",
593 scope.create_function(move |_, (key, text): (mlua::Value, String)| {
594 let mut ui = ui.borrow_mut();
595 match key {
596 mlua::Value::String(label) => {
597 ui.set_edit_text_by_label(&label.to_str()?, &text);
598 }
599 other => {
600 let key = key_arg(&mut ui, other)?;
601 ui.set_edit_text(key, &text);
602 }
603 }
604 Ok(())
605 })?,
606 )?;
607 t.set(
610 "is_focused",
611 scope.create_function(move |_, key: mlua::Value| {
612 let mut ui = ui.borrow_mut();
613 let Some(key) = key_query(&mut ui, key)? else {
614 return Ok(false);
615 };
616 Ok(ui.is_focused(key))
617 })?,
618 )?;
619 t.set(
620 "is_hovered",
621 scope.create_function(move |_, key: mlua::Value| {
622 let mut ui = ui.borrow_mut();
623 let Some(key) = key_query(&mut ui, key)? else {
624 return Ok(false);
625 };
626 Ok(ui.is_hovered(key))
627 })?,
628 )?;
629 t.set(
633 "is_pressed",
634 scope.create_function(move |_, key: mlua::Value| {
635 let mut ui = ui.borrow_mut();
636 let Some(key) = key_query(&mut ui, key)? else {
637 return Ok(false);
638 };
639 Ok(ui.is_pressed(key))
640 })?,
641 )?;
642 t.set(
645 "is_drop_target",
646 scope.create_function(move |_, key: mlua::Value| {
647 let mut ui = ui.borrow_mut();
648 let Some(key) = key_query(&mut ui, key)? else {
649 return Ok(false);
650 };
651 Ok(ui.is_drop_target(key))
652 })?,
653 )?;
654 t.set(
657 "drop_target",
658 scope.create_function(move |_, ()| {
659 let ui = ui.borrow();
660 Ok(ui.drop_target().map(|k| k.0 as i64))
661 })?,
662 )?;
663 t.set(
672 "set_focus",
673 scope.create_function(move |_, key: mlua::Value| {
674 let mut ui = ui.borrow_mut();
675 let key = key_arg(&mut ui, key)?;
676 ui.focus(key);
677 Ok(())
678 })?,
679 )?;
680 t.set(
681 "blur",
682 scope.create_function(move |_, ()| {
683 ui.borrow_mut().blur();
684 Ok(())
685 })?,
686 )?;
687 t.set(
694 "focus_next",
695 scope.create_function(move |_, ()| {
696 ui.borrow_mut().focus_next();
697 Ok(())
698 })?,
699 )?;
700 t.set(
701 "focus_prev",
702 scope.create_function(move |_, ()| {
703 ui.borrow_mut().focus_prev();
704 Ok(())
705 })?,
706 )?;
707 t.set(
713 "focus_region",
714 scope.create_function(move |_, key: mlua::Value| {
715 let mut ui = ui.borrow_mut();
716 let key = match key {
717 mlua::Value::Nil => None,
718 v => Some(key_arg(&mut ui, v)?),
719 };
720 ui.focus_region(key);
721 Ok(())
722 })?,
723 )?;
724 t.set(
735 "announce",
736 scope.create_function(move |_, (text, live): (String, Option<String>)| {
737 let live = live.unwrap_or_else(|| "polite".to_string());
738 let Some(i) = schema::LIVE.iter().position(|v| *v == live) else {
739 return Err(mlua::Error::runtime(format!(
740 "bad politeness {live:?} (one of {})",
741 schema::LIVE.join(" | ")
742 )));
743 };
744 ui.borrow_mut()
745 .announce(&text, kui_core::Live::from_index(i));
746 Ok(())
747 })?,
748 )?;
749 t.set(
763 "reveal",
764 scope.create_function(move |_, key: mlua::Value| {
765 let mut ui = ui.borrow_mut();
766 match key {
767 mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
768 ui.reveal_label(&s.to_str()?);
769 }
770 key => {
771 let key = key_arg(&mut ui, key)?;
772 ui.reveal(key);
773 }
774 }
775 Ok(())
776 })?,
777 )?;
778 t.set(
782 "scroll_offset",
783 scope.create_function(move |lua, key: mlua::Value| {
784 let mut ui = ui.borrow_mut();
785 let off = match key_query(&mut ui, key)? {
786 Some(key) => ui.scroll_offset(key),
787 None => kui_core::Vec2::ZERO,
788 };
789 let r = lua.create_table()?;
790 r.set("x", off.x)?;
791 r.set("y", off.y)?;
792 Ok(r)
793 })?,
794 )?;
795 t.set(
810 "text_hit",
811 scope.create_function(move |lua, (key, x, y): (mlua::Value, f32, f32)| {
812 let mut ui = ui.borrow_mut();
813 let Some(key) = key_query(&mut ui, key)? else {
814 return Ok(mlua::Value::Nil);
815 };
816 let Some(h) = ui.text_hit(key, kui_core::Vec2::new(x, y)) else {
817 return Ok(mlua::Value::Nil);
818 };
819 value_to_lua(lua, &h.to_value())
820 })?,
821 )?;
822 t.set(
830 "open_menu",
831 scope.create_function(
832 move |_, (key, x, y, items): (mlua::Value, f32, f32, mlua::Table)| {
833 let mut ui = ui.borrow_mut();
834 let Some(target) = key_query(&mut ui, key)? else {
835 return Ok(false);
836 };
837 let items = menu_items(&items)?;
838 ui.open_menu(kui_core::Menu::new(
839 target,
840 kui_core::Vec2::new(x, y),
841 items,
842 ));
843 Ok(true)
844 },
845 )?,
846 )?;
847 t.set(
849 "close_menu",
850 scope.create_function(move |_, ()| {
851 let mut ui = ui.borrow_mut();
852 Ok(ui.close_menu())
853 })?,
854 )?;
855 t.set(
860 "selection_text",
861 scope.create_function(move |_, ()| {
862 let ui = ui.borrow();
863 Ok(ui.selection_text())
864 })?,
865 )?;
866 t.set(
872 "request_copy",
873 scope.create_function(move |_, ()| {
874 let mut ui = ui.borrow_mut();
875 Ok(match ui.request_copy() {
876 kui_core::CopyRequest::Ready(text) => (Some(text), false),
877 kui_core::CopyRequest::Asked => (None, true),
878 kui_core::CopyRequest::Nothing => (None, false),
879 })
880 })?,
881 )?;
882 t.set(
885 "answer_selection_range",
886 scope.create_function(move |_, text: String| {
887 let mut ui = ui.borrow_mut();
888 Ok(ui.answer_selection_range(&text))
889 })?,
890 )?;
891 t.set(
897 "set_clipboard",
898 scope.create_function(move |_, (text, html): (String, Option<String>)| {
899 ui.borrow_mut().set_clipboard(text, html);
900 Ok(())
901 })?,
902 )?;
903 t.set(
907 "set_clipboard_secret",
908 scope.create_function(move |_, text: String| {
909 ui.borrow_mut().set_clipboard_secret(text);
910 Ok(())
911 })?,
912 )?;
913 t.set(
914 "request_paste",
915 scope.create_function(move |_, ()| {
916 ui.borrow_mut().request_paste();
917 Ok(())
918 })?,
919 )?;
920 t.set(
921 "awaiting_paste",
922 scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_paste()))?,
923 )?;
924 t.set(
930 "request_files",
931 scope.create_function(move |_, opts: Option<mlua::Value>| {
932 let v = match &opts {
933 Some(o) => lua_to_value(o)?,
934 None => Value::Null,
935 };
936 let dialog = kui_core::FileDialog::from_value(&v)
937 .map_err(|e| mlua::Error::runtime(format!("request_files: {e}")))?;
938 Ok(ui.borrow_mut().request_files(dialog))
939 })?,
940 )?;
941 t.set(
942 "awaiting_files",
943 scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_files()))?,
944 )?;
945 t.set(
952 "selection_ends",
953 scope.create_function(move |lua, ()| {
954 let ui = ui.borrow();
955 let Some((a, f)) = ui.selection_ends() else {
956 return Ok(mlua::Value::Nil);
957 };
958 let end = |e: kui_core::RangeEnd| -> mlua::Result<mlua::Table> {
959 let t = lua.create_table()?;
960 if let Some(r) = e.row {
961 t.set("index", r)?;
962 }
963 t.set("byte", e.byte)?;
964 Ok(t)
965 };
966 let out = lua.create_table()?;
967 out.set("anchor", end(a)?)?;
968 out.set("focus", end(f)?)?;
969 Ok(mlua::Value::Table(out))
970 })?,
971 )?;
972 t.set(
980 "cell_selection",
981 scope.create_function(move |lua, ()| {
982 let ui = ui.borrow();
983 match ui.cell_selection() {
984 Some(sel) => value_to_lua(lua, &sel.to_value(kui_core::Handles::INT)),
985 None => Ok(mlua::Value::Nil),
986 }
987 })?,
988 )?;
989 t.set(
993 "selection_html",
994 scope.create_function(move |_, ()| {
995 let ui = ui.borrow();
996 Ok(ui.selection_html())
997 })?,
998 )?;
999 t.set(
1004 "select_all_in",
1005 scope.create_function(move |_, key: mlua::Value| {
1006 let mut ui = ui.borrow_mut();
1007 let Some(key) = key_query(&mut ui, key)? else {
1008 return Ok(false);
1009 };
1010 Ok(ui.select_all_in(key))
1011 })?,
1012 )?;
1013 t.set(
1016 "clear_selection",
1017 scope.create_function(move |_, ()| {
1018 let mut ui = ui.borrow_mut();
1019 Ok(ui.clear_selection())
1020 })?,
1021 )?;
1022 t.set(
1026 "caret_rect",
1027 scope.create_function(move |lua, (key, byte): (mlua::Value, usize)| {
1028 let mut ui = ui.borrow_mut();
1029 let Some(key) = key_query(&mut ui, key)? else {
1030 return Ok(mlua::Value::Nil);
1031 };
1032 let Some(r) = ui.caret_rect(key, byte) else {
1033 return Ok(mlua::Value::Nil);
1034 };
1035 value_to_lua(lua, &r.to_value())
1036 })?,
1037 )?;
1038 t.set(
1039 "scroll_geometry",
1040 scope.create_function(move |lua, key: mlua::Value| {
1041 let mut ui = ui.borrow_mut();
1042 let Some(key) = key_query(&mut ui, key)? else {
1043 return Ok(mlua::Value::Nil);
1044 };
1045 let Some(g) = ui.scroll_geometry(key) else {
1046 return Ok(mlua::Value::Nil);
1047 };
1048 value_to_lua(lua, &g.to_value())
1050 })?,
1051 )?;
1052 t.set(
1055 "layout_of",
1056 scope.create_function(move |lua, key: mlua::Value| {
1057 let mut ui = ui.borrow_mut();
1058 let Some(key) = key_query(&mut ui, key)? else {
1059 return Ok(mlua::Value::Nil);
1060 };
1061 let Some(r) = ui.layout_of(key) else {
1062 return Ok(mlua::Value::Nil);
1063 };
1064 value_to_lua(lua, &r.to_value())
1065 })?,
1066 )?;
1067 t.set(
1070 "set_scroll",
1071 scope.create_function(move |_, (key, x, y): (mlua::Value, f32, f32)| {
1072 let mut ui = ui.borrow_mut();
1073 let at = kui_core::Vec2::new(x, y);
1074 match key {
1077 mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
1078 ui.set_scroll_label(&s.to_str()?, at);
1079 }
1080 key => {
1081 let key = key_arg(&mut ui, key)?;
1082 ui.set_scroll(key, at);
1083 }
1084 }
1085 Ok(())
1086 })?,
1087 )?;
1088 t.set(
1093 "shift_scroll",
1094 scope.create_function(move |_, (key, drawn, target): (mlua::Value, f32, f32)| {
1095 let mut ui = ui.borrow_mut();
1096 if let Some(key) = key_query(&mut ui, key)? {
1097 ui.shift_scroll(
1098 key,
1099 kui_core::Vec2::new(0.0, drawn),
1100 kui_core::Vec2::new(0.0, target),
1101 );
1102 }
1103 Ok(())
1104 })?,
1105 )?;
1106 t.set(
1114 "set_window_size",
1115 scope.create_function(move |_, (window, w, h): (i64, f32, f32)| {
1116 ui.borrow_mut()
1117 .set_window_size(kui_core::WindowId(window as u32), kui_core::Size::new(w, h));
1118 Ok(())
1119 })?,
1120 )?;
1121 let env = t.clone();
1126 t.set(
1127 "set_tokens",
1128 scope.create_function(move |lua, decl: Table| {
1129 let tokens = parse_tokens(&decl)?;
1130 ui.borrow_mut().set_tokens(tokens);
1131 env.set("tokens", tokens_table(lua, &ui.borrow())?)
1132 })?,
1133 )?;
1134 t.set(
1135 "focus_window",
1136 scope.create_function(move |_, window: i64| {
1137 ui.borrow_mut()
1138 .focus_window(kui_core::WindowId(window as u32));
1139 Ok(())
1140 })?,
1141 )?;
1142 t.set(
1143 "measure_text",
1144 scope.create_function(
1145 move |lua, (s, opts, max_w): (mlua::Value, Option<Table>, Option<f32>)| {
1146 let mut guard = ui.borrow_mut();
1147 let m = measure_from_lua(&mut guard, &s, opts.as_ref(), max_w)?;
1148 value_to_lua(lua, &m.to_value())
1149 },
1150 )?,
1151 )?;
1152 t.set(
1169 "add_extension",
1170 scope.create_function(move |_, (namespace, path): (String, String)| {
1171 let path = std::path::PathBuf::from(path);
1172 if let Some(prev) = loaded.borrow().iter().find(|l| l.namespace == namespace) {
1173 return Ok(if prev.path == path {
1174 (Some(true), None)
1176 } else {
1177 (
1178 None,
1179 Some(format!(
1180 "`{namespace}` is already {} in this script; a second plugin needs a \
1181 second namespace",
1182 prev.path.display()
1183 )),
1184 )
1185 });
1186 }
1187 let ext = match unsafe { kui_ffi::CExtension::open(&path) } {
1191 Ok(ext) => ext,
1192 Err(e) => return Ok((None, Some(e))),
1193 };
1194 let origin = match ui.borrow_mut().add_extension(&namespace, Box::new(ext)) {
1195 Ok(origin) => origin,
1196 Err(e) => return Ok((None, Some(e))),
1197 };
1198 loaded.borrow_mut().push(Loaded {
1199 namespace,
1200 path,
1201 origin,
1202 });
1203 Ok((Some(true), None))
1204 })?,
1205 )?;
1206 t.set(
1209 "extension_namespaces",
1210 scope.create_function(move |lua, ()| {
1211 lua.create_sequence_from(loaded.borrow().iter().map(|l| l.namespace.clone()))
1212 })?,
1213 )?;
1214 Ok(t)
1215}
1216
1217fn tokens_table(lua: &Lua, ui: &Ui<'_>) -> mlua::Result<Table> {
1229 let look = ui.tokens();
1230 let tokens = lua.create_table()?;
1231 let colors = lua.create_table()?;
1232 for (name, c) in look.colors() {
1233 colors.set(name, c.to_hex())?;
1234 }
1235 let lengths = lua.create_table()?;
1236 for (name, v) in look.lengths() {
1237 lengths.set(name, v)?;
1238 }
1239 tokens.set("colors", colors)?;
1240 tokens.set("lengths", lengths)?;
1241 Ok(tokens)
1242}
1243
1244fn measure_from_lua(
1245 ui: &mut Ui<'_>,
1246 s: &mlua::Value,
1247 opts: Option<&Table>,
1248 max_w: Option<f32>,
1249) -> mlua::Result<kui_core::TextMetrics> {
1250 let style = match opts {
1251 Some(t) => with_refs(ui, |refs| parse_props(t, false, refs))?.style,
1252 None => kui_core::TextStyle::default(),
1253 };
1254 let measure_spans = |ui: &mut Ui<'_>, spans: &Table, style: &kui_core::TextStyle| {
1255 let parts = with_refs(ui, |refs| collect_spans(spans, refs))?;
1256 let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1257 Ok(ui.measure_rich_text(&spans, style, max_w))
1258 };
1259 match s {
1260 mlua::Value::String(s) => Ok(ui.measure_text(&s.to_str()?, &style, max_w)),
1261 mlua::Value::Table(t) => {
1262 if t.get::<Option<String>>("type")?.as_deref() == Some("text") {
1263 let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
1264 match t.get::<Option<Table>>("spans")? {
1265 Some(spans) => measure_spans(ui, &spans, &style),
1266 None => {
1267 let value: String = t.get("value")?;
1268 Ok(ui.measure_text(&value, &style, max_w))
1269 }
1270 }
1271 } else {
1272 measure_spans(ui, t, &style)
1273 }
1274 }
1275 other => Err(bad(format!(
1276 "measure_text: expected a string, a span list or a text node, got {}",
1277 other.type_name()
1278 ))),
1279 }
1280}
1281
1282fn bad(msg: impl std::fmt::Display) -> mlua::Error {
1286 mlua::Error::runtime(msg.to_string())
1287}
1288
1289fn build_children(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1290 for child in t.sequence_values::<Table>() {
1291 build_node(ui, &child?)?;
1292 }
1293 Ok(())
1294}
1295
1296fn with_children(
1299 ui: &mut Ui<'_>,
1300 t: &Table,
1301 widget: impl FnOnce(&mut Ui<'_>, &mut dyn FnMut(&mut Ui<'_>)),
1302) -> mlua::Result<()> {
1303 let mut result = Ok(());
1304 widget(ui, &mut |ui| result = build_children(ui, t));
1305 result
1306}
1307
1308fn element_of(ty: &str) -> &str {
1312 match ty {
1313 "row" | "column" => "box",
1314 "grid" => "table",
1315 "input" => "edit",
1316 "dropdown" => "select",
1317 "radio_group" => "radioGroup",
1318 "window_buttons" => "windowButtons",
1319 "menu_bar" => "menuBar",
1320 "latency_graph" | "latency_hud" => "latencyGraph",
1321 other => other,
1322 }
1323}
1324
1325fn menu_bar_of(t: &Table) -> mlua::Result<kui_core::MenuBar> {
1326 let Some(list) = t.get::<Option<Table>>("menu")? else {
1327 return Ok(kui_core::MenuBar::default());
1328 };
1329 let mut menus = lua_list_to_value(&list)?;
1330 if let Value::List(menus) = &mut menus {
1331 for menu in menus.iter_mut() {
1332 let Value::Map(fields) = menu else { continue };
1333 for (k, items) in fields.iter_mut() {
1334 if k != "items" {
1335 continue;
1336 }
1337 if matches!(items, Value::Map(m) if m.is_empty()) {
1339 *items = Value::List(Vec::new());
1340 }
1341 if let Value::List(rows) = items {
1342 rows.iter_mut().for_each(alias_menu_role);
1343 }
1344 }
1345 }
1346 }
1347 kui_core::MenuBar::from_value(&menus).map_err(mlua::Error::runtime)
1348}
1349
1350fn declare_windows(ui: &mut Ui<'_>, root: &Table) -> mlua::Result<()> {
1351 let Some(list) = root.get::<Option<Table>>("windows")? else {
1352 return Ok(());
1353 };
1354 for entry in list.sequence_values::<mlua::Value>() {
1358 let v = lua_to_value(&entry?)?;
1359 let (name, cfg) = WindowConfig::from_value(&v).map_err(mlua::Error::runtime)?;
1360 ui.window(&name, cfg);
1361 }
1362 Ok(())
1363}
1364
1365fn check_props(ui: &mut Ui<'_>, t: &Table, element: &str) -> mlua::Result<()> {
1369 if !ui.core().diagnostics() {
1370 return Ok(());
1371 }
1372 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1373 let (k, _) = pair?;
1374 let mlua::Value::String(k) = k else { continue };
1375 let name = k.to_str()?;
1376 if name.as_ref() == "type" || schema::known_prop(element, &name, schema::Spelling::Snake) {
1378 continue;
1379 }
1380 let w = kui_core::diag::unknown_prop(element, &name, schema::Spelling::Snake);
1381 ui.core().warn(w);
1382 }
1383 Ok(())
1384}
1385
1386fn build_fill(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1396 let name: String = t
1397 .get::<Option<String>>("name")?
1398 .filter(|n| !n.is_empty())
1399 .ok_or_else(|| bad("fill needs a name (\"namespace/slot\")"))?;
1400 if !name.contains(kui_core::NAMESPACE_SEPARATOR) {
1401 return Err(bad(format!(
1402 "bad slot name {name:?} (a full \"namespace/slot\")"
1403 )));
1404 }
1405 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1406 let (k, _) = pair?;
1407 let mlua::Value::String(k) = k else { continue };
1408 let k = k.to_str()?;
1409 if !matches!(k.as_ref(), "type" | "name" | "params") {
1410 return Err(bad(format!(
1411 "fill takes name and params, not {:?} — it is a position, not a box",
1412 k.as_ref()
1413 )));
1414 }
1415 }
1416 let params = match t.get::<mlua::Value>("params")? {
1417 mlua::Value::Nil => Value::Null,
1418 v => lua_to_value(&v)?,
1419 };
1420 ui.slot_with(&name, ¶ms);
1421 Ok(())
1422}
1423
1424fn build_devtools_tab(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1433 let name: String = t.get::<Option<String>>("name")?.unwrap_or_default();
1434 let refuse = |ui: &mut Ui<'_>, why: &str| {
1435 ui.core().warn(kui_core::diag::bad_devtools_tab(&name, why));
1436 Ok(())
1437 };
1438 if name.is_empty() {
1439 return refuse(ui, "it needs a name (its identity)");
1440 }
1441 let label: String = t
1442 .get::<Option<String>>("label")?
1443 .unwrap_or_else(|| name.clone());
1444 for pair in t.pairs::<mlua::Value, mlua::Value>() {
1445 let (k, _) = pair?;
1446 let mlua::Value::String(k) = k else { continue };
1447 let k = k.to_str()?;
1448 if !matches!(k.as_ref(), "type" | "name" | "label" | "slot" | "view") {
1449 return refuse(
1450 ui,
1451 &format!(
1452 "it takes name, label and slot or view, not {:?}",
1453 k.as_ref()
1454 ),
1455 );
1456 }
1457 }
1458 let slot = t.get::<Option<String>>("slot")?;
1459 let view = t.get::<mlua::Value>("view")?;
1460 match (slot, view) {
1461 (Some(slot), mlua::Value::Nil) => {
1462 if !slot.contains(kui_core::NAMESPACE_SEPARATOR) {
1463 return refuse(
1464 ui,
1465 &format!("bad slot {slot:?} (a full \"namespace/slot\")"),
1466 );
1467 }
1468 ui.devtools_tab(&name, &label, &slot);
1469 Ok(())
1470 }
1471 (None, mlua::Value::Function(view)) => {
1472 let mut result = Ok(());
1473 ui.devtools_tab_with(&name, &label, |ui| {
1474 result = view
1475 .call::<Table>(())
1476 .and_then(|tree| build_node(ui, &tree));
1477 });
1478 result
1479 }
1480 (Some(_), mlua::Value::Function(_)) => refuse(ui, "it takes a slot or a view, not both"),
1481 (None, mlua::Value::Nil) => refuse(ui, "it needs a slot or a view function"),
1482 (_, other) => refuse(
1483 ui,
1484 &format!("view is a {}, not a function", other.type_name()),
1485 ),
1486 }
1487}
1488
1489pub const MAX_VIEW_DEPTH: usize = 128;
1496
1497thread_local! {
1498 static VIEW_PATH: std::cell::RefCell<Vec<*const std::ffi::c_void>> =
1502 const { std::cell::RefCell::new(Vec::new()) };
1503}
1504
1505fn build_node(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1506 let at = t.to_pointer();
1507 VIEW_PATH.with_borrow_mut(|path| {
1508 if path.contains(&at) {
1509 return Err(bad("a view node that holds itself"));
1510 }
1511 if path.len() >= MAX_VIEW_DEPTH {
1512 return Err(bad(format!("a view nested past {MAX_VIEW_DEPTH} nodes")));
1513 }
1514 path.push(at);
1515 Ok(())
1516 })?;
1517 let built = build_one(ui, t);
1518 VIEW_PATH.with_borrow_mut(|path| path.pop());
1519 built
1520}
1521
1522fn build_one(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1529 let ty: String = t.get("type")?;
1530 match ty.as_str() {
1531 "fill" => build_fill(ui, t),
1532 "devtools_tab" => build_devtools_tab(ui, t),
1533 "row" | "column" | "grid" => {
1534 open_box(ui, t, &ty)?;
1535 build_children(ui, t)?;
1536 ui.close();
1537 Ok(())
1538 }
1539 "fragment" => {
1540 open_fragment(ui, t)?;
1541 build_children(ui, t)?;
1542 ui.close();
1543 Ok(())
1544 }
1545 "titlebar" | "tooltip" | "radio_group" => build_holder(ui, t, &ty),
1546 _ => build_widget(ui, t, &ty),
1547 }
1548}
1549
1550#[inline(never)]
1551fn open_box(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1552 check_props(ui, t, element_of(ty))?;
1553 let mut p = with_refs(ui, |refs| parse_props(t, ty == "row", refs))?;
1554 p.spec.layout.table = ty == "grid";
1556 ui.core().open_from(p, Content::Box);
1557 Ok(())
1558}
1559
1560#[inline(never)]
1561fn open_fragment(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
1562 check_props(ui, t, element_of("fragment"))?;
1563 let id: i64 = t.get("id")?;
1568 let image: Option<i64> = t.get("image")?;
1569 let params: Vec<f32> = match t.get::<Option<Table>>("params")? {
1570 Some(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
1571 None => Vec::new(),
1572 };
1573 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1574 let frag = kui_core::FragmentRef {
1575 id: kui_core::FragmentId::from_ffi(id as u64),
1576 image: image
1577 .filter(|i| *i != 0)
1578 .map(|i| kui_core::ImageId::from_ffi(i as u64)),
1579 };
1580 ui.core().open_from(p, Content::Fragment(frag, ¶ms));
1581 Ok(())
1582}
1583
1584#[inline(never)]
1587fn build_holder(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1588 check_props(ui, t, element_of(ty))?;
1589 match ty {
1590 "titlebar" => {
1591 if t.raw_len() > 0 {
1592 with_children(ui, t, |ui, body| widgets::titlebar_with(ui, body))
1593 } else {
1594 let title: String = t.get::<Option<String>>("title")?.unwrap_or_default();
1595 widgets::titlebar(ui, &title);
1596 Ok(())
1597 }
1598 }
1599 "tooltip" => {
1600 if t.raw_len() > 0 {
1601 with_children(ui, t, |ui, body| widgets::tooltip_with(ui, body))
1602 } else {
1603 let value: String = t.get("value")?;
1604 widgets::tooltip(ui, &value);
1605 Ok(())
1606 }
1607 }
1608 "radio_group" => {
1609 let label: String = t.get("label")?;
1612 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1613 let mut result = Ok(());
1614 widgets::radio_group_with(ui, &label, p.spec, |ui| result = build_children(ui, t));
1615 result
1616 }
1617 other => unreachable!("{other} holds no children"),
1618 }
1619}
1620
1621#[inline(never)]
1622fn build_widget(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
1623 check_props(ui, t, element_of(ty))?;
1624 match ty {
1625 "text" => {
1626 let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
1627 if let Some(spans) = t.get::<Option<Table>>("spans")? {
1628 let parts = with_refs(ui, |refs| collect_spans(&spans, refs))?;
1629 let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
1630 ui.rich_text(&spans, style);
1631 } else {
1632 let value: String = t.get("value")?;
1633 ui.text(&value, style);
1634 }
1635 Ok(())
1636 }
1637 "image" => {
1638 let id: i64 = t.get("id")?;
1642 let spec = with_refs(ui, |refs| parse_props(t, false, refs))?.spec;
1643 let named = |row: &str, names: &[&str]| -> mlua::Result<usize> {
1644 match t.get::<Option<String>>(row)? {
1645 None => Ok(0),
1646 Some(s) => names
1647 .iter()
1648 .position(|n| *n == s)
1649 .ok_or_else(|| bad(format!("{row} must be one of {}", names.join(", ")))),
1650 }
1651 };
1652 let sampling_names: Vec<&str> =
1653 kui_core::Sampling::ALL.iter().map(|s| s.name()).collect();
1654 let fit_names: Vec<&str> = kui_core::ImageFit::ALL.iter().map(|f| f.name()).collect();
1655 let opts = kui_core::ImageOpts {
1656 sampling: kui_core::Sampling::ALL[named("sampling", &sampling_names)?],
1657 fit: kui_core::ImageFit::ALL[named("fit", &fit_names)?],
1658 };
1659 ui.image_with(kui_core::ImageId::from_ffi(id as u64), opts, spec);
1660 Ok(())
1661 }
1662 "polygon" => {
1663 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1666 let Some(list) = t.get::<Option<Table>>("points")? else {
1667 return Err(bad("polygon needs points"));
1668 };
1669 let points: Vec<kui_core::Vec2> = list
1670 .sequence_values::<mlua::Value>()
1671 .map(|v| {
1672 let mlua::Value::Table(pt) = v? else {
1673 return Err(bad("a polygon point is a {x, y} table"));
1674 };
1675 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
1676 })
1677 .collect::<mlua::Result<_>>()?;
1678 ui.core().open_from(p, Content::Polygon(&points));
1679 Ok(())
1680 }
1681 "line" => {
1682 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1688 let point = |v: mlua::Value| -> mlua::Result<kui_core::Vec2> {
1689 let mlua::Value::Table(pt) = v else {
1690 return Err(bad("a line point is a {x, y} table"));
1691 };
1692 Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
1693 };
1694 let points: Vec<kui_core::Vec2> = match t.get::<Option<Table>>("points")? {
1695 Some(list) => list
1696 .sequence_values::<mlua::Value>()
1697 .map(|v| point(v?))
1698 .collect::<mlua::Result<_>>()?,
1699 None => {
1700 let (Some(from), Some(to)) = (
1701 t.get::<Option<mlua::Value>>("from")?,
1702 t.get::<Option<mlua::Value>>("to")?,
1703 ) else {
1704 return Err(bad("line needs from and to, or points"));
1705 };
1706 vec![point(from)?, point(to)?]
1707 }
1708 };
1709 let width = match t.get::<mlua::Value>("width")? {
1712 mlua::Value::Nil => None,
1713 v => with_refs(ui, |refs| length_of(&v, refs))?,
1714 }
1715 .unwrap_or(1.0);
1716 let stroke_color = p.style.color.unwrap_or(ui.theme().fg);
1718 let mut stroke = kui_core::Stroke::new(width, stroke_color);
1719 stroke.curve = t.get::<Option<bool>>("curve")?.unwrap_or(false);
1720 ui.core().open_from(p, Content::Line(&points, stroke));
1721 Ok(())
1722 }
1723 "cells" => {
1724 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1730 let rows: usize = t.get::<Option<usize>>("rows")?.unwrap_or(0);
1731 let cols: usize = t.get::<Option<usize>>("cols")?.unwrap_or(0);
1732 if rows == 0 || cols == 0 {
1733 return Err(bad("cells needs rows and cols"));
1734 }
1735 let default_fg = p.style.color.unwrap_or(ui.theme().fg).to_hex();
1736 let mut cells = vec![kui_core::Cell::new(' ', default_fg, 0); rows * cols];
1737 if let Some(lines) = t.get::<Option<Table>>("lines")? {
1738 for (r, line) in lines.sequence_values::<String>().enumerate() {
1739 if r >= rows {
1740 break;
1741 }
1742 for (c, ch) in line?.chars().take(cols).enumerate() {
1743 cells[r * cols + c].ch = ch;
1744 }
1745 }
1746 }
1747 if let Some(runs) = t.get::<Option<Table>>("runs")? {
1748 for run in runs.sequence_values::<Table>() {
1749 let run = run?;
1750 let row: usize = run.get(1)?;
1751 let col: usize = run.get(2)?;
1752 let len: usize = run.get(3)?;
1753 let fg: u32 = run.get::<Option<u32>>(4)?.unwrap_or(0);
1754 let bg: u32 = run.get::<Option<u32>>(5)?.unwrap_or(0);
1755 let flags: u8 = run.get::<Option<u8>>(6)?.unwrap_or(0);
1756 let ul: u32 = run.get::<Option<u32>>(7)?.unwrap_or(0);
1757 if row >= rows {
1758 continue;
1759 }
1760 for c in col..(col + len).min(cols) {
1761 let cell = &mut cells[row * cols + c];
1762 if fg != 0 {
1763 cell.fg = fg;
1764 }
1765 if bg != 0 {
1766 cell.bg = bg;
1767 }
1768 cell.flags |= flags;
1769 if ul != 0 {
1770 cell.ul = ul;
1771 }
1772 }
1773 }
1774 }
1775 let cursor = match t.get::<Option<Table>>("cursor_at")? {
1776 Some(cur) => {
1777 let shape = match t.get::<Option<String>>("cursor_shape")? {
1780 None => kui_core::CellCursor::Block,
1781 Some(s) => kui_core::CellCursor::from_name(&s).ok_or_else(|| {
1782 bad(format!(
1783 "cursor_shape must be {}, not {s:?}",
1784 kui_core::CellCursor::NAMES.join(" | ")
1785 ))
1786 })?,
1787 };
1788 let color = match t.get::<mlua::Value>("cursor_color")? {
1789 mlua::Value::Nil => None,
1790 v => with_refs(ui, |refs| parse_color(&v, refs))?,
1791 }
1792 .unwrap_or(Color::rgb8(0xff, 0xff, 0xff));
1793 Some((cur.get::<usize>(1)?, cur.get::<usize>(2)?, shape, color))
1794 }
1795 None => None,
1796 };
1797 let origin_line = t.get::<Option<u64>>("origin_line")?.unwrap_or(0);
1799 let grid = kui_core::CellGrid {
1800 rows,
1801 cols,
1802 cells: &cells,
1803 style: p.style,
1804 cursor,
1805 origin_line,
1806 };
1807 ui.core().open_from(p, Content::Cells(&grid));
1808 Ok(())
1809 }
1810 "audio" => {
1811 let src: i64 = t.get("src")?;
1814 let mut spec = kui_core::AudioSpec::new(kui_core::SoundId::from_ffi(src as u64));
1815 if let Some(v) = t.get::<Option<f32>>("volume")? {
1816 spec = spec.volume(v);
1817 }
1818 if t.get::<Option<bool>>("loop")?.unwrap_or(false) {
1819 spec = spec.looped();
1820 }
1821 spec = spec.paused(t.get::<Option<bool>>("paused")?.unwrap_or(false));
1822 if t.get::<Option<bool>>("finish")?.unwrap_or(false) {
1823 spec = spec.finish();
1824 }
1825 if let Some(tag) = t.get::<Option<mlua::Value>>("tag")? {
1826 spec.tag = Some(lua_to_value(&tag)?);
1827 }
1828 match t.get::<Option<String>>("key")? {
1829 Some(k) => ui.audio_keyed(&k, spec),
1830 None => ui.audio(spec),
1831 };
1832 Ok(())
1833 }
1834 "input" => {
1835 let label: String = t.get("label")?;
1836 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
1837 widgets::text_input(ui, &label, &initial);
1838 Ok(())
1839 }
1840 "dropdown" => {
1841 let Some(label) = t.get::<Option<String>>("label")?.filter(|l| !l.is_empty()) else {
1845 return Err(bad("dropdown needs a label (its key and accessible name)"));
1846 };
1847 let Some(options) = t.get::<Option<Table>>("options")? else {
1848 return Err(bad(
1849 "dropdown needs options, a list of strings or menu rows",
1850 ));
1851 };
1852 let mut rows = lua_list_to_value(&options)?;
1853 if let Value::List(rows) = &mut rows {
1854 rows.iter_mut().for_each(alias_menu_role);
1855 if ui.core().diagnostics() {
1859 for row in rows.iter() {
1860 let Value::Map(fields) = row else { continue };
1861 for (k, _) in fields.iter() {
1862 if !kui_core::MenuItem::KEYS.contains(&k.as_str()) {
1863 ui.core().warn(kui_core::diag::unknown_menu_item_key(k));
1864 }
1865 }
1866 }
1867 }
1868 }
1869 let items =
1870 kui_core::MenuItem::options_from_value(&rows).map_err(mlua::Error::runtime)?;
1871 let current = match t.get::<Option<i64>>("current")? {
1872 None => None,
1873 Some(i) if i >= 1 => Some(i as usize - 1),
1874 Some(i) => {
1875 return Err(bad(format!("dropdown current is an index from 1, not {i}")));
1876 }
1877 };
1878 widgets::select_items(ui, &label, &items, current);
1879 Ok(())
1880 }
1881 "edit" => {
1882 let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
1883 let label = match p.key.clone().or(t.get::<Option<String>>("label")?) {
1884 Some(l) => l,
1885 None => return Err(bad("edit needs a key (state is retained by key)")),
1886 };
1887 let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
1888 let opts = EditOptions {
1889 style: p.style,
1890 multiline: t.get::<Option<bool>>("multiline")?.unwrap_or(false),
1891 autofocus: t.get::<Option<bool>>("autofocus")?.unwrap_or(false),
1892 wrap: p.wrap,
1893 ..Default::default()
1894 };
1895 ui.text_edit(&label, &initial, &opts, p.spec);
1896 Ok(())
1897 }
1898 "window_buttons" => {
1899 widgets::window_buttons(ui);
1900 Ok(())
1901 }
1902 "menu_bar" => {
1903 widgets::menu_bar(ui, menu_bar_of(t)?);
1904 Ok(())
1905 }
1906 "latency_graph" => {
1907 widgets::latency_graph(ui);
1908 Ok(())
1909 }
1910 "latency_hud" => {
1911 let (mut x, mut y) = (Align::End, Align::End);
1912 if let Some(at) = t.get::<Option<Table>>("at")? {
1913 x = parse_align(&at.get::<String>(1)?)?;
1914 y = parse_align(&at.get::<String>(2)?)?;
1915 }
1916 widgets::latency_hud_at(ui, x, y);
1917 Ok(())
1918 }
1919 "button" => {
1920 let label: String = t.get("label")?;
1930 let text: String = t
1931 .get::<Option<String>>("text")?
1932 .unwrap_or_else(|| label.clone());
1933 let key: String = t
1934 .get::<Option<String>>("key")?
1935 .unwrap_or_else(|| label.clone());
1936 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
1937 Some(v) => lua_to_value(&v)?,
1938 None => Value::Null,
1939 };
1940 let mut out = PropsOut::new();
1941 out.spec = widgets::button_spec(&ui.theme(), &ui.metrics())
1942 .on_click(payload)
1943 .label(label.as_str());
1944 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
1945 out.apply_tooltip(&hint);
1946 }
1947 with_refs(ui, |refs| {
1948 for name in ["description", "disabled", "accent"] {
1949 let v = t.get::<mlua::Value>(name)?;
1950 if v.is_nil() {
1951 continue;
1952 }
1953 let def = schema::by_snake_name(name).expect("a button row");
1954 if let Some(parsed) =
1955 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
1956 {
1957 schema::apply(def, parsed, &mut out).map_err(bad)?;
1958 }
1959 }
1960 Ok(())
1961 })?;
1962 match t.get::<Option<u64>>("index")? {
1965 Some(i) => widgets::button_indexed(ui, i, &text, out.spec, out.tooltip.as_deref()),
1966 None => widgets::button_with(ui, &key, &text, out.spec, out.tooltip.as_deref()),
1967 }
1968 Ok(())
1969 }
1970 "checkbox" | "radio" | "switch" => {
1971 let kind = match ty {
1976 "checkbox" => widgets::Toggle::Checkbox,
1977 "radio" => widgets::Toggle::Radio,
1978 _ => widgets::Toggle::Switch,
1979 };
1980 let label: String = t.get("label")?;
1981 let text: String = t
1982 .get::<Option<String>>("text")?
1983 .unwrap_or_else(|| label.clone());
1984 let key: String = t
1985 .get::<Option<String>>("key")?
1986 .unwrap_or_else(|| label.clone());
1987 let payload = match t.get::<Option<mlua::Value>>("on_click")? {
1988 Some(v) => lua_to_value(&v)?,
1989 None => Value::Null,
1990 };
1991 let mut out = PropsOut::new();
1992 out.spec = widgets::toggle_spec(&ui.metrics())
1993 .on_click(payload)
1994 .label(label.as_str());
1995 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
1996 out.apply_tooltip(&hint);
1997 }
1998 apply_named_rows(
1999 ui,
2000 t,
2001 &["description", "disabled", "checked", "mixed"],
2002 &mut out,
2003 )?;
2004 widgets::toggle_with(ui, kind, &key, &text, out.spec, out.tooltip.as_deref());
2005 Ok(())
2006 }
2007 "slider" => {
2008 let label: String = t.get("label")?;
2012 let key: String = t
2013 .get::<Option<String>>("key")?
2014 .unwrap_or_else(|| label.clone());
2015 let mut out = PropsOut::new();
2016 out.spec = widgets::slider_spec(&ui.metrics()).label(label.as_str());
2017 if let Some(hint) = t.get::<Option<String>>("tooltip")? {
2018 out.apply_tooltip(&hint);
2019 }
2020 apply_named_rows(
2021 ui,
2022 t,
2023 &[
2024 "description",
2025 "disabled",
2026 "value_now",
2027 "value_min",
2028 "value_max",
2029 "value_step",
2030 "value_text",
2031 "on_change",
2032 "width",
2033 "min_width",
2034 "max_width",
2035 ],
2036 &mut out,
2037 )?;
2038 widgets::slider_with(ui, &key, out.spec, out.tooltip.as_deref());
2039 Ok(())
2040 }
2041 other => Err(mlua::Error::runtime(format!("unknown node type '{other}'"))),
2042 }
2043}
2044
2045fn apply_named_rows(
2050 ui: &mut Ui<'_>,
2051 t: &Table,
2052 names: &[&str],
2053 out: &mut PropsOut,
2054) -> mlua::Result<()> {
2055 with_refs(ui, |refs| {
2056 for name in names {
2057 let v = t.get::<mlua::Value>(*name)?;
2058 if v.is_nil() {
2059 continue;
2060 }
2061 let def = schema::by_snake_name(name).expect("a schema row");
2062 if let Some(parsed) =
2063 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2064 {
2065 schema::apply(def, parsed, out).map_err(bad)?;
2066 }
2067 }
2068 Ok(())
2069 })
2070}
2071
2072struct SpanPart {
2073 text: String,
2074 bold: bool,
2075 italic: bool,
2076 underline: bool,
2077 underline_color: Option<Color>,
2080 underline_style: Option<kui_core::UnderlineStyle>,
2081 strikethrough: bool,
2082 color: Option<Color>,
2083 bg: Option<Color>,
2084 bg_radius: f32,
2087}
2088
2089fn span_of(p: &SpanPart) -> Span<'_> {
2090 let mut s = Span::new(&p.text);
2091 if p.bold {
2092 s = s.bold();
2093 }
2094 if p.italic {
2095 s = s.italic();
2096 }
2097 if p.underline {
2098 s = s.underline();
2099 }
2100 if let Some(c) = p.underline_color {
2101 s = s.underline_color(c);
2102 }
2103 if let Some(st) = p.underline_style {
2104 s = s.underline_style(st);
2105 }
2106 if p.strikethrough {
2107 s = s.strikethrough();
2108 }
2109 if let Some(c) = p.color {
2110 s = s.color(c);
2111 }
2112 if let Some(c) = p.bg {
2113 s = s.bg(c);
2114 }
2115 if p.bg_radius > 0.0 {
2116 s = s.bg_radius(p.bg_radius);
2117 }
2118 s
2119}
2120
2121fn collect_spans(spans: &Table, refs: &mut Refs<'_>) -> mlua::Result<Vec<SpanPart>> {
2123 let mut out = Vec::new();
2124 for item in spans.sequence_values::<mlua::Value>() {
2125 match item? {
2126 mlua::Value::String(s) => out.push(SpanPart {
2127 text: s.to_str()?.to_string(),
2128 bold: false,
2129 italic: false,
2130 underline: false,
2131 underline_color: None,
2132 underline_style: None,
2133 strikethrough: false,
2134 color: None,
2135 bg: None,
2136 bg_radius: 0.0,
2137 }),
2138 mlua::Value::Table(t) => {
2139 let text: String = t
2140 .get::<Option<String>>(1)?
2141 .ok_or_else(|| bad("span table needs its text at [1]"))?;
2142 let color = match t.get::<mlua::Value>("color")? {
2143 mlua::Value::Nil => None,
2144 v => parse_color(&v, refs)?,
2145 };
2146 let bg = match t.get::<mlua::Value>("bg")? {
2147 mlua::Value::Nil => None,
2148 v => parse_color(&v, refs)?,
2149 };
2150 let underline_color = match t.get::<mlua::Value>("underline_color")? {
2151 mlua::Value::Nil => None,
2152 v => parse_color(&v, refs)?,
2153 };
2154 let underline_style = match t.get::<Option<String>>("underline_style")? {
2155 None => None,
2156 Some(name) => Some(
2157 kui_core::UnderlineStyle::NAMES
2158 .iter()
2159 .position(|n| *n == name)
2160 .map(|i| kui_core::UnderlineStyle::from_index(i as u32))
2161 .ok_or_else(|| {
2162 bad(format!(
2163 "underline_style must be one of {}, got {name:?}",
2164 kui_core::UnderlineStyle::NAMES.join(" | ")
2165 ))
2166 })?,
2167 ),
2168 };
2169 out.push(SpanPart {
2170 text,
2171 bold: t.get::<Option<bool>>("bold")?.unwrap_or(false),
2172 italic: t.get::<Option<bool>>("italic")?.unwrap_or(false),
2173 underline: t.get::<Option<bool>>("underline")?.unwrap_or(false),
2174 underline_color,
2175 underline_style,
2176 strikethrough: t.get::<Option<bool>>("strikethrough")?.unwrap_or(false),
2177 color,
2178 bg,
2179 bg_radius: t.get::<Option<f32>>("bg_radius")?.unwrap_or(0.0).max(0.0),
2180 });
2181 }
2182 other => {
2183 return Err(bad(format!(
2184 "span must be a string or table, got {}",
2185 other.type_name()
2186 )));
2187 }
2188 }
2189 }
2190 Ok(out)
2191}
2192
2193pub fn parse_tokens(t: &Table) -> mlua::Result<kui_core::Tokens> {
2204 let mut out = kui_core::Tokens::new();
2205 for pair in t.pairs::<String, mlua::Value>() {
2206 let (k, _) = pair?;
2207 if k != "colors" && k != "lengths" {
2208 return Err(bad(format!("tokens: unknown key {k:?} (colors, lengths)")));
2209 }
2210 }
2211 if let Some(colors) = t.get::<Option<Table>>("colors")? {
2212 let mut entries: Vec<(String, mlua::Value)> = colors
2213 .pairs::<String, mlua::Value>()
2214 .collect::<mlua::Result<_>>()?;
2215 entries.sort_by(|a, b| a.0.cmp(&b.0));
2216 let mut derived: Vec<(String, String, Vec<kui_core::ColorOp>)> = Vec::new();
2218 for (name, v) in entries {
2219 out = match &v {
2220 mlua::Value::Table(t) if t.contains_key("from")? => {
2221 let (from, ops) = parse_recipe(&name, t)?;
2222 derived.push((name, from, ops));
2223 out
2224 }
2225 mlua::Value::Table(halves) => {
2226 let half = |k: &str| -> mlua::Result<Color> {
2227 match halves.get::<mlua::Value>(k)? {
2228 mlua::Value::Nil => Err(bad(format!(
2229 "tokens.colors.{name}: needs both light and dark"
2230 ))),
2231 v => parse_color_value(&v),
2232 }
2233 };
2234 out.color_themed(&name, half("light")?, half("dark")?)
2235 }
2236 v => out.color(
2237 &name,
2238 parse_color_value(v).map_err(|e| bad(format!("tokens.colors.{name}: {e}")))?,
2239 ),
2240 };
2241 }
2242 while !derived.is_empty() {
2245 let ready = derived.iter().position(|(_, from, ops)| {
2246 let known = |s: &str| kui_core::tokens::is_role(s) || out.color_id(s).is_some();
2247 known(from)
2248 && ops.iter().all(|op| match op {
2249 kui_core::ColorOp::Mix(c, _) | kui_core::ColorOp::Readable(c, _) => {
2250 known(c)
2251 }
2252 _ => true,
2253 })
2254 });
2255 let (name, from, ops) = derived.remove(ready.unwrap_or(0));
2256 out = out.derive(&name, &from, ops);
2257 }
2258 }
2259 if let Some(lengths) = t.get::<Option<Table>>("lengths")? {
2260 let mut entries: Vec<(String, mlua::Value)> = lengths
2261 .pairs::<String, mlua::Value>()
2262 .collect::<mlua::Result<_>>()?;
2263 entries.sort_by(|a, b| a.0.cmp(&b.0));
2264 for (name, v) in entries {
2265 let px = number(&v)
2266 .ok_or_else(|| bad(format!("tokens.lengths.{name}: a length is a number")))?;
2267 out = out.length(&name, px);
2268 }
2269 }
2270 Ok(out)
2271}
2272
2273fn parse_recipe(name: &str, t: &Table) -> mlua::Result<(String, Vec<kui_core::ColorOp>)> {
2278 for pair in t.pairs::<String, mlua::Value>() {
2279 let (k, _) = pair?;
2280 if k != "from" && k != "ops" {
2281 return Err(bad(format!(
2282 "tokens.colors.{name}: unknown key {k:?} (from, ops)"
2283 )));
2284 }
2285 }
2286 let from = match t.get::<mlua::Value>("from")? {
2287 mlua::Value::String(s) => s.to_str()?.to_string(),
2288 _ => {
2289 return Err(bad(format!(
2290 "tokens.colors.{name}.from names a colour token or role"
2291 )));
2292 }
2293 };
2294 let ops = match t.get::<mlua::Value>("ops")? {
2295 mlua::Value::Nil => Vec::new(),
2296 mlua::Value::Table(list) => {
2297 let tuples: Vec<Table> = match list.get::<mlua::Value>(1)? {
2299 mlua::Value::String(_) => vec![list],
2300 _ => list
2301 .sequence_values::<Table>()
2302 .collect::<mlua::Result<_>>()?,
2303 };
2304 tuples
2305 .iter()
2306 .map(|op| parse_color_op(name, op))
2307 .collect::<mlua::Result<_>>()?
2308 }
2309 _ => {
2310 return Err(bad(format!(
2311 "tokens.colors.{name}.ops is a list of {{ verb, … }} tuples"
2312 )));
2313 }
2314 };
2315 Ok((from, ops))
2316}
2317
2318fn parse_color_op(name: &str, op: &Table) -> mlua::Result<kui_core::ColorOp> {
2319 let verb = match op.get::<mlua::Value>(1)? {
2320 mlua::Value::String(s) => s.to_str()?.to_string(),
2321 _ => {
2322 return Err(bad(format!(
2323 "tokens.colors.{name}.ops: an op starts with its verb"
2324 )));
2325 }
2326 };
2327 let Some(takes_color) = kui_core::ColorOp::takes_color(&verb) else {
2328 return Err(bad(format!(
2329 "tokens.colors.{name}.ops: unknown verb {verb:?} (lift, darken, raise, alpha, mix, readable)"
2330 )));
2331 };
2332 let arity = if takes_color { 3 } else { 2 };
2333 if op.raw_len() != arity {
2334 return Err(bad(format!(
2335 "tokens.colors.{name}.ops: {verb} takes {} — {{ \"{verb}\", {} }}",
2336 if takes_color {
2337 "a colour and a number"
2338 } else {
2339 "one number"
2340 },
2341 if takes_color { "token, t" } else { "t" }
2342 )));
2343 }
2344 let color = if takes_color {
2345 match op.get::<mlua::Value>(2)? {
2346 mlua::Value::String(s) => Some(s.to_str()?.to_string()),
2347 _ => {
2348 return Err(bad(format!(
2349 "tokens.colors.{name}.ops: {verb}'s colour is a token or role name"
2350 )));
2351 }
2352 }
2353 } else {
2354 None
2355 };
2356 let n = number(&op.get::<mlua::Value>(arity as i64)?).ok_or_else(|| {
2357 bad(format!(
2358 "tokens.colors.{name}.ops: {verb}'s number is a number"
2359 ))
2360 })?;
2361 Ok(kui_core::ColorOp::parse(&verb, color.as_deref(), n).expect("checked above"))
2362}
2363
2364pub type Refs<'a> = kui_core::NameRefs<'a>;
2376
2377fn with_refs<R>(
2381 ui: &mut Ui<'_>,
2382 f: impl FnOnce(&mut Refs<'_>) -> mlua::Result<R>,
2383) -> mlua::Result<R> {
2384 let (r, errors, families) = {
2385 let mut refs = Refs::new(ui.core().token_lookup());
2386 let r = f(&mut refs);
2387 (r, refs.take_missed(), refs.take_missed_families())
2388 };
2389 for e in errors {
2390 ui.core().warn_unknown_token(&e);
2391 }
2392 for name in families {
2393 ui.core().warn_unknown_family(&name);
2394 }
2395 r
2396}
2397
2398fn reference(v: &mlua::Value) -> mlua::Result<Option<String>> {
2400 if let mlua::Value::String(s) = v
2401 && let Some(name) = kui_core::tokens::reference(&s.to_str()?)
2402 {
2403 return Ok(Some(name.to_string()));
2404 }
2405 Ok(None)
2406}
2407
2408fn length_of(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<f32>> {
2410 if let Some(name) = reference(v)? {
2411 return Ok(refs.length(&name));
2412 }
2413 number(v)
2414 .map(Some)
2415 .ok_or_else(|| bad("expected a number or a \"$token\""))
2416}
2417
2418pub fn parse_props(t: &Table, is_row: bool, refs: &mut Refs<'_>) -> mlua::Result<PropsOut> {
2424 let mut out = PropsOut::new();
2425 if is_row {
2426 out.spec = kui_core::NodeSpec::row();
2427 }
2428 match t.get::<mlua::Value>("size")? {
2429 mlua::Value::Nil => {}
2430 v => {
2431 if let Some(size) = length_of(&v, refs)? {
2432 out.style = kui_core::TextStyle::new(size);
2433 }
2434 }
2435 }
2436 match t.get::<mlua::Value>("radius")? {
2439 mlua::Value::Nil => {}
2440 v => {
2441 if let Some(r) = length_of(&v, refs)? {
2442 out.with_spec(|s| s.radius(r));
2443 }
2444 }
2445 }
2446 let mut overflow = 0;
2450 for pair in t.pairs::<mlua::Value, mlua::Value>() {
2451 let (k, v) = pair?;
2452 let mlua::Value::String(k) = k else { continue };
2453 let k = k.to_str()?;
2454 match k.as_ref() {
2455 "size" | "radius" => {}
2456 "pad" => {
2457 let pad = parse_pad(&v, refs)?;
2458 out.apply_pad(pad);
2459 }
2460 "border" => {
2461 let b = match v {
2462 mlua::Value::Table(b) => b,
2463 _ => return Err(bad("border must be a table {w=, color=}")),
2464 };
2465 let w = length_of(&b.get::<mlua::Value>("w")?, refs)?.unwrap_or(0.0);
2466 let c = parse_color(&b.get::<mlua::Value>("color")?, refs)?
2467 .unwrap_or(Color::TRANSPARENT);
2468 out.with_spec(|s| s.border(w, c));
2469 }
2470 "clip" => overflow |= bit(&v, kui_core::OVERFLOW_CLIP),
2471 "scroll_x" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_X),
2472 "scroll" | "scroll_y" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_Y),
2473 "float" => {
2474 let cfg = parse_float(&v)?;
2475 out.with_spec(|s| s.float(cfg));
2476 }
2477 "key_focus" => out.key_focus = truthy(&v),
2478 "key" => {
2479 let mlua::Value::String(s) = &v else {
2480 return Err(bad("key must be a string"));
2481 };
2482 out.key = Some(s.to_str()?.to_string());
2483 }
2484 "index" => {
2485 let Some(i) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
2486 return Err(bad("index must be a number (the row's data index)"));
2487 };
2488 out.index = Some(i.max(0.0) as u64);
2489 }
2490 "row_count" => {
2491 let Some(n) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
2492 return Err(bad(
2493 "row_count must be a number (how many indexed rows the list has)",
2494 ));
2495 };
2496 out.row_count = Some(n.max(0.0) as u64);
2497 }
2498 "tooltip" => {
2499 let mlua::Value::String(s) = &v else {
2500 return Err(bad("tooltip must be a string"));
2501 };
2502 out.apply_tooltip(s.to_str()?.as_ref());
2503 }
2504 name => {
2505 let name = schema::lua_alias(name).unwrap_or(name);
2509 let Some(def) = schema::by_snake_name(name) else {
2510 continue;
2511 };
2512 if let Some(parsed) =
2513 parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
2514 {
2515 schema::apply(def, parsed, &mut out).map_err(bad)?;
2516 }
2517 }
2518 }
2519 }
2520 out.with_spec(|s| s.overflow_bits(overflow));
2521 Ok(out)
2522}
2523
2524fn truthy(v: &mlua::Value) -> bool {
2525 matches!(v, mlua::Value::Boolean(true))
2526}
2527
2528fn bit(v: &mlua::Value, bit: u32) -> u32 {
2530 if truthy(v) { bit } else { 0 }
2531}
2532
2533fn number(v: &mlua::Value) -> Option<f32> {
2534 match v {
2535 mlua::Value::Number(n) => Some(*n as f32),
2536 mlua::Value::Integer(n) => Some(*n as f32),
2537 _ => None,
2538 }
2539}
2540
2541fn parse_value(kind: &Kind, v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Parsed>> {
2543 Ok(Some(match kind {
2544 Kind::F32 => match length_of(v, refs)? {
2545 Some(px) => Parsed::F32(px),
2546 None => return Ok(None),
2547 },
2548 Kind::Color => match parse_color(v, refs)? {
2549 Some(c) => Parsed::Color(c),
2550 None => return Ok(None),
2551 },
2552 Kind::Flag => {
2553 if truthy(v) {
2554 Parsed::Flag
2555 } else {
2556 return Ok(None);
2557 }
2558 }
2559 Kind::Enum(names) => {
2560 let mlua::Value::String(s) = v else {
2561 return Err(bad(format!("expected one of {names:?}")));
2562 };
2563 Parsed::Enum(schema::enum_index(names, &s.to_str()?).map_err(bad)?)
2564 }
2565 Kind::Sizing => match parse_sizing(v, refs)? {
2566 Some(s) => Parsed::Sizing(s),
2567 None => return Ok(None),
2568 },
2569 Kind::Min | Kind::Max => Parsed::Bound(match v {
2574 v if reference(v)?.is_some() => match length_of(v, refs)? {
2575 Some(px) => kui_core::Bound::Px(px),
2576 None => return Ok(None),
2577 },
2578 mlua::Value::String(s) => {
2581 let s = s.to_str()?;
2582 let b = if matches!(kind, Kind::Min) {
2583 schema::min_str(&s)
2584 } else {
2585 schema::max_str(&s)
2586 };
2587 match kept(b)? {
2588 Some(b) => b,
2589 None => return Ok(None),
2590 }
2591 }
2592 mlua::Value::Table(_) => match kept(kui_core::calc::bound_value(&size_value(v)?))? {
2593 Some(b) => b,
2594 None => return Ok(None),
2595 },
2596 v => kui_core::Bound::Px(
2597 number(v).ok_or_else(|| bad("expected a number, a string or a size table"))?,
2598 ),
2599 }),
2600 Kind::Msg | Kind::Tag => Parsed::Msg(lua_to_value(v)?),
2601 Kind::Str => {
2602 let mlua::Value::String(s) = v else {
2603 return Err(bad("expected a string"));
2604 };
2605 Parsed::Str(s.to_str()?.to_string())
2606 }
2607 Kind::Family => {
2610 let mlua::Value::String(s) = v else {
2611 return Err(bad("expected a family name"));
2612 };
2613 Parsed::Family(refs.family(&s.to_str()?))
2614 }
2615 Kind::Resource => match v {
2616 mlua::Value::Integer(n) => Parsed::Resource(*n as u64),
2617 mlua::Value::Number(n) => Parsed::Resource(*n as u64),
2618 _ => return Err(bad("expected a resource handle (integer)")),
2619 },
2620 Kind::Keyframes => Parsed::Keyframes(
2623 kui_core::keyframes::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
2624 ),
2625 Kind::Enter => {
2626 Parsed::Enter(kui_core::enter::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?)
2627 }
2628 }))
2629}
2630
2631fn parse_color_value(v: &mlua::Value) -> mlua::Result<Color> {
2634 match v {
2635 mlua::Value::Integer(n) => Ok(schema::color_num(*n as u32)),
2636 mlua::Value::Number(n) => Ok(schema::color_num(*n as u32)),
2637 mlua::Value::String(s) => schema::color_hex_str(&s.to_str()?).map_err(bad),
2638 _ => Err(bad("color must be a 0xRRGGBBAA integer or \"#hex\" string")),
2639 }
2640}
2641
2642fn parse_color(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Color>> {
2644 if let Some(name) = reference(v)? {
2645 return Ok(refs.color(&name));
2646 }
2647 parse_color_value(v)
2648 .map(Some)
2649 .map_err(|_| bad("color must be a 0xRRGGBBAA integer, a \"#hex\" string or a \"$token\""))
2650}
2651
2652fn size_value(v: &mlua::Value) -> mlua::Result<Value> {
2657 fn check(v: &Value) -> mlua::Result<()> {
2658 match v {
2659 Value::Map(m) => {
2660 for (k, x) in m {
2661 if k == "percent" {
2662 return Err(bad("a percentage is { pct = n } in Lua"));
2663 }
2664 check(x)?;
2665 }
2666 Ok(())
2667 }
2668 Value::List(xs) => xs.iter().try_for_each(check),
2669 _ => Ok(()),
2670 }
2671 }
2672 let value = lua_to_value(v)?;
2673 check(&value)?;
2674 Ok(value)
2675}
2676
2677fn kept<T>(r: Result<T, String>) -> mlua::Result<Option<T>> {
2681 match r {
2682 Ok(v) => Ok(Some(v)),
2683 Err(e) if kui_core::calc::is_full(&e) => Ok(None),
2684 Err(e) => Err(bad(e)),
2685 }
2686}
2687
2688fn parse_sizing(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Sizing>> {
2689 if let Some(name) = reference(v)? {
2690 return Ok(refs.length(&name).map(Sizing::Fixed));
2691 }
2692 Ok(Some(match v {
2693 mlua::Value::Number(n) => Sizing::Fixed(*n as f32),
2694 mlua::Value::Integer(n) => Sizing::Fixed(*n as f32),
2695 mlua::Value::String(s) => match kept(schema::sizing_str(&s.to_str()?))? {
2696 Some(s) => s,
2697 None => return Ok(None),
2698 },
2699 mlua::Value::Table(t) => {
2700 if let Some(p) = t.get::<Option<f32>>("pct")? {
2701 Sizing::Percent(p / 100.0)
2702 } else if let Some(f) = t.get::<Option<f32>>("grow")? {
2703 Sizing::Grow(f)
2704 } else {
2705 match kept(kui_core::calc::sizing_value(&size_value(v)?)).map_err(|e| {
2708 bad(format!(
2709 "sizing table needs pct, grow or a size expression: {e}"
2710 ))
2711 })? {
2712 Some(s) => s,
2713 None => return Ok(None),
2714 }
2715 }
2716 }
2717 _ => return Err(bad("invalid sizing value")),
2718 }))
2719}
2720
2721fn parse_pad(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<PadShorthand> {
2725 match v {
2726 mlua::Value::Table(t) => {
2727 let mut edge = |k: &str| -> mlua::Result<Option<f32>> {
2728 match t.get::<mlua::Value>(k)? {
2729 mlua::Value::Nil => Ok(None),
2730 v => length_of(&v, refs),
2731 }
2732 };
2733 Ok(PadShorthand {
2734 all: edge("all")?,
2735 x: edge("x")?,
2736 y: edge("y")?,
2737 l: edge("l")?,
2738 r: edge("r")?,
2739 t: edge("t")?,
2740 b: edge("b")?,
2741 })
2742 }
2743 v => Ok(PadShorthand {
2744 all: length_of(v, refs).map_err(|_| bad("invalid padding value"))?,
2745 ..PadShorthand::default()
2746 }),
2747 }
2748}
2749
2750fn parse_align(s: &str) -> mlua::Result<Align> {
2751 schema::enum_index(schema::ALIGNS, s)
2752 .map(schema::align_idx)
2753 .map_err(bad)
2754}
2755
2756fn float_preset(name: &str) -> mlua::Result<FloatConfig> {
2759 FloatConfig::preset(name).ok_or_else(|| {
2760 bad(format!(
2761 "bad float preset '{name}' (one of {})",
2762 kui_core::FLOAT_PRESETS.join(" | ")
2763 ))
2764 })
2765}
2766
2767fn parse_attach(f: &Table, key: &str) -> mlua::Result<Option<(Align, Align)>> {
2769 let Some(at) = f.get::<Option<Table>>(key)? else {
2770 return Ok(None);
2771 };
2772 Ok(Some((
2773 parse_align(&at.get::<String>(1)?)?,
2774 parse_align(&at.get::<String>(2)?)?,
2775 )))
2776}
2777
2778fn parse_float(v: &mlua::Value) -> mlua::Result<FloatConfig> {
2779 let f = match v {
2780 mlua::Value::String(s) => return float_preset(&s.to_str()?),
2781 mlua::Value::Table(f) => f,
2782 _ => return Err(bad("float must be a preset string or a table")),
2783 };
2784 let self_at = match parse_attach(f, "self")? {
2787 Some(at) => Some(at),
2788 None => parse_attach(f, "self_at")?,
2789 };
2790 Ok(FloatConfig::build(
2791 match f.get::<Option<String>>("anchor")? {
2792 Some(name) => float_preset(&name)?,
2793 None => FloatConfig::parent(),
2794 },
2795 parse_attach(f, "at")?,
2796 self_at,
2797 f.get::<Option<f32>>("dx")?,
2798 f.get::<Option<f32>>("dy")?,
2799 f.get::<Option<bool>>("fit")?.unwrap_or(false),
2800 f.get::<Option<bool>>("clip")?.unwrap_or(false),
2801 ))
2802}
2803
2804pub const MAX_VALUE_DEPTH: usize = 64;
2812
2813pub fn lua_to_value(v: &mlua::Value) -> mlua::Result<Value> {
2814 to_value(
2815 v,
2816 &mut ValuePath {
2817 depth: 0,
2818 seen: Vec::new(),
2819 },
2820 )
2821}
2822
2823const VALUE_TRACKED_PAST: usize = 8;
2829
2830struct ValuePath {
2833 depth: usize,
2834 seen: Vec<*const std::ffi::c_void>,
2835}
2836
2837fn to_value(v: &mlua::Value, path: &mut ValuePath) -> mlua::Result<Value> {
2841 Ok(match v {
2842 mlua::Value::Nil => Value::Null,
2843 mlua::Value::Boolean(b) => Value::Bool(*b),
2844 mlua::Value::Integer(i) => Value::Int(*i),
2845 mlua::Value::Number(n) => Value::Float(*n),
2846 mlua::Value::String(s) => Value::Str(s.to_str()?.to_string()),
2847 mlua::Value::Table(t) => {
2848 let tracked = path.depth >= VALUE_TRACKED_PAST;
2849 if tracked {
2850 let at = t.to_pointer();
2851 if path.seen.contains(&at) {
2852 return Err(bad("a table that holds itself cannot be a value"));
2853 }
2854 path.seen.push(at);
2855 }
2856 if path.depth >= MAX_VALUE_DEPTH {
2857 return Err(bad(format!("a value nested past {MAX_VALUE_DEPTH} tables")));
2858 }
2859 path.depth += 1;
2860 let len = t.raw_len();
2861 let value = if len > 0 {
2862 let mut list = Vec::with_capacity(len);
2863 for item in t.sequence_values::<mlua::Value>() {
2864 list.push(to_value(&item?, path)?);
2865 }
2866 Value::List(list)
2867 } else {
2868 let mut map = Vec::new();
2869 for pair in t.pairs::<String, mlua::Value>() {
2870 let (k, v) = pair?;
2871 map.push((k, to_value(&v, path)?));
2872 }
2873 Value::Map(map)
2874 };
2875 path.depth -= 1;
2876 if tracked {
2877 path.seen.pop();
2878 }
2879 value
2880 }
2881 other => {
2882 return Err(mlua::Error::runtime(format!(
2883 "cannot convert {} to event payload",
2884 other.type_name()
2885 )));
2886 }
2887 })
2888}
2889
2890pub fn value_to_lua(lua: &Lua, v: &Value) -> mlua::Result<mlua::Value> {
2891 Ok(match v {
2892 Value::Null => mlua::Value::Nil,
2893 Value::Bool(b) => mlua::Value::Boolean(*b),
2894 Value::Int(i) => mlua::Value::Integer(*i),
2895 Value::Float(f) => mlua::Value::Number(*f),
2896 Value::Str(s) => mlua::Value::String(lua.create_string(s)?),
2897 Value::List(items) => {
2898 let t = lua.create_table_with_capacity(items.len(), 0)?;
2899 for (i, item) in items.iter().enumerate() {
2900 t.set(i + 1, value_to_lua(lua, item)?)?;
2901 }
2902 mlua::Value::Table(t)
2903 }
2904 Value::Map(entries) => {
2905 let t = lua.create_table_with_capacity(0, entries.len())?;
2906 for (k, v) in entries {
2907 t.set(k.as_str(), value_to_lua(lua, v)?)?;
2908 }
2909 mlua::Value::Table(t)
2910 }
2911 })
2912}
2913
2914#[cfg(test)]
2915mod tests {
2916 use super::*;
2917 use kui_core::{
2918 Core, Edges, FontFamily, InputEvent, NodeSpec, OriginId, Rect, Size, TextStyle, Vec2,
2919 WindowButton, WindowId,
2920 };
2921
2922 #[test]
2923 fn value_round_trips_through_lua() {
2924 let lua = Lua::new();
2925 let original = Value::map([
2926 ("kind", "inc".into()),
2927 ("by", Value::Int(2)),
2928 (
2929 "weights",
2930 Value::List(vec![Value::Float(0.5), Value::Float(1.5)]),
2931 ),
2932 ("enabled", Value::Bool(true)),
2933 ]);
2934 let lua_v = value_to_lua(&lua, &original).unwrap();
2935 let back = lua_to_value(&lua_v).unwrap();
2936 assert_eq!(back.get_str("kind"), Some("inc"));
2937 assert_eq!(back.get_int("by"), Some(2));
2938 assert_eq!(back.get_bool("enabled"), Some(true));
2939 match back.get("weights") {
2940 Some(Value::List(items)) => assert_eq!(items.len(), 2),
2941 other => panic!("expected list, got {other:?}"),
2942 }
2943 }
2944
2945 fn test_refs() -> Refs<'static> {
2949 let core: &'static Core = Box::leak(Box::new(Core::new()));
2950 Refs::new(core.token_lookup())
2951 }
2952
2953 fn eval_table(lua: &Lua, src: &str) -> Table {
2954 lua.load(src).eval().unwrap()
2955 }
2956
2957 #[test]
2959 fn schema_props_match_the_rust_builder() {
2960 let lua = Lua::new();
2961 let t = eval_table(
2962 &lua,
2963 r##"{
2964 width = "grow", height = {pct = 50},
2965 min_width = 10, max_width = 500, min_height = 5, max_height = 300,
2966 pad = {l = 1, r = 2, t = 3, b = 4}, gap = 8,
2967 main_align = "center", cross_align = "end", center = false,
2968 bg = "#14161e", radius = 6, border = {w = 1, color = 0x2a2d3aff},
2969 clip = true, scroll = true, scroll_x = true,
2970 float = {anchor = "viewport", at = {"end", "end"}, self_at = {"end", "end"},
2971 dx = -8, dy = -8, fit = true},
2972 hoverable = true, window = "close",
2973 on_click = {kind = "hit"}, on_drag = "d", on_key = 7, key_up = true,
2974 modal = "dlg", on_context_menu = {kind = "menu"},
2975 initial_focus = true,
2976 key = "panel", key_focus = true,
2977 }"##,
2978 );
2979 let p = parse_props(&t, true, &mut test_refs()).unwrap();
2980 let expected = NodeSpec::row()
2981 .grow_width()
2982 .height(Sizing::Percent(0.5))
2983 .min_width(10.0)
2984 .max_width(500.0)
2985 .min_height(5.0)
2986 .max_height(300.0)
2987 .padding(Edges {
2988 l: 1.0,
2989 r: 2.0,
2990 t: 3.0,
2991 b: 4.0,
2992 })
2993 .gap(8.0)
2994 .main_align(Align::Center)
2995 .cross_align(Align::End)
2996 .bg(Color::hex(0x14161eff))
2997 .radius(6.0)
2998 .border(1.0, Color::hex(0x2a2d3aff))
2999 .clip()
3000 .scroll_y()
3001 .scroll_x()
3002 .float(
3003 FloatConfig::viewport()
3004 .inside(Align::End, Align::End)
3005 .offset(-8.0, -8.0)
3006 .fit(),
3007 )
3008 .hoverable()
3009 .window_button(WindowButton::Close)
3010 .on_click(Value::map([("kind", "hit".into())]))
3011 .on_drag("d")
3012 .on_key(Value::Int(7))
3013 .key_up()
3014 .modal("dlg")
3015 .initial_focus()
3016 .on_context_menu(Value::map([("kind", "menu".into())]));
3017 assert_eq!(p.spec, expected);
3018 assert_eq!(p.key.as_deref(), Some("panel"));
3019 assert!(p.key_focus);
3020 }
3021
3022 #[test]
3025 fn a_min_is_a_number_or_fit() {
3026 let lua = Lua::new();
3027 let t = eval_table(&lua, r#"{ min_width = "fit", min_height = 3 }"#);
3028 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3029 let expected = NodeSpec::column()
3030 .min_width(kui_core::Min::FIT)
3031 .min_height(3.0);
3032 assert_eq!(p.spec, expected);
3033 let t = eval_table(&lua, r#"{ min_width = "grow" }"#);
3034 let err = parse_props(&t, false, &mut test_refs())
3035 .unwrap_err()
3036 .to_string();
3037 assert!(err.contains("bad min"), "{err}");
3038 }
3039
3040 #[test]
3044 fn the_lua_composites_resolve_the_way_the_core_says() {
3045 let lua = Lua::new();
3046 let t = eval_table(
3047 &lua,
3048 r#"{ pad = { all = 4, x = 10, b = 1 },
3049 float = { anchor = "below", dx = 6 } }"#,
3050 );
3051 let p = parse_props(&t, false, &mut test_refs()).unwrap();
3052 assert_eq!(
3053 p.spec.layout.padding,
3054 Edges {
3055 l: 10.0,
3056 r: 10.0,
3057 t: 4.0,
3058 b: 1.0,
3059 }
3060 );
3061 assert_eq!(
3062 p.spec.layout.float,
3063 Some(FloatConfig::build(
3064 FloatConfig::below(),
3065 None,
3066 None,
3067 Some(6.0),
3068 None,
3069 false,
3070 false
3071 ))
3072 );
3073
3074 let by_self = eval_table(&lua, r#"{ float = { self = {"end", "start"} } }"#);
3076 let by_self_at = eval_table(&lua, r#"{ float = { self_at = {"end", "start"} } }"#);
3077 assert_eq!(
3078 parse_props(&by_self, false, &mut test_refs())
3079 .unwrap()
3080 .spec
3081 .layout
3082 .float,
3083 parse_props(&by_self_at, false, &mut test_refs())
3084 .unwrap()
3085 .spec
3086 .layout
3087 .float
3088 );
3089
3090 let bad_preset = eval_table(&lua, r#"{ float = "beneath" }"#);
3093 let e = parse_props(&bad_preset, false, &mut test_refs())
3094 .unwrap_err()
3095 .to_string();
3096 assert!(e.contains("beneath") && e.contains("below"), "{e}");
3097 }
3098
3099 #[test]
3100 fn text_style_props_match_the_rust_builder() {
3101 let lua = Lua::new();
3102 let t = eval_table(
3103 &lua,
3104 r#"{ size = 20, line_height = 30, color = 0x73d98cff, family = "mono" }"#,
3105 );
3106 let style = parse_props(&t, false, &mut test_refs()).unwrap().style;
3107 assert_eq!(
3108 style,
3109 TextStyle::new(20.0)
3110 .line_height(30.0)
3111 .color(Color::hex(0x73d98cff))
3112 .family(FontFamily::Mono)
3113 );
3114 let t = eval_table(&lua, r#"{ wrap = "none", max_lines = 2, ellipsis = true }"#);
3115 assert_eq!(
3116 parse_props(&t, false, &mut test_refs()).unwrap().style,
3117 TextStyle::default().nowrap().max_lines(2).ellipsis()
3118 );
3119 let t = eval_table(&lua, r##"{ color = "#fff", size = 12 }"##);
3122 assert_eq!(
3123 parse_props(&t, false, &mut test_refs()).unwrap().style,
3124 TextStyle::new(12.0).color(Color::hex(0xffffffff))
3125 );
3126 }
3127
3128 #[test]
3129 fn bad_values_name_the_prop() {
3130 let lua = Lua::new();
3131 let t = eval_table(&lua, r#"{ main_align = "middle" }"#);
3132 let e = parse_props(&t, false, &mut test_refs())
3133 .unwrap_err()
3134 .to_string();
3135 assert!(e.contains("main_align"), "{e}");
3136 assert!(e.contains("middle"), "{e}");
3137 }
3138
3139 fn frame(core: &mut Core, ext: &mut LuaExtension) -> usize {
3140 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3141 ui.set_origin(OriginId(1));
3142 ext.view(&Slot::root(), &mut ui).unwrap();
3143 ui.finish();
3144 core.output().0.quads.len()
3145 }
3146
3147 #[test]
3148 fn script_view_builds_ir_nodes() {
3149 let mut ext = LuaExtension::from_source(
3150 "test",
3151 r#"
3152 count = 41
3153 function view()
3154 return column { gap = 8, pad = 16, bg = 0x10121aff,
3155 text("count: " .. count, { size = 20 }),
3156 button { label = "bump", on_click = { kind = "bump" } },
3157 }
3158 end
3159 function on_event(ev)
3160 if ev.kind == "bump" then count = count + 1 end
3161 end
3162 "#,
3163 )
3164 .unwrap();
3165
3166 let mut core = Core::new();
3167 let quads = frame(&mut core, &mut ext);
3168 assert!(quads > 10, "expected panel/button/glyph quads, got {quads}");
3170
3171 ext.on_event(&UiEvent {
3173 origin: OriginId(1),
3174 key: Key::ROOT,
3175 payload: Value::map([("kind", "bump".into())]),
3176 window: WindowId::MAIN,
3177 slot: None,
3178 });
3179 let count: i64 = ext.lua.globals().get("count").unwrap();
3180 assert_eq!(count, 42);
3181 }
3182
3183 #[test]
3189 fn a_devtools_tab_calls_its_view_only_while_on_show() {
3190 let mut ext = LuaExtension::from_source(
3191 "test",
3192 r#"
3193 calls = 0
3194 function view(env)
3195 return column { gap = 8,
3196 text("app"),
3197 devtools_tab { name = "syntax", label = "Tree-sitter", view = function()
3198 calls = calls + 1
3199 return column { text("from lua"),
3200 button { label = "jump", on_click = { kind = "jump" } } }
3201 end },
3202 devtools_tab { name = "plug", label = "Plugin", slot = "ts/panel" },
3203 devtools_tab { name = "bad", label = "Bad" },
3204 }
3205 end
3206 "#,
3207 )
3208 .unwrap();
3209 let mut core = Core::new();
3210 core.set_devtools(true);
3211 core.set_devtools_dock(kui_core::DevtoolsDock::Right);
3212 core.set_inspect(true);
3213 frame(&mut core, &mut ext);
3214 frame(&mut core, &mut ext);
3215 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3216 assert_eq!(calls, 0, "not on show: the view was not called");
3217 let ws = core.take_warnings();
3218 assert_eq!(
3219 ws.iter()
3220 .filter(|w| w.code == kui_core::diag::BAD_DEVTOOLS_TAB)
3221 .count(),
3222 1,
3223 "the tab with neither form warns once: {ws:?}"
3224 );
3225 let chord = || {
3228 kui_core::InputEvent::KeyDown(kui_core::KeyPress::new(
3229 kui_core::KeyCode::Char('N'),
3230 kui_core::KeyMods::NONE.with_ctrl().with_shift(),
3231 ))
3232 };
3233 core.handle_input(chord());
3234 core.handle_input(chord());
3235 frame(&mut core, &mut ext);
3236 let calls: i64 = ext.lua.globals().get("calls").unwrap();
3237 assert_eq!(calls, 1, "on show: called once a frame");
3238 frame(&mut core, &mut ext);
3239 let body = core
3240 .nodes()
3241 .iter()
3242 .find(|n| n.label.as_deref() == Some("kui-devtools/tab/syntax"))
3243 .map(|n| n.rect)
3244 .expect("the body");
3245 let jump = core
3246 .nodes()
3247 .iter()
3248 .find(|n| n.label.as_deref() == Some("jump"))
3249 .map(|n| n.rect)
3250 .expect("the script's button");
3251 assert!(
3252 jump.x >= body.x && jump.x + jump.w <= body.x + body.w,
3253 "{jump:?} in {body:?}"
3254 );
3255 assert!(
3256 core.nodes()
3257 .iter()
3258 .any(|n| n.text.as_deref() == Some("from lua")),
3259 "the script's text painted"
3260 );
3261 let evs = kui_core::testing::click_at(&mut core, jump.x + 2.0, jump.y + 2.0);
3262 assert_eq!(evs.len(), 1);
3263 assert_eq!(evs[0].origin, OriginId(1), "the script's own event");
3264 assert_eq!(evs[0].kind(), Some("jump"));
3265 }
3266
3267 #[test]
3272 fn the_root_table_declares_windows() {
3273 use kui_core::{WindowCommand, WindowConfig};
3274 let mut ext = LuaExtension::from_source(
3275 "windows",
3276 r#"
3277 function view(env)
3278 return column {
3279 windows = { { name = "palette", width = 400, height = 300,
3280 activates = false }, "tools" },
3281 text("main"),
3282 }
3283 end
3284 "#,
3285 )
3286 .unwrap();
3287 let mut core = Core::new();
3288 frame(&mut core, &mut ext);
3289 let cmds = core.take_window_commands();
3290 assert_eq!(cmds.len(), 2, "{cmds:?}");
3291 assert_eq!(
3292 cmds[0],
3293 WindowCommand::Open {
3294 id: WindowId(1),
3295 owner: WindowId::MAIN,
3296 origin: OriginId(1),
3297 config: WindowConfig {
3298 size: Size::new(400.0, 300.0),
3299 activates: false,
3300 ..WindowConfig::default()
3301 },
3302 }
3303 );
3304 assert_eq!(
3305 cmds[1],
3306 WindowCommand::Open {
3307 id: WindowId(2),
3308 owner: WindowId::MAIN,
3309 origin: OriginId(1),
3310 config: WindowConfig::default(),
3311 }
3312 );
3313 frame(&mut core, &mut ext);
3315 assert!(core.take_window_commands().is_empty());
3316 assert!(core.take_warnings().is_empty());
3317 }
3318
3319 #[test]
3323 fn a_windows_entry_declares_a_popup() {
3324 use kui_core::{Rect, WindowCommand, WindowConfig, WindowKind};
3325 let mut ext = LuaExtension::from_source(
3326 "windows-popup",
3327 r#"
3328 function view(env)
3329 return column {
3330 windows = { { name = "menu", kind = "popup",
3331 width = 160, height = 320,
3332 anchor = { x = 12, y = 40, w = 160, h = 24 } } },
3333 text("main"),
3334 }
3335 end
3336 "#,
3337 )
3338 .unwrap();
3339 let mut core = Core::new();
3340 frame(&mut core, &mut ext);
3341 assert_eq!(
3342 core.take_window_commands(),
3343 vec![WindowCommand::Open {
3344 id: WindowId(1),
3345 owner: WindowId::MAIN,
3346 origin: OriginId(1),
3347 config: WindowConfig {
3348 kind: WindowKind::Popup,
3349 size: Size::new(160.0, 320.0),
3350 activates: false,
3351 anchor: Rect::new(12.0, 40.0, 160.0, 24.0),
3352 },
3353 }]
3354 );
3355 }
3356
3357 #[test]
3362 fn a_windows_entry_cannot_name_an_unknown_kind() {
3363 let mut ext = LuaExtension::from_source(
3364 "windows-kind",
3365 r#"
3366 function view(env)
3367 return column {
3368 windows = { { name = "palette", kind = "sheet" } },
3369 text("main"),
3370 }
3371 end
3372 "#,
3373 )
3374 .unwrap();
3375 let mut core = Core::new();
3376 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
3377 ui.set_origin(OriginId(1));
3378 let err = ext.view(&Slot::root(), &mut ui).unwrap_err();
3379 assert!(err.contains("sheet"), "{err}");
3380 assert!(err.contains("popup"), "{err}");
3381 }
3382
3383 #[test]
3387 fn a_button_takes_an_index_and_a_cursor_shape_is_refused() {
3388 let mut ext = LuaExtension::from_source(
3389 "rows",
3390 r#"
3391 from = 0
3392 function view(env)
3393 return column {
3394 button { label = "Open", index = from, on_click = { row = from } },
3395 button { label = "Open", index = from + 1, on_click = { row = from + 1 } },
3396 button { label = "Keyed", key = "named", index = 7, on_click = "k" },
3397 }
3398 end
3399 "#,
3400 )
3401 .unwrap();
3402 let mut core = Core::new();
3403 core.set_diagnostics(true);
3404 let frame = |core: &mut Core, ext: &mut LuaExtension| {
3405 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3406 ui.set_origin(OriginId(1));
3407 ext.view(&Slot::root(), &mut ui).unwrap();
3408 ui.finish();
3409 };
3410 frame(&mut core, &mut ext);
3411 assert!(core.take_warnings().is_empty(), "index is a button row");
3412 let buttons = |core: &mut Core| -> Vec<Key> {
3413 core.access_tree()
3414 .nodes
3415 .iter()
3416 .filter(|n| n.role == kui_core::Role::Button)
3417 .map(|n| n.key)
3418 .collect()
3419 };
3420 let before = buttons(&mut core);
3421 assert_eq!(before.len(), 3, "two rows with the same text are two nodes");
3422 assert!(
3423 core.key_of("named").is_none(),
3424 "declared beside `key`, the index wins"
3425 );
3426 ext.lua.globals().set("from", 1).unwrap();
3427 frame(&mut core, &mut ext);
3428 let after = buttons(&mut core);
3429 assert_eq!(
3430 after[0], before[1],
3431 "row 1 keeps its key as it moves up the list"
3432 );
3433
3434 let mut ext = LuaExtension::from_source(
3435 "term",
3436 r#"
3437 function view(env)
3438 return column { cells { key = "term", rows = 1, cols = 4, size = 14,
3439 lines = { "abcd" }, cursor_at = { 1, 1 }, cursor_shape = "blob" } }
3440 end
3441 "#,
3442 )
3443 .unwrap();
3444 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3445 ui.set_origin(OriginId(1));
3446 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
3447 assert!(
3448 err.contains("block | bar | underline") && err.contains("blob"),
3449 "{err}"
3450 );
3451 }
3452
3453 #[test]
3456 fn option_as_alt_is_a_side_by_name() {
3457 let mut core = Core::new();
3458 let mut ext = LuaExtension::from_source(
3459 "keys",
3460 r#"
3461 function view(env)
3462 return column { option_as_alt = "left", text("x") }
3463 end
3464 "#,
3465 )
3466 .unwrap();
3467 frame(&mut core, &mut ext);
3468 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
3469
3470 let mut ext = LuaExtension::from_source(
3471 "keys",
3472 r#"
3473 function view(env)
3474 return column { option_as_alt = "meta", text("x") }
3475 end
3476 "#,
3477 )
3478 .unwrap();
3479 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3480 ui.set_origin(OriginId(1));
3481 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
3482 assert!(
3483 err.contains("option_as_alt") && err.contains("\"both\"") && err.contains("meta"),
3484 "{err}"
3485 );
3486 }
3487
3488 #[test]
3492 fn a_value_that_holds_itself_or_nests_too_deep_is_refused() {
3493 let lua = Lua::new();
3494 let read = |src: &str| lua_to_value(&lua.load(src).eval::<mlua::Value>().unwrap());
3495 let err = |src: &str| read(src).unwrap_err().to_string();
3496 assert!(err("local t = {}; t[1] = t; return t").contains("holds itself"));
3497 assert!(err("local t = {}; t.me = { t }; return t").contains("holds itself"));
3498 let shared = read("local l = { 1, 2 }; return { a = l, b = l }").unwrap();
3499 assert_eq!(shared.get("a"), shared.get("b"));
3500 let nested = |n: usize| format!("local t = 1; for _ = 1, {n} do t = {{ t }} end; return t");
3501 assert!(read(&nested(MAX_VALUE_DEPTH)).is_ok());
3502 assert!(err(&nested(MAX_VALUE_DEPTH + 1)).contains("nested past 64"));
3503 assert!(err(&nested(100_000)).contains("nested past 64"));
3505 }
3506
3507 #[test]
3511 fn a_view_that_holds_itself_or_nests_too_deep_is_refused() {
3512 let run = |body: &str| {
3513 let mut core = Core::new();
3514 let mut ext =
3515 LuaExtension::from_source("deep", &format!("function view(env)\n{body}\nend"))
3516 .unwrap();
3517 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3518 ui.set_origin(OriginId(1));
3519 let built = ext.view(&Slot::root(), &mut ui);
3520 ui.finish();
3521 built
3522 };
3523 let err = run("local t = column {}; t[1] = row { t }; return t").unwrap_err();
3524 assert!(err.contains("holds itself"), "{err}");
3525 let nested = |n: usize| {
3526 format!(
3527 "local t = text('x')
3528 for i = 2, {n} do
3529 if i % 2 == 0 then t = radio_group {{ label = 'g', t }}
3530 elseif i % 3 == 0 then t = tooltip {{ t }}
3531 else t = column {{ t }} end
3532 end
3533 return t"
3534 )
3535 };
3536 run(&nested(MAX_VIEW_DEPTH)).unwrap();
3537 let err = run(&nested(MAX_VIEW_DEPTH + 1)).unwrap_err();
3538 assert!(err.contains("nested past 128"), "{err}");
3539 run(&nested(8)).unwrap();
3541 }
3542
3543 #[test]
3547 fn option_as_alt_none_leaves_the_hosts_side() {
3548 let mut core = Core::new();
3549 let mut ext = LuaExtension::from_source(
3550 "keys",
3551 r#"
3552 function view(env)
3553 return column { option_as_alt = "none", text("x") }
3554 end
3555 "#,
3556 )
3557 .unwrap();
3558 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
3559 ui.option_as_alt(kui_core::OptionAsAlt::Left);
3560 ui.set_origin(OriginId(1));
3561 ext.view(&Slot::root(), &mut ui).unwrap();
3562 ui.finish();
3563 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
3564 }
3565
3566 #[test]
3568 fn every_node_type_lowers() {
3569 let mut ext = LuaExtension::from_source(
3570 "all",
3571 r##"
3572 function view(env)
3573 return column { gap = 4, window_title = "all nodes", always_on_top = true,
3574 secure_input = true, option_as_alt = "right",
3575 titlebar { text("custom title"), window_buttons() },
3576 titlebar { title = "plain title" },
3577 text({ "same IR as ", { "Rust", bold = true, color = "#73d98c" },
3578 { " — flatter", italic = true } }, { size = 13 }),
3579 edit { key = "note", initial = "hello", size = 14, width = 200,
3580 multiline = true },
3581 input { label = "name", initial = "" },
3582 dropdown { label = "language", options = { "English", "Deutsch" }, current = 1 },
3583 row { tooltip = "hover hint", pad = 4, text("badge") },
3584 row { hoverable = true, text("legend"), tooltip("always shown") },
3585 row { text("rich tip"), tooltip { text("a"), text("b") } },
3586 latency_graph(),
3587 latency_hud { at = { "start", "end" } },
3588 button { label = "ok", on_click = "ok" },
3589 }
3590 end
3591 "##,
3592 )
3593 .unwrap();
3594 let mut core = Core::new();
3595 let quads = frame(&mut core, &mut ext);
3596 assert!(quads > 60, "got {quads} quads");
3597 assert_eq!(core.window_title(), Some("all nodes"));
3598 assert!(core.always_on_top());
3599 assert!(core.secure_input());
3600 assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Right);
3601 let unknown: Vec<String> = core
3605 .take_warnings()
3606 .into_iter()
3607 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
3608 .map(|w| w.message)
3609 .collect();
3610 assert!(unknown.is_empty(), "{unknown:#?}");
3611 }
3612
3613 #[test]
3616 fn unknown_props_warn_once_in_lua_spelling() {
3617 let mut ext = LuaExtension::from_source(
3618 "typos",
3619 r#"
3620 function view(env)
3621 return column { pad = 8,
3622 row { hoverBg = 0x333333ff, width = 10, height = 10 },
3623 row { hoverBg = 0x333333ff, width = 10, height = 10 },
3624 row { colour = 0x333333ff, width = 10, height = 10 },
3625 }
3626 end
3627 "#,
3628 )
3629 .unwrap();
3630 let mut core = Core::new();
3631 frame(&mut core, &mut ext);
3632 let mut warned: Vec<String> = core
3633 .take_warnings()
3634 .into_iter()
3635 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
3636 .map(|w| w.message)
3637 .collect();
3638 warned.sort();
3639 assert_eq!(warned.len(), 2, "one per name, not per node: {warned:#?}");
3640 assert!(
3641 warned[1].contains("`hoverBg` is not a prop of box")
3642 && warned[1].contains("did you mean `hover_bg`?"),
3643 "{warned:#?}"
3644 );
3645 assert!(warned[0].contains("`colour`") && !warned[0].contains("did you mean"));
3647 frame(&mut core, &mut ext);
3649 assert!(core.take_warnings().is_empty());
3650 }
3651
3652 #[test]
3656 fn a_text_warns_about_the_rows_it_does_not_read() {
3657 let mut ext = LuaExtension::from_source(
3658 "textrows",
3659 r#"
3660 function view(env)
3661 return column { pad = 8,
3662 text("hi", { live = "polite", label = "x", on_click = "go", size = 14, line_height = 20, max_lines = 2 }),
3663 text({ "a", { "b", bold = true, bg = 0x00ff00ff } }, { color = 0xff0000ff }),
3664 }
3665 end
3666 "#,
3667 )
3668 .unwrap();
3669 let mut core = Core::new();
3670 frame(&mut core, &mut ext);
3671 let mut warned: Vec<String> = core
3672 .take_warnings()
3673 .into_iter()
3674 .filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
3675 .map(|w| w.message)
3676 .collect();
3677 warned.sort();
3678 assert_eq!(warned.len(), 3, "{warned:#?}");
3679 for (w, name) in warned.iter().zip(["label", "live", "on_click"]) {
3680 assert!(
3681 w.contains(&format!("`{name}`")) && w.contains("not one text reads"),
3682 "{w}"
3683 );
3684 assert!(w.contains("`max_lines`"), "names the rows it reads: {w}");
3685 }
3686 }
3687
3688 #[test]
3691 fn the_repeat_alias_does_not_warn() {
3692 let mut ext = LuaExtension::from_source(
3693 "alias",
3694 r#"
3695 function view(env)
3696 return column {
3697 row { width = 10, height = 10, keyframes = { { bg = 0x000000ff } },
3698 transition = 100, direction = "alternate" },
3699 }
3700 end
3701 "#,
3702 )
3703 .unwrap();
3704 let mut core = Core::new();
3705 frame(&mut core, &mut ext);
3706 assert!(core.take_warnings().is_empty());
3707 }
3708
3709 #[test]
3711 fn unknown_props_stay_quiet_with_diagnostics_off() {
3712 let mut ext = LuaExtension::from_source(
3713 "quiet",
3714 r#"
3715 function view(env)
3716 return column { hoverBg = 0x333333ff }
3717 end
3718 "#,
3719 )
3720 .unwrap();
3721 let mut core = Core::new();
3722 core.set_diagnostics(false);
3723 frame(&mut core, &mut ext);
3724 assert!(core.take_warnings().is_empty());
3725 }
3726
3727 #[test]
3730 fn tooltip_prop_is_hover_gated() {
3731 let mut ext = LuaExtension::from_source(
3732 "tip",
3733 r#"
3734 function view(env)
3735 return column { pad = 10,
3736 row { width = 100, height = 40, bg = 0x333333ff, tooltip = "a long hint" },
3737 }
3738 end
3739 "#,
3740 )
3741 .unwrap();
3742 let mut core = Core::new();
3743 let idle = frame(&mut core, &mut ext);
3744 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
3745 let hovered = frame(&mut core, &mut ext);
3746 assert!(
3747 hovered > idle + 5,
3748 "hover should add the tooltip's quads ({idle} -> {hovered})"
3749 );
3750 core.handle_input(InputEvent::CursorLeft);
3751 assert_eq!(frame(&mut core, &mut ext), idle);
3752 }
3753
3754 #[test]
3760 fn the_stock_button_admits_the_access_rows() {
3761 let mut ext = LuaExtension::from_source(
3762 "button",
3763 r#"
3764 function view(env)
3765 return column { pad = 10, gap = 4,
3766 button { label = "go", on_click = "go", description = "Starts the run" },
3767 button { label = "Stop the run", text = "stop", on_click = "stop",
3768 disabled = true, tooltip = "Nothing is running" },
3769 button { label = "x", on_click = "x", radius = 12, hoverBg = 0x333333ff },
3770 }
3771 end
3772 "#,
3773 )
3774 .unwrap();
3775 let mut core = Core::new();
3776 frame(&mut core, &mut ext);
3777 let tree = core.access_tree().clone();
3778 let named = |n: &str| {
3779 tree.nodes
3780 .iter()
3781 .find(|node| node.name.as_deref() == Some(n))
3782 };
3783 let go = named("go").expect("the button, named by its label");
3784 assert_eq!(go.role, kui_core::Role::Button);
3785 assert_eq!(go.description.as_deref(), Some("Starts the run"));
3786 assert!(!go.disabled);
3787 let stop = named("Stop the run").expect("named past its text");
3788 assert_eq!(stop.description.as_deref(), Some("Nothing is running"));
3789 assert!(stop.disabled);
3790 assert_eq!(tree.nodes.len(), 4, "window and three buttons");
3791 let ws = core.take_warnings();
3792 assert_eq!(ws.len(), 2, "{ws:?}");
3793 let radius = ws
3794 .iter()
3795 .find(|w| w.message.contains("`radius`"))
3796 .expect("a row the button does not read");
3797 assert!(
3798 radius
3799 .message
3800 .contains("is a prop, but not one button reads")
3801 );
3802 assert!(radius.message.contains("`description`"));
3803 let hover = ws
3806 .iter()
3807 .find(|w| w.message.contains("hoverBg"))
3808 .expect("a misspelling");
3809 assert!(hover.message.contains("is not a prop of button"));
3810 assert!(!hover.message.contains("did you mean"));
3811 }
3812
3813 #[test]
3818 fn an_accent_button_takes_the_colour_the_host_pushed() {
3819 let mut ext = LuaExtension::from_source(
3820 "accent",
3821 r#"
3822 function view(env)
3823 return column { pad = 10,
3824 button { label = "go", on_click = "go", accent = true },
3825 }
3826 end
3827 "#,
3828 )
3829 .unwrap();
3830 let mut bg = |core: &mut Core| {
3831 frame(core, &mut ext);
3832 core.output().0.quads[0].color
3833 };
3834 let mut core = Core::new();
3835 assert_eq!(
3836 bg(&mut core),
3837 kui_core::Color::rgb8(0x3b, 0x5b, 0xd4),
3838 "no accent pushed, the stock button"
3839 );
3840 assert!(
3841 core.take_warnings().is_empty(),
3842 "and a row the button reads"
3843 );
3844
3845 let accent = kui_core::Color::hex(0x007affff);
3846 core.env.system.accent = Some(accent);
3847 assert_eq!(bg(&mut core), accent);
3848 }
3849
3850 #[test]
3854 fn semantics_reach_the_access_tree() {
3855 let mut ext = LuaExtension::from_source(
3856 "a11y",
3857 r#"
3858 function view(env)
3859 return column { pad = 10,
3860 row { key = "save", on_click = "save", label = "Save", width = 20, height = 20 },
3861 row { key = "check", role = "checkbox", checked = true, text("Remember") },
3862 row { key = "vol", role = "slider", label = "Volume",
3863 value_now = 3, value_min = 0, value_max = 10 },
3864 row { key = "art", role = "none", on_click = "art", text("Art") },
3865 row { key = "tip", tooltip = "more here", on_click = "t", text("Tip") },
3866 row { key = "tabs", role = "tabList",
3867 row { key = "t0", role = "tab", text("General") },
3868 row { key = "t1", role = "tab", selected = true, text("Network") },
3869 },
3870 row { key = "adv", on_click = "adv", expanded = "collapsed",
3871 label = "Advanced" },
3872 }
3873 end
3874 "#,
3875 )
3876 .unwrap();
3877 let mut core = Core::new();
3878 frame(&mut core, &mut ext);
3879 let tree = core.access_tree().clone();
3880 let named = |n: &str| {
3881 tree.nodes
3882 .iter()
3883 .find(|node| node.name.as_deref() == Some(n))
3884 .cloned()
3885 };
3886 let save = named("Save").expect("labelled button");
3887 assert_eq!(save.role, kui_core::Role::Button);
3888 assert_eq!(save.origin, OriginId(1));
3889 let check = named("Remember").expect("checkbox named by its text");
3890 assert_eq!(check.role, kui_core::Role::Checkbox);
3891 assert_eq!(check.checked, Some(true));
3892 let vol = named("Volume").expect("slider");
3893 assert_eq!(
3894 (vol.number, vol.min, vol.max),
3895 (Some(3.0), Some(0.0), Some(10.0))
3896 );
3897 assert!(named("Art").is_none(), "role none hides a would-be button");
3898 let tip = named("Tip").expect("button");
3899 assert_eq!(tip.description.as_deref(), Some("more here"));
3900 let (t0, t1) = (named("General").unwrap(), named("Network").unwrap());
3903 assert_eq!((t0.selected, t1.selected), (Some(false), Some(true)));
3904 assert_eq!((t0.pos_in_set, t1.pos_in_set), (Some(0), Some(1)));
3905 let tabs = tree
3906 .nodes
3907 .iter()
3908 .find(|n| n.role == kui_core::Role::TabList)
3909 .expect("tab list");
3910 assert_eq!(tabs.set_size, Some(2));
3911 let adv = named("Advanced").expect("disclosure");
3913 assert_eq!(adv.expanded, Some(false));
3914
3915 let evs = core.handle_input(InputEvent::Access(kui_core::AccessRequest {
3916 key: save.key,
3917 action: kui_core::AccessAction::Click,
3918 value: None,
3919 anchor: None,
3920 focus: None,
3921 }));
3922 assert_eq!(evs.len(), 1);
3923 assert_eq!(evs[0].origin, OriginId(1));
3924 assert_eq!(evs[0].payload.as_str(), Some("save"));
3925 }
3926
3927 #[test]
3931 fn a_custom_editor_in_lua_reaches_the_access_tree() {
3932 let mut ext = LuaExtension::from_source(
3933 "ed",
3934 r#"
3935 function view(env)
3936 return column { key = "ed", role = "multilineTextInput", label = "Doc",
3937 on_key = "keys",
3938 row { role = "line", selection_anchor = 1, text("ab"), text("cd") },
3939 row { role = "line", caret = 2, text("ef") },
3940 }
3941 end
3942 "#,
3943 )
3944 .unwrap();
3945 let mut core = Core::new();
3946 frame(&mut core, &mut ext);
3947 let tree = core.access_tree().clone();
3948 let ed = tree
3949 .nodes
3950 .iter()
3951 .find(|n| n.name.as_deref() == Some("Doc"))
3952 .expect("the sink is the editor")
3953 .clone();
3954 assert_eq!(ed.role, kui_core::Role::MultilineTextInput);
3955 assert_eq!(ed.value.as_deref(), Some("abcd\nef"));
3956 assert_eq!(ed.runs.len(), 3);
3957 assert_eq!(ed.runs[1].text, "cd\n");
3958 assert_eq!(ed.caret, Some(7));
3959 assert_eq!(ed.selection, Some((1, 7)));
3960 let (a, f) = (ed.anchor.unwrap(), ed.focus.unwrap());
3961 assert_eq!((a.run, a.character), (ed.runs[0].key, 1));
3962 assert_eq!((f.run, f.character), (ed.runs[2].key, 2));
3963
3964 let evs = core.handle_input(InputEvent::Access(
3965 kui_core::AccessRequest::new(ed.key, kui_core::AccessAction::SetTextSelection)
3966 .with_selection(
3967 kui_core::TextPos {
3968 run: ed.runs[1].key,
3969 character: 1,
3970 },
3971 kui_core::TextPos {
3972 run: ed.runs[2].key,
3973 character: 0,
3974 },
3975 ),
3976 ));
3977 assert_eq!(evs.len(), 1);
3978 assert_eq!(evs[0].origin, OriginId(1));
3979 let p = &evs[0].payload;
3980 assert_eq!(p.get_str("action"), Some("setTextSelection"));
3981 let at = |k: &str, f: &str| p.get(k).and_then(|v| v.get(f)).and_then(Value::as_int);
3982 assert_eq!(
3983 (at("anchor", "line"), at("anchor", "offset")),
3984 (Some(0), Some(3))
3985 );
3986 assert_eq!(
3987 (at("focus", "line"), at("focus", "offset")),
3988 (Some(1), Some(0))
3989 );
3990 assert_eq!(p.get_str("tag"), Some("keys"));
3991 }
3992
3993 #[test]
3996 fn edit_text_round_trips_through_events() {
3997 let mut ext = LuaExtension::from_source(
3998 "edit",
3999 r#"
4000 pending = nil
4001 seen = nil
4002 by_label = nil
4003 window = nil
4004 function view(env)
4005 if pending then seen = env.edit_text(pending) end
4006 -- AR26: by the label its `key` declares, like every
4007 -- query beside it; a label nothing declared is nil.
4008 by_label = env.edit_text("note")
4009 nothing = env.edit_text("nope")
4010 return column {
4011 edit { key = "note", initial = "hi", autofocus = true, width = 200 },
4012 }
4013 end
4014 function on_event(ev)
4015 if ev.kind == "changed" then pending = ev.node_key; window = ev.window end
4016 end
4017 "#,
4018 )
4019 .unwrap();
4020 let mut core = Core::new();
4021 frame(&mut core, &mut ext);
4022 let events = core.handle_input(InputEvent::Text("!".into()));
4023 assert_eq!(
4024 events.len(),
4025 1,
4026 "typing into the autofocused editor emits one event"
4027 );
4028 assert_eq!(events[0].kind(), Some("changed"));
4029 for ev in &events {
4030 ext.on_event(ev);
4031 }
4032 frame(&mut core, &mut ext);
4033 let seen: Option<String> = ext.lua.globals().get("seen").unwrap();
4035 assert_eq!(seen.as_deref(), Some("hi!"));
4036 let by_label: Option<String> = ext.lua.globals().get("by_label").unwrap();
4037 assert_eq!(by_label.as_deref(), Some("hi!"), "the same text by label");
4038 let nothing: Option<String> = ext.lua.globals().get("nothing").unwrap();
4039 assert_eq!(nothing, None);
4040 let window: Option<i64> = ext.lua.globals().get("window").unwrap();
4043 assert_eq!(window, Some(0));
4044 }
4045
4046 #[test]
4051 fn a_dropdown_opens_the_cores_menu_and_hears_the_choice() {
4052 let mut ext = LuaExtension::from_source(
4053 "dd",
4054 r#"
4055 current = 1
4056 heard = {}
4057 function view(env)
4058 return column { pad = 10,
4059 dropdown { label = "language",
4060 options = { "English", "Deutsch", { label = "Latin", id = "la" } },
4061 current = current },
4062 }
4063 end
4064 function on_event(ev)
4065 heard[#heard + 1] = ev.kind
4066 if ev.kind == "menu" then
4067 if ev.item == "Deutsch" then current = 2 end
4068 if ev.item == "la" then current = 3 end
4069 end
4070 end
4071 "#,
4072 )
4073 .unwrap();
4074 let mut core = Core::new();
4075 frame(&mut core, &mut ext);
4076 let field = core
4077 .key_of("language")
4078 .expect("the field is keyed by its label");
4079 let node = |core: &mut Core| {
4080 core.access_tree()
4081 .nodes
4082 .iter()
4083 .find(|n| n.key == field)
4084 .cloned()
4085 .unwrap()
4086 };
4087 assert_eq!(node(&mut core).description.as_deref(), Some("English"));
4088 let events = core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4089 field,
4090 kui_core::AccessAction::Click,
4091 )));
4092 assert!(
4093 events.is_empty(),
4094 "the field's click is the core's: {events:?}"
4095 );
4096 let menu = core.menu().expect("the menu opened");
4097 assert_eq!(menu.target, field);
4098 assert_eq!(menu.items.len(), 3);
4099 assert!(menu.items[0].checked);
4100 frame(&mut core, &mut ext);
4101 let events = core.activate_menu_item(2).expect("the row is enabled");
4103 assert_eq!(events.len(), 1);
4104 for ev in &events {
4105 ext.on_event(ev);
4106 }
4107 frame(&mut core, &mut ext);
4108 assert_eq!(node(&mut core).description.as_deref(), Some("Latin"));
4109 let heard: Vec<String> = ext
4110 .lua
4111 .globals()
4112 .get::<Table>("heard")
4113 .unwrap()
4114 .sequence_values()
4115 .collect::<mlua::Result<_>>()
4116 .unwrap();
4117 assert_eq!(heard, vec!["menu".to_string()]);
4118 assert!(core.menu().is_none());
4119 let mut ext = LuaExtension::from_source(
4121 "bad",
4122 r#"function view(env) return dropdown { label = "x" } end"#,
4123 )
4124 .unwrap();
4125 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4126 ui.set_origin(OriginId(1));
4127 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4128 assert!(err.contains("needs options"), "{err}");
4129 ui.finish();
4130 let mut ext = LuaExtension::from_source(
4131 "bad",
4132 r#"function view(env) return dropdown { label = "x", options = { "a" }, current = 0 } end"#,
4133 )
4134 .unwrap();
4135 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4136 ui.set_origin(OriginId(1));
4137 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4138 assert!(err.contains("index from 1"), "{err}");
4139 ui.finish();
4140 let refused = |core: &mut Core, src: &str| {
4143 let mut ext = LuaExtension::from_source("bad", src).unwrap();
4144 let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
4145 ui.set_origin(OriginId(1));
4146 let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
4147 ui.finish();
4148 err
4149 };
4150 let err = refused(
4151 &mut core,
4152 r#"function view(env) return dropdown { options = { "a" } } end"#,
4153 );
4154 assert!(err.contains("dropdown needs a label"), "{err}");
4155 let err = refused(
4156 &mut core,
4157 r#"function view(env) return dropdown { label = "x", options = {} } end"#,
4158 );
4159 assert!(err.contains("at least one option"), "{err}");
4160 }
4161
4162 #[test]
4168 fn a_dropdowns_bad_rows_and_current_are_warned_and_a_disabled_option_is_refused() {
4169 let mut ext = LuaExtension::from_source(
4170 "dd",
4171 r#"
4172 current = 4
4173 function view(env)
4174 return column { pad = 10,
4175 dropdown { label = "language",
4176 options = { "English", { role = "separator" },
4177 { label = "Latin", id = "la", disabled = true } },
4178 current = current },
4179 }
4180 end
4181 "#,
4182 )
4183 .unwrap();
4184 let mut core = Core::new();
4185 frame(&mut core, &mut ext);
4186 let field = core.key_of("language").unwrap();
4187 let node = |core: &mut Core| {
4188 core.access_tree()
4189 .nodes
4190 .iter()
4191 .find(|n| n.key == field)
4192 .cloned()
4193 .unwrap()
4194 };
4195 assert_eq!(node(&mut core).description.as_deref(), Some(""));
4196 let warned = core.take_warnings();
4197 let codes: Vec<&str> = warned.iter().map(|w| w.code).collect();
4198 assert_eq!(
4199 codes,
4200 [
4201 kui_core::diag::UNKNOWN_PROP,
4202 kui_core::diag::SELECT_CURRENT_IGNORED
4203 ],
4204 "{warned:?}"
4205 );
4206 assert!(
4207 warned[0]
4208 .message
4209 .contains("`disabled` is not a key of a menu item")
4210 && warned[0].message.contains("`enabled: false`"),
4211 "{}",
4212 warned[0].message
4213 );
4214 assert_eq!(warned[1].key, field);
4215 assert!(
4216 warned[1].message.contains("names option 3 counted from 0")
4217 && warned[1].message.contains("the field has 3 options"),
4218 "{}",
4219 warned[1].message
4220 );
4221 ext.lua.globals().set("current", 2).unwrap();
4223 let mut core = Core::new();
4224 frame(&mut core, &mut ext);
4225 let warned = core.take_warnings();
4226 assert!(
4227 warned
4228 .iter()
4229 .any(|w| w.code == kui_core::diag::SELECT_CURRENT_IGNORED
4230 && w.message
4231 .contains("option 1 counted from 0, which is a separator")),
4232 "{warned:?}"
4233 );
4234 let mut ext = LuaExtension::from_source(
4237 "dd",
4238 r#"
4239 function view(env)
4240 return column { pad = 10,
4241 dropdown { label = "language",
4242 options = { "English", { label = "Latin", id = "la", enabled = false } },
4243 current = 1 },
4244 }
4245 end
4246 "#,
4247 )
4248 .unwrap();
4249 let mut core = Core::new();
4250 frame(&mut core, &mut ext);
4251 let field = core.key_of("language").unwrap();
4252 core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
4253 field,
4254 kui_core::AccessAction::Click,
4255 )));
4256 assert!(core.menu().is_some());
4257 assert_eq!(core.activate_menu_item(1), None, "refused");
4258 assert!(core.menu().is_some(), "the menu stays open");
4259 let events = core
4260 .activate_menu_item(0)
4261 .expect("the enabled row is taken");
4262 assert_eq!(events.len(), 1);
4263 assert!(core.menu().is_none());
4264 assert!(core.take_warnings().is_empty());
4265 }
4266
4267 #[test]
4270 fn a_grid_lines_its_rows_cells_up() {
4271 let mut ext = LuaExtension::from_source(
4272 "grid",
4273 r#"
4274 function view(env)
4275 return grid { key = "t", width = 300,
4276 row { key = "r1", width = "grow", gap = 8,
4277 text("ab", { size = 12 }),
4278 column { key = "b1", width = 10, height = 10, bg = 0xff0000ff },
4279 column { key = "c1", width = "grow", height = 10, bg = 0x00ff00ff },
4280 },
4281 row { key = "r2", width = "grow", gap = 8,
4282 text("abcdef", { size = 12 }),
4283 column { key = "b2", width = 50, height = 10, bg = 0xff0000ff },
4284 column { key = "c2", width = 20, height = 10, bg = 0x00ff00ff },
4285 },
4286 }
4287 end
4288 "#,
4289 )
4290 .unwrap();
4291 let mut core = Core::new();
4292 core.set_inspect(true);
4293 frame(&mut core, &mut ext);
4294 let rect = |core: &mut Core, label: &str| {
4295 let key = core.key_of(label).unwrap_or_else(|| panic!("{label}"));
4296 core.nodes()
4297 .iter()
4298 .find(|n| n.key == key)
4299 .map(|n| n.rect)
4300 .unwrap_or_else(|| panic!("{label}"))
4301 };
4302 let (b1, b2) = (rect(&mut core, "b1"), rect(&mut core, "b2"));
4303 let (c1, c2) = (rect(&mut core, "c1"), rect(&mut core, "c2"));
4304 assert_eq!(
4305 b1.x, b2.x,
4306 "the fixed column starts after the longest label"
4307 );
4308 assert_eq!(b1.w, 50.0, "the fixed column is its widest cell");
4309 assert_eq!(b2.w, 50.0);
4310 assert_eq!(c1.x, c2.x);
4311 assert_eq!(c1.w, c2.w, "the grow column is one width in both rows");
4312 assert_eq!(c1.x + c1.w, 300.0, "and it takes the rest");
4313 let t = core.key_of("t").unwrap();
4314 assert!(
4315 core.nodes().iter().any(|n| n.key == t && n.table),
4316 "the grid is a table"
4317 );
4318 assert!(core.take_warnings().is_empty());
4319 }
4320
4321 #[test]
4326 fn set_edit_text_by_label_seeds_the_editor_the_next_frame_declares() {
4327 let mut ext = LuaExtension::from_source(
4328 "edit",
4329 r#"
4330 frames = 0
4331 function view(env)
4332 frames = frames + 1
4333 if frames == 1 then return column {} end
4334 if frames == 2 then
4335 env.set_edit_text("note", "from the model")
4336 end
4337 return column {
4338 edit { key = "note", initial = "ignored", width = 200 },
4339 }
4340 end
4341 "#,
4342 )
4343 .unwrap();
4344 let mut core = Core::new();
4345 frame(&mut core, &mut ext);
4349 frame(&mut core, &mut ext);
4350 let key = core.key_of("note").expect("the view declared the editor");
4351 assert_eq!(core.edit_text(key).as_deref(), Some("from the model"));
4352 let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
4353 assert!(!codes.contains(&"edit-text-without-editor"), "{codes:?}");
4354 core.set_focus(Some(key));
4357 core.handle_input(InputEvent::Text("!".into()));
4358 frame(&mut core, &mut ext);
4359 assert_eq!(core.edit_text(key).as_deref(), Some("from the model!"));
4360 }
4361
4362 #[test]
4367 fn a_lua_key_sink_hears_press_and_release() {
4368 use kui_core::{KeyCode, KeyMods, KeyPress};
4369 let mut ext = LuaExtension::from_source(
4370 "game",
4371 r#"
4372 log = {}
4373 function view(env)
4374 return column { key = "world", on_key = "keys", key_up = true,
4375 key_focus = true, width = 400, height = 300 }
4376 end
4377 function on_event(ev)
4378 if ev.kind == "key" then
4379 log[#log + 1] = ev.phase .. ":" .. ev.code ..
4380 "@" .. ev.physical ..
4381 ":" .. tostring(ev.text) .. ":" .. tostring(ev.tag)
4382 end
4383 end
4384 "#,
4385 )
4386 .unwrap();
4387 let mut core = Core::new();
4388 frame(&mut core, &mut ext);
4389 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
4390 for e in core.handle_input(ev) {
4391 ext.on_event(&e);
4392 }
4393 };
4394 let w = || KeyPress::new(KeyCode::Char('w'), KeyMods::default()).with_text("w");
4395 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
4396 feed(&mut core, &mut ext, InputEvent::KeyUp(w()));
4397 let ru = || {
4402 KeyPress::from_layout(KeyCode::Char('ц'), KeyCode::Char('w'), KeyMods::default())
4403 .with_text("ц")
4404 };
4405 feed(&mut core, &mut ext, InputEvent::KeyDown(ru()));
4406 feed(&mut core, &mut ext, InputEvent::KeyUp(ru()));
4407 feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
4409 core.set_focus(None);
4410 for e in core.take_pending_events() {
4411 ext.on_event(&e);
4412 }
4413 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
4414 assert_eq!(
4415 log,
4416 [
4417 "down:w@w:w:keys",
4418 "up:w@w:nil:keys",
4420 "down:w@w:ц:keys",
4423 "up:w@w:nil:keys",
4424 "down:w@w:w:keys",
4425 "up:w@w:nil:keys",
4426 ]
4427 );
4428 }
4429
4430 #[test]
4434 fn a_lua_editor_draws_its_caret_on_the_phase_the_host_sets() {
4435 let mut ext = LuaExtension::from_source(
4436 "ed",
4437 r#"
4438 function view(env)
4439 local caret = env.caret_visible and row { key = "caret", width = 2, height = 16 } or nil
4440 return column { key = "editor", on_key = "ed", key_focus = true,
4441 role = "multilineTextInput", label = "buf",
4442 row { role = "line", caret = 3, text("let value", { family = "mono", size = 14 }), caret },
4443 }
4444 end
4445 "#,
4446 )
4447 .unwrap();
4448 let mut core = Core::new();
4449 frame(&mut core, &mut ext);
4450 assert!(
4451 core.key_of("caret").is_some(),
4452 "the on phase draws the caret"
4453 );
4454 assert!(core.has_caret(), "the caret row is a caret to blink");
4455 core.set_caret_visible(false);
4456 frame(&mut core, &mut ext);
4457 assert!(core.key_of("caret").is_none(), "the off phase draws none");
4458 assert!(
4459 core.has_caret(),
4460 "and the row stays, so the clock stays armed"
4461 );
4462 core.set_caret_visible(true);
4463 frame(&mut core, &mut ext);
4464 assert!(core.key_of("caret").is_some());
4465 }
4466
4467 #[test]
4472 fn a_lua_editors_solid_caret_arms_no_clock() {
4473 let mut ext = LuaExtension::from_source(
4474 "ed",
4475 r#"
4476 function view(env)
4477 return column { key = "editor", on_key = "ed", key_focus = true,
4478 role = "multilineTextInput", label = "buf",
4479 row { role = "line", caret = 3, caret_solid = true,
4480 text("let value", { family = "mono", size = 14 }) },
4481 }
4482 end
4483 "#,
4484 )
4485 .unwrap();
4486 let mut core = Core::new();
4487 frame(&mut core, &mut ext);
4488 assert!(!core.has_caret(), "a solid caret is not a caret to blink");
4489 assert!(core.ime_rect().is_some(), "and still the IME's anchor");
4490 let editor = core.key_of("editor").unwrap();
4491 assert_eq!(core.access_tree().get(editor).unwrap().caret, Some(3));
4492 assert!(
4493 core.warnings_raised().is_empty(),
4494 "{:?}",
4495 core.warnings_raised()
4496 );
4497 }
4498
4499 #[test]
4505 fn a_lua_editor_yanks_pastes_and_hears_where_a_press_landed() {
4506 use kui_core::testing::{press, release};
4507 use kui_core::{KeyCode, KeyMods, KeyPress, MenuAction, Vec2};
4508 let mut ext = LuaExtension::from_source(
4509 "ed",
4510 r#"
4511 yank = nil
4512 paste = false
4513 log = {}
4514 function view(env)
4515 if yank then env.set_clipboard(yank, nil); yank = nil end
4516 if secret then env.set_clipboard_secret(secret); secret = nil end
4517 -- Asked on every view until the answer lands: the core
4518 -- takes one ask at a time (AR34), so this is one paste.
4519 if paste then env.request_paste() end
4520 return column { key = "editor", on_key = "ed", on_drag = "sel",
4521 key_focus = true, role = "multilineTextInput", label = "buf",
4522 width = 400, height = 300,
4523 row { role = "line", height = 20, text("hello world", { family = "mono", size = 14 }) },
4524 row { role = "line", height = 20, text("second", { family = "mono", size = 14 }) },
4525 }
4526 end
4527 function on_event(ev)
4528 if ev.kind == "key" and ev.code == "y" then yank = "hello world" end
4529 if ev.kind == "key" and ev.code == "s" then secret = "hunter2" end
4530 if ev.kind == "key" and ev.code == "p" then paste = true end
4531 if ev.kind == "text" then
4532 paste = false
4533 local marks = (ev.concealed and ":concealed" or "") .. (ev.transient and ":transient" or "")
4534 log[#log + 1] = "text:" .. ev.text .. marks
4535 end
4536 if ev.kind == "drag" then
4537 log[#log + 1] = ev.phase .. ":" .. ev.line .. ":" .. ev.byte .. ":" .. ev.clicks
4538 end
4539 end
4540 "#,
4541 )
4542 .unwrap();
4543 let mut core = Core::new();
4544 frame(&mut core, &mut ext);
4545 let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
4546 for e in core.handle_input(ev) {
4547 ext.on_event(&e);
4548 }
4549 };
4550 let key = |c| KeyPress::new(KeyCode::Char(c), KeyMods::default());
4551 feed(&mut core, &mut ext, InputEvent::KeyDown(key('y')));
4552 frame(&mut core, &mut ext);
4553 assert_eq!(
4554 core.take_menu_actions(),
4555 vec![MenuAction::SetClipboard {
4556 text: "hello world".into(),
4557 html: None
4558 }]
4559 );
4560 feed(&mut core, &mut ext, InputEvent::KeyDown(key('s')));
4562 frame(&mut core, &mut ext);
4563 assert_eq!(
4564 core.take_menu_actions(),
4565 vec![MenuAction::SetClipboardSecret {
4566 text: "hunter2".into()
4567 }]
4568 );
4569 feed(&mut core, &mut ext, InputEvent::KeyDown(key('p')));
4570 frame(&mut core, &mut ext);
4571 frame(&mut core, &mut ext);
4572 assert_eq!(
4573 core.take_menu_actions(),
4574 vec![MenuAction::Paste],
4575 "two views asked, one paste queued"
4576 );
4577 assert!(core.awaiting_paste());
4578 feed(
4580 &mut core,
4581 &mut ext,
4582 InputEvent::Commit("from the clipboard".into()),
4583 );
4584 assert!(!core.awaiting_paste());
4585 frame(&mut core, &mut ext);
4586 assert!(
4587 core.take_menu_actions().is_empty(),
4588 "answered: the script stopped asking"
4589 );
4590 feed(
4592 &mut core,
4593 &mut ext,
4594 InputEvent::Paste {
4595 text: "s3cret".into(),
4596 marks: kui_core::ClipboardMarks::SECRET,
4597 },
4598 );
4599 for e in press(&mut core, Vec2::new(390.0, 30.0)) {
4601 ext.on_event(&e);
4602 }
4603 for e in release(&mut core) {
4604 ext.on_event(&e);
4605 }
4606 for e in core.handle_input(InputEvent::mouse_down(2)) {
4607 ext.on_event(&e);
4608 }
4609 for e in release(&mut core) {
4610 ext.on_event(&e);
4611 }
4612 let log: Vec<String> = ext.lua.globals().get("log").unwrap();
4613 assert_eq!(
4614 log,
4615 [
4616 "text:from the clipboard",
4617 "text:s3cret:concealed:transient",
4618 "start:1:6:1",
4619 "end:1:6:1",
4620 "start:1:6:2",
4621 "end:1:6:2",
4622 ]
4623 );
4624 }
4625
4626 #[test]
4630 fn audio_nodes_drive_playback_commands() {
4631 use kui_core::AudioCommand;
4632 let mut ext = LuaExtension::from_source(
4633 "audio",
4634 r#"
4635 playing = true
4636 function view(env)
4637 local items = {}
4638 if playing then
4639 items[1] = audio { src = SOUND, loop = true, volume = 0.5,
4640 key = "music", tag = { kind = "music" } }
4641 end
4642 return column { table.unpack(items) }
4643 end
4644 "#,
4645 )
4646 .unwrap();
4647 let mut core = Core::new();
4648 let sound = core.add_sound(vec![0; 8]);
4649 ext.lua
4650 .globals()
4651 .set("SOUND", sound.to_ffi() as i64)
4652 .unwrap();
4653 frame(&mut core, &mut ext);
4654 let cmds = core.take_audio_commands();
4655 assert!(
4656 matches!(
4657 cmds.as_slice(),
4658 [AudioCommand::Play { sound: s, looped: true, volume, .. }]
4659 if *s == sound && *volume == 0.5
4660 ),
4661 "{cmds:?}"
4662 );
4663 frame(&mut core, &mut ext);
4664 assert!(
4665 core.take_audio_commands().is_empty(),
4666 "re-declaring is silent"
4667 );
4668 ext.lua.globals().set("playing", false).unwrap();
4669 frame(&mut core, &mut ext);
4670 assert!(matches!(
4671 core.take_audio_commands().as_slice(),
4672 [AudioCommand::Stop { .. }]
4673 ));
4674 }
4675
4676 #[test]
4680 fn measure_and_layout_events_reach_scripts() {
4681 let mut ext = LuaExtension::from_source(
4682 "measure",
4683 r#"
4684 seen = nil
4685 function view(env)
4686 local plain = env.measure_text("hello world", { size = 14 })
4687 local node = env.measure_text(text("hello world", { size = 14 }))
4688 local rich = env.measure_text({ "hello ", { "world", bold = true } }, { size = 14 })
4689 local narrow = env.measure_text("hello world", { size = 14 }, plain.width / 2)
4690 assert(plain.width > 0 and plain.lines == 1)
4691 assert(node.width == plain.width and node.height == plain.height)
4692 assert(rich.width > 0)
4693 assert(narrow.lines > 1 and narrow.width <= plain.width / 2 + 0.5)
4694 return column {
4695 row { key = "panel", width = plain.width, height = 20, on_layout = "panel" },
4696 }
4697 end
4698 function on_event(ev)
4699 if ev.kind == "layout" then seen = ev end
4700 end
4701 "#,
4702 )
4703 .unwrap();
4704 let mut core = Core::new();
4705 frame(&mut core, &mut ext);
4706 let evs = core.take_pending_events();
4707 assert_eq!(evs.len(), 1, "one layout event on first sight");
4708 for ev in &evs {
4709 ext.on_event(ev);
4710 }
4711 let seen: Table = ext.lua.globals().get("seen").unwrap();
4712 assert_eq!(seen.get::<String>("tag").unwrap(), "panel");
4713 assert_eq!(seen.get::<f64>("h").unwrap(), 20.0);
4714 assert!(seen.get::<f64>("w").unwrap() > 0.0);
4715 assert_eq!(seen.get::<i64>("node_key").unwrap(), evs[0].key.0 as i64);
4716 frame(&mut core, &mut ext);
4718 assert!(core.take_pending_events().is_empty());
4719 }
4720
4721 #[test]
4725 fn scripts_queue_window_size_and_focus_requests() {
4726 let mut ext = LuaExtension::from_source(
4727 "win",
4728 r#"
4729 function view(env)
4730 env.set_window_size(env.window.id, 640, 480)
4731 env.focus_window(env.window.id)
4732 return column { width = "grow", height = "grow" }
4733 end
4734 "#,
4735 )
4736 .unwrap();
4737 let mut core = Core::new();
4738 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
4739 ui.set_origin(OriginId(1));
4740 ext.view(&Slot::root(), &mut ui).unwrap();
4741 ui.finish();
4742 assert_eq!(
4743 core.take_window_commands(),
4744 vec![
4745 kui_core::WindowCommand::SetSize {
4746 window: WindowId::MAIN,
4747 size: Size::new(640.0, 480.0),
4748 },
4749 kui_core::WindowCommand::Focus(WindowId::MAIN),
4750 ]
4751 );
4752 assert!(core.take_window_commands().is_empty());
4753 assert_eq!(core.viewport(), Size::new(400.0, 200.0));
4754 }
4755
4756 #[test]
4762 fn scripts_reveal_and_move_scroll_offsets() {
4763 let mut ext = LuaExtension::from_source(
4764 "scroll",
4765 r#"
4766 rows, want, jump, seen = 20, nil, nil, nil
4767 function view(env)
4768 if want then env.reveal(want) end
4769 if jump then env.set_scroll(jump[1], jump[2], jump[3]) end
4770 want, jump = nil, nil
4771 local list = { key = "list", width = "grow", height = "grow",
4772 scroll_y = true }
4773 for i = 0, rows - 1 do
4774 list[#list + 1] = row { key = "row" .. i, width = "grow",
4775 height = 30, bg = 0x282840ff }
4776 end
4777 seen = env.scroll_offset(list_key)
4778 return column(list)
4779 end
4780 "#,
4781 )
4782 .unwrap();
4783 let list = Key::ROOT.str("list");
4785 let row = |i: usize| list.str(&format!("row{i}"));
4786 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
4787 let mut core = Core::new();
4788 let frame = |core: &mut Core, ext: &mut LuaExtension| {
4789 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
4790 ui.set_origin(OriginId(1));
4791 ext.view(&Slot::root(), &mut ui).unwrap();
4792 ui.finish();
4793 };
4794
4795 frame(&mut core, &mut ext);
4796 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
4797
4798 ext.lua.globals().set("want", row(15).0 as i64).unwrap();
4801 frame(&mut core, &mut ext);
4802 let after = core.scroll_offset(list);
4803 assert!(after.y > 0.0, "reveal moved nothing: {after:?}");
4804 assert!(after.y <= 15.0 * 30.0, "scrolled past row 15: {after:?}");
4805 frame(&mut core, &mut ext);
4807 assert_eq!(core.scroll_offset(list), after);
4808
4809 let seen: Table = ext.lua.globals().get("seen").unwrap();
4811 assert_eq!(seen.get::<f32>("y").unwrap(), after.y);
4812 assert_eq!(seen.get::<f32>("x").unwrap(), 0.0);
4813
4814 let jump = |ext: &LuaExtension, y: f64| {
4816 let t = ext.lua.create_table().unwrap();
4817 t.set(1, list.0 as i64).unwrap();
4818 t.set(2, 0.0).unwrap();
4819 t.set(3, y).unwrap();
4820 ext.lua.globals().set("jump", t).unwrap();
4821 };
4822 jump(&ext, 1e9);
4823 frame(&mut core, &mut ext);
4824 assert_eq!(core.scroll_offset(list).y, 400.0);
4825 jump(&ext, -1e9);
4826 frame(&mut core, &mut ext);
4827 assert_eq!(core.scroll_offset(list), Vec2::ZERO);
4828
4829 jump(&ext, 120.0);
4831 frame(&mut core, &mut ext);
4832 ext.lua
4833 .globals()
4834 .set("want", Key::ROOT.str("ghost").0 as i64)
4835 .unwrap();
4836 frame(&mut core, &mut ext);
4837 assert_eq!(core.scroll_offset(list).y, 120.0);
4838 frame(&mut core, &mut ext);
4839 assert_eq!(core.scroll_offset(list).y, 120.0);
4840 }
4841
4842 #[test]
4846 fn scripts_read_the_pressed_state() {
4847 let mut ext = LuaExtension::from_source(
4848 "press",
4849 r#"
4850 function view(env)
4851 pressed = env.is_pressed(btn_key)
4852 hovered = env.is_hovered(btn_key)
4853 return column { key = "root", pad = 10,
4854 row { key = "btn", width = 100, height = 40,
4855 bg = 0x333333ff, on_click = "hit" },
4856 }
4857 end
4858 "#,
4859 )
4860 .unwrap();
4861 let btn = Key::ROOT.str("root").str("btn");
4862 ext.lua.globals().set("btn_key", btn.0 as i64).unwrap();
4863 let mut core = Core::new();
4864 let pressed = |ext: &LuaExtension| ext.lua.globals().get::<bool>("pressed").unwrap();
4865 let hovered = |ext: &LuaExtension| ext.lua.globals().get::<bool>("hovered").unwrap();
4866
4867 frame(&mut core, &mut ext);
4868 assert!(!pressed(&ext), "idle");
4869
4870 core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
4872 frame(&mut core, &mut ext);
4873 assert!(hovered(&ext) && !pressed(&ext), "hover is not press");
4874
4875 core.handle_input(InputEvent::MouseDown {
4876 button: kui_core::MouseButton::Primary,
4877 clicks: 1,
4878 });
4879 frame(&mut core, &mut ext);
4880 assert!(pressed(&ext), "held down");
4881
4882 core.handle_input(InputEvent::MouseUp {
4886 button: kui_core::MouseButton::Primary,
4887 });
4888 frame(&mut core, &mut ext);
4889 assert!(!pressed(&ext), "released");
4890 }
4891
4892 #[test]
4897 fn scripts_read_the_drop_target() {
4898 let mut ext = LuaExtension::from_source(
4899 "drop",
4900 r#"
4901 function view(env)
4902 over = env.is_drop_target(zone_key)
4903 target = env.drop_target()
4904 return column { key = "root", pad = 10,
4905 row { key = "zone", width = 100, height = 40,
4906 bg = 0x333333ff, drop_bg = 0x335533ff, on_drop = "files" },
4907 }
4908 end
4909 "#,
4910 )
4911 .unwrap();
4912 let zone = Key::ROOT.str("root").str("zone");
4913 ext.lua.globals().set("zone_key", zone.0 as i64).unwrap();
4914 let mut core = Core::new();
4915 let over = |ext: &LuaExtension| ext.lua.globals().get::<bool>("over").unwrap();
4916 let target = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("target").unwrap();
4917
4918 frame(&mut core, &mut ext);
4919 assert!(!over(&ext) && target(&ext).is_none(), "idle");
4920
4921 let paths = vec!["/drop/1.txt".to_string()];
4922 let evs = core.handle_input(InputEvent::DragFiles {
4923 paths: paths.clone(),
4924 at: Vec2::new(50.0, 30.0),
4925 });
4926 assert_eq!(evs[0].payload.get_str("tag"), Some("files"));
4927 frame(&mut core, &mut ext);
4928 assert!(over(&ext), "the files are over the zone");
4929 assert_eq!(target(&ext), Some(zone.0 as i64));
4930 let (dl, _) = core.output();
4931 let lit = dl
4932 .quads
4933 .iter()
4934 .find(|q| q.rect.w == 100.0)
4935 .map(|q| q.color)
4936 .expect("zone quad");
4937 assert!(
4938 (lit.g - 0x55 as f32 / 255.0).abs() < 0.01,
4939 "drop_bg painted"
4940 );
4941
4942 core.handle_input(InputEvent::DropFiles {
4943 paths,
4944 at: Vec2::new(50.0, 30.0),
4945 });
4946 frame(&mut core, &mut ext);
4947 assert!(
4948 !over(&ext) && target(&ext).is_none(),
4949 "a drop ends the hover"
4950 );
4951 }
4952
4953 #[test]
4964 fn the_env_table_is_the_documented_env_shape() {
4965 let mut ext = LuaExtension::from_source(
4966 "env",
4967 r#"
4968 function view(env)
4969 values, calls = {}, {}
4970 for k, v in pairs(env) do
4971 if type(v) == "function" then calls[#calls + 1] = k
4972 elseif type(v) == "table" then
4973 for wk in pairs(v) do values[#values + 1] = k .. "." .. wk end
4974 else values[#values + 1] = k end
4975 end
4976 return column { key = "root",
4977 row { key = "a", focusable = true, width = 50, height = 20 },
4978 column { key = "dock", focus_region = true,
4979 row { key = "d", focusable = true, width = 50, height = 20 },
4980 },
4981 }
4982 end
4983 "#,
4984 )
4985 .unwrap();
4986 let mut core = Core::new();
4987 core.env.refresh_hz = Some(60.0);
4992 core.env.system.accent = Some(kui_core::Color::hex(0x3b82f6ff));
4993 core.env.system.locale = kui_core::Locale::new("en-US");
4994 core.env.window.native_controls = Some(Rect::new(0.0, 0.0, 78.0, 28.0));
4995 frame(&mut core, &mut ext);
4996 core.set_focus(Some(Key::ROOT.str("root").str("dock").str("d")));
4997 frame(&mut core, &mut ext);
4998
4999 let sorted = |name: &str| -> Vec<String> {
5000 let mut v: Vec<String> = ext.lua.globals().get(name).unwrap();
5001 v.sort();
5002 v
5003 };
5004 let mut documented: Vec<String> = kui_core::schema::ENV_FIELDS
5005 .iter()
5006 .flat_map(|f| f.lua.iter().map(|k| (*k).to_string()))
5007 .collect();
5008 documented.extend(
5014 kui_core::schema::THEME_ROLES
5015 .iter()
5016 .map(|r| format!("theme.{}", r.name)),
5017 );
5018 documented.push("theme.appearance".into());
5019 documented.push("theme.disabled_opacity".into());
5020 documented.extend(
5023 kui_core::schema::METRIC_ROLES
5024 .iter()
5025 .map(|r| format!("metrics.{}", r.name)),
5026 );
5027 documented.push("tokens.colors".into());
5030 documented.push("tokens.lengths".into());
5031 documented.sort();
5032 assert_eq!(
5033 sorted("values"),
5034 documented,
5035 "env's value keys and schema::ENV_FIELDS + THEME_ROLES + METRIC_ROLES + tokens disagree"
5036 );
5037 let mut doors: Vec<String> = kui_core::schema::DOORS
5044 .iter()
5045 .filter_map(|d| match d.lua {
5046 kui_core::schema::Cell::Is(name) => Some(name.to_string()),
5047 _ => None,
5048 })
5049 .collect();
5050 doors.sort();
5051 assert_eq!(
5052 sorted("calls"),
5053 doors,
5054 "env's queries and verbs and schema::DOORS's Lua column disagree; update env_table's doc too"
5055 );
5056 }
5057
5058 #[test]
5063 fn a_script_declares_tokens_and_references_them_by_name() {
5064 let mut ext = LuaExtension::from_source(
5065 "tokens",
5066 r##"
5067 tokens = {
5068 colors = { peach = "#ffcc99", ink = { light = "#111111", dark = "#eeeeee" } },
5069 lengths = { side_w = 132, gap = 6 },
5070 }
5071 function view(env)
5072 seen = env.tokens
5073 return column { pad = "$gap", gap = "$gap",
5074 row { key = "a", width = "$side_w", height = 20, bg = "$peach",
5075 border = { w = "$gap", color = "$ink" }, radius = "$radius" },
5076 row { key = "b", width = 20, height = "$side_w", bg = "$surface",
5077 pad = { l = "$gap", r = 2 } },
5078 text({ "x", { "y", color = "$peach", bg = "$ink" } }, { size = "$gap", color = "$peach" }),
5079 }
5080 end
5081 "##,
5082 )
5083 .unwrap();
5084 let mut core = Core::new();
5085 frame(&mut core, &mut ext);
5086 assert!(core.take_warnings().is_empty());
5087 let peach = Color::hex(0xffcc99ff);
5088 let paper = Color::hex(0xeeeeeeff);
5089 let radius = core.metrics().radius;
5090 let surface = core.theme().surface;
5091 let dl = core.output().0;
5092 let a = dl
5093 .quads
5094 .iter()
5095 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
5096 .expect("the peach box");
5097 assert_eq!(a.rect.w, 132.0, "width from a length token");
5098 assert_eq!(a.border_w, 6.0);
5099 assert_eq!(
5100 a.border_color, paper,
5101 "the dark half on an unknown appearance"
5102 );
5103 assert_eq!(a.radius[0], radius, "$radius is the metric");
5104 let b = dl
5105 .quads
5106 .iter()
5107 .find(|q| q.color == surface && q.rect.h == 132.0)
5108 .expect("the $surface box, 132 tall");
5109 assert_eq!(b.rect.w, 20.0);
5110 let seen: Table = ext.lua.globals().get("seen").unwrap();
5111 let colors: Table = seen.get("colors").unwrap();
5112 let lengths: Table = seen.get("lengths").unwrap();
5113 assert_eq!(colors.get::<u32>("peach").unwrap(), 0xffcc99ff);
5114 assert_eq!(colors.get::<u32>("ink").unwrap(), 0xeeeeeeff);
5115 assert_eq!(lengths.get::<f32>("side_w").unwrap(), 132.0);
5116 core.set_system(kui_core::SystemEnv {
5118 appearance: kui_core::Appearance::Light,
5119 ..Default::default()
5120 });
5121 frame(&mut core, &mut ext);
5122 let a = core
5123 .output()
5124 .0
5125 .quads
5126 .iter()
5127 .find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
5128 .unwrap();
5129 assert_eq!(a.border_color, Color::hex(0x111111ff));
5130 }
5131
5132 #[test]
5138 fn a_script_derives_a_token_from_another() {
5139 let mut ext = LuaExtension::from_source(
5140 "derived",
5141 r##"
5142 tokens = {
5143 colors = {
5144 peach = "#ffcc99",
5145 z_lit = { from = "peach", ops = { "lift", 0.5 } },
5146 a_deep = { from = "z_lit", ops = { { "alpha", 0.5 }, { "mix", "peach", 0.0 } } },
5147 up = { from = "surface", ops = { { "raise", 0.25 } } },
5148 bad = { from = "nothing" },
5149 },
5150 }
5151 function view(env)
5152 seen = env.tokens.colors
5153 return column {
5154 row { key = "a", width = 20, height = 20, bg = "$a_deep" },
5155 row { key = "b", width = 20, height = 20, bg = "$bad" },
5156 }
5157 end
5158 "##,
5159 )
5160 .unwrap();
5161 let mut core = Core::new();
5162 frame(&mut core, &mut ext);
5163 let warnings = core.take_warnings();
5164 let codes: Vec<&str> = warnings.iter().map(|w| w.code).collect();
5165 assert_eq!(codes, ["unknown-token"], "{warnings:?}");
5166 assert!(
5167 warnings[0].message.contains("`$bad`") && warnings[0].message.contains("`$nothing`")
5168 );
5169 let seen: Table = ext.lua.globals().get("seen").unwrap();
5170 assert_eq!(seen.get::<u32>("z_lit").unwrap(), 0xffe6ccff);
5171 assert_eq!(seen.get::<u32>("a_deep").unwrap(), 0xffe6cc80);
5172 let surface = core.theme().surface;
5173 assert_eq!(
5174 seen.get::<u32>("up").unwrap(),
5175 surface.mix(Color::WHITE, 0.25).to_hex(),
5176 "a role source, raised toward the dark base's front"
5177 );
5178 assert!(seen.get::<mlua::Value>("bad").unwrap().is_nil());
5179 let dl = core.output().0;
5180 assert!(
5181 dl.quads.iter().any(|q| q.color.to_hex() == 0xffe6cc80),
5182 "$a_deep painted the derived colour"
5183 );
5184
5185 let refused = |colors: &str| {
5187 LuaExtension::from_source(
5188 "bad",
5189 &format!(
5190 "tokens = {{ colors = {{ {colors} }} }}\nfunction view() return column {{}} end"
5191 ),
5192 )
5193 .err()
5194 .map(|e| e.to_string())
5195 .unwrap_or_default()
5196 };
5197 assert!(
5198 refused(r#"x = { from = "peach", ops = { { "glow", 1 } } }"#)
5199 .contains("unknown verb \"glow\"")
5200 );
5201 assert!(
5202 refused(r#"x = { from = "peach", ops = { { "mix", 0.5 } } }"#)
5203 .contains("mix takes a colour and a number")
5204 );
5205 assert!(
5206 refused(r#"x = { from = "peach", ops = { { "lift", "lots" } } }"#)
5207 .contains("number is a number")
5208 );
5209 assert!(refused(r#"x = { from = 3 }"#).contains("from names a colour token or role"));
5210 assert!(refused(r#"x = { from = "peach", glow = 1 }"#).contains("unknown key \"glow\""));
5211 }
5212
5213 #[test]
5218 fn a_scripts_tokens_sit_over_the_hosts() {
5219 let mut ext = LuaExtension::from_source(
5220 "guest",
5221 r##"
5222 tokens = { colors = { grey = "#808080" } }
5223 function view(env)
5224 first = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
5225 if redeclare then
5226 env.set_tokens { colors = { peach = "#ffaa77" }, lengths = { w = 40 } }
5227 end
5228 second = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
5229 return column { row { key = "a", width = redeclare and "$w" or 10, height = 10, bg = "$peach" } }
5230 end
5231 "##,
5232 )
5233 .unwrap();
5234 let mut core = Core::new();
5235 core.set_tokens(kui_core::Tokens::new().color("peach", Color::hex(0xffcc99ff)));
5236 frame(&mut core, &mut ext);
5237 let first: Table = ext.lua.globals().get("first").unwrap();
5238 assert_eq!(
5239 first.get::<u32>("peach").unwrap(),
5240 0xffcc99ff,
5241 "the host's peach"
5242 );
5243 assert_eq!(
5244 first.get::<u32>("grey").unwrap(),
5245 0x808080ff,
5246 "its own grey"
5247 );
5248 assert!(
5249 core.token_lookup().color("grey").is_err(),
5250 "the guest's grey is not the host's"
5251 );
5252 assert!(core.take_warnings().is_empty());
5253
5254 ext.lua.globals().set("redeclare", true).unwrap();
5255 frame(&mut core, &mut ext);
5256 let second: Table = ext.lua.globals().get("second").unwrap();
5257 assert_eq!(
5258 second.get::<u32>("peach").unwrap(),
5259 0xffaa77ff,
5260 "its own peach now"
5261 );
5262 assert!(
5263 second.get::<Option<u32>>("grey").unwrap().is_none(),
5264 "replaced whole"
5265 );
5266 let a = core
5267 .output()
5268 .0
5269 .quads
5270 .iter()
5271 .find(|q| q.color == Color::hex(0xffaa77ff))
5272 .expect("painted the script's peach");
5273 assert_eq!(a.rect.w, 40.0);
5274 assert_eq!(
5275 core.token_lookup().color("peach"),
5276 Ok(Color::hex(0xffcc99ff)),
5277 "the host's table did not move"
5278 );
5279 }
5280
5281 #[test]
5284 fn a_missing_token_warns_and_paints_nothing() {
5285 let mut ext = LuaExtension::from_source(
5286 "missing",
5287 r##"
5288 tokens = { colors = { surface = "#ff0000", peach = "#ffcc99" }, lengths = { gap = 6 } }
5289 function view(env)
5290 return column {
5291 row { key = "a", width = "$gap", height = 10, bg = "$peech", pad = "$peach" },
5292 row { key = "b", width = "$peach", height = 10, bg = "$gap" },
5293 text("still here", { color = "$peech", size = "$gap" }),
5294 text("still here", { size = 6 }),
5295 text("still here"),
5296 }
5297 end
5298 "##,
5299 )
5300 .unwrap();
5301 let mut core = Core::new();
5302 core.set_inspect(true);
5303 frame(&mut core, &mut ext);
5304 frame(&mut core, &mut ext);
5305 let ws = core.take_warnings();
5306 let mut codes: Vec<&str> = ws.iter().map(|w| w.code).collect();
5307 codes.sort();
5308 assert_eq!(
5309 codes,
5310 [
5311 kui_core::diag::RESERVED_TOKEN,
5312 kui_core::diag::UNKNOWN_TOKEN,
5313 kui_core::diag::UNKNOWN_TOKEN,
5314 kui_core::diag::UNKNOWN_TOKEN,
5315 ],
5316 "surface refused once; peech, peach-as-length and gap-as-colour once each: {ws:?}"
5317 );
5318 assert!(
5319 ws.iter().any(|w| w
5320 .message
5321 .contains("`$gap` is a length token, and this slot takes a color")),
5322 "{ws:?}"
5323 );
5324 let fg = core.theme().fg;
5328 let dl = core.output().0;
5329 assert!(
5330 dl.quads
5331 .iter()
5332 .all(|q| q.kind != kui_core::QuadKind::Solid || q.color.a == 0.0)
5333 );
5334 let glyphs: Vec<_> = dl
5335 .quads
5336 .iter()
5337 .filter(|q| {
5338 matches!(
5339 q.kind,
5340 kui_core::QuadKind::GlyphMask | kui_core::QuadKind::GlyphSubpixel
5341 )
5342 })
5343 .collect();
5344 assert!(!glyphs.is_empty(), "the text painted");
5345 assert!(glyphs.iter().all(|q| q.color == fg), "in the foreground");
5346 let heights: Vec<f32> = core
5351 .nodes()
5352 .iter()
5353 .filter(|n| n.text.as_deref() == Some("still here"))
5354 .map(|n| n.rect.h)
5355 .collect();
5356 assert_eq!(heights.len(), 3, "{heights:?}");
5357 assert!(heights[0] > 0.0, "{heights:?}");
5358 assert_eq!(heights[0], heights[1], "$gap is the literal 6: {heights:?}");
5359 assert!(heights[2] > heights[0], "and not the default: {heights:?}");
5360 }
5361
5362 #[test]
5367 fn a_token_reaches_a_min_a_stroke_and_a_stop_and_misses_by_leaving_the_slot() {
5368 let mut ext = LuaExtension::from_source(
5369 "everywhere",
5370 r##"
5371 tokens = { colors = { peach = "#ffcc99" }, lengths = { gap = 6, wide = 40 } }
5372 function view(env)
5373 return column { pad = 4,
5374 row { key = "clamp", width = 10, height = 10, min_width = "$wide" },
5375 row { key = "typo", width = 10, height = 10, min_width = "$nope" },
5376 line { key = "stroke", from = { 0, 0 }, to = { 30, 0 }, width = "$gap", color = "$peach" },
5377 row { key = "anim", width = 10, height = 10, transition = 100,
5378 keyframes = { { bg = "$peach", width = "$wide" }, { bg = "$peech", radius = "$gap" } },
5379 enter = { width = "$nothing", bg = "$peach" } },
5380 }
5381 end
5382 "##,
5383 )
5384 .unwrap();
5385 let mut core = Core::new();
5386 core.set_inspect(true);
5387 frame(&mut core, &mut ext);
5388 let nodes = core.nodes();
5389 let by = |label: &str| {
5390 nodes
5391 .iter()
5392 .find(|n| n.label.as_deref() == Some(label))
5393 .unwrap()
5394 };
5395 assert_eq!(by("clamp").rect.w, 40.0, "the clamp is the token's 40 px");
5396 assert_eq!(
5397 by("typo").rect.w,
5398 10.0,
5399 "a miss leaves the row at its default"
5400 );
5401 let dl = core.output().0;
5402 let seg = dl
5403 .quads
5404 .iter()
5405 .find(|q| q.kind == kui_core::QuadKind::Segment)
5406 .expect("the line drew");
5407 assert_eq!(seg.color, Color::hex(0xffcc99ff));
5408 assert_eq!(seg.border_w, 6.0, "the stroke is the token's width");
5409 let ws = core.take_warnings();
5410 let mut names: Vec<String> = ws
5411 .iter()
5412 .filter(|w| w.code == kui_core::diag::UNKNOWN_TOKEN)
5413 .map(|w| w.message.clone())
5414 .collect();
5415 names.sort();
5416 assert_eq!(names.len(), 3, "nope, peech, nothing: {ws:?}");
5417 assert!(names.iter().any(|m| m.contains("`$nope`")), "{names:?}");
5418 assert!(names.iter().any(|m| m.contains("`$peech`")), "{names:?}");
5419 assert!(names.iter().any(|m| m.contains("`$nothing`")), "{names:?}");
5420 }
5421
5422 #[test]
5423 fn scripts_move_focus_with_the_verbs() {
5424 let mut ext = LuaExtension::from_source(
5425 "focus",
5426 r#"
5427 function view(env)
5428 if cmd == "set" then env.set_focus(target)
5429 elseif cmd == "blur" then env.blur()
5430 elseif cmd == "next" then env.focus_next()
5431 elseif cmd == "prev" then env.focus_prev() end
5432 cmd = nil
5433 -- `env.focus` is a value the host filled in before view()
5434 -- ran, so it lags a verb called above by a frame;
5435 -- `env.is_focused` is a call and answers about now.
5436 seen_focus = env.focus
5437 seen_live = env.is_focused(target or 0)
5438 seen_window = env.focused
5439 return column { key = "root", pad = 10,
5440 row { key = "a", focusable = true, width = 50, height = 20 },
5441 row { key = "b", focusable = true, width = 50, height = 20 },
5442 row { key = "c", focusable = true, width = 50, height = 20 },
5443 }
5444 end
5445 "#,
5446 )
5447 .unwrap();
5448 let root = Key::ROOT.str("root");
5449 let (a, b, c) = (root.str("a"), root.str("b"), root.str("c"));
5450 let mut core = Core::new();
5451 let cmd = |ext: &LuaExtension, c: &str| ext.lua.globals().set("cmd", c).unwrap();
5452 let seen = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("seen_focus").unwrap();
5453 let live = |ext: &LuaExtension| ext.lua.globals().get::<bool>("seen_live").unwrap();
5454
5455 frame(&mut core, &mut ext);
5457 assert_eq!(core.focus(), None);
5458 assert_eq!(seen(&ext), None, "nil for no focus, not 0");
5459
5460 ext.lua.globals().set("target", b.0 as i64).unwrap();
5462 cmd(&ext, "set");
5463 frame(&mut core, &mut ext);
5464 assert_eq!(core.focus(), Some(b));
5465 assert_eq!(seen(&ext), None, "the value lags a same-frame verb");
5469 assert!(live(&ext), "the query does not");
5470 frame(&mut core, &mut ext);
5471 assert_eq!(
5472 seen(&ext),
5473 Some(b.0 as i64),
5474 "and the next frame carries it"
5475 );
5476
5477 cmd(&ext, "next");
5479 frame(&mut core, &mut ext);
5480 assert_eq!(core.focus(), Some(c));
5481 cmd(&ext, "next");
5482 frame(&mut core, &mut ext);
5483 assert_eq!(core.focus(), Some(a), "wraps");
5484 cmd(&ext, "prev");
5485 frame(&mut core, &mut ext);
5486 assert_eq!(core.focus(), Some(c), "wraps back");
5487
5488 cmd(&ext, "blur");
5489 frame(&mut core, &mut ext);
5490 assert_eq!(core.focus(), None);
5491 frame(&mut core, &mut ext);
5492 assert_eq!(seen(&ext), None);
5493
5494 ext.lua.globals().set("target", "c").unwrap();
5497 cmd(&ext, "set");
5498 frame(&mut core, &mut ext);
5499 assert_eq!(core.focus(), Some(c), "set_focus(\"c\") resolves the label");
5500 assert!(live(&ext), "and is_focused(\"c\") answers about it");
5501 assert!(core.take_warnings().is_empty());
5502 ext.lua.globals().set("target", "nope").unwrap();
5504 cmd(&ext, "set");
5505 let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
5506 let e = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
5507 ui.finish();
5508 assert!(
5509 e.contains("no node is keyed \"nope\"") && e.contains("integer key"),
5510 "{e}"
5511 );
5512
5513 assert!(ext.lua.globals().get::<bool>("seen_window").unwrap());
5516 }
5517
5518 #[test]
5521 fn a_shared_label_resolves_to_the_first_and_warns() {
5522 let mut ext = LuaExtension::from_source(
5523 "dup",
5524 r#"
5525 function view(env)
5526 if go then env.set_focus("item"); go = nil end
5527 return column { key = "root",
5528 row { row { key = "item", focusable = true, width = 50, height = 20 } },
5529 row { row { key = "item", focusable = true, width = 50, height = 20 } },
5530 }
5531 end
5532 "#,
5533 )
5534 .unwrap();
5535 let mut core = Core::new();
5536 frame(&mut core, &mut ext);
5537 ext.lua.globals().set("go", true).unwrap();
5538 frame(&mut core, &mut ext);
5539 assert_eq!(
5540 core.focus(),
5541 Some(Key::ROOT.str("root").index(0).str("item")),
5542 "the first, under the auto-keyed first row"
5543 );
5544 let ws = core.take_warnings();
5545 assert_eq!(ws.len(), 1, "{ws:?}");
5546 assert_eq!(ws[0].code, kui_core::diag::AMBIGUOUS_KEY);
5547 assert!(ws[0].message.contains("\"item\""), "{}", ws[0].message);
5548 }
5549
5550 #[test]
5553 fn scripts_open_a_menu_and_hear_the_row() {
5554 let mut ext = LuaExtension::from_source(
5555 "menu",
5556 r#"
5557 frames = 0
5558 function view(env)
5559 frames = frames + 1
5560 if frames == 2 then
5561 opened = env.open_menu("card", 40, 30, {
5562 { role = "copy" },
5563 { role = "separator" },
5564 -- The wire spelling the event reports, and the
5565 -- snake one a script may have learned first.
5566 { role = "selectAll" },
5567 { role = "look_up" },
5568 { label = "Wrap", checked = true },
5569 { label = "Inspect", id = "inspect" },
5570 })
5571 end
5572 return column {
5573 column { key = "card", selectable = true, text("one", { size = 14 }) },
5574 }
5575 end
5576 "#,
5577 )
5578 .unwrap();
5579 let mut core = Core::new();
5580 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5581 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5582 ui.set_origin(OriginId(1));
5583 ext.view(&Slot::root(), &mut ui).unwrap();
5584 ui.finish();
5585 };
5586 frame(&mut core, &mut ext);
5587 frame(&mut core, &mut ext);
5588 assert!(ext.lua.globals().get::<bool>("opened").unwrap());
5589 let roles: Vec<_> = core
5590 .menu()
5591 .expect("open")
5592 .items
5593 .iter()
5594 .map(|i| (i.role, i.checked))
5595 .collect();
5596 assert_eq!(
5597 roles,
5598 [
5599 (kui_core::MenuRole::Copy, false),
5600 (kui_core::MenuRole::Separator, false),
5601 (kui_core::MenuRole::SelectAll, false),
5602 (kui_core::MenuRole::LookUp, false),
5603 (kui_core::MenuRole::Custom, true),
5604 (kui_core::MenuRole::Custom, false),
5605 ]
5606 );
5607 frame(&mut core, &mut ext);
5608 let tree = core.access_tree();
5611 assert_eq!(
5612 tree.nodes
5613 .iter()
5614 .find(|n| n.name.as_deref() == Some("Wrap"))
5615 .expect("the checked row")
5616 .checked,
5617 Some(true)
5618 );
5619 let row = tree
5620 .nodes
5621 .iter()
5622 .find(|n| n.name.as_deref() == Some("Inspect"))
5623 .expect("the menu drew")
5624 .rect;
5625 let at = kui_core::Vec2::new(row.x + row.w / 2.0, row.y + row.h / 2.0);
5626 core.handle_input(InputEvent::CursorMoved(at));
5627 core.handle_input(InputEvent::mouse_down(1));
5628 let events = core.handle_input(InputEvent::mouse_up());
5629 assert_eq!(events.len(), 1, "{events:?}");
5630 assert_eq!(events[0].payload.get_str("item"), Some("inspect"));
5631 assert_eq!(
5632 events[0].origin,
5633 OriginId(1),
5634 "posted with the origin that asked for the menu"
5635 );
5636 }
5637
5638 #[test]
5641 fn scripts_select_and_read_a_scope() {
5642 let mut ext = LuaExtension::from_source(
5643 "sel",
5644 r#"
5645 frames = 0
5646 function view(env)
5647 frames = frames + 1
5648 if frames > 1 then
5649 before = env.selection_text()
5650 took = env.select_all_in("card")
5651 text_out = env.selection_text()
5652 not_a_scope = env.select_all_in("plain")
5653 end
5654 return column {
5655 column { key = "card", selectable = true,
5656 text("one", { size = 14 }),
5657 text("two", { size = 14 }) },
5658 row { key = "plain", width = 10, height = 10 },
5659 }
5660 end
5661 "#,
5662 )
5663 .unwrap();
5664 let mut core = Core::new();
5665 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5666 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5667 ui.set_origin(OriginId(1));
5668 ext.view(&Slot::root(), &mut ui).unwrap();
5669 ui.finish();
5670 };
5671 frame(&mut core, &mut ext);
5672 frame(&mut core, &mut ext);
5673 let g = ext.lua.globals();
5674 assert!(matches!(
5675 g.get::<mlua::Value>("before").unwrap(),
5676 mlua::Value::Nil
5677 ));
5678 assert!(g.get::<bool>("took").unwrap());
5679 assert_eq!(g.get::<String>("text_out").unwrap(), "one\ntwo");
5680 assert!(
5681 !g.get::<bool>("not_a_scope").unwrap(),
5682 "a node that drew no text is not a scope"
5683 );
5684 }
5685
5686 #[test]
5691 fn scripts_map_points_to_bytes_on_a_line_of_runs() {
5692 let mut ext = LuaExtension::from_source(
5693 "hit",
5694 r#"
5695 frames = 0
5696 function view(env)
5697 local mono = { size = 14, family = "mono" }
5698 local w = env.measure_text("M", mono, 0).width
5699 frames = frames + 1
5700 -- A label nothing has declared yet is an error by name
5701 -- (F5), so the first frame declares and the next asks.
5702 if frames > 1 then
5703 hit = env.text_hit("line", 7.2 * w, 5)
5704 seam = env.caret_rect("line", 4)
5705 far = env.text_hit("line", 390, 5)
5706 none = env.caret_rect("plain", 0)
5707 end
5708 cell = w
5709 return column {
5710 row { key = "line",
5711 text("let ", mono),
5712 row { bg = 0x3b5bd455, text("value", mono) },
5713 text(" = 1;", mono) },
5714 row { key = "plain", width = 10, height = 10 },
5715 }
5716 end
5717 "#,
5718 )
5719 .unwrap();
5720 let mut core = Core::new();
5721 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5722 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5723 ui.set_origin(OriginId(1));
5724 ext.view(&Slot::root(), &mut ui).unwrap();
5725 ui.finish();
5726 };
5727 frame(&mut core, &mut ext);
5728 let hit: mlua::Value = ext.lua.globals().get("hit").unwrap();
5729 assert!(
5730 matches!(hit, mlua::Value::Nil),
5731 "nothing drawn before the first frame"
5732 );
5733 frame(&mut core, &mut ext);
5734 let hit: mlua::Table = ext.lua.globals().get("hit").unwrap();
5735 assert_eq!(
5736 hit.get::<usize>("byte").unwrap(),
5737 7,
5738 "the fourth cell of \"value\""
5739 );
5740 assert_eq!(hit.get::<u32>("line").unwrap(), 0);
5741 let cell: f32 = ext.lua.globals().get("cell").unwrap();
5742 let seam: mlua::Table = ext.lua.globals().get("seam").unwrap();
5743 let x: f32 = seam.get("x").unwrap();
5744 assert!((x - 4.0 * cell).abs() < 0.75, "{x} vs {}", 4.0 * cell);
5745 assert_eq!(seam.get::<f32>("w").unwrap(), 0.0);
5746 let far: mlua::Table = ext.lua.globals().get("far").unwrap();
5747 assert_eq!(
5752 far.get::<usize>("byte").unwrap(),
5753 14,
5754 "the end, across the runs"
5755 );
5756 let none: mlua::Value = ext.lua.globals().get("none").unwrap();
5757 assert!(matches!(none, mlua::Value::Nil), "a node that drew no text");
5758 }
5759
5760 #[test]
5764 fn scripts_draw_a_screen_of_cells() {
5765 let mut ext = LuaExtension::from_source(
5766 "term",
5767 r#"
5768 function view(env)
5769 return column { cells { key = "term", rows = 2, cols = 11, size = 14, family = "mono",
5770 lines = { "hello world", " bye" },
5771 runs = { { 1, 2, 3, 0xff0000ff, 0x0000ffff, 1 } },
5772 cursor_at = { 1, 4 }, cursor_shape = "underline" } }
5773 end
5774 "#,
5775 )
5776 .unwrap();
5777 let mut core = Core::new();
5778 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5779 ui.set_origin(OriginId(1));
5780 ext.view(&Slot::root(), &mut ui).unwrap();
5781 ui.finish();
5782 let (dl, _) = core.output();
5783 let glyphs = dl
5784 .quads
5785 .iter()
5786 .filter(|q| q.kind == kui_core::QuadKind::GlyphMask)
5787 .count();
5788 assert_eq!(glyphs, 13, "hello world + bye");
5789 let tree = core.access_tree();
5790 let term = tree
5791 .nodes
5792 .iter()
5793 .find(|n| n.role == kui_core::Role::Terminal)
5794 .expect("a terminal node");
5795 assert_eq!(term.value.as_deref(), Some("hello world\n bye"));
5796 }
5797
5798 #[test]
5804 fn a_lua_grid_selects_by_cell_word_and_row() {
5805 let mut ext = LuaExtension::from_source(
5806 "term",
5807 r#"
5808 function view(env)
5809 said = env.selection_text()
5810 return column { cells { key = "term", rows = 2, cols = 12,
5811 size = 14, family = "mono", line_height = 20,
5812 origin_line = 900, selectable = true,
5813 lines = { "hello world", "bye there" } } }
5814 end
5815 "#,
5816 )
5817 .unwrap();
5818 let mut core = Core::new();
5819 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5820 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5821 ui.set_origin(OriginId(1));
5822 ext.view(&Slot::root(), &mut ui).unwrap();
5823 ui.finish();
5824 };
5825 frame(&mut core, &mut ext);
5826 let said = |ext: &LuaExtension| ext.lua.globals().get::<Option<String>>("said").unwrap();
5827
5828 let w = core
5830 .measure_text(
5831 "M",
5832 &kui_core::TextStyle::new(14.0)
5833 .family(kui_core::FontFamily::Mono)
5834 .line_height(20.0),
5835 None,
5836 )
5837 .width;
5838 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
5839 let click = |core: &mut Core, p: Vec2, clicks: u8| {
5840 core.handle_input(InputEvent::CursorMoved(p));
5841 core.handle_input(InputEvent::MouseDown {
5842 button: kui_core::MouseButton::Primary,
5843 clicks,
5844 });
5845 core.handle_input(InputEvent::MouseUp {
5846 button: kui_core::MouseButton::Primary,
5847 });
5848 };
5849
5850 click(&mut core, at(0, 8), 2);
5851 frame(&mut core, &mut ext);
5852 assert_eq!(said(&ext).as_deref(), Some("world"), "a double click");
5853
5854 click(&mut core, at(1, 1), 3);
5855 frame(&mut core, &mut ext);
5856 assert_eq!(said(&ext).as_deref(), Some("bye there"), "a triple click");
5857 }
5858
5859 #[test]
5865 fn a_grid_selection_reads_back_as_absolute_lines_and_columns() {
5866 let mut ext = LuaExtension::from_source(
5867 "term",
5868 r#"
5869 function view(env)
5870 sel = env.cell_selection()
5871 return column { cells { key = "term", rows = 2, cols = 12,
5872 size = 14, family = "mono", line_height = 20,
5873 origin_line = 900, selectable = true,
5874 lines = { "hello world", "bye there" } } }
5875 end
5876 "#,
5877 )
5878 .unwrap();
5879 let mut core = Core::new();
5880 let frame = |core: &mut Core, ext: &mut LuaExtension| {
5881 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5882 ui.set_origin(OriginId(1));
5883 ext.view(&Slot::root(), &mut ui).unwrap();
5884 ui.finish();
5885 };
5886 frame(&mut core, &mut ext);
5887 assert!(
5888 ext.lua
5889 .globals()
5890 .get::<mlua::Value>("sel")
5891 .unwrap()
5892 .is_nil(),
5893 "nothing selected yet"
5894 );
5895 let w = core
5896 .measure_text(
5897 "M",
5898 &kui_core::TextStyle::new(14.0)
5899 .family(kui_core::FontFamily::Mono)
5900 .line_height(20.0),
5901 None,
5902 )
5903 .width;
5904 let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
5905 core.handle_input(InputEvent::CursorMoved(at(1, 4)));
5906 core.handle_input(InputEvent::MouseDown {
5907 button: kui_core::MouseButton::Primary,
5908 clicks: 1,
5909 });
5910 core.handle_input(InputEvent::CursorMoved(at(0, 1)));
5911 core.handle_input(InputEvent::MouseUp {
5912 button: kui_core::MouseButton::Primary,
5913 });
5914 frame(&mut core, &mut ext);
5915 let sel: Table = ext.lua.globals().get("sel").unwrap();
5916 let end = |name: &str| -> (u64, usize) {
5917 let t: Table = sel.get(name).unwrap();
5918 (t.get("line").unwrap(), t.get("col").unwrap())
5919 };
5920 assert_eq!(end("anchor"), (901, 4), "the press, on the second row");
5921 assert_eq!(end("focus"), (900, 1), "the pointer, backwards");
5922 assert_eq!(
5923 sel.get::<i64>("node").unwrap(),
5924 core.key_of("term").unwrap().0 as i64
5925 );
5926 assert!(!sel.get::<bool>("block").unwrap());
5927 }
5928
5929 #[test]
5932 fn spans_carry_their_decorations() {
5933 let mut ext = LuaExtension::from_source(
5934 "deco",
5935 r#"
5936 function view(env)
5937 return row { text({ "let ", { "value", bg = 0x3b5bd455, underline = true },
5938 " = 1;" }, { size = 14, family = "mono" }) }
5939 end
5940 "#,
5941 )
5942 .unwrap();
5943 let mut core = Core::new();
5944 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
5945 ui.set_origin(OriginId(1));
5946 ext.view(&Slot::root(), &mut ui).unwrap();
5947 ui.finish();
5948 let (dl, _) = core.output();
5949 let solids = dl
5950 .quads
5951 .iter()
5952 .filter(|q| q.kind == kui_core::QuadKind::Solid)
5953 .count();
5954 assert_eq!(solids, 2, "a background and an underline");
5955 }
5956
5957 #[test]
5962 fn underlines_have_a_colour_and_a_shape_of_their_own() {
5963 let mut ext = LuaExtension::from_source(
5964 "k4",
5965 r#"
5966 function view(env)
5967 return column {
5968 text({ "let ", { "value", underline_color = 0xff0000ff, underline_style = "wavy" } },
5969 { size = 14, family = "mono" }),
5970 text("warn", { size = 14, family = "mono", underline_color = 0x00ff00ff }),
5971 cells { rows = 1, cols = 3, size = 14, family = "mono",
5972 lines = { "abc" }, runs = { { 0, 0, 3, 0, 0, 32, 0xff0000ff } } },
5973 }
5974 end
5975 "#,
5976 )
5977 .unwrap();
5978 let mut core = Core::new();
5979 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
5980 ui.set_origin(OriginId(1));
5981 ext.view(&Slot::root(), &mut ui).unwrap();
5982 ui.finish();
5983 let (dl, _) = core.output();
5984 let red = Color::hex(0xff0000ff);
5985 let segs: Vec<_> = dl
5986 .quads
5987 .iter()
5988 .filter(|q| q.kind == kui_core::QuadKind::Segment)
5989 .collect();
5990 assert!(
5991 segs.len() >= 6,
5992 "a wave under the span and an undercurl over the cells: {}",
5993 segs.len()
5994 );
5995 assert!(
5996 segs.iter().all(|q| q.color == red),
5997 "in the underline colour"
5998 );
5999 let solids: Vec<_> = dl
6000 .quads
6001 .iter()
6002 .filter(|q| q.kind == kui_core::QuadKind::Solid)
6003 .collect();
6004 assert_eq!(solids.len(), 1, "the text's solid line, coloured");
6005 assert_eq!(solids[0].color, Color::hex(0x00ff00ff));
6006 let mut bad = LuaExtension::from_source(
6008 "k4bad",
6009 r#"function view(env) return text({ { "x", underline_style = "squiggly" } }) end"#,
6010 )
6011 .unwrap();
6012 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6013 ui.set_origin(OriginId(1));
6014 let err = bad.view(&Slot::root(), &mut ui).unwrap_err();
6015 assert!(err.contains("solid | wavy | dotted"), "{err}");
6016 }
6017
6018 #[test]
6022 fn features_are_a_text_option() {
6023 let mut ext = LuaExtension::from_source(
6024 "features",
6025 r#"
6026 function view(env)
6027 return row { text("fi ->", { size = 16 }),
6028 text("fi ->", { size = 16, features = "liga=0 calt=0" }) }
6029 end
6030 "#,
6031 )
6032 .unwrap();
6033 let mut core = Core::new();
6034 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6035 ui.set_origin(OriginId(1));
6036 ext.view(&Slot::root(), &mut ui).unwrap();
6037 ui.finish();
6038 assert_eq!(core.text_cache_len(), 2);
6039 }
6040
6041 #[test]
6047 fn a_style_table_shared_by_three_texts_is_three_texts() {
6048 let mut ext = LuaExtension::from_source(
6049 "shared",
6050 r#"
6051 local mono = { size = 14, family = "mono" }
6052 function view(env)
6053 return row { text("a", mono), text("bb", mono), text("ccc", mono) }
6054 end
6055 "#,
6056 )
6057 .unwrap();
6058 let mut core = Core::new();
6059 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6060 ui.set_origin(OriginId(1));
6061 ext.view(&Slot::root(), &mut ui).unwrap();
6062 ui.finish();
6063 let names: Vec<String> = core
6064 .access_tree()
6065 .nodes
6066 .iter()
6067 .filter_map(|n| n.value.clone().or_else(|| n.name.clone()))
6068 .collect();
6069 let joined = names.join("|");
6070 assert!(
6071 joined.contains("a") && joined.contains("bb") && joined.contains("ccc"),
6072 "three different texts, got {joined:?}"
6073 );
6074 assert_eq!(
6075 core.text_cache_len(),
6076 3,
6077 "three strings shaped, not one three times"
6078 );
6079 }
6080
6081 #[test]
6086 fn scripts_slice_a_long_list_from_the_geometry() {
6087 let mut ext = LuaExtension::from_source(
6088 "virtual",
6089 r#"
6090 ROWS, ROW_H, built = 10000, 30, 0
6091 function view(env)
6092 local g = env.scroll_geometry(list_key)
6093 -- No layout yet: fall back to the window for one frame.
6094 local h = g and g.h or 200
6095 local top = g and g.offset.y or 0
6096 local first = math.min(ROWS, math.max(0, math.floor(top / ROW_H)))
6097 local last = math.min(ROWS, math.ceil((top + h) / ROW_H))
6098 local list = { key = "list", width = "grow", height = "grow",
6099 scroll_y = true }
6100 if first > 0 then
6101 list[#list + 1] = row { key = "lead", width = "grow",
6102 height = first * ROW_H }
6103 end
6104 for i = first, last - 1 do
6105 list[#list + 1] = row { key = "row" .. i, width = "grow",
6106 height = ROW_H, bg = 0x282840ff }
6107 end
6108 if last < ROWS then
6109 list[#list + 1] = row { key = "tail", width = "grow",
6110 height = (ROWS - last) * ROW_H }
6111 end
6112 built = last - first
6113 return column(list)
6114 end
6115 "#,
6116 )
6117 .unwrap();
6118 let list = Key::ROOT.str("list");
6119 ext.lua.globals().set("list_key", list.0 as i64).unwrap();
6120 let mut core = Core::new();
6121 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6122 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6123 ui.set_origin(OriginId(1));
6124 ext.view(&Slot::root(), &mut ui).unwrap();
6125 ui.finish();
6126 };
6127
6128 frame(&mut core, &mut ext);
6129 frame(&mut core, &mut ext);
6130 let built: i64 = ext.lua.globals().get("built").unwrap();
6131 assert_eq!(built, 7, "200 / 30 rounded up");
6132
6133 let g = core.scroll_geometry(list).expect("laid out");
6136 assert_eq!(g.content.h, 10_000.0 * 30.0);
6137 core.set_scroll(list, Vec2::new(0.0, 1e9));
6138 frame(&mut core, &mut ext);
6139 frame(&mut core, &mut ext);
6140 assert_eq!(core.scroll_offset(list).y, 10_000.0 * 30.0 - 200.0);
6141 let built: i64 = ext.lua.globals().get("built").unwrap();
6142 assert_eq!(built, 7, "still a screenful at the far end");
6143 }
6144
6145 #[test]
6151 fn the_prelude_virtualizes_a_long_list_in_one_call() {
6152 let mut ext = LuaExtension::from_source(
6153 "virtual",
6154 r#"
6155 ROWS, ROW_H, first_built, built = 10000, 30, -1, 0
6156 function view(env)
6157 built, first_built = 0, -1
6158 return uniform_list(env,
6159 { key = "list", rows = ROWS, row_h = ROW_H,
6160 width = "grow", height = "grow" },
6161 function(i)
6162 built = built + 1
6163 if first_built < 0 then first_built = i end
6164 return column { fill = true, bg = 0x282840ff,
6165 on_click = { kind = "pick", row = i } }
6166 end)
6167 end
6168 "#,
6169 )
6170 .unwrap();
6171 let list = Key::ROOT.str("list");
6172 let mut core = Core::new();
6173 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6174 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6175 ui.set_origin(OriginId(1));
6176 ext.view(&Slot::root(), &mut ui).unwrap();
6177 ui.finish();
6178 };
6179
6180 frame(&mut core, &mut ext);
6181 frame(&mut core, &mut ext);
6182 let built: i64 = ext.lua.globals().get("built").unwrap();
6183 assert_eq!(built, 9, "200 / 30 rounded up, plus two rows of overscan");
6184
6185 let g = core.scroll_geometry(list).expect("laid out");
6188 assert_eq!(g.content.h, 10_000.0 * 30.0);
6189 core.set_scroll(list, Vec2::new(0.0, 300.0 * 30.0));
6190 frame(&mut core, &mut ext);
6191 frame(&mut core, &mut ext);
6192 let first: i64 = ext.lua.globals().get("first_built").unwrap();
6193 assert_eq!(first, 298, "two rows of overscan above row 300");
6194 let built: i64 = ext.lua.globals().get("built").unwrap();
6195 assert_eq!(built, 11, "a screenful with overscan on both sides now");
6196 assert!(
6199 core.access_tree()
6200 .nodes
6201 .iter()
6202 .any(|n| n.key == list.index(300).index(0)),
6203 "row 300 is not keyed by its data index"
6204 );
6205 }
6206
6207 #[test]
6211 fn a_view_names_a_family_and_draws_in_it() {
6212 let mut ext = LuaExtension::from_source(
6213 "fam",
6214 r#"
6215 named, sans, missed = nil, nil, nil
6216 function view(env)
6217 named = env.measure_text("iiiWWW", { family = "Fixture Mono", size = 20 }).width
6218 sans = env.measure_text("iiiWWW", { family = "sans", size = 20 }).width
6219 return column {
6220 text("iiiWWW", { family = "Fixture Mono", size = 20 }),
6221 text("x", { family = "No Such Family 7" }),
6222 }
6223 end
6224 "#,
6225 )
6226 .unwrap();
6227 let mut core = Core::new();
6228 core.add_font_data(kui_core::testing::font_face(
6229 "Fixture Mono",
6230 400,
6231 false,
6232 true,
6233 ))
6234 .unwrap();
6235 let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
6236 ui.set_origin(OriginId(1));
6237 ext.view(&Slot::root(), &mut ui).unwrap();
6238 ui.finish();
6239 let handle = core.add_system_font("Fixture Mono").unwrap();
6240 let expected = core
6241 .measure_text("iiiWWW", &kui_core::TextStyle::new(20.0).font(handle), None)
6242 .width;
6243 let g = ext.lua.globals();
6244 let (named, sans) = (
6245 g.get::<f32>("named").unwrap(),
6246 g.get::<f32>("sans").unwrap(),
6247 );
6248 assert_eq!(named, expected, "the face the host's handle draws");
6249 assert_ne!(named, sans, "and not sans");
6250 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
6251 assert_eq!(codes, ["unknown-family"]);
6252 }
6253
6254 #[test]
6257 fn the_prelude_reveals_a_row_styles_rows_and_splits() {
6258 let mut ext = LuaExtension::from_source(
6259 "dx22",
6260 r#"
6261 target, scrolled, page = nil, nil, nil
6262 function view(env)
6263 if target then scrolled = reveal_row(env, "list", target, 20) end
6264 page = rows_in_view(env, "list", 20)
6265 zero = rows_in_view(env, "list", 0)
6266 return row { width = 300, height = 100,
6267 uniform_list(env,
6268 { key = "list", rows = 50, row_h = 20, width = 200, height = 100,
6269 row_props = function(i) return { on_click = { kind = "pick", row = i } } end },
6270 function(i) return text("row " .. i) end),
6271 splitter(env, { key = "bar", on_drag = { kind = "split" } }),
6272 column { width = "grow", height = "grow" },
6273 }
6274 end
6275 "#,
6276 )
6277 .unwrap();
6278 let mut core = Core::new();
6279 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6280 let mut ui = core.frame(Size::new(300.0, 100.0), 1.0);
6281 ui.set_origin(OriginId(1));
6282 ext.view(&Slot::root(), &mut ui).unwrap();
6283 ui.finish();
6284 };
6285 frame(&mut core, &mut ext);
6286 frame(&mut core, &mut ext);
6287 assert_eq!(ext.lua.globals().get::<i64>("page").unwrap(), 5);
6288 assert!(
6289 core.take_warnings().is_empty(),
6290 "every prop the three spell is known"
6291 );
6292
6293 ext.lua.globals().set("target", 40).unwrap();
6294 frame(&mut core, &mut ext);
6295 assert!(ext.lua.globals().get::<bool>("scrolled").unwrap());
6296 let list = core.key_of("list").unwrap();
6297 assert_eq!(
6298 core.scroll_offset(list),
6299 Vec2::new(0.0, 760.0),
6300 "row 40 to the middle"
6301 );
6302 core.handle_input(InputEvent::CursorMoved(Vec2::new(10.0, 45.0)));
6305 core.handle_input(InputEvent::mouse_down(1));
6306 let up = core.handle_input(InputEvent::mouse_up());
6307 assert_eq!(up.iter().find_map(|e| e.payload.get_int("row")), Some(40));
6308
6309 core.handle_input(InputEvent::CursorMoved(Vec2::new(202.0, 50.0)));
6311 core.handle_input(InputEvent::mouse_down(1));
6312 core.handle_input(InputEvent::CursorMoved(Vec2::new(225.0, 50.0)));
6313 let end = core.handle_input(InputEvent::mouse_up());
6314 let d = end.iter().find_map(|e| e.drag()).expect("the drag's end");
6315 assert_eq!(d.ratio().x, 0.75);
6316
6317 ext.lua.globals().set("target", 500).unwrap();
6319 frame(&mut core, &mut ext);
6320 assert!(!ext.lua.globals().get::<bool>("scrolled").unwrap());
6321 assert_eq!(core.scroll_offset(list), Vec2::new(0.0, 760.0));
6322 assert_eq!(ext.lua.globals().get::<i64>("zero").unwrap(), 0);
6323 }
6324
6325 #[test]
6329 fn a_uniform_list_that_shrank_lands_in_one_frame() {
6330 let mut ext = LuaExtension::from_source(
6331 "virtual",
6332 r#"
6333 ROWS, first_built = 200, -1
6334 function view(env)
6335 first_built = -1
6336 return uniform_list(env,
6337 { key = "list", rows = ROWS, row_h = 20,
6338 width = "grow", height = "grow" },
6339 function(i)
6340 if first_built < 0 then first_built = i end
6341 return column { fill = true, bg = 0x282840ff }
6342 end)
6343 end
6344 "#,
6345 )
6346 .unwrap();
6347 let list = Key::ROOT.str("list");
6348 let mut core = Core::new();
6349 let frame = |core: &mut Core, ext: &mut LuaExtension| {
6350 let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
6351 ui.set_origin(OriginId(1));
6352 ext.view(&Slot::root(), &mut ui).unwrap();
6353 ui.finish();
6354 };
6355 frame(&mut core, &mut ext);
6356 frame(&mut core, &mut ext);
6357 core.set_scroll(list, Vec2::new(0.0, 3_000.0));
6358 frame(&mut core, &mut ext);
6359 frame(&mut core, &mut ext);
6360 let deep: i64 = ext.lua.globals().get("first_built").unwrap();
6361 assert!(
6362 deep > 100,
6363 "expected to be deep in the list, built from {deep}"
6364 );
6365
6366 ext.lua.globals().set("ROWS", 10).unwrap();
6367 frame(&mut core, &mut ext);
6368 let g = core.scroll_geometry(list).expect("laid out");
6369 assert_eq!(
6370 g.content.h,
6371 10.0 * 20.0,
6372 "the content is the list it has now"
6373 );
6374 frame(&mut core, &mut ext);
6375 let first: i64 = ext.lua.globals().get("first_built").unwrap();
6376 assert_eq!(first, 0, "and every row of it is built");
6377 assert_eq!(core.scroll_offset(list).y, 0.0);
6378 }
6379
6380 const VARIABLE_LIST: &str = r#"
6385 heights = row_heights(1000, 20)
6386 measured = 0
6387 function view(env)
6388 return list(env,
6389 { key = "list", heights = heights, width = "grow", height = "grow",
6390 transition = TRANSITION },
6391 function(i, width)
6392 measured = measured + 1
6393 if i < 100 then return 20 else return 60 end
6394 end,
6395 function(i)
6396 return column { width = "grow", height = "grow", bg = 0x282840ff,
6397 label = "row", on_click = i }
6398 end)
6399 end
6400 "#;
6401
6402 fn variable_frame(core: &mut Core, ext: &mut LuaExtension, t: f64) {
6404 core.set_time(t);
6405 let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
6406 ui.set_origin(OriginId(1));
6407 ext.view(&Slot::root(), &mut ui).unwrap();
6408 ui.finish();
6409 }
6410
6411 fn row_under(core: &mut Core, y: f32) -> Option<i64> {
6414 core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, y)));
6415 core.handle_input(InputEvent::mouse_down(1));
6416 let evs = core.handle_input(InputEvent::mouse_up());
6417 evs.first()?.payload.as_int()
6418 }
6419
6420 #[test]
6424 fn a_lua_list_keeps_the_row_under_the_pointer_while_the_estimate_moves() {
6425 let mut ext = LuaExtension::from_source("variable", VARIABLE_LIST).unwrap();
6426 let list = Key::ROOT.str("list");
6427 let mut core = Core::new();
6428 variable_frame(&mut core, &mut ext, 0.0);
6429 variable_frame(&mut core, &mut ext, 0.0);
6430 let measured: i64 = ext.lua.globals().get("measured").unwrap();
6431 assert!(
6432 (10..=20).contains(&measured),
6433 "a screenful measured, not the list: {measured}"
6434 );
6435
6436 core.set_scroll(list, Vec2::new(0.0, 5_000.0));
6437 variable_frame(&mut core, &mut ext, 0.0);
6438 let settled = row_under(&mut core, 4.0);
6439 assert!(settled.is_some(), "nothing under the top edge");
6440 for n in 0..4 {
6441 variable_frame(&mut core, &mut ext, 0.0);
6442 assert_eq!(
6443 row_under(&mut core, 4.0),
6444 settled,
6445 "the content slid on frame {n} as the estimate moved"
6446 );
6447 }
6448 let g = core.scroll_geometry(list).expect("laid out");
6449 assert!(
6450 g.content.h > 1000.0 * 20.0 * 1.5,
6451 "the list learned it is longer: {}",
6452 g.content.h
6453 );
6454 }
6455
6456 #[test]
6460 fn a_lua_list_glides_to_the_row_asked_for() {
6461 let mut ext =
6462 LuaExtension::from_source("variable", &format!("TRANSITION = 100\n{VARIABLE_LIST}"))
6463 .unwrap();
6464 let list = Key::ROOT.str("list");
6465 let mut core = Core::new();
6466 let mut t = 0.0;
6467 variable_frame(&mut core, &mut ext, t);
6468 variable_frame(&mut core, &mut ext, t);
6469 let target = 400;
6470 let offset: f32 = ext
6471 .lua
6472 .load(format!("return heights:offset_of({target})"))
6473 .eval()
6474 .unwrap();
6475 core.set_scroll(list, Vec2::new(0.0, offset));
6476 for _ in 0..30 {
6477 t += 1.0 / 60.0;
6478 variable_frame(&mut core, &mut ext, t);
6479 }
6480 assert_eq!(row_under(&mut core, 4.0), Some(target));
6481 }
6482
6483 #[test]
6487 fn a_script_asks_for_a_file_dialog_and_hears_the_answer() {
6488 let mut ext = LuaExtension::from_source(
6489 "files",
6490 r#"
6491 ask, asked, again, waiting, heard = true, nil, nil, nil, nil
6492 function view(env)
6493 if ask then
6494 asked = env.request_files {
6495 mode = "save", title = "Export", file_name = "notes.md",
6496 filters = { { name = "Markdown", extensions = { "md" } } },
6497 tag = "export",
6498 }
6499 again = env.request_files {}
6500 waiting = env.awaiting_files()
6501 ask = false
6502 end
6503 return column {}
6504 end
6505 function on_event(ev)
6506 if ev.kind == "files" then heard = ev.paths[1] .. "|" .. ev.tag end
6507 end
6508 "#,
6509 )
6510 .unwrap();
6511 let mut core = Core::new();
6512 variable_frame(&mut core, &mut ext, 0.0);
6513 let g = ext.lua.globals();
6514 assert!(g.get::<bool>("asked").unwrap(), "the first ask is taken");
6515 assert!(
6516 !g.get::<bool>("again").unwrap(),
6517 "a second while it is out is not"
6518 );
6519 assert!(g.get::<bool>("waiting").unwrap());
6520
6521 let asks = core.take_file_requests();
6522 assert_eq!(asks.len(), 1);
6523 let d = &asks[0];
6524 assert_eq!(d.mode, kui_core::FileDialogMode::Save);
6525 assert_eq!(d.file_name.as_deref(), Some("notes.md"));
6526 assert_eq!(d.filters[0].extensions, vec!["md".to_string()]);
6527
6528 let evs = core.handle_input(InputEvent::Files(vec!["/tmp/notes.md".into()]));
6529 assert_eq!(evs.len(), 1);
6530 assert_eq!(
6531 evs[0].origin,
6532 OriginId(1),
6533 "the answer goes to the script that asked"
6534 );
6535 for e in &evs {
6536 ext.on_event(e);
6537 }
6538 let heard: String = ext.lua.globals().get("heard").unwrap();
6539 assert_eq!(heard, "/tmp/notes.md|export");
6540 }
6541
6542 #[test]
6545 fn a_lua_list_names_what_it_is_missing() {
6546 let mut ext = LuaExtension::from_source(
6547 "bad",
6548 r#"
6549 function view(env)
6550 ok, err = pcall(list, env, { key = "list" }, function() return 1 end,
6551 function() return column {} end)
6552 return column {}
6553 end
6554 "#,
6555 )
6556 .unwrap();
6557 let mut core = Core::new();
6558 variable_frame(&mut core, &mut ext, 0.0);
6559 let err: String = ext.lua.globals().get("err").unwrap();
6560 assert!(err.contains("row_heights"), "{err}");
6561 }
6562
6563 #[test]
6567 fn a_query_answers_for_an_undeclared_label_and_a_command_refuses() {
6568 let mut ext = LuaExtension::from_source(
6569 "queries",
6570 r#"
6571 function view(env)
6572 geom = env.scroll_geometry("nothing")
6573 off = env.scroll_offset("nothing")
6574 hovered = env.is_hovered("nothing")
6575 pressed = env.is_pressed("nothing")
6576 focused = env.is_focused("nothing")
6577 hit = env.text_hit("nothing", 1, 1)
6578 caret = env.caret_rect("nothing", 0)
6579 deferred = pcall(function() env.set_scroll("nothing", 0, 0) end)
6580 refused_focus = not pcall(function() env.set_focus("nothing") end)
6581 return column { width = "grow", height = "grow" }
6582 end
6583 "#,
6584 )
6585 .unwrap();
6586 let mut core = Core::new();
6587 let mut ui = core.frame(Size::new(200.0, 100.0), 1.0);
6588 ext.view(&Slot::root(), &mut ui).unwrap();
6589 ui.finish();
6590 let g = ext.lua.globals();
6591 assert_eq!(g.get::<mlua::Value>("geom").unwrap(), mlua::Value::Nil);
6592 assert_eq!(g.get::<mlua::Value>("hit").unwrap(), mlua::Value::Nil);
6593 assert_eq!(g.get::<mlua::Value>("caret").unwrap(), mlua::Value::Nil);
6594 assert!(!g.get::<bool>("hovered").unwrap());
6595 assert!(!g.get::<bool>("pressed").unwrap());
6596 assert!(!g.get::<bool>("focused").unwrap());
6597 let off: Table = g.get("off").unwrap();
6598 assert_eq!(off.get::<f32>("y").unwrap(), 0.0);
6599 assert!(g.get::<bool>("deferred").unwrap());
6602 let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
6603 assert_eq!(codes, ["label-without-node"]);
6604 assert!(g.get::<bool>("refused_focus").unwrap());
6605 }
6606
6607 #[test]
6608 fn bad_script_reports_error_not_panic() {
6609 let mut ext = LuaExtension::from_source("bad", "function view() return 5 end").unwrap();
6610 let mut core = Core::new();
6611 let mut ui = core.frame(Size::new(100.0, 100.0), 1.0);
6612 assert!(ext.view(&Slot::root(), &mut ui).is_err());
6613 }
6614}