use kui_core::schema::{self, Kind, Parsed, PropsOut};
use kui_core::{
Align, Color, Content, EditOptions, Extension, FloatConfig, Key, PadShorthand, Sizing, Slot,
Span, Ui, UiEvent, Value, WindowConfig, widgets,
};
use mlua::{Lua, Table};
mod meta;
mod rows;
pub use meta::luals_meta;
const PRELUDE: &str = include_str!("prelude.lua");
pub struct LuaExtension {
lua: Lua,
name: String,
slots: Vec<String>,
loaded: std::cell::RefCell<Vec<Loaded>>,
slot_names: std::collections::HashMap<kui_core::Key, String>,
tokens: Option<kui_core::Tokens>,
}
struct Loaded {
namespace: String,
path: std::path::PathBuf,
origin: kui_core::OriginId,
}
impl LuaExtension {
pub fn from_source(name: impl Into<String>, source: &str) -> mlua::Result<Self> {
let lua = Lua::new();
rows::register(&lua)?;
lua.load(PRELUDE).set_name("kui:prelude").exec()?;
let name = name.into();
lua.load(source).set_name(&name).exec()?;
let slots = match lua.globals().get::<mlua::Value>("slots")? {
mlua::Value::Nil => Vec::new(),
mlua::Value::Table(t) => t.sequence_values::<String>().collect::<mlua::Result<_>>()?,
other => {
return Err(mlua::Error::runtime(format!(
"`slots` must be a list of slot names, not {}",
other.type_name()
)));
}
};
let tokens = match lua.globals().get::<mlua::Value>("tokens")? {
mlua::Value::Nil => None,
mlua::Value::Table(t) => Some(parse_tokens(&t)?),
other => {
return Err(mlua::Error::runtime(format!(
"`tokens` must be a table {{ colors = …, lengths = … }}, not {}",
other.type_name()
)));
}
};
Ok(Self {
lua,
name,
slots,
loaded: Default::default(),
slot_names: Default::default(),
tokens,
})
}
fn loaded_as(&self, origin: kui_core::OriginId) -> Option<String> {
self.loaded
.borrow()
.iter()
.find(|l| l.origin == origin)
.map(|l| l.namespace.clone())
}
pub fn lua(&self) -> &Lua {
&self.lua
}
pub fn from_file(path: impl AsRef<std::path::Path>) -> mlua::Result<Self> {
let path = path.as_ref();
let source = std::fs::read_to_string(path).map_err(mlua::Error::external)?;
let name = path
.file_name()
.map_or_else(|| "lua".into(), |n| n.to_string_lossy().into_owned());
Self::from_source(name, &source)
}
}
impl Extension for LuaExtension {
fn name(&self) -> &str {
&self.name
}
fn slots(&self) -> &[String] {
&self.slots
}
fn view(&mut self, slot: &Slot<'_>, ui: &mut Ui<'_>) -> Result<(), String> {
let view: mlua::Function = self
.lua
.globals()
.get("view")
.map_err(|_| "script defines no view()".to_string())?;
let slot_table = slot_table(&self.lua, slot).map_err(|e| format!("slot: {e}"))?;
self.slot_names
.entry(slot.key)
.or_insert_with(|| slot.full_name());
let origin = ui.core().origin();
if let Some(t) = &self.tokens
&& !ui.core().tokens_declared(origin)
{
ui.set_tokens(t.clone());
}
let root: Table = {
let frame = std::cell::RefCell::new(&mut *ui);
self.lua
.scope(|scope| {
let env = env_table(&self.lua, scope, &frame, &self.loaded)?;
view.call((env, slot_table))
})
.map_err(|e| format!("view(): {e}"))?
};
if let Ok(Some(title)) = root.get::<Option<String>>("window_title") {
ui.window_title(&title);
}
if let Ok(Some(true)) = root.get::<Option<bool>>("always_on_top") {
ui.always_on_top(true);
}
if let Ok(Some(true)) = root.get::<Option<bool>>("secure_input") {
ui.secure_input(true);
}
if let Some(name) = root
.get::<Option<String>>("option_as_alt")
.map_err(|e| format!("option_as_alt: {e}"))?
{
let v = kui_core::OptionAsAlt::from_name(&name).ok_or_else(|| {
format!(
"option_as_alt: expected \"none\", \"left\", \"right\" or \"both\", got {name:?}"
)
})?;
if v != kui_core::OptionAsAlt::None {
ui.option_as_alt(v);
}
}
if let Ok(Some(true)) = root.get::<Option<bool>>("ime_off") {
ui.ime_off(true);
}
declare_windows(ui, &root).map_err(|e| format!("windows: {e}"))?;
build_node(ui, &root).map_err(|e| format!("view table: {e}"))
}
fn on_event(&mut self, ev: &UiEvent) -> Vec<Value> {
let Ok(f) = self.lua.globals().get::<mlua::Function>("on_event") else {
return Vec::new();
};
let payload = value_to_lua(&self.lua, &ev.payload).and_then(|p| {
if let mlua::Value::Table(t) = &p
&& !t.contains_key("node_key")?
{
t.set("node_key", ev.key.0 as i64)?;
t.set("window", ev.window.0)?;
if let Some(name) = ev.slot.and_then(|k| self.slot_names.get(&k)) {
t.set("slot", name.as_str())?;
}
if let Some(ns) = self.loaded_as(ev.origin) {
t.set("from", ns)?;
}
}
Ok(p)
});
match payload.and_then(|p| f.call::<mlua::Value>(p)) {
Ok(mlua::Value::Nil) => Vec::new(),
Ok(v) => match lua_to_value(&v) {
Ok(Value::List(replies)) => replies,
Ok(reply) => vec![reply],
Err(e) => {
eprintln!("kui-lua: '{}' on_event reply: {e}", self.name);
Vec::new()
}
},
Err(e) => {
eprintln!("kui-lua: '{}' on_event error: {e}", self.name);
Vec::new()
}
}
}
}
fn slot_table(lua: &Lua, slot: &Slot<'_>) -> mlua::Result<Table> {
let t = lua.create_table()?;
t.set("name", slot.name)?;
t.set("namespace", slot.namespace)?;
t.set("key", slot.key.0 as i64)?;
if !matches!(slot.params, Value::Null) {
t.set("params", value_to_lua(lua, slot.params)?)?;
}
Ok(t)
}
fn key_arg(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Key> {
match v {
mlua::Value::Integer(i) => Ok(Key(i as u64)),
mlua::Value::String(s) => {
let label = s.to_str()?;
ui.key_of(&label).ok_or_else(|| {
mlua::Error::runtime(format!(
"no node is keyed {:?}: pass the label a `key` field declared in this or the last \
frame, or the integer key an event carried",
&*label
))
})
}
other => Err(mlua::Error::runtime(format!(
"a node key is an integer or a declared label, not {}",
other.type_name()
))),
}
}
fn key_query(ui: &mut Ui<'_>, v: mlua::Value) -> mlua::Result<Option<Key>> {
match v {
mlua::Value::Integer(i) => Ok(Some(Key(i as u64))),
mlua::Value::String(s) => Ok(ui.key_of(&s.to_str()?)),
other => Err(mlua::Error::runtime(format!(
"a node key is an integer or a declared label, not {}",
other.type_name()
))),
}
}
fn lua_list_to_value(t: &Table) -> mlua::Result<Value> {
let mut items = Vec::with_capacity(t.raw_len());
for item in t.sequence_values::<mlua::Value>() {
items.push(lua_to_value(&item?)?);
}
Ok(Value::List(items))
}
fn alias_menu_role(row: &mut Value) {
let Value::Map(fields) = row else { return };
for (k, v) in fields.iter_mut() {
if k == "role"
&& let Value::Str(name) = v
{
match name.as_str() {
"select_all" => *name = kui_core::MenuRole::SelectAll.name().to_string(),
"look_up" => *name = kui_core::MenuRole::LookUp.name().to_string(),
_ => {}
}
}
if k == "items"
&& let Value::List(rows) = v
{
rows.iter_mut().for_each(alias_menu_role);
}
}
}
fn menu_items(t: &mlua::Table) -> mlua::Result<Vec<kui_core::MenuItem>> {
let mut rows = lua_list_to_value(t)?;
if let Value::List(rows) = &mut rows {
rows.iter_mut().for_each(alias_menu_role);
}
kui_core::MenuItem::list_from_value(&rows).map_err(mlua::Error::runtime)
}
fn env_table<'scope, 'env: 'scope>(
lua: &Lua,
scope: &'scope mlua::Scope<'scope, 'env>,
ui: &'env std::cell::RefCell<&'env mut Ui<'_>>,
loaded: &'env std::cell::RefCell<Vec<Loaded>>,
) -> mlua::Result<Table> {
let (facts, theme, metrics) = {
let ui = ui.borrow();
(ui.env_facts(), ui.theme(), ui.metrics())
};
let t = lua.create_table()?;
t.set("tokens", tokens_table(lua, &ui.borrow())?)?;
for row in kui_core::schema::ENV_FIELDS {
let value = (row.get)(&facts);
if value == Value::Null {
continue;
}
if row.name == "window.native_controls" {
let win = t
.get::<Option<Table>>("window")?
.unwrap_or(lua.create_table()?);
let f = |k: &str| value.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
win.set("controls_w", f("x") + f("w"))?;
win.set("controls_h", f("y") + f("h"))?;
t.set("window", win)?;
continue;
}
for path in row.lua {
let lua_value = value_to_lua(lua, &value)?;
match path.split_once('.') {
None => t.set(*path, lua_value)?,
Some((head, leaf)) => {
let sub = t.get::<Option<Table>>(head)?.unwrap_or(lua.create_table()?);
sub.set(leaf, lua_value)?;
t.set(head, sub)?;
}
}
}
}
let th = lua.create_table()?;
for role in kui_core::schema::THEME_ROLES {
th.set(role.name, (role.get)(&theme).to_hex())?;
}
th.set("appearance", theme.appearance.name())?;
th.set("disabled_opacity", theme.disabled_opacity)?;
t.set("theme", th)?;
let mt = lua.create_table()?;
for role in kui_core::schema::METRIC_ROLES {
mt.set(role.name, (role.get)(&metrics))?;
}
t.set("metrics", mt)?;
t.set(
"edit_text",
scope.create_function(move |_, key: mlua::Value| {
let mut ui = ui.borrow_mut();
let Some(key) = key_query(&mut ui, key)? else {
return Ok(None);
};
Ok(ui.edit_text(key))
})?,
)?;
t.set(
"set_edit_text",
scope.create_function(move |_, (key, text): (mlua::Value, String)| {
let mut ui = ui.borrow_mut();
match key {
mlua::Value::String(label) => {
ui.set_edit_text_by_label(&label.to_str()?, &text);
}
other => {
let key = key_arg(&mut ui, other)?;
ui.set_edit_text(key, &text);
}
}
Ok(())
})?,
)?;
t.set(
"is_focused",
scope.create_function(move |_, key: mlua::Value| {
let mut ui = ui.borrow_mut();
let Some(key) = key_query(&mut ui, key)? else {
return Ok(false);
};
Ok(ui.is_focused(key))
})?,
)?;
t.set(
"is_hovered",
scope.create_function(move |_, key: mlua::Value| {
let mut ui = ui.borrow_mut();
let Some(key) = key_query(&mut ui, key)? else {
return Ok(false);
};
Ok(ui.is_hovered(key))
})?,
)?;
t.set(
"is_pressed",
scope.create_function(move |_, key: mlua::Value| {
let mut ui = ui.borrow_mut();
let Some(key) = key_query(&mut ui, key)? else {
return Ok(false);
};
Ok(ui.is_pressed(key))
})?,
)?;
t.set(
"is_drop_target",
scope.create_function(move |_, key: mlua::Value| {
let mut ui = ui.borrow_mut();
let Some(key) = key_query(&mut ui, key)? else {
return Ok(false);
};
Ok(ui.is_drop_target(key))
})?,
)?;
t.set(
"drop_target",
scope.create_function(move |_, ()| {
let ui = ui.borrow();
Ok(ui.drop_target().map(|k| k.0 as i64))
})?,
)?;
t.set(
"set_focus",
scope.create_function(move |_, key: mlua::Value| {
let mut ui = ui.borrow_mut();
let key = key_arg(&mut ui, key)?;
ui.focus(key);
Ok(())
})?,
)?;
t.set(
"blur",
scope.create_function(move |_, ()| {
ui.borrow_mut().blur();
Ok(())
})?,
)?;
t.set(
"focus_next",
scope.create_function(move |_, ()| {
ui.borrow_mut().focus_next();
Ok(())
})?,
)?;
t.set(
"focus_prev",
scope.create_function(move |_, ()| {
ui.borrow_mut().focus_prev();
Ok(())
})?,
)?;
t.set(
"focus_region",
scope.create_function(move |_, key: mlua::Value| {
let mut ui = ui.borrow_mut();
let key = match key {
mlua::Value::Nil => None,
v => Some(key_arg(&mut ui, v)?),
};
ui.focus_region(key);
Ok(())
})?,
)?;
t.set(
"announce",
scope.create_function(move |_, (text, live): (String, Option<String>)| {
let live = live.unwrap_or_else(|| "polite".to_string());
let Some(i) = schema::LIVE.iter().position(|v| *v == live) else {
return Err(mlua::Error::runtime(format!(
"bad politeness {live:?} (one of {})",
schema::LIVE.join(" | ")
)));
};
ui.borrow_mut()
.announce(&text, kui_core::Live::from_index(i));
Ok(())
})?,
)?;
t.set(
"reveal",
scope.create_function(move |_, key: mlua::Value| {
let mut ui = ui.borrow_mut();
match key {
mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
ui.reveal_label(&s.to_str()?);
}
key => {
let key = key_arg(&mut ui, key)?;
ui.reveal(key);
}
}
Ok(())
})?,
)?;
t.set(
"scroll_offset",
scope.create_function(move |lua, key: mlua::Value| {
let mut ui = ui.borrow_mut();
let off = match key_query(&mut ui, key)? {
Some(key) => ui.scroll_offset(key),
None => kui_core::Vec2::ZERO,
};
let r = lua.create_table()?;
r.set("x", off.x)?;
r.set("y", off.y)?;
Ok(r)
})?,
)?;
t.set(
"text_hit",
scope.create_function(move |lua, (key, x, y): (mlua::Value, f32, f32)| {
let mut ui = ui.borrow_mut();
let Some(key) = key_query(&mut ui, key)? else {
return Ok(mlua::Value::Nil);
};
let Some(h) = ui.text_hit(key, kui_core::Vec2::new(x, y)) else {
return Ok(mlua::Value::Nil);
};
value_to_lua(lua, &h.to_value())
})?,
)?;
t.set(
"open_menu",
scope.create_function(
move |_, (key, x, y, items): (mlua::Value, f32, f32, mlua::Table)| {
let mut ui = ui.borrow_mut();
let Some(target) = key_query(&mut ui, key)? else {
return Ok(false);
};
let items = menu_items(&items)?;
ui.open_menu(kui_core::Menu::new(
target,
kui_core::Vec2::new(x, y),
items,
));
Ok(true)
},
)?,
)?;
t.set(
"close_menu",
scope.create_function(move |_, ()| {
let mut ui = ui.borrow_mut();
Ok(ui.close_menu())
})?,
)?;
t.set(
"selection_text",
scope.create_function(move |_, ()| {
let ui = ui.borrow();
Ok(ui.selection_text())
})?,
)?;
t.set(
"request_copy",
scope.create_function(move |_, ()| {
let mut ui = ui.borrow_mut();
Ok(match ui.request_copy() {
kui_core::CopyRequest::Ready(text) => (Some(text), false),
kui_core::CopyRequest::Asked => (None, true),
kui_core::CopyRequest::Nothing => (None, false),
})
})?,
)?;
t.set(
"answer_selection_range",
scope.create_function(move |_, text: String| {
let mut ui = ui.borrow_mut();
Ok(ui.answer_selection_range(&text))
})?,
)?;
t.set(
"set_clipboard",
scope.create_function(move |_, (text, html): (String, Option<String>)| {
ui.borrow_mut().set_clipboard(text, html);
Ok(())
})?,
)?;
t.set(
"set_clipboard_secret",
scope.create_function(move |_, text: String| {
ui.borrow_mut().set_clipboard_secret(text);
Ok(())
})?,
)?;
t.set(
"request_paste",
scope.create_function(move |_, ()| {
ui.borrow_mut().request_paste();
Ok(())
})?,
)?;
t.set(
"awaiting_paste",
scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_paste()))?,
)?;
t.set(
"request_files",
scope.create_function(move |_, opts: Option<mlua::Value>| {
let v = match &opts {
Some(o) => lua_to_value(o)?,
None => Value::Null,
};
let dialog = kui_core::FileDialog::from_value(&v)
.map_err(|e| mlua::Error::runtime(format!("request_files: {e}")))?;
Ok(ui.borrow_mut().request_files(dialog))
})?,
)?;
t.set(
"awaiting_files",
scope.create_function(move |_, ()| Ok(ui.borrow().awaiting_files()))?,
)?;
t.set(
"selection_ends",
scope.create_function(move |lua, ()| {
let ui = ui.borrow();
let Some((a, f)) = ui.selection_ends() else {
return Ok(mlua::Value::Nil);
};
let end = |e: kui_core::RangeEnd| -> mlua::Result<mlua::Table> {
let t = lua.create_table()?;
if let Some(r) = e.row {
t.set("index", r)?;
}
t.set("byte", e.byte)?;
Ok(t)
};
let out = lua.create_table()?;
out.set("anchor", end(a)?)?;
out.set("focus", end(f)?)?;
Ok(mlua::Value::Table(out))
})?,
)?;
t.set(
"cell_selection",
scope.create_function(move |lua, ()| {
let ui = ui.borrow();
match ui.cell_selection() {
Some(sel) => value_to_lua(lua, &sel.to_value(kui_core::Handles::INT)),
None => Ok(mlua::Value::Nil),
}
})?,
)?;
t.set(
"selection_html",
scope.create_function(move |_, ()| {
let ui = ui.borrow();
Ok(ui.selection_html())
})?,
)?;
t.set(
"select_all_in",
scope.create_function(move |_, key: mlua::Value| {
let mut ui = ui.borrow_mut();
let Some(key) = key_query(&mut ui, key)? else {
return Ok(false);
};
Ok(ui.select_all_in(key))
})?,
)?;
t.set(
"clear_selection",
scope.create_function(move |_, ()| {
let mut ui = ui.borrow_mut();
Ok(ui.clear_selection())
})?,
)?;
t.set(
"caret_rect",
scope.create_function(move |lua, (key, byte): (mlua::Value, usize)| {
let mut ui = ui.borrow_mut();
let Some(key) = key_query(&mut ui, key)? else {
return Ok(mlua::Value::Nil);
};
let Some(r) = ui.caret_rect(key, byte) else {
return Ok(mlua::Value::Nil);
};
value_to_lua(lua, &r.to_value())
})?,
)?;
t.set(
"scroll_geometry",
scope.create_function(move |lua, key: mlua::Value| {
let mut ui = ui.borrow_mut();
let Some(key) = key_query(&mut ui, key)? else {
return Ok(mlua::Value::Nil);
};
let Some(g) = ui.scroll_geometry(key) else {
return Ok(mlua::Value::Nil);
};
value_to_lua(lua, &g.to_value())
})?,
)?;
t.set(
"layout_of",
scope.create_function(move |lua, key: mlua::Value| {
let mut ui = ui.borrow_mut();
let Some(key) = key_query(&mut ui, key)? else {
return Ok(mlua::Value::Nil);
};
let Some(r) = ui.layout_of(key) else {
return Ok(mlua::Value::Nil);
};
value_to_lua(lua, &r.to_value())
})?,
)?;
t.set(
"set_scroll",
scope.create_function(move |_, (key, x, y): (mlua::Value, f32, f32)| {
let mut ui = ui.borrow_mut();
let at = kui_core::Vec2::new(x, y);
match key {
mlua::Value::String(s) if ui.key_of(&s.to_str()?).is_none() => {
ui.set_scroll_label(&s.to_str()?, at);
}
key => {
let key = key_arg(&mut ui, key)?;
ui.set_scroll(key, at);
}
}
Ok(())
})?,
)?;
t.set(
"shift_scroll",
scope.create_function(move |_, (key, drawn, target): (mlua::Value, f32, f32)| {
let mut ui = ui.borrow_mut();
if let Some(key) = key_query(&mut ui, key)? {
ui.shift_scroll(
key,
kui_core::Vec2::new(0.0, drawn),
kui_core::Vec2::new(0.0, target),
);
}
Ok(())
})?,
)?;
t.set(
"set_window_size",
scope.create_function(move |_, (window, w, h): (i64, f32, f32)| {
ui.borrow_mut()
.set_window_size(kui_core::WindowId(window as u32), kui_core::Size::new(w, h));
Ok(())
})?,
)?;
let env = t.clone();
t.set(
"set_tokens",
scope.create_function(move |lua, decl: Table| {
let tokens = parse_tokens(&decl)?;
ui.borrow_mut().set_tokens(tokens);
env.set("tokens", tokens_table(lua, &ui.borrow())?)
})?,
)?;
t.set(
"focus_window",
scope.create_function(move |_, window: i64| {
ui.borrow_mut()
.focus_window(kui_core::WindowId(window as u32));
Ok(())
})?,
)?;
t.set(
"measure_text",
scope.create_function(
move |lua, (s, opts, max_w): (mlua::Value, Option<Table>, Option<f32>)| {
let mut guard = ui.borrow_mut();
let m = measure_from_lua(&mut guard, &s, opts.as_ref(), max_w)?;
value_to_lua(lua, &m.to_value())
},
)?,
)?;
t.set(
"add_extension",
scope.create_function(move |_, (namespace, path): (String, String)| {
let path = std::path::PathBuf::from(path);
if let Some(prev) = loaded.borrow().iter().find(|l| l.namespace == namespace) {
return Ok(if prev.path == path {
(Some(true), None)
} else {
(
None,
Some(format!(
"`{namespace}` is already {} in this script; a second plugin needs a \
second namespace",
prev.path.display()
)),
)
});
}
let ext = match unsafe { kui_ffi::CExtension::open(&path) } {
Ok(ext) => ext,
Err(e) => return Ok((None, Some(e))),
};
let origin = match ui.borrow_mut().add_extension(&namespace, Box::new(ext)) {
Ok(origin) => origin,
Err(e) => return Ok((None, Some(e))),
};
loaded.borrow_mut().push(Loaded {
namespace,
path,
origin,
});
Ok((Some(true), None))
})?,
)?;
t.set(
"extension_namespaces",
scope.create_function(move |lua, ()| {
lua.create_sequence_from(loaded.borrow().iter().map(|l| l.namespace.clone()))
})?,
)?;
Ok(t)
}
fn tokens_table(lua: &Lua, ui: &Ui<'_>) -> mlua::Result<Table> {
let look = ui.tokens();
let tokens = lua.create_table()?;
let colors = lua.create_table()?;
for (name, c) in look.colors() {
colors.set(name, c.to_hex())?;
}
let lengths = lua.create_table()?;
for (name, v) in look.lengths() {
lengths.set(name, v)?;
}
tokens.set("colors", colors)?;
tokens.set("lengths", lengths)?;
Ok(tokens)
}
fn measure_from_lua(
ui: &mut Ui<'_>,
s: &mlua::Value,
opts: Option<&Table>,
max_w: Option<f32>,
) -> mlua::Result<kui_core::TextMetrics> {
let style = match opts {
Some(t) => with_refs(ui, |refs| parse_props(t, false, refs))?.style,
None => kui_core::TextStyle::default(),
};
let measure_spans = |ui: &mut Ui<'_>, spans: &Table, style: &kui_core::TextStyle| {
let parts = with_refs(ui, |refs| collect_spans(spans, refs))?;
let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
Ok(ui.measure_rich_text(&spans, style, max_w))
};
match s {
mlua::Value::String(s) => Ok(ui.measure_text(&s.to_str()?, &style, max_w)),
mlua::Value::Table(t) => {
if t.get::<Option<String>>("type")?.as_deref() == Some("text") {
let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
match t.get::<Option<Table>>("spans")? {
Some(spans) => measure_spans(ui, &spans, &style),
None => {
let value: String = t.get("value")?;
Ok(ui.measure_text(&value, &style, max_w))
}
}
} else {
measure_spans(ui, t, &style)
}
}
other => Err(bad(format!(
"measure_text: expected a string, a span list or a text node, got {}",
other.type_name()
))),
}
}
fn bad(msg: impl std::fmt::Display) -> mlua::Error {
mlua::Error::runtime(msg.to_string())
}
fn build_children(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
for child in t.sequence_values::<Table>() {
build_node(ui, &child?)?;
}
Ok(())
}
fn with_children(
ui: &mut Ui<'_>,
t: &Table,
widget: impl FnOnce(&mut Ui<'_>, &mut dyn FnMut(&mut Ui<'_>)),
) -> mlua::Result<()> {
let mut result = Ok(());
widget(ui, &mut |ui| result = build_children(ui, t));
result
}
fn element_of(ty: &str) -> &str {
match ty {
"row" | "column" => "box",
"grid" => "table",
"input" => "edit",
"dropdown" => "select",
"radio_group" => "radioGroup",
"window_buttons" => "windowButtons",
"menu_bar" => "menuBar",
"latency_graph" | "latency_hud" => "latencyGraph",
other => other,
}
}
fn menu_bar_of(t: &Table) -> mlua::Result<kui_core::MenuBar> {
let Some(list) = t.get::<Option<Table>>("menu")? else {
return Ok(kui_core::MenuBar::default());
};
let mut menus = lua_list_to_value(&list)?;
if let Value::List(menus) = &mut menus {
for menu in menus.iter_mut() {
let Value::Map(fields) = menu else { continue };
for (k, items) in fields.iter_mut() {
if k != "items" {
continue;
}
if matches!(items, Value::Map(m) if m.is_empty()) {
*items = Value::List(Vec::new());
}
if let Value::List(rows) = items {
rows.iter_mut().for_each(alias_menu_role);
}
}
}
}
kui_core::MenuBar::from_value(&menus).map_err(mlua::Error::runtime)
}
fn declare_windows(ui: &mut Ui<'_>, root: &Table) -> mlua::Result<()> {
let Some(list) = root.get::<Option<Table>>("windows")? else {
return Ok(());
};
for entry in list.sequence_values::<mlua::Value>() {
let v = lua_to_value(&entry?)?;
let (name, cfg) = WindowConfig::from_value(&v).map_err(mlua::Error::runtime)?;
ui.window(&name, cfg);
}
Ok(())
}
fn check_props(ui: &mut Ui<'_>, t: &Table, element: &str) -> mlua::Result<()> {
if !ui.core().diagnostics() {
return Ok(());
}
for pair in t.pairs::<mlua::Value, mlua::Value>() {
let (k, _) = pair?;
let mlua::Value::String(k) = k else { continue };
let name = k.to_str()?;
if name.as_ref() == "type" || schema::known_prop(element, &name, schema::Spelling::Snake) {
continue;
}
let w = kui_core::diag::unknown_prop(element, &name, schema::Spelling::Snake);
ui.core().warn(w);
}
Ok(())
}
fn build_fill(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
let name: String = t
.get::<Option<String>>("name")?
.filter(|n| !n.is_empty())
.ok_or_else(|| bad("fill needs a name (\"namespace/slot\")"))?;
if !name.contains(kui_core::NAMESPACE_SEPARATOR) {
return Err(bad(format!(
"bad slot name {name:?} (a full \"namespace/slot\")"
)));
}
for pair in t.pairs::<mlua::Value, mlua::Value>() {
let (k, _) = pair?;
let mlua::Value::String(k) = k else { continue };
let k = k.to_str()?;
if !matches!(k.as_ref(), "type" | "name" | "params") {
return Err(bad(format!(
"fill takes name and params, not {:?} — it is a position, not a box",
k.as_ref()
)));
}
}
let params = match t.get::<mlua::Value>("params")? {
mlua::Value::Nil => Value::Null,
v => lua_to_value(&v)?,
};
ui.slot_with(&name, ¶ms);
Ok(())
}
fn build_devtools_tab(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
let name: String = t.get::<Option<String>>("name")?.unwrap_or_default();
let refuse = |ui: &mut Ui<'_>, why: &str| {
ui.core().warn(kui_core::diag::bad_devtools_tab(&name, why));
Ok(())
};
if name.is_empty() {
return refuse(ui, "it needs a name (its identity)");
}
let label: String = t
.get::<Option<String>>("label")?
.unwrap_or_else(|| name.clone());
for pair in t.pairs::<mlua::Value, mlua::Value>() {
let (k, _) = pair?;
let mlua::Value::String(k) = k else { continue };
let k = k.to_str()?;
if !matches!(k.as_ref(), "type" | "name" | "label" | "slot" | "view") {
return refuse(
ui,
&format!(
"it takes name, label and slot or view, not {:?}",
k.as_ref()
),
);
}
}
let slot = t.get::<Option<String>>("slot")?;
let view = t.get::<mlua::Value>("view")?;
match (slot, view) {
(Some(slot), mlua::Value::Nil) => {
if !slot.contains(kui_core::NAMESPACE_SEPARATOR) {
return refuse(
ui,
&format!("bad slot {slot:?} (a full \"namespace/slot\")"),
);
}
ui.devtools_tab(&name, &label, &slot);
Ok(())
}
(None, mlua::Value::Function(view)) => {
let mut result = Ok(());
ui.devtools_tab_with(&name, &label, |ui| {
result = view
.call::<Table>(())
.and_then(|tree| build_node(ui, &tree));
});
result
}
(Some(_), mlua::Value::Function(_)) => refuse(ui, "it takes a slot or a view, not both"),
(None, mlua::Value::Nil) => refuse(ui, "it needs a slot or a view function"),
(_, other) => refuse(
ui,
&format!("view is a {}, not a function", other.type_name()),
),
}
}
pub const MAX_VIEW_DEPTH: usize = 128;
thread_local! {
static VIEW_PATH: std::cell::RefCell<Vec<*const std::ffi::c_void>> =
const { std::cell::RefCell::new(Vec::new()) };
}
fn build_node(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
let at = t.to_pointer();
VIEW_PATH.with_borrow_mut(|path| {
if path.contains(&at) {
return Err(bad("a view node that holds itself"));
}
if path.len() >= MAX_VIEW_DEPTH {
return Err(bad(format!("a view nested past {MAX_VIEW_DEPTH} nodes")));
}
path.push(at);
Ok(())
})?;
let built = build_one(ui, t);
VIEW_PATH.with_borrow_mut(|path| path.pop());
built
}
fn build_one(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
let ty: String = t.get("type")?;
match ty.as_str() {
"fill" => build_fill(ui, t),
"devtools_tab" => build_devtools_tab(ui, t),
"row" | "column" | "grid" => {
open_box(ui, t, &ty)?;
build_children(ui, t)?;
ui.close();
Ok(())
}
"fragment" => {
open_fragment(ui, t)?;
build_children(ui, t)?;
ui.close();
Ok(())
}
"titlebar" | "tooltip" | "radio_group" => build_holder(ui, t, &ty),
_ => build_widget(ui, t, &ty),
}
}
#[inline(never)]
fn open_box(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
check_props(ui, t, element_of(ty))?;
let mut p = with_refs(ui, |refs| parse_props(t, ty == "row", refs))?;
p.spec.layout.table = ty == "grid";
ui.core().open_from(p, Content::Box);
Ok(())
}
#[inline(never)]
fn open_fragment(ui: &mut Ui<'_>, t: &Table) -> mlua::Result<()> {
check_props(ui, t, element_of("fragment"))?;
let id: i64 = t.get("id")?;
let image: Option<i64> = t.get("image")?;
let params: Vec<f32> = match t.get::<Option<Table>>("params")? {
Some(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
None => Vec::new(),
};
let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
let frag = kui_core::FragmentRef {
id: kui_core::FragmentId::from_ffi(id as u64),
image: image
.filter(|i| *i != 0)
.map(|i| kui_core::ImageId::from_ffi(i as u64)),
};
ui.core().open_from(p, Content::Fragment(frag, ¶ms));
Ok(())
}
#[inline(never)]
fn build_holder(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
check_props(ui, t, element_of(ty))?;
match ty {
"titlebar" => {
if t.raw_len() > 0 {
with_children(ui, t, |ui, body| widgets::titlebar_with(ui, body))
} else {
let title: String = t.get::<Option<String>>("title")?.unwrap_or_default();
widgets::titlebar(ui, &title);
Ok(())
}
}
"tooltip" => {
if t.raw_len() > 0 {
with_children(ui, t, |ui, body| widgets::tooltip_with(ui, body))
} else {
let value: String = t.get("value")?;
widgets::tooltip(ui, &value);
Ok(())
}
}
"radio_group" => {
let label: String = t.get("label")?;
let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
let mut result = Ok(());
widgets::radio_group_with(ui, &label, p.spec, |ui| result = build_children(ui, t));
result
}
other => unreachable!("{other} holds no children"),
}
}
#[inline(never)]
fn build_widget(ui: &mut Ui<'_>, t: &Table, ty: &str) -> mlua::Result<()> {
check_props(ui, t, element_of(ty))?;
match ty {
"text" => {
let style = with_refs(ui, |refs| parse_props(t, false, refs))?.style;
if let Some(spans) = t.get::<Option<Table>>("spans")? {
let parts = with_refs(ui, |refs| collect_spans(&spans, refs))?;
let spans: Vec<Span<'_>> = parts.iter().map(span_of).collect();
ui.rich_text(&spans, style);
} else {
let value: String = t.get("value")?;
ui.text(&value, style);
}
Ok(())
}
"image" => {
let id: i64 = t.get("id")?;
let spec = with_refs(ui, |refs| parse_props(t, false, refs))?
.for_leaf()
.spec;
let named = |row: &str, names: &[&str]| -> mlua::Result<usize> {
match t.get::<Option<String>>(row)? {
None => Ok(0),
Some(s) => names
.iter()
.position(|n| *n == s)
.ok_or_else(|| bad(format!("{row} must be one of {}", names.join(", ")))),
}
};
let sampling_names: Vec<&str> =
kui_core::Sampling::ALL.iter().map(|s| s.name()).collect();
let fit_names: Vec<&str> = kui_core::ImageFit::ALL.iter().map(|f| f.name()).collect();
let opts = kui_core::ImageOpts {
sampling: kui_core::Sampling::ALL[named("sampling", &sampling_names)?],
fit: kui_core::ImageFit::ALL[named("fit", &fit_names)?],
};
ui.image_with(kui_core::ImageId::from_ffi(id as u64), opts, spec);
Ok(())
}
"polygon" => {
let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
let Some(list) = t.get::<Option<Table>>("points")? else {
return Err(bad("polygon needs points"));
};
let points: Vec<kui_core::Vec2> = list
.sequence_values::<mlua::Value>()
.map(|v| {
let mlua::Value::Table(pt) = v? else {
return Err(bad("a polygon point is a {x, y} table"));
};
Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
})
.collect::<mlua::Result<_>>()?;
ui.core().open_from(p, Content::Polygon(&points));
Ok(())
}
"path" => {
let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
let rule = match t.get::<Option<String>>("fill_rule")? {
Some(name) => kui_core::FillRule::parse(&name)
.ok_or_else(|| bad("fill_rule is \"nonzero\" or \"evenodd\""))?,
None => kui_core::FillRule::NonZero,
};
let width = match t.get::<mlua::Value>("width")? {
mlua::Value::Nil => None,
v => with_refs(ui, |refs| length_of(&v, refs))?,
};
let dash = dash_of(t)?;
let stroke = width.filter(|w| *w > 0.0).map(|w| {
kui_core::Stroke::new(w, p.style.color.unwrap_or(ui.theme().fg)).dashed(dash)
});
let turns = t.get::<Option<f32>>("rotate")?;
let pivot = match t.get::<Option<Table>>("pivot")? {
Some(p) => Some(kui_core::Vec2::new(p.get::<f32>(1)?, p.get::<f32>(2)?)),
None => None,
};
let turn = (turns.is_some() || pivot.is_some()).then_some(kui_core::Turn {
turns: turns.unwrap_or(0.0),
pivot,
});
if let Some(d) = t.get::<Option<String>>("d")? {
ui.core()
.open_from(p, Content::PathD(&d, rule, stroke, turn));
} else if let Some(list) = t.get::<Option<Table>>("ops")? {
let floats: Vec<f32> =
list.sequence_values::<f32>().collect::<mlua::Result<_>>()?;
ui.core()
.open_from(p, Content::PathFlat(&floats, rule, stroke, turn));
} else {
return Err(bad("path needs d or ops"));
}
Ok(())
}
"line" => {
let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
let point = |v: mlua::Value| -> mlua::Result<kui_core::Vec2> {
let mlua::Value::Table(pt) = v else {
return Err(bad("a line point is a {x, y} table"));
};
Ok(kui_core::Vec2::new(pt.get(1)?, pt.get(2)?))
};
let points: Vec<kui_core::Vec2> = match t.get::<Option<Table>>("points")? {
Some(list) => list
.sequence_values::<mlua::Value>()
.map(|v| point(v?))
.collect::<mlua::Result<_>>()?,
None => {
let (Some(from), Some(to)) = (
t.get::<Option<mlua::Value>>("from")?,
t.get::<Option<mlua::Value>>("to")?,
) else {
return Err(bad("line needs from and to, or points"));
};
vec![point(from)?, point(to)?]
}
};
let width = match t.get::<mlua::Value>("width")? {
mlua::Value::Nil => None,
v => with_refs(ui, |refs| length_of(&v, refs))?,
}
.unwrap_or(1.0);
let stroke_color = p.style.color.unwrap_or(ui.theme().fg);
let mut stroke = kui_core::Stroke::new(width, stroke_color);
stroke.curve = t.get::<Option<bool>>("curve")?.unwrap_or(false);
stroke.dash = dash_of(t)?;
ui.core().open_from(p, Content::Line(&points, stroke));
Ok(())
}
"cells" => {
let p = with_refs(ui, |refs| parse_props(t, false, refs))?;
let rows: usize = t.get::<Option<usize>>("rows")?.unwrap_or(0);
let cols: usize = t.get::<Option<usize>>("cols")?.unwrap_or(0);
if rows == 0 || cols == 0 {
return Err(bad("cells needs rows and cols"));
}
let default_fg = p.style.color.unwrap_or(ui.theme().fg).to_hex();
let mut cells = vec![kui_core::Cell::new(' ', default_fg, 0); rows * cols];
if let Some(lines) = t.get::<Option<Table>>("lines")? {
for (r, line) in lines.sequence_values::<String>().enumerate() {
if r >= rows {
break;
}
for (c, ch) in line?.chars().take(cols).enumerate() {
cells[r * cols + c].ch = ch;
}
}
}
if let Some(runs) = t.get::<Option<Table>>("runs")? {
for run in runs.sequence_values::<Table>() {
let run = run?;
let row: usize = run.get(1)?;
let col: usize = run.get(2)?;
let len: usize = run.get(3)?;
let fg: u32 = run.get::<Option<u32>>(4)?.unwrap_or(0);
let bg: u32 = run.get::<Option<u32>>(5)?.unwrap_or(0);
let flags: u8 = run.get::<Option<u8>>(6)?.unwrap_or(0);
let ul: u32 = run.get::<Option<u32>>(7)?.unwrap_or(0);
if row >= rows {
continue;
}
for c in col..(col + len).min(cols) {
let cell = &mut cells[row * cols + c];
if fg != 0 {
cell.fg = fg;
}
if bg != 0 {
cell.bg = bg;
}
cell.flags |= flags;
if ul != 0 {
cell.ul = ul;
}
}
}
}
let cursor = match t.get::<Option<Table>>("cursor_at")? {
Some(cur) => {
let shape = match t.get::<Option<String>>("cursor_shape")? {
None => kui_core::CellCursor::Block,
Some(s) => kui_core::CellCursor::from_name(&s).ok_or_else(|| {
bad(format!(
"cursor_shape must be {}, not {s:?}",
kui_core::CellCursor::NAMES.join(" | ")
))
})?,
};
let color = match t.get::<mlua::Value>("cursor_color")? {
mlua::Value::Nil => None,
v => with_refs(ui, |refs| parse_color(&v, refs))?,
}
.unwrap_or(Color::rgb8(0xff, 0xff, 0xff));
Some((cur.get::<usize>(1)?, cur.get::<usize>(2)?, shape, color))
}
None => None,
};
let origin_line = t.get::<Option<u64>>("origin_line")?.unwrap_or(0);
let grid = kui_core::CellGrid {
rows,
cols,
cells: &cells,
style: p.style,
cursor,
origin_line,
};
ui.core().open_from(p, Content::Cells(&grid));
Ok(())
}
"audio" => {
let src: i64 = t.get("src")?;
let mut spec = kui_core::AudioSpec::new(kui_core::SoundId::from_ffi(src as u64));
if let Some(v) = t.get::<Option<f32>>("volume")? {
spec = spec.volume(v);
}
if t.get::<Option<bool>>("loop")?.unwrap_or(false) {
spec = spec.looped();
}
spec = spec.paused(t.get::<Option<bool>>("paused")?.unwrap_or(false));
if t.get::<Option<bool>>("finish")?.unwrap_or(false) {
spec = spec.finish();
}
if let Some(tag) = t.get::<Option<mlua::Value>>("tag")? {
spec.tag = Some(lua_to_value(&tag)?);
}
match t.get::<Option<String>>("key")? {
Some(k) => ui.audio_keyed(&k, spec),
None => ui.audio(spec),
};
Ok(())
}
"input" => {
let label: String = t.get("label")?;
let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
widgets::text_input(ui, &label, &initial);
Ok(())
}
"dropdown" => {
let Some(label) = t.get::<Option<String>>("label")?.filter(|l| !l.is_empty()) else {
return Err(bad("dropdown needs a label (its key and accessible name)"));
};
let Some(options) = t.get::<Option<Table>>("options")? else {
return Err(bad(
"dropdown needs options, a list of strings or menu rows",
));
};
let mut rows = lua_list_to_value(&options)?;
if let Value::List(rows) = &mut rows {
rows.iter_mut().for_each(alias_menu_role);
if ui.core().diagnostics() {
for row in rows.iter() {
let Value::Map(fields) = row else { continue };
for (k, _) in fields.iter() {
if !kui_core::MenuItem::KEYS.contains(&k.as_str()) {
ui.core().warn(kui_core::diag::unknown_menu_item_key(k));
}
}
}
}
}
let items =
kui_core::MenuItem::options_from_value(&rows).map_err(mlua::Error::runtime)?;
let current = match t.get::<Option<i64>>("current")? {
None => None,
Some(i) if i >= 1 => Some(i as usize - 1),
Some(i) => {
return Err(bad(format!("dropdown current is an index from 1, not {i}")));
}
};
widgets::select_items(ui, &label, &items, current);
Ok(())
}
"edit" => {
let p = with_refs(ui, |refs| parse_props(t, false, refs))?.for_leaf();
let label = match p.key.clone().or(t.get::<Option<String>>("label")?) {
Some(l) => l,
None => return Err(bad("edit needs a key (state is retained by key)")),
};
let initial: String = t.get::<Option<String>>("initial")?.unwrap_or_default();
let opts = EditOptions {
style: p.style,
multiline: t.get::<Option<bool>>("multiline")?.unwrap_or(false),
autofocus: t.get::<Option<bool>>("autofocus")?.unwrap_or(false),
wrap: p.wrap,
..Default::default()
};
ui.text_edit(&label, &initial, &opts, p.spec);
Ok(())
}
"window_buttons" => {
widgets::window_buttons(ui);
Ok(())
}
"menu_bar" => {
widgets::menu_bar(ui, menu_bar_of(t)?);
Ok(())
}
"latency_graph" => {
widgets::latency_graph(ui);
Ok(())
}
"latency_hud" => {
let (mut x, mut y) = (Align::End, Align::End);
if let Some(at) = t.get::<Option<Table>>("at")? {
x = parse_align(&at.get::<String>(1)?)?;
y = parse_align(&at.get::<String>(2)?)?;
}
widgets::latency_hud_at(ui, x, y);
Ok(())
}
"button" => {
let label: String = t.get("label")?;
let text: String = t
.get::<Option<String>>("text")?
.unwrap_or_else(|| label.clone());
let key: String = t
.get::<Option<String>>("key")?
.unwrap_or_else(|| label.clone());
let payload = match t.get::<Option<mlua::Value>>("on_click")? {
Some(v) => lua_to_value(&v)?,
None => Value::Null,
};
let mut out = PropsOut::new();
out.spec = widgets::button_spec(&ui.theme(), &ui.metrics())
.on_click(payload)
.label(label.as_str());
if let Some(hint) = t.get::<Option<String>>("tooltip")? {
out.apply_tooltip(&hint);
}
with_refs(ui, |refs| {
for name in ["description", "disabled", "accent"] {
let v = t.get::<mlua::Value>(name)?;
if v.is_nil() {
continue;
}
let def = schema::by_snake_name(name).expect("a button row");
if let Some(parsed) =
parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
{
schema::apply(def, parsed, &mut out).map_err(bad)?;
}
}
Ok(())
})?;
match t.get::<Option<u64>>("index")? {
Some(i) => widgets::button_indexed(ui, i, &text, out.spec, out.tooltip.as_deref()),
None => widgets::button_with(ui, &key, &text, out.spec, out.tooltip.as_deref()),
}
Ok(())
}
"checkbox" | "radio" | "switch" => {
let kind = match ty {
"checkbox" => widgets::Toggle::Checkbox,
"radio" => widgets::Toggle::Radio,
_ => widgets::Toggle::Switch,
};
let label: String = t.get("label")?;
let text: String = t
.get::<Option<String>>("text")?
.unwrap_or_else(|| label.clone());
let key: String = t
.get::<Option<String>>("key")?
.unwrap_or_else(|| label.clone());
let payload = match t.get::<Option<mlua::Value>>("on_click")? {
Some(v) => lua_to_value(&v)?,
None => Value::Null,
};
let mut out = PropsOut::new();
out.spec = widgets::toggle_spec(&ui.metrics())
.on_click(payload)
.label(label.as_str());
if let Some(hint) = t.get::<Option<String>>("tooltip")? {
out.apply_tooltip(&hint);
}
apply_named_rows(
ui,
t,
&["description", "disabled", "checked", "mixed"],
&mut out,
)?;
widgets::toggle_with(ui, kind, &key, &text, out.spec, out.tooltip.as_deref());
Ok(())
}
"slider" => {
let label: String = t.get("label")?;
let key: String = t
.get::<Option<String>>("key")?
.unwrap_or_else(|| label.clone());
let mut out = PropsOut::new();
out.spec = widgets::slider_spec(&ui.metrics()).label(label.as_str());
if let Some(hint) = t.get::<Option<String>>("tooltip")? {
out.apply_tooltip(&hint);
}
apply_named_rows(
ui,
t,
&[
"description",
"disabled",
"value_now",
"value_min",
"value_max",
"value_step",
"value_text",
"on_change",
"width",
"min_width",
"max_width",
],
&mut out,
)?;
widgets::slider_with(ui, &key, out.spec, out.tooltip.as_deref());
Ok(())
}
other => Err(mlua::Error::runtime(format!("unknown node type '{other}'"))),
}
}
fn apply_named_rows(
ui: &mut Ui<'_>,
t: &Table,
names: &[&str],
out: &mut PropsOut,
) -> mlua::Result<()> {
with_refs(ui, |refs| {
for name in names {
let v = t.get::<mlua::Value>(*name)?;
if v.is_nil() {
continue;
}
let def = schema::by_snake_name(name).expect("a schema row");
if let Some(parsed) =
parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
{
schema::apply(def, parsed, out).map_err(bad)?;
}
}
Ok(())
})
}
struct SpanPart {
text: String,
bold: bool,
italic: bool,
underline: bool,
underline_color: Option<Color>,
underline_style: Option<kui_core::UnderlineStyle>,
strikethrough: bool,
color: Option<Color>,
bg: Option<Color>,
bg_radius: f32,
family: Option<kui_core::FontFamily>,
size: Option<f32>,
}
fn span_of(p: &SpanPart) -> Span<'_> {
let mut s = Span::new(&p.text);
if p.bold {
s = s.bold();
}
if p.italic {
s = s.italic();
}
if p.underline {
s = s.underline();
}
if let Some(c) = p.underline_color {
s = s.underline_color(c);
}
if let Some(st) = p.underline_style {
s = s.underline_style(st);
}
if p.strikethrough {
s = s.strikethrough();
}
if let Some(c) = p.color {
s = s.color(c);
}
if let Some(c) = p.bg {
s = s.bg(c);
}
if p.bg_radius > 0.0 {
s = s.bg_radius(p.bg_radius);
}
if let Some(f) = p.family {
s = s.family(f);
}
if let Some(px) = p.size {
s = s.size(px);
}
s
}
fn collect_spans(spans: &Table, refs: &mut Refs<'_>) -> mlua::Result<Vec<SpanPart>> {
let mut out = Vec::new();
for item in spans.sequence_values::<mlua::Value>() {
match item? {
mlua::Value::String(s) => out.push(SpanPart {
text: s.to_str()?.to_string(),
bold: false,
italic: false,
underline: false,
underline_color: None,
underline_style: None,
strikethrough: false,
color: None,
bg: None,
bg_radius: 0.0,
family: None,
size: None,
}),
mlua::Value::Table(t) => {
let text: String = t
.get::<Option<String>>(1)?
.ok_or_else(|| bad("span table needs its text at [1]"))?;
let color = match t.get::<mlua::Value>("color")? {
mlua::Value::Nil => None,
v => parse_color(&v, refs)?,
};
let bg = match t.get::<mlua::Value>("bg")? {
mlua::Value::Nil => None,
v => parse_color(&v, refs)?,
};
let underline_color = match t.get::<mlua::Value>("underline_color")? {
mlua::Value::Nil => None,
v => parse_color(&v, refs)?,
};
let underline_style = match t.get::<Option<String>>("underline_style")? {
None => None,
Some(name) => Some(
kui_core::UnderlineStyle::NAMES
.iter()
.position(|n| *n == name)
.map(|i| kui_core::UnderlineStyle::from_index(i as u32))
.ok_or_else(|| {
bad(format!(
"underline_style must be one of {}, got {name:?}",
kui_core::UnderlineStyle::NAMES.join(" | ")
))
})?,
),
};
let family = match t.get::<mlua::Value>("font")? {
mlua::Value::Nil => match t.get::<mlua::Value>("family")? {
mlua::Value::Nil => None,
v => match parse_value(&Kind::Family, &v, refs)
.map_err(|e| bad(format!("span family: {e}")))?
{
Some(Parsed::Family(f)) => Some(f),
_ => None,
},
},
v => match parse_value(&Kind::Resource, &v, refs)
.map_err(|e| bad(format!("span font: {e}")))?
{
Some(Parsed::Resource(id)) => {
Some(kui_core::FontFamily::Custom(kui_core::FontId::from_ffi(id)))
}
_ => None,
},
};
out.push(SpanPart {
family,
size: t.get::<Option<f32>>("size")?,
text,
bold: t.get::<Option<bool>>("bold")?.unwrap_or(false),
italic: t.get::<Option<bool>>("italic")?.unwrap_or(false),
underline: t.get::<Option<bool>>("underline")?.unwrap_or(false),
underline_color,
underline_style,
strikethrough: t.get::<Option<bool>>("strikethrough")?.unwrap_or(false),
color,
bg,
bg_radius: t.get::<Option<f32>>("bg_radius")?.unwrap_or(0.0).max(0.0),
});
}
other => {
return Err(bad(format!(
"span must be a string or table, got {}",
other.type_name()
)));
}
}
}
Ok(out)
}
pub fn parse_tokens(t: &Table) -> mlua::Result<kui_core::Tokens> {
let mut out = kui_core::Tokens::new();
for pair in t.pairs::<String, mlua::Value>() {
let (k, _) = pair?;
if k != "colors" && k != "lengths" {
return Err(bad(format!("tokens: unknown key {k:?} (colors, lengths)")));
}
}
if let Some(colors) = t.get::<Option<Table>>("colors")? {
let mut entries: Vec<(String, mlua::Value)> = colors
.pairs::<String, mlua::Value>()
.collect::<mlua::Result<_>>()?;
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut derived: Vec<(String, String, Vec<kui_core::ColorOp>)> = Vec::new();
for (name, v) in entries {
out = match &v {
mlua::Value::Table(t) if t.contains_key("from")? => {
let (from, ops) = parse_recipe(&name, t)?;
derived.push((name, from, ops));
out
}
mlua::Value::Table(halves) => {
let half = |k: &str| -> mlua::Result<Color> {
match halves.get::<mlua::Value>(k)? {
mlua::Value::Nil => Err(bad(format!(
"tokens.colors.{name}: needs both light and dark"
))),
v => parse_color_value(&v),
}
};
out.color_themed(&name, half("light")?, half("dark")?)
}
v => out.color(
&name,
parse_color_value(v).map_err(|e| bad(format!("tokens.colors.{name}: {e}")))?,
),
};
}
while !derived.is_empty() {
let ready = derived.iter().position(|(_, from, ops)| {
let known = |s: &str| kui_core::tokens::is_role(s) || out.color_id(s).is_some();
known(from)
&& ops.iter().all(|op| match op {
kui_core::ColorOp::Mix(c, _) | kui_core::ColorOp::Readable(c, _) => {
known(c)
}
_ => true,
})
});
let (name, from, ops) = derived.remove(ready.unwrap_or(0));
out = out.derive(&name, &from, ops);
}
}
if let Some(lengths) = t.get::<Option<Table>>("lengths")? {
let mut entries: Vec<(String, mlua::Value)> = lengths
.pairs::<String, mlua::Value>()
.collect::<mlua::Result<_>>()?;
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (name, v) in entries {
let px = number(&v)
.ok_or_else(|| bad(format!("tokens.lengths.{name}: a length is a number")))?;
out = out.length(&name, px);
}
}
Ok(out)
}
fn parse_recipe(name: &str, t: &Table) -> mlua::Result<(String, Vec<kui_core::ColorOp>)> {
for pair in t.pairs::<String, mlua::Value>() {
let (k, _) = pair?;
if k != "from" && k != "ops" {
return Err(bad(format!(
"tokens.colors.{name}: unknown key {k:?} (from, ops)"
)));
}
}
let from = match t.get::<mlua::Value>("from")? {
mlua::Value::String(s) => s.to_str()?.to_string(),
_ => {
return Err(bad(format!(
"tokens.colors.{name}.from names a colour token or role"
)));
}
};
let ops = match t.get::<mlua::Value>("ops")? {
mlua::Value::Nil => Vec::new(),
mlua::Value::Table(list) => {
let tuples: Vec<Table> = match list.get::<mlua::Value>(1)? {
mlua::Value::String(_) => vec![list],
_ => list
.sequence_values::<Table>()
.collect::<mlua::Result<_>>()?,
};
tuples
.iter()
.map(|op| parse_color_op(name, op))
.collect::<mlua::Result<_>>()?
}
_ => {
return Err(bad(format!(
"tokens.colors.{name}.ops is a list of {{ verb, … }} tuples"
)));
}
};
Ok((from, ops))
}
fn parse_color_op(name: &str, op: &Table) -> mlua::Result<kui_core::ColorOp> {
let verb = match op.get::<mlua::Value>(1)? {
mlua::Value::String(s) => s.to_str()?.to_string(),
_ => {
return Err(bad(format!(
"tokens.colors.{name}.ops: an op starts with its verb"
)));
}
};
let Some(takes_color) = kui_core::ColorOp::takes_color(&verb) else {
return Err(bad(format!(
"tokens.colors.{name}.ops: unknown verb {verb:?} (lift, darken, raise, alpha, mix, readable)"
)));
};
let arity = if takes_color { 3 } else { 2 };
if op.raw_len() != arity {
return Err(bad(format!(
"tokens.colors.{name}.ops: {verb} takes {} — {{ \"{verb}\", {} }}",
if takes_color {
"a colour and a number"
} else {
"one number"
},
if takes_color { "token, t" } else { "t" }
)));
}
let color = if takes_color {
match op.get::<mlua::Value>(2)? {
mlua::Value::String(s) => Some(s.to_str()?.to_string()),
_ => {
return Err(bad(format!(
"tokens.colors.{name}.ops: {verb}'s colour is a token or role name"
)));
}
}
} else {
None
};
let n = number(&op.get::<mlua::Value>(arity as i64)?).ok_or_else(|| {
bad(format!(
"tokens.colors.{name}.ops: {verb}'s number is a number"
))
})?;
Ok(kui_core::ColorOp::parse(&verb, color.as_deref(), n).expect("checked above"))
}
pub type Refs<'a> = kui_core::NameRefs<'a>;
fn with_refs<R>(
ui: &mut Ui<'_>,
f: impl FnOnce(&mut Refs<'_>) -> mlua::Result<R>,
) -> mlua::Result<R> {
let (r, errors, families) = {
let mut refs = Refs::new(ui.core().token_lookup());
let r = f(&mut refs);
(r, refs.take_missed(), refs.take_missed_families())
};
for e in errors {
ui.core().warn_unknown_token(&e);
}
for name in families {
ui.core().warn_unknown_family(&name);
}
r
}
fn reference(v: &mlua::Value) -> mlua::Result<Option<String>> {
if let mlua::Value::String(s) = v
&& let Some(name) = kui_core::tokens::reference(&s.to_str()?)
{
return Ok(Some(name.to_string()));
}
Ok(None)
}
fn dash_of(t: &Table) -> mlua::Result<kui_core::Dash> {
let offset = t.get::<Option<f32>>("dash_offset")?.unwrap_or(0.0);
let lengths: Vec<f32> = match t.get::<mlua::Value>("dash")? {
mlua::Value::Nil => return Ok(kui_core::Dash::SOLID),
mlua::Value::Table(list) => list.sequence_values::<f32>().collect::<mlua::Result<_>>()?,
mlua::Value::Integer(n) => vec![n as f32],
mlua::Value::Number(n) => vec![n as f32],
_ => Vec::new(),
};
kui_core::Dash::of(&lengths)
.map(|d| d.offset(offset))
.ok_or_else(|| bad("dash is a length, {mark, gap} or {mark, gap, mark, gap}"))
}
fn length_of(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<f32>> {
if let Some(name) = reference(v)? {
return Ok(refs.length(&name));
}
number(v)
.map(Some)
.ok_or_else(|| bad("expected a number or a \"$token\""))
}
pub fn parse_props(t: &Table, is_row: bool, refs: &mut Refs<'_>) -> mlua::Result<PropsOut> {
let mut out = PropsOut::new();
if is_row {
out.spec = kui_core::NodeSpec::row();
}
match t.get::<mlua::Value>("size")? {
mlua::Value::Nil => {}
v => {
if let Some(size) = length_of(&v, refs)? {
out.style = kui_core::TextStyle::new(size);
}
}
}
match t.get::<mlua::Value>("radius")? {
mlua::Value::Nil => {}
v => {
if let Some(r) = length_of(&v, refs)? {
out.with_spec(|s| s.radius(r));
}
}
}
let mut overflow = 0;
for pair in t.pairs::<mlua::Value, mlua::Value>() {
let (k, v) = pair?;
let mlua::Value::String(k) = k else { continue };
let k = k.to_str()?;
match k.as_ref() {
"size" | "radius" => {}
"pad" => {
let pad = parse_pad(&v, refs)?;
out.apply_pad(pad);
}
"border" => {
let b = match v {
mlua::Value::Table(b) => b,
_ => return Err(bad("border must be a table {w=, color=}")),
};
let w = length_of(&b.get::<mlua::Value>("w")?, refs)?.unwrap_or(0.0);
let c = parse_color(&b.get::<mlua::Value>("color")?, refs)?
.unwrap_or(Color::TRANSPARENT);
out.with_spec(|s| s.border(w, c));
}
"clip" => overflow |= bit(&v, kui_core::OVERFLOW_CLIP),
"scroll_x" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_X),
"scroll" | "scroll_y" => overflow |= bit(&v, kui_core::OVERFLOW_SCROLL_Y),
"float" => {
let cfg = parse_float(&v)?;
out.with_spec(|s| s.float(cfg));
}
"key_focus" => out.key_focus = truthy(&v),
"key" => {
let mlua::Value::String(s) = &v else {
return Err(bad("key must be a string"));
};
out.key = Some(s.to_str()?.to_string());
}
"index" => {
let Some(i) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
return Err(bad("index must be a number (the row's data index)"));
};
out.index = Some(i.max(0.0) as u64);
}
"row_count" => {
let Some(n) = v.as_number().or_else(|| v.as_integer().map(|i| i as f64)) else {
return Err(bad(
"row_count must be a number (how many indexed rows the list has)",
));
};
out.row_count = Some(n.max(0.0) as u64);
}
"tooltip" => {
let mlua::Value::String(s) = &v else {
return Err(bad("tooltip must be a string"));
};
out.apply_tooltip(s.to_str()?.as_ref());
}
name => {
let name = schema::lua_alias(name).unwrap_or(name);
let Some(def) = schema::by_snake_name(name) else {
continue;
};
if let Some(parsed) =
parse_value(&def.kind, &v, refs).map_err(|e| bad(format!("{name}: {e}")))?
{
schema::apply(def, parsed, &mut out).map_err(bad)?;
}
}
}
}
out.with_spec(|s| s.overflow_bits(overflow));
Ok(out)
}
fn truthy(v: &mlua::Value) -> bool {
matches!(v, mlua::Value::Boolean(true))
}
fn bit(v: &mlua::Value, bit: u32) -> u32 {
if truthy(v) { bit } else { 0 }
}
fn number(v: &mlua::Value) -> Option<f32> {
match v {
mlua::Value::Number(n) => Some(*n as f32),
mlua::Value::Integer(n) => Some(*n as f32),
_ => None,
}
}
fn parse_value(kind: &Kind, v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Parsed>> {
Ok(Some(match kind {
Kind::F32 => match length_of(v, refs)? {
Some(px) => Parsed::F32(px),
None => return Ok(None),
},
Kind::Color => match parse_color(v, refs)? {
Some(c) => Parsed::Color(c),
None => return Ok(None),
},
Kind::Flag => {
if truthy(v) {
Parsed::Flag
} else {
return Ok(None);
}
}
Kind::Enum(names) => {
let mlua::Value::String(s) = v else {
return Err(bad(format!("expected one of {names:?}")));
};
Parsed::Enum(schema::enum_index(names, &s.to_str()?).map_err(bad)?)
}
Kind::Sizing => match parse_sizing(v, refs)? {
Some(s) => Parsed::Sizing(s),
None => return Ok(None),
},
Kind::Min | Kind::Max => Parsed::Bound(match v {
v if reference(v)?.is_some() => match length_of(v, refs)? {
Some(px) => kui_core::Bound::Px(px),
None => return Ok(None),
},
mlua::Value::String(s) => {
let s = s.to_str()?;
let b = if matches!(kind, Kind::Min) {
schema::min_str(&s)
} else {
schema::max_str(&s)
};
match kept(b)? {
Some(b) => b,
None => return Ok(None),
}
}
mlua::Value::Table(_) => match kept(kui_core::calc::bound_value(&size_value(v)?))? {
Some(b) => b,
None => return Ok(None),
},
v => kui_core::Bound::Px(
number(v).ok_or_else(|| bad("expected a number, a string or a size table"))?,
),
}),
Kind::Msg | Kind::Tag => Parsed::Msg(lua_to_value(v)?),
Kind::Str => {
let mlua::Value::String(s) = v else {
return Err(bad("expected a string"));
};
Parsed::Str(s.to_str()?.to_string())
}
Kind::Family => {
let mlua::Value::String(s) = v else {
return Err(bad("expected a family name"));
};
Parsed::Family(refs.family(&s.to_str()?))
}
Kind::Resource => match v {
mlua::Value::Integer(n) => Parsed::Resource(*n as u64),
mlua::Value::Number(n) => Parsed::Resource(*n as u64),
_ => return Err(bad("expected a resource handle (integer)")),
},
Kind::Keyframes => Parsed::Keyframes(
kui_core::keyframes::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
),
Kind::Enter => {
Parsed::Enter(kui_core::enter::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?)
}
Kind::Gradient => Parsed::Gradient(
kui_core::gradient::parse_with(&lua_to_value(v)?, Some(refs)).map_err(bad)?,
),
}))
}
fn parse_color_value(v: &mlua::Value) -> mlua::Result<Color> {
match v {
mlua::Value::Integer(n) => Ok(schema::color_num(*n as u32)),
mlua::Value::Number(n) => Ok(schema::color_num(*n as u32)),
mlua::Value::String(s) => schema::color_hex_str(&s.to_str()?).map_err(bad),
_ => Err(bad("color must be a 0xRRGGBBAA integer or \"#hex\" string")),
}
}
fn parse_color(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Color>> {
if let Some(name) = reference(v)? {
return Ok(refs.color(&name));
}
parse_color_value(v)
.map(Some)
.map_err(|_| bad("color must be a 0xRRGGBBAA integer, a \"#hex\" string or a \"$token\""))
}
fn size_value(v: &mlua::Value) -> mlua::Result<Value> {
fn check(v: &Value) -> mlua::Result<()> {
match v {
Value::Map(m) => {
for (k, x) in m {
if k == "percent" {
return Err(bad("a percentage is { pct = n } in Lua"));
}
check(x)?;
}
Ok(())
}
Value::List(xs) => xs.iter().try_for_each(check),
_ => Ok(()),
}
}
let value = lua_to_value(v)?;
check(&value)?;
Ok(value)
}
fn kept<T>(r: Result<T, String>) -> mlua::Result<Option<T>> {
match r {
Ok(v) => Ok(Some(v)),
Err(e) if kui_core::calc::is_full(&e) => Ok(None),
Err(e) => Err(bad(e)),
}
}
fn parse_sizing(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<Option<Sizing>> {
if let Some(name) = reference(v)? {
return Ok(refs.length(&name).map(Sizing::Fixed));
}
Ok(Some(match v {
mlua::Value::Number(n) => Sizing::Fixed(*n as f32),
mlua::Value::Integer(n) => Sizing::Fixed(*n as f32),
mlua::Value::String(s) => match kept(schema::sizing_str(&s.to_str()?))? {
Some(s) => s,
None => return Ok(None),
},
mlua::Value::Table(t) => {
if let Some(p) = t.get::<Option<f32>>("pct")? {
Sizing::Percent(p / 100.0)
} else if let Some(f) = t.get::<Option<f32>>("grow")? {
Sizing::Grow(f)
} else {
match kept(kui_core::calc::sizing_value(&size_value(v)?)).map_err(|e| {
bad(format!(
"sizing table needs pct, grow or a size expression: {e}"
))
})? {
Some(s) => s,
None => return Ok(None),
}
}
}
_ => return Err(bad("invalid sizing value")),
}))
}
fn parse_pad(v: &mlua::Value, refs: &mut Refs<'_>) -> mlua::Result<PadShorthand> {
match v {
mlua::Value::Table(t) => {
let mut edge = |k: &str| -> mlua::Result<Option<f32>> {
match t.get::<mlua::Value>(k)? {
mlua::Value::Nil => Ok(None),
v => length_of(&v, refs),
}
};
Ok(PadShorthand {
all: edge("all")?,
x: edge("x")?,
y: edge("y")?,
l: edge("l")?,
r: edge("r")?,
t: edge("t")?,
b: edge("b")?,
})
}
v => Ok(PadShorthand {
all: length_of(v, refs).map_err(|_| bad("invalid padding value"))?,
..PadShorthand::default()
}),
}
}
fn parse_align(s: &str) -> mlua::Result<Align> {
schema::enum_index(schema::ALIGNS, s)
.map(schema::align_idx)
.map_err(bad)
}
fn float_preset(name: &str) -> mlua::Result<FloatConfig> {
FloatConfig::preset(name).ok_or_else(|| {
bad(format!(
"bad float preset '{name}' (one of {})",
kui_core::FLOAT_PRESETS.join(" | ")
))
})
}
fn parse_attach(f: &Table, key: &str) -> mlua::Result<Option<(Align, Align)>> {
let Some(at) = f.get::<Option<Table>>(key)? else {
return Ok(None);
};
Ok(Some((
parse_align(&at.get::<String>(1)?)?,
parse_align(&at.get::<String>(2)?)?,
)))
}
fn parse_float(v: &mlua::Value) -> mlua::Result<FloatConfig> {
let f = match v {
mlua::Value::String(s) => return float_preset(&s.to_str()?),
mlua::Value::Table(f) => f,
_ => return Err(bad("float must be a preset string or a table")),
};
let self_at = match parse_attach(f, "self")? {
Some(at) => Some(at),
None => parse_attach(f, "self_at")?,
};
Ok(FloatConfig::build(
match f.get::<Option<String>>("anchor")? {
Some(name) => float_preset(&name)?,
None => FloatConfig::parent(),
},
parse_attach(f, "at")?,
self_at,
f.get::<Option<f32>>("dx")?,
f.get::<Option<f32>>("dy")?,
f.get::<Option<bool>>("fit")?.unwrap_or(false),
f.get::<Option<bool>>("clip")?.unwrap_or(false),
))
}
pub const MAX_VALUE_DEPTH: usize = 64;
pub fn lua_to_value(v: &mlua::Value) -> mlua::Result<Value> {
to_value(
v,
&mut ValuePath {
depth: 0,
seen: Vec::new(),
},
)
}
const VALUE_TRACKED_PAST: usize = 8;
struct ValuePath {
depth: usize,
seen: Vec<*const std::ffi::c_void>,
}
fn to_value(v: &mlua::Value, path: &mut ValuePath) -> mlua::Result<Value> {
Ok(match v {
mlua::Value::Nil => Value::Null,
mlua::Value::Boolean(b) => Value::Bool(*b),
mlua::Value::Integer(i) => Value::Int(*i),
mlua::Value::Number(n) => Value::Float(*n),
mlua::Value::String(s) => Value::Str(s.to_str()?.to_string()),
mlua::Value::Table(t) => {
let tracked = path.depth >= VALUE_TRACKED_PAST;
if tracked {
let at = t.to_pointer();
if path.seen.contains(&at) {
return Err(bad("a table that holds itself cannot be a value"));
}
path.seen.push(at);
}
if path.depth >= MAX_VALUE_DEPTH {
return Err(bad(format!("a value nested past {MAX_VALUE_DEPTH} tables")));
}
path.depth += 1;
let len = t.raw_len();
let value = if len > 0 {
let mut list = Vec::with_capacity(len);
for item in t.sequence_values::<mlua::Value>() {
list.push(to_value(&item?, path)?);
}
Value::List(list)
} else {
let mut map = Vec::new();
for pair in t.pairs::<String, mlua::Value>() {
let (k, v) = pair?;
map.push((k, to_value(&v, path)?));
}
Value::Map(map)
};
path.depth -= 1;
if tracked {
path.seen.pop();
}
value
}
other => {
return Err(mlua::Error::runtime(format!(
"cannot convert {} to event payload",
other.type_name()
)));
}
})
}
pub fn value_to_lua(lua: &Lua, v: &Value) -> mlua::Result<mlua::Value> {
Ok(match v {
Value::Null => mlua::Value::Nil,
Value::Bool(b) => mlua::Value::Boolean(*b),
Value::Int(i) => mlua::Value::Integer(*i),
Value::Float(f) => mlua::Value::Number(*f),
Value::Str(s) => mlua::Value::String(lua.create_string(s)?),
Value::List(items) => {
let t = lua.create_table_with_capacity(items.len(), 0)?;
for (i, item) in items.iter().enumerate() {
t.set(i + 1, value_to_lua(lua, item)?)?;
}
mlua::Value::Table(t)
}
Value::Map(entries) => {
let t = lua.create_table_with_capacity(0, entries.len())?;
for (k, v) in entries {
t.set(k.as_str(), value_to_lua(lua, v)?)?;
}
mlua::Value::Table(t)
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use kui_core::{
Core, Edges, FontFamily, InputEvent, NodeSpec, OriginId, Rect, Size, TextStyle, Vec2,
WindowButton, WindowId,
};
#[test]
fn value_round_trips_through_lua() {
let lua = Lua::new();
let original = Value::map([
("kind", "inc".into()),
("by", Value::Int(2)),
(
"weights",
Value::List(vec![Value::Float(0.5), Value::Float(1.5)]),
),
("enabled", Value::Bool(true)),
]);
let lua_v = value_to_lua(&lua, &original).unwrap();
let back = lua_to_value(&lua_v).unwrap();
assert_eq!(back.get_str("kind"), Some("inc"));
assert_eq!(back.get_int("by"), Some(2));
assert_eq!(back.get_bool("enabled"), Some(true));
match back.get("weights") {
Some(Value::List(items)) => assert_eq!(items.len(), 2),
other => panic!("expected list, got {other:?}"),
}
}
fn test_refs() -> Refs<'static> {
let core: &'static Core = Box::leak(Box::new(Core::new()));
Refs::new(core.token_lookup())
}
fn eval_table(lua: &Lua, src: &str) -> Table {
lua.load(src).eval().unwrap()
}
#[test]
fn schema_props_match_the_rust_builder() {
let lua = Lua::new();
let t = eval_table(
&lua,
r##"{
width = "grow", height = {pct = 50},
min_width = 10, max_width = 500, min_height = 5, max_height = 300,
pad = {l = 1, r = 2, t = 3, b = 4}, gap = 8,
main_align = "center", cross_align = "end", center = false,
bg = "#14161e", radius = 6, border = {w = 1, color = 0x2a2d3aff},
clip = true, scroll = true, scroll_x = true,
float = {anchor = "viewport", at = {"end", "end"}, self_at = {"end", "end"},
dx = -8, dy = -8, fit = true},
hoverable = true, window = "close",
on_click = {kind = "hit"}, on_drag = "d", on_key = 7, key_up = true,
modal = "dlg", on_context_menu = {kind = "menu"},
initial_focus = true,
key = "panel", key_focus = true,
}"##,
);
let p = parse_props(&t, true, &mut test_refs()).unwrap();
let expected = NodeSpec::row()
.grow_width()
.height(Sizing::Percent(0.5))
.min_width(10.0)
.max_width(500.0)
.min_height(5.0)
.max_height(300.0)
.padding(Edges {
l: 1.0,
r: 2.0,
t: 3.0,
b: 4.0,
})
.gap(8.0)
.main_align(Align::Center)
.cross_align(Align::End)
.bg(Color::hex(0x14161eff))
.radius(6.0)
.border(1.0, Color::hex(0x2a2d3aff))
.clip()
.scroll_y()
.scroll_x()
.float(
FloatConfig::viewport()
.inside(Align::End, Align::End)
.offset(-8.0, -8.0)
.fit(),
)
.hoverable()
.window_button(WindowButton::Close)
.on_click(Value::map([("kind", "hit".into())]))
.on_drag("d")
.on_key(Value::Int(7))
.key_up()
.modal("dlg")
.initial_focus()
.on_context_menu(Value::map([("kind", "menu".into())]));
assert_eq!(p.spec, expected);
assert_eq!(p.key.as_deref(), Some("panel"));
assert!(p.key_focus);
}
#[test]
fn a_min_is_a_number_or_fit() {
let lua = Lua::new();
let t = eval_table(&lua, r#"{ min_width = "fit", min_height = 3 }"#);
let p = parse_props(&t, false, &mut test_refs()).unwrap();
let expected = NodeSpec::column()
.min_width(kui_core::Min::FIT)
.min_height(3.0);
assert_eq!(p.spec, expected);
let t = eval_table(&lua, r#"{ min_width = "grow" }"#);
let err = parse_props(&t, false, &mut test_refs())
.unwrap_err()
.to_string();
assert!(err.contains("bad min"), "{err}");
}
#[test]
fn the_lua_composites_resolve_the_way_the_core_says() {
let lua = Lua::new();
let t = eval_table(
&lua,
r#"{ pad = { all = 4, x = 10, b = 1 },
float = { anchor = "below", dx = 6 } }"#,
);
let p = parse_props(&t, false, &mut test_refs()).unwrap();
assert_eq!(
p.spec.layout.padding,
Edges {
l: 10.0,
r: 10.0,
t: 4.0,
b: 1.0,
}
);
assert_eq!(
p.spec.layout.float,
Some(FloatConfig::build(
FloatConfig::below(),
None,
None,
Some(6.0),
None,
false,
false
))
);
let by_self = eval_table(&lua, r#"{ float = { self = {"end", "start"} } }"#);
let by_self_at = eval_table(&lua, r#"{ float = { self_at = {"end", "start"} } }"#);
assert_eq!(
parse_props(&by_self, false, &mut test_refs())
.unwrap()
.spec
.layout
.float,
parse_props(&by_self_at, false, &mut test_refs())
.unwrap()
.spec
.layout
.float
);
let bad_preset = eval_table(&lua, r#"{ float = "beneath" }"#);
let e = parse_props(&bad_preset, false, &mut test_refs())
.unwrap_err()
.to_string();
assert!(e.contains("beneath") && e.contains("below"), "{e}");
}
#[test]
fn text_style_props_match_the_rust_builder() {
let lua = Lua::new();
let t = eval_table(
&lua,
r#"{ size = 20, line_height = 30, color = 0x73d98cff, family = "mono" }"#,
);
let style = parse_props(&t, false, &mut test_refs()).unwrap().style;
assert_eq!(
style,
TextStyle::new(20.0)
.line_height(30.0)
.color(Color::hex(0x73d98cff))
.family(FontFamily::Mono)
);
let t = eval_table(&lua, r#"{ wrap = "none", max_lines = 2, ellipsis = true }"#);
assert_eq!(
parse_props(&t, false, &mut test_refs()).unwrap().style,
TextStyle::default().nowrap().max_lines(2).ellipsis()
);
let t = eval_table(&lua, r##"{ color = "#fff", size = 12 }"##);
assert_eq!(
parse_props(&t, false, &mut test_refs()).unwrap().style,
TextStyle::new(12.0).color(Color::hex(0xffffffff))
);
}
#[test]
fn bad_values_name_the_prop() {
let lua = Lua::new();
let t = eval_table(&lua, r#"{ main_align = "middle" }"#);
let e = parse_props(&t, false, &mut test_refs())
.unwrap_err()
.to_string();
assert!(e.contains("main_align"), "{e}");
assert!(e.contains("middle"), "{e}");
}
fn frame(core: &mut Core, ext: &mut LuaExtension) -> usize {
let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
core.output().0.quads.len()
}
#[test]
fn script_view_builds_ir_nodes() {
let mut ext = LuaExtension::from_source(
"test",
r#"
count = 41
function view()
return column { gap = 8, pad = 16, bg = 0x10121aff,
text("count: " .. count, { size = 20 }),
button { label = "bump", on_click = { kind = "bump" } },
}
end
function on_event(ev)
if ev.kind == "bump" then count = count + 1 end
end
"#,
)
.unwrap();
let mut core = Core::new();
let quads = frame(&mut core, &mut ext);
assert!(quads > 10, "expected panel/button/glyph quads, got {quads}");
ext.on_event(&UiEvent {
origin: OriginId(1),
key: Key::ROOT,
payload: Value::map([("kind", "bump".into())]),
window: WindowId::MAIN,
slot: None,
});
let count: i64 = ext.lua.globals().get("count").unwrap();
assert_eq!(count, 42);
}
#[test]
fn a_devtools_tab_calls_its_view_only_while_on_show() {
let mut ext = LuaExtension::from_source(
"test",
r#"
calls = 0
function view(env)
return column { gap = 8,
text("app"),
devtools_tab { name = "syntax", label = "Tree-sitter", view = function()
calls = calls + 1
return column { text("from lua"),
button { label = "jump", on_click = { kind = "jump" } } }
end },
devtools_tab { name = "plug", label = "Plugin", slot = "ts/panel" },
devtools_tab { name = "bad", label = "Bad" },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
core.set_devtools(true);
core.set_devtools_dock(kui_core::DevtoolsDock::Right);
core.set_inspect(true);
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
let calls: i64 = ext.lua.globals().get("calls").unwrap();
assert_eq!(calls, 0, "not on show: the view was not called");
let ws = core.take_warnings();
assert_eq!(
ws.iter()
.filter(|w| w.code == kui_core::diag::BAD_DEVTOOLS_TAB)
.count(),
1,
"the tab with neither form warns once: {ws:?}"
);
let chord = || {
kui_core::InputEvent::KeyDown(kui_core::KeyPress::new(
kui_core::KeyCode::Char('N'),
kui_core::KeyMods::NONE.with_ctrl().with_shift(),
))
};
core.handle_input(chord());
core.handle_input(chord());
frame(&mut core, &mut ext);
let calls: i64 = ext.lua.globals().get("calls").unwrap();
assert_eq!(calls, 1, "on show: called once a frame");
frame(&mut core, &mut ext);
let body = core
.nodes()
.iter()
.find(|n| n.label.as_deref() == Some("kui-devtools/tab/syntax"))
.map(|n| n.rect)
.expect("the body");
let jump = core
.nodes()
.iter()
.find(|n| n.label.as_deref() == Some("jump"))
.map(|n| n.rect)
.expect("the script's button");
assert!(
jump.x >= body.x && jump.x + jump.w <= body.x + body.w,
"{jump:?} in {body:?}"
);
assert!(
core.nodes()
.iter()
.any(|n| n.text.as_deref() == Some("from lua")),
"the script's text painted"
);
let evs = kui_core::testing::click_at(&mut core, jump.x + 2.0, jump.y + 2.0);
assert_eq!(evs.len(), 1);
assert_eq!(evs[0].origin, OriginId(1), "the script's own event");
assert_eq!(evs[0].kind(), Some("jump"));
}
#[test]
fn the_root_table_declares_windows() {
use kui_core::{WindowCommand, WindowConfig};
let mut ext = LuaExtension::from_source(
"windows",
r#"
function view(env)
return column {
windows = { { name = "palette", width = 400, height = 300,
activates = false }, "tools" },
text("main"),
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let cmds = core.take_window_commands();
assert_eq!(cmds.len(), 2, "{cmds:?}");
assert_eq!(
cmds[0],
WindowCommand::Open {
id: WindowId(1),
owner: WindowId::MAIN,
origin: OriginId(1),
config: WindowConfig {
size: Size::new(400.0, 300.0),
activates: false,
..WindowConfig::default()
},
}
);
assert_eq!(
cmds[1],
WindowCommand::Open {
id: WindowId(2),
owner: WindowId::MAIN,
origin: OriginId(1),
config: WindowConfig::default(),
}
);
frame(&mut core, &mut ext);
assert!(core.take_window_commands().is_empty());
assert!(core.take_warnings().is_empty());
}
#[test]
fn a_windows_entry_declares_a_popup() {
use kui_core::{Rect, WindowCommand, WindowConfig, WindowKind};
let mut ext = LuaExtension::from_source(
"windows-popup",
r#"
function view(env)
return column {
windows = { { name = "menu", kind = "popup",
width = 160, height = 320,
anchor = { x = 12, y = 40, w = 160, h = 24 } } },
text("main"),
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
assert_eq!(
core.take_window_commands(),
vec![WindowCommand::Open {
id: WindowId(1),
owner: WindowId::MAIN,
origin: OriginId(1),
config: WindowConfig {
kind: WindowKind::Popup,
size: Size::new(160.0, 320.0),
activates: false,
anchor: Rect::new(12.0, 40.0, 160.0, 24.0),
},
}]
);
}
#[test]
fn a_windows_entry_cannot_name_an_unknown_kind() {
let mut ext = LuaExtension::from_source(
"windows-kind",
r#"
function view(env)
return column {
windows = { { name = "palette", kind = "sheet" } },
text("main"),
}
end
"#,
)
.unwrap();
let mut core = Core::new();
let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
ui.set_origin(OriginId(1));
let err = ext.view(&Slot::root(), &mut ui).unwrap_err();
assert!(err.contains("sheet"), "{err}");
assert!(err.contains("popup"), "{err}");
}
#[test]
fn a_button_takes_an_index_and_a_cursor_shape_is_refused() {
let mut ext = LuaExtension::from_source(
"rows",
r#"
from = 0
function view(env)
return column {
button { label = "Open", index = from, on_click = { row = from } },
button { label = "Open", index = from + 1, on_click = { row = from + 1 } },
button { label = "Keyed", key = "named", index = 7, on_click = "k" },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
core.set_diagnostics(true);
let frame = |core: &mut Core, ext: &mut LuaExtension| {
let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
};
frame(&mut core, &mut ext);
assert!(core.take_warnings().is_empty(), "index is a button row");
let buttons = |core: &mut Core| -> Vec<Key> {
core.access_tree()
.nodes
.iter()
.filter(|n| n.role == kui_core::Role::Button)
.map(|n| n.key)
.collect()
};
let before = buttons(&mut core);
assert_eq!(before.len(), 3, "two rows with the same text are two nodes");
assert!(
core.key_of("named").is_none(),
"declared beside `key`, the index wins"
);
ext.lua.globals().set("from", 1).unwrap();
frame(&mut core, &mut ext);
let after = buttons(&mut core);
assert_eq!(
after[0], before[1],
"row 1 keeps its key as it moves up the list"
);
let mut ext = LuaExtension::from_source(
"term",
r#"
function view(env)
return column { cells { key = "term", rows = 1, cols = 4, size = 14,
lines = { "abcd" }, cursor_at = { 1, 1 }, cursor_shape = "blob" } }
end
"#,
)
.unwrap();
let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
assert!(
err.contains("block | bar | underline") && err.contains("blob"),
"{err}"
);
}
#[test]
fn option_as_alt_is_a_side_by_name() {
let mut core = Core::new();
let mut ext = LuaExtension::from_source(
"keys",
r#"
function view(env)
return column { option_as_alt = "left", text("x") }
end
"#,
)
.unwrap();
frame(&mut core, &mut ext);
assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
let mut ext = LuaExtension::from_source(
"keys",
r#"
function view(env)
return column { option_as_alt = "meta", text("x") }
end
"#,
)
.unwrap();
let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
assert!(
err.contains("option_as_alt") && err.contains("\"both\"") && err.contains("meta"),
"{err}"
);
}
#[test]
fn a_value_that_holds_itself_or_nests_too_deep_is_refused() {
let lua = Lua::new();
let read = |src: &str| lua_to_value(&lua.load(src).eval::<mlua::Value>().unwrap());
let err = |src: &str| read(src).unwrap_err().to_string();
assert!(err("local t = {}; t[1] = t; return t").contains("holds itself"));
assert!(err("local t = {}; t.me = { t }; return t").contains("holds itself"));
let shared = read("local l = { 1, 2 }; return { a = l, b = l }").unwrap();
assert_eq!(shared.get("a"), shared.get("b"));
let nested = |n: usize| format!("local t = 1; for _ = 1, {n} do t = {{ t }} end; return t");
assert!(read(&nested(MAX_VALUE_DEPTH)).is_ok());
assert!(err(&nested(MAX_VALUE_DEPTH + 1)).contains("nested past 64"));
assert!(err(&nested(100_000)).contains("nested past 64"));
}
#[test]
fn a_view_that_holds_itself_or_nests_too_deep_is_refused() {
let run = |body: &str| {
let mut core = Core::new();
let mut ext =
LuaExtension::from_source("deep", &format!("function view(env)\n{body}\nend"))
.unwrap();
let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
let built = ext.view(&Slot::root(), &mut ui);
ui.finish();
built
};
let err = run("local t = column {}; t[1] = row { t }; return t").unwrap_err();
assert!(err.contains("holds itself"), "{err}");
let nested = |n: usize| {
format!(
"local t = text('x')
for i = 2, {n} do
if i % 2 == 0 then t = radio_group {{ label = 'g', t }}
elseif i % 3 == 0 then t = tooltip {{ t }}
else t = column {{ t }} end
end
return t"
)
};
run(&nested(MAX_VIEW_DEPTH)).unwrap();
let err = run(&nested(MAX_VIEW_DEPTH + 1)).unwrap_err();
assert!(err.contains("nested past 128"), "{err}");
run(&nested(8)).unwrap();
}
#[test]
fn option_as_alt_none_leaves_the_hosts_side() {
let mut core = Core::new();
let mut ext = LuaExtension::from_source(
"keys",
r#"
function view(env)
return column { option_as_alt = "none", text("x") }
end
"#,
)
.unwrap();
let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
ui.option_as_alt(kui_core::OptionAsAlt::Left);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Left);
}
#[test]
fn every_node_type_lowers() {
let mut ext = LuaExtension::from_source(
"all",
r##"
function view(env)
return column { gap = 4, window_title = "all nodes", always_on_top = true,
secure_input = true, option_as_alt = "right", ime_off = true,
titlebar { text("custom title"), window_buttons() },
titlebar { title = "plain title" },
text({ "same IR as ", { "Rust", bold = true, color = "#73d98c" },
{ " — flatter", italic = true } }, { size = 13 }),
edit { key = "note", initial = "hello", size = 14, width = 200,
multiline = true },
input { label = "name", initial = "" },
dropdown { label = "language", options = { "English", "Deutsch" }, current = 1 },
row { tooltip = "hover hint", pad = 4, text("badge") },
row { hoverable = true, text("legend"), tooltip("always shown") },
row { text("rich tip"), tooltip { text("a"), text("b") } },
latency_graph(),
latency_hud { at = { "start", "end" } },
button { label = "ok", on_click = "ok" },
}
end
"##,
)
.unwrap();
let mut core = Core::new();
let quads = frame(&mut core, &mut ext);
assert!(quads > 60, "got {quads} quads");
assert_eq!(core.window_title(), Some("all nodes"));
assert!(core.always_on_top());
assert!(core.secure_input());
assert_eq!(core.option_as_alt(), kui_core::OptionAsAlt::Right);
assert!(core.ime_off());
let unknown: Vec<String> = core
.take_warnings()
.into_iter()
.filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
.map(|w| w.message)
.collect();
assert!(unknown.is_empty(), "{unknown:#?}");
}
#[test]
fn unknown_props_warn_once_in_lua_spelling() {
let mut ext = LuaExtension::from_source(
"typos",
r#"
function view(env)
return column { pad = 8,
row { hoverBg = 0x333333ff, width = 10, height = 10 },
row { hoverBg = 0x333333ff, width = 10, height = 10 },
row { colour = 0x333333ff, width = 10, height = 10 },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let mut warned: Vec<String> = core
.take_warnings()
.into_iter()
.filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
.map(|w| w.message)
.collect();
warned.sort();
assert_eq!(warned.len(), 2, "one per name, not per node: {warned:#?}");
assert!(
warned[1].contains("`hoverBg` is not a prop of box")
&& warned[1].contains("did you mean `hover_bg`?"),
"{warned:#?}"
);
assert!(warned[0].contains("`colour`") && !warned[0].contains("did you mean"));
frame(&mut core, &mut ext);
assert!(core.take_warnings().is_empty());
}
#[test]
fn a_text_warns_about_the_rows_it_does_not_read() {
let mut ext = LuaExtension::from_source(
"textrows",
r#"
function view(env)
return column { pad = 8,
text("hi", { live = "polite", label = "x", on_click = "go", size = 14, line_height = 20, max_lines = 2 }),
text({ "a", { "b", bold = true, bg = 0x00ff00ff } }, { color = 0xff0000ff }),
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let mut warned: Vec<String> = core
.take_warnings()
.into_iter()
.filter(|w| w.code == kui_core::diag::UNKNOWN_PROP)
.map(|w| w.message)
.collect();
warned.sort();
assert_eq!(warned.len(), 3, "{warned:#?}");
for (w, name) in warned.iter().zip(["label", "live", "on_click"]) {
assert!(
w.contains(&format!("`{name}`")) && w.contains("not one text reads"),
"{w}"
);
assert!(w.contains("`max_lines`"), "names the rows it reads: {w}");
}
}
#[test]
fn the_repeat_alias_does_not_warn() {
let mut ext = LuaExtension::from_source(
"alias",
r#"
function view(env)
return column {
row { width = 10, height = 10, keyframes = { { bg = 0x000000ff } },
transition = 100, direction = "alternate" },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
assert!(core.take_warnings().is_empty());
}
#[test]
fn unknown_props_stay_quiet_with_diagnostics_off() {
let mut ext = LuaExtension::from_source(
"quiet",
r#"
function view(env)
return column { hoverBg = 0x333333ff }
end
"#,
)
.unwrap();
let mut core = Core::new();
core.set_diagnostics(false);
frame(&mut core, &mut ext);
assert!(core.take_warnings().is_empty());
}
#[test]
fn tooltip_prop_is_hover_gated() {
let mut ext = LuaExtension::from_source(
"tip",
r#"
function view(env)
return column { pad = 10,
row { width = 100, height = 40, bg = 0x333333ff, tooltip = "a long hint" },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
let idle = frame(&mut core, &mut ext);
core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
let hovered = frame(&mut core, &mut ext);
assert!(
hovered > idle + 5,
"hover should add the tooltip's quads ({idle} -> {hovered})"
);
core.handle_input(InputEvent::CursorLeft);
assert_eq!(frame(&mut core, &mut ext), idle);
}
#[test]
fn the_stock_button_admits_the_access_rows() {
let mut ext = LuaExtension::from_source(
"button",
r#"
function view(env)
return column { pad = 10, gap = 4,
button { label = "go", on_click = "go", description = "Starts the run" },
button { label = "Stop the run", text = "stop", on_click = "stop",
disabled = true, tooltip = "Nothing is running" },
button { label = "x", on_click = "x", radius = 12, hoverBg = 0x333333ff },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let tree = core.access_tree().clone();
let named = |n: &str| {
tree.nodes
.iter()
.find(|node| node.name.as_deref() == Some(n))
};
let go = named("go").expect("the button, named by its label");
assert_eq!(go.role, kui_core::Role::Button);
assert_eq!(go.description.as_deref(), Some("Starts the run"));
assert!(!go.disabled);
let stop = named("Stop the run").expect("named past its text");
assert_eq!(stop.description.as_deref(), Some("Nothing is running"));
assert!(stop.disabled);
assert_eq!(tree.nodes.len(), 4, "window and three buttons");
let ws = core.take_warnings();
assert_eq!(ws.len(), 2, "{ws:?}");
let radius = ws
.iter()
.find(|w| w.message.contains("`radius`"))
.expect("a row the button does not read");
assert!(
radius
.message
.contains("is a prop, but not one button reads")
);
assert!(radius.message.contains("`description`"));
let hover = ws
.iter()
.find(|w| w.message.contains("hoverBg"))
.expect("a misspelling");
assert!(hover.message.contains("is not a prop of button"));
assert!(!hover.message.contains("did you mean"));
}
#[test]
fn an_accent_button_takes_the_colour_the_host_pushed() {
let mut ext = LuaExtension::from_source(
"accent",
r#"
function view(env)
return column { pad = 10,
button { label = "go", on_click = "go", accent = true },
}
end
"#,
)
.unwrap();
let mut bg = |core: &mut Core| {
frame(core, &mut ext);
core.output().0.quads[0].color
};
let mut core = Core::new();
assert_eq!(
bg(&mut core),
kui_core::Color::rgb8(0x3b, 0x5b, 0xd4),
"no accent pushed, the stock button"
);
assert!(
core.take_warnings().is_empty(),
"and a row the button reads"
);
let accent = kui_core::Color::hex(0x007affff);
core.env.system.accent = Some(accent);
assert_eq!(bg(&mut core), accent);
}
#[test]
fn semantics_reach_the_access_tree() {
let mut ext = LuaExtension::from_source(
"a11y",
r#"
function view(env)
return column { pad = 10,
row { key = "save", on_click = "save", label = "Save", width = 20, height = 20 },
row { key = "check", role = "checkbox", checked = true, text("Remember") },
row { key = "vol", role = "slider", label = "Volume",
value_now = 3, value_min = 0, value_max = 10 },
row { key = "art", role = "none", on_click = "art", text("Art") },
row { key = "tip", tooltip = "more here", on_click = "t", text("Tip") },
row { key = "tabs", role = "tabList",
row { key = "t0", role = "tab", text("General") },
row { key = "t1", role = "tab", selected = true, text("Network") },
},
row { key = "adv", on_click = "adv", expanded = "collapsed",
label = "Advanced" },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let tree = core.access_tree().clone();
let named = |n: &str| {
tree.nodes
.iter()
.find(|node| node.name.as_deref() == Some(n))
.cloned()
};
let save = named("Save").expect("labelled button");
assert_eq!(save.role, kui_core::Role::Button);
assert_eq!(save.origin, OriginId(1));
let check = named("Remember").expect("checkbox named by its text");
assert_eq!(check.role, kui_core::Role::Checkbox);
assert_eq!(check.checked, Some(true));
let vol = named("Volume").expect("slider");
assert_eq!(
(vol.number, vol.min, vol.max),
(Some(3.0), Some(0.0), Some(10.0))
);
assert!(named("Art").is_none(), "role none hides a would-be button");
let tip = named("Tip").expect("button");
assert_eq!(tip.description.as_deref(), Some("more here"));
let (t0, t1) = (named("General").unwrap(), named("Network").unwrap());
assert_eq!((t0.selected, t1.selected), (Some(false), Some(true)));
assert_eq!((t0.pos_in_set, t1.pos_in_set), (Some(0), Some(1)));
let tabs = tree
.nodes
.iter()
.find(|n| n.role == kui_core::Role::TabList)
.expect("tab list");
assert_eq!(tabs.set_size, Some(2));
let adv = named("Advanced").expect("disclosure");
assert_eq!(adv.expanded, Some(false));
let evs = core.handle_input(InputEvent::Access(kui_core::AccessRequest {
key: save.key,
action: kui_core::AccessAction::Click,
value: None,
anchor: None,
focus: None,
}));
assert_eq!(evs.len(), 1);
assert_eq!(evs[0].origin, OriginId(1));
assert_eq!(evs[0].payload.as_str(), Some("save"));
}
#[test]
fn a_custom_editor_in_lua_reaches_the_access_tree() {
let mut ext = LuaExtension::from_source(
"ed",
r#"
function view(env)
return column { key = "ed", role = "multilineTextInput", label = "Doc",
on_key = "keys",
row { role = "line", selection_anchor = 1, text("ab"), text("cd") },
row { role = "line", caret = 2, text("ef") },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let tree = core.access_tree().clone();
let ed = tree
.nodes
.iter()
.find(|n| n.name.as_deref() == Some("Doc"))
.expect("the sink is the editor")
.clone();
assert_eq!(ed.role, kui_core::Role::MultilineTextInput);
assert_eq!(ed.value.as_deref(), Some("abcd\nef"));
assert_eq!(ed.runs.len(), 3);
assert_eq!(ed.runs[1].text, "cd\n");
assert_eq!(ed.caret, Some(7));
assert_eq!(ed.selection, Some((1, 7)));
let (a, f) = (ed.anchor.unwrap(), ed.focus.unwrap());
assert_eq!((a.run, a.character), (ed.runs[0].key, 1));
assert_eq!((f.run, f.character), (ed.runs[2].key, 2));
let evs = core.handle_input(InputEvent::Access(
kui_core::AccessRequest::new(ed.key, kui_core::AccessAction::SetTextSelection)
.with_selection(
kui_core::TextPos {
run: ed.runs[1].key,
character: 1,
},
kui_core::TextPos {
run: ed.runs[2].key,
character: 0,
},
),
));
assert_eq!(evs.len(), 1);
assert_eq!(evs[0].origin, OriginId(1));
let p = &evs[0].payload;
assert_eq!(p.get_str("action"), Some("setTextSelection"));
let at = |k: &str, f: &str| p.get(k).and_then(|v| v.get(f)).and_then(Value::as_int);
assert_eq!(
(at("anchor", "line"), at("anchor", "offset")),
(Some(0), Some(3))
);
assert_eq!(
(at("focus", "line"), at("focus", "offset")),
(Some(1), Some(0))
);
assert_eq!(p.get_str("tag"), Some("keys"));
}
#[test]
fn edit_text_round_trips_through_events() {
let mut ext = LuaExtension::from_source(
"edit",
r#"
pending = nil
seen = nil
by_label = nil
window = nil
function view(env)
if pending then seen = env.edit_text(pending) end
-- AR26: by the label its `key` declares, like every
-- query beside it; a label nothing declared is nil.
by_label = env.edit_text("note")
nothing = env.edit_text("nope")
return column {
edit { key = "note", initial = "hi", autofocus = true, width = 200 },
}
end
function on_event(ev)
if ev.kind == "changed" then pending = ev.node_key; window = ev.window end
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let events = core.handle_input(InputEvent::Text("!".into()));
assert_eq!(
events.len(),
1,
"typing into the autofocused editor emits one event"
);
assert_eq!(events[0].kind(), Some("changed"));
for ev in &events {
ext.on_event(ev);
}
frame(&mut core, &mut ext);
let seen: Option<String> = ext.lua.globals().get("seen").unwrap();
assert_eq!(seen.as_deref(), Some("hi!"));
let by_label: Option<String> = ext.lua.globals().get("by_label").unwrap();
assert_eq!(by_label.as_deref(), Some("hi!"), "the same text by label");
let nothing: Option<String> = ext.lua.globals().get("nothing").unwrap();
assert_eq!(nothing, None);
let window: Option<i64> = ext.lua.globals().get("window").unwrap();
assert_eq!(window, Some(0));
}
#[test]
fn a_dropdown_opens_the_cores_menu_and_hears_the_choice() {
let mut ext = LuaExtension::from_source(
"dd",
r#"
current = 1
heard = {}
function view(env)
return column { pad = 10,
dropdown { label = "language",
options = { "English", "Deutsch", { label = "Latin", id = "la" } },
current = current },
}
end
function on_event(ev)
heard[#heard + 1] = ev.kind
if ev.kind == "menu" then
if ev.item == "Deutsch" then current = 2 end
if ev.item == "la" then current = 3 end
end
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let field = core
.key_of("language")
.expect("the field is keyed by its label");
let node = |core: &mut Core| {
core.access_tree()
.nodes
.iter()
.find(|n| n.key == field)
.cloned()
.unwrap()
};
assert_eq!(node(&mut core).description.as_deref(), Some("English"));
let events = core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
field,
kui_core::AccessAction::Click,
)));
assert!(
events.is_empty(),
"the field's click is the core's: {events:?}"
);
let menu = core.menu().expect("the menu opened");
assert_eq!(menu.target, field);
assert_eq!(menu.items.len(), 3);
assert!(menu.items[0].checked);
frame(&mut core, &mut ext);
let events = core.activate_menu_item(2).expect("the row is enabled");
assert_eq!(events.len(), 1);
for ev in &events {
ext.on_event(ev);
}
frame(&mut core, &mut ext);
assert_eq!(node(&mut core).description.as_deref(), Some("Latin"));
let heard: Vec<String> = ext
.lua
.globals()
.get::<Table>("heard")
.unwrap()
.sequence_values()
.collect::<mlua::Result<_>>()
.unwrap();
assert_eq!(heard, vec!["menu".to_string()]);
assert!(core.menu().is_none());
let mut ext = LuaExtension::from_source(
"bad",
r#"function view(env) return dropdown { label = "x" } end"#,
)
.unwrap();
let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
assert!(err.contains("needs options"), "{err}");
ui.finish();
let mut ext = LuaExtension::from_source(
"bad",
r#"function view(env) return dropdown { label = "x", options = { "a" }, current = 0 } end"#,
)
.unwrap();
let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
assert!(err.contains("index from 1"), "{err}");
ui.finish();
let refused = |core: &mut Core, src: &str| {
let mut ext = LuaExtension::from_source("bad", src).unwrap();
let mut ui = core.frame(Size::new(300.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
let err = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
ui.finish();
err
};
let err = refused(
&mut core,
r#"function view(env) return dropdown { options = { "a" } } end"#,
);
assert!(err.contains("dropdown needs a label"), "{err}");
let err = refused(
&mut core,
r#"function view(env) return dropdown { label = "x", options = {} } end"#,
);
assert!(err.contains("at least one option"), "{err}");
}
#[test]
fn a_dropdowns_bad_rows_and_current_are_warned_and_a_disabled_option_is_refused() {
let mut ext = LuaExtension::from_source(
"dd",
r#"
current = 4
function view(env)
return column { pad = 10,
dropdown { label = "language",
options = { "English", { role = "separator" },
{ label = "Latin", id = "la", disabled = true } },
current = current },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let field = core.key_of("language").unwrap();
let node = |core: &mut Core| {
core.access_tree()
.nodes
.iter()
.find(|n| n.key == field)
.cloned()
.unwrap()
};
assert_eq!(node(&mut core).description.as_deref(), Some(""));
let warned = core.take_warnings();
let codes: Vec<&str> = warned.iter().map(|w| w.code).collect();
assert_eq!(
codes,
[
kui_core::diag::UNKNOWN_PROP,
kui_core::diag::SELECT_CURRENT_IGNORED
],
"{warned:?}"
);
assert!(
warned[0]
.message
.contains("`disabled` is not a key of a menu item")
&& warned[0].message.contains("`enabled: false`"),
"{}",
warned[0].message
);
assert_eq!(warned[1].key, field);
assert!(
warned[1].message.contains("names option 3 counted from 0")
&& warned[1].message.contains("the field has 3 options"),
"{}",
warned[1].message
);
ext.lua.globals().set("current", 2).unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let warned = core.take_warnings();
assert!(
warned
.iter()
.any(|w| w.code == kui_core::diag::SELECT_CURRENT_IGNORED
&& w.message
.contains("option 1 counted from 0, which is a separator")),
"{warned:?}"
);
let mut ext = LuaExtension::from_source(
"dd",
r#"
function view(env)
return column { pad = 10,
dropdown { label = "language",
options = { "English", { label = "Latin", id = "la", enabled = false } },
current = 1 },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let field = core.key_of("language").unwrap();
core.handle_input(InputEvent::Access(kui_core::AccessRequest::new(
field,
kui_core::AccessAction::Click,
)));
assert!(core.menu().is_some());
assert_eq!(core.activate_menu_item(1), None, "refused");
assert!(core.menu().is_some(), "the menu stays open");
let events = core
.activate_menu_item(0)
.expect("the enabled row is taken");
assert_eq!(events.len(), 1);
assert!(core.menu().is_none());
assert!(core.take_warnings().is_empty());
}
#[test]
fn a_grid_lines_its_rows_cells_up() {
let mut ext = LuaExtension::from_source(
"grid",
r#"
function view(env)
return grid { key = "t", width = 300,
row { key = "r1", width = "grow", gap = 8,
text("ab", { size = 12 }),
column { key = "b1", width = 10, height = 10, bg = 0xff0000ff },
column { key = "c1", width = "grow", height = 10, bg = 0x00ff00ff },
},
row { key = "r2", width = "grow", gap = 8,
text("abcdef", { size = 12 }),
column { key = "b2", width = 50, height = 10, bg = 0xff0000ff },
column { key = "c2", width = 20, height = 10, bg = 0x00ff00ff },
},
}
end
"#,
)
.unwrap();
let mut core = Core::new();
core.set_inspect(true);
frame(&mut core, &mut ext);
let rect = |core: &mut Core, label: &str| {
let key = core.key_of(label).unwrap_or_else(|| panic!("{label}"));
core.nodes()
.iter()
.find(|n| n.key == key)
.map(|n| n.rect)
.unwrap_or_else(|| panic!("{label}"))
};
let (b1, b2) = (rect(&mut core, "b1"), rect(&mut core, "b2"));
let (c1, c2) = (rect(&mut core, "c1"), rect(&mut core, "c2"));
assert_eq!(
b1.x, b2.x,
"the fixed column starts after the longest label"
);
assert_eq!(b1.w, 50.0, "the fixed column is its widest cell");
assert_eq!(b2.w, 50.0);
assert_eq!(c1.x, c2.x);
assert_eq!(c1.w, c2.w, "the grow column is one width in both rows");
assert_eq!(c1.x + c1.w, 300.0, "and it takes the rest");
let t = core.key_of("t").unwrap();
assert!(
core.nodes().iter().any(|n| n.key == t && n.table),
"the grid is a table"
);
assert!(core.take_warnings().is_empty());
}
#[test]
fn set_edit_text_by_label_seeds_the_editor_the_next_frame_declares() {
let mut ext = LuaExtension::from_source(
"edit",
r#"
frames = 0
function view(env)
frames = frames + 1
if frames == 1 then return column {} end
if frames == 2 then
env.set_edit_text("note", "from the model")
end
return column {
edit { key = "note", initial = "ignored", width = 200 },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
let key = core.key_of("note").expect("the view declared the editor");
assert_eq!(core.edit_text(key).as_deref(), Some("from the model"));
let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
assert!(!codes.contains(&"edit-text-without-editor"), "{codes:?}");
core.set_focus(Some(key));
core.handle_input(InputEvent::Text("!".into()));
frame(&mut core, &mut ext);
assert_eq!(core.edit_text(key).as_deref(), Some("from the model!"));
}
#[test]
fn a_lua_key_sink_hears_press_and_release() {
use kui_core::{KeyCode, KeyMods, KeyPress};
let mut ext = LuaExtension::from_source(
"game",
r#"
log = {}
function view(env)
return column { key = "world", on_key = "keys", key_up = true,
key_focus = true, width = 400, height = 300 }
end
function on_event(ev)
if ev.kind == "key" then
log[#log + 1] = ev.phase .. ":" .. ev.code ..
"@" .. ev.physical ..
":" .. tostring(ev.text) .. ":" .. tostring(ev.tag)
end
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
for e in core.handle_input(ev) {
ext.on_event(&e);
}
};
let w = || KeyPress::new(KeyCode::Char('w'), KeyMods::default()).with_text("w");
feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
feed(&mut core, &mut ext, InputEvent::KeyUp(w()));
let ru = || {
KeyPress::from_layout(KeyCode::Char('ц'), KeyCode::Char('w'), KeyMods::default())
.with_text("ц")
};
feed(&mut core, &mut ext, InputEvent::KeyDown(ru()));
feed(&mut core, &mut ext, InputEvent::KeyUp(ru()));
feed(&mut core, &mut ext, InputEvent::KeyDown(w()));
core.set_focus(None);
for e in core.take_pending_events() {
ext.on_event(&e);
}
let log: Vec<String> = ext.lua.globals().get("log").unwrap();
assert_eq!(
log,
[
"down:w@w:w:keys",
"up:w@w:nil:keys",
"down:w@w:ц:keys",
"up:w@w:nil:keys",
"down:w@w:w:keys",
"up:w@w:nil:keys",
]
);
}
#[test]
fn a_lua_editor_draws_its_caret_on_the_phase_the_host_sets() {
let mut ext = LuaExtension::from_source(
"ed",
r#"
function view(env)
local caret = env.caret_visible and row { key = "caret", width = 2, height = 16 } or nil
return column { key = "editor", on_key = "ed", key_focus = true,
role = "multilineTextInput", label = "buf",
row { role = "line", caret = 3, text("let value", { family = "mono", size = 14 }), caret },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
assert!(
core.key_of("caret").is_some(),
"the on phase draws the caret"
);
assert!(core.has_caret(), "the caret row is a caret to blink");
core.set_caret_visible(false);
frame(&mut core, &mut ext);
assert!(core.key_of("caret").is_none(), "the off phase draws none");
assert!(
core.has_caret(),
"and the row stays, so the clock stays armed"
);
core.set_caret_visible(true);
frame(&mut core, &mut ext);
assert!(core.key_of("caret").is_some());
}
#[test]
fn a_lua_editors_solid_caret_arms_no_clock() {
let mut ext = LuaExtension::from_source(
"ed",
r#"
function view(env)
return column { key = "editor", on_key = "ed", key_focus = true,
role = "multilineTextInput", label = "buf",
row { role = "line", caret = 3, caret_solid = true,
text("let value", { family = "mono", size = 14 }) },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
assert!(!core.has_caret(), "a solid caret is not a caret to blink");
assert!(core.ime_rect().is_some(), "and still the IME's anchor");
let editor = core.key_of("editor").unwrap();
assert_eq!(core.access_tree().get(editor).unwrap().caret, Some(3));
assert!(
core.warnings_raised().is_empty(),
"{:?}",
core.warnings_raised()
);
}
#[test]
fn a_lua_editor_yanks_pastes_and_hears_where_a_press_landed() {
use kui_core::testing::{press, release};
use kui_core::{KeyCode, KeyMods, KeyPress, MenuAction, Vec2};
let mut ext = LuaExtension::from_source(
"ed",
r#"
yank = nil
paste = false
log = {}
function view(env)
if yank then env.set_clipboard(yank, nil); yank = nil end
if secret then env.set_clipboard_secret(secret); secret = nil end
-- Asked on every view until the answer lands: the core
-- takes one ask at a time (AR34), so this is one paste.
if paste then env.request_paste() end
return column { key = "editor", on_key = "ed", on_drag = "sel",
key_focus = true, role = "multilineTextInput", label = "buf",
width = 400, height = 300,
row { role = "line", height = 20, text("hello world", { family = "mono", size = 14 }) },
row { role = "line", height = 20, text("second", { family = "mono", size = 14 }) },
}
end
function on_event(ev)
if ev.kind == "key" and ev.code == "y" then yank = "hello world" end
if ev.kind == "key" and ev.code == "s" then secret = "hunter2" end
if ev.kind == "key" and ev.code == "p" then paste = true end
if ev.kind == "text" then
paste = false
local marks = (ev.concealed and ":concealed" or "") .. (ev.transient and ":transient" or "")
log[#log + 1] = "text:" .. ev.text .. marks
end
if ev.kind == "drag" then
log[#log + 1] = ev.phase .. ":" .. ev.line .. ":" .. ev.byte .. ":" .. ev.clicks
end
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let feed = |core: &mut Core, ext: &mut LuaExtension, ev| {
for e in core.handle_input(ev) {
ext.on_event(&e);
}
};
let key = |c| KeyPress::new(KeyCode::Char(c), KeyMods::default());
feed(&mut core, &mut ext, InputEvent::KeyDown(key('y')));
frame(&mut core, &mut ext);
assert_eq!(
core.take_menu_actions(),
vec![MenuAction::SetClipboard {
text: "hello world".into(),
html: None
}]
);
feed(&mut core, &mut ext, InputEvent::KeyDown(key('s')));
frame(&mut core, &mut ext);
assert_eq!(
core.take_menu_actions(),
vec![MenuAction::SetClipboardSecret {
text: "hunter2".into()
}]
);
feed(&mut core, &mut ext, InputEvent::KeyDown(key('p')));
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
assert_eq!(
core.take_menu_actions(),
vec![MenuAction::Paste],
"two views asked, one paste queued"
);
assert!(core.awaiting_paste());
feed(
&mut core,
&mut ext,
InputEvent::Commit("from the clipboard".into()),
);
assert!(!core.awaiting_paste());
frame(&mut core, &mut ext);
assert!(
core.take_menu_actions().is_empty(),
"answered: the script stopped asking"
);
feed(
&mut core,
&mut ext,
InputEvent::Paste {
text: "s3cret".into(),
marks: kui_core::ClipboardMarks::SECRET,
},
);
for e in press(&mut core, Vec2::new(390.0, 30.0)) {
ext.on_event(&e);
}
for e in release(&mut core) {
ext.on_event(&e);
}
for e in core.handle_input(InputEvent::mouse_down(2)) {
ext.on_event(&e);
}
for e in release(&mut core) {
ext.on_event(&e);
}
let log: Vec<String> = ext.lua.globals().get("log").unwrap();
assert_eq!(
log,
[
"text:from the clipboard",
"text:s3cret:concealed:transient",
"start:1:6:1",
"end:1:6:1",
"start:1:6:2",
"end:1:6:2",
]
);
}
#[test]
fn audio_nodes_drive_playback_commands() {
use kui_core::AudioCommand;
let mut ext = LuaExtension::from_source(
"audio",
r#"
playing = true
function view(env)
local items = {}
if playing then
items[1] = audio { src = SOUND, loop = true, volume = 0.5,
key = "music", tag = { kind = "music" } }
end
return column { table.unpack(items) }
end
"#,
)
.unwrap();
let mut core = Core::new();
let sound = core.add_sound(vec![0; 8]);
ext.lua
.globals()
.set("SOUND", sound.to_ffi() as i64)
.unwrap();
frame(&mut core, &mut ext);
let cmds = core.take_audio_commands();
assert!(
matches!(
cmds.as_slice(),
[AudioCommand::Play { sound: s, looped: true, volume, .. }]
if *s == sound && *volume == 0.5
),
"{cmds:?}"
);
frame(&mut core, &mut ext);
assert!(
core.take_audio_commands().is_empty(),
"re-declaring is silent"
);
ext.lua.globals().set("playing", false).unwrap();
frame(&mut core, &mut ext);
assert!(matches!(
core.take_audio_commands().as_slice(),
[AudioCommand::Stop { .. }]
));
}
#[test]
fn measure_and_layout_events_reach_scripts() {
let mut ext = LuaExtension::from_source(
"measure",
r#"
seen = nil
function view(env)
local plain = env.measure_text("hello world", { size = 14 })
local node = env.measure_text(text("hello world", { size = 14 }))
local rich = env.measure_text({ "hello ", { "world", bold = true } }, { size = 14 })
local narrow = env.measure_text("hello world", { size = 14 }, plain.width / 2)
assert(plain.width > 0 and plain.lines == 1)
assert(node.width == plain.width and node.height == plain.height)
assert(rich.width > 0)
assert(narrow.lines > 1 and narrow.width <= plain.width / 2 + 0.5)
return column {
row { key = "panel", width = plain.width, height = 20, on_layout = "panel" },
}
end
function on_event(ev)
if ev.kind == "layout" then seen = ev end
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let evs = core.take_pending_events();
assert_eq!(evs.len(), 1, "one layout event on first sight");
for ev in &evs {
ext.on_event(ev);
}
let seen: Table = ext.lua.globals().get("seen").unwrap();
assert_eq!(seen.get::<String>("tag").unwrap(), "panel");
assert_eq!(seen.get::<f64>("h").unwrap(), 20.0);
assert!(seen.get::<f64>("w").unwrap() > 0.0);
assert_eq!(seen.get::<i64>("node_key").unwrap(), evs[0].key.0 as i64);
frame(&mut core, &mut ext);
assert!(core.take_pending_events().is_empty());
}
#[test]
fn scripts_queue_window_size_and_focus_requests() {
let mut ext = LuaExtension::from_source(
"win",
r#"
function view(env)
env.set_window_size(env.window.id, 640, 480)
env.focus_window(env.window.id)
return column { width = "grow", height = "grow" }
end
"#,
)
.unwrap();
let mut core = Core::new();
let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
assert_eq!(
core.take_window_commands(),
vec![
kui_core::WindowCommand::SetSize {
window: WindowId::MAIN,
size: Size::new(640.0, 480.0),
},
kui_core::WindowCommand::Focus(WindowId::MAIN),
]
);
assert!(core.take_window_commands().is_empty());
assert_eq!(core.viewport(), Size::new(400.0, 200.0));
}
#[test]
fn scripts_reveal_and_move_scroll_offsets() {
let mut ext = LuaExtension::from_source(
"scroll",
r#"
rows, want, jump, seen = 20, nil, nil, nil
function view(env)
if want then env.reveal(want) end
if jump then env.set_scroll(jump[1], jump[2], jump[3]) end
want, jump = nil, nil
local list = { key = "list", width = "grow", height = "grow",
scroll_y = true }
for i = 0, rows - 1 do
list[#list + 1] = row { key = "row" .. i, width = "grow",
height = 30, bg = 0x282840ff }
end
seen = env.scroll_offset(list_key)
return column(list)
end
"#,
)
.unwrap();
let list = Key::ROOT.str("list");
let row = |i: usize| list.str(&format!("row{i}"));
ext.lua.globals().set("list_key", list.0 as i64).unwrap();
let mut core = Core::new();
let frame = |core: &mut Core, ext: &mut LuaExtension| {
let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
};
frame(&mut core, &mut ext);
assert_eq!(core.scroll_offset(list), Vec2::ZERO);
ext.lua.globals().set("want", row(15).0 as i64).unwrap();
frame(&mut core, &mut ext);
let after = core.scroll_offset(list);
assert!(after.y > 0.0, "reveal moved nothing: {after:?}");
assert!(after.y <= 15.0 * 30.0, "scrolled past row 15: {after:?}");
frame(&mut core, &mut ext);
assert_eq!(core.scroll_offset(list), after);
let seen: Table = ext.lua.globals().get("seen").unwrap();
assert_eq!(seen.get::<f32>("y").unwrap(), after.y);
assert_eq!(seen.get::<f32>("x").unwrap(), 0.0);
let jump = |ext: &LuaExtension, y: f64| {
let t = ext.lua.create_table().unwrap();
t.set(1, list.0 as i64).unwrap();
t.set(2, 0.0).unwrap();
t.set(3, y).unwrap();
ext.lua.globals().set("jump", t).unwrap();
};
jump(&ext, 1e9);
frame(&mut core, &mut ext);
assert_eq!(core.scroll_offset(list).y, 400.0);
jump(&ext, -1e9);
frame(&mut core, &mut ext);
assert_eq!(core.scroll_offset(list), Vec2::ZERO);
jump(&ext, 120.0);
frame(&mut core, &mut ext);
ext.lua
.globals()
.set("want", Key::ROOT.str("ghost").0 as i64)
.unwrap();
frame(&mut core, &mut ext);
assert_eq!(core.scroll_offset(list).y, 120.0);
frame(&mut core, &mut ext);
assert_eq!(core.scroll_offset(list).y, 120.0);
}
#[test]
fn scripts_read_the_pressed_state() {
let mut ext = LuaExtension::from_source(
"press",
r#"
function view(env)
pressed = env.is_pressed(btn_key)
hovered = env.is_hovered(btn_key)
return column { key = "root", pad = 10,
row { key = "btn", width = 100, height = 40,
bg = 0x333333ff, on_click = "hit" },
}
end
"#,
)
.unwrap();
let btn = Key::ROOT.str("root").str("btn");
ext.lua.globals().set("btn_key", btn.0 as i64).unwrap();
let mut core = Core::new();
let pressed = |ext: &LuaExtension| ext.lua.globals().get::<bool>("pressed").unwrap();
let hovered = |ext: &LuaExtension| ext.lua.globals().get::<bool>("hovered").unwrap();
frame(&mut core, &mut ext);
assert!(!pressed(&ext), "idle");
core.handle_input(InputEvent::CursorMoved(Vec2::new(50.0, 30.0)));
frame(&mut core, &mut ext);
assert!(hovered(&ext) && !pressed(&ext), "hover is not press");
core.handle_input(InputEvent::MouseDown {
button: kui_core::MouseButton::Primary,
clicks: 1,
});
frame(&mut core, &mut ext);
assert!(pressed(&ext), "held down");
core.handle_input(InputEvent::MouseUp {
button: kui_core::MouseButton::Primary,
});
frame(&mut core, &mut ext);
assert!(!pressed(&ext), "released");
}
#[test]
fn scripts_read_the_drop_target() {
let mut ext = LuaExtension::from_source(
"drop",
r#"
function view(env)
over = env.is_drop_target(zone_key)
target = env.drop_target()
return column { key = "root", pad = 10,
row { key = "zone", width = 100, height = 40,
bg = 0x333333ff, drop_bg = 0x335533ff, on_drop = "files" },
}
end
"#,
)
.unwrap();
let zone = Key::ROOT.str("root").str("zone");
ext.lua.globals().set("zone_key", zone.0 as i64).unwrap();
let mut core = Core::new();
let over = |ext: &LuaExtension| ext.lua.globals().get::<bool>("over").unwrap();
let target = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("target").unwrap();
frame(&mut core, &mut ext);
assert!(!over(&ext) && target(&ext).is_none(), "idle");
let paths = vec!["/drop/1.txt".to_string()];
let evs = core.handle_input(InputEvent::DragFiles {
paths: paths.clone(),
at: Vec2::new(50.0, 30.0),
});
assert_eq!(evs[0].payload.get_str("tag"), Some("files"));
frame(&mut core, &mut ext);
assert!(over(&ext), "the files are over the zone");
assert_eq!(target(&ext), Some(zone.0 as i64));
let (dl, _) = core.output();
let lit = dl
.quads
.iter()
.find(|q| q.rect.w == 100.0)
.map(|q| q.color)
.expect("zone quad");
assert!(
(lit.g - 0x55 as f32 / 255.0).abs() < 0.01,
"drop_bg painted"
);
core.handle_input(InputEvent::DropFiles {
paths,
at: Vec2::new(50.0, 30.0),
});
frame(&mut core, &mut ext);
assert!(
!over(&ext) && target(&ext).is_none(),
"a drop ends the hover"
);
}
#[test]
fn the_env_table_is_the_documented_env_shape() {
let mut ext = LuaExtension::from_source(
"env",
r#"
function view(env)
values, calls = {}, {}
for k, v in pairs(env) do
if type(v) == "function" then calls[#calls + 1] = k
elseif type(v) == "table" then
for wk in pairs(v) do values[#values + 1] = k .. "." .. wk end
else values[#values + 1] = k end
end
return column { key = "root",
row { key = "a", focusable = true, width = 50, height = 20 },
column { key = "dock", focus_region = true,
row { key = "d", focusable = true, width = 50, height = 20 },
},
}
end
"#,
)
.unwrap();
let mut core = Core::new();
core.env.refresh_hz = Some(60.0);
core.env.system.accent = Some(kui_core::Color::hex(0x3b82f6ff));
core.env.system.locale = kui_core::Locale::new("en-US");
core.env.window.native_controls = Some(Rect::new(0.0, 0.0, 78.0, 28.0));
frame(&mut core, &mut ext);
core.set_focus(Some(Key::ROOT.str("root").str("dock").str("d")));
frame(&mut core, &mut ext);
let sorted = |name: &str| -> Vec<String> {
let mut v: Vec<String> = ext.lua.globals().get(name).unwrap();
v.sort();
v
};
let mut documented: Vec<String> = kui_core::schema::ENV_FIELDS
.iter()
.flat_map(|f| f.lua.iter().map(|k| (*k).to_string()))
.collect();
documented.extend(
kui_core::schema::THEME_ROLES
.iter()
.map(|r| format!("theme.{}", r.name)),
);
documented.push("theme.appearance".into());
documented.push("theme.disabled_opacity".into());
documented.extend(
kui_core::schema::METRIC_ROLES
.iter()
.map(|r| format!("metrics.{}", r.name)),
);
documented.push("tokens.colors".into());
documented.push("tokens.lengths".into());
documented.sort();
assert_eq!(
sorted("values"),
documented,
"env's value keys and schema::ENV_FIELDS + THEME_ROLES + METRIC_ROLES + tokens disagree"
);
let mut doors: Vec<String> = kui_core::schema::DOORS
.iter()
.filter_map(|d| match d.lua {
kui_core::schema::Cell::Is(name) => Some(name.to_string()),
_ => None,
})
.collect();
doors.sort();
assert_eq!(
sorted("calls"),
doors,
"env's queries and verbs and schema::DOORS's Lua column disagree; update env_table's doc too"
);
}
#[test]
fn a_script_declares_tokens_and_references_them_by_name() {
let mut ext = LuaExtension::from_source(
"tokens",
r##"
tokens = {
colors = { peach = "#ffcc99", ink = { light = "#111111", dark = "#eeeeee" } },
lengths = { side_w = 132, gap = 6 },
}
function view(env)
seen = env.tokens
return column { pad = "$gap", gap = "$gap",
row { key = "a", width = "$side_w", height = 20, bg = "$peach",
border = { w = "$gap", color = "$ink" }, radius = "$radius" },
row { key = "b", width = 20, height = "$side_w", bg = "$surface",
pad = { l = "$gap", r = 2 } },
text({ "x", { "y", color = "$peach", bg = "$ink" } }, { size = "$gap", color = "$peach" }),
}
end
"##,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
assert!(core.take_warnings().is_empty());
let peach = Color::hex(0xffcc99ff);
let paper = Color::hex(0xeeeeeeff);
let radius = core.metrics().radius;
let surface = core.theme().surface;
let dl = core.output().0;
let a = dl
.quads
.iter()
.find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
.expect("the peach box");
assert_eq!(a.rect.w, 132.0, "width from a length token");
assert_eq!(a.border_w, 6.0);
assert_eq!(
a.border_color, paper,
"the dark half on an unknown appearance"
);
assert_eq!(a.radius[0], radius, "$radius is the metric");
let b = dl
.quads
.iter()
.find(|q| q.color == surface && q.rect.h == 132.0)
.expect("the $surface box, 132 tall");
assert_eq!(b.rect.w, 20.0);
let seen: Table = ext.lua.globals().get("seen").unwrap();
let colors: Table = seen.get("colors").unwrap();
let lengths: Table = seen.get("lengths").unwrap();
assert_eq!(colors.get::<u32>("peach").unwrap(), 0xffcc99ff);
assert_eq!(colors.get::<u32>("ink").unwrap(), 0xeeeeeeff);
assert_eq!(lengths.get::<f32>("side_w").unwrap(), 132.0);
core.set_system(kui_core::SystemEnv {
appearance: kui_core::Appearance::Light,
..Default::default()
});
frame(&mut core, &mut ext);
let a = core
.output()
.0
.quads
.iter()
.find(|q| q.color == peach && q.kind == kui_core::QuadKind::Solid)
.unwrap();
assert_eq!(a.border_color, Color::hex(0x111111ff));
}
#[test]
fn a_script_derives_a_token_from_another() {
let mut ext = LuaExtension::from_source(
"derived",
r##"
tokens = {
colors = {
peach = "#ffcc99",
z_lit = { from = "peach", ops = { "lift", 0.5 } },
a_deep = { from = "z_lit", ops = { { "alpha", 0.5 }, { "mix", "peach", 0.0 } } },
up = { from = "surface", ops = { { "raise", 0.25 } } },
bad = { from = "nothing" },
},
}
function view(env)
seen = env.tokens.colors
return column {
row { key = "a", width = 20, height = 20, bg = "$a_deep" },
row { key = "b", width = 20, height = 20, bg = "$bad" },
}
end
"##,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
let warnings = core.take_warnings();
let codes: Vec<&str> = warnings.iter().map(|w| w.code).collect();
assert_eq!(codes, ["unknown-token"], "{warnings:?}");
assert!(
warnings[0].message.contains("`$bad`") && warnings[0].message.contains("`$nothing`")
);
let seen: Table = ext.lua.globals().get("seen").unwrap();
assert_eq!(seen.get::<u32>("z_lit").unwrap(), 0xffe6ccff);
assert_eq!(seen.get::<u32>("a_deep").unwrap(), 0xffe6cc80);
let surface = core.theme().surface;
assert_eq!(
seen.get::<u32>("up").unwrap(),
surface.mix(Color::WHITE, 0.25).to_hex(),
"a role source, raised toward the dark base's front"
);
assert!(seen.get::<mlua::Value>("bad").unwrap().is_nil());
let dl = core.output().0;
assert!(
dl.quads.iter().any(|q| q.color.to_hex() == 0xffe6cc80),
"$a_deep painted the derived colour"
);
let refused = |colors: &str| {
LuaExtension::from_source(
"bad",
&format!(
"tokens = {{ colors = {{ {colors} }} }}\nfunction view() return column {{}} end"
),
)
.err()
.map(|e| e.to_string())
.unwrap_or_default()
};
assert!(
refused(r#"x = { from = "peach", ops = { { "glow", 1 } } }"#)
.contains("unknown verb \"glow\"")
);
assert!(
refused(r#"x = { from = "peach", ops = { { "mix", 0.5 } } }"#)
.contains("mix takes a colour and a number")
);
assert!(
refused(r#"x = { from = "peach", ops = { { "lift", "lots" } } }"#)
.contains("number is a number")
);
assert!(refused(r#"x = { from = 3 }"#).contains("from names a colour token or role"));
assert!(refused(r#"x = { from = "peach", glow = 1 }"#).contains("unknown key \"glow\""));
}
#[test]
fn a_scripts_tokens_sit_over_the_hosts() {
let mut ext = LuaExtension::from_source(
"guest",
r##"
tokens = { colors = { grey = "#808080" } }
function view(env)
first = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
if redeclare then
env.set_tokens { colors = { peach = "#ffaa77" }, lengths = { w = 40 } }
end
second = { peach = env.tokens.colors.peach, grey = env.tokens.colors.grey }
return column { row { key = "a", width = redeclare and "$w" or 10, height = 10, bg = "$peach" } }
end
"##,
)
.unwrap();
let mut core = Core::new();
core.set_tokens(kui_core::Tokens::new().color("peach", Color::hex(0xffcc99ff)));
frame(&mut core, &mut ext);
let first: Table = ext.lua.globals().get("first").unwrap();
assert_eq!(
first.get::<u32>("peach").unwrap(),
0xffcc99ff,
"the host's peach"
);
assert_eq!(
first.get::<u32>("grey").unwrap(),
0x808080ff,
"its own grey"
);
assert!(
core.token_lookup().color("grey").is_err(),
"the guest's grey is not the host's"
);
assert!(core.take_warnings().is_empty());
ext.lua.globals().set("redeclare", true).unwrap();
frame(&mut core, &mut ext);
let second: Table = ext.lua.globals().get("second").unwrap();
assert_eq!(
second.get::<u32>("peach").unwrap(),
0xffaa77ff,
"its own peach now"
);
assert!(
second.get::<Option<u32>>("grey").unwrap().is_none(),
"replaced whole"
);
let a = core
.output()
.0
.quads
.iter()
.find(|q| q.color == Color::hex(0xffaa77ff))
.expect("painted the script's peach");
assert_eq!(a.rect.w, 40.0);
assert_eq!(
core.token_lookup().color("peach"),
Ok(Color::hex(0xffcc99ff)),
"the host's table did not move"
);
}
#[test]
fn a_missing_token_warns_and_paints_nothing() {
let mut ext = LuaExtension::from_source(
"missing",
r##"
tokens = { colors = { surface = "#ff0000", peach = "#ffcc99" }, lengths = { gap = 6 } }
function view(env)
return column {
row { key = "a", width = "$gap", height = 10, bg = "$peech", pad = "$peach" },
row { key = "b", width = "$peach", height = 10, bg = "$gap" },
text("still here", { color = "$peech", size = "$gap" }),
text("still here", { size = 6 }),
text("still here"),
}
end
"##,
)
.unwrap();
let mut core = Core::new();
core.set_inspect(true);
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
let ws = core.take_warnings();
let mut codes: Vec<&str> = ws.iter().map(|w| w.code).collect();
codes.sort();
assert_eq!(
codes,
[
kui_core::diag::RESERVED_TOKEN,
kui_core::diag::UNKNOWN_TOKEN,
kui_core::diag::UNKNOWN_TOKEN,
kui_core::diag::UNKNOWN_TOKEN,
],
"surface refused once; peech, peach-as-length and gap-as-colour once each: {ws:?}"
);
assert!(
ws.iter().any(|w| w
.message
.contains("`$gap` is a length token, and this slot takes a color")),
"{ws:?}"
);
let fg = core.theme().fg;
let dl = core.output().0;
assert!(
dl.quads
.iter()
.all(|q| q.kind != kui_core::QuadKind::Solid || q.color.a == 0.0)
);
let glyphs: Vec<_> = dl
.quads
.iter()
.filter(|q| {
matches!(
q.kind,
kui_core::QuadKind::GlyphMask | kui_core::QuadKind::GlyphSubpixel
)
})
.collect();
assert!(!glyphs.is_empty(), "the text painted");
assert!(glyphs.iter().all(|q| q.color == fg), "in the foreground");
let heights: Vec<f32> = core
.nodes()
.iter()
.filter(|n| n.text.as_deref() == Some("still here"))
.map(|n| n.rect.h)
.collect();
assert_eq!(heights.len(), 3, "{heights:?}");
assert!(heights[0] > 0.0, "{heights:?}");
assert_eq!(heights[0], heights[1], "$gap is the literal 6: {heights:?}");
assert!(heights[2] > heights[0], "and not the default: {heights:?}");
}
#[test]
fn a_gradient_stop_that_misses_is_left_out() {
let mut ext = LuaExtension::from_source(
"gradient",
r##"
tokens = { colors = { peach = "#ffcc99" } }
function view(env)
return column {
row { key = "three", width = 40, height = 10,
gradient = { stops = { "$peach", "$peech", "#0000ff" } } },
row { key = "two", width = 40, height = 10, bg = "#000000",
gradient = { stops = { "$peach", "$peech" } } },
}
end
"##,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
let codes: Vec<&str> = core.take_warnings().iter().map(|w| w.code).collect();
assert_eq!(codes, [kui_core::diag::UNKNOWN_TOKEN], "once, for the name");
let dl = core.output().0;
let images: Vec<_> = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Image)
.collect();
assert_eq!(images.len(), 1, "the three-stop row's two");
assert!(
images[0].rect.y < 10.0,
"on the first row: {:?}",
images[0].rect
);
let black = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid && q.color == Color::hex(0x000000ff))
.count();
assert_eq!(black, 1, "the second row's bg");
}
#[test]
fn a_token_reaches_a_min_a_stroke_and_a_stop_and_misses_by_leaving_the_slot() {
let mut ext = LuaExtension::from_source(
"everywhere",
r##"
tokens = { colors = { peach = "#ffcc99" }, lengths = { gap = 6, wide = 40 } }
function view(env)
return column { pad = 4,
row { key = "clamp", width = 10, height = 10, min_width = "$wide" },
row { key = "typo", width = 10, height = 10, min_width = "$nope" },
line { key = "stroke", from = { 0, 0 }, to = { 30, 0 }, width = "$gap", color = "$peach" },
row { key = "anim", width = 10, height = 10, transition = 100,
keyframes = { { bg = "$peach", width = "$wide" }, { bg = "$peech", radius = "$gap" } },
enter = { width = "$nothing", bg = "$peach" } },
}
end
"##,
)
.unwrap();
let mut core = Core::new();
core.set_inspect(true);
frame(&mut core, &mut ext);
let nodes = core.nodes();
let by = |label: &str| {
nodes
.iter()
.find(|n| n.label.as_deref() == Some(label))
.unwrap()
};
assert_eq!(by("clamp").rect.w, 40.0, "the clamp is the token's 40 px");
assert_eq!(
by("typo").rect.w,
10.0,
"a miss leaves the row at its default"
);
let dl = core.output().0;
let seg = dl
.quads
.iter()
.find(|q| q.kind == kui_core::QuadKind::Segment)
.expect("the line drew");
assert_eq!(seg.color, Color::hex(0xffcc99ff));
assert_eq!(seg.border_w, 6.0, "the stroke is the token's width");
let ws = core.take_warnings();
let mut names: Vec<String> = ws
.iter()
.filter(|w| w.code == kui_core::diag::UNKNOWN_TOKEN)
.map(|w| w.message.clone())
.collect();
names.sort();
assert_eq!(names.len(), 3, "nope, peech, nothing: {ws:?}");
assert!(names.iter().any(|m| m.contains("`$nope`")), "{names:?}");
assert!(names.iter().any(|m| m.contains("`$peech`")), "{names:?}");
assert!(names.iter().any(|m| m.contains("`$nothing`")), "{names:?}");
}
#[test]
fn scripts_move_focus_with_the_verbs() {
let mut ext = LuaExtension::from_source(
"focus",
r#"
function view(env)
if cmd == "set" then env.set_focus(target)
elseif cmd == "blur" then env.blur()
elseif cmd == "next" then env.focus_next()
elseif cmd == "prev" then env.focus_prev() end
cmd = nil
-- `env.focus` is a value the host filled in before view()
-- ran, so it lags a verb called above by a frame;
-- `env.is_focused` is a call and answers about now.
seen_focus = env.focus
seen_live = env.is_focused(target or 0)
seen_window = env.focused
return column { key = "root", pad = 10,
row { key = "a", focusable = true, width = 50, height = 20 },
row { key = "b", focusable = true, width = 50, height = 20 },
row { key = "c", focusable = true, width = 50, height = 20 },
}
end
"#,
)
.unwrap();
let root = Key::ROOT.str("root");
let (a, b, c) = (root.str("a"), root.str("b"), root.str("c"));
let mut core = Core::new();
let cmd = |ext: &LuaExtension, c: &str| ext.lua.globals().set("cmd", c).unwrap();
let seen = |ext: &LuaExtension| ext.lua.globals().get::<Option<i64>>("seen_focus").unwrap();
let live = |ext: &LuaExtension| ext.lua.globals().get::<bool>("seen_live").unwrap();
frame(&mut core, &mut ext);
assert_eq!(core.focus(), None);
assert_eq!(seen(&ext), None, "nil for no focus, not 0");
ext.lua.globals().set("target", b.0 as i64).unwrap();
cmd(&ext, "set");
frame(&mut core, &mut ext);
assert_eq!(core.focus(), Some(b));
assert_eq!(seen(&ext), None, "the value lags a same-frame verb");
assert!(live(&ext), "the query does not");
frame(&mut core, &mut ext);
assert_eq!(
seen(&ext),
Some(b.0 as i64),
"and the next frame carries it"
);
cmd(&ext, "next");
frame(&mut core, &mut ext);
assert_eq!(core.focus(), Some(c));
cmd(&ext, "next");
frame(&mut core, &mut ext);
assert_eq!(core.focus(), Some(a), "wraps");
cmd(&ext, "prev");
frame(&mut core, &mut ext);
assert_eq!(core.focus(), Some(c), "wraps back");
cmd(&ext, "blur");
frame(&mut core, &mut ext);
assert_eq!(core.focus(), None);
frame(&mut core, &mut ext);
assert_eq!(seen(&ext), None);
ext.lua.globals().set("target", "c").unwrap();
cmd(&ext, "set");
frame(&mut core, &mut ext);
assert_eq!(core.focus(), Some(c), "set_focus(\"c\") resolves the label");
assert!(live(&ext), "and is_focused(\"c\") answers about it");
assert!(core.take_warnings().is_empty());
ext.lua.globals().set("target", "nope").unwrap();
cmd(&ext, "set");
let mut ui = core.frame(Size::new(800.0, 600.0), 1.0);
let e = ext.view(&Slot::root(), &mut ui).unwrap_err().to_string();
ui.finish();
assert!(
e.contains("no node is keyed \"nope\"") && e.contains("integer key"),
"{e}"
);
assert!(ext.lua.globals().get::<bool>("seen_window").unwrap());
}
#[test]
fn a_shared_label_resolves_to_the_first_and_warns() {
let mut ext = LuaExtension::from_source(
"dup",
r#"
function view(env)
if go then env.set_focus("item"); go = nil end
return column { key = "root",
row { row { key = "item", focusable = true, width = 50, height = 20 } },
row { row { key = "item", focusable = true, width = 50, height = 20 } },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
frame(&mut core, &mut ext);
ext.lua.globals().set("go", true).unwrap();
frame(&mut core, &mut ext);
assert_eq!(
core.focus(),
Some(Key::ROOT.str("root").index(0).str("item")),
"the first, under the auto-keyed first row"
);
let ws = core.take_warnings();
assert_eq!(ws.len(), 1, "{ws:?}");
assert_eq!(ws[0].code, kui_core::diag::AMBIGUOUS_KEY);
assert!(ws[0].message.contains("\"item\""), "{}", ws[0].message);
}
#[test]
fn scripts_open_a_menu_and_hear_the_row() {
let mut ext = LuaExtension::from_source(
"menu",
r#"
frames = 0
function view(env)
frames = frames + 1
if frames == 2 then
opened = env.open_menu("card", 40, 30, {
{ role = "copy" },
{ role = "separator" },
-- The wire spelling the event reports, and the
-- snake one a script may have learned first.
{ role = "selectAll" },
{ role = "look_up" },
{ label = "Wrap", checked = true },
{ label = "Inspect", id = "inspect" },
})
end
return column {
column { key = "card", selectable = true, text("one", { size = 14 }) },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
let frame = |core: &mut Core, ext: &mut LuaExtension| {
let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
};
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
assert!(ext.lua.globals().get::<bool>("opened").unwrap());
let roles: Vec<_> = core
.menu()
.expect("open")
.items
.iter()
.map(|i| (i.role, i.checked))
.collect();
assert_eq!(
roles,
[
(kui_core::MenuRole::Copy, false),
(kui_core::MenuRole::Separator, false),
(kui_core::MenuRole::SelectAll, false),
(kui_core::MenuRole::LookUp, false),
(kui_core::MenuRole::Custom, true),
(kui_core::MenuRole::Custom, false),
]
);
frame(&mut core, &mut ext);
let tree = core.access_tree();
assert_eq!(
tree.nodes
.iter()
.find(|n| n.name.as_deref() == Some("Wrap"))
.expect("the checked row")
.checked,
Some(true)
);
let row = tree
.nodes
.iter()
.find(|n| n.name.as_deref() == Some("Inspect"))
.expect("the menu drew")
.rect;
let at = kui_core::Vec2::new(row.x + row.w / 2.0, row.y + row.h / 2.0);
core.handle_input(InputEvent::CursorMoved(at));
core.handle_input(InputEvent::mouse_down(1));
let events = core.handle_input(InputEvent::mouse_up());
assert_eq!(events.len(), 1, "{events:?}");
assert_eq!(events[0].payload.get_str("item"), Some("inspect"));
assert_eq!(
events[0].origin,
OriginId(1),
"posted with the origin that asked for the menu"
);
}
#[test]
fn scripts_select_and_read_a_scope() {
let mut ext = LuaExtension::from_source(
"sel",
r#"
frames = 0
function view(env)
frames = frames + 1
if frames > 1 then
before = env.selection_text()
took = env.select_all_in("card")
text_out = env.selection_text()
not_a_scope = env.select_all_in("plain")
end
return column {
column { key = "card", selectable = true,
text("one", { size = 14 }),
text("two", { size = 14 }) },
row { key = "plain", width = 10, height = 10 },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
let frame = |core: &mut Core, ext: &mut LuaExtension| {
let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
};
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
let g = ext.lua.globals();
assert!(matches!(
g.get::<mlua::Value>("before").unwrap(),
mlua::Value::Nil
));
assert!(g.get::<bool>("took").unwrap());
assert_eq!(g.get::<String>("text_out").unwrap(), "one\ntwo");
assert!(
!g.get::<bool>("not_a_scope").unwrap(),
"a node that drew no text is not a scope"
);
}
#[test]
fn scripts_map_points_to_bytes_on_a_line_of_runs() {
let mut ext = LuaExtension::from_source(
"hit",
r#"
frames = 0
function view(env)
local mono = { size = 14, family = "mono" }
local w = env.measure_text("M", mono, 0).width
frames = frames + 1
-- A label nothing has declared yet is an error by name
-- (F5), so the first frame declares and the next asks.
if frames > 1 then
hit = env.text_hit("line", 7.2 * w, 5)
seam = env.caret_rect("line", 4)
far = env.text_hit("line", 390, 5)
none = env.caret_rect("plain", 0)
end
cell = w
return column {
row { key = "line",
text("let ", mono),
row { bg = 0x3b5bd455, text("value", mono) },
text(" = 1;", mono) },
row { key = "plain", width = 10, height = 10 },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
let frame = |core: &mut Core, ext: &mut LuaExtension| {
let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
};
frame(&mut core, &mut ext);
let hit: mlua::Value = ext.lua.globals().get("hit").unwrap();
assert!(
matches!(hit, mlua::Value::Nil),
"nothing drawn before the first frame"
);
frame(&mut core, &mut ext);
let hit: mlua::Table = ext.lua.globals().get("hit").unwrap();
assert_eq!(
hit.get::<usize>("byte").unwrap(),
7,
"the fourth cell of \"value\""
);
assert_eq!(hit.get::<u32>("line").unwrap(), 0);
let cell: f32 = ext.lua.globals().get("cell").unwrap();
let seam: mlua::Table = ext.lua.globals().get("seam").unwrap();
let x: f32 = seam.get("x").unwrap();
assert!((x - 4.0 * cell).abs() < 0.75, "{x} vs {}", 4.0 * cell);
assert_eq!(seam.get::<f32>("w").unwrap(), 0.0);
let far: mlua::Table = ext.lua.globals().get("far").unwrap();
assert_eq!(
far.get::<usize>("byte").unwrap(),
14,
"the end, across the runs"
);
let none: mlua::Value = ext.lua.globals().get("none").unwrap();
assert!(matches!(none, mlua::Value::Nil), "a node that drew no text");
}
#[test]
fn scripts_draw_a_screen_of_cells() {
let mut ext = LuaExtension::from_source(
"term",
r#"
function view(env)
return column { cells { key = "term", rows = 2, cols = 11, size = 14, family = "mono",
lines = { "hello world", " bye" },
runs = { { 1, 2, 3, 0xff0000ff, 0x0000ffff, 1 } },
cursor_at = { 1, 4 }, cursor_shape = "underline" } }
end
"#,
)
.unwrap();
let mut core = Core::new();
let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
let (dl, _) = core.output();
let glyphs = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::GlyphMask)
.count();
assert_eq!(glyphs, 13, "hello world + bye");
let tree = core.access_tree();
let term = tree
.nodes
.iter()
.find(|n| n.role == kui_core::Role::Terminal)
.expect("a terminal node");
assert_eq!(term.value.as_deref(), Some("hello world\n bye"));
}
#[test]
fn a_lua_grid_selects_by_cell_word_and_row() {
let mut ext = LuaExtension::from_source(
"term",
r#"
function view(env)
said = env.selection_text()
return column { cells { key = "term", rows = 2, cols = 12,
size = 14, family = "mono", line_height = 20,
origin_line = 900, selectable = true,
lines = { "hello world", "bye there" } } }
end
"#,
)
.unwrap();
let mut core = Core::new();
let frame = |core: &mut Core, ext: &mut LuaExtension| {
let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
};
frame(&mut core, &mut ext);
let said = |ext: &LuaExtension| ext.lua.globals().get::<Option<String>>("said").unwrap();
let w = core
.measure_text(
"M",
&kui_core::TextStyle::new(14.0)
.family(kui_core::FontFamily::Mono)
.line_height(20.0),
None,
)
.width;
let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
let click = |core: &mut Core, p: Vec2, clicks: u8| {
core.handle_input(InputEvent::CursorMoved(p));
core.handle_input(InputEvent::MouseDown {
button: kui_core::MouseButton::Primary,
clicks,
});
core.handle_input(InputEvent::MouseUp {
button: kui_core::MouseButton::Primary,
});
};
click(&mut core, at(0, 8), 2);
frame(&mut core, &mut ext);
assert_eq!(said(&ext).as_deref(), Some("world"), "a double click");
click(&mut core, at(1, 1), 3);
frame(&mut core, &mut ext);
assert_eq!(said(&ext).as_deref(), Some("bye there"), "a triple click");
}
#[test]
fn a_grid_selection_reads_back_as_absolute_lines_and_columns() {
let mut ext = LuaExtension::from_source(
"term",
r#"
function view(env)
sel = env.cell_selection()
return column { cells { key = "term", rows = 2, cols = 12,
size = 14, family = "mono", line_height = 20,
origin_line = 900, selectable = true,
lines = { "hello world", "bye there" } } }
end
"#,
)
.unwrap();
let mut core = Core::new();
let frame = |core: &mut Core, ext: &mut LuaExtension| {
let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
};
frame(&mut core, &mut ext);
assert!(
ext.lua
.globals()
.get::<mlua::Value>("sel")
.unwrap()
.is_nil(),
"nothing selected yet"
);
let w = core
.measure_text(
"M",
&kui_core::TextStyle::new(14.0)
.family(kui_core::FontFamily::Mono)
.line_height(20.0),
None,
)
.width;
let at = |r: usize, c: usize| Vec2::new(w * (c as f32 + 0.5), 20.0 * (r as f32 + 0.5));
core.handle_input(InputEvent::CursorMoved(at(1, 4)));
core.handle_input(InputEvent::MouseDown {
button: kui_core::MouseButton::Primary,
clicks: 1,
});
core.handle_input(InputEvent::CursorMoved(at(0, 1)));
core.handle_input(InputEvent::MouseUp {
button: kui_core::MouseButton::Primary,
});
frame(&mut core, &mut ext);
let sel: Table = ext.lua.globals().get("sel").unwrap();
let end = |name: &str| -> (u64, usize) {
let t: Table = sel.get(name).unwrap();
(t.get("line").unwrap(), t.get("col").unwrap())
};
assert_eq!(end("anchor"), (901, 4), "the press, on the second row");
assert_eq!(end("focus"), (900, 1), "the pointer, backwards");
assert_eq!(
sel.get::<i64>("node").unwrap(),
core.key_of("term").unwrap().0 as i64
);
assert!(!sel.get::<bool>("block").unwrap());
}
#[test]
fn spans_carry_their_decorations() {
let mut ext = LuaExtension::from_source(
"deco",
r#"
function view(env)
return row { text({ "let ", { "value", bg = 0x3b5bd455, underline = true },
" = 1;" }, { size = 14, family = "mono" }) }
end
"#,
)
.unwrap();
let mut core = Core::new();
let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
let (dl, _) = core.output();
let solids = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid)
.count();
assert_eq!(solids, 2, "a background and an underline");
}
#[test]
fn underlines_have_a_colour_and_a_shape_of_their_own() {
let mut ext = LuaExtension::from_source(
"k4",
r#"
function view(env)
return column {
text({ "let ", { "value", underline_color = 0xff0000ff, underline_style = "wavy" } },
{ size = 14, family = "mono" }),
text("warn", { size = 14, family = "mono", underline_color = 0x00ff00ff }),
cells { rows = 1, cols = 3, size = 14, family = "mono",
lines = { "abc" }, runs = { { 0, 0, 3, 0, 0, 32, 0xff0000ff } } },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
let (dl, _) = core.output();
let red = Color::hex(0xff0000ff);
let segs: Vec<_> = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Segment)
.collect();
assert!(
segs.len() >= 6,
"a wave under the span and an undercurl over the cells: {}",
segs.len()
);
assert!(
segs.iter().all(|q| q.color == red),
"in the underline colour"
);
let solids: Vec<_> = dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Solid)
.collect();
assert_eq!(solids.len(), 1, "the text's solid line, coloured");
assert_eq!(solids[0].color, Color::hex(0x00ff00ff));
let mut bad = LuaExtension::from_source(
"k4bad",
r#"function view(env) return text({ { "x", underline_style = "squiggly" } }) end"#,
)
.unwrap();
let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
let err = bad.view(&Slot::root(), &mut ui).unwrap_err();
assert!(err.contains("solid | wavy | dotted"), "{err}");
}
#[test]
fn a_line_takes_a_dash_as_a_length_a_pair_or_four() {
let segments = |view: &str| -> Result<usize, String> {
let mut ext = LuaExtension::from_source("dash", view).unwrap();
let mut core = Core::new();
let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
let lowered = ext.view(&Slot::root(), &mut ui);
ui.finish();
lowered?;
let (dl, _) = core.output();
Ok(dl
.quads
.iter()
.filter(|q| q.kind == kui_core::QuadKind::Segment)
.count())
};
let line = |dash: &str| {
format!(
"function view(env) return column {{ width = 200, height = 40,
line {{ from = {{0, 20}}, to = {{100, 20}}, width = 2, {dash} }} }} end"
)
};
assert_eq!(segments(&line("")), Ok(1));
assert_eq!(segments(&line("dash = 5")), Ok(10));
assert_eq!(segments(&line("dash = {6, 4}")), Ok(10));
assert_eq!(segments(&line("dash = {6, 4}, dash_offset = 5")), Ok(10));
assert_eq!(segments(&line("dash = {10, 4, 2, 4}")), Ok(10));
let err = segments(&line("dash = {1, 2, 3}")).unwrap_err();
assert!(err.contains("dash is a length"), "{err}");
}
#[test]
fn features_are_a_text_option() {
let mut ext = LuaExtension::from_source(
"features",
r#"
function view(env)
return row { text("fi ->", { size = 16 }),
text("fi ->", { size = 16, features = "liga=0 calt=0" }) }
end
"#,
)
.unwrap();
let mut core = Core::new();
let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
assert_eq!(core.text_cache_len(), 2);
}
#[test]
fn a_style_table_shared_by_three_texts_is_three_texts() {
let mut ext = LuaExtension::from_source(
"shared",
r#"
local mono = { size = 14, family = "mono" }
function view(env)
return row { text("a", mono), text("bb", mono), text("ccc", mono) }
end
"#,
)
.unwrap();
let mut core = Core::new();
let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
let names: Vec<String> = core
.access_tree()
.nodes
.iter()
.filter_map(|n| n.value.clone().or_else(|| n.name.clone()))
.collect();
let joined = names.join("|");
assert!(
joined.contains("a") && joined.contains("bb") && joined.contains("ccc"),
"three different texts, got {joined:?}"
);
assert_eq!(
core.text_cache_len(),
3,
"three strings shaped, not one three times"
);
}
#[test]
fn scripts_slice_a_long_list_from_the_geometry() {
let mut ext = LuaExtension::from_source(
"virtual",
r#"
ROWS, ROW_H, built = 10000, 30, 0
function view(env)
local g = env.scroll_geometry(list_key)
-- No layout yet: fall back to the window for one frame.
local h = g and g.h or 200
local top = g and g.offset.y or 0
local first = math.min(ROWS, math.max(0, math.floor(top / ROW_H)))
local last = math.min(ROWS, math.ceil((top + h) / ROW_H))
local list = { key = "list", width = "grow", height = "grow",
scroll_y = true }
if first > 0 then
list[#list + 1] = row { key = "lead", width = "grow",
height = first * ROW_H }
end
for i = first, last - 1 do
list[#list + 1] = row { key = "row" .. i, width = "grow",
height = ROW_H, bg = 0x282840ff }
end
if last < ROWS then
list[#list + 1] = row { key = "tail", width = "grow",
height = (ROWS - last) * ROW_H }
end
built = last - first
return column(list)
end
"#,
)
.unwrap();
let list = Key::ROOT.str("list");
ext.lua.globals().set("list_key", list.0 as i64).unwrap();
let mut core = Core::new();
let frame = |core: &mut Core, ext: &mut LuaExtension| {
let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
};
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
let built: i64 = ext.lua.globals().get("built").unwrap();
assert_eq!(built, 7, "200 / 30 rounded up");
let g = core.scroll_geometry(list).expect("laid out");
assert_eq!(g.content.h, 10_000.0 * 30.0);
core.set_scroll(list, Vec2::new(0.0, 1e9));
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
assert_eq!(core.scroll_offset(list).y, 10_000.0 * 30.0 - 200.0);
let built: i64 = ext.lua.globals().get("built").unwrap();
assert_eq!(built, 7, "still a screenful at the far end");
}
#[test]
fn the_prelude_virtualizes_a_long_list_in_one_call() {
let mut ext = LuaExtension::from_source(
"virtual",
r#"
ROWS, ROW_H, first_built, built = 10000, 30, -1, 0
function view(env)
built, first_built = 0, -1
return uniform_list(env,
{ key = "list", rows = ROWS, row_h = ROW_H,
width = "grow", height = "grow" },
function(i)
built = built + 1
if first_built < 0 then first_built = i end
return column { fill = true, bg = 0x282840ff,
on_click = { kind = "pick", row = i } }
end)
end
"#,
)
.unwrap();
let list = Key::ROOT.str("list");
let mut core = Core::new();
let frame = |core: &mut Core, ext: &mut LuaExtension| {
let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
};
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
let built: i64 = ext.lua.globals().get("built").unwrap();
assert_eq!(built, 9, "200 / 30 rounded up, plus two rows of overscan");
let g = core.scroll_geometry(list).expect("laid out");
assert_eq!(g.content.h, 10_000.0 * 30.0);
core.set_scroll(list, Vec2::new(0.0, 300.0 * 30.0));
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
let first: i64 = ext.lua.globals().get("first_built").unwrap();
assert_eq!(first, 298, "two rows of overscan above row 300");
let built: i64 = ext.lua.globals().get("built").unwrap();
assert_eq!(built, 11, "a screenful with overscan on both sides now");
assert!(
core.access_tree()
.nodes
.iter()
.any(|n| n.key == list.index(300).index(0)),
"row 300 is not keyed by its data index"
);
}
#[test]
fn a_view_names_a_family_and_draws_in_it() {
let mut ext = LuaExtension::from_source(
"fam",
r#"
named, sans, missed = nil, nil, nil
function view(env)
named = env.measure_text("iiiWWW", { family = "Fixture Mono", size = 20 }).width
sans = env.measure_text("iiiWWW", { family = "sans", size = 20 }).width
return column {
text("iiiWWW", { family = "Fixture Mono", size = 20 }),
text("x", { family = "No Such Family 7" }),
}
end
"#,
)
.unwrap();
let mut core = Core::new();
core.add_font_data(kui_core::testing::font_face(
"Fixture Mono",
400,
false,
true,
))
.unwrap();
let mut ui = core.frame(Size::new(400.0, 100.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
let handle = core.add_system_font("Fixture Mono").unwrap();
let expected = core
.measure_text("iiiWWW", &kui_core::TextStyle::new(20.0).font(handle), None)
.width;
let g = ext.lua.globals();
let (named, sans) = (
g.get::<f32>("named").unwrap(),
g.get::<f32>("sans").unwrap(),
);
assert_eq!(named, expected, "the face the host's handle draws");
assert_ne!(named, sans, "and not sans");
let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
assert_eq!(codes, ["unknown-family"]);
}
#[test]
fn the_prelude_reveals_a_row_styles_rows_and_splits() {
let mut ext = LuaExtension::from_source(
"dx22",
r#"
target, scrolled, page = nil, nil, nil
function view(env)
if target then scrolled = reveal_row(env, "list", target, 20) end
page = rows_in_view(env, "list", 20)
zero = rows_in_view(env, "list", 0)
return row { width = 300, height = 100,
uniform_list(env,
{ key = "list", rows = 50, row_h = 20, width = 200, height = 100,
row_props = function(i) return { on_click = { kind = "pick", row = i } } end },
function(i) return text("row " .. i) end),
splitter(env, { key = "bar", on_drag = { kind = "split" } }),
column { width = "grow", height = "grow" },
}
end
"#,
)
.unwrap();
let mut core = Core::new();
let frame = |core: &mut Core, ext: &mut LuaExtension| {
let mut ui = core.frame(Size::new(300.0, 100.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
};
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
assert_eq!(ext.lua.globals().get::<i64>("page").unwrap(), 5);
assert!(
core.take_warnings().is_empty(),
"every prop the three spell is known"
);
ext.lua.globals().set("target", 40).unwrap();
frame(&mut core, &mut ext);
assert!(ext.lua.globals().get::<bool>("scrolled").unwrap());
let list = core.key_of("list").unwrap();
assert_eq!(
core.scroll_offset(list),
Vec2::new(0.0, 760.0),
"row 40 to the middle"
);
core.handle_input(InputEvent::CursorMoved(Vec2::new(10.0, 45.0)));
core.handle_input(InputEvent::mouse_down(1));
let up = core.handle_input(InputEvent::mouse_up());
assert_eq!(up.iter().find_map(|e| e.payload.get_int("row")), Some(40));
core.handle_input(InputEvent::CursorMoved(Vec2::new(202.0, 50.0)));
core.handle_input(InputEvent::mouse_down(1));
core.handle_input(InputEvent::CursorMoved(Vec2::new(225.0, 50.0)));
let end = core.handle_input(InputEvent::mouse_up());
let d = end.iter().find_map(|e| e.drag()).expect("the drag's end");
assert_eq!(d.ratio().x, 0.75);
ext.lua.globals().set("target", 500).unwrap();
frame(&mut core, &mut ext);
assert!(!ext.lua.globals().get::<bool>("scrolled").unwrap());
assert_eq!(core.scroll_offset(list), Vec2::new(0.0, 760.0));
assert_eq!(ext.lua.globals().get::<i64>("zero").unwrap(), 0);
}
#[test]
fn a_uniform_list_that_shrank_lands_in_one_frame() {
let mut ext = LuaExtension::from_source(
"virtual",
r#"
ROWS, first_built = 200, -1
function view(env)
first_built = -1
return uniform_list(env,
{ key = "list", rows = ROWS, row_h = 20,
width = "grow", height = "grow" },
function(i)
if first_built < 0 then first_built = i end
return column { fill = true, bg = 0x282840ff }
end)
end
"#,
)
.unwrap();
let list = Key::ROOT.str("list");
let mut core = Core::new();
let frame = |core: &mut Core, ext: &mut LuaExtension| {
let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
};
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
core.set_scroll(list, Vec2::new(0.0, 3_000.0));
frame(&mut core, &mut ext);
frame(&mut core, &mut ext);
let deep: i64 = ext.lua.globals().get("first_built").unwrap();
assert!(
deep > 100,
"expected to be deep in the list, built from {deep}"
);
ext.lua.globals().set("ROWS", 10).unwrap();
frame(&mut core, &mut ext);
let g = core.scroll_geometry(list).expect("laid out");
assert_eq!(
g.content.h,
10.0 * 20.0,
"the content is the list it has now"
);
frame(&mut core, &mut ext);
let first: i64 = ext.lua.globals().get("first_built").unwrap();
assert_eq!(first, 0, "and every row of it is built");
assert_eq!(core.scroll_offset(list).y, 0.0);
}
const VARIABLE_LIST: &str = r#"
heights = row_heights(1000, 20)
measured = 0
function view(env)
return list(env,
{ key = "list", heights = heights, width = "grow", height = "grow",
transition = TRANSITION },
function(i, width)
measured = measured + 1
if i < 100 then return 20 else return 60 end
end,
function(i)
return column { width = "grow", height = "grow", bg = 0x282840ff,
label = "row", on_click = i }
end)
end
"#;
fn variable_frame(core: &mut Core, ext: &mut LuaExtension, t: f64) {
core.set_time(t);
let mut ui = core.frame(Size::new(400.0, 200.0), 1.0);
ui.set_origin(OriginId(1));
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
}
fn row_under(core: &mut Core, y: f32) -> Option<i64> {
core.handle_input(InputEvent::CursorMoved(Vec2::new(200.0, y)));
core.handle_input(InputEvent::mouse_down(1));
let evs = core.handle_input(InputEvent::mouse_up());
evs.first()?.payload.as_int()
}
#[test]
fn a_lua_list_keeps_the_row_under_the_pointer_while_the_estimate_moves() {
let mut ext = LuaExtension::from_source("variable", VARIABLE_LIST).unwrap();
let list = Key::ROOT.str("list");
let mut core = Core::new();
variable_frame(&mut core, &mut ext, 0.0);
variable_frame(&mut core, &mut ext, 0.0);
let measured: i64 = ext.lua.globals().get("measured").unwrap();
assert!(
(10..=20).contains(&measured),
"a screenful measured, not the list: {measured}"
);
core.set_scroll(list, Vec2::new(0.0, 5_000.0));
variable_frame(&mut core, &mut ext, 0.0);
let settled = row_under(&mut core, 4.0);
assert!(settled.is_some(), "nothing under the top edge");
for n in 0..4 {
variable_frame(&mut core, &mut ext, 0.0);
assert_eq!(
row_under(&mut core, 4.0),
settled,
"the content slid on frame {n} as the estimate moved"
);
}
let g = core.scroll_geometry(list).expect("laid out");
assert!(
g.content.h > 1000.0 * 20.0 * 1.5,
"the list learned it is longer: {}",
g.content.h
);
}
#[test]
fn a_lua_list_glides_to_the_row_asked_for() {
let mut ext =
LuaExtension::from_source("variable", &format!("TRANSITION = 100\n{VARIABLE_LIST}"))
.unwrap();
let list = Key::ROOT.str("list");
let mut core = Core::new();
let mut t = 0.0;
variable_frame(&mut core, &mut ext, t);
variable_frame(&mut core, &mut ext, t);
let target = 400;
let offset: f32 = ext
.lua
.load(format!("return heights:offset_of({target})"))
.eval()
.unwrap();
core.set_scroll(list, Vec2::new(0.0, offset));
for _ in 0..30 {
t += 1.0 / 60.0;
variable_frame(&mut core, &mut ext, t);
}
assert_eq!(row_under(&mut core, 4.0), Some(target));
}
#[test]
fn a_script_asks_for_a_file_dialog_and_hears_the_answer() {
let mut ext = LuaExtension::from_source(
"files",
r#"
ask, asked, again, waiting, heard = true, nil, nil, nil, nil
function view(env)
if ask then
asked = env.request_files {
mode = "save", title = "Export", file_name = "notes.md",
filters = { { name = "Markdown", extensions = { "md" } } },
tag = "export",
}
again = env.request_files {}
waiting = env.awaiting_files()
ask = false
end
return column {}
end
function on_event(ev)
if ev.kind == "files" then heard = ev.paths[1] .. "|" .. ev.tag end
end
"#,
)
.unwrap();
let mut core = Core::new();
variable_frame(&mut core, &mut ext, 0.0);
let g = ext.lua.globals();
assert!(g.get::<bool>("asked").unwrap(), "the first ask is taken");
assert!(
!g.get::<bool>("again").unwrap(),
"a second while it is out is not"
);
assert!(g.get::<bool>("waiting").unwrap());
let asks = core.take_file_requests();
assert_eq!(asks.len(), 1);
let d = &asks[0];
assert_eq!(d.mode, kui_core::FileDialogMode::Save);
assert_eq!(d.file_name.as_deref(), Some("notes.md"));
assert_eq!(d.filters[0].extensions, vec!["md".to_string()]);
let evs = core.handle_input(InputEvent::Files(vec!["/tmp/notes.md".into()]));
assert_eq!(evs.len(), 1);
assert_eq!(
evs[0].origin,
OriginId(1),
"the answer goes to the script that asked"
);
for e in &evs {
ext.on_event(e);
}
let heard: String = ext.lua.globals().get("heard").unwrap();
assert_eq!(heard, "/tmp/notes.md|export");
}
#[test]
fn a_host_hands_a_script_the_documents_the_os_opened() {
let mut ext = LuaExtension::from_source(
"open",
r#"
heard = nil
function view(env) return column {} end
function on_event(ev)
if ev.kind == "open" then heard = #ev.paths .. "|" .. ev.paths[2] end
end
"#,
)
.unwrap();
let mut core = Core::new();
variable_frame(&mut core, &mut ext, 0.0);
let evs = core.handle_input(InputEvent::Open(vec![
"/tmp/a.txt".into(),
"/tmp/b.md".into(),
]));
assert_eq!(evs.len(), 1);
assert_eq!(evs[0].origin, OriginId::HOST, "nobody asked: the host's");
assert_eq!(evs[0].key, kui_core::Key::ROOT);
for e in &evs {
ext.on_event(e);
}
let heard: String = ext.lua.globals().get("heard").unwrap();
assert_eq!(heard, "2|/tmp/b.md");
assert!(
core.handle_input(InputEvent::Open(Vec::new())).is_empty(),
"no documents is no event"
);
}
#[test]
fn a_lua_list_names_what_it_is_missing() {
let mut ext = LuaExtension::from_source(
"bad",
r#"
function view(env)
ok, err = pcall(list, env, { key = "list" }, function() return 1 end,
function() return column {} end)
return column {}
end
"#,
)
.unwrap();
let mut core = Core::new();
variable_frame(&mut core, &mut ext, 0.0);
let err: String = ext.lua.globals().get("err").unwrap();
assert!(err.contains("row_heights"), "{err}");
}
#[test]
fn a_query_answers_for_an_undeclared_label_and_a_command_refuses() {
let mut ext = LuaExtension::from_source(
"queries",
r#"
function view(env)
geom = env.scroll_geometry("nothing")
off = env.scroll_offset("nothing")
hovered = env.is_hovered("nothing")
pressed = env.is_pressed("nothing")
focused = env.is_focused("nothing")
hit = env.text_hit("nothing", 1, 1)
caret = env.caret_rect("nothing", 0)
deferred = pcall(function() env.set_scroll("nothing", 0, 0) end)
refused_focus = not pcall(function() env.set_focus("nothing") end)
return column { width = "grow", height = "grow" }
end
"#,
)
.unwrap();
let mut core = Core::new();
let mut ui = core.frame(Size::new(200.0, 100.0), 1.0);
ext.view(&Slot::root(), &mut ui).unwrap();
ui.finish();
let g = ext.lua.globals();
assert_eq!(g.get::<mlua::Value>("geom").unwrap(), mlua::Value::Nil);
assert_eq!(g.get::<mlua::Value>("hit").unwrap(), mlua::Value::Nil);
assert_eq!(g.get::<mlua::Value>("caret").unwrap(), mlua::Value::Nil);
assert!(!g.get::<bool>("hovered").unwrap());
assert!(!g.get::<bool>("pressed").unwrap());
assert!(!g.get::<bool>("focused").unwrap());
let off: Table = g.get("off").unwrap();
assert_eq!(off.get::<f32>("y").unwrap(), 0.0);
assert!(g.get::<bool>("deferred").unwrap());
let codes: Vec<_> = core.take_warnings().iter().map(|w| w.code).collect();
assert_eq!(codes, ["label-without-node"]);
assert!(g.get::<bool>("refused_focus").unwrap());
}
#[test]
fn bad_script_reports_error_not_panic() {
let mut ext = LuaExtension::from_source("bad", "function view() return 5 end").unwrap();
let mut core = Core::new();
let mut ui = core.frame(Size::new(100.0, 100.0), 1.0);
assert!(ext.view(&Slot::root(), &mut ui).is_err());
}
}