focal 0.2.8

Terminal focus library - focus terminal windows and multiplexer panes
Documentation
//! Terminal-specific focus handlers.
//!
//! This module provides handlers for terminals that support precise tab/pane
//! focusing beyond simple window activation.
//!
//! # Architecture
//!
//! Terminal handlers implement the [`TerminalFocusHandler`] trait. Simple terminals
//! (Wave, Tabby, Ghostty, Warp) only need to implement `app_name()` and inherit
//! default focus behavior. Complex terminals (iTerm2, Kitty, WezTerm) override
//! `try_focus()` with custom logic.
//!
//! # Focus Modes
//!
//! All terminal handlers support two focus modes:
//!
//! - **SingleWindow**: Uses System Events 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 to the front. No special permissions required.
//!
//! Note: There is no automatic fallback from SingleWindow to ActivateApp if
//! accessibility permissions are unavailable. The caller should choose the
//! appropriate mode based on their requirements.

mod iterm2;
pub(crate) mod jxa;
mod kitty;
mod terminal_app;
mod vscode;
mod wezterm;

use crate::FocusMode;
use crate::activate;
use crate::detect::{Terminal, TerminalKind};

// ============================================================================
// Trait Definition
// ============================================================================

/// Trait for terminal-specific focus handling.
///
/// Terminals implement this trait to provide focus behavior. Simple terminals
/// that only need app activation can implement just `app_name()` and inherit
/// default implementations. Complex terminals override `try_focus()`.
///
/// # Example
///
/// Simple terminal (inherits default focus behavior):
/// ```ignore
/// struct WaveHandler;
/// impl TerminalFocusHandler for WaveHandler {
///     fn app_name(&self) -> &'static str { "Wave" }
/// }
/// ```
///
/// Complex terminal (custom focus logic):
/// ```ignore
/// struct ITerm2Handler;
/// impl TerminalFocusHandler for ITerm2Handler {
///     fn app_name(&self) -> &'static str { "iTerm2" }
///     fn try_focus(&self, tty_device: &str, mode: FocusMode) -> bool {
///         // Custom JXA logic to find session by TTY
///     }
/// }
/// ```
pub trait TerminalFocusHandler: Send + Sync {
    /// The application name used for activation (e.g., "Wave", "iTerm2").
    fn app_name(&self) -> &'static str;

    /// Try to focus the terminal window/tab/pane containing the given TTY.
    ///
    /// Default implementation uses JXA to activate by app name. Override this
    /// for terminals with custom focusing logic (e.g., iTerm2's session lookup).
    fn try_focus(&self, _tty_device: &str, mode: FocusMode) -> bool {
        match mode {
            FocusMode::SingleWindow => jxa::raise_front_window(self.app_name()),
            FocusMode::ActivateApp => jxa::activate_app(self.app_name()),
        }
    }

    /// Try to focus using PID-based matching when available.
    ///
    /// Default delegates to `try_focus()`. Override for terminals that support
    /// PID-based window/tab lookup (e.g., Kitty).
    fn try_focus_by_pid(&self, _pid: i32, tty_device: &str, mode: FocusMode) -> bool {
        self.try_focus(tty_device, mode)
    }
}

// ============================================================================
// Simple Terminal Handlers
// ============================================================================

/// Wave terminal handler.
///
/// Wave supports window-level focus only. Block-level focus is not available
/// as `wsh` does not provide TTY enumeration. See: <https://docs.waveterm.dev/wsh>
struct WaveHandler;
impl TerminalFocusHandler for WaveHandler {
    fn app_name(&self) -> &'static str {
        "Wave"
    }
}

/// Tabby terminal handler (formerly Terminus).
///
/// Tabby supports window-level focus only. Tab-level focus is not available
/// as Tabby lacks a tab enumeration/focus API. See: <https://tabby.sh/>
struct TabbyHandler;
impl TerminalFocusHandler for TabbyHandler {
    fn app_name(&self) -> &'static str {
        "Tabby"
    }
}

/// Ghostty terminal handler.
///
/// Ghostty supports window-level focus only. Split-level focus is not available
/// as Ghostty lacks a pane enumeration API.
/// Tracked upstream: <https://github.com/ghostty-org/ghostty/discussions/2353>
struct GhosttyHandler;
impl TerminalFocusHandler for GhosttyHandler {
    fn app_name(&self) -> &'static str {
        "Ghostty"
    }
}

/// Warp terminal handler.
///
/// Warp supports window-level focus only. Tab/pane-level focus is not available
/// as Warp lacks an enumeration API.
/// Tracked upstream: <https://github.com/warpdotdev/Warp/issues/1550>
struct WarpHandler;
impl TerminalFocusHandler for WarpHandler {
    fn app_name(&self) -> &'static str {
        "Warp"
    }
}

// ============================================================================
// Complex Terminal Handlers
// ============================================================================

/// iTerm2 terminal handler.
///
/// Uses JXA to find and focus the specific iTerm2 session matching a TTY device.
struct ITerm2Handler;
impl TerminalFocusHandler for ITerm2Handler {
    fn app_name(&self) -> &'static str {
        "iTerm2"
    }

    fn try_focus(&self, tty_device: &str, mode: FocusMode) -> bool {
        iterm2::try_focus(tty_device, mode)
    }
}

/// Terminal.app handler (macOS).
///
/// Uses JXA to find and focus the specific Terminal.app tab matching a TTY device.
struct TerminalAppHandler;
impl TerminalFocusHandler for TerminalAppHandler {
    fn app_name(&self) -> &'static str {
        "Terminal"
    }

    fn try_focus(&self, tty_device: &str, mode: FocusMode) -> bool {
        terminal_app::try_focus(tty_device, mode)
    }
}

/// WezTerm terminal handler.
///
/// Uses `wezterm cli` to find and activate the specific pane matching a TTY device.
struct WezTermHandler;
impl TerminalFocusHandler for WezTermHandler {
    fn app_name(&self) -> &'static str {
        "WezTerm"
    }

    fn try_focus(&self, tty_device: &str, mode: FocusMode) -> bool {
        wezterm::try_focus(tty_device, mode)
    }
}

/// Kitty terminal handler.
///
/// Uses `kitty @` remote control to find and focus the specific window
/// matching a target process. Supports both TTY-based and PID-based matching.
struct KittyHandler;
impl TerminalFocusHandler for KittyHandler {
    fn app_name(&self) -> &'static str {
        "kitty"
    }

    fn try_focus(&self, tty_device: &str, mode: FocusMode) -> bool {
        kitty::try_focus(tty_device, mode)
    }

    fn try_focus_by_pid(&self, pid: i32, tty_device: &str, mode: FocusMode) -> bool {
        kitty::try_focus_by_pid(pid, tty_device, mode)
    }
}

/// VS Code terminal handler.
///
/// Provides window-level focus for Visual Studio Code.
struct VSCodeHandler;
impl TerminalFocusHandler for VSCodeHandler {
    fn app_name(&self) -> &'static str {
        "Code"
    }

    fn try_focus(&self, tty_device: &str, mode: FocusMode) -> bool {
        vscode::try_focus(self.app_name(), tty_device, mode)
    }
}

/// Cursor IDE terminal handler.
///
/// Provides window-level focus for Cursor (VS Code fork).
struct CursorHandler;
impl TerminalFocusHandler for CursorHandler {
    fn app_name(&self) -> &'static str {
        "Cursor"
    }

    fn try_focus(&self, tty_device: &str, mode: FocusMode) -> bool {
        vscode::try_focus(self.app_name(), tty_device, mode)
    }
}

// ============================================================================
// Handler Registry
// ============================================================================

/// Get the handler for a terminal kind.
fn get_handler(kind: TerminalKind) -> Option<&'static dyn TerminalFocusHandler> {
    match kind {
        TerminalKind::Wave => Some(&WaveHandler),
        TerminalKind::Tabby => Some(&TabbyHandler),
        TerminalKind::Ghostty => Some(&GhosttyHandler),
        TerminalKind::Warp => Some(&WarpHandler),
        TerminalKind::ITerm2 => Some(&ITerm2Handler),
        TerminalKind::TerminalApp => Some(&TerminalAppHandler),
        TerminalKind::WezTerm => Some(&WezTermHandler),
        TerminalKind::Kitty => Some(&KittyHandler),
        TerminalKind::VSCode => Some(&VSCodeHandler),
        TerminalKind::Cursor => Some(&CursorHandler),
        _ => None,
    }
}

// ============================================================================
// Public API
// ============================================================================

/// Try to focus a terminal using a terminal-specific handler.
///
/// Returns `Some(true)` if focusing succeeded, `Some(false)` if the handler
/// ran but failed, or `None` if no specific handler exists for this terminal
/// (in which case the caller should use generic window activation).
///
/// # Arguments
/// * `terminal` - The detected terminal
/// * `tty_device` - TTY device name (e.g., "ttys003")
/// * `mode` - The focus mode to use
pub fn try_focus(terminal: &Terminal, tty_device: &str, mode: FocusMode) -> Option<bool> {
    get_handler(terminal.kind).map(|h| h.try_focus(tty_device, mode))
}

/// Try to focus a terminal using PID-based matching when available.
///
/// This is preferred over TTY-based matching for terminals that support it
/// (currently Kitty). For other terminals, falls back to TTY-based matching.
///
/// # Arguments
/// * `terminal` - The detected terminal
/// * `pid` - Target process ID
/// * `tty_device` - TTY device name (e.g., "ttys003")
/// * `mode` - The focus mode to use
pub fn try_focus_by_pid(
    terminal: &Terminal,
    pid: i32,
    tty_device: &str,
    mode: FocusMode,
) -> Option<bool> {
    get_handler(terminal.kind).map(|h| h.try_focus_by_pid(pid, tty_device, mode))
}

/// Focus a terminal, trying specific handlers first, then generic activation.
///
/// # Arguments
/// * `terminal` - The detected terminal
/// * `tty_device` - TTY device name (e.g., "ttys003")
/// * `mode` - The focus mode to use
///
/// # Returns
/// `true` if focusing succeeded, `false` otherwise.
pub fn focus(terminal: &Terminal, tty_device: &str, mode: FocusMode) -> bool {
    // Try terminal-specific handler first
    if let Some(success) = try_focus(terminal, tty_device, mode) {
        return success;
    }

    // Fall back to generic window activation
    activate::window(&terminal.process_name)
}

/// Focus a terminal using PID-based matching when available.
///
/// # Arguments
/// * `terminal` - The detected terminal
/// * `pid` - Target process ID
/// * `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_pid(terminal: &Terminal, pid: i32, tty_device: &str, mode: FocusMode) -> bool {
    // Try terminal-specific handler with PID first
    if let Some(success) = try_focus_by_pid(terminal, pid, tty_device, mode) {
        return success;
    }

    // Fall back to generic window activation
    activate::window(&terminal.process_name)
}

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

    /// Test handler that uses only the default trait implementations.
    struct TestHandler;
    impl TerminalFocusHandler for TestHandler {
        fn app_name(&self) -> &'static str {
            "NonExistentTestApp12345"
        }
    }

    #[test]
    fn test_default_try_focus_single_window() {
        // Verify default try_focus implementation doesn't panic
        // Returns false because the app doesn't exist
        let result = TestHandler.try_focus("ttys999999", FocusMode::SingleWindow);
        assert!(!result);
    }

    #[test]
    fn test_default_try_focus_activate_app() {
        // Verify default try_focus implementation doesn't panic
        let result = TestHandler.try_focus("ttys999999", FocusMode::ActivateApp);
        assert!(!result);
    }

    #[test]
    fn test_default_try_focus_by_pid_delegates() {
        // Verify default try_focus_by_pid delegates to try_focus
        let result = TestHandler.try_focus_by_pid(12345, "ttys999999", FocusMode::SingleWindow);
        assert!(!result);
    }

    #[test]
    fn test_get_handler_returns_some_for_supported() {
        assert!(get_handler(TerminalKind::Wave).is_some());
        assert!(get_handler(TerminalKind::Tabby).is_some());
        assert!(get_handler(TerminalKind::Ghostty).is_some());
        assert!(get_handler(TerminalKind::Warp).is_some());
        assert!(get_handler(TerminalKind::ITerm2).is_some());
        assert!(get_handler(TerminalKind::TerminalApp).is_some());
        assert!(get_handler(TerminalKind::WezTerm).is_some());
        assert!(get_handler(TerminalKind::Kitty).is_some());
        assert!(get_handler(TerminalKind::VSCode).is_some());
        assert!(get_handler(TerminalKind::Cursor).is_some());
    }

    #[test]
    fn test_get_handler_returns_none_for_unsupported() {
        assert!(get_handler(TerminalKind::Alacritty).is_none());
        assert!(get_handler(TerminalKind::GnomeTerminal).is_none());
        assert!(get_handler(TerminalKind::Unknown).is_none());
    }
}