Skip to main content

tmux_lib/
window.rs

1//! This module provides a few types and functions to handle Tmux windows.
2//!
3//! The main use cases are running Tmux commands & parsing Tmux window information.
4
5use std::str::FromStr;
6
7use smol::process::Command;
8
9use nom::{
10    IResult, Parser,
11    character::complete::{char, digit1},
12    combinator::{all_consuming, map_res, recognize},
13};
14use serde::{Deserialize, Serialize};
15
16use crate::{
17    Result,
18    error::{Error, check_empty_process_output, check_process_success, map_add_intent},
19    layout::{self, window_layout},
20    pane::Pane,
21    pane_id::{PaneId, parse::pane_id},
22    parse::{boolean, quoted_nonempty_string},
23    session::Session,
24    window_id::{WindowId, parse::window_id},
25};
26
27/// A Tmux window.
28///
29/// ```
30/// use std::str::FromStr;
31/// use tmux_lib::window::Window;
32///
33/// let line = "@5:0:true:64f0,334x85,0,0,11:'ben':'rust'";
34/// let window = Window::from_str(line).unwrap();
35///
36/// assert_eq!(window.id.as_str(), "@5");
37/// assert_eq!(window.index, 0);
38/// assert!(window.is_active);
39/// assert_eq!(window.name, "ben");
40/// ```
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Window {
43    /// Window identifier, e.g. `@3`.
44    pub id: WindowId,
45    /// Index of the Window in the Session.
46    pub index: u16,
47    /// Describes whether the Window is active.
48    pub is_active: bool,
49    /// Describes how panes are laid out in the Window.
50    pub layout: String,
51    /// Name of the Window.
52    pub name: String,
53    /// Name of Sessions to which this Window is attached.
54    pub sessions: Vec<String>,
55}
56
57impl FromStr for Window {
58    type Err = Error;
59
60    /// Parse a string containing the tmux window status into a new `Window`.
61    ///
62    /// This returns a `Result<Window, Error>` as this call can obviously
63    /// fail if provided an invalid format.
64    ///
65    /// The expected format of the tmux status is
66    ///
67    /// ```text
68    /// @1:0:true:035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}:'ignite':'pytorch'
69    /// @2:1:false:4438,334x85,0,0[334x41,0,0{167x41,0,0,4,166x41,168,0,5},334x43,0,42{167x43,0,42,6,166x43,168,42,7}]:'dates-attn':'pytorch'
70    /// @3:2:false:9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}:'th-bits':'pytorch'
71    /// @4:3:false:64ef,334x85,0,0,10:'docker-pytorch':'pytorch'
72    /// @5:0:true:64f0,334x85,0,0,11:'ben':'rust'
73    /// @6:1:false:64f1,334x85,0,0,12:'pyo3':'rust'
74    /// @7:2:false:64f2,334x85,0,0,13:'mdns-repeater':'rust'
75    /// @8:0:true:64f3,334x85,0,0,14:'combine':'swift'
76    /// @9:0:false:64f4,334x85,0,0,15:'copyrat':'tmux-hacking'
77    /// @10:1:false:ae3a,334x85,0,0[334x48,0,0,17,334x36,0,49{175x36,0,49,18,158x36,176,49,19}]:'mytui-app':'tmux-hacking'
78    /// @11:2:true:e2e2,334x85,0,0{175x85,0,0,20,158x85,176,0[158x42,176,0,21,158x42,176,43,27]}:'tmux-backup':'tmux-hacking'
79    /// ```
80    ///
81    /// This status line is obtained with
82    ///
83    /// ```text
84    /// tmux list-windows -a -F "#{window_id}:#{window_index}:#{?window_active,true,false}:#{window_layout}:'#{window_name}':'#{window_linked_sessions_list}'"
85    /// ```
86    ///
87    /// For definitions, look at `Window` type and the tmux man page for
88    /// definitions.
89    fn from_str(input: &str) -> std::result::Result<Self, Self::Err> {
90        let desc = "Window";
91        let intent = "##{window_id}:##{window_index}:##{?window_active,true,false}:##{window_layout}:'##{window_name}':'##{window_linked_sessions_list}'";
92
93        let (_, window) = all_consuming(parse::window)
94            .parse(input)
95            .map_err(|e| map_add_intent(desc, intent, e))?;
96
97        Ok(window)
98    }
99}
100
101impl Window {
102    /// Return all `PaneId` in this window.
103    pub fn pane_ids(&self) -> Vec<PaneId> {
104        let layout = layout::parse_window_layout(&self.layout).unwrap();
105        layout.pane_ids().iter().map(PaneId::from).collect()
106    }
107}
108
109pub(crate) mod parse {
110    use super::*;
111
112    pub(crate) fn window(input: &str) -> IResult<&str, Window> {
113        let (input, (id, _, index, _, is_active, _, layout, _, name, _, session_names)) = (
114            window_id,
115            char(':'),
116            map_res(digit1, str::parse),
117            char(':'),
118            boolean,
119            char(':'),
120            recognize(window_layout),
121            char(':'),
122            quoted_nonempty_string,
123            char(':'),
124            quoted_nonempty_string,
125        )
126            .parse(input)?;
127
128        Ok((
129            input,
130            Window {
131                id,
132                index,
133                is_active,
134                layout: layout.to_string(),
135                name: name.to_string(),
136                sessions: vec![session_names.to_string()],
137            },
138        ))
139    }
140}
141
142// ------------------------------
143// Ops
144// ------------------------------
145
146/// Return a list of all `Window` from all sessions.
147pub async fn available_windows() -> Result<Vec<Window>> {
148    let args = vec![
149        "list-windows",
150        "-a",
151        "-F",
152        "#{window_id}\
153        :#{window_index}\
154        :#{?window_active,true,false}\
155        :#{window_layout}\
156        :'#{window_name}'\
157        :'#{window_linked_sessions_list}'",
158    ];
159
160    let output = Command::new("tmux").args(&args).output().await?;
161    let buffer = String::from_utf8(output.stdout)?;
162
163    // Note: each call to the `Window::from_str` returns a `Result<Window, _>`.
164    // All results are then collected into a Result<Vec<Window>, _>, via
165    // `collect()`.
166    let result: Result<Vec<Window>> = buffer
167        .trim_end() // trim last '\n' as it would create an empty line
168        .split('\n')
169        .map(Window::from_str)
170        .collect();
171
172    result
173}
174
175/// Create a Tmux window in a session exactly named as the passed `session`.
176///
177/// The new window attributes:
178///
179/// - created in the `session`
180/// - the window name is taken from the passed `window`
181/// - the working directory is the pane's working directory.
182///
183pub async fn new_window(
184    session: &Session,
185    window: &Window,
186    pane: &Pane,
187    pane_command: Option<&str>,
188) -> Result<(WindowId, PaneId)> {
189    // Use session ID for targeting - it's unambiguous and immediately valid
190    // after session creation, unlike names which may have parsing issues
191    // (e.g., names containing colons) or brief lookup race conditions.
192    let target_session = session.id.as_str();
193
194    let mut args = vec![
195        "new-window",
196        "-d",
197        "-c",
198        pane.dirpath.to_str().unwrap(),
199        "-n",
200        &window.name,
201        "-t",
202        target_session,
203        "-P",
204        "-F",
205        "#{window_id}:#{pane_id}",
206    ];
207    if let Some(pane_command) = pane_command {
208        args.push(pane_command);
209    }
210
211    let output = Command::new("tmux").args(&args).output().await?;
212
213    // Check exit status before parsing to avoid confusing parse errors
214    // when tmux fails and returns empty/garbage stdout.
215    check_process_success(&output, "new-window")?;
216
217    let buffer = String::from_utf8(output.stdout)?;
218    let buffer = buffer.trim_end();
219
220    let desc = "new-window";
221    let intent = "##{window_id}:##{pane_id}";
222
223    let (_, (new_window_id, _, new_pane_id)) = all_consuming((window_id, char(':'), pane_id))
224        .parse(buffer)
225        .map_err(|e| map_add_intent(desc, intent, e))?;
226
227    Ok((new_window_id, new_pane_id))
228}
229
230/// Apply the provided `layout` to the window with `window_id`.
231pub async fn set_layout(layout: &str, window_id: &WindowId) -> Result<()> {
232    let args = vec!["select-layout", "-t", window_id.as_str(), layout];
233
234    let output = Command::new("tmux").args(&args).output().await?;
235    check_empty_process_output(&output, "select-layout")
236}
237
238/// Select (make active) the window with `window_id`.
239pub async fn select_window(window_id: &WindowId) -> Result<()> {
240    let args = vec!["select-window", "-t", window_id.as_str()];
241
242    let output = Command::new("tmux").args(&args).output().await?;
243    check_empty_process_output(&output, "select-window")
244}
245
246#[cfg(test)]
247mod tests {
248    use super::Window;
249    use super::WindowId;
250    use crate::Result;
251    use crate::pane_id::PaneId;
252    use std::str::FromStr;
253
254    #[test]
255    fn parse_list_sessions() {
256        let output = vec![
257            "@1:0:true:035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}:'ignite':'pytorch'",
258            "@2:1:false:4438,334x85,0,0[334x41,0,0{167x41,0,0,4,166x41,168,0,5},334x43,0,42{167x43,0,42,6,166x43,168,42,7}]:'dates-attn':'pytorch'",
259            "@3:2:false:9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}:'th-bits':'pytorch'",
260            "@4:3:false:64ef,334x85,0,0,10:'docker-pytorch':'pytorch'",
261            "@5:0:true:64f0,334x85,0,0,11:'ben':'rust'",
262            "@6:1:false:64f1,334x85,0,0,12:'pyo3':'rust'",
263            "@7:2:false:64f2,334x85,0,0,13:'mdns-repeater':'rust'",
264            "@8:0:true:64f3,334x85,0,0,14:'combine':'swift'",
265            "@9:0:false:64f4,334x85,0,0,15:'copyrat':'tmux-hacking'",
266            "@10:1:false:ae3a,334x85,0,0[334x48,0,0,17,334x36,0,49{175x36,0,49,18,158x36,176,49,19}]:'mytui-app':'tmux-hacking'",
267            "@11:2:true:e2e2,334x85,0,0{175x85,0,0,20,158x85,176,0[158x42,176,0,21,158x42,176,43,27]}:'tmux-backup':'tmux-hacking'",
268        ];
269        let sessions: Result<Vec<Window>> =
270            output.iter().map(|&line| Window::from_str(line)).collect();
271        let windows = sessions.expect("Could not parse tmux sessions");
272
273        let expected = vec![
274            Window {
275                id: WindowId::from_str("@1").unwrap(),
276                index: 0,
277                is_active: true,
278                layout: String::from(
279                    "035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}",
280                ),
281                name: String::from("ignite"),
282                sessions: vec![String::from("pytorch")],
283            },
284            Window {
285                id: WindowId::from_str("@2").unwrap(),
286                index: 1,
287                is_active: false,
288                layout: String::from(
289                    "4438,334x85,0,0[334x41,0,0{167x41,0,0,4,166x41,168,0,5},334x43,0,42{167x43,0,42,6,166x43,168,42,7}]",
290                ),
291                name: String::from("dates-attn"),
292                sessions: vec![String::from("pytorch")],
293            },
294            Window {
295                id: WindowId::from_str("@3").unwrap(),
296                index: 2,
297                is_active: false,
298                layout: String::from("9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}"),
299                name: String::from("th-bits"),
300                sessions: vec![String::from("pytorch")],
301            },
302            Window {
303                id: WindowId::from_str("@4").unwrap(),
304                index: 3,
305                is_active: false,
306                layout: String::from("64ef,334x85,0,0,10"),
307                name: String::from("docker-pytorch"),
308                sessions: vec![String::from("pytorch")],
309            },
310            Window {
311                id: WindowId::from_str("@5").unwrap(),
312                index: 0,
313                is_active: true,
314                layout: String::from("64f0,334x85,0,0,11"),
315                name: String::from("ben"),
316                sessions: vec![String::from("rust")],
317            },
318            Window {
319                id: WindowId::from_str("@6").unwrap(),
320                index: 1,
321                is_active: false,
322                layout: String::from("64f1,334x85,0,0,12"),
323                name: String::from("pyo3"),
324                sessions: vec![String::from("rust")],
325            },
326            Window {
327                id: WindowId::from_str("@7").unwrap(),
328                index: 2,
329                is_active: false,
330                layout: String::from("64f2,334x85,0,0,13"),
331                name: String::from("mdns-repeater"),
332                sessions: vec![String::from("rust")],
333            },
334            Window {
335                id: WindowId::from_str("@8").unwrap(),
336                index: 0,
337                is_active: true,
338                layout: String::from("64f3,334x85,0,0,14"),
339                name: String::from("combine"),
340                sessions: vec![String::from("swift")],
341            },
342            Window {
343                id: WindowId::from_str("@9").unwrap(),
344                index: 0,
345                is_active: false,
346                layout: String::from("64f4,334x85,0,0,15"),
347                name: String::from("copyrat"),
348                sessions: vec![String::from("tmux-hacking")],
349            },
350            Window {
351                id: WindowId::from_str("@10").unwrap(),
352                index: 1,
353                is_active: false,
354                layout: String::from(
355                    "ae3a,334x85,0,0[334x48,0,0,17,334x36,0,49{175x36,0,49,18,158x36,176,49,19}]",
356                ),
357                name: String::from("mytui-app"),
358                sessions: vec![String::from("tmux-hacking")],
359            },
360            Window {
361                id: WindowId::from_str("@11").unwrap(),
362                index: 2,
363                is_active: true,
364                layout: String::from(
365                    "e2e2,334x85,0,0{175x85,0,0,20,158x85,176,0[158x42,176,0,21,158x42,176,43,27]}",
366                ),
367                name: String::from("tmux-backup"),
368                sessions: vec![String::from("tmux-hacking")],
369            },
370        ];
371
372        assert_eq!(windows, expected);
373    }
374
375    #[test]
376    fn parse_window_single_pane() {
377        let input = "@5:0:true:64f0,334x85,0,0,11:'ben':'rust'";
378        let window = Window::from_str(input).expect("Should parse window with single pane");
379
380        assert_eq!(window.id, WindowId::from_str("@5").unwrap());
381        assert_eq!(window.index, 0);
382        assert!(window.is_active);
383        assert_eq!(window.name, "ben");
384        assert_eq!(window.sessions, vec!["rust".to_string()]);
385    }
386
387    #[test]
388    fn parse_window_with_large_index() {
389        let input = "@100:99:false:64f0,334x85,0,0,11:'test':'session'";
390        let window = Window::from_str(input).expect("Should parse window with large index");
391
392        assert_eq!(window.id, WindowId::from_str("@100").unwrap());
393        assert_eq!(window.index, 99);
394        assert!(!window.is_active);
395    }
396
397    #[test]
398    fn parse_window_fails_on_missing_id() {
399        let input = "0:true:64f0,334x85,0,0,11:'name':'session'";
400        let result = Window::from_str(input);
401
402        assert!(result.is_err());
403    }
404
405    #[test]
406    fn parse_window_fails_on_invalid_boolean() {
407        let input = "@1:0:yes:64f0,334x85,0,0,11:'name':'session'";
408        let result = Window::from_str(input);
409
410        assert!(result.is_err());
411    }
412
413    #[test]
414    fn parse_window_fails_on_empty_name() {
415        let input = "@1:0:true:64f0,334x85,0,0,11:'':'session'";
416        let result = Window::from_str(input);
417
418        assert!(result.is_err());
419    }
420
421    #[test]
422    fn window_pane_ids_single_pane() {
423        let window = Window {
424            id: WindowId::from_str("@1").unwrap(),
425            index: 0,
426            is_active: true,
427            layout: String::from("64f0,334x85,0,0,11"),
428            name: String::from("test"),
429            sessions: vec![String::from("session")],
430        };
431
432        let pane_ids = window.pane_ids();
433        assert_eq!(pane_ids.len(), 1);
434        assert_eq!(pane_ids[0], PaneId::from_str("%11").unwrap());
435    }
436
437    #[test]
438    fn window_pane_ids_multiple_panes() {
439        let window = Window {
440            id: WindowId::from_str("@3").unwrap(),
441            index: 2,
442            is_active: false,
443            layout: String::from("9e8b,334x85,0,0{167x85,0,0,8,166x85,168,0,9}"),
444            name: String::from("th-bits"),
445            sessions: vec![String::from("pytorch")],
446        };
447
448        let pane_ids = window.pane_ids();
449        assert_eq!(pane_ids.len(), 2);
450        assert_eq!(pane_ids[0], PaneId::from_str("%8").unwrap());
451        assert_eq!(pane_ids[1], PaneId::from_str("%9").unwrap());
452    }
453
454    #[test]
455    fn window_pane_ids_complex_layout() {
456        // Complex nested layout with 4 panes
457        let window = Window {
458            id: WindowId::from_str("@1").unwrap(),
459            index: 0,
460            is_active: true,
461            layout: String::from(
462                "035d,334x85,0,0{167x85,0,0,1,166x85,168,0[166x48,168,0,2,166x36,168,49,3]}",
463            ),
464            name: String::from("ignite"),
465            sessions: vec![String::from("pytorch")],
466        };
467
468        let pane_ids = window.pane_ids();
469        assert_eq!(pane_ids.len(), 3);
470        assert_eq!(pane_ids[0], PaneId::from_str("%1").unwrap());
471        assert_eq!(pane_ids[1], PaneId::from_str("%2").unwrap());
472        assert_eq!(pane_ids[2], PaneId::from_str("%3").unwrap());
473    }
474}