focal 0.2.8

Terminal focus library - focus terminal windows and multiplexer panes
Documentation
//! WezTerm-specific focus handler.
//!
//! Uses the `wezterm cli` to find and activate the specific pane
//! matching a TTY device.
//!
//! # Multi-Window Behavior
//!
//! Unlike iTerm2, WezTerm does not expose native AppleScript/JXA APIs for
//! window management. This handler uses a workaround:
//!
//! 1. Finds the target pane via `wezterm cli list --format json`
//! 2. Activates the pane via `wezterm cli activate-pane`
//! 3. Uses System Events `AXFocusedWindow` to identify and raise the
//!    specific window that WezTerm considers focused after pane activation
//!
//! This approach works reliably with multiple WezTerm windows because
//! `activate-pane` internally updates WezTerm's focused window state.
//!
//! Tracked upstream: <https://github.com/wezterm/wezterm/issues/3542>

use crate::FocusMode;
use crate::activate;
use std::process::Command;

/// Try to focus the WezTerm pane with matching TTY.
///
/// Uses `wezterm cli list` to find panes and `wezterm cli activate-pane`
/// to focus the matching pane.
///
/// # Focus Modes
///
/// - **SingleWindow**: Uses System Events `AXFocusedWindow` to identify the
///   specific window after pane activation, then raises only that window.
///   Requires accessibility permissions.
///
/// - **ActivateApp**: Uses traditional app activation which brings ALL
///   WezTerm windows to the front. No special permissions required.
///
/// # Arguments
/// * `tty_device` - TTY device name (e.g., "ttys003")
/// * `mode` - The focus mode to use
///
/// # Returns
/// `true` if focusing succeeded, `false` otherwise.
pub fn try_focus(tty_device: &str, mode: FocusMode) -> bool {
    // Check if wezterm CLI is available
    let output = Command::new("wezterm")
        .args(["cli", "list", "--format", "json"])
        .output();

    let Ok(out) = output else { return false };
    if !out.status.success() {
        return false;
    }

    // Parse JSON to find pane with matching TTY
    // Format: [{"pane_id": 0, "tty_name": "/dev/ttys001", ...}, ...]
    let Ok(panes) = serde_json::from_slice::<Vec<serde_json::Value>>(&out.stdout) else {
        return false;
    };

    let tty_full = format!("/dev/{tty_device}");

    for pane in panes {
        // Check if tty_name matches
        let Some(tty_name) = pane.get("tty_name").and_then(|v| v.as_str()) else {
            continue;
        };

        if tty_name == tty_full || tty_name.ends_with(tty_device) {
            // Extract pane_id
            let Some(pane_id) = pane.get("pane_id").and_then(|v| v.as_i64()) else {
                continue;
            };

            // Activate this pane - this updates WezTerm's internal focused window state
            let result = Command::new("wezterm")
                .args(["cli", "activate-pane", "--pane-id", &pane_id.to_string()])
                .status();

            if result.map(|s| s.success()).unwrap_or(false) {
                // Bring WezTerm to front using the appropriate mode
                return bring_to_front(mode);
            }
        }
    }

    false
}

/// Bring WezTerm window to front using the specified mode.
fn bring_to_front(mode: FocusMode) -> bool {
    match mode {
        FocusMode::SingleWindow => bring_to_front_single_window(),
        FocusMode::ActivateApp => {
            activate::window("WezTerm");
            true
        }
    }
}

/// Bring WezTerm's focused window to front using AXFocusedWindow + AXRaise.
///
/// After `wezterm cli activate-pane` runs, WezTerm internally sets the
/// window containing that pane as its focused window. We use the
/// `AXFocusedWindow` accessibility attribute to identify that specific
/// window and raise it, rather than blindly raising `seWindows[0]` which
/// may be a different window.
///
/// This fixes the multi-window focus issue where the wrong window could
/// be brought to front when multiple WezTerm windows are open.
#[cfg(target_os = "macos")]
fn bring_to_front_single_window() -> bool {
    // Use raise_focused_window instead of raise_front_window to correctly
    // identify the window after activate-pane updates WezTerm's focus state
    super::jxa::raise_focused_window("WezTerm")
}

#[cfg(not(target_os = "macos"))]
fn bring_to_front_single_window() -> bool {
    // On non-macOS, fall back to generic activation
    activate::window("WezTerm");
    true
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_try_focus_nonexistent_tty_single_window() {
        let result = try_focus("ttys999999", FocusMode::SingleWindow);
        assert!(!result);
    }

    #[test]
    fn test_try_focus_nonexistent_tty_activate_app() {
        let result = try_focus("ttys999999", FocusMode::ActivateApp);
        assert!(!result);
    }

    #[test]
    fn test_json_parsing() {
        // Test that we can parse the expected wezterm JSON format
        let json = r#"[
            {"pane_id": 0, "tty_name": "/dev/ttys001", "window_id": 0},
            {"pane_id": 1, "tty_name": "/dev/ttys002", "window_id": 0}
        ]"#;

        let panes: Vec<serde_json::Value> = serde_json::from_str(json).unwrap();
        assert_eq!(panes.len(), 2);
        assert_eq!(panes[0]["pane_id"], 0);
        assert_eq!(panes[0]["tty_name"], "/dev/ttys001");
    }
}