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