use std::cell::RefCell;
use std::rc::Rc;
use mlua::{IntoLua, Lua, LuaSerdeExt, Table, Value};
use tracing::{error, info};
use super::shared;
use crate::types::script_state::{ScriptStateWrite, WriteOutcome};
use super::convert::LuaEvent;
use super::runtime::{
HandlerEntry, Outbox, SharedRegistry, from_lua_value, store_error, to_lua_value,
};
use super::world::DispatchWorld;
use crate::commands::Command;
use crate::config::{Config, config_from_lua, resolve_chord};
use crate::ecs::state::StateQueryKind;
use crate::types::windowset_lua::returned_ops;
struct ExecJob {
program: String,
args: Vec<String>,
reply: async_channel::Sender<std::io::Result<ExecOutput>>,
}
struct ExecOutput {
code: Option<i32>,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
fn run_exec_job(program: &str, args: &[String]) -> std::io::Result<ExecOutput> {
use std::io::Read;
use std::process::{Command, Stdio};
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut errors = child.stderr.take();
let draining = std::thread::spawn(move || {
let mut buffer = Vec::new();
if let Some(pipe) = errors.as_mut() {
let _ = pipe.read_to_end(&mut buffer);
}
buffer
});
let mut stdout = Vec::new();
if let Some(pipe) = child.stdout.as_mut() {
let _ = pipe.read_to_end(&mut stdout);
}
let stderr = draining.join().unwrap_or_default();
let code = match child.wait() {
Ok(status) => status.code(),
Err(err) if err.raw_os_error() == Some(libc::ECHILD) => None,
Err(err) => return Err(err),
};
Ok(ExecOutput {
code,
stdout,
stderr,
})
}
const DEFAULT_EXEC_WORKERS: usize = 4;
fn exec_workers() -> usize {
std::thread::available_parallelism().map_or(DEFAULT_EXEC_WORKERS, std::num::NonZeroUsize::get)
}
fn spawn_exec_pool() -> async_channel::Sender<ExecJob> {
let (jobs, queue) = async_channel::unbounded::<ExecJob>();
for worker in 0..exec_workers() {
let queue = queue.clone();
let spawned = std::thread::Builder::new()
.name(format!("paneru-lua-exec-{worker}"))
.spawn(move || {
while let Ok(job) = queue.recv_blocking() {
let output = run_exec_job(&job.program, &job.args);
let _ = job.reply.send_blocking(output);
}
});
if let Err(err) = spawned {
error!("could not start paneru.exec worker {worker}: {err}");
}
}
jobs
}
const MUTATE_ATTEMPTS: usize = 8;
#[allow(clippy::too_many_lines)]
pub(super) fn install(
lua: &Lua,
outbox: &Rc<RefCell<Outbox>>,
registry: &SharedRegistry,
config_cell: &Rc<RefCell<Option<Config>>>,
world: &Rc<DispatchWorld>,
) -> mlua::Result<()> {
let paneru = lua.create_table()?;
lua.globals().set("paneru", paneru.clone())?;
let dispatch = {
let outbox = Rc::clone(outbox);
move |_: &Lua, command: Command| {
outbox.borrow_mut().commands.push(command);
Ok(true)
}
};
shared::install(lua, &paneru, &(Rc::new(dispatch) as shared::Dispatch))?;
let run: mlua::Function = paneru.get("run")?;
paneru.set("cmd", run)?;
install_query(lua, &paneru, world)?;
install_script_state(lua, &paneru, world)?;
let log = lua.create_function(|_, message: String| {
info!(target: "paneru::lua", "{message}");
Ok(())
})?;
paneru.set("log", log)?;
let flash = {
let outbox = Rc::clone(outbox);
lua.create_function(move |_, (message, duration): (String, Option<f32>)| {
outbox
.borrow_mut()
.flashes
.push((message, duration.unwrap_or(2.0)));
Ok(())
})?
};
paneru.set("flash", flash)?;
let exec = {
let pool: Rc<RefCell<Option<async_channel::Sender<ExecJob>>>> = Rc::new(RefCell::new(None));
lua.create_async_function(move |lua, (program, args): (String, Option<Vec<String>>)| {
let jobs = pool
.borrow_mut()
.get_or_insert_with(spawn_exec_pool)
.clone();
async move {
let (reply, answer) = async_channel::bounded(1);
let job = ExecJob {
program,
args: args.unwrap_or_default(),
reply,
};
jobs.send(job)
.await
.map_err(|_| mlua::Error::RuntimeError("exec worker is gone".to_string()))?;
let output = answer
.recv()
.await
.map_err(|_| mlua::Error::RuntimeError("exec worker is gone".to_string()))?
.map_err(|err| mlua::Error::RuntimeError(format!("exec: {err}")))?;
let result = lua.create_table()?;
result.set("code", output.code)?;
result.set(
"stdout",
String::from_utf8_lossy(&output.stdout).into_owned(),
)?;
result.set(
"stderr",
String::from_utf8_lossy(&output.stderr).into_owned(),
)?;
Ok(result)
}
})?
};
paneru.set("exec", exec)?;
let on = {
let registry = Rc::clone(registry);
lua.create_function(move |lua, args: mlua::Variadic<Value>| {
if args.len() < 2 || args.len() > 3 {
return Err(mlua::Error::RuntimeError(
"paneru.on requires 2 or 3 arguments: (event_name, [filter,] handler)".into(),
));
}
let name = match &args[0] {
Value::String(s) => s.to_str()?.to_string(),
_ => {
return Err(mlua::Error::RuntimeError(
"paneru.on: expected event name string as 1st argument".into(),
));
}
};
if !LuaEvent::is_known(&name) {
return Err(mlua::Error::RuntimeError(format!(
"paneru.on: unknown event '{name}'; known events are {}",
LuaEvent::NAMES.join(", ")
)));
}
let (filter, handler) = if args.len() == 2 {
let Value::Function(handler) = args[1].clone() else {
return Err(mlua::Error::RuntimeError(
"paneru.on: expected handler function as 2nd argument".into(),
));
};
(None, handler)
} else {
let Value::Function(handler) = args[2].clone() else {
return Err(mlua::Error::RuntimeError(
"paneru.on: expected handler function as 3rd argument".into(),
));
};
let filter_fn = match &args[1] {
Value::Table(table) => Some(shared::matcher(lua, table.clone())?),
Value::Function(f) => Some(f.clone()),
_ => {
return Err(mlua::Error::RuntimeError(
"paneru.on: expected table or function as filter".into(),
));
}
};
(filter_fn, handler)
};
registry
.borrow_mut()
.handlers
.entry(name)
.or_default()
.push(HandlerEntry { filter, handler });
Ok(())
})?
};
paneru.set("on", on)?;
let bind = {
let registry = Rc::clone(registry);
lua.create_function(move |_, (chord, handler): (String, Value)| {
register_bind(®istry, &chord, handler)
})?
};
paneru.set("bind", bind)?;
let default_config = Config::defaults().unwrap_or_default();
paneru.set("config", config_to_lua_table(lua, &default_config)?)?;
let setup = {
let registry = Rc::clone(registry);
let config_cell = Rc::clone(config_cell);
let paneru_table = paneru.clone();
lua.create_function(move |lua, table: Table| {
if let Some(bindings) = table.get::<Option<Table>>("bindings")? {
for pair in bindings.pairs::<String, String>() {
let (command, chord) = pair?;
let handler = Value::String(lua.create_string(&command)?);
register_bind(®istry, &chord, handler)?;
}
table.set("bindings", Value::Nil)?;
}
let config = config_from_lua(lua, Value::Table(table.clone()))?;
let resolved = config_to_lua_table(lua, &config)?;
merge_lua_tables(&resolved, &table)?;
paneru_table.set("config", resolved)?;
*config_cell.borrow_mut() = Some(config);
Ok(())
})?
};
paneru.set("setup", setup)?;
let windows = {
let outbox = Rc::clone(outbox);
let world = Rc::clone(world);
lua.create_async_function(move |lua, transform: mlua::Function| {
let outbox = Rc::clone(&outbox);
let world = Rc::clone(&world);
async move {
let set = world.layout().await.map_err(mlua::Error::runtime)?;
let window_set = lua.create_userdata((*set).clone())?;
let returned: Value = transform.call_async(window_set).await?;
let ops = returned_ops(&returned)?;
if ops.is_empty() {
return Ok(false);
}
outbox.borrow_mut().commands.push(Command::Layout(ops));
Ok(true)
}
})?
};
paneru.set("windows", windows)?;
let package: Table = lua.globals().get("package")?;
let loaded: Table = package.get("loaded")?;
loaded.set("paneru", paneru)?;
Ok(())
}
fn register_bind(registry: &SharedRegistry, chord: &str, handler: Value) -> mlua::Result<()> {
match &handler {
Value::Function(_) | Value::String(_) => {}
other => {
return Err(mlua::Error::RuntimeError(format!(
"paneru.bind: handler must be a function or command string, got {}",
other.type_name()
)));
}
}
let (code, modifiers) = resolve_chord(chord)
.map_err(|err| mlua::Error::RuntimeError(format!("paneru.bind: {err}")))?;
let mut registry = registry.borrow_mut();
registry.binds.push(handler);
let id = u32::try_from(registry.binds.len())
.map_err(|_| mlua::Error::RuntimeError("paneru.bind: too many binds".into()))?;
registry.keybinds.push((code, modifiers, id));
Ok(())
}
fn install_query(lua: &Lua, paneru: &mlua::Table, world: &Rc<DispatchWorld>) -> mlua::Result<()> {
let raw = query_function(lua, world, None, true)?;
paneru.set("query", raw)?;
let json = query_function(lua, world, None, false)?;
paneru.set("query_json", json)?;
for (name, kind) in StateQueryKind::SHORTHANDS {
let shorthand = query_function(lua, world, Some(kind), false)?;
paneru.set(name, shorthand)?;
}
Ok(())
}
fn query_function(
lua: &Lua,
world: &Rc<DispatchWorld>,
fixed: Option<StateQueryKind>,
as_json: bool,
) -> mlua::Result<mlua::Function> {
let world = Rc::clone(world);
lua.create_async_function(move |lua, requested: Option<String>| {
let world = Rc::clone(&world);
async move {
let kind = if let Some(kind) = fixed {
kind
} else {
let token = requested
.as_deref()
.unwrap_or(StateQueryKind::State.token());
StateQueryKind::parse(token).ok_or_else(|| {
mlua::Error::RuntimeError(format!(
"paneru.query: unknown kind '{token}'; expected one of {}",
StateQueryKind::tokens()
))
})?
};
let state = world
.query_state()
.await
.map_err(|err| mlua::Error::RuntimeError(format!("paneru.query: {err}")))?;
if as_json {
state
.to_query_json(kind)
.map_err(mlua::Error::external)?
.into_lua(&lua)
} else {
let value = state.to_query_value(kind).map_err(mlua::Error::external)?;
lua.to_value(&value)
}
}
})
}
fn install_script_state(
lua: &Lua,
paneru: &mlua::Table,
world: &Rc<DispatchWorld>,
) -> mlua::Result<()> {
let state = lua.create_table()?;
state.set("get", {
let world = Rc::clone(world);
lua.create_async_function(move |lua, key: String| {
let world = Rc::clone(&world);
async move {
let store = world
.script_state()
.await
.map_err(|err| store_error("get", &err))?;
to_lua_value(&lua, store.get(&key))
}
})?
})?;
state.set("set", {
let world = Rc::clone(world);
lua.create_async_function(move |lua, (key, value): (String, Value)| {
let world = Rc::clone(&world);
async move {
let write = if value.is_nil() {
ScriptStateWrite::remove(key)
} else {
ScriptStateWrite::set(key, from_lua_value(&lua, value, "set")?)
};
world
.write_script_state(&write)
.await
.map(|_| ())
.map_err(|err| store_error("set", &err))
}
})?
})?;
state.set("mutate", {
let world = Rc::clone(world);
lua.create_async_function(move |lua, (key, transform): (String, mlua::Function)| {
let world = Rc::clone(&world);
async move {
let store = world
.script_state()
.await
.map_err(|err| store_error("mutate", &err))?;
let mut current = store.get(&key).cloned();
for _ in 0..MUTATE_ATTEMPTS {
let next = {
let current = to_lua_value(&lua, current.as_ref())?;
let returned: Value = transform.call_async(current).await?;
if returned.is_nil() {
None
} else {
Some(from_lua_value(&lua, returned, "mutate")?)
}
};
let write = ScriptStateWrite::compare_and_set(
key.clone(),
current.clone(),
next.clone(),
);
match world
.write_script_state(&write)
.await
.map_err(|err| store_error("mutate", &err))?
{
WriteOutcome::Applied { .. } => {
return to_lua_value(&lua, next.as_ref());
}
WriteOutcome::Conflict {
current: overtaken, ..
} => current = overtaken,
}
}
Err(store_error(
"mutate",
&format!("'{key}' kept changing under it after {MUTATE_ATTEMPTS} attempts"),
))
}
})?
})?;
paneru.set("state", state)?;
Ok(())
}
#[allow(clippy::too_many_lines)]
fn config_to_lua_table(lua: &Lua, config: &Config) -> mlua::Result<Table> {
use crate::config::{
MainOptions, RestoreOptions, format_modifiers,
padding::PaddingOptions,
swipe::{GestureOptions, ScrollOptions, SwipeOptions},
};
let root = lua.create_table()?;
root.set("default_workspaces", config.default_workspaces())?;
let options = lua.create_table()?;
let raw_opts = config.options();
let MainOptions {
focus_follows_mouse: _,
mouse_follows_focus: _,
horizontal_mouse_warp: _,
horizontal_mouse_warp_offset: _,
preset_column_widths: _,
preset_stack_heights: _,
animation_speed,
auto_center: _,
sliver_height: _,
sliver_width: _,
padding_top: _,
padding_bottom: _,
padding_left: _,
padding_right: _,
dim_inactive_windows: _,
dim_inactive_color: _,
border_active_window: _,
border_color: _,
border_opacity: _,
border_width: _,
border_radius: _,
swipe_gesture_fingers: _,
swipe_gesture_direction: _,
continuous_swipe: _,
swipe_sensitivity: _,
swipe_deceleration: _,
mouse_resize_modifier: _,
menubar_height: _,
window_hidden_ratio: _,
window_resize_cycle: _,
reap_empty_workspaces: _,
disable_native_tabs: _,
virtual_workspace_animations: _,
insert_windows_mid_strip: _,
create_virtual_workspace_automatically: _,
} = &raw_opts;
options.set("focus_follows_mouse", config.focus_follows_mouse())?;
options.set("mouse_follows_focus", config.mouse_follows_focus())?;
options.set("horizontal_mouse_warp", config.horizontal_mouse_warp())?;
options.set(
"horizontal_mouse_warp_offset",
config.horizontal_mouse_warp_offset(),
)?;
options.set("preset_column_widths", config.preset_column_widths())?;
options.set("preset_stack_heights", config.preset_stack_heights())?;
options.set("animation_speed", *animation_speed)?;
options.set("auto_center", config.auto_center())?;
options.set("sliver_height", config.sliver_height())?;
options.set("sliver_width", config.sliver_width())?;
options.set(
"mouse_resize_modifier",
config.mouse_resize_modifier().map(format_modifiers),
)?;
options.set("menubar_height", config.menubar_height())?;
options.set("window_hidden_ratio", config.window_hidden_ratio())?;
options.set("window_resize_cycle", config.window_resize_cycle())?;
options.set("reap_empty_workspaces", config.reap_empty_workspaces())?;
options.set("disable_native_tabs", !config.native_tabs_enabled())?;
options.set(
"virtual_workspace_animations",
config.virtual_workspace_animations(),
)?;
options.set(
"insert_windows_mid_strip",
config.insert_windows_mid_strip(),
)?;
options.set(
"create_virtual_workspace_automatically",
config.create_workspace_automatically(),
)?;
root.set("options", options)?;
let PaddingOptions {
top: _,
bottom: _,
left: _,
right: _,
} = PaddingOptions::default();
let padding = lua.create_table()?;
let (top, right, bottom, left) = config.edge_padding();
padding.set("top", top)?;
padding.set("right", right)?;
padding.set("bottom", bottom)?;
padding.set("left", left)?;
root.set("padding", padding)?;
let SwipeOptions {
sensitivity: _,
deceleration: _,
continuous: _,
gesture: _,
scroll: _,
} = SwipeOptions::default();
let swipe = lua.create_table()?;
swipe.set("sensitivity", config.swipe_sensitivity())?;
swipe.set("deceleration", config.swipe_deceleration())?;
swipe.set("continuous", config.continuous_swipe())?;
let GestureOptions {
fingers_count: _,
direction: _,
vertical: _,
} = GestureOptions::default();
let gesture = lua.create_table()?;
gesture.set("fingers_count", config.swipe_gesture_fingers())?;
let direction_str = match config.swipe_gesture_direction() {
crate::config::swipe::SwipeGestureDirection::Natural => "Natural",
crate::config::swipe::SwipeGestureDirection::Reversed => "Reversed",
};
gesture.set("direction", direction_str)?;
gesture.set("vertical", config.swipe_vertical())?;
swipe.set("gesture", gesture)?;
let ScrollOptions {
window_step: _,
modifier: _,
vertical_modifier: _,
} = ScrollOptions::default();
let scroll = lua.create_table()?;
scroll.set("window_step", config.swipe_scroll_window_step())?;
scroll.set("modifier", format_modifiers(config.swipe_scroll_modifier()))?;
scroll.set(
"vertical_modifier",
config
.swipe_scroll_vertical_modifier()
.map(format_modifiers),
)?;
swipe.set("scroll", scroll)?;
root.set("swipe", swipe)?;
let decorations = lua.create_table()?;
decorations.set("workspace_menu_status", config.workspace_menu_status())?;
decorations.set("workspace_popup_status", config.workspace_popup_status())?;
let active = lua.create_table()?;
let border = lua.create_table()?;
border.set("enabled", config.border_active_window())?;
border.set("opacity", config.border_opacity())?;
border.set("width", config.border_width())?;
border.set("color", "#FFFFFF")?;
active.set("border", border)?;
decorations.set("active", active)?;
let inactive = lua.create_table()?;
let dim = lua.create_table()?;
dim.set("opacity", config.dim_inactive_opacity())?;
dim.set("color", "#000000")?;
inactive.set("dim", dim)?;
decorations.set("inactive", inactive)?;
root.set("decorations", decorations)?;
let RestoreOptions {
enabled: _,
startup_grace_ms: _,
missing_windows: _,
} = RestoreOptions::default();
let restore = lua.create_table()?;
restore.set("enabled", config.restore_enabled())?;
restore.set(
"startup_grace_ms",
u64::try_from(config.restore_startup_grace().as_millis()).unwrap_or(2000),
)?;
restore.set("missing_windows", "ignore")?;
root.set("restore", restore)?;
let windows = lua.create_table()?;
root.set("windows", windows)?;
Ok(root)
}
fn is_lua_array(table: &Table) -> bool {
table.raw_len() > 0
}
fn merge_lua_tables(dst: &Table, src: &Table) -> mlua::Result<()> {
for pair in src.pairs::<Value, Value>() {
let (key, src_val) = pair?;
if let Value::Table(src_sub) = &src_val
&& !is_lua_array(src_sub)
&& let Value::Table(dst_sub) = dst.get::<Value>(key.clone())?
&& !is_lua_array(&dst_sub)
{
merge_lua_tables(&dst_sub, src_sub)?;
} else {
dst.set(key, src_val)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
MainOptions, RestoreOptions,
padding::PaddingOptions,
swipe::{GestureOptions, ScrollOptions, SwipeOptions},
};
const LEGACY_MAIN_OPTION_KEYS: &[&str] = &[
"padding_top",
"padding_bottom",
"padding_left",
"padding_right",
"dim_inactive_windows",
"dim_inactive_color",
"border_active_window",
"border_color",
"border_opacity",
"border_width",
"border_radius",
"swipe_gesture_fingers",
"swipe_gesture_direction",
"continuous_swipe",
"swipe_sensitivity",
"swipe_deceleration",
];
fn struct_field_names<T: serde::Serialize + Default>() -> Vec<String> {
let serde_json::Value::Object(map) =
serde_json::to_value(T::default()).expect("struct should serialize to JSON object")
else {
panic!("expected JSON object");
};
map.keys().cloned().collect()
}
#[test]
fn config_to_lua_table_covers_every_struct_field() {
let config = Config::try_from(
r#"
default_workspaces = 2
[options]
horizontal_mouse_warp = 10
animation_speed = 12.0
mouse_resize_modifier = "alt"
menubar_height = 24
[swipe.gesture]
fingers_count = 3
[swipe.scroll]
modifier = "alt"
vertical_modifier = "shift"
"#,
)
.expect("valid test config");
let lua = Lua::new();
let root = config_to_lua_table(&lua, &config).expect("config_to_lua_table should succeed");
let options_table: Table = root.get("options").unwrap();
for field in struct_field_names::<MainOptions>() {
if LEGACY_MAIN_OPTION_KEYS.contains(&field.as_str()) {
continue;
}
let val: Value = options_table.get(field.as_str()).unwrap();
assert!(
!val.is_nil(),
"MainOptions field '{field}' is missing from paneru.config.options! \
Did you add a new option to MainOptions and forget to set it in config_to_lua_table?"
);
}
let padding_table: Table = root.get("padding").unwrap();
for field in struct_field_names::<PaddingOptions>() {
let val: Value = padding_table.get(field.as_str()).unwrap();
assert!(
!val.is_nil(),
"PaddingOptions field '{field}' is missing from paneru.config.padding!"
);
}
let swipe_table: Table = root.get("swipe").unwrap();
for field in struct_field_names::<SwipeOptions>() {
let val: Value = swipe_table.get(field.as_str()).unwrap();
assert!(
!val.is_nil(),
"SwipeOptions field '{field}' is missing from paneru.config.swipe!"
);
}
let gesture_table: Table = swipe_table.get("gesture").unwrap();
for field in struct_field_names::<GestureOptions>() {
let val: Value = gesture_table.get(field.as_str()).unwrap();
assert!(
!val.is_nil(),
"GestureOptions field '{field}' is missing from paneru.config.swipe.gesture!"
);
}
let scroll_table: Table = swipe_table.get("scroll").unwrap();
for field in struct_field_names::<ScrollOptions>() {
let val: Value = scroll_table.get(field.as_str()).unwrap();
assert!(
!val.is_nil(),
"ScrollOptions field '{field}' is missing from paneru.config.swipe.scroll!"
);
}
let restore_table: Table = root.get("restore").unwrap();
for field in struct_field_names::<RestoreOptions>() {
let val: Value = restore_table.get(field.as_str()).unwrap();
assert!(
!val.is_nil(),
"RestoreOptions field '{field}' is missing from paneru.config.restore!"
);
}
}
}