use crate::layouts::Layout;
use crate::models::TagId;
use crate::{command, Command, ReleaseScratchPadOption};
use std::error::Error;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{env, fmt};
use tokio::fs;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
#[derive(Debug)]
pub struct CommandPipe {
pipe_file: PathBuf,
rx: mpsc::UnboundedReceiver<Command>,
}
impl Drop for CommandPipe {
fn drop(&mut self) {
use std::os::unix::fs::OpenOptionsExt;
self.rx.close();
if let Err(err) = std::fs::OpenOptions::new()
.write(true)
.custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits())
.open(&self.pipe_file)
{
eprintln!(
"Failed to open {} when dropping CommandPipe: {err}",
self.pipe_file.display()
);
}
}
}
impl CommandPipe {
pub async fn new(pipe_file: PathBuf) -> Result<Self, std::io::Error> {
fs::remove_file(pipe_file.as_path()).await.ok();
if let Err(e) = nix::unistd::mkfifo(&pipe_file, nix::sys::stat::Mode::S_IRWXU) {
tracing::error!("Failed to create new fifo {:?}", e);
}
let path = pipe_file.clone();
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
while !tx.is_closed() {
read_from_pipe(&path, &tx).await;
}
fs::remove_file(path).await.ok();
});
Ok(Self { pipe_file, rx })
}
pub fn pipe_name() -> PathBuf {
let display = env::var("DISPLAY")
.ok()
.and_then(|d| d.rsplit_once(':').map(|(_, r)| r.to_owned()))
.unwrap_or_else(|| "0".to_string());
PathBuf::from(format!("command-{display}.pipe"))
}
pub async fn read_command(&mut self) -> Option<Command> {
self.rx.recv().await
}
}
async fn read_from_pipe(pipe_file: &Path, tx: &mpsc::UnboundedSender<Command>) -> Option<()> {
let file = fs::File::open(pipe_file).await.ok()?;
let mut lines = BufReader::new(file).lines();
while let Some(line) = lines.next_line().await.ok()? {
let cmd = match parse_command(&line) {
Ok(cmd) => cmd,
Err(err) => {
tracing::error!("An error occurred while parsing the command: {}", err);
return None;
}
};
tx.send(cmd).ok()?;
}
Some(())
}
fn parse_command(s: &str) -> Result<Command, Box<dyn std::error::Error>> {
let (head, rest) = s.split_once(' ').unwrap_or((s, ""));
match head {
"MoveWindowDown" => Ok(Command::MoveWindowDown),
"MoveWindowTop" => build_move_window_top(rest),
"SwapWindowTop" => build_swap_window_top(rest),
"MoveWindowUp" => Ok(Command::MoveWindowUp),
"MoveWindowToNextTag" => build_move_window_to_next_tag(rest),
"MoveWindowToPreviousTag" => build_move_window_to_previous_tag(rest),
"MoveWindowToLastWorkspace" => Ok(Command::MoveWindowToLastWorkspace),
"MoveWindowToNextWorkspace" => Ok(Command::MoveWindowToNextWorkspace),
"MoveWindowToPreviousWorkspace" => Ok(Command::MoveWindowToPreviousWorkspace),
"SendWindowToTag" => build_send_window_to_tag(rest),
"FocusWindowDown" => Ok(Command::FocusWindowDown),
"FocusWindowTop" => build_focus_window_top(rest),
"FocusWindowUp" => Ok(Command::FocusWindowUp),
"FocusNextTag" => build_focus_next_tag(rest),
"FocusPreviousTag" => build_focus_previous_tag(rest),
"FocusWorkspaceNext" => Ok(Command::FocusWorkspaceNext),
"FocusWorkspacePrevious" => Ok(Command::FocusWorkspacePrevious),
"DecreaseMainWidth" => build_decrease_main_width(rest),
"IncreaseMainWidth" => build_increase_main_width(rest),
"NextLayout" => Ok(Command::NextLayout),
"PreviousLayout" => Ok(Command::PreviousLayout),
"RotateTag" => Ok(Command::RotateTag),
"SetLayout" => build_set_layout(rest),
"SetMarginMultiplier" => build_set_margin_multiplier(rest),
"ToggleScratchPad" => build_toggle_scratchpad(rest),
"AttachScratchPad" => build_attach_scratchpad(rest),
"ReleaseScratchPad" => Ok(build_release_scratchpad(rest)),
"NextScratchPadWindow" => Ok(Command::NextScratchPadWindow {
scratchpad: rest.to_owned().into(),
}),
"PrevScratchPadWindow" => Ok(Command::PrevScratchPadWindow {
scratchpad: rest.to_owned().into(),
}),
"FloatingToTile" => Ok(Command::FloatingToTile),
"TileToFloating" => Ok(Command::TileToFloating),
"ToggleFloating" => Ok(Command::ToggleFloating),
"GoToTag" => build_go_to_tag(rest),
"ReturnToLastTag" => Ok(Command::ReturnToLastTag),
"SendWorkspaceToTag" => build_send_workspace_to_tag(rest),
"SwapScreens" => Ok(Command::SwapScreens),
"ToggleFullScreen" => Ok(Command::ToggleFullScreen),
"ToggleSticky" => Ok(Command::ToggleSticky),
"CloseWindow" => Ok(Command::CloseWindow),
"CloseAllOtherWindows" => Ok(Command::CloseAllOtherWindows),
"SoftReload" => Ok(Command::SoftReload),
_ => Ok(Command::Other(s.into())),
}
}
fn build_attach_scratchpad(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let name = if raw.is_empty() {
return Err("missing argument scratchpad's name".into());
} else {
raw
};
Ok(Command::AttachScratchPad {
scratchpad: name.into(),
window: None,
})
}
fn build_release_scratchpad(raw: &str) -> Command {
if raw.is_empty() {
Command::ReleaseScratchPad {
window: ReleaseScratchPadOption::None,
tag: None,
}
} else if let Ok(tag_id) = usize::from_str(raw) {
Command::ReleaseScratchPad {
window: ReleaseScratchPadOption::None,
tag: Some(tag_id),
}
} else {
Command::ReleaseScratchPad {
window: ReleaseScratchPadOption::ScratchpadName(raw.into()),
tag: None,
}
}
}
fn build_toggle_scratchpad(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let name = if raw.is_empty() {
return Err("missing argument scratchpad's name".into());
} else {
raw
};
Ok(Command::ToggleScratchPad(name.into()))
}
fn build_go_to_tag(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let headless = without_head(raw, "GoToTag ");
let mut parts = headless.split(' ');
let tag: TagId = parts.next().ok_or("missing argument tag_id")?.parse()?;
let swap: bool = parts.next().ok_or("missing argument swap")?.parse()?;
Ok(Command::GoToTag { tag, swap })
}
fn build_send_window_to_tag(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let tag_id = if raw.is_empty() {
return Err("missing argument tag_id".into());
} else {
TagId::from_str(raw)?
};
Ok(Command::SendWindowToTag {
window: None,
tag: tag_id,
})
}
fn build_send_workspace_to_tag(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
if raw.is_empty() {
return Err("missing argument workspace index".into());
}
let mut parts = raw.split(' ');
let ws_index: usize = parts
.next()
.expect("split() always returns an array of at least 1 element")
.parse()?;
let tag_index: usize = parts.next().ok_or("missing argument tag index")?.parse()?;
Ok(Command::SendWorkspaceToTag(ws_index, tag_index))
}
fn build_set_layout(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let layout_name = if raw.is_empty() {
return Err("missing layout name".into());
} else {
raw
};
Ok(Command::SetLayout(Layout::from_str(layout_name)?))
}
fn build_set_margin_multiplier(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let margin_multiplier = if raw.is_empty() {
return Err("missing argument multiplier".into());
} else {
f32::from_str(raw)?
};
Ok(Command::SetMarginMultiplier(margin_multiplier))
}
fn build_focus_window_top(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let swap = if raw.is_empty() {
false
} else {
bool::from_str(raw)?
};
Ok(Command::FocusWindowTop { swap })
}
fn build_move_window_top(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let swap = if raw.is_empty() {
true
} else {
bool::from_str(raw)?
};
Ok(Command::MoveWindowTop { swap })
}
fn build_swap_window_top(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let swap = if raw.is_empty() {
true
} else {
bool::from_str(raw)?
};
Ok(Command::SwapWindowTop { swap })
}
fn build_move_window_to_next_tag(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let follow = if raw.is_empty() {
true
} else {
bool::from_str(raw)?
};
Ok(Command::MoveWindowToNextTag { follow })
}
fn build_move_window_to_previous_tag(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let follow = if raw.is_empty() {
true
} else {
bool::from_str(raw)?
};
Ok(Command::MoveWindowToPreviousTag { follow })
}
fn build_increase_main_width(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let headless = without_head(raw, "IncreaseMainWidth ");
let mut parts = headless.split(' ');
let change: i8 = parts.next().ok_or("missing argument change")?.parse()?;
Ok(Command::IncreaseMainWidth(change))
}
fn build_decrease_main_width(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
let headless = without_head(raw, "DecreaseMainWidth ");
let mut parts = headless.split(' ');
let change: i8 = parts.next().ok_or("missing argument change")?.parse()?;
Ok(Command::DecreaseMainWidth(change))
}
fn build_focus_next_tag(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
match raw {
"ignore_empty" | "goto_used" => Ok(Command::FocusNextTag {
behavior: command::FocusDeltaBehavior::IgnoreEmpty,
}),
"ignore_used" | "goto_empty" => Ok(Command::FocusNextTag {
behavior: command::FocusDeltaBehavior::IgnoreUsed,
}),
"default" | "" => Ok(Command::FocusNextTag {
behavior: command::FocusDeltaBehavior::Default,
}),
_ => Err(Box::new(InvalidFocusDeltaBehaviorError {
attempted_value: raw.to_owned(),
command: Command::FocusNextTag {
behavior: command::FocusDeltaBehavior::Default,
},
})),
}
}
fn build_focus_previous_tag(raw: &str) -> Result<Command, Box<dyn std::error::Error>> {
match raw {
"ignore_empty" | "goto_used" => Ok(Command::FocusPreviousTag {
behavior: command::FocusDeltaBehavior::IgnoreEmpty,
}),
"ignore_used" | "goto_empty" => Ok(Command::FocusPreviousTag {
behavior: command::FocusDeltaBehavior::IgnoreUsed,
}),
"default" | "" => Ok(Command::FocusPreviousTag {
behavior: command::FocusDeltaBehavior::Default,
}),
_ => Err(Box::new(InvalidFocusDeltaBehaviorError {
attempted_value: raw.to_owned(),
command: Command::FocusPreviousTag {
behavior: command::FocusDeltaBehavior::Default,
},
})),
}
}
fn without_head<'a>(s: &'a str, head: &'a str) -> &'a str {
if !s.starts_with(head) {
return s;
}
&s[head.len()..]
}
#[derive(Debug)]
struct InvalidFocusDeltaBehaviorError {
attempted_value: String,
command: Command,
}
impl Error for InvalidFocusDeltaBehaviorError {}
impl fmt::Display for InvalidFocusDeltaBehaviorError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self.command {
Command::FocusNextTag { .. } => write!(
f,
"Invalid behavior for FocusNextTag: {}",
&self.attempted_value
),
Command::FocusPreviousTag { .. } => write!(
f,
"Invalid behavior for FocusPreviousTag: {}",
&self.attempted_value
),
_ => write!(f, "Invalid behavior: {}", &self.attempted_value),
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::utils::helpers::test::temp_path;
use tokio::io::AsyncWriteExt;
use tokio::time;
#[tokio::test]
async fn read_good_command() {
let pipe_file = temp_path().await.unwrap();
let mut command_pipe = CommandPipe::new(pipe_file.clone()).await.unwrap();
{
let mut pipe = fs::OpenOptions::new()
.write(true)
.open(&pipe_file)
.await
.unwrap();
pipe.write_all(b"SoftReload\n").await.unwrap();
pipe.flush().await.unwrap();
assert_eq!(
Command::SoftReload,
command_pipe.read_command().await.unwrap()
);
}
}
#[tokio::test]
async fn read_bad_command() {
let pipe_file = temp_path().await.unwrap();
let mut command_pipe = CommandPipe::new(pipe_file.clone()).await.unwrap();
{
let mut pipe = fs::OpenOptions::new()
.write(true)
.open(&pipe_file)
.await
.unwrap();
pipe.write_all(b"Hello World\n").await.unwrap();
pipe.flush().await.unwrap();
assert_eq!(
Command::Other("Hello World".to_string()),
command_pipe.read_command().await.unwrap()
);
}
}
#[tokio::test]
async fn pipe_cleanup() {
let pipe_file = temp_path().await.unwrap();
fs::remove_file(pipe_file.as_path()).await.unwrap();
{
let _command_pipe = CommandPipe::new(pipe_file.clone()).await.unwrap();
let mut pipe = fs::OpenOptions::new()
.write(true)
.open(&pipe_file)
.await
.unwrap();
pipe.write_all(b"ToggleFullScreen\n").await.unwrap();
pipe.flush().await.unwrap();
}
time::sleep(time::Duration::from_millis(100)).await;
{
assert!(!pipe_file.exists());
}
}
#[test]
fn build_toggle_scratchpad_without_parameter() {
assert!(build_toggle_scratchpad("").is_err());
}
#[test]
fn build_send_window_to_tag_without_parameter() {
assert!(build_send_window_to_tag("").is_err());
}
#[test]
fn build_send_workspace_to_tag_without_parameter() {
assert!(build_send_workspace_to_tag("").is_err());
}
#[test]
fn build_set_layout_without_parameter() {
assert!(build_set_layout("").is_err());
}
#[test]
fn build_set_margin_multiplier_without_parameter() {
assert!(build_set_margin_multiplier("").is_err());
}
#[test]
fn build_move_window_top_without_parameter() {
assert_eq!(
build_move_window_top("").unwrap(),
Command::MoveWindowTop { swap: true }
);
}
#[test]
fn build_focus_window_top_without_parameter() {
assert_eq!(
build_focus_window_top("").unwrap(),
Command::FocusWindowTop { swap: false }
);
}
#[test]
fn build_move_window_to_next_tag_without_parameter() {
assert_eq!(
build_move_window_to_next_tag("").unwrap(),
Command::MoveWindowToNextTag { follow: true }
);
}
#[test]
fn build_move_window_to_previous_tag_without_parameter() {
assert_eq!(
build_move_window_to_previous_tag("").unwrap(),
Command::MoveWindowToPreviousTag { follow: true }
);
}
#[test]
fn build_focus_next_tag_without_parameter() {
assert_eq!(
build_focus_next_tag("").unwrap(),
Command::FocusNextTag {
behavior: command::FocusDeltaBehavior::Default
}
);
}
#[test]
fn build_focus_previous_tag_without_parameter() {
assert_eq!(
build_focus_previous_tag("").unwrap(),
Command::FocusPreviousTag {
behavior: command::FocusDeltaBehavior::Default
}
);
}
#[test]
fn build_focus_next_tag_with_invalid() {
assert_eq!(
build_focus_next_tag("gurke").unwrap_err().to_string(),
(InvalidFocusDeltaBehaviorError {
attempted_value: String::from("gurke"),
command: Command::FocusNextTag {
behavior: command::FocusDeltaBehavior::Default,
}
})
.to_string()
);
}
#[test]
fn build_focus_previous_tag_with_invalid() {
assert_eq!(
build_focus_previous_tag("gurke").unwrap_err().to_string(),
(InvalidFocusDeltaBehaviorError {
attempted_value: String::from("gurke"),
command: Command::FocusPreviousTag {
behavior: command::FocusDeltaBehavior::Default,
}
})
.to_string()
);
}
}