focal 0.2.8

Terminal focus library - focus terminal windows and multiplexer panes
Documentation
//! Focal - Terminal focus library.
//!
//! This crate provides cross-platform functionality for focusing terminal windows
//! and switching multiplexer panes. It supports:
//!
//! - **Terminal detection**: Identify which terminal emulator owns a TTY
//! - **Cross-platform activation**: Bring terminal windows to front (macOS + Linux)
//! - **Terminal-specific handlers**: Precise tab/pane focusing for iTerm2, Terminal.app, WezTerm, Kitty
//! - **Multiplexer support**: Switch to the correct tmux/zellij/screen pane
//!
//! # Focus Modes
//!
//! The library supports two focus modes:
//!
//! - **SingleWindow**: Uses Accessibility APIs (AXRaise) to bring only the specific
//!   window to the front. Requires accessibility permissions on macOS.
//!
//! - **ActivateApp**: Uses traditional app activation which brings ALL windows of
//!   the terminal app to the front. No special permissions required.
//!
//! # Usage
//!
//! ```ignore
//! use focal::{focus_session, focus_session_with_mode, FocusResult, FocusMode};
//!
//! // Focus using default mode (SingleWindow with ActivateApp fallback)
//! match focus_session(pid, None) {
//!     FocusResult::Success => println!("Focused!"),
//!     FocusResult::MuxSwitched { mux, target } => {
//!         println!("Switched to {mux} {target}");
//!     }
//!     FocusResult::NotFound => println!("Terminal not found"),
//!     _ => {}
//! }
//!
//! // Or explicitly choose a mode
//! focus_session_with_mode(pid, None, FocusMode::ActivateApp);
//! ```
//!
//! # Architecture
//!
//! The focus flow works as follows:
//!
//! 1. Check if the process is in a terminal multiplexer (tmux, zellij, screen)
//! 2. If so, switch to the correct pane and get the client TTY
//! 3. Get the TTY device for the process
//! 4. Detect which terminal emulator owns the TTY
//! 5. Try terminal-specific focusing (for precise tab/pane control)
//! 6. Fall back to generic window activation

pub mod activate;
pub mod detect;
pub mod mux;
pub mod terminals;
pub mod tty;
pub mod util;

// Re-export key types at crate root
pub use detect::{Terminal, TerminalKind};
pub use mux::{Focuser, Multiplexer, MuxFocusResult};

/// Default implementation of [`Focuser`] that uses the terminal handler registry.
///
/// This is used by the library's main entry points to focus terminal windows
/// after multiplexer pane switching.
struct DefaultFocuser {
    mode: FocusMode,
}

impl Focuser for DefaultFocuser {
    fn focus(&self, tty: &str, client_pid: Option<i32>) -> bool {
        focus_terminal_by_tty_with_fallback(tty, client_pid, self.mode)
    }
}

/// Focus mode for terminal window activation.
///
/// Controls how windows are brought to the front on macOS.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FocusMode {
    /// Bring only the specific window to the front using Accessibility APIs.
    ///
    /// This is the preferred mode as it doesn't disturb other windows.
    /// Requires accessibility permissions on macOS.
    ///
    /// Uses JXA + System Events AXRaise action (~140ms).
    #[default]
    SingleWindow,

    /// Activate the entire application, bringing ALL its windows to the front.
    ///
    /// This is the traditional behavior and doesn't require accessibility
    /// permissions, but will raise all terminal windows above other apps.
    ///
    /// Uses JXA + Application.activate() (~100ms).
    ActivateApp,
}

/// Result of attempting to focus a terminal.
#[derive(Debug, Clone)]
pub enum FocusResult {
    /// Successfully focused the terminal.
    Success,
    /// Switched to a multiplexer pane and focused the terminal.
    MuxSwitched {
        /// Name of the multiplexer (e.g., "tmux")
        mux: String,
        /// Target identifier (e.g., "session:window.pane")
        target: String,
    },
    /// Terminal type not supported for focusing.
    Unsupported(String),
    /// TTY device not found for this session.
    NoTty,
    /// Terminal window not found (may have been closed).
    NotFound,
    /// Error occurred.
    Error(String),
}

/// Focus the terminal window containing the given process.
///
/// This is the main entry point for the library. It handles:
/// 1. Multiplexer detection and pane switching (tmux, etc.)
/// 2. Terminal detection from TTY
/// 3. Terminal-specific focusing (iTerm2, Terminal.app, etc.)
/// 4. Generic window activation fallback
///
/// Uses `FocusMode::SingleWindow` by default (brings only the target window
/// to the front). Use `focus_session_with_mode` for explicit mode control.
///
/// # Arguments
/// * `pid` - Process ID of the session
/// * `mux_hint` - Optional hint about the multiplexer type (e.g., "tmux")
///
/// # Returns
/// A `FocusResult` indicating what happened.
pub fn focus_session(pid: i32, mux_hint: Option<&str>) -> FocusResult {
    focus_session_with_mode(pid, mux_hint, FocusMode::default())
}

/// Focus the terminal window with explicit mode control.
///
/// # Arguments
/// * `pid` - Process ID of the session
/// * `mux_hint` - Optional hint about the multiplexer type (e.g., "tmux")
/// * `mode` - The focus mode to use
///
/// # Returns
/// A `FocusResult` indicating what happened.
pub fn focus_session_with_mode(pid: i32, mux_hint: Option<&str>, mode: FocusMode) -> FocusResult {
    // Build process parent map once for all operations
    let parent_map = prock::build_parent_map();

    // Create the focuser for multiplexer callbacks
    let focuser = DefaultFocuser { mode };

    // Check for multiplexer and handle if present
    let detected_mux = match mux_hint {
        Some("tmux") => Some(Multiplexer::Tmux),
        Some("screen") => Some(Multiplexer::Screen),
        Some("zellij") => Some(Multiplexer::Zellij),
        _ => mux::detect(pid, &parent_map),
    };

    if let Some(mux) = detected_mux {
        let mux_name = match mux {
            Multiplexer::Tmux => "tmux",
            Multiplexer::Screen => "screen",
            Multiplexer::Zellij => "zellij",
        };

        match mux::focus(mux, pid, &parent_map, &focuser) {
            MuxFocusResult::Switched {
                session,
                window,
                pane,
            } => {
                return FocusResult::MuxSwitched {
                    mux: mux_name.to_string(),
                    target: format!("{session}:{window}.{pane}"),
                };
            }
            MuxFocusResult::NoClient { session } => {
                return FocusResult::Error(format!(
                    "No terminal attached to {mux_name} session '{session}'"
                ));
            }
            MuxFocusResult::Error(e) => {
                return FocusResult::Error(e);
            }
            MuxFocusResult::NotFound => {
                // Fall through to try regular terminal focus
            }
        }
    }

    // Get TTY device for the process
    let tty_device = tty::get_device(pid);

    // If we have a TTY, use the standard TTY-based focusing
    if let Some(ref tty) = tty_device
        && focus_terminal_by_tty_and_pid(tty, Some(pid), mode)
    {
        return FocusResult::Success;
    }

    // Fallback: For processes without TTY (e.g., IDE extension hosts like Cursor/VSCode),
    // try to detect the terminal from the PID ancestry and focus it directly
    if let Some(terminal) = detect::terminal_from_pid(pid) {
        // For IDEs, we can focus the window even without a TTY
        let dummy_tty = tty_device.as_deref().unwrap_or("");
        if terminals::focus(&terminal, dummy_tty, mode) {
            return FocusResult::Success;
        }
        return FocusResult::NotFound;
    }

    // No TTY and no terminal detected
    if tty_device.is_none() {
        FocusResult::NoTty
    } else {
        FocusResult::NotFound
    }
}

/// Focus a terminal window by its TTY device.
///
/// This function:
/// 1. Detects which terminal owns the TTY
/// 2. Tries terminal-specific focusing if available
/// 3. Falls back to generic window activation
///
/// Uses `FocusMode::SingleWindow` by default.
///
/// # Arguments
/// * `tty_device` - TTY device name (e.g., "ttys003")
///
/// # Returns
/// `true` if focusing succeeded, `false` otherwise.
pub fn focus_by_tty(tty_device: &str) -> bool {
    focus_by_tty_with_mode(tty_device, FocusMode::default())
}

/// Focus a terminal window by TTY with explicit mode control.
///
/// # Arguments
/// * `tty_device` - TTY device name (e.g., "ttys003")
/// * `mode` - The focus mode to use
///
/// # Returns
/// `true` if focusing succeeded, `false` otherwise.
pub fn focus_by_tty_with_mode(tty_device: &str, mode: FocusMode) -> bool {
    focus_terminal_by_tty(tty_device, mode)
}

/// Internal function to focus terminal by TTY.
fn focus_terminal_by_tty(tty_device: &str, mode: FocusMode) -> bool {
    focus_terminal_by_tty_and_pid(tty_device, None, mode)
}

/// Internal function to focus terminal by TTY with optional PID for better matching.
fn focus_terminal_by_tty_and_pid(tty_device: &str, pid: Option<i32>, mode: FocusMode) -> bool {
    // Detect which terminal owns this TTY
    let Some(terminal) = detect::terminal(tty_device) else {
        return false;
    };

    // Use PID-based focusing when available (more reliable for Kitty)
    if let Some(pid) = pid {
        terminals::focus_by_pid(&terminal, pid, tty_device, mode)
    } else {
        terminals::focus(&terminal, tty_device, mode)
    }
}

/// Focus terminal by TTY with optional client PID as fallback.
///
/// This is used for tmux focus where we have both the client TTY and PID.
/// If TTY-based detection fails, we can try walking up from the client PID.
fn focus_terminal_by_tty_with_fallback(
    tty_device: &str,
    client_pid: Option<i32>,
    mode: FocusMode,
) -> bool {
    // Try TTY-based detection first
    if let Some(terminal) = detect::terminal(tty_device) {
        return terminals::focus(&terminal, tty_device, mode);
    }

    // Fallback: try using client PID if available
    if let Some(pid) = client_pid
        && let Some(terminal) = detect::terminal_from_pid(pid)
    {
        return terminals::focus(&terminal, tty_device, mode);
    }

    false
}

/// Get the TTY device for a process.
///
/// This is a convenience re-export of `tty::get_device`.
#[must_use]
pub fn get_tty_device(pid: i32) -> Option<String> {
    tty::get_device(pid)
}

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

    #[test]
    fn test_focus_session_nonexistent_pid() {
        let result = focus_session(999_999_999, None);
        assert!(matches!(result, FocusResult::NoTty));
    }

    #[test]
    fn test_focus_by_tty_nonexistent() {
        let result = focus_by_tty("ttys999999");
        assert!(!result);
    }
}