umbriel-mcp 0.4.3

MCP server for the Umbriel Wayland compositor: windows, workspaces, outputs, pointer and keyboard input, screenshots with OCR, and the clipboard.
use serde_json::Value;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rect {
    pub x: i64,
    pub y: i64,
    pub width: i64,
    pub height: i64,
}

impl Rect {
    fn right(&self) -> i64 {
        self.x + self.width
    }

    fn bottom(&self) -> i64 {
        self.y + self.height
    }

    fn area(&self) -> i64 {
        self.width.max(0) * self.height.max(0)
    }

    pub fn overlap(&self, other: &Rect) -> i64 {
        let width = self.right().min(other.right()) - self.x.max(other.x);
        let height = self.bottom().min(other.bottom()) - self.y.max(other.y);
        width.max(0) * height.max(0)
    }

    pub fn contains_rect(&self, other: &Rect) -> bool {
        other.x >= self.x
            && other.y >= self.y
            && other.right() <= self.right()
            && other.bottom() <= self.bottom()
    }

    pub fn contains_point(&self, x: i64, y: i64) -> bool {
        x >= self.x && y >= self.y && x < self.right() && y < self.bottom()
    }
}

fn number(entry: &Value, key: &str) -> Option<i64> {
    entry.get(key)?.as_i64()
}

pub fn window_rect(window: &Value) -> Option<Rect> {
    Some(Rect {
        x: number(window, "x")?,
        y: number(window, "y")?,
        width: number(window, "w")?,
        height: number(window, "h")?,
    })
}

pub fn output_rect(outputs: &[Value], name: &str) -> Option<Rect> {
    let output = outputs
        .iter()
        .find(|output| output.get("name").and_then(Value::as_str) == Some(name))?;
    let position = output.get("position")?;
    let size = output.get("logical_size")?;
    Some(Rect {
        x: number(position, "x")?,
        y: number(position, "y")?,
        width: number(size, "width")?,
        height: number(size, "height")?,
    })
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OnScreen {
    Hidden,
    ScrolledOutOfView,
    PartlyInView,
    FullyInView,
}

impl OnScreen {
    pub fn any_part_shown(self) -> bool {
        matches!(self, OnScreen::PartlyInView | OnScreen::FullyInView)
    }
}

fn flag(window: &Value, key: &str) -> Option<bool> {
    window.get(key).and_then(Value::as_bool)
}

pub fn on_screen(window: &Value, outputs: &[Value]) -> OnScreen {
    let on_active_workspace = flag(window, "visible").unwrap_or(true);
    if !on_active_workspace || flag(window, "minimized") == Some(true) {
        return OnScreen::Hidden;
    }
    let output = window
        .get("output")
        .and_then(Value::as_str)
        .and_then(|name| output_rect(outputs, name));
    let (Some(rect), Some(output)) = (window_rect(window), output) else {
        return OnScreen::FullyInView;
    };
    if output.contains_rect(&rect) {
        OnScreen::FullyInView
    } else if output.overlap(&rect) > 0 {
        OnScreen::PartlyInView
    } else {
        OnScreen::ScrolledOutOfView
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Covering {
    pub description: String,
    pub hides_most_of_it: bool,
    pub area: Rect,
}

const MOSTLY_HIDDEN_SHARE: f64 = 0.5;

fn covering(description: String, area: Rect, of: &Rect, modal: bool) -> Covering {
    let share = of.overlap(&area) as f64 / of.area().max(1) as f64;
    Covering {
        description,
        hides_most_of_it: modal || share >= MOSTLY_HIDDEN_SHARE,
        area,
    }
}

pub fn things_drawn_over(window: &Value, windows: &[Value], layers: &[Value]) -> Vec<Covering> {
    let Some(rect) = window_rect(window) else {
        return Vec::new();
    };
    let mut found = Vec::new();
    for layer in layers {
        let level = layer.get("layer").and_then(Value::as_str).unwrap_or("");
        let mapped = flag(layer, "mapped").unwrap_or(true);
        if !mapped || !matches!(level, "top" | "overlay") {
            continue;
        }
        let Some(layer_rect) = window_rect(layer) else {
            continue;
        };
        if rect.overlap(&layer_rect) == 0 {
            continue;
        }
        let modal =
            layer.get("keyboard_interactivity").and_then(Value::as_str) == Some("exclusive");
        let name = layer
            .get("namespace")
            .and_then(Value::as_str)
            .unwrap_or("unnamed");
        found.push(covering(
            format!("the {level}-layer surface \"{name}\""),
            layer_rect,
            &rect,
            modal,
        ));
    }
    let id = window.get("id").and_then(Value::as_str);
    let target_floats = flag(window, "floating") == Some(true);
    for other in windows {
        let same_workspace = other.get("workspace") == window.get("workspace");
        let drawn_above = flag(other, "fullscreen") == Some(true)
            || (flag(other, "floating") == Some(true) && !target_floats);
        if other.get("id").and_then(Value::as_str) == id
            || !same_workspace
            || !drawn_above
            || flag(other, "visible") == Some(false)
            || flag(other, "minimized") == Some(true)
        {
            continue;
        }
        let Some(other_rect) = window_rect(other) else {
            continue;
        };
        if rect.overlap(&other_rect) == 0 {
            continue;
        }
        let name = other.get("app_id").and_then(Value::as_str).unwrap_or("?");
        let other_id = other.get("id").and_then(Value::as_str).unwrap_or("?");
        found.push(covering(
            format!("window {other_id} ({name})"),
            other_rect,
            &rect,
            false,
        ));
    }
    found
}

#[cfg(test)]
mod tests {
    use super::{OnScreen, on_screen, things_drawn_over};
    use serde_json::json;

    fn outputs() -> Vec<serde_json::Value> {
        vec![json!({"name": "DP-1", "position": {"x": 1920, "y": 0},
                    "logical_size": {"width": 1920, "height": 1080}})]
    }

    fn window(x: i64, visible: bool) -> serde_json::Value {
        json!({"id": "w", "output": "DP-1", "workspace": "DP-1:1", "visible": visible,
               "x": x, "y": 40, "w": 900, "h": 600})
    }

    #[test]
    fn a_window_on_the_active_workspace_can_still_be_scrolled_out_of_view() {
        assert_eq!(
            on_screen(&window(2000, true), &outputs()),
            OnScreen::FullyInView
        );
        assert_eq!(
            on_screen(&window(3500, true), &outputs()),
            OnScreen::PartlyInView
        );
        assert_eq!(
            on_screen(&window(4200, true), &outputs()),
            OnScreen::ScrolledOutOfView
        );
        assert_eq!(
            on_screen(&window(2000, false), &outputs()),
            OnScreen::Hidden
        );
        let minimized = json!({"visible": true, "minimized": true});
        assert_eq!(on_screen(&minimized, &outputs()), OnScreen::Hidden);
    }

    #[test]
    fn without_output_details_umbriels_visible_flag_decides() {
        assert_eq!(on_screen(&window(9000, true), &[]), OnScreen::FullyInView);
    }

    #[test]
    fn a_modal_menu_or_a_large_overlay_hides_the_window_and_a_toast_only_overlaps_it() {
        let target = window(2000, true);
        let session_menu = json!({"layer": "overlay", "namespace": "session-menu", "mapped": true,
                                   "keyboard_interactivity": "exclusive",
                                   "x": 2300, "y": 300, "w": 300, "h": 200});
        let toast = json!({"layer": "overlay", "namespace": "toast", "mapped": true,
                           "keyboard_interactivity": "none",
                           "x": 2700, "y": 40, "w": 300, "h": 80});
        let wallpaper = json!({"layer": "background", "namespace": "wallpaper", "mapped": true,
                               "x": 1920, "y": 0, "w": 1920, "h": 1080});

        let found = things_drawn_over(&target, &[], &[session_menu, toast, wallpaper]);

        assert_eq!(found.len(), 2, "{found:?}");
        assert!(found[0].hides_most_of_it && found[0].description.contains("session-menu"));
        assert!(!found[1].hides_most_of_it && found[1].description.contains("toast"));
    }

    #[test]
    fn floating_windows_are_drawn_over_tiled_ones_on_the_same_workspace() {
        let target = window(2000, true);
        let floating = json!({"id": "f", "app_id": "pavucontrol", "workspace": "DP-1:1",
                              "floating": true, "visible": true,
                              "x": 2000, "y": 40, "w": 800, "h": 500});
        let elsewhere = json!({"id": "e", "workspace": "DP-1:2", "floating": true,
                               "x": 2000, "y": 40, "w": 800, "h": 500});

        let found = things_drawn_over(&target, &[target.clone(), floating, elsewhere], &[]);

        assert_eq!(found.len(), 1, "{found:?}");
        assert!(found[0].hides_most_of_it && found[0].description.contains("pavucontrol"));
    }
}