use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::Duration;
use anyhow::{Result, anyhow};
use mlua::{
Function, Lua, ObjectLike, RegistryKey, Table, UserData, UserDataFields, UserDataMethods, Value,
};
use serde_json::{Map as JsonMap, Number as JsonNumber, Value as JsonValue};
use crate::plugins::{self, PluginSpec};
use crate::process::ProcessInfo;
use crate::protocol::{PaneSnapshot, PanelCard, PanelView};
use crate::store::Store;
use crate::tmux::{self, PaneInfo};
pub struct LuaRuntime {
lua: Lua,
kinds: Rc<RefCell<Vec<Kind>>>,
commands: Rc<RefCell<HashMap<String, RegistryKey>>>,
events: Rc<RefCell<HashMap<String, Vec<RegistryKey>>>>,
deferred: Rc<RefCell<Vec<RegistryKey>>>,
keybinds: Rc<RefCell<Vec<Keybind>>>,
unbinds: Rc<RefCell<Vec<Unbind>>>,
panels: Rc<RefCell<Vec<Panel>>>,
widgets: Rc<RefCell<HashMap<String, RegistryKey>>>,
pane_border: Rc<RefCell<Option<RegistryKey>>>,
states: Rc<RefCell<HashMap<String, StatePresentation>>>,
statusline: Rc<RefCell<Option<StatusLineDef>>>,
options: Rc<RefCell<Vec<(String, String)>>>,
option_appends: Rc<RefCell<Vec<(String, String)>>>,
jobs: Rc<RefCell<Vec<JobDef>>>,
job_data: Rc<RefCell<HashMap<String, String>>>,
used_plugins: Rc<RefCell<HashMap<String, PluginSpec>>>,
load_plugins: bool,
panes: Rc<RefCell<Vec<PaneSnapshot>>>,
store: Rc<RefCell<Store>>,
}
struct Kind {
name: String,
detect: RegistryKey,
label: RegistryKey,
state: Option<RegistryKey>,
color: Option<String>,
tag: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Keybind {
pub mode: String,
pub key: String,
pub command: Vec<String>,
pub raw: bool,
pub context: bool,
pub popup: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unbind {
pub mode: String,
pub key: String,
}
struct Panel {
id: String,
title: String,
cards: RegistryKey,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StatePresentation {
pub color: Option<String>,
pub glyph: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StatusLineDef {
position: Option<String>,
interval: Option<u64>,
left: Option<Vec<StatusItem>>,
right: Option<Vec<StatusItem>>,
rows: Vec<StatusRowDef>,
separator: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StatusRowDef {
left: Option<Vec<StatusItem>>,
right: Option<Vec<StatusItem>>,
separator: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum StatusItem {
Widget(String),
Job(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StatusRender {
pub active: bool,
pub position: Option<String>,
pub interval: Option<u64>,
pub rows: Option<usize>,
pub left: Option<String>,
pub right: Option<String>,
pub formats: Vec<(usize, String)>,
}
#[derive(Debug, Clone)]
pub struct Detection {
pub kind: String,
pub label: String,
pub raw_state: Option<String>,
pub color: Option<String>,
pub tag: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobDef {
pub name: String,
pub every: Duration,
pub timeout: Duration,
pub command: String,
}
#[derive(Clone)]
struct LuaJob {
name: String,
data: Rc<RefCell<HashMap<String, String>>>,
}
#[derive(Clone)]
struct LuaWidget {
name: String,
}
#[derive(Debug, Clone)]
struct LuaPane {
id: String,
pid: i32,
cwd: String,
cwd_basename: String,
command: String,
proc_tree: Vec<ProcessInfo>,
window: String,
session: String,
active: bool,
zoomed: bool,
kind: String,
label: String,
tag: Option<String>,
home: Option<String>,
state: Option<String>,
}
#[derive(Debug, Clone)]
struct LuaProcTree(Vec<ProcessInfo>);
impl LuaRuntime {
pub fn new(panes: Rc<RefCell<Vec<PaneSnapshot>>>) -> Result<Self> {
Self::with_store(panes, Rc::new(RefCell::new(Store::memory())))
}
pub fn collector(panes: Rc<RefCell<Vec<PaneSnapshot>>>) -> Result<Self> {
Self::with_store_and_plugin_loading(panes, Rc::new(RefCell::new(Store::memory())), false)
}
pub fn with_store(
panes: Rc<RefCell<Vec<PaneSnapshot>>>,
store: Rc<RefCell<Store>>,
) -> Result<Self> {
Self::with_store_and_data(panes, store, Rc::new(RefCell::new(HashMap::new())))
}
pub fn with_store_and_data(
panes: Rc<RefCell<Vec<PaneSnapshot>>>,
store: Rc<RefCell<Store>>,
job_data: Rc<RefCell<HashMap<String, String>>>,
) -> Result<Self> {
Self::with_store_data_and_plugin_loading(panes, store, job_data, true)
}
fn with_store_and_plugin_loading(
panes: Rc<RefCell<Vec<PaneSnapshot>>>,
store: Rc<RefCell<Store>>,
load_plugins: bool,
) -> Result<Self> {
Self::with_store_data_and_plugin_loading(
panes,
store,
Rc::new(RefCell::new(HashMap::new())),
load_plugins,
)
}
fn with_store_data_and_plugin_loading(
panes: Rc<RefCell<Vec<PaneSnapshot>>>,
store: Rc<RefCell<Store>>,
job_data: Rc<RefCell<HashMap<String, String>>>,
load_plugins: bool,
) -> Result<Self> {
let lua = Lua::new();
let kinds = Rc::new(RefCell::new(Vec::new()));
let commands = Rc::new(RefCell::new(HashMap::new()));
let events = Rc::new(RefCell::new(HashMap::new()));
let deferred = Rc::new(RefCell::new(Vec::new()));
let keybinds = Rc::new(RefCell::new(Vec::new()));
let unbinds = Rc::new(RefCell::new(Vec::new()));
let panels = Rc::new(RefCell::new(Vec::new()));
let widgets = Rc::new(RefCell::new(HashMap::new()));
let pane_border = Rc::new(RefCell::new(None));
let states = Rc::new(RefCell::new(HashMap::new()));
let statusline = Rc::new(RefCell::new(None));
let options = Rc::new(RefCell::new(Vec::new()));
let option_appends = Rc::new(RefCell::new(Vec::new()));
let jobs = Rc::new(RefCell::new(Vec::new()));
let used_plugins = Rc::new(RefCell::new(HashMap::new()));
let runtime = Self {
lua,
kinds,
commands,
events,
deferred,
keybinds,
unbinds,
panels,
widgets,
pane_border,
states,
statusline,
options,
option_appends,
jobs,
job_data,
used_plugins,
load_plugins,
panes,
store,
};
runtime.install_api()?;
runtime.load_prelude()?;
Ok(runtime)
}
pub fn detect(
&self,
pane: &PaneInfo,
proc_tree: Vec<ProcessInfo>,
) -> Result<Option<Detection>> {
let lua_pane = LuaPane {
id: pane.id.clone(),
pid: pane.pid,
cwd: pane.cwd.clone(),
cwd_basename: basename(&pane.cwd),
command: pane.command.clone(),
proc_tree,
window: pane.window.clone(),
session: pane.session.clone(),
active: pane.active,
zoomed: pane.zoomed,
kind: String::new(),
label: String::new(),
tag: pane.tag.clone(),
home: pane.home.clone(),
state: pane.state.clone(),
};
let userdata = self.lua.create_userdata(lua_pane).map_err(lua_err)?;
for kind in self.kinds.borrow().iter() {
let Ok(detect) = self.lua.registry_value::<Function>(&kind.detect) else {
continue;
};
let Ok(matched) = detect.call::<bool>(userdata.clone()) else {
continue;
};
if matched {
let Ok(label_fn) = self.lua.registry_value::<Function>(&kind.label) else {
continue;
};
let Ok(label) = label_fn.call::<String>(userdata.clone()) else {
continue;
};
let raw_state = kind
.state
.as_ref()
.and_then(|state_key| self.lua.registry_value::<Function>(state_key).ok())
.and_then(|state_fn| state_fn.call::<String>(userdata.clone()).ok());
return Ok(Some(Detection {
kind: kind.name.clone(),
label,
raw_state,
color: kind.color.clone(),
tag: kind.tag.clone(),
}));
}
}
Ok(None)
}
fn install_api(&self) -> Result<()> {
let tpane = self.lua.create_table().map_err(lua_err)?;
let use_plugin_loaded = Rc::clone(&self.used_plugins);
let load_plugins = self.load_plugins;
let use_plugin = self
.lua
.create_function(move |lua, args: mlua::MultiValue| {
let values = args.into_iter().collect::<Vec<_>>();
let (name, spec) = match values.as_slice() {
[Value::String(name)] => (name.to_string_lossy(), PluginSpec::default()),
[Value::String(name), Value::Table(table)] => {
(name.to_string_lossy(), plugin_spec_from_lua(table)?)
}
_ => {
return Err(mlua::Error::RuntimeError(
"expected tpane.use(name[, opts])".to_string(),
));
}
};
if use_plugin_loaded.borrow().contains_key(&name) {
return Ok(());
}
if load_plugins {
load_plugin(lua, &name, &spec)?;
} else {
plugins::validate_plugin_name(&name).map_err(mlua_external)?;
plugins::validate_spec(&spec).map_err(mlua_external)?;
}
use_plugin_loaded.borrow_mut().insert(name, spec);
Ok(())
})
.map_err(lua_err)?;
tpane.set("use", use_plugin).map_err(lua_err)?;
let kinds = Rc::clone(&self.kinds);
let kind = self
.lua
.create_function(move |lua, table: Table| {
let name: String = table.get("name")?;
let matcher: Option<String> = table.get("match")?;
let detect: Function = match table.get::<Option<Function>>("detect")? {
Some(detect) => detect,
None => match matcher {
Some(pattern) => lua.create_function(move |_, pane: Value| match pane {
Value::Table(table) => {
let running: Function = table.get("running")?;
running.call((table, pattern.clone()))
}
Value::UserData(userdata) => {
userdata.call_method("running", pattern.clone())
}
_ => Ok(false),
})?,
None => {
return Err(mlua::Error::RuntimeError(
"kind requires detect or match".to_string(),
));
}
},
};
let label: Function = match table.get::<Option<Function>>("label")? {
Some(label) => label,
None => {
let label = name.clone();
lua.create_function(move |_, _pane: Value| Ok(label.clone()))?
}
};
let detect = lua.create_registry_value(detect)?;
let label = lua.create_registry_value(label)?;
let state = table
.get::<Option<Function>>("state")?
.map(|state| lua.create_registry_value(state))
.transpose()?;
let color = table.get::<Option<String>>("color")?;
let tag = table.get::<Option<String>>("tag")?;
kinds.borrow_mut().push(Kind {
name,
detect,
label,
state,
color,
tag,
});
Ok(())
})
.map_err(lua_err)?;
tpane.set("kind", kind).map_err(lua_err)?;
let states = Rc::clone(&self.states);
let state = self
.lua
.create_function(move |lua, args: mlua::MultiValue| {
let values = args.into_iter().collect::<Vec<_>>();
match values.as_slice() {
[Value::String(name)] => {
let name = name.to_string_lossy();
match states.borrow().get(&name) {
Some(presentation) => state_presentation_table(lua, presentation),
None => Ok(Value::Nil),
}
}
[Value::String(name), Value::Table(table)] => {
let name = name.to_string_lossy();
let presentation = StatePresentation {
color: table.get("color")?,
glyph: table.get("glyph")?,
};
states.borrow_mut().insert(name, presentation);
Ok(Value::Nil)
}
_ => Err(mlua::Error::RuntimeError(
"expected tpane.state(name) or tpane.state(name, { color=..., glyph=... })"
.to_string(),
)),
}
})
.map_err(lua_err)?;
tpane.set("state", state).map_err(lua_err)?;
#[cfg(test)]
{
let commands = Rc::clone(&self.commands);
let generated_command = Rc::new(Cell::new(0usize));
let command = self
.lua
.create_function(move |lua, handler: Function| {
let idx = generated_command.get() + 1;
generated_command.set(idx);
let name = format!("__tpane_command_{idx}");
let handler = lua.create_registry_value(handler)?;
commands.borrow_mut().insert(name.clone(), handler);
run_action_table(lua, &[name])
})
.map_err(lua_err)?;
tpane.set("command", command).map_err(lua_err)?;
}
let events = Rc::clone(&self.events);
let on = self
.lua
.create_function(move |lua, (event, handler): (String, Function)| {
let handler = lua.create_registry_value(handler)?;
events.borrow_mut().entry(event).or_default().push(handler);
Ok(())
})
.map_err(lua_err)?;
tpane.set("on", on).map_err(lua_err)?;
let deferred = Rc::clone(&self.deferred);
let defer = self
.lua
.create_function(move |lua, handler: Function| {
deferred
.borrow_mut()
.push(lua.create_registry_value(handler)?);
Ok(())
})
.map_err(lua_err)?;
tpane.set("_defer", defer).map_err(lua_err)?;
let keybinds = Rc::clone(&self.keybinds);
let key_commands = Rc::clone(&self.commands);
let key_panes = Rc::clone(&self.panes);
let generated_key_command = Rc::new(Cell::new(0usize));
let bind = self
.lua
.create_function(move |lua, args: mlua::MultiValue| {
let keybind =
parse_bind(lua, &key_commands, &key_panes, &generated_key_command, args)?;
keybinds.borrow_mut().push(keybind);
Ok(())
})
.map_err(lua_err)?;
tpane.set("bind", bind).map_err(lua_err)?;
let unbinds = Rc::clone(&self.unbinds);
let unbind = self
.lua
.create_function(move |_, args: mlua::MultiValue| {
let values = args.into_iter().collect::<Vec<_>>();
let (key, mode) = match values.as_slice() {
[key] => (value_to_string(key, "key")?, "prefix".to_string()),
[key, opts] => {
let opts = parse_keybind_opts(opts, false)?;
(value_to_string(key, "key")?, opts.mode)
}
_ => {
return Err(mlua::Error::RuntimeError(
"expected tpane.unbind(key[, opts])".to_string(),
));
}
};
unbinds.borrow_mut().push(Unbind { mode, key });
Ok(())
})
.map_err(lua_err)?;
tpane.set("unbind", unbind).map_err(lua_err)?;
let panels = Rc::clone(&self.panels);
let panel = self
.lua
.create_function(move |lua, table: Table| {
let id: String = table.get("id")?;
let title: String = table.get("title")?;
let cards: Function = table.get("cards")?;
panels.borrow_mut().push(Panel {
id,
title,
cards: lua.create_registry_value(cards)?,
});
Ok(())
})
.map_err(lua_err)?;
tpane.set("panel", panel).map_err(lua_err)?;
let widget_handles = self.lua.create_table().map_err(lua_err)?;
tpane.set("widgets", widget_handles).map_err(lua_err)?;
let widgets = Rc::clone(&self.widgets);
let widget_counter = Rc::new(Cell::new(0));
let widget = self
.lua
.create_function(move |lua, handler: Function| {
let id = widget_counter.get() + 1;
widget_counter.set(id);
let name = format!("__tpane_widget_{id}");
widgets
.borrow_mut()
.insert(name.clone(), lua.create_registry_value(handler)?);
lua.create_userdata(LuaWidget { name })
})
.map_err(lua_err)?;
tpane.set("widget", widget).map_err(lua_err)?;
let pane_border = Rc::clone(&self.pane_border);
let pane_border_fn = self
.lua
.create_function(move |lua, handler: Function| {
*pane_border.borrow_mut() = Some(lua.create_registry_value(handler)?);
Ok(())
})
.map_err(lua_err)?;
tpane.set("pane_border", pane_border_fn).map_err(lua_err)?;
let statusline = Rc::clone(&self.statusline);
let set_statusline = self
.lua
.create_function(move |_, table: Table| {
let def = parse_statusline_def(table)?;
*statusline.borrow_mut() = Some(def);
Ok(())
})
.map_err(lua_err)?;
tpane.set("statusline", set_statusline).map_err(lua_err)?;
let options = Rc::clone(&self.options);
let set_options = self
.lua
.create_function(move |_, table: Table| {
options.borrow_mut().extend(flatten_options(table)?);
Ok(())
})
.map_err(lua_err)?;
tpane.set("options", set_options).map_err(lua_err)?;
let option_appends = Rc::clone(&self.option_appends);
let append = self
.lua
.create_function(move |_, (name, value): (String, String)| {
option_appends
.borrow_mut()
.push((name.replace('_', "-"), value));
Ok(())
})
.map_err(lua_err)?;
tpane.set("append", append).map_err(lua_err)?;
let jobs = Rc::clone(&self.jobs);
let job_data = Rc::clone(&self.job_data);
let generated_job = Rc::new(Cell::new(0usize));
let job = self
.lua
.create_function(move |lua, table: Table| {
let idx = generated_job.get() + 1;
generated_job.set(idx);
let name = format!("__tpane_job_{idx}");
jobs.borrow_mut().push(parse_job_def(name.clone(), table)?);
lua.create_userdata(LuaJob {
name,
data: Rc::clone(&job_data),
})
})
.map_err(lua_err)?;
tpane.set("job", job).map_err(lua_err)?;
let opt_options = Rc::clone(&self.options);
let opt = self.lua.create_table().map_err(lua_err)?;
let opt_meta = self.lua.create_table().map_err(lua_err)?;
opt_meta
.set(
"__newindex",
self.lua
.create_function(move |lua, (_table, key, value): (Table, String, Value)| {
let table = value_to_option_table(lua, &key, value)?;
opt_options.borrow_mut().extend(flatten_options(table)?);
Ok(())
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
opt.set_metatable(Some(opt_meta));
tpane.set("opt", opt).map_err(lua_err)?;
let panes = Rc::clone(&self.panes);
let panes_fn = self
.lua
.create_function(move |lua, ()| snapshots_table(lua, &panes.borrow()))
.map_err(lua_err)?;
tpane.set("panes", panes_fn).map_err(lua_err)?;
let pane_fn = self
.lua
.create_function(move |lua, pane_id: String| pane_ref_table(lua, &pane_id))
.map_err(lua_err)?;
tpane.set("pane", pane_fn).map_err(lua_err)?;
tpane
.set("store", store_api(&self.lua, Rc::clone(&self.store))?)
.map_err(lua_err)?;
install_package_path(&self.lua)?;
tpane.set("tmux", tmux_api(&self.lua)?).map_err(lua_err)?;
tpane
.set("with_pane", with_pane_fn(&self.lua)?)
.map_err(lua_err)?;
self.lua.globals().set("tpane", tpane).map_err(lua_err)?;
Ok(())
}
pub fn load_source(&self, name: &str, source: &str) -> Result<()> {
self.lua
.load(source)
.set_name(name)
.exec()
.map_err(|error| anyhow!("{error}"))
}
fn load_prelude(&self) -> Result<()> {
self.load_source("prelude.lua", PRELUDE)
.and_then(|_| self.load_source("widgets.lua", WIDGETS))
.map_err(|error| anyhow!("failed to load Lua prelude: {error}"))
}
pub fn load_builtins(&self) -> Result<()> {
self.load_source("builtin-kinds.lua", BUILTIN_KINDS)
.map_err(|error| anyhow!("failed to load built-in Lua kinds: {error}"))
}
pub fn keybinds(&self) -> Vec<Keybind> {
self.keybinds.borrow().clone()
}
pub fn unbinds(&self) -> Vec<Unbind> {
self.unbinds.borrow().clone()
}
pub fn used_plugin_specs(&self) -> HashMap<String, PluginSpec> {
self.used_plugins.borrow().clone()
}
pub fn render_panels(&self) -> Result<Vec<PanelView>> {
let panels = {
let panels = self.panels.borrow();
panels
.iter()
.filter_map(|panel| {
self.lua
.registry_value::<Function>(&panel.cards)
.ok()
.map(|cards| (panel.id.clone(), panel.title.clone(), cards))
})
.collect::<Vec<_>>()
};
panels
.into_iter()
.map(|(id, title, cards_fn)| {
let cards = cards_fn.call::<Table>(()).map_err(lua_err)?;
Ok(PanelView {
id,
title,
cards: parse_panel_cards(cards).map_err(lua_err)?,
})
})
.collect()
}
pub fn status_options(&self) -> StatusRender {
self.statusline
.borrow()
.as_ref()
.map(|def| StatusRender {
active: true,
position: def.position.clone(),
interval: def.interval,
rows: (!def.rows.is_empty()).then_some(def.rows.len()),
left: None,
right: None,
formats: Vec::new(),
})
.unwrap_or_default()
}
pub fn options(&self) -> Vec<(String, String)> {
let mut options = BTreeMap::new();
for (name, value) in self.options.borrow().iter() {
options.insert(name.clone(), value.clone());
}
options.into_iter().collect()
}
pub fn option_appends(&self) -> Vec<(String, String)> {
let mut appends = self.option_appends.borrow().clone();
appends.sort_by(|a, b| a.0.cmp(&b.0));
appends
}
pub fn jobs(&self) -> Vec<JobDef> {
self.jobs.borrow().clone()
}
pub fn state_presentation(&self, state: &str) -> Option<StatePresentation> {
self.states.borrow().get(state).cloned()
}
pub fn render_pane_border(&self, pane: &PaneSnapshot) -> Result<Option<String>> {
let handler: Function = {
let pane_border = self.pane_border.borrow();
let Some(handler_key) = pane_border.as_ref() else {
return Ok(None);
};
self.lua.registry_value(handler_key).map_err(lua_err)?
};
let pane = snapshot_table(&self.lua, pane).map_err(lua_err)?;
let value = handler.call::<Value>(pane).map_err(lua_err)?;
render_widget_value(value).map_err(lua_err)
}
pub fn render_statusline(&self, current_pane_id: Option<&str>) -> (StatusRender, Vec<String>) {
let Some(def) = self.statusline.borrow().clone() else {
return (StatusRender::default(), Vec::new());
};
let mut errors = Vec::new();
let ctx = match self.status_context(current_pane_id) {
Ok(ctx) => Some(ctx),
Err(error) => {
errors.push(format!("status context: {error}"));
None
}
};
if !def.rows.is_empty() {
let formats = def
.rows
.iter()
.enumerate()
.map(|(index, row)| {
let left = row.left.as_ref().map(|widgets| {
self.render_status_slot(widgets, &row.separator, ctx.clone(), &mut errors)
});
let right = row.right.as_ref().map(|widgets| {
self.render_status_slot(widgets, &row.separator, ctx.clone(), &mut errors)
});
(index, status_format_row(left.as_deref(), right.as_deref()))
})
.collect();
return (
StatusRender {
active: true,
position: def.position,
interval: def.interval,
rows: Some(def.rows.len()),
left: None,
right: None,
formats,
},
errors,
);
}
let left = def.left.as_ref().map(|widgets| {
self.render_status_slot(widgets, &def.separator, ctx.clone(), &mut errors)
});
let right = def.right.as_ref().map(|widgets| {
self.render_status_slot(widgets, &def.separator, ctx.clone(), &mut errors)
});
(
StatusRender {
active: true,
position: def.position,
interval: def.interval,
rows: None,
left,
right,
formats: Vec::new(),
},
errors,
)
}
fn render_status_slot(
&self,
items: &[StatusItem],
separator: &str,
ctx: Option<Table>,
errors: &mut Vec<String>,
) -> String {
items
.iter()
.filter_map(|item| self.render_status_item(item, ctx.clone(), errors))
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(separator)
}
fn render_status_item(
&self,
item: &StatusItem,
ctx: Option<Table>,
errors: &mut Vec<String>,
) -> Option<String> {
match item {
StatusItem::Widget(name) => self.render_widget(name, ctx, errors),
StatusItem::Job(name) => self.job_data.borrow().get(name).cloned(),
}
}
fn status_context(&self, current_pane_id: Option<&str>) -> mlua::Result<Table> {
let panes = self.panes.borrow();
let current = current_pane_id
.and_then(|id| panes.iter().find(|pane| pane.id == id))
.or_else(|| panes.first());
let ctx = self.lua.create_table()?;
ctx.set("panes", snapshots_table(&self.lua, &panes)?)?;
if let Some(pane) = current {
ctx.set("pane", snapshot_table(&self.lua, pane)?)?;
ctx.set("session", pane.session.clone())?;
ctx.set("window", pane.window.clone())?;
} else {
ctx.set("pane", Value::Nil)?;
ctx.set("session", Value::Nil)?;
ctx.set("window", Value::Nil)?;
}
Ok(ctx)
}
fn render_widget(
&self,
name: &str,
ctx: Option<Table>,
errors: &mut Vec<String>,
) -> Option<String> {
let handler = {
let widgets = self.widgets.borrow();
let Some(handler_key) = widgets.get(name) else {
errors.push(format!("status widget {name}: unknown widget"));
return None;
};
match self.lua.registry_value::<Function>(handler_key) {
Ok(handler) => handler,
Err(error) => {
errors.push(format!("status widget {name}: {error}"));
return None;
}
}
};
let arg = ctx.map(Value::Table).unwrap_or(Value::Nil);
match handler.call::<Value>(arg) {
Ok(value) => match render_widget_value(value) {
Ok(value) => value,
Err(error) => {
errors.push(format!("status widget {name}: {error}"));
None
}
},
Err(error) => {
errors.push(format!("status widget {name}: {error}"));
None
}
}
}
pub fn run_command(&self, name: &str, args: &[String]) -> Result<Option<String>> {
let handler: Function = {
let commands = self.commands.borrow();
let Some(handler_key) = commands.get(name) else {
anyhow::bail!("unknown command: {name}");
};
self.lua.registry_value(handler_key).map_err(lua_err)?
};
let arg_table = self.lua.create_table().map_err(lua_err)?;
for (idx, arg) in args.iter().enumerate() {
arg_table.set(idx + 1, arg.as_str()).map_err(lua_err)?;
}
let value = handler.call::<Value>(arg_table).map_err(lua_err)?;
Ok(match value {
Value::Nil => None,
Value::String(value) => Some(value.to_string_lossy()),
other => Some(format!("{other:?}")),
})
}
pub fn run_deferred(&self) -> Vec<String> {
let deferred = std::mem::take(&mut *self.deferred.borrow_mut());
let mut errors = Vec::new();
for key in deferred {
match self.lua.registry_value::<Function>(&key) {
Ok(handler) => {
if let Err(error) = handler.call::<()>(()) {
errors.push(format!("deferred: {error}"));
}
}
Err(error) => errors.push(format!("deferred: {error}")),
}
}
errors
}
pub fn fire_event(&self, event: &str, pane: Option<&PaneSnapshot>) -> Vec<String> {
let payload = pane
.map(|pane| snapshot_table(&self.lua, pane).map(Value::Table))
.unwrap_or(Ok(Value::Nil));
self.fire_event_value(event, payload)
}
pub fn fire_event_text(&self, event: &str, text: &str) -> Vec<String> {
self.fire_event_value(event, self.lua.create_string(text).map(Value::String))
}
fn fire_event_value(&self, event: &str, payload: mlua::Result<Value>) -> Vec<String> {
let mut errors = Vec::new();
let handlers: Vec<Function> = {
let events = self.events.borrow();
let Some(handler_keys) = events.get(event) else {
return Vec::new();
};
handler_keys
.iter()
.filter_map(
|handler_key| match self.lua.registry_value::<Function>(handler_key) {
Ok(handler) => Some(handler),
Err(error) => {
errors.push(format!("event {event}: {error}"));
None
}
},
)
.collect()
};
let payload = match payload {
Ok(value) => value,
Err(error) => return vec![format!("event {event}: {error}")],
};
for handler in handlers {
if let Err(error) = handler.call::<()>(payload.clone()) {
errors.push(format!("event {event}: {error}"));
}
}
errors
}
}
pub fn user_plugin_files() -> Vec<PathBuf> {
let config = config_dir();
let mut files = Vec::new();
collect_lua_files(&config, &mut files);
files.sort();
files
}
pub fn config_lua_files() -> Vec<PathBuf> {
let config = config_dir();
let mut files = Vec::new();
collect_lua_files_recursive(&config, &mut files);
files.sort();
files
}
pub fn builtin_theme_names() -> Vec<String> {
BUILTIN_PLUGIN_THEMES_DATA
.lines()
.filter_map(|line| line.split('\t').next())
.filter(|name| !name.is_empty())
.map(ToString::to_string)
.collect()
}
fn collect_lua_files(dir: &Path, files: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.filter_map(std::result::Result::ok) {
let path = entry.path();
if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("lua") {
files.push(path);
}
}
}
fn collect_lua_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.filter_map(std::result::Result::ok) {
let path = entry.path();
if path.is_dir() {
collect_lua_files_recursive(&path, files);
} else if path.extension().and_then(|ext| ext.to_str()) == Some("lua") {
files.push(path);
}
}
}
fn status_format_row(left: Option<&str>, right: Option<&str>) -> String {
match (left, right) {
(Some(left), Some(right)) => format!("{left}#[align=right]{right}"),
(Some(left), None) => left.to_string(),
(None, Some(right)) => format!("#[align=right]{right}"),
(None, None) => String::new(),
}
}
fn parse_statusline_def(table: Table) -> mlua::Result<StatusLineDef> {
let position = table.get::<Option<String>>("position")?;
if let Some(position) = &position
&& !matches!(position.as_str(), "top" | "bottom")
{
return Err(mlua::Error::RuntimeError(format!(
"statusline position must be top or bottom, got {position}"
)));
}
let separator = table
.get::<Option<String>>("separator")?
.unwrap_or_else(|| " ".to_string());
let rows_value: Value = table.get("rows")?;
let rows = if matches!(rows_value, Value::Nil) && table.raw_len() > 0 {
parse_status_rows(Value::Table(table.clone()), &separator)?
} else {
parse_status_rows(rows_value, &separator)?
};
let (left, right) = if rows.is_empty() {
(
parse_status_slot(table.get("left")?)?,
parse_status_slot(table.get("right")?)?,
)
} else {
(None, None)
};
Ok(StatusLineDef {
position,
interval: table.get("interval")?,
left,
right,
rows,
separator,
})
}
fn parse_status_rows(value: Value, default_separator: &str) -> mlua::Result<Vec<StatusRowDef>> {
match value {
Value::Nil => Ok(Vec::new()),
Value::Table(table) => {
if table.raw_len() > 5 {
return Err(mlua::Error::RuntimeError(
"statusline supports at most 5 rows".to_string(),
));
}
table
.sequence_values::<Table>()
.map(|row| {
let row = row?;
Ok(StatusRowDef {
left: parse_status_slot(row.get("left")?)?,
right: parse_status_slot(row.get("right")?)?,
separator: row
.get::<Option<String>>("separator")?
.unwrap_or_else(|| default_separator.to_string()),
})
})
.collect()
}
other => Err(mlua::Error::RuntimeError(format!(
"statusline rows must be a list of row tables, got {other:?}"
))),
}
}
fn parse_status_slot(value: Value) -> mlua::Result<Option<Vec<StatusItem>>> {
match value {
Value::Nil => Ok(None),
Value::Table(table) => {
let mut items = Vec::new();
for value in table.sequence_values::<Value>() {
items.push(parse_status_item(value?)?);
}
Ok(Some(items))
}
other => Err(mlua::Error::RuntimeError(format!(
"statusline slot must be a list of widgets or jobs, got {other:?}"
))),
}
}
fn parse_status_item(value: Value) -> mlua::Result<StatusItem> {
match value {
Value::UserData(value) if value.is::<LuaWidget>() => Ok(StatusItem::Widget(
value.borrow::<LuaWidget>()?.name.clone(),
)),
Value::UserData(value) if value.is::<LuaJob>() => {
Ok(StatusItem::Job(value.borrow::<LuaJob>()?.name.clone()))
}
other => Err(mlua::Error::RuntimeError(format!(
"statusline item must be a widget or job; got {other:?}"
))),
}
}
fn parse_job_def(name: String, table: Table) -> mlua::Result<JobDef> {
let command = table
.get::<Option<String>>("cmd")?
.or(table.get::<Option<String>>("command")?)
.ok_or_else(|| mlua::Error::RuntimeError("job requires cmd or command".to_string()))?;
let every = parse_duration_value("every", table.get("every")?)?;
let timeout = match table.get::<Option<Value>>("timeout")? {
Some(value) => parse_duration_value("timeout", value)?,
None => Duration::from_secs(10),
};
Ok(JobDef {
name,
every,
timeout,
command,
})
}
fn parse_duration_value(name: &str, value: Value) -> mlua::Result<Duration> {
match value {
Value::Integer(seconds) if seconds >= 0 => Ok(Duration::from_secs(seconds as u64)),
Value::Number(seconds) if seconds >= 0.0 => Ok(Duration::from_secs_f64(seconds)),
Value::String(value) => parse_duration_string(name, &value.to_string_lossy()),
other => Err(mlua::Error::RuntimeError(format!(
"job {name} must be seconds or a string like 10s, 5m, or 1h; got {other:?}"
))),
}
}
fn parse_duration_string(name: &str, value: &str) -> mlua::Result<Duration> {
let value = value.trim();
let (number, multiplier) = match value.chars().last() {
Some('s') => (&value[..value.len() - 1], 1),
Some('m') => (&value[..value.len() - 1], 60),
Some('h') => (&value[..value.len() - 1], 60 * 60),
Some(_) => (value, 1),
None => {
return Err(mlua::Error::RuntimeError(format!(
"job {name} cannot be empty"
)));
}
};
let amount = number
.parse::<u64>()
.map_err(|_| mlua::Error::RuntimeError(format!("invalid job {name}: {value}")))?;
Ok(Duration::from_secs(amount * multiplier))
}
fn value_to_option_table(lua: &Lua, key: &str, value: Value) -> mlua::Result<Table> {
let table = lua.create_table()?;
table.set(key, value)?;
Ok(table)
}
fn flatten_options(table: Table) -> mlua::Result<Vec<(String, String)>> {
let mut options = Vec::new();
flatten_option_table(&table, &[], &mut options)?;
options.sort_by(|a, b| a.0.cmp(&b.0));
Ok(options)
}
fn flatten_option_table(
table: &Table,
path: &[String],
options: &mut Vec<(String, String)>,
) -> mlua::Result<()> {
for pair in table.clone().pairs::<String, Value>() {
let (key, value) = pair?;
if !path.is_empty() && key.contains('-') {
return Err(mlua::Error::RuntimeError(format!(
"literal tmux option names are only supported at top level: {key}"
)));
}
let segment = if path.is_empty() && key.contains('-') {
key
} else {
key.replace('_', "-")
};
let mut next_path = path.to_vec();
next_path.push(segment);
let name = next_path.join("-");
match value {
Value::String(value) => options.push((name, value.to_string_lossy())),
Value::Integer(value) => options.push((name, value.to_string())),
Value::Number(value) => options.push((name, value.to_string())),
Value::Boolean(value) => {
options.push((name, if value { "on" } else { "off" }.to_string()))
}
Value::Table(table) if name.ends_with("-style") => {
options.push((name, style_spec(&table)?));
}
Value::Table(table) if name.ends_with("-format") => {
options.push((name, render_widget_table(table)?.unwrap_or_default()));
}
Value::Table(table) => flatten_option_table(&table, &next_path, options)?,
other => {
return Err(mlua::Error::RuntimeError(format!(
"unsupported option value for {name}: {other:?}"
)));
}
}
}
Ok(())
}
fn render_widget_value(value: Value) -> mlua::Result<Option<String>> {
match value {
Value::Nil => Ok(None),
Value::String(value) => Ok(Some(value.to_string_lossy())),
Value::Table(table) => render_widget_table(table),
Value::UserData(value) if value.is::<LuaJob>() => Ok(value.borrow::<LuaJob>()?.value()),
other => Err(mlua::Error::RuntimeError(format!(
"expected string, table, job, or nil; got {other:?}"
))),
}
}
fn render_widget_table(table: Table) -> mlua::Result<Option<String>> {
if table.get::<Option<Value>>(1)?.is_some() {
let mut out = String::new();
for value in table.sequence_values::<Value>() {
if let Some(value) = render_widget_value(value?)? {
out.push_str(&value);
}
}
return Ok(Some(out));
}
let text = table.get::<Option<String>>("text")?.unwrap_or_default();
let attrs = style_attrs(&table)?;
if attrs.is_empty() {
Ok(Some(text))
} else {
Ok(Some(format!("#[{}]{}#[default]", attrs.join(","), text)))
}
}
fn style_spec(table: &Table) -> mlua::Result<String> {
Ok(style_attrs(table)?.join(","))
}
fn style_attrs(table: &Table) -> mlua::Result<Vec<String>> {
for key in table
.clone()
.pairs::<String, Value>()
.map(|pair| pair.map(|(key, _)| key))
{
match key?.as_str() {
"text" | "fg" | "bg" | "bold" | "dim" | "italics" | "blink" | "reverse" | "hidden"
| "strikethrough" | "underscore" | "align" | "fill" => {}
other => {
return Err(mlua::Error::RuntimeError(format!(
"unknown status style: {other}"
)));
}
}
}
let mut attrs = Vec::new();
for key in ["fg", "bg"] {
if let Some(value) = table.get::<Option<String>>(key)? {
attrs.push(format!("{key}={value}"));
}
}
for key in [
"bold",
"dim",
"italics",
"blink",
"reverse",
"hidden",
"strikethrough",
] {
if table.get::<Option<bool>>(key)?.unwrap_or(false) {
attrs.push(key.to_string());
}
}
match table.get::<Value>("underscore")? {
Value::Boolean(true) => attrs.push("underscore".to_string()),
Value::Boolean(false) | Value::Nil => {}
Value::String(style) => attrs.push(format!("underscore={}", style.to_string_lossy())),
other => {
return Err(mlua::Error::RuntimeError(format!(
"status style underscore must be a boolean or string, got {other:?}"
)));
}
}
for key in ["align", "fill"] {
if let Some(value) = table.get::<Option<String>>(key)? {
attrs.push(format!("{key}={value}"));
}
}
Ok(attrs)
}
fn parse_panel_cards(cards: Table) -> mlua::Result<Vec<PanelCard>> {
cards
.sequence_values::<Table>()
.map(|card| {
let card = card?;
Ok(PanelCard {
title: card.get("title")?,
subtitle: card.get("subtitle")?,
state: card.get("state")?,
tag: card.get("tag")?,
pane: card.get("pane")?,
enter: parse_optional_command(card.get("enter")?)?,
expand: parse_optional_command(card.get("expand")?)?,
})
})
.collect()
}
fn parse_optional_command(value: Value) -> mlua::Result<Option<Vec<String>>> {
match value {
Value::Nil => Ok(None),
other => parse_keybind_command(other).map(Some),
}
}
fn parse_bind(
lua: &Lua,
commands: &Rc<RefCell<HashMap<String, RegistryKey>>>,
panes: &Rc<RefCell<Vec<PaneSnapshot>>>,
generated: &Rc<Cell<usize>>,
args: mlua::MultiValue,
) -> mlua::Result<Keybind> {
let values = args.into_iter().collect::<Vec<_>>();
match values.as_slice() {
[key, command] => {
let command =
parse_bind_command_value(lua, commands, panes, generated, command.clone())?;
Ok(Keybind {
mode: "prefix".to_string(),
key: value_to_string(key, "key")?,
raw: command.raw,
command: command.command,
context: command.context.unwrap_or(true),
popup: false,
})
}
[key, command, opts] => {
let opts = parse_keybind_opts(opts, true)?;
let command =
parse_bind_command_value(lua, commands, panes, generated, command.clone())?;
Ok(Keybind {
mode: opts.mode,
key: value_to_string(key, "key")?,
raw: command.raw,
command: command.command,
context: command.context.unwrap_or(opts.context),
popup: opts.popup,
})
}
_ => Err(mlua::Error::RuntimeError(
"expected tpane.bind(key, action[, opts])".to_string(),
)),
}
}
struct ParsedBindCommand {
command: Vec<String>,
raw: bool,
context: Option<bool>,
}
fn parse_bind_command_value(
lua: &Lua,
commands: &Rc<RefCell<HashMap<String, RegistryKey>>>,
panes: &Rc<RefCell<Vec<PaneSnapshot>>>,
generated: &Rc<Cell<usize>>,
value: Value,
) -> mlua::Result<ParsedBindCommand> {
match value {
Value::Function(function) => {
let idx = generated.get() + 1;
generated.set(idx);
let name = format!("__tpane_key_{idx}");
let panes = Rc::clone(panes);
let handler = lua.create_function(move |lua, args: Table| {
let pane = args
.get::<Option<String>>(1)?
.map(|id| pane_from_snapshot_or_id(lua, &panes.borrow(), &id))
.transpose()?
.unwrap_or(Value::Nil);
function.call::<Value>((pane, args))
})?;
commands
.borrow_mut()
.insert(name.clone(), lua.create_registry_value(handler)?);
Ok(ParsedBindCommand {
command: vec![name],
raw: false,
context: None,
})
}
Value::Table(table) => parse_action_table(table),
Value::String(command) => Ok(ParsedBindCommand {
command: vec![command.to_string_lossy()],
raw: true,
context: Some(false),
}),
other => Err(mlua::Error::RuntimeError(format!(
"expected bind action, got {other:?}"
))),
}
}
#[cfg(test)]
fn run_action_table(lua: &Lua, parts: &[String]) -> mlua::Result<Table> {
let table = lua.create_table()?;
table.set("__tpane_action", "run")?;
let command = lua.create_table()?;
for (idx, part) in parts.iter().enumerate() {
command.set(idx + 1, part.as_str())?;
}
table.set("command", command)?;
Ok(table)
}
fn parse_action_table(table: Table) -> mlua::Result<ParsedBindCommand> {
match table.get::<Option<String>>("__tpane_action")?.as_deref() {
Some("raw") => Ok(ParsedBindCommand {
command: vec![table.get::<String>("command")?],
raw: true,
context: Some(false),
}),
Some("run") => Ok(ParsedBindCommand {
command: table
.get::<Table>("command")?
.sequence_values::<String>()
.collect::<mlua::Result<Vec<_>>>()?,
raw: false,
context: None,
}),
Some(other) => Err(mlua::Error::RuntimeError(format!(
"unknown bind action: {other}"
))),
None => Ok(ParsedBindCommand {
command: table
.sequence_values::<String>()
.collect::<mlua::Result<Vec<_>>>()?,
raw: false,
context: None,
}),
}
}
struct KeybindOpts {
mode: String,
context: bool,
popup: bool,
}
fn parse_keybind_opts(value: &Value, default_context: bool) -> mlua::Result<KeybindOpts> {
match value {
Value::Table(table) => {
for key in table
.clone()
.pairs::<String, Value>()
.map(|pair| pair.map(|(key, _)| key))
{
match key?.as_str() {
"popup" | "context" | "prefix" | "table" | "mode" => {}
other => {
return Err(mlua::Error::RuntimeError(format!(
"unknown bind option: {other}"
)));
}
}
}
let popup = table.get::<Option<bool>>("popup")?.unwrap_or(false);
let context = table.get::<Option<bool>>("context")?.unwrap_or(if popup {
false
} else {
default_context
});
let mode = match (
table.get::<Option<String>>("table")?,
table.get::<Option<String>>("mode")?,
) {
(Some(table), _) => table,
(None, Some(mode)) if mode == "copy" => "copy-mode-vi".to_string(),
(None, Some(mode)) => mode,
(None, None) if table.get::<Option<bool>>("prefix")? == Some(false) => {
"root".to_string()
}
(None, None) => "prefix".to_string(),
};
Ok(KeybindOpts {
mode,
context,
popup,
})
}
Value::Nil => Ok(KeybindOpts {
mode: "prefix".to_string(),
context: default_context,
popup: false,
}),
other => Err(mlua::Error::RuntimeError(format!(
"expected keybind opts table, got {other:?}"
))),
}
}
fn value_to_string(value: &Value, name: &str) -> mlua::Result<String> {
match value {
Value::String(value) => Ok(value.to_string_lossy()),
other => Err(mlua::Error::RuntimeError(format!(
"expected {name} string, got {other:?}"
))),
}
}
fn parse_keybind_command(value: Value) -> mlua::Result<Vec<String>> {
match value {
Value::String(command) => Ok(command
.to_string_lossy()
.split_whitespace()
.map(str::to_string)
.collect()),
Value::Table(table) => table.sequence_values::<String>().collect(),
other => Err(mlua::Error::RuntimeError(format!(
"expected keybind command string or table, got {other:?}"
))),
}
}
pub(crate) fn config_dir() -> PathBuf {
if let Some(path) = env::var_os("TPANE_CONFIG_DIR") {
return PathBuf::from(path);
}
let home = config_home();
let tmux = home.join("tmux/tpane");
let legacy = home.join("tpane");
if tmux.exists() || !legacy.exists() {
tmux
} else {
legacy
}
}
fn config_home() -> PathBuf {
env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.unwrap_or_else(|| PathBuf::from(".config"))
}
fn pane_ref_table(lua: &Lua, pane_id: &str) -> mlua::Result<Table> {
let table = lua.create_table()?;
table.set("id", pane_id)?;
add_pane_table_methods(lua, &table, pane_id, Vec::new())?;
Ok(table)
}
fn pane_from_snapshot_or_id(
lua: &Lua,
panes: &[PaneSnapshot],
pane_id: &str,
) -> mlua::Result<Value> {
panes
.iter()
.find(|pane| pane.id == pane_id)
.map(|pane| snapshot_table(lua, pane).map(Value::Table))
.unwrap_or_else(|| pane_ref_table(lua, pane_id).map(Value::Table))
}
fn snapshots_table(lua: &Lua, panes: &[PaneSnapshot]) -> mlua::Result<Table> {
let table = lua.create_table()?;
for (idx, pane) in panes.iter().enumerate() {
table.set(idx + 1, snapshot_table(lua, pane)?)?;
}
Ok(table)
}
fn snapshot_table(lua: &Lua, pane: &PaneSnapshot) -> mlua::Result<Table> {
let table = lua.create_table()?;
table.set("id", pane.id.clone())?;
table.set("pid", pane.pid)?;
table.set("kind", pane.kind.clone())?;
table.set("label", pane.label.clone())?;
table.set("cwd", pane.cwd.clone())?;
table.set("cwd_basename", pane.cwd_basename.clone())?;
table.set("command", pane.command.clone())?;
table.set("session", pane.session.clone())?;
table.set("window", pane.window.clone())?;
table.set("active", pane.active)?;
table.set("zoomed", pane.zoomed)?;
table.set("tag", pane.tag.clone())?;
table.set("home", pane.home.clone())?;
table.set("state", pane.state.clone())?;
add_pane_table_methods(lua, &table, &pane.id, pane.processes.clone())?;
Ok(table)
}
fn add_pane_table_methods(
lua: &Lua,
table: &Table,
pane_id: &str,
processes: Vec<ProcessInfo>,
) -> mlua::Result<()> {
let id = pane_id.to_string();
table.set(
"var",
lua.create_function(move |_, (_self, name): (Table, String)| {
tmux::get_pane_var(&id, &name).map_err(mlua_external)
})?,
)?;
let id = pane_id.to_string();
table.set(
"capture",
lua.create_function(move |_, _self: Table| tmux::capture(&id).map_err(mlua_external))?,
)?;
let id = pane_id.to_string();
table.set(
"set",
lua.create_function(move |_, (_self, values): (Table, Table)| {
set_pane_fields(&id, values)
})?,
)?;
let tree = processes.clone();
table.set(
"proc_tree",
lua.create_function(move |_, _self: Table| Ok(LuaProcTree(tree.clone())))?,
)?;
let tree = processes;
table.set(
"running",
lua.create_function(move |_, (_self, pattern): (Table, String)| {
Ok(process_running(&tree, &pattern))
})?,
)?;
Ok(())
}
fn set_pane_fields(pane_id: &str, table: Table) -> mlua::Result<()> {
for name in ["kind", "label", "state", "tag", "home"] {
if let Some(value) = table.get::<Option<String>>(name)? {
tmux::set_pane_var(pane_id, &format!("@tpane_{name}"), &value)
.map_err(mlua_external)?;
}
}
if let Some(title) = table.get::<Option<String>>("title")? {
tmux::set_pane_title(pane_id, &title).map_err(mlua_external)?;
}
Ok(())
}
fn state_presentation_table(lua: &Lua, presentation: &StatePresentation) -> mlua::Result<Value> {
let table = lua.create_table()?;
table.set("color", presentation.color.clone())?;
table.set("glyph", presentation.glyph.clone())?;
Ok(Value::Table(table))
}
fn plugin_spec_from_lua(table: &Table) -> mlua::Result<PluginSpec> {
let url: Option<String> = table.get("url")?;
let repo: Option<String> = table.get("repo")?;
if url.is_some() && repo.is_some() && url != repo {
return Err(mlua::Error::RuntimeError(
"plugin spec cannot set different url and repo values".to_string(),
));
}
Ok(PluginSpec {
url: url.or(repo),
branch: table.get("branch")?,
tag: table.get("tag")?,
rev: table.get("rev")?,
path: table.get("path")?,
})
}
fn load_plugin(lua: &Lua, name: &str, spec: &PluginSpec) -> mlua::Result<()> {
plugins::validate_plugin_name(name).map_err(mlua_external)?;
plugins::validate_spec(spec).map_err(mlua_external)?;
if spec.url.is_some() {
let missing = !plugins::plugin_dir(name).exists();
if missing {
let _ = tmux::display_global_message(&format!("tpane: installing plugin {name}"));
}
plugins::ensure(name, spec).map_err(mlua_external)?;
if missing {
let _ = tmux::display_global_message(&format!("tpane: installed plugin {name}"));
}
} else {
plugins::assert_compatible(name, spec).map_err(mlua_external)?;
}
let entrypoint = plugins::entrypoint(name, spec).map_err(mlua_external)?;
if entrypoint.is_file() {
let source = fs::read_to_string(&entrypoint).map_err(mlua::Error::external)?;
if let Some(dir) = entrypoint.parent() {
prepend_package_path(lua, dir)?;
}
return lua
.load(&source)
.set_name(entrypoint.display().to_string())
.exec();
}
match name {
"vim-navigator" => lua
.load(BUILTIN_PLUGIN_VIM_NAVIGATOR)
.set_name("builtin/plugins/vim-navigator/init.lua")
.exec(),
"yank" => lua
.load(BUILTIN_PLUGIN_YANK)
.set_name("builtin/plugins/yank/init.lua")
.exec(),
"sensible" => lua
.load(BUILTIN_PLUGIN_SENSIBLE)
.set_name("builtin/plugins/sensible/init.lua")
.exec(),
"themes" => {
let tpane: Table = lua.globals().get("tpane")?;
tpane.set("_theme_data", BUILTIN_PLUGIN_THEMES_DATA)?;
lua.load(BUILTIN_PLUGIN_THEMES)
.set_name("builtin/plugins/themes/init.lua")
.exec()
}
_ => Err(mlua::Error::RuntimeError(format!("unknown plugin: {name}"))),
}
}
fn prepend_package_path(lua: &Lua, dir: &Path) -> mlua::Result<()> {
let package: Table = lua.globals().get("package")?;
let current = package.get::<String>("path").unwrap_or_default();
let dir = dir.display().to_string();
let paths = [format!("{dir}/?.lua"), format!("{dir}/?/init.lua")].join(";");
let next = if current.is_empty() {
paths
} else {
format!("{paths};{current}")
};
package.set("path", next)
}
fn install_package_path(lua: &Lua) -> Result<()> {
install_package_path_for(lua, &config_dir())
}
fn install_package_path_for(lua: &Lua, config_dir: &Path) -> Result<()> {
let package: Table = lua.globals().get("package").map_err(lua_err)?;
let current = package.get::<String>("path").unwrap_or_default();
let config = config_dir.display().to_string();
let paths = [
format!("{config}/?.lua"),
format!("{config}/?/init.lua"),
format!("{}/?.lua", plugins::plugin_root().display()),
format!("{}/?/init.lua", plugins::plugin_root().display()),
]
.join(";");
let next = if current.is_empty() {
paths
} else {
format!("{paths};{current}")
};
package.set("path", next).map_err(lua_err)
}
fn store_api(lua: &Lua, store: Rc<RefCell<Store>>) -> Result<Table> {
let table = lua.create_table().map_err(lua_err)?;
let get_store = Rc::clone(&store);
table
.set(
"get",
lua.create_function(move |lua, key: String| {
get_store
.borrow()
.get(&key)
.map(|value| json_to_lua(lua, &value))
.unwrap_or(Ok(Value::Nil))
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
let set_store = Rc::clone(&store);
table
.set(
"set",
lua.create_function(move |_, (key, value): (String, Value)| {
let value = lua_to_json(value)?;
set_store.borrow_mut().set(key, value);
Ok(())
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"delete",
lua.create_function(move |_, key: String| {
store.borrow_mut().delete(&key);
Ok(())
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
Ok(table)
}
fn json_to_lua(lua: &Lua, value: &JsonValue) -> mlua::Result<Value> {
Ok(match value {
JsonValue::Null => Value::Nil,
JsonValue::Bool(value) => Value::Boolean(*value),
JsonValue::Number(value) => {
if let Some(integer) = value.as_i64() {
Value::Integer(integer)
} else if let Some(number) = value.as_f64() {
Value::Number(number)
} else {
Value::Nil
}
}
JsonValue::String(value) => Value::String(lua.create_string(value)?),
JsonValue::Array(values) => {
let table = lua.create_table()?;
for (idx, value) in values.iter().enumerate() {
table.set(idx + 1, json_to_lua(lua, value)?)?;
}
Value::Table(table)
}
JsonValue::Object(values) => {
let table = lua.create_table()?;
for (key, value) in values {
table.set(key.as_str(), json_to_lua(lua, value)?)?;
}
Value::Table(table)
}
})
}
fn lua_to_json(value: Value) -> mlua::Result<JsonValue> {
lua_to_json_seen(value, &mut HashSet::new())
}
fn lua_to_json_seen(value: Value, seen: &mut HashSet<usize>) -> mlua::Result<JsonValue> {
match value {
Value::Nil => Ok(JsonValue::Null),
Value::Boolean(value) => Ok(JsonValue::Bool(value)),
Value::Integer(value) => Ok(JsonValue::Number(JsonNumber::from(value))),
Value::Number(value) => JsonNumber::from_f64(value)
.map(JsonValue::Number)
.ok_or_else(|| mlua::Error::RuntimeError("cannot store non-finite number".to_string())),
Value::String(value) => Ok(JsonValue::String(value.to_string_lossy())),
Value::Table(table) => lua_table_to_json(table, seen),
other => Err(mlua::Error::RuntimeError(format!(
"cannot store Lua value: {other:?}"
))),
}
}
fn lua_table_to_json(table: Table, seen: &mut HashSet<usize>) -> mlua::Result<JsonValue> {
let pointer = table.to_pointer() as usize;
if !seen.insert(pointer) {
return Err(mlua::Error::RuntimeError(
"cannot store recursive table".to_string(),
));
}
let result = (|| {
let mut array_values: Vec<(usize, JsonValue)> = Vec::new();
let mut object = JsonMap::new();
let mut array_like = true;
for pair in table.pairs::<Value, Value>() {
let (key, value) = pair?;
let value = lua_to_json_seen(value, seen)?;
match key {
Value::Integer(index) if index > 0 => {
array_values.push((index as usize, value));
}
Value::String(key) => {
array_like = false;
object.insert(key.to_string_lossy(), value);
}
other => {
return Err(mlua::Error::RuntimeError(format!(
"cannot store table key: {other:?}"
)));
}
}
}
if !array_values.is_empty() {
if !array_like {
return Err(mlua::Error::RuntimeError(
"cannot store table with mixed array and object keys".to_string(),
));
}
array_values.sort_by_key(|(idx, _)| *idx);
if array_values
.iter()
.enumerate()
.all(|(offset, (idx, _))| *idx == offset + 1)
{
return Ok(JsonValue::Array(
array_values.into_iter().map(|(_, value)| value).collect(),
));
}
return Err(mlua::Error::RuntimeError(
"cannot store sparse array table".to_string(),
));
}
Ok(JsonValue::Object(object))
})();
seen.remove(&pointer);
result
}
fn tmux_api(lua: &Lua) -> Result<Table> {
let table = lua.create_table().map_err(lua_err)?;
table
.set(
"select",
lua.create_function(|_, pane: Value| {
let pane_id = pane_id_from_value(pane)?;
tmux::select_pane(&pane_id).map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"zoom",
lua.create_function(|_, pane: Value| {
let pane_id = pane_id_from_value(pane)?;
tmux::zoom(&pane_id).map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"unzoom",
lua.create_function(|_, target: String| tmux::unzoom(&target).map_err(mlua_external))
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"capture",
lua.create_function(|_, pane: Value| {
let pane_id = pane_id_from_value(pane)?;
tmux::capture(&pane_id).map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"kill_pane",
lua.create_function(|_, pane: Value| {
let pane_id = pane_id_from_value(pane)?;
tmux::kill_pane(&pane_id).map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"current_pane",
lua.create_function(|_, ()| tmux::current_pane().map_err(mlua_external))
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"active_pane",
lua.create_function(|_, target: String| {
tmux::active_pane(&target).map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"is_zoomed",
lua.create_function(|_, target: String| {
tmux::is_zoomed(&target).map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"window_id",
lua.create_function(|_, target: String| {
tmux::window_id(&target).map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"new_window",
lua.create_function(|_, opts: Table| {
tmux::new_window(tmux::NewWindowOptions {
name: opts.get("name")?,
cwd: opts.get("cwd")?,
command: opts.get("command")?,
})
.map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"select_window",
lua.create_function(|_, target: String| {
tmux::select_window(&target).map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"send_keys",
lua.create_function(|_, opts: Table| {
let target: String = opts.get("target")?;
let keys: String = opts.get("keys")?;
let enter = opts.get::<Option<bool>>("enter")?.unwrap_or(false);
tmux::send_keys(&target, &keys, enter).map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"display",
lua.create_function(|_, opts: Table| {
let target: String = opts.get("target")?;
let message: String = opts.get("message")?;
tmux::display_message(&target, &message).map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"split",
lua.create_function(|_, opts: Table| {
let target: String = opts.get("target")?;
let dir = opts
.get::<Option<String>>("dir")?
.or(opts.get::<Option<String>>("direction")?)
.unwrap_or_else(|| "right".to_string());
let (direction, before) = split_direction(&dir)?;
tmux::split(
&target,
tmux::SplitOptions {
direction,
before: before || opts.get::<Option<bool>>("before")?.unwrap_or(false),
size: opts.get("size")?,
cwd: opts.get("cwd")?,
command: opts.get("command")?,
detached: opts.get::<Option<bool>>("detached")?.unwrap_or(false),
full: opts.get::<Option<bool>>("full")?.unwrap_or(false),
},
)
.map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"join",
lua.create_function(|_, opts: Table| {
let src: String = opts.get("src")?;
let target: String = opts.get("target")?;
tmux::join(
&src,
&target,
tmux::JoinOptions {
horizontal: opts.get::<Option<bool>>("horizontal")?.unwrap_or(true),
size: opts.get("size")?,
full: opts.get::<Option<bool>>("full")?.unwrap_or(false),
},
)
.map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"break_pane",
lua.create_function(|_, opts: Table| {
let pane: String = opts.get("pane")?;
let session: String = opts.get("session")?;
let name: String = opts.get("name").unwrap_or_else(|_| "tpane".to_string());
tmux::break_pane(&pane, &session, &name).map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"break",
table.get::<Function>("break_pane").map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"stash",
lua.create_function(|_, opts: Table| {
tmux::stash(tmux::StashOptions {
pane: opts.get("pane")?,
window: opts.get("window")?,
cwd: opts.get("cwd")?,
name: opts.get("name").unwrap_or_else(|_| "tpane".to_string()),
})
.map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
table
.set(
"unstash",
lua.create_function(|_, opts: Table| {
tmux::unstash(tmux::UnstashOptions {
pane: opts.get("pane")?,
target: opts.get("target")?,
horizontal: opts.get::<Option<bool>>("horizontal")?.unwrap_or(true),
size: opts.get("size")?,
full: opts.get::<Option<bool>>("full")?.unwrap_or(false),
})
.map_err(mlua_external)
})
.map_err(lua_err)?,
)
.map_err(lua_err)?;
let cleanup = lua
.create_function(|_, window: String| tmux::cleanup_stash(&window).map_err(mlua_external))
.map_err(lua_err)?;
table
.set("cleanup_stash", cleanup.clone())
.map_err(lua_err)?;
table.set("cleanup", cleanup).map_err(lua_err)?;
Ok(table)
}
fn split_direction(dir: &str) -> mlua::Result<(tmux::SplitDirection, bool)> {
match dir {
"right" | "h" | "horizontal" => Ok((tmux::SplitDirection::Horizontal, false)),
"left" => Ok((tmux::SplitDirection::Horizontal, true)),
"below" | "bottom" | "down" | "v" | "vertical" => {
Ok((tmux::SplitDirection::Vertical, false))
}
"above" | "top" | "up" => Ok((tmux::SplitDirection::Vertical, true)),
other => Err(mlua::Error::RuntimeError(format!(
"unknown split dir: {other}"
))),
}
}
fn with_pane_fn(lua: &Lua) -> Result<Function> {
lua.create_function(|_, (pane, opts, body): (Value, Table, Function)| {
let pane_id = pane_id_from_value(pane)?;
let _guard = PaneGuard::stage(&pane_id, &opts).map_err(mlua_external)?;
body.call::<Value>(())
})
.map_err(lua_err)
}
struct PaneGuard {
pane_id: String,
active_before: String,
zoomed_before: bool,
}
impl PaneGuard {
fn stage(pane_id: &str, opts: &Table) -> Result<Self> {
let window = tmux::window_id(pane_id)?;
let active_before = tmux::active_pane(&window)?;
let zoomed_before = tmux::is_zoomed(&window)?;
tmux::select_pane(pane_id)?;
if opts
.get::<Option<bool>>("zoom")
.map_err(lua_err)?
.unwrap_or(false)
&& !tmux::is_zoomed(pane_id)?
{
tmux::zoom(pane_id)?;
}
if let Some(state) = opts.get::<Option<String>>("state").map_err(lua_err)? {
tmux::set_pane_var(pane_id, "@tpane_state", &state)?;
}
Ok(Self {
pane_id: pane_id.to_string(),
active_before,
zoomed_before,
})
}
}
impl Drop for PaneGuard {
fn drop(&mut self) {
let _ = tmux::select_pane(&self.pane_id);
if let Ok(current_zoomed) = tmux::is_zoomed(&self.pane_id)
&& current_zoomed != self.zoomed_before
{
let _ = tmux::zoom(&self.pane_id);
}
let _ = tmux::select_pane(&self.active_before);
}
}
fn pane_id_from_value(value: Value) -> mlua::Result<String> {
match value {
Value::String(value) => Ok(value.to_string_lossy()),
Value::Table(table) => table.get("id"),
other => Err(mlua::Error::RuntimeError(format!(
"expected pane id string or pane table, got {other:?}"
))),
}
}
fn mlua_external(error: anyhow::Error) -> mlua::Error {
mlua::Error::RuntimeError(error.to_string())
}
impl LuaJob {
fn value(&self) -> Option<String> {
self.data.borrow().get(&self.name).cloned()
}
}
impl UserData for LuaJob {
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
fields.add_field_method_get("name", |_, this| Ok(this.name.clone()));
fields.add_field_method_get("value", |_, this| Ok(this.value()));
}
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
methods.add_method("get", |_, this, ()| Ok(this.value()));
}
}
impl UserData for LuaWidget {
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
fields.add_field_method_get("name", |_, this| Ok(this.name.clone()));
}
}
impl UserData for LuaPane {
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
fields.add_field_method_get("id", |_, this| Ok(this.id.clone()));
fields.add_field_method_get("pid", |_, this| Ok(this.pid));
fields.add_field_method_get("cwd", |_, this| Ok(this.cwd.clone()));
fields.add_field_method_get("cwd_basename", |_, this| Ok(this.cwd_basename.clone()));
fields.add_field_method_get("command", |_, this| Ok(this.command.clone()));
fields.add_field_method_get("window", |_, this| Ok(this.window.clone()));
fields.add_field_method_get("session", |_, this| Ok(this.session.clone()));
fields.add_field_method_get("active", |_, this| Ok(this.active));
fields.add_field_method_get("zoomed", |_, this| Ok(this.zoomed));
fields.add_field_method_get("kind", |_, this| Ok(this.kind.clone()));
fields.add_field_method_get("label", |_, this| Ok(this.label.clone()));
fields.add_field_method_get("tag", |_, this| Ok(this.tag.clone()));
fields.add_field_method_get("home", |_, this| Ok(this.home.clone()));
fields.add_field_method_get("state", |_, this| Ok(this.state.clone()));
}
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
methods.add_method("proc_tree", |_, this, ()| {
Ok(LuaProcTree(this.proc_tree.clone()))
});
methods.add_method("running", |_, this, pattern: String| {
Ok(process_running(&this.proc_tree, &pattern))
});
methods.add_method("var", |_, this, name: String| {
tmux::get_pane_var(&this.id, &name).map_err(mlua_external)
});
methods.add_method("capture", |_, this, ()| {
tmux::capture(&this.id).map_err(mlua_external)
});
methods.add_method("set", |_, this, table: Table| {
set_pane_fields(&this.id, table)
});
}
}
impl UserData for LuaProcTree {
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
methods.add_method("any", |lua, this, predicate: Function| {
for process in &this.0 {
let table = process_table(lua, process)?;
if predicate.call::<bool>(table)? {
return Ok(true);
}
}
Ok(false)
});
methods.add_method("list", |lua, this, ()| {
let table = lua.create_table()?;
for (idx, process) in this.0.iter().enumerate() {
table.set(idx + 1, process_table(lua, process)?)?;
}
Ok(table)
});
}
}
fn lua_err(error: mlua::Error) -> anyhow::Error {
anyhow!(error.to_string())
}
fn process_running(processes: &[ProcessInfo], name: &str) -> bool {
processes
.iter()
.any(|process| argv_has_command(&process.argv, name))
}
fn argv_has_command(argv: &str, name: &str) -> bool {
argv.split_whitespace()
.any(|token| command_matches(token, name))
}
fn command_matches(token: &str, name: &str) -> bool {
token == name
|| std::path::Path::new(token)
.file_name()
.and_then(|part| part.to_str())
== Some(name)
}
fn process_table(lua: &Lua, process: &ProcessInfo) -> mlua::Result<Table> {
let table = lua.create_table()?;
table.set("pid", process.pid)?;
table.set("ppid", process.ppid)?;
table.set("argv", process.argv.clone())?;
Ok(table)
}
fn basename(path: &str) -> String {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(path)
.to_string()
}
const PRELUDE: &str = include_str!("lua/prelude.lua");
const WIDGETS: &str = include_str!("lua/widgets.lua");
const BUILTIN_PLUGIN_VIM_NAVIGATOR: &str = include_str!("../plugins/vim-navigator/init.lua");
const BUILTIN_PLUGIN_YANK: &str = include_str!("../plugins/yank/init.lua");
const BUILTIN_PLUGIN_SENSIBLE: &str = include_str!("../plugins/sensible/init.lua");
const BUILTIN_PLUGIN_THEMES: &str = include_str!("../plugins/themes/init.lua");
const BUILTIN_PLUGIN_THEMES_DATA: &str = include_str!("../plugins/themes/palettes.tsv");
const BUILTIN_KINDS: &str = include_str!("lua/builtin_kinds.lua");
#[cfg(test)]
mod tests {
use super::*;
fn pane(id: &str) -> PaneSnapshot {
PaneSnapshot {
id: id.to_string(),
pid: 123,
kind: "term".to_string(),
label: "term · tpane".to_string(),
cwd: "/tmp/tpane".to_string(),
cwd_basename: "tpane".to_string(),
command: "zsh".to_string(),
session: "s".to_string(),
window: "@1".to_string(),
active: true,
zoomed: false,
tag: Some("terminal".to_string()),
home: Some("@1".to_string()),
state: Some("idle".to_string()),
processes: vec![ProcessInfo {
pid: 123,
ppid: 1,
argv: "zsh".to_string(),
}],
}
}
fn runtime() -> (LuaRuntime, Rc<RefCell<Vec<PaneSnapshot>>>) {
let panes = Rc::new(RefCell::new(Vec::new()));
(LuaRuntime::new(Rc::clone(&panes)).unwrap(), panes)
}
#[test]
fn collect_lua_files_loads_only_top_level_config() {
let root = std::env::temp_dir().join(format!("tpane-lua-files-{}", std::process::id()));
let nested = root.join("lib");
let plugin = root.join("plugins/foo");
std::fs::create_dir_all(&nested).unwrap();
std::fs::create_dir_all(&plugin).unwrap();
std::fs::write(root.join("a.lua"), "").unwrap();
std::fs::write(nested.join("helper.lua"), "").unwrap();
std::fs::write(plugin.join("init.lua"), "").unwrap();
std::fs::write(plugin.join("lib.lua"), "").unwrap();
let mut files = Vec::new();
collect_lua_files(&root, &mut files);
files.sort();
let rel = files
.iter()
.map(|path| path.strip_prefix(&root).unwrap().display().to_string())
.collect::<Vec<_>>();
assert_eq!(rel, ["a.lua"]);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn split_direction_maps_user_words() {
assert!(matches!(
split_direction("below").unwrap(),
(tmux::SplitDirection::Vertical, false)
));
assert!(matches!(
split_direction("bottom").unwrap(),
(tmux::SplitDirection::Vertical, false)
));
assert!(matches!(
split_direction("above").unwrap(),
(tmux::SplitDirection::Vertical, true)
));
assert!(matches!(
split_direction("top").unwrap(),
(tmux::SplitDirection::Vertical, true)
));
assert!(matches!(
split_direction("right").unwrap(),
(tmux::SplitDirection::Horizontal, false)
));
assert!(matches!(
split_direction("left").unwrap(),
(tmux::SplitDirection::Horizontal, true)
));
}
#[test]
fn job_registers_command_and_data_reads_cache() {
let panes = Rc::new(RefCell::new(Vec::new()));
let data = Rc::new(RefCell::new(HashMap::new()));
data.borrow_mut()
.insert("__tpane_job_1".to_string(), "up 1 hour".to_string());
let runtime = LuaRuntime::with_store_and_data(
Rc::clone(&panes),
Rc::new(RefCell::new(Store::memory())),
data,
)
.unwrap();
runtime
.load_source(
"test.lua",
r#"
local uptime = tpane.job({ every = "1m", timeout = "5s", cmd = "uptime" })
tpane.statusline { left = { uptime } }
value = uptime:get()
"#,
)
.unwrap();
assert_eq!(
runtime.jobs(),
vec![JobDef {
name: "__tpane_job_1".to_string(),
every: Duration::from_secs(60),
timeout: Duration::from_secs(5),
command: "uptime".to_string(),
}]
);
assert_eq!(
runtime.lua.globals().get::<String>("value").unwrap(),
"up 1 hour"
);
assert_eq!(
runtime.render_statusline(None).0.left.as_deref(),
Some("up 1 hour")
);
}
#[test]
fn bind_accepts_run_and_raw_actions() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.bind("a", tpane.run("pi"))
tpane.bind("A", tpane.run({ "pi", "expand" }))
tpane.bind("M-a", tpane.pane.select("left"), { prefix = false })
tpane.bind("%", tpane.pane.split("right", { cwd = "pane" }))
tpane.bind("C-S-l", tpane.window.swap("next"), { prefix = false })
tpane.bind("p", tpane.window.previous())
tpane.bind("n", tpane.window.next())
"#,
)
.unwrap();
assert_eq!(
runtime.keybinds(),
vec![
Keybind {
mode: "prefix".to_string(),
key: "a".to_string(),
command: vec!["pi".to_string()],
raw: false,
context: true,
popup: false,
},
Keybind {
mode: "prefix".to_string(),
key: "A".to_string(),
command: vec!["pi".to_string(), "expand".to_string()],
raw: false,
context: true,
popup: false,
},
Keybind {
mode: "root".to_string(),
key: "M-a".to_string(),
command: vec!["select-pane -L".to_string()],
raw: true,
context: false,
popup: false,
},
Keybind {
mode: "prefix".to_string(),
key: "%".to_string(),
command: vec!["split-window -h -c \"#{pane_current_path}\"".to_string()],
raw: true,
context: false,
popup: false,
},
Keybind {
mode: "root".to_string(),
key: "C-S-l".to_string(),
command: vec!["swap-window -t +1 ; select-window -t +1".to_string()],
raw: true,
context: false,
popup: false,
},
Keybind {
mode: "prefix".to_string(),
key: "p".to_string(),
command: vec!["previous-window".to_string()],
raw: true,
context: false,
popup: false,
},
Keybind {
mode: "prefix".to_string(),
key: "n".to_string(),
command: vec!["next-window".to_string()],
raw: true,
context: false,
popup: false,
},
]
);
}
#[test]
fn bind_opts_select_prefix_root_or_table() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.bind("C-g", function() end, { prefix = false })
tpane.bind("M-h", function() end)
tpane.bind("v", function() end, { mode = "copy" })
tpane.bind("x", function() end, { prefix = false, table = "copy-mode-vi" })
tpane.bind("C-a", function() end, { prefix = true })
"#,
)
.unwrap();
let keybinds = runtime.keybinds();
assert_eq!(keybinds[0].mode, "root");
assert_eq!(keybinds[0].key, "C-g");
assert_eq!(keybinds[1].mode, "prefix");
assert_eq!(keybinds[1].key, "M-h");
assert_eq!(keybinds[2].mode, "copy-mode-vi");
assert_eq!(keybinds[2].key, "v");
assert_eq!(keybinds[3].mode, "copy-mode-vi");
assert_eq!(keybinds[3].key, "x");
assert_eq!(keybinds[4].mode, "prefix");
assert_eq!(keybinds[4].key, "C-a");
}
#[test]
fn register_pane_stores_reusable_config_without_overloading_pane_handle() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.register_pane("agent", { command = "pi" })
tpane.command(function()
local pane = tpane.pane("%1")
local cfg = tpane._pane_defs.agent
return pane.id .. ":" .. cfg.tag .. ":" .. cfg.name
end)
"#,
)
.unwrap();
assert_eq!(
runtime
.run_command("__tpane_command_1", &[])
.unwrap()
.as_deref(),
Some("%1:agent:agent")
);
}
#[test]
fn collector_records_plugin_specs_without_loading_plugins() {
let panes = Rc::new(RefCell::new(Vec::new()));
let runtime = LuaRuntime::collector(panes).unwrap();
runtime
.load_source(
"test.lua",
r#"
tpane.use("foo", { repo = "https://example.test/foo.git", branch = "main", path = "plugins/foo" })
"#,
)
.unwrap();
let specs = runtime.used_plugin_specs();
let spec = specs.get("foo").unwrap();
assert_eq!(spec.url.as_deref(), Some("https://example.test/foo.git"));
assert_eq!(spec.branch.as_deref(), Some("main"));
assert_eq!(spec.path.as_deref(), Some("plugins/foo"));
}
#[test]
fn builtin_sensible_plugin_sets_defaults() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.use("sensible")
"#,
)
.unwrap();
let options = runtime.options();
assert!(options.contains(&("escape-time".to_string(), "0".to_string())));
assert!(options.contains(&("history-limit".to_string(), "50000".to_string())));
assert!(options.contains(&("display-time".to_string(), "4000".to_string())));
assert!(options.contains(&("status-interval".to_string(), "5".to_string())));
assert!(options.contains(&("focus-events".to_string(), "on".to_string())));
assert!(options.contains(&("status-keys".to_string(), "emacs".to_string())));
assert!(options.contains(&("aggressive-resize".to_string(), "on".to_string())));
assert!(runtime.keybinds().is_empty());
}
#[test]
fn builtin_themes_plugin_applies_palette() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.use("themes")
tpane.theme("catppuccin-mocha")
"#,
)
.unwrap();
assert!(runtime.options().contains(&(
"status-style".to_string(),
"fg=#cdd6f4,bg=#1e1e2e".to_string()
)));
assert_eq!(
runtime
.state_presentation("working")
.unwrap()
.color
.as_deref(),
Some("#f9e2af")
);
}
#[test]
fn deferred_theme_wins_after_later_status_config() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.use("themes")
tpane.theme("Gruvbox Dark")
tpane.options({ status = { style = { bg = "default" } } })
"#,
)
.unwrap();
assert!(
runtime
.options()
.contains(&("status-style".to_string(), "bg=default".to_string()))
);
assert!(runtime.run_deferred().is_empty());
assert!(runtime.options().contains(&(
"status-style".to_string(),
"fg=#ebdbb2,bg=#282828".to_string()
)));
}
#[test]
fn theme_transparent_uses_default_status_background() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.use("themes")
tpane.theme("Gruvbox Dark", { transparent = true })
"#,
)
.unwrap();
assert!(runtime.run_deferred().is_empty());
assert!(runtime.options().contains(&(
"status-style".to_string(),
"fg=#ebdbb2,bg=default".to_string()
)));
}
#[test]
fn plugin_specs_reject_conflicting_repo_aliases() {
let panes = Rc::new(RefCell::new(Vec::new()));
let runtime = LuaRuntime::collector(panes).unwrap();
let error = runtime
.load_source(
"test.lua",
r#"
tpane.use("foo", { repo = "https://example.test/a.git", url = "https://example.test/b.git" })
"#,
)
.unwrap_err()
.to_string();
assert!(error.contains("plugin spec cannot set different url and repo values"));
}
#[test]
fn user_config_can_override_prelude_helpers() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
function tpane.expand()
return "custom"
end
tpane.command(function()
return tpane.expand()
end)
"#,
)
.unwrap();
assert_eq!(
runtime
.run_command("__tpane_command_1", &[])
.unwrap()
.as_deref(),
Some("custom")
);
}
#[test]
fn lua_require_uses_config_dir_package_path() {
let root = std::env::temp_dir().join(format!("tpane-require-{}", std::process::id()));
let lib = root.join("lib");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&lib).unwrap();
std::fs::write(lib.join("helper.lua"), "return { value = 'ok' }").unwrap();
let panes = Rc::new(RefCell::new(Vec::new()));
let runtime = LuaRuntime::new(panes).unwrap();
install_package_path_for(&runtime.lua, &root).unwrap();
runtime
.load_source(
"test.lua",
r#"
local helper = require("lib.helper")
tpane.command(function() return helper.value end)
"#,
)
.unwrap();
assert_eq!(
runtime
.run_command("__tpane_command_1", &[])
.unwrap()
.as_deref(),
Some("ok")
);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn store_api_round_trips_lua_values() {
let panes = Rc::new(RefCell::new(Vec::new()));
let store = Rc::new(RefCell::new(Store::memory()));
let runtime = LuaRuntime::with_store(panes, store).unwrap();
runtime
.load_source(
"test.lua",
r#"
tpane.command(function()
tpane.store.set("prefs", { count = 2, items = { "a", "b" } })
end)
tpane.command(function()
local prefs = tpane.store.get("prefs")
return prefs.count .. ":" .. prefs.items[2]
end)
"#,
)
.unwrap();
runtime.run_command("__tpane_command_1", &[]).unwrap();
assert_eq!(
runtime
.run_command("__tpane_command_2", &[])
.unwrap()
.as_deref(),
Some("2:b")
);
}
#[test]
fn store_rejects_sparse_lua_array_tables() {
let panes = Rc::new(RefCell::new(Vec::new()));
let store = Rc::new(RefCell::new(Store::memory()));
let runtime = LuaRuntime::with_store(panes, store).unwrap();
runtime
.load_source(
"test.lua",
r#"
tpane.command(function()
tpane.store.set("sparse", { [1] = "a", [3] = "c" })
end)
"#,
)
.unwrap();
let error = runtime
.run_command("__tpane_command_1", &[])
.unwrap_err()
.to_string();
assert!(error.contains("cannot store sparse array table"));
}
#[test]
fn store_rejects_mixed_lua_table_keys() {
let panes = Rc::new(RefCell::new(Vec::new()));
let store = Rc::new(RefCell::new(Store::memory()));
let runtime = LuaRuntime::with_store(panes, store).unwrap();
runtime
.load_source(
"test.lua",
r#"
tpane.command(function()
tpane.store.set("mixed", { "a", name = "b" })
end)
"#,
)
.unwrap();
let error = runtime
.run_command("__tpane_command_1", &[])
.unwrap_err()
.to_string();
assert!(error.contains("cannot store table with mixed array and object keys"));
}
#[test]
fn store_rejects_recursive_lua_tables() {
let panes = Rc::new(RefCell::new(Vec::new()));
let store = Rc::new(RefCell::new(Store::memory()));
let runtime = LuaRuntime::with_store(panes, store).unwrap();
runtime
.load_source(
"test.lua",
r#"
tpane.command(function()
local value = {}
value.self = value
tpane.store.set("cycle", value)
end)
"#,
)
.unwrap();
let error = runtime
.run_command("__tpane_command_1", &[])
.unwrap_err()
.to_string();
assert!(error.contains("cannot store recursive table"));
}
#[test]
fn workspace_registers_declarative_layout() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.workspace { name = "dev", windows = { { name = "app" }, { name = "logs" } } }
tpane.command(function()
return tostring(#tpane._workspaces.dev.windows)
end)
"#,
)
.unwrap();
assert_eq!(
runtime
.run_command("__tpane_command_1", &[])
.unwrap()
.as_deref(),
Some("2")
);
}
#[test]
fn find_and_find_all_match_query_fields() {
let (runtime, panes) = runtime();
panes.borrow_mut().push(pane("%1"));
let mut second = pane("%2");
second.tag = Some("agent".to_string());
second.window = "@2".to_string();
panes.borrow_mut().push(second);
runtime
.load_source(
"test.lua",
r#"
tpane.command(function()
local one = tpane.find{ tag = "agent" }
local all = tpane.find_all{ active = true }
return one.id .. ":" .. #all
end)
"#,
)
.unwrap();
assert_eq!(
runtime
.run_command("__tpane_command_1", &[])
.unwrap()
.as_deref(),
Some("%2:2")
);
}
#[test]
fn bind_accepts_function_and_registers_internal_command() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.bind("M-e", function()
return "ok"
end, { prefix = false })
"#,
)
.unwrap();
let keybind = &runtime.keybinds()[0];
assert_eq!(keybind.mode, "root");
assert_eq!(keybind.key, "M-e");
assert_eq!(keybind.command, ["__tpane_key_1"]);
assert_eq!(
runtime
.run_command("__tpane_key_1", &[])
.unwrap()
.as_deref(),
Some("ok")
);
}
#[test]
fn bind_rejects_unknown_options() {
let (runtime, _) = runtime();
let error = runtime
.load_source(
"test.lua",
r#"tpane.bind("a", tpane.run("pi"), { desc = "unused" })"#,
)
.unwrap_err()
.to_string();
assert!(error.contains("unknown bind option: desc"));
}
#[test]
fn registered_panel_renders_cards() {
let (runtime, panes) = runtime();
panes.borrow_mut().push(pane("%1"));
runtime
.load_source(
"test.lua",
r#"
tpane.panel{
id = "workspace",
title = "Workspace",
cards = function()
local p = tpane.panes()[1]
return {{ title = p.label, subtitle = p.window, state = p.state, tag = p.tag, pane = p.id }}
end,
}
"#,
)
.unwrap();
let panels = runtime.render_panels().unwrap();
assert_eq!(panels.len(), 1);
assert_eq!(panels[0].id, "workspace");
assert_eq!(panels[0].cards[0].pane.as_deref(), Some("%1"));
}
#[test]
fn style_builder_emits_spec_and_format_forms() {
let (runtime, _) = runtime();
let table = runtime.lua.create_table().unwrap();
table.set("text", "x").unwrap();
table.set("fg", "red").unwrap();
table.set("bold", true).unwrap();
assert_eq!(style_spec(&table).unwrap(), "fg=red,bold");
assert_eq!(
render_widget_table(table).unwrap().as_deref(),
Some("#[fg=red,bold]x#[default]")
);
}
#[test]
fn nested_options_flatten_and_serialize_styles() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r##"
tpane.options{
status = { left_length = 120 },
pane = { border = { lines = "heavy", style = { fg = "#51576d" } } },
["pane-active-border-style"] = { fg = "#8caaee" },
window = { status = { current_format = { text = "#I", fg = "#8caaee", bold = true } } },
}
"##,
)
.unwrap();
assert_eq!(
runtime.options(),
vec![
(
"pane-active-border-style".to_string(),
"fg=#8caaee".to_string()
),
(
"pane-border-format".to_string(),
"#{@tpane_border}".to_string()
),
("pane-border-lines".to_string(), "heavy".to_string()),
("pane-border-status".to_string(), "top".to_string()),
("pane-border-style".to_string(), "fg=#51576d".to_string()),
("status-left-length".to_string(), "120".to_string()),
(
"window-status-current-format".to_string(),
"#[fg=#8caaee,bold]#I#[default]".to_string()
),
]
);
}
#[test]
fn tabline_helper_sets_window_status_formats() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r##"
tpane.tabline{
label = "cwd",
inactive = { fg = "#777777" },
current = { fg = "#c6d0f5", bg = "#232634", bold = true },
}
"##,
)
.unwrap();
let options = runtime.options();
assert!(
options.contains(&(
"window-status-format".to_string(),
"#[fg=#777777]#I:#(pwd=\"#{pane_current_path}\"; echo ${pwd####*/})#[default]"
.to_string(),
))
);
assert!(options.contains(&(
"window-status-current-format".to_string(),
"#[fg=#c6d0f5,bg=#232634,bold]#I:#(pwd=\"#{pane_current_path}\"; echo ${pwd####*/})#[default]"
.to_string(),
)));
}
#[test]
fn fmt_helpers_return_tmux_conditionals() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r##"
tpane.command(function()
return tpane.fmt.prefix("ON", "off") .. ";" .. tpane.fmt.when("window_zoomed_flag", "Z", "")
end)
"##,
)
.unwrap();
assert_eq!(
runtime
.run_command("__tpane_command_1", &[])
.unwrap()
.as_deref(),
Some("#{?client_prefix,ON,off};#{?window_zoomed_flag,Z,}")
);
}
#[test]
fn state_registry_registers_and_reads_presentations() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.state("approval", { color = "magenta", glyph = "?" })
tpane.command(function()
return tpane.state("approval").color .. tpane.state("approval").glyph .. ";" .. tpane.state("idle_seen").color
end)
"#,
)
.unwrap();
assert_eq!(
runtime
.run_command("__tpane_command_1", &[])
.unwrap()
.as_deref(),
Some("magenta?;green")
);
}
#[test]
fn pane_border_renders_from_lua_and_state_registry() {
let (runtime, _) = runtime();
let mut pane = pane("%1");
pane.state = Some("working".to_string());
pane.label = "build".to_string();
let border = runtime.render_pane_border(&pane).unwrap().unwrap();
assert_eq!(
border,
"#[fg=yellow] #[default]#[fg=yellow]build#[default]"
);
}
#[test]
fn statusline_context_uses_current_pane_id() {
let (runtime, panes) = runtime();
let mut other = pane("%1");
other.session = "other".to_string();
other.window = "@1".to_string();
other.active = true;
panes.borrow_mut().push(other);
let mut current = pane("%2");
current.session = "current".to_string();
current.window = "@2".to_string();
current.active = true;
panes.borrow_mut().push(current);
runtime
.load_source(
"test.lua",
r#"
local s = tpane.widget(function(ctx) return "[" .. ctx.session .. "]" end)
tpane.statusline { left = { s } }
"#,
)
.unwrap();
let (status, errors) = runtime.render_statusline(Some("%2"));
assert!(errors.is_empty());
assert_eq!(status.left.as_deref(), Some("[current]"));
}
#[test]
fn builtin_session_widget_uses_tmux_client_session() {
let (runtime, panes) = runtime();
panes.borrow_mut().push(pane("%1"));
runtime
.load_source(
"test.lua",
"tpane.statusline { left = { tpane.widgets.session } }",
)
.unwrap();
let (status, errors) = runtime.render_statusline(Some("%1"));
assert!(errors.is_empty());
assert_eq!(status.left.as_deref(), Some("[#{client_session}] "));
}
#[test]
fn builtin_widget_pack_renders_common_status_parts() {
let (runtime, panes) = runtime();
panes.borrow_mut().push(pane("%1"));
runtime
.load_source(
"test.lua",
r#"
tpane.statusline {
left = { tpane.widgets.host, tpane.widgets.prefix }
}
"#,
)
.unwrap();
let (status, errors) = runtime.render_statusline(Some("%1"));
assert!(errors.is_empty());
assert_eq!(
status.left.as_deref(),
Some("#H #{?client_prefix, , }")
);
}
#[test]
fn unknown_builtin_widget_errors_with_name() {
let (runtime, _) = runtime();
let error = runtime
.load_source(
"test.lua",
r#"
tpane.statusline { right = { tpane.widgets.typo } }
"#,
)
.unwrap_err()
.to_string();
assert!(error.contains("unknown widget: tpane.widgets.typo"));
}
#[test]
fn builtin_job_widgets_register_jobs() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
local battery = tpane.widgets.battery({ every = "20s", timeout = "1s", cmd = "printf battery" })
local player = tpane.widgets.player({ every = "5s", timeout = "1s", cmd = "printf song" })
local cpu = tpane.widgets.cpu({ every = "2s", timeout = "1s", cmd = "printf cpu" })
local memory = tpane.widgets.memory({ every = "3s", timeout = "1s", cmd = "printf memory" })
tpane.statusline { right = { battery, player, cpu, memory } }
"#,
)
.unwrap();
let jobs = runtime.jobs();
assert_eq!(jobs.len(), 4);
assert_eq!(jobs[0].every, Duration::from_secs(20));
assert_eq!(jobs[0].timeout, Duration::from_secs(1));
assert_eq!(jobs[0].command, "printf battery");
assert_eq!(jobs[1].every, Duration::from_secs(5));
assert_eq!(jobs[1].timeout, Duration::from_secs(1));
assert_eq!(jobs[1].command, "printf song");
assert_eq!(jobs[2].every, Duration::from_secs(2));
assert_eq!(jobs[2].command, "printf cpu");
assert_eq!(jobs[3].every, Duration::from_secs(3));
assert_eq!(jobs[3].command, "printf memory");
}
#[test]
fn statusline_renders_raw_and_styled_widgets() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r##"
local raw = tpane.widget(function() return "#{session_name}" end)
local styled = tpane.widget(function() return { text = "x", fg = "red", bold = true } end)
tpane.statusline { position = "top", interval = 1, left = { raw }, right = { styled }, separator = " | " }
"##,
)
.unwrap();
let (status, errors) = runtime.render_statusline(None);
assert!(errors.is_empty());
assert_eq!(status.position.as_deref(), Some("top"));
assert_eq!(status.interval, Some(1));
assert_eq!(status.left.as_deref(), Some("#{session_name}"));
assert_eq!(status.right.as_deref(), Some("#[fg=red,bold]x#[default]"));
}
#[test]
fn statusline_rows_render_extra_status_formats() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
local session = tpane.widget(function() return "session" end)
local cwd = tpane.widget(function() return "cwd" end)
local clock = tpane.widget(function() return "clock" end)
tpane.statusline {
rows = {
{ left = { session }, right = { clock } },
{ left = { cwd, tpane.widgets.tabs }, right = { tpane.widgets.prefix } },
}
}
"#,
)
.unwrap();
let (status, errors) = runtime.render_statusline(None);
assert!(errors.is_empty());
assert_eq!(status.rows, Some(2));
assert_eq!(status.left, None);
assert_eq!(status.right, None);
assert_eq!(
status.formats,
vec![
(0, "session#[align=right]clock".to_string()),
(
1,
"cwd #{W:#{E:window-status-format} ,#{E:window-status-current-format} }#[align=right]#{?client_prefix, , }".to_string()
)
]
);
}
#[test]
fn statusline_accepts_implicit_top_level_rows() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
local session = tpane.widget(function() return "session" end)
local clock = tpane.widget(function() return "clock" end)
tpane.statusline {
position = "top",
interval = 1,
{ left = { session } },
{ right = { clock } },
}
"#,
)
.unwrap();
let (status, errors) = runtime.render_statusline(None);
assert!(errors.is_empty());
assert_eq!(status.position.as_deref(), Some("top"));
assert_eq!(status.interval, Some(1));
assert_eq!(status.rows, Some(2));
assert_eq!(
status.formats,
vec![
(0, "session".to_string()),
(1, "#[align=right]clock".to_string())
]
);
}
#[test]
fn statusline_rows_are_limited_to_tmux_max() {
let (runtime, _) = runtime();
let error = runtime
.load_source(
"test.lua",
r#"
local w = tpane.widget(function() return "x" end)
tpane.statusline { rows = {
{ left = { w } },
{ left = { w } },
{ left = { w } },
{ left = { w } },
{ left = { w } },
{ left = { w } },
} }
"#,
)
.unwrap_err()
.to_string();
assert!(error.contains("statusline supports at most 5 rows"));
}
#[test]
fn statusline_skips_failing_widgets_and_reports_errors() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
local bad = tpane.widget(function() error("boom") end)
local ok = tpane.widget(function() return "ok" end)
tpane.statusline { right = { bad, ok } }
"#,
)
.unwrap();
let (status, errors) = runtime.render_statusline(None);
assert_eq!(status.right.as_deref(), Some("ok"));
assert_eq!(errors.len(), 1);
assert!(errors.iter().any(|error| error.contains("boom")));
}
#[test]
fn statusline_rejects_unknown_style_keys() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
local bad = tpane.widget(function() return { text = "x", nope = true } end)
tpane.statusline { right = { bad } }
"#,
)
.unwrap();
let (status, errors) = runtime.render_statusline(None);
assert_eq!(status.right.as_deref(), Some(""));
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("unknown status style: nope"));
}
#[test]
fn named_commands_are_rejected() {
let (runtime, _) = runtime();
let error = runtime
.load_source(
"test.lua",
r#"
tpane.command("deploy", function() end)
"#,
)
.unwrap_err()
.to_string();
assert!(error.contains("error converting Lua string to function"));
}
#[test]
fn short_panel_names_are_primary_api() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.panel {
id = "main",
title = "Main",
cards = function() return {} end,
}
"#,
)
.unwrap();
assert_eq!(runtime.render_panels().unwrap()[0].id, "main");
}
#[test]
fn registered_command_returns_string_and_receives_args() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.command(function(args) return "hi " .. args[1] end)
"#,
)
.unwrap();
let out = runtime
.run_command("__tpane_command_1", &["there".to_string()])
.unwrap();
assert_eq!(out.as_deref(), Some("hi there"));
}
#[test]
fn unknown_and_throwing_commands_return_errors() {
let (runtime, _) = runtime();
assert!(runtime.run_command("missing", &[]).is_err());
runtime
.load_source(
"test.lua",
r#"
tpane.command(function() error("nope") end)
"#,
)
.unwrap();
let error = runtime
.run_command("__tpane_command_1", &[])
.unwrap_err()
.to_string();
assert!(error.contains("nope"));
}
#[test]
fn running_matches_exact_token_or_basename_not_substrings() {
let processes = vec![
ProcessInfo {
pid: 1,
ppid: 0,
argv: "pip install thing".to_string(),
},
ProcessInfo {
pid: 2,
ppid: 0,
argv: "compile pi".to_string(),
},
ProcessInfo {
pid: 3,
ppid: 0,
argv: "/usr/bin/psql -h localhost".to_string(),
},
];
assert!(!process_running(&processes[..1], "pi"));
assert!(process_running(&processes, "pi"));
assert!(process_running(&processes, "psql"));
assert!(!process_running(&processes, "sql"));
}
#[test]
fn declarative_kind_match_uses_running_helper_and_default_label() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"tpane.kind { name = "shell", match = "zsh" }"#,
)
.unwrap();
let detected = runtime
.detect(
&PaneInfo {
id: "%1".to_string(),
pid: 1,
cwd: "/tmp/work".to_string(),
command: "zsh".to_string(),
session: "s".to_string(),
window: "@1".to_string(),
active: true,
zoomed: false,
tag: None,
home: None,
state: None,
},
vec![ProcessInfo {
pid: 1,
ppid: 0,
argv: "zsh".to_string(),
}],
)
.unwrap()
.unwrap();
assert_eq!(detected.kind, "shell");
assert_eq!(detected.label, "shell");
}
#[test]
fn command_can_call_command_without_refcell_panic() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.command(function()
tpane.command(function() return "inner ok" end)
return "outer ok"
end)
"#,
)
.unwrap();
assert_eq!(
runtime
.run_command("__tpane_command_1", &[])
.unwrap()
.as_deref(),
Some("outer ok")
);
assert_eq!(
runtime
.run_command("__tpane_command_2", &[])
.unwrap()
.as_deref(),
Some("inner ok")
);
}
#[test]
fn event_can_register_event_without_refcell_panic() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.on("tick", function()
tpane.on("tick", function() end)
end)
"#,
)
.unwrap();
assert!(runtime.fire_event("tick", None).is_empty());
}
#[test]
fn panes_reads_shared_live_state() {
let (runtime, panes) = runtime();
panes.borrow_mut().push(pane("%1"));
runtime
.load_source(
"test.lua",
r#"
tpane.command(function()
local panes = tpane.panes()
return panes[1].id .. ":" .. panes[1].kind .. ":" .. panes[1].pid .. ":" .. panes[1].tag .. ":" .. panes[1].home .. ":" .. panes[1].state
end)
"#,
)
.unwrap();
let out = runtime.run_command("__tpane_command_1", &[]).unwrap();
assert_eq!(out.as_deref(), Some("%1:term:123:terminal:@1:idle"));
}
#[test]
fn pane_ref_exposes_methods_for_fresh_split_ids() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.command(function()
local p = tpane.pane("%9")
return p.id .. ":" .. type(p.set) .. ":" .. type(p.var) .. ":" .. type(p.capture)
end)
"#,
)
.unwrap();
let out = runtime.run_command("__tpane_command_1", &[]).unwrap();
assert_eq!(out.as_deref(), Some("%9:function:function:function"));
}
#[test]
fn pane_tables_expose_running_and_proc_tree() {
let (runtime, panes) = runtime();
panes.borrow_mut().push(pane("%1"));
runtime
.load_source(
"test.lua",
r#"
tpane.command(function()
local p = tpane.panes()[1]
return tostring(p:running("zsh")) .. ":" .. p:proc_tree():list()[1].argv .. ":" .. p.cwd_basename
end)
"#,
)
.unwrap();
let out = runtime.run_command("__tpane_command_1", &[]).unwrap();
assert_eq!(out.as_deref(), Some("true:zsh:tpane"));
}
#[test]
fn pane_tables_expose_methods() {
let (runtime, panes) = runtime();
panes.borrow_mut().push(pane("%1"));
runtime
.load_source(
"test.lua",
r#"
tpane.command(function()
local p = tpane.panes()[1]
return type(p.set) .. ":" .. type(p.var) .. ":" .. type(p.capture)
end)
"#,
)
.unwrap();
let out = runtime.run_command("__tpane_command_1", &[]).unwrap();
assert_eq!(out.as_deref(), Some("function:function:function"));
}
#[test]
fn events_call_handlers_and_collect_errors() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
seen = ""
tpane.on("pane:new", function(p) seen = p.id end)
tpane.on("pane:new", function() error("bad event") end)
tpane.command(function() return seen end)
"#,
)
.unwrap();
let errors = runtime.fire_event("pane:new", Some(&pane("%9")));
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("bad event"));
let out = runtime.run_command("__tpane_command_1", &[]).unwrap();
assert_eq!(out.as_deref(), Some("%9"));
}
#[test]
fn text_events_pass_plain_string_payload() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
seen = ""
tpane.on("window:close", function(window) seen = window end)
tpane.command(function() return seen end)
"#,
)
.unwrap();
assert!(runtime.fire_event_text("window:close", "@9").is_empty());
let out = runtime.run_command("__tpane_command_1", &[]).unwrap();
assert_eq!(out.as_deref(), Some("@9"));
}
#[test]
fn detect_skips_throwing_kind_and_uses_next_match() {
let (runtime, _) = runtime();
runtime
.load_source(
"test.lua",
r#"
tpane.kind{
name = "broken",
detect = function() error("bad detect") end,
label = function() return "broken" end,
}
tpane.kind{
name = "ok",
detect = function() return true end,
label = function(p) return "ok · " .. p.cwd_basename end,
}
"#,
)
.unwrap();
let detected = runtime
.detect(
&PaneInfo {
id: "%1".to_string(),
pid: 1,
cwd: "/tmp/work".to_string(),
command: "zsh".to_string(),
session: "s".to_string(),
window: "@1".to_string(),
active: true,
zoomed: false,
tag: None,
home: None,
state: None,
},
Vec::new(),
)
.unwrap()
.unwrap();
assert_eq!(detected.kind, "ok");
assert_eq!(detected.label, "ok · work");
}
}