par_term/tab/
mod.rs

1//! Tab management for multi-tab terminal support
2//!
3//! This module provides the core tab infrastructure including:
4//! - `Tab`: Represents a single terminal session with its own state
5//! - `TabManager`: Coordinates multiple tabs within a window
6//! - `TabId`: Unique identifier for each tab
7
8mod manager;
9
10pub use manager::TabManager;
11
12use crate::app::bell::BellState;
13use crate::app::mouse::MouseState;
14use crate::app::render_cache::RenderCache;
15use crate::config::Config;
16use crate::scroll_state::ScrollState;
17use crate::terminal::TerminalManager;
18use std::sync::Arc;
19use tokio::runtime::Runtime;
20use tokio::sync::Mutex;
21use tokio::task::JoinHandle;
22
23/// Unique identifier for a tab
24pub type TabId = u64;
25
26/// A single terminal tab with its own state
27pub struct Tab {
28    /// Unique identifier for this tab
29    pub id: TabId,
30    /// The terminal session for this tab
31    pub terminal: Arc<Mutex<TerminalManager>>,
32    /// Tab title (from OSC sequences or fallback)
33    pub title: String,
34    /// Whether this tab has unread activity since last viewed
35    pub has_activity: bool,
36    /// Scroll state for this tab
37    pub scroll_state: ScrollState,
38    /// Mouse state for this tab
39    pub mouse: MouseState,
40    /// Bell state for this tab
41    pub bell: BellState,
42    /// Render cache for this tab
43    pub cache: RenderCache,
44    /// Async task for refresh polling
45    pub refresh_task: Option<JoinHandle<()>>,
46    /// Working directory when tab was created (for inheriting)
47    pub working_directory: Option<String>,
48}
49
50impl Tab {
51    /// Create a new tab with a terminal session
52    pub fn new(
53        id: TabId,
54        config: &Config,
55        _runtime: Arc<Runtime>,
56        working_directory: Option<String>,
57    ) -> anyhow::Result<Self> {
58        let initial_opacity = config.window_opacity;
59
60        // Create terminal with scrollback from config
61        let mut terminal = TerminalManager::new_with_scrollback(
62            config.cols,
63            config.rows,
64            config.scrollback_lines,
65        )?;
66
67        // Set theme from config
68        terminal.set_theme(config.load_theme());
69
70        // Apply clipboard history limits from config
71        terminal.set_max_clipboard_sync_events(config.clipboard_max_sync_events);
72        terminal.set_max_clipboard_event_bytes(config.clipboard_max_event_bytes);
73
74        // Determine working directory
75        let work_dir = working_directory
76            .as_deref()
77            .or(config.working_directory.as_deref());
78
79        // Determine the shell command to use
80        let (shell_cmd, mut shell_args) = if let Some(ref custom) = config.custom_shell {
81            (custom.clone(), config.shell_args.clone())
82        } else {
83            #[cfg(target_os = "windows")]
84            {
85                ("powershell.exe".to_string(), None)
86            }
87            #[cfg(not(target_os = "windows"))]
88            {
89                (
90                    std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()),
91                    None,
92                )
93            }
94        };
95
96        // On Unix-like systems, spawn as login shell if configured
97        #[cfg(not(target_os = "windows"))]
98        if config.login_shell {
99            let args = shell_args.get_or_insert_with(Vec::new);
100            if !args.iter().any(|a| a == "-l" || a == "--login") {
101                args.insert(0, "-l".to_string());
102            }
103        }
104
105        let shell_args_deref = shell_args.as_deref();
106        let shell_env = config.shell_env.as_ref();
107        terminal.spawn_custom_shell_with_dir(&shell_cmd, shell_args_deref, work_dir, shell_env)?;
108
109        let terminal = Arc::new(Mutex::new(terminal));
110
111        // Generate initial title
112        let title = format!("Tab {}", id);
113
114        Ok(Self {
115            id,
116            terminal,
117            title,
118            has_activity: false,
119            scroll_state: ScrollState::new(),
120            mouse: MouseState::new(),
121            bell: BellState::new(),
122            cache: RenderCache::new(initial_opacity),
123            refresh_task: None,
124            working_directory: working_directory.or_else(|| config.working_directory.clone()),
125        })
126    }
127
128    /// Check if the visual bell is currently active (within flash duration)
129    pub fn is_bell_active(&self) -> bool {
130        const FLASH_DURATION_MS: u128 = 150;
131        if let Some(flash_start) = self.bell.visual_flash {
132            flash_start.elapsed().as_millis() < FLASH_DURATION_MS
133        } else {
134            false
135        }
136    }
137
138    /// Update tab title from terminal OSC sequences
139    pub fn update_title(&mut self) {
140        if let Ok(term) = self.terminal.try_lock() {
141            let osc_title = term.get_title();
142            if !osc_title.is_empty() {
143                self.title = osc_title;
144            } else if let Some(cwd) = term.shell_integration_cwd() {
145                // Abbreviate home directory to ~
146                let abbreviated = if let Some(home) = dirs::home_dir() {
147                    cwd.replace(&home.to_string_lossy().to_string(), "~")
148                } else {
149                    cwd
150                };
151                // Use just the last component for brevity
152                if let Some(last) = abbreviated.rsplit('/').next() {
153                    if !last.is_empty() {
154                        self.title = last.to_string();
155                    } else {
156                        self.title = abbreviated;
157                    }
158                } else {
159                    self.title = abbreviated;
160                }
161            }
162            // Otherwise keep the existing title (e.g., "Tab N")
163        }
164    }
165
166    /// Check if the terminal in this tab is still running
167    #[allow(dead_code)]
168    pub fn is_running(&self) -> bool {
169        if let Ok(term) = self.terminal.try_lock() {
170            term.is_running()
171        } else {
172            true // Assume running if locked
173        }
174    }
175
176    /// Get the current working directory of this tab's shell
177    pub fn get_cwd(&self) -> Option<String> {
178        if let Ok(term) = self.terminal.try_lock() {
179            term.shell_integration_cwd()
180        } else {
181            self.working_directory.clone()
182        }
183    }
184
185    /// Start the refresh polling task for this tab
186    pub fn start_refresh_task(
187        &mut self,
188        runtime: Arc<Runtime>,
189        window: Arc<winit::window::Window>,
190        max_fps: u32,
191    ) {
192        let terminal_clone = Arc::clone(&self.terminal);
193        let refresh_interval_ms = 1000 / max_fps.max(1);
194
195        let handle = runtime.spawn(async move {
196            let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(
197                refresh_interval_ms as u64,
198            ));
199            let mut last_gen = 0;
200
201            loop {
202                interval.tick().await;
203
204                let should_redraw = if let Ok(term) = terminal_clone.try_lock() {
205                    let current_gen = term.update_generation();
206                    if current_gen > last_gen {
207                        last_gen = current_gen;
208                        true
209                    } else {
210                        term.has_updates()
211                    }
212                } else {
213                    false
214                };
215
216                if should_redraw {
217                    window.request_redraw();
218                }
219            }
220        });
221
222        self.refresh_task = Some(handle);
223    }
224
225    /// Stop the refresh polling task
226    pub fn stop_refresh_task(&mut self) {
227        if let Some(handle) = self.refresh_task.take() {
228            handle.abort();
229        }
230    }
231}
232
233impl Drop for Tab {
234    fn drop(&mut self) {
235        log::info!("Dropping tab {}", self.id);
236        self.stop_refresh_task();
237
238        // Give the task time to abort
239        std::thread::sleep(std::time::Duration::from_millis(50));
240
241        // Kill the terminal
242        if let Ok(mut term) = self.terminal.try_lock()
243            && term.is_running()
244        {
245            log::info!("Killing terminal for tab {}", self.id);
246            let _ = term.kill();
247        }
248    }
249}