use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use std::cell::RefCell;
use std::rc::Rc;
use async_channel::{Receiver, Sender, bounded, unbounded};
use async_executor::LocalExecutor;
use bevy::ecs::resource::Resource;
use futures_lite::future::block_on;
use tracing::{error, info, warn};
use super::convert::{self, LuaEvent};
use super::runtime::LuaRuntime;
use super::world::{DispatchWorld, WorldAccess};
use crate::commands::Command;
use crate::config::Config;
use crate::ecs::state::PaneruQueryState;
use crate::platform::input::set_lua_keybinds;
use crate::types::script_state::{ScriptState, ScriptStateWrite, WriteOutcome};
use crate::types::windowset::WindowSet;
const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
const SHUTDOWN_POLL: Duration = Duration::from_millis(2);
pub enum LuaSource {
Path(PathBuf),
#[cfg(test)]
Inline(String),
}
enum ToLua {
Events(Vec<LuaEvent>),
Binds {
ids: Vec<u32>,
},
Reload(PathBuf),
Shutdown,
}
pub(super) enum FromLua {
Command(Command),
Flash {
message: String,
duration: f32,
},
ConfigChanged,
}
pub(super) type Shared<T> = Result<Arc<T>, String>;
pub(super) enum WorldRequest {
State {
reply: Sender<Shared<PaneruQueryState>>,
},
WindowSet { reply: Sender<Shared<WindowSet>> },
}
pub(super) enum StoreRequest {
Read {
reply: Sender<Result<ScriptState, String>>,
},
Write {
write: ScriptStateWrite,
reply: Sender<Result<WriteOutcome, String>>,
},
}
#[cfg(test)]
impl WorldRequest {
fn answer(self, state: Shared<PaneruQueryState>) {
match self {
WorldRequest::State { reply } => {
let _ = reply.try_send(state);
}
WorldRequest::WindowSet { .. } => panic!("expected a state query"),
}
}
}
#[derive(Resource)]
pub struct LuaWorker {
to_lua: Sender<ToLua>,
outbox: Receiver<FromLua>,
world_queries: Receiver<WorldRequest>,
store_queries: Receiver<StoreRequest>,
subscribed_events: Arc<AtomicU64>,
built_config: Arc<Mutex<Option<Config>>>,
thread: Option<JoinHandle<()>>,
}
pub type ScriptStateRevision = Arc<AtomicU64>;
impl LuaWorker {
pub fn spawn(source: LuaSource, revision: ScriptStateRevision) -> Self {
let (to_lua, from_main) = unbounded();
let (to_main, outbox) = unbounded();
let (world_tx, world_queries) = unbounded();
let (store_tx, store_queries) = unbounded();
let (ready_tx, ready) = bounded(1);
let subscribed_events = Arc::new(AtomicU64::new(0));
let built_config = Arc::new(Mutex::new(None));
let thread = {
let subscribed_events = Arc::clone(&subscribed_events);
let built_config = Arc::clone(&built_config);
std::thread::Builder::new()
.name("paneru-lua".to_string())
.spawn(move || {
run(
&source,
&from_main,
&to_main,
&world_tx,
&store_tx,
&subscribed_events,
&built_config,
&revision,
&ready_tx,
);
})
.expect("spawning the Lua worker thread")
};
let _ = ready.recv_blocking();
Self {
to_lua,
outbox,
world_queries,
store_queries,
subscribed_events,
built_config,
thread: Some(thread),
}
}
pub fn built_config(&self) -> Option<Config> {
self.built_config
.lock()
.expect("the Lua worker never panics while holding this")
.clone()
}
pub(super) fn subscribed_event_mask(&self) -> u64 {
self.subscribed_events.load(Ordering::Relaxed)
}
#[cfg(test)]
pub(super) fn has_event_handlers(&self) -> bool {
self.subscribed_event_mask() != 0
}
pub(super) fn send_events(&self, events: Vec<LuaEvent>) {
let _ = self.to_lua.try_send(ToLua::Events(events));
}
pub(super) fn send_binds(&self, ids: Vec<u32>) {
let _ = self.to_lua.try_send(ToLua::Binds { ids });
}
pub(super) fn send_reload(&self, path: PathBuf) {
let _ = self.to_lua.try_send(ToLua::Reload(path));
}
pub(super) fn drain_outbox(&self) -> impl Iterator<Item = FromLua> + '_ {
std::iter::from_fn(|| self.outbox.try_recv().ok())
}
pub(super) fn pending_world_queries(&self) -> impl Iterator<Item = WorldRequest> + '_ {
std::iter::from_fn(|| self.world_queries.try_recv().ok())
}
pub(super) fn pending_store_queries(&self) -> impl Iterator<Item = StoreRequest> + '_ {
std::iter::from_fn(|| self.store_queries.try_recv().ok())
}
}
impl Drop for LuaWorker {
fn drop(&mut self) {
let _ = self.to_lua.try_send(ToLua::Shutdown);
let Some(thread) = self.thread.take() else {
return;
};
let mut waited = Duration::ZERO;
while !thread.is_finished() && waited < SHUTDOWN_GRACE {
std::thread::sleep(SHUTDOWN_POLL);
waited += SHUTDOWN_POLL;
}
if thread.is_finished() {
let _ = thread.join();
} else {
warn!("Lua worker did not stop in time; detaching it");
}
}
}
fn load(source: &LuaSource, world: &Rc<DispatchWorld>) -> LuaRuntime {
let runtime = match source {
LuaSource::Path(path) => match LuaRuntime::from_file(path, world) {
Ok(runtime) => {
info!("Loaded Lua script {}", path.display());
runtime
}
Err(err) => {
warn!("Loading Lua script '{}': {err}", path.display());
LuaRuntime::empty(world)
}
},
#[cfg(test)]
LuaSource::Inline(source) => LuaRuntime::from_source(source, world).unwrap_or_else(|err| {
warn!("Loading inline Lua source: {err}");
LuaRuntime::empty(world)
}),
};
set_lua_keybinds(runtime.published_keybinds());
runtime
}
fn reload(
path: &Path,
world: &Rc<DispatchWorld>,
to_main: &Sender<FromLua>,
built_config: &Mutex<Option<Config>>,
) -> Option<LuaRuntime> {
match LuaRuntime::from_file(path, world) {
Ok(runtime) => {
set_lua_keybinds(runtime.published_keybinds());
if let Some(config) = runtime.built_config() {
publish_config(built_config, config);
let _ = to_main.try_send(FromLua::ConfigChanged);
}
info!("Reloaded Lua script {}", path.display());
flash(to_main, "Lua reloaded".to_string(), 1.5);
Some(runtime)
}
Err(err) => {
error!("Reloading Lua script '{}': {err}", path.display());
flash(to_main, format!("Lua error: {err}"), 4.0);
None
}
}
}
fn publish_config(slot: &Mutex<Option<Config>>, config: &Config) {
*slot
.lock()
.expect("the main thread never panics while holding this") = Some(config.clone());
}
fn flash(to_main: &Sender<FromLua>, message: String, duration: f32) {
let _ = to_main.try_send(FromLua::Flash { message, duration });
}
#[allow(clippy::too_many_arguments)]
fn run(
source: &LuaSource,
from_main: &Receiver<ToLua>,
to_main: &Sender<FromLua>,
world_queries: &Sender<WorldRequest>,
store_queries: &Sender<StoreRequest>,
subscribed_events: &AtomicU64,
built_config: &Mutex<Option<Config>>,
revision: &ScriptStateRevision,
ready: &Sender<()>,
) {
let world = DispatchWorld::new(WorldAccess::new(
world_queries.clone(),
store_queries.clone(),
Arc::clone(revision),
));
let loaded = load(source, &world);
subscribed_events.store(loaded.subscribed_event_mask(), Ordering::Relaxed);
if let Some(config) = loaded.built_config() {
publish_config(built_config, config);
}
let _ = ready.try_send(());
let current = RefCell::new(Rc::new(loaded));
let executor = LocalExecutor::new();
block_on(executor.run(async {
while let Ok(message) = from_main.recv().await {
match message {
ToLua::Events(events) => {
let runtime = Rc::clone(¤t.borrow());
for event in &events {
let name = event.name();
let handlers = runtime.event_handlers(name);
if handlers.is_empty() {
continue;
}
let Some((_, table)) = convert::event_table(runtime.lua(), event) else {
continue;
};
for entry in handlers {
if let Some(ref filter) = entry.filter {
match filter.call::<bool>(&table) {
Ok(true) => {}
Ok(false) => continue,
Err(err) => {
error!("lua event filter for '{name}': {err}");
continue;
}
}
}
let task = Task {
runtime: Rc::clone(&runtime),
to_main: to_main.clone(),
subscribed_events,
};
let (name, table, handler) =
(name.to_owned(), table.clone(), entry.handler.clone());
executor
.spawn(async move {
task.runtime.dispatch_event(&name, &table, &handler).await;
task.finish();
})
.detach();
}
}
}
ToLua::Binds { ids } => {
let runtime = Rc::clone(¤t.borrow());
for id in ids {
let task = Task {
runtime: Rc::clone(&runtime),
to_main: to_main.clone(),
subscribed_events,
};
executor
.spawn(async move {
task.runtime.dispatch_bind(id).await;
task.finish();
})
.detach();
}
}
ToLua::Reload(path) => {
if let Some(rebuilt) = reload(&path, &world, to_main, built_config) {
*current.borrow_mut() = Rc::new(rebuilt);
}
let current_ref = current.borrow();
subscribed_events.store(current_ref.subscribed_event_mask(), Ordering::Relaxed);
}
ToLua::Shutdown => break,
}
}
}));
}
struct Task<'a> {
runtime: Rc<LuaRuntime>,
to_main: Sender<FromLua>,
subscribed_events: &'a AtomicU64,
}
impl Task<'_> {
fn finish(&self) {
let (commands, flashes) = self.runtime.drain_outbox();
for command in commands {
let _ = self.to_main.try_send(FromLua::Command(command));
}
for (message, duration) in flashes {
flash(&self.to_main, message, duration);
}
self.subscribed_events
.store(self.runtime.subscribed_event_mask(), Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecs::state::{PaneruActiveState, PaneruVirtualWorkspaceState, PaneruWindowState};
use crate::lua::convert::WindowSpawnPayload;
use crate::types::windowset::{LayoutOp, WinID};
const TIMEOUT: Duration = Duration::from_secs(5);
thread_local! {
static STORE: std::cell::RefCell<TestStore> =
std::cell::RefCell::new(TestStore::new(revision()));
}
fn worker(source: &str) -> LuaWorker {
spawn_with_store(LuaSource::Inline(source.to_string()))
}
fn spawn_with_store(source: LuaSource) -> LuaWorker {
let revision = revision();
STORE.with_borrow_mut(|store| *store = TestStore::new(Arc::clone(&revision)));
LuaWorker::spawn(source, revision)
}
fn revision() -> ScriptStateRevision {
Arc::new(AtomicU64::new(0))
}
fn serve_store(worker: &LuaWorker) {
while let Ok(request) = worker.store_queries.try_recv() {
STORE.with_borrow_mut(|store| store.answer(request));
}
}
fn next_world_request(worker: &LuaWorker, what: &str) -> WorldRequest {
let deadline = std::time::Instant::now() + TIMEOUT;
loop {
serve_store(worker);
if let Ok(request) = worker.world_queries.try_recv() {
return request;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for {what}"
);
std::thread::sleep(Duration::from_millis(1));
}
}
struct TestStore {
state: ScriptState,
revision: ScriptStateRevision,
}
impl TestStore {
fn new(revision: ScriptStateRevision) -> Self {
Self {
state: ScriptState::default(),
revision,
}
}
fn answer(&mut self, request: StoreRequest) {
match request {
StoreRequest::Read { reply } => {
let _ = reply.try_send(Ok(self.state.clone()));
}
StoreRequest::Write { write, reply } => {
let outcome = self.state.apply(&write);
if matches!(outcome, Ok(WriteOutcome::Applied { changed: true })) {
self.revision.fetch_add(1, Ordering::Release);
}
let _ = reply.try_send(outcome);
}
}
}
}
fn serve_until_effect(worker: &LuaWorker, what: &str) -> FromLua {
let deadline = std::time::Instant::now() + TIMEOUT;
loop {
serve_store(worker);
if let Ok(request) = worker.world_queries.try_recv() {
match request {
WorldRequest::WindowSet { reply } => {
let _ = reply.try_send(Ok(Arc::new(test_window_set())));
}
WorldRequest::State { .. } => {
panic!("this test only expects store and window-set requests")
}
}
continue;
}
if let Ok(effect) = worker.outbox.try_recv() {
return effect;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for {what}"
);
std::thread::sleep(Duration::from_millis(2));
}
}
fn test_state() -> PaneruQueryState {
PaneruQueryState {
version: 1,
timestamp: 0,
active: PaneruActiveState {
focused_window_id: Some(7),
focused_app_name: Some("Test App".to_string()),
..PaneruActiveState::default()
},
virtual_workspaces: vec![PaneruVirtualWorkspaceState {
number: 1,
native_workspace_id: 10,
active: true,
windows: vec![PaneruWindowState {
window_id: 7,
bundle_id: "com.example.app".to_string(),
app_name: "Test App".to_string(),
title: "window".to_string(),
focused: true,
floating: false,
display_id: Some(1),
frame: None,
visible: true,
}],
}],
}
}
fn next_effect(worker: &LuaWorker, what: &str) -> FromLua {
serve_until_effect(worker, what)
}
fn next_flash(worker: &LuaWorker, what: &str) -> String {
match next_effect(worker, what) {
FromLua::Flash { message, .. } => message,
FromLua::Command(command) => panic!("expected a flash, got {command:?}"),
FromLua::ConfigChanged => panic!("expected a flash, got a config change"),
}
}
#[test]
fn bind_dispatch_reaches_the_outbox() {
let worker = worker(r#"paneru.bind("alt - b", "window balance")"#);
worker.send_binds(vec![1]);
let FromLua::Command(command) = next_effect(&worker, "the bound command") else {
panic!("expected a command");
};
assert!(
matches!(
command,
Command::Window(crate::commands::Operation::Balance)
),
"expected a balance command, got {command:?}"
);
}
#[test]
fn event_dispatch_reaches_the_outbox() {
let worker = worker(r#"paneru.on("space_changed", function(e) paneru.flash(e.type) end)"#);
assert!(worker.has_event_handlers());
worker.send_events(vec![LuaEvent::SpaceChanged]);
assert_eq!(next_flash(&worker, "the event flash"), "space_changed");
}
#[test]
fn query_round_trip_is_served_by_the_host() {
let worker = worker(
r#"
paneru.bind("alt - q", function()
paneru.flash(paneru.query_active().focused_app_name)
end)
"#,
);
worker.send_binds(vec![1]);
serve_window_set(&worker);
let request = next_world_request(&worker, "the world");
request.answer(Ok(Arc::new(test_state())));
assert_eq!(next_flash(&worker, "the queried app name"), "Test App");
}
#[test]
fn two_queries_in_one_dispatch_cost_one_round_trip() {
let worker = worker(
r#"
paneru.bind("alt - q", function()
paneru.query_active()
paneru.query_on_screen()
paneru.flash("done")
end)
"#,
);
worker.send_binds(vec![1]);
serve_window_set(&worker);
next_world_request(&worker, "the first query").answer(Ok(Arc::new(test_state())));
assert_eq!(next_flash(&worker, "the handler to finish"), "done");
assert!(
worker.world_queries.try_recv().is_err(),
"the second query should have been served from the cached extraction"
);
}
#[test]
fn a_dropped_reply_channel_errors_the_handler_not_the_worker() {
let worker = worker(
r#"
paneru.bind("alt - q", function() paneru.query_active() end)
paneru.bind("alt - b", "window balance")
"#,
);
worker.send_binds(vec![1]);
drop(next_world_request(&worker, "the world"));
worker.send_binds(vec![2]);
let FromLua::Command(command) = next_effect(&worker, "the next bind") else {
panic!("expected a command");
};
assert!(matches!(
command,
Command::Window(crate::commands::Operation::Balance)
));
}
#[test]
fn reload_failure_keeps_the_old_runtime() {
let directory = std::env::temp_dir().join("paneru-lua-worker-reload-failure");
std::fs::create_dir_all(&directory).unwrap();
let script = directory.join("init.lua");
std::fs::write(&script, r#"paneru.bind("alt - b", "window balance")"#).unwrap();
let worker = LuaWorker::spawn(LuaSource::Path(script.clone()), revision());
std::fs::write(&script, "this is not lua ===").unwrap();
worker.send_reload(script.clone());
assert!(
next_flash(&worker, "the reload error").starts_with("Lua error:"),
"a broken script should be reported"
);
worker.send_binds(vec![1]);
assert!(matches!(
next_effect(&worker, "the surviving bind"),
FromLua::Command(Command::Window(crate::commands::Operation::Balance))
));
std::fs::remove_dir_all(&directory).ok();
}
#[test]
fn reload_republishes_handlers() {
let directory = std::env::temp_dir().join("paneru-lua-worker-reload-success");
std::fs::create_dir_all(&directory).unwrap();
let script = directory.join("init.lua");
std::fs::write(&script, r#"paneru.bind("alt - b", "window balance")"#).unwrap();
let worker = LuaWorker::spawn(LuaSource::Path(script.clone()), revision());
assert!(!worker.has_event_handlers(), "no paneru.on handlers yet");
std::fs::write(
&script,
r#"paneru.on("space_changed", function(e) paneru.flash("reloaded") end)"#,
)
.unwrap();
worker.send_reload(script.clone());
assert_eq!(next_flash(&worker, "the reload notice"), "Lua reloaded");
assert!(
worker.has_event_handlers(),
"the reloaded script's handler should be visible to the fast path"
);
worker.send_events(vec![LuaEvent::SpaceChanged]);
assert_eq!(next_flash(&worker, "the new handler"), "reloaded");
std::fs::remove_dir_all(&directory).ok();
}
fn test_window_set() -> WindowSet {
test_window_set_on(1)
}
fn layout(windows: &[(WinID, &str, u32)]) -> WindowSet {
use crate::types::state::Frame;
layout_on(
1,
Frame {
x: 0,
y: 0,
width: 1920,
height: 1080,
},
windows,
)
}
fn layout_on(
display_id: u32,
display_frame: crate::types::state::Frame,
windows: &[(WinID, &str, u32)],
) -> WindowSet {
use crate::types::windowset::{ColumnSet, DisplaySet, WindowRec, WorkspaceSet};
let workspaces = [1, 9]
.map(|number| WorkspaceSet {
number,
native_id: 10,
active: number == 1,
columns: Arc::new(
windows
.iter()
.filter(|(_, _, on)| *on == number)
.map(|(id, app, _)| {
ColumnSet::single(
WindowRec {
id: *id,
app_name: (*app).to_string(),
bundle_id: format!("com.example.{app}"),
title: format!("{app} window"),
frame: None,
floating: false,
managed: true,
visible: number == 1,
focused: false,
},
1.0,
)
})
.collect(),
),
floating: Arc::new(Vec::new()),
})
.to_vec();
WindowSet::new(
vec![DisplaySet {
id: display_id,
frame: display_frame,
active: true,
workspaces: Arc::new(workspaces),
}],
None,
)
}
fn serve(worker: &LuaWorker, set: WindowSet) {
match next_world_request(worker, "the window set") {
WorldRequest::WindowSet { reply } => {
let _ = reply.try_send(Ok(Arc::new(set)));
}
WorldRequest::State { .. } => panic!("expected a window-set request"),
}
}
fn next_ops(worker: &LuaWorker, what: &str) -> Vec<LayoutOp> {
match next_effect(worker, what) {
FromLua::Command(Command::Layout(ops)) => ops,
other => match other {
FromLua::Flash { message, .. } => panic!("expected ops, got flash {message:?}"),
FromLua::Command(command) => panic!("expected ops, got {command:?}"),
FromLua::ConfigChanged => panic!("expected ops, got a config change"),
},
}
}
fn test_window_set_on(holding: u32) -> WindowSet {
use crate::types::state::Frame;
use crate::types::windowset::{ColumnSet, DisplaySet, WindowRec, WorkspaceSet};
let window = WindowRec {
id: 7,
app_name: "Test App".to_string(),
bundle_id: "com.example.app".to_string(),
title: "window".to_string(),
frame: None,
floating: false,
managed: true,
visible: true,
focused: true,
};
WindowSet::new(
vec![DisplaySet {
id: 1,
frame: Frame {
x: 0,
y: 0,
width: 1920,
height: 1080,
},
active: true,
workspaces: Arc::new(
[1, 9]
.map(|number| WorkspaceSet {
number,
native_id: 10,
active: number == 1,
columns: Arc::new(if number == holding {
vec![ColumnSet::single(window.clone(), 1.0)]
} else {
Vec::new()
}),
floating: Arc::new(Vec::new()),
})
.to_vec(),
),
}],
Some(7),
)
}
fn serve_window_set(worker: &LuaWorker) {
match next_world_request(worker, "the window set") {
WorldRequest::WindowSet { reply } => {
let _ = reply.try_send(Ok(Arc::new(test_window_set())));
}
WorldRequest::State { .. } => panic!("expected a window-set request"),
}
}
#[test]
fn script_state_survives_a_reload() {
let directory = std::env::temp_dir().join("paneru-lua-worker-state-reload");
std::fs::create_dir_all(&directory).unwrap();
let script = directory.join("init.lua");
std::fs::write(
&script,
r#"paneru.bind("alt - a", function() paneru.state.set("counter", 41) end)"#,
)
.unwrap();
let worker = spawn_with_store(LuaSource::Path(script.clone()));
worker.send_binds(vec![1]);
std::fs::write(
&script,
r#"paneru.bind("alt - b", function()
paneru.flash("counter=" .. tostring(paneru.state.get("counter")))
end)"#,
)
.unwrap();
worker.send_reload(script.clone());
let FromLua::Flash { message, .. } = serve_until_effect(&worker, "the reload notice")
else {
panic!("expected the reload notice");
};
assert_eq!(message, "Lua reloaded");
worker.send_binds(vec![1]);
let FromLua::Flash { message, .. } = serve_until_effect(&worker, "the value read back")
else {
panic!("expected the flash the reloaded script sends");
};
assert_eq!(
message, "counter=41",
"a value written before the reload should still be there after it"
);
std::fs::remove_dir_all(&directory).ok();
}
#[test]
fn a_returned_window_set_commits_its_operations() {
let worker =
worker(r#"paneru.bind("alt - f", function(ws) return ws:focus(ws:focused()) end)"#);
worker.send_binds(vec![1]);
serve_window_set(&worker);
let FromLua::Command(command) = next_effect(&worker, "the layout command") else {
panic!("expected a command");
};
let Command::Layout(ops) = command else {
panic!("expected a layout command, got {command:?}");
};
assert_eq!(ops, vec![LayoutOp::Focus(7)]);
}
#[test]
fn a_window_set_computed_but_not_returned_commits_nothing() {
let worker = worker(
r#"
paneru.bind("alt - f", function(ws)
local unused = ws:focus(ws:focused()):view(2)
paneru.flash("discarded")
end)
"#,
);
worker.send_binds(vec![1]);
serve_window_set(&worker);
assert_eq!(next_flash(&worker, "the handler to finish"), "discarded");
assert!(
worker.outbox.try_recv().is_err(),
"an unreturned window set should commit nothing"
);
}
#[test]
fn a_handler_that_raises_after_transforming_commits_nothing() {
let worker = worker(
r#"
paneru.bind("alt - f", function(ws)
local pending = ws:focus(ws:focused())
error("nope")
end)
paneru.bind("alt - b", "window balance")
"#,
);
worker.send_binds(vec![1]);
serve_window_set(&worker);
worker.send_binds(vec![2]);
assert!(matches!(
next_effect(&worker, "the next bind"),
FromLua::Command(Command::Window(crate::commands::Operation::Balance))
));
}
#[test]
fn chained_transforms_commit_in_order() {
let worker = worker(
r#"
paneru.bind("alt - x", function(ws)
return ws:focus(7):width(7, 0.75):shift(7, 2)
end)
"#,
);
worker.send_binds(vec![1]);
serve_window_set(&worker);
let FromLua::Command(Command::Layout(ops)) = next_effect(&worker, "the layout command")
else {
panic!("expected a layout command");
};
assert_eq!(
ops,
vec![
LayoutOp::Focus(7),
LayoutOp::SetWidth {
window: 7,
ratio: 0.75
},
LayoutOp::MoveToWorkspace {
window: 7,
workspace: 2,
follow: false
},
]
);
}
#[test]
fn a_handler_that_ignores_the_window_set_never_fetches_one() {
let worker = worker(r#"paneru.bind("alt - b", "window balance")"#);
worker.send_binds(vec![1]);
assert!(matches!(
next_effect(&worker, "the bound command"),
FromLua::Command(Command::Window(crate::commands::Operation::Balance))
));
assert!(
worker.world_queries.try_recv().is_err(),
"a handler that never touches the window set should not ask for one"
);
}
#[test]
fn two_handlers_in_one_batch_share_one_window_set() {
let worker = worker(
r#"
paneru.bind("alt - a", function(ws) paneru.flash(tostring(ws:focused())) end)
paneru.bind("alt - b", function(ws) paneru.flash(tostring(ws:focused())) end)
"#,
);
worker.send_binds(vec![1, 2]);
serve_window_set(&worker);
assert_eq!(next_flash(&worker, "the first handler"), "7");
assert_eq!(next_flash(&worker, "the second handler"), "7");
assert!(
worker.world_queries.try_recv().is_err(),
"the second handler should have reused the first fetch"
);
}
#[test]
fn a_handler_waiting_on_the_world_does_not_hold_up_the_next_one() {
let worker = worker(
r#"
paneru.bind("alt - a", function()
paneru.flash(paneru.query_active().focused_app_name)
end)
paneru.bind("alt - b", function() paneru.flash("second") end)
"#,
);
worker.send_binds(vec![1, 2]);
serve_window_set(&worker);
let parked = next_world_request(&worker, "the state query");
assert_eq!(
next_flash(&worker, "the second handler"),
"second",
"the second handler should not be waiting on the first"
);
parked.answer(Ok(Arc::new(test_state())));
assert_eq!(next_flash(&worker, "the first handler"), "Test App");
}
#[test]
fn event_handlers_receive_the_event_then_the_window_set() {
let worker = worker(
r#"
paneru.on("space_changed", function(event, ws)
paneru.flash(event.type .. ":" .. tostring(ws:focused()))
end)
"#,
);
worker.send_events(vec![LuaEvent::SpaceChanged]);
serve_window_set(&worker);
assert_eq!(next_flash(&worker, "the event handler"), "space_changed:7");
}
#[test]
fn a_captured_window_set_stays_the_snapshot_it_was() {
let worker = worker(
r#"
escaped = nil
paneru.bind("alt - a", function(ws)
escaped = ws
paneru.flash(tostring(ws:focused()))
end)
paneru.bind("alt - b", function() paneru.flash(tostring(escaped:focused())) end)
"#,
);
worker.send_binds(vec![1]);
serve_window_set(&worker);
assert_eq!(next_flash(&worker, "the first handler"), "7");
worker.send_binds(vec![2]);
assert_eq!(next_flash(&worker, "the captured set"), "7");
assert!(
worker.world_queries.try_recv().is_err(),
"reading a captured set should not go back to the world"
);
}
const SCRATCHPAD: &str = r#"
scratchpad = { stash = 9, pads = {}, order = {} }
function scratchpad.define(name, spec)
scratchpad.pads[name] = spec
table.insert(scratchpad.order, name)
end
-- The pad a window belongs to, if any. Declaration order decides ties.
function scratchpad.pad_of(window)
for _, name in ipairs(scratchpad.order) do
if scratchpad.pads[name].match(window) then
return name, scratchpad.pads[name]
end
end
end
-- Park every pad in `names` that is currently on screen.
function scratchpad.hide(ws, names)
for _, name in ipairs(names) do
local window = ws:find(scratchpad.pads[name].match)
if window and ws:workspace_of(window.id) == ws:current() then
ws = ws:shift(window.id, scratchpad.stash)
end
end
return ws
end
function scratchpad.hide_all(ws)
return scratchpad.hide(ws, scratchpad.order)
end
-- Everything declared in the same group as `name`, except itself.
function scratchpad.group_of(name)
local group, mine = {}, scratchpad.pads[name].group
if not mine then return group end
for _, other in ipairs(scratchpad.order) do
if other ~= name and scratchpad.pads[other].group == mine then
table.insert(group, other)
end
end
return group
end
function scratchpad.toggle(name)
return function(ws)
local pad = scratchpad.pads[name]
local window = ws:find(pad.match)
if not window then
os.execute(pad.spawn .. " &")
return
end
if ws:workspace_of(window.id) == ws:current() then
return ws:shift(window.id, scratchpad.stash)
end
ws = scratchpad.hide(ws, scratchpad.group_of(name))
return ws:shift(window.id, ws:current(), true):focus(window.id)
end
end
-- The manage hook: place a pad window the first time we see it. What
-- has been seen goes in the store, not a global, so a reload does not
-- re-run the hook on every open window.
paneru.on("window_focused", function(event, ws)
local first_time = false
paneru.state.mutate("scratchpad.seen", function(seen)
seen = seen or {}
first_time = not seen[tostring(event.window_id)]
seen[tostring(event.window_id)] = true
return seen
end)
if not first_time then return end
local window = ws:window(event.window_id)
if not window then return end
local _, pad = scratchpad.pad_of(window)
if pad and pad.float and window.managed then
return ws:float(window.id, pad.float)
end
end)
-- Hide a pad when the focus leaves it.
paneru.on("window_focused", function(event, ws)
local previous = paneru.state.get("scratchpad.focused")
paneru.state.set("scratchpad.focused", event.window_id)
if not previous or previous == event.window_id then return end
local window = ws:window(previous)
if window and scratchpad.pad_of(window) then
return ws:shift(previous, scratchpad.stash)
end
end)
scratchpad.define("terminal", {
match = paneru.match{ app = "Alacritty" },
spawn = "true", group = "console",
float = { x = 0.1, y = 0.05, width = 0.8, height = 0.5 },
})
scratchpad.define("notes", {
match = paneru.match{ app = "Obsidian" },
spawn = "true", group = "console",
})
paneru.bind("alt - s", scratchpad.toggle("terminal"))
paneru.bind("alt - n", scratchpad.toggle("notes"))
paneru.bind("alt - 0", scratchpad.hide_all)
-- A sentinel for the tests: touches nothing, so anything queued ahead
-- of its flash is something a handler actually asked for.
paneru.bind("alt - z", function() paneru.flash("sentinel") end)
"#;
fn assert_nothing_queued(worker: &LuaWorker) {
worker.send_binds(vec![4]);
assert_eq!(
next_flash(worker, "the sentinel"),
"sentinel",
"a handler queued something it should not have"
);
}
#[test]
fn a_scratchpad_on_screen_is_parked() {
let worker = worker(SCRATCHPAD);
worker.send_binds(vec![1]);
serve(&worker, layout(&[(7, "Alacritty", 1)]));
assert_eq!(
next_ops(&worker, "the stash"),
vec![LayoutOp::MoveToWorkspace {
window: 7,
workspace: 9,
follow: false
}]
);
}
#[test]
fn a_stashed_scratchpad_is_summoned_and_focused() {
let worker = worker(SCRATCHPAD);
worker.send_binds(vec![1]);
serve(&worker, layout(&[(7, "Alacritty", 9)]));
assert_eq!(
next_ops(&worker, "the summons"),
vec![
LayoutOp::MoveToWorkspace {
window: 7,
workspace: 1,
follow: true
},
LayoutOp::Focus(7),
]
);
}
#[test]
fn a_scratchpad_that_is_not_running_is_spawned_and_nothing_moves() {
let worker = worker(SCRATCHPAD);
worker.send_binds(vec![1]);
serve(&worker, layout(&[(3, "Something Else", 1)]));
assert_nothing_queued(&worker);
}
#[test]
fn summoning_a_scratchpad_hides_its_exclusive_group() {
let worker = worker(SCRATCHPAD);
worker.send_binds(vec![1]);
serve(&worker, layout(&[(7, "Alacritty", 9), (8, "Obsidian", 1)]));
assert_eq!(
next_ops(&worker, "the exclusive swap"),
vec![
LayoutOp::MoveToWorkspace {
window: 8,
workspace: 9,
follow: false
},
LayoutOp::MoveToWorkspace {
window: 7,
workspace: 1,
follow: true
},
LayoutOp::Focus(7),
]
);
}
#[test]
fn hide_all_parks_every_visible_scratchpad() {
let worker = worker(SCRATCHPAD);
worker.send_binds(vec![3]);
serve(
&worker,
layout(&[(7, "Alacritty", 1), (8, "Obsidian", 1), (9, "Mail", 1)]),
);
assert_eq!(
next_ops(&worker, "the sweep"),
vec![
LayoutOp::MoveToWorkspace {
window: 7,
workspace: 9,
follow: false
},
LayoutOp::MoveToWorkspace {
window: 8,
workspace: 9,
follow: false
},
]
);
}
#[test]
fn the_manage_hook_floats_a_pad_window_once() {
let worker = worker(SCRATCHPAD);
worker.send_events(vec![LuaEvent::WindowFocused { window_id: 7 }]);
serve(&worker, layout(&[(7, "Alacritty", 1)]));
assert_eq!(
next_ops(&worker, "the float"),
vec![
LayoutOp::SetFloating {
window: 7,
floating: true
},
LayoutOp::SetFrame {
window: 7,
frame: crate::types::state::Frame {
x: 192,
y: 54,
width: 1536,
height: 540
}
},
]
);
worker.send_events(vec![LuaEvent::WindowFocused { window_id: 7 }]);
assert_nothing_queued(&worker);
}
#[test]
fn a_pad_with_no_rect_is_not_placed_at_all() {
let worker = worker(SCRATCHPAD);
worker.send_events(vec![LuaEvent::WindowFocused { window_id: 8 }]);
serve(&worker, layout(&[(8, "Obsidian", 1)]));
assert_nothing_queued(&worker);
}
#[test]
fn a_pad_is_placed_on_the_display_it_is_on() {
let worker = worker(SCRATCHPAD);
worker.send_events(vec![LuaEvent::WindowFocused { window_id: 7 }]);
serve(
&worker,
layout_on(
2,
crate::types::state::Frame {
x: 1920,
y: -200,
width: 1280,
height: 800,
},
&[(7, "Alacritty", 1)],
),
);
assert_eq!(
next_ops(&worker, "the float"),
vec![
LayoutOp::SetFloating {
window: 7,
floating: true
},
LayoutOp::SetFrame {
window: 7,
frame: crate::types::state::Frame {
x: 2048,
y: -160,
width: 1024,
height: 400
}
},
]
);
}
#[test]
fn a_placed_pad_still_stashes_and_summons() {
let worker = worker(SCRATCHPAD);
worker.send_events(vec![LuaEvent::WindowFocused { window_id: 7 }]);
serve(&worker, layout(&[(7, "Alacritty", 1)]));
assert_eq!(next_ops(&worker, "the float").len(), 2, "float then place");
worker.send_binds(vec![1]);
serve(&worker, layout(&[(7, "Alacritty", 1)]));
assert_eq!(
next_ops(&worker, "the stash"),
vec![LayoutOp::MoveToWorkspace {
window: 7,
workspace: 9,
follow: false
}]
);
worker.send_binds(vec![1]);
serve(&worker, layout(&[(7, "Alacritty", 9)]));
assert_eq!(
next_ops(&worker, "the summons"),
vec![
LayoutOp::MoveToWorkspace {
window: 7,
workspace: 1,
follow: true
},
LayoutOp::Focus(7),
]
);
}
#[test]
fn losing_focus_parks_a_scratchpad() {
let worker = worker(SCRATCHPAD);
worker.send_events(vec![LuaEvent::WindowFocused { window_id: 7 }]);
serve(&worker, layout(&[(7, "Alacritty", 1), (8, "Mail", 1)]));
assert_eq!(
next_ops(&worker, "the float").first(),
Some(&LayoutOp::SetFloating {
window: 7,
floating: true
})
);
worker.send_events(vec![LuaEvent::WindowFocused { window_id: 8 }]);
serve(&worker, layout(&[(7, "Alacritty", 1), (8, "Mail", 1)]));
assert_eq!(
next_ops(&worker, "the park"),
vec![LayoutOp::MoveToWorkspace {
window: 7,
workspace: 9,
follow: false
}]
);
}
#[test]
fn dropping_the_handle_stops_the_thread() {
let mut worker = worker("");
let thread = worker.thread.take().expect("just spawned");
drop(worker);
let mut waited = Duration::ZERO;
while !thread.is_finished() && waited < TIMEOUT {
std::thread::sleep(SHUTDOWN_POLL);
waited += SHUTDOWN_POLL;
}
assert!(
thread.is_finished(),
"the worker should stop with its handle"
);
}
#[test]
fn window_spawned_event_is_dispatched_to_lua() {
let worker = worker(
r#"
paneru.on("window_spawned", function(event, ws)
paneru.flash(event.type .. ":" .. tostring(event.window_id) .. ":" .. event.title .. ":" .. event.app_name)
end)
"#,
);
let event = LuaEvent::WindowSpawned(WindowSpawnPayload {
window_id: 42,
pid: 100,
app_name: "Ghostty".into(),
bundle_id: "com.mitchellh.ghostty".into(),
title: "Terminal".into(),
frame: crate::types::state::Frame {
x: 0,
y: 0,
width: 800,
height: 600,
},
floating: false,
managed: true,
});
worker.send_events(vec![event]);
assert_eq!(
next_flash(&worker, "window_spawned"),
"window_spawned:42:Terminal:Ghostty"
);
}
#[test]
fn filtered_window_spawned_event_only_fires_on_match() {
let worker = worker(
r#"
paneru.on("window_spawned", { bundle = "libreoffice" }, function(event, ws)
paneru.flash("matched:" .. event.app_name)
end)
"#,
);
let ghostty_event = LuaEvent::WindowSpawned(WindowSpawnPayload {
window_id: 1,
pid: 100,
app_name: "Ghostty".into(),
bundle_id: "com.mitchellh.ghostty".into(),
title: "Terminal".into(),
frame: crate::types::state::Frame {
x: 0,
y: 0,
width: 800,
height: 600,
},
floating: false,
managed: true,
});
let libreoffice_event = LuaEvent::WindowSpawned(WindowSpawnPayload {
window_id: 2,
pid: 200,
app_name: "LibreOffice".into(),
bundle_id: "org.libreoffice.script".into(),
title: "Document".into(),
frame: crate::types::state::Frame {
x: 0,
y: 0,
width: 300,
height: 300,
},
floating: false,
managed: true,
});
worker.send_events(vec![ghostty_event]);
assert!(worker.outbox.try_recv().is_err());
worker.send_events(vec![libreoffice_event]);
assert_eq!(
next_flash(&worker, "libreoffice match"),
"matched:LibreOffice"
);
}
}