quickterm 0.1.0

A small drop-down terminal for Sway/i3 IPC
Documentation
use swayipc::{Connection, Node, NodeType, Rect};

use crate::config::Position;
use crate::error::QuicktermError;

pub const MARK_QT_PATTERN: &str = "quickterm_.*";

pub fn mark_for_shell(shell: &str) -> String {
    format!("quickterm_{shell}")
}

pub struct WorkspaceInfo {
    pub name: String,
    pub rect: Rect,
}

pub struct Ipc {
    connection: Connection,
}

fn popup_geometry(rect: Rect, pos: &Position, ratio: f64) -> (i32, i32, i32, i32) {
    let wx = rect.x;
    let wy = rect.y;
    let mut width = rect.width;
    let wheight = rect.height;

    let height = (wheight as f64 * ratio) as i32;
    let (posx, posy) = match pos {
        Position::Bottom => (wx, wy + wheight - height - 6),
        Position::Center => {
            width = (width as f64 * (ratio / 1.25)) as i32;
            let posx = wx + (rect.width - width) / 2;
            let posy = wy + (wheight - height) / 2;
            (posx, posy)
        }
        Position::Top => (wx, wy),
    };

    (width, height, posx, posy)
}

impl Ipc {
    pub fn connect() -> Result<Self, QuicktermError> {
        let connection = Connection::new().map_err(|err| QuicktermError::Ipc(err.to_string()))?;
        Ok(Self { connection })
    }

    pub fn current_workspace(&mut self) -> Result<WorkspaceInfo, QuicktermError> {
        let workspace = self
            .connection
            .get_workspaces()
            .map_err(|err| QuicktermError::Ipc(err.to_string()))?
            .into_iter()
            .find(|workspace| workspace.focused)
            .ok_or_else(|| QuicktermError::Ipc("no current workspace".to_string()))?;

        Ok(WorkspaceInfo {
            name: workspace.name,
            rect: workspace.rect,
        })
    }

    pub fn command(&mut self, command: &str) -> Result<(), QuicktermError> {
        self.connection
            .run_command(command)
            .map_err(|err| QuicktermError::Ipc(err.to_string()))?;
        Ok(())
    }

    pub fn find_marked_exact(&mut self, mark: &str) -> Result<Vec<Node>, QuicktermError> {
        let tree = self
            .connection
            .get_tree()
            .map_err(|err| QuicktermError::Ipc(err.to_string()))?;
        Ok(find_marked(&tree, mark))
    }

    pub fn find_marked_pattern_in_current_workspace(
        &mut self,
        pattern: &str,
    ) -> Result<Vec<Node>, QuicktermError> {
        let workspace = self.current_workspace_node()?;
        Ok(find_marked(&workspace, pattern))
    }

    pub fn workspace_name_for_node(
        &mut self,
        node_id: i64,
    ) -> Result<Option<String>, QuicktermError> {
        let tree = self
            .connection
            .get_tree()
            .map_err(|err| QuicktermError::Ipc(err.to_string()))?;
        Ok(find_workspace_name_for_node(&tree, node_id, None))
    }

    pub fn find_titled_in_current_workspace(
        &mut self,
        title: &str,
    ) -> Result<Vec<Node>, QuicktermError> {
        let workspace = self.current_workspace_node()?;
        Ok(workspace
            .iter()
            .filter(|node| node.name.as_deref() == Some(title))
            .cloned()
            .collect())
    }

    fn current_workspace_node(&mut self) -> Result<Node, QuicktermError> {
        let workspace_name = self.current_workspace()?.name;
        let tree = self
            .connection
            .get_tree()
            .map_err(|err| QuicktermError::Ipc(err.to_string()))?;
        tree.find_as_ref(|node| {
            node.node_type == NodeType::Workspace && node.name.as_deref() == Some(&workspace_name)
        })
        .cloned()
        .ok_or_else(|| QuicktermError::Ipc("no current workspace".to_string()))
    }

    pub fn move_back(&mut self, selector: &str) -> Result<(), QuicktermError> {
        self.command(&format!("{selector} floating enable, move scratchpad"))
    }

    pub fn pop_it(&mut self, mark: &str, pos: &Position, ratio: f64) -> Result<(), QuicktermError> {
        let ws = self.current_workspace()?;
        let (width, height, posx, posy) = popup_geometry(ws.rect, pos, ratio);

        self.command(&format!(
            "[con_mark={mark}], move scratchpad, scratchpad show, resize set {width} px {height} px, move absolute position {posx}px {posy}px"
        ))
    }
}

fn find_marked(node: &Node, pattern: &str) -> Vec<Node> {
    node.iter()
        .filter(|candidate| {
            candidate
                .marks
                .iter()
                .any(|mark| mark_matches(mark, pattern))
        })
        .cloned()
        .collect()
}

fn mark_matches(mark: &str, pattern: &str) -> bool {
    if let Some(prefix) = pattern.strip_suffix(".*") {
        mark.starts_with(prefix)
    } else {
        mark == pattern
    }
}

fn find_workspace_name_for_node(
    node: &Node,
    target_id: i64,
    workspace_name: Option<&str>,
) -> Option<String> {
    let workspace_name = if node.node_type == NodeType::Workspace {
        node.name.as_deref()
    } else {
        workspace_name
    };

    if node.id == target_id {
        return workspace_name.map(str::to_string);
    }

    for child in node.nodes.iter().chain(node.floating_nodes.iter()) {
        if let Some(found) = find_workspace_name_for_node(child, target_id, workspace_name) {
            return Some(found);
        }
    }

    None
}

#[cfg(test)]
mod tests {
    use super::popup_geometry;
    use crate::config::Position;
    use swayipc::Rect;

    #[test]
    fn center_position_centers_popup_rect() {
        let rect: Rect = serde_json::from_value(serde_json::json!({
            "x": 0,
            "y": 0,
            "width": 2000,
            "height": 1000
        }))
        .unwrap();

        let (width, height, posx, posy) = popup_geometry(rect, &Position::Center, 0.55);

        assert_eq!(width, 880);
        assert_eq!(height, 550);
        assert_eq!(posx, 560);
        assert_eq!(posy, 225);
    }
}