tmux_layout/config/
model.rs

1use std::ops::{Deref, DerefMut};
2
3use super::includes::*;
4use serde::{de::DeserializeOwned, Deserialize, Serialize};
5
6pub type Config = ConfigL<NoIncludes>;
7pub type PartialConfig = ConfigL<FilePathIncludes>;
8
9type Cwd = crate::cwd::Cwd<'static>;
10
11#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(bound = "Includes: DeserializeOwned")]
13pub struct ConfigL<Includes: ConfigIncludes> {
14    #[serde(default, skip_serializing_if = "ConfigIncludes::is_empty")]
15    pub includes: Includes,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub selected_session: Option<String>,
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub sessions: Vec<Session>,
20    #[serde(default, skip_serializing_if = "Vec::is_empty")]
21    pub windows: Vec<Window>,
22}
23
24impl PartialConfig {
25    pub fn into_config(self) -> Result<Config, UnresolvedIncludes> {
26        if self.includes.is_empty() {
27            Ok(Config {
28                selected_session: self.selected_session,
29                sessions: self.sessions,
30                windows: self.windows,
31                includes: NoIncludes,
32            })
33        } else {
34            Err(UnresolvedIncludes)
35        }
36    }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
40pub struct Session {
41    pub name: String,
42    #[serde(skip_serializing_if = "Cwd::is_empty")]
43    pub cwd: Cwd,
44    pub windows: Vec<Window>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
48pub struct Window {
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub name: Option<String>,
51    #[serde(skip_serializing_if = "Cwd::is_empty")]
52    pub cwd: Cwd,
53    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
54    pub active: bool,
55    #[serde(flatten)]
56    pub root_split: RootSplit,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(from = "serialization::SplitMap", into = "serialization::SplitMap")]
61pub enum Split {
62    Pane(Pane),
63    H { left: HSplitPart, right: HSplitPart },
64    V { top: VSplitPart, bottom: VSplitPart },
65}
66
67impl Split {
68    pub fn into_root(self) -> RootSplit {
69        RootSplit(self)
70    }
71
72    pub fn single_pane(&self) -> Option<&Pane> {
73        match self {
74            Split::Pane(pane) => Some(pane),
75            _ => None,
76        }
77    }
78
79    pub fn single_pane_mut(&mut self) -> Option<&mut Pane> {
80        match self {
81            Split::Pane(pane) => Some(pane),
82            _ => None,
83        }
84    }
85
86    pub fn pane_iter(&self) -> Panes {
87        Panes::new(self)
88    }
89
90    pub fn pane_iter_mut(&mut self) -> PanesMut {
91        PanesMut::new(self)
92    }
93}
94
95impl Default for Split {
96    fn default() -> Self {
97        Split::Pane(Pane::default())
98    }
99}
100
101#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(from = "serialization::SplitMap", into = "serialization::SplitMap")]
103#[repr(transparent)]
104pub struct RootSplit(Split);
105
106impl Deref for RootSplit {
107    type Target = Split;
108
109    fn deref(&self) -> &Self::Target {
110        &self.0
111    }
112}
113
114impl DerefMut for RootSplit {
115    fn deref_mut(&mut self) -> &mut Self::Target {
116        &mut self.0
117    }
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
121pub struct HSplitPart {
122    #[serde(skip_serializing_if = "serialization::is_default_size")]
123    pub width: Option<String>,
124    #[serde(flatten)]
125    pub split: Box<Split>,
126}
127
128#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
129pub struct VSplitPart {
130    #[serde(skip_serializing_if = "serialization::is_default_size")]
131    pub height: Option<String>,
132    #[serde(flatten)]
133    pub split: Box<Split>,
134}
135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
136pub struct Pane {
137    #[serde(skip_serializing_if = "Cwd::is_empty")]
138    pub cwd: Cwd,
139    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
140    pub active: bool,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub shell_command: Option<String>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub send_keys: Option<Vec<String>>,
145}
146
147/// Iterates panes in tmux index order.
148pub struct Panes<'a> {
149    stack: Vec<&'a Split>,
150}
151
152impl<'a> Panes<'a> {
153    pub fn new(root: &'a Split) -> Self {
154        Self { stack: vec![root] }
155    }
156}
157
158impl<'a> Iterator for Panes<'a> {
159    type Item = &'a Pane;
160
161    fn next(&mut self) -> Option<Self::Item> {
162        let split = self.stack.pop()?;
163        match split {
164            Split::Pane(pane) => Some(pane),
165            Split::H { left, right } => {
166                self.stack.push(&right.split);
167                self.stack.push(&left.split);
168                self.next()
169            }
170            Split::V { top, bottom } => {
171                self.stack.push(&bottom.split);
172                self.stack.push(&top.split);
173                self.next()
174            }
175        }
176    }
177}
178
179/// Iterates panes in tmux index order (mutable).
180pub struct PanesMut<'a> {
181    stack: Vec<&'a mut Split>,
182}
183
184impl<'a> PanesMut<'a> {
185    pub fn new(root: &'a mut Split) -> Self {
186        Self { stack: vec![root] }
187    }
188}
189
190impl<'a> Iterator for PanesMut<'a> {
191    type Item = &'a mut Pane;
192
193    fn next(&mut self) -> Option<Self::Item> {
194        let split = self.stack.pop()?;
195        match split {
196            Split::Pane(pane) => Some(pane),
197            Split::H { left, right } => {
198                self.stack.push(&mut right.split);
199                self.stack.push(&mut left.split);
200                self.next()
201            }
202            Split::V { top, bottom } => {
203                self.stack.push(&mut bottom.split);
204                self.stack.push(&mut top.split);
205                self.next()
206            }
207        }
208    }
209}
210
211pub(super) mod serialization {
212    use super::*;
213    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
214    pub(super) struct SplitMap {
215        #[serde(skip_serializing_if = "Option::is_none")]
216        pub(super) left: Option<HSplitPart>,
217        #[serde(skip_serializing_if = "Option::is_none")]
218        pub(super) right: Option<HSplitPart>,
219        #[serde(skip_serializing_if = "Option::is_none")]
220        pub(super) top: Option<VSplitPart>,
221        #[serde(skip_serializing_if = "Option::is_none")]
222        pub(super) bottom: Option<VSplitPart>,
223        #[serde(skip_serializing_if = "Cwd::is_empty")]
224        pub(super) cwd: Cwd,
225        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
226        pub active: bool,
227        #[serde(skip_serializing_if = "Option::is_none")]
228        pub(super) shell_command: Option<String>,
229        #[serde(skip_serializing_if = "Option::is_none")]
230        pub(super) send_keys: Option<Vec<String>>,
231    }
232
233    impl From<SplitMap> for Split {
234        fn from(map: SplitMap) -> Self {
235            if map.left.is_some() || map.right.is_some() {
236                return Split::H {
237                    left: map.left.unwrap_or_default(),
238                    right: map.right.unwrap_or_default(),
239                };
240            }
241
242            if map.top.is_some() || map.bottom.is_some() {
243                return Split::V {
244                    top: map.top.unwrap_or_default(),
245                    bottom: map.bottom.unwrap_or_default(),
246                };
247            }
248
249            Split::Pane(Pane {
250                cwd: map.cwd,
251                active: map.active,
252                shell_command: map.shell_command,
253                send_keys: map.send_keys,
254            })
255        }
256    }
257
258    impl From<Split> for SplitMap {
259        fn from(split: Split) -> Self {
260            match split {
261                Split::Pane(pane) => Self {
262                    cwd: pane.cwd,
263                    active: pane.active,
264                    shell_command: pane.shell_command,
265                    send_keys: pane.send_keys,
266                    ..Default::default()
267                },
268                Split::H { left, right } => Self {
269                    left: Some(left),
270                    right: Some(right),
271                    ..Default::default()
272                },
273                Split::V { top, bottom } => Self {
274                    top: Some(top),
275                    bottom: Some(bottom),
276                    ..Default::default()
277                },
278            }
279        }
280    }
281
282    impl From<RootSplit> for SplitMap {
283        fn from(mut root: RootSplit) -> Self {
284            // Avoid rendering the `active` property for single root panes.
285            // While unneccessary, it also leads to ambiguity in the config file.
286            // The `active` property of a single root pane would be interpreted
287            // as the containing window's active state.
288            if let Some(single_pane) = root.single_pane_mut() {
289                single_pane.active = false;
290            }
291            root.0.into()
292        }
293    }
294
295    impl From<SplitMap> for RootSplit {
296        fn from(map: SplitMap) -> Self {
297            Split::from(map).into_root()
298        }
299    }
300
301    pub(super) fn is_default_size(size: &Option<String>) -> bool {
302        match size {
303            None => true,
304            Some(size) => size == "50%",
305        }
306    }
307}
308
309#[cfg(test)]
310mod test {
311    use crate::config::{model::Cwd, HSplitPart, Pane, Session, Split, VSplitPart, Window};
312
313    use super::PartialConfig;
314
315    #[test]
316    fn test_single_window_config() {
317        let config_str = include_str!(concat!(
318            env!("CARGO_MANIFEST_DIR"),
319            "/examples/config/single-window.toml"
320        ));
321        let config = toml::from_str::<PartialConfig>(config_str).unwrap();
322
323        assert_eq!(
324            config,
325            PartialConfig {
326                includes: Default::default(),
327                selected_session: None,
328                sessions: vec![],
329                windows: vec![Window {
330                    name: Some("A new window".to_string()),
331                    cwd: "/tmp".into(),
332                    active: false,
333                    root_split: Split::H {
334                        left: HSplitPart {
335                            width: None,
336                            split: Box::new(Split::Pane(Pane {
337                                cwd: shellexpand::full("~").unwrap().into_owned().into(),
338                                shell_command: Some("bash".to_string()),
339                                ..Default::default()
340                            })),
341                        },
342                        right: HSplitPart {
343                            width: None,
344                            split: Box::new(Split::Pane(Pane {
345                                cwd: shellexpand::full("~/Downloads")
346                                    .unwrap()
347                                    .into_owned()
348                                    .into(),
349                                ..Default::default()
350                            }))
351                        }
352                    }
353                    .into_root(),
354                }],
355            }
356        );
357    }
358
359    #[test]
360    fn test_layout_config_yaml() {
361        let config_str = include_str!(concat!(
362            env!("CARGO_MANIFEST_DIR"),
363            "/examples/config/.tmux-layout.yml"
364        ));
365        let config = serde_yaml::from_str::<PartialConfig>(config_str).unwrap();
366
367        assert!(config.includes.0.is_empty());
368        assert_eq!(config.sessions.len(), 2);
369        assert_eq!(config.selected_session.as_deref(), Some("sess1"));
370        assert!(config.windows.is_empty());
371
372        let sess1 = &config.sessions[0];
373        assert_eq!(sess1.name, "sess1");
374        assert_eq!(sess1.cwd, shellexpand::full("~").unwrap().as_ref());
375        assert_eq!(sess1.windows.len(), 2);
376
377        let win1 = &sess1.windows[0];
378        assert_eq!(win1.name.as_deref(), Some("win1"));
379        assert!(win1.active);
380        assert_eq!(win1.cwd, "code");
381
382        let split: &Split = &win1.root_split;
383        let Split::H { left, right } = split else {
384            panic!("expected horizontal split");
385        };
386
387        assert!(left.width.is_none());
388        assert_eq!(right.width.as_deref(), Some("66%"));
389
390        let left_split: &Split = &left.split;
391        let Split::V { top, bottom } = left_split else {
392            panic!("expected vertical split");
393        };
394
395        assert!(top.height.is_none());
396        assert!(bottom.height.is_none());
397
398        let top_pane = top.split.single_pane().unwrap();
399        assert_eq!(
400            top_pane,
401            &Pane {
402                cwd: "projects".into(),
403                ..Default::default()
404            }
405        );
406
407        let bot_pane = bottom.split.single_pane().unwrap();
408        assert_eq!(
409            bot_pane,
410            &Pane {
411                cwd: "scratch".into(),
412                ..Default::default()
413            }
414        );
415
416        let right_split: &Split = &right.split;
417        let Split::V { top, bottom } = right_split else {
418            panic!("expected vertical split");
419        };
420
421        assert!(top.height.is_none());
422        assert!(bottom.height.is_none());
423
424        assert_eq!(top.split.single_pane().unwrap(), &Pane::default());
425        assert_eq!(
426            bottom.split.single_pane().unwrap(),
427            &Pane {
428                cwd: "projects/tmux-layout".into(),
429                ..Default::default()
430            }
431        );
432
433        assert_eq!(
434            sess1.windows[1],
435            Window {
436                name: Some("win2".to_string()),
437                active: false,
438                cwd: ".zsh".into(),
439                root_split: Split::H {
440                    left: HSplitPart {
441                        width: None,
442                        split: Box::new(Split::Pane(Pane {
443                            cwd: shellexpand::full("$JAVA_HOME").unwrap().into_owned().into(),
444                            ..Default::default()
445                        })),
446                    },
447                    right: HSplitPart::default(),
448                }
449                .into_root(),
450            }
451        );
452
453        let sess2 = &config.sessions[1];
454        assert_eq!(
455            sess2,
456            &Session {
457                name: "sess2".to_string(),
458                cwd: Cwd::new(None),
459                windows: vec![Window {
460                    name: None,
461                    active: false,
462                    cwd: Cwd::new(None),
463                    root_split: Split::H {
464                        left: HSplitPart {
465                            width: Some("20%".to_string()),
466                            split: Box::new(Split::Pane(Pane {
467                                send_keys: Some(vec!["ls -al".to_string(), "ENTER".to_string()]),
468                                ..Default::default()
469                            })),
470                        },
471                        right: HSplitPart {
472                            width: None,
473                            split: Box::new(Split::Pane(Pane {
474                                shell_command: Some("bash".to_string()),
475                                ..Default::default()
476                            }),),
477                        }
478                    }
479                    .into_root(),
480                }],
481            }
482        );
483    }
484
485    #[test]
486    fn test_layout_config_toml() {
487        let config_str = include_str!(concat!(
488            env!("CARGO_MANIFEST_DIR"),
489            "/examples/config/.tmux-layout.toml"
490        ));
491        let config = toml::from_str::<PartialConfig>(config_str).unwrap();
492
493        assert_eq!(
494            config,
495            PartialConfig {
496                includes: Default::default(),
497                selected_session: Some("sess1".to_string()),
498                windows: vec![],
499                sessions: vec![
500                    Session {
501                        name: "sess1".to_string(),
502                        cwd: shellexpand::full("~").unwrap().into_owned().into(),
503                        windows: vec![
504                            Window {
505                                name: Some("win1".to_string()),
506                                cwd: "code".into(),
507                                active: true,
508                                root_split: Split::H {
509                                    left: HSplitPart {
510                                        width: None,
511                                        split: Box::new(Split::V {
512                                            top: VSplitPart {
513                                                height: None,
514                                                split: Box::new(Split::Pane(Pane {
515                                                    cwd: "projects".into(),
516                                                    ..Default::default()
517                                                })),
518                                            },
519                                            bottom: VSplitPart {
520                                                height: None,
521                                                split: Box::new(Split::Pane(Pane {
522                                                    cwd: "scratch".into(),
523                                                    ..Default::default()
524                                                })),
525                                            },
526                                        })
527                                    },
528                                    right: HSplitPart {
529                                        width: None,
530                                        split: Box::new(Split::V {
531                                            top: VSplitPart {
532                                                height: None,
533                                                split: Box::new(Split::Pane(Pane::default())),
534                                            },
535                                            bottom: VSplitPart {
536                                                height: None,
537                                                split: Box::new(Split::Pane(Pane {
538                                                    cwd: "projects/tmux-layout".into(),
539                                                    send_keys: Some(vec![
540                                                        "g".to_string(),
541                                                        "ENTER".to_string()
542                                                    ]),
543                                                    ..Default::default()
544                                                })),
545                                            },
546                                        })
547                                    }
548                                }
549                                .into_root(),
550                            },
551                            Window {
552                                name: Some("win2".to_string()),
553                                active: false,
554                                cwd: ".zsh".into(),
555                                root_split: Split::H {
556                                    left: HSplitPart {
557                                        width: Some("33%".to_string()),
558                                        split: Box::new(Split::Pane(Pane {
559                                            cwd: shellexpand::full("$JAVA_HOME")
560                                                .unwrap()
561                                                .into_owned()
562                                                .into(),
563                                            ..Default::default()
564                                        })),
565                                    },
566                                    right: HSplitPart {
567                                        width: None,
568                                        split: Box::new(Split::Pane(Pane::default())),
569                                    }
570                                }
571                                .into_root(),
572                            },
573                        ]
574                    },
575                    Session {
576                        name: "sess2".to_string(),
577                        cwd: Cwd::new(None),
578                        windows: vec![Window {
579                            name: None,
580                            active: false,
581                            cwd: Cwd::new(None),
582                            root_split: Split::H {
583                                left: HSplitPart {
584                                    width: None,
585                                    split: Box::new(Split::Pane(Pane {
586                                        send_keys: Some(vec![
587                                            "ls -al".to_string(),
588                                            "ENTER".to_string()
589                                        ]),
590                                        ..Default::default()
591                                    })),
592                                },
593                                right: HSplitPart {
594                                    width: Some("120".to_string()),
595                                    split: Box::new(Split::Pane(Pane {
596                                        shell_command: Some("bash".to_string()),
597                                        ..Default::default()
598                                    })),
599                                },
600                            }
601                            .into_root(),
602                        }],
603                    }
604                ],
605            }
606        );
607    }
608
609    #[test]
610    fn test_config_serde_roundtrip() {
611        let config_str = include_str!(concat!(
612            env!("CARGO_MANIFEST_DIR"),
613            "/examples/config/.tmux-layout.yml"
614        ));
615        let config = serde_yaml::from_str::<PartialConfig>(config_str)
616            .unwrap()
617            .into_config()
618            .unwrap();
619
620        let serialized = serde_yaml::to_string(&config).unwrap();
621        let parsed = serde_yaml::from_str::<PartialConfig>(&serialized)
622            .unwrap()
623            .into_config()
624            .unwrap();
625
626        assert_eq!(config, parsed);
627    }
628
629    #[test]
630    fn test_config_serde_cross_format() {
631        let config_str = include_str!(concat!(
632            env!("CARGO_MANIFEST_DIR"),
633            "/examples/config/.tmux-layout.yml"
634        ));
635        let config = serde_yaml::from_str::<PartialConfig>(config_str)
636            .unwrap()
637            .into_config()
638            .unwrap();
639
640        let serialized = toml::to_string(&config).unwrap();
641        let parsed = toml::from_str::<PartialConfig>(&serialized)
642            .unwrap()
643            .into_config()
644            .unwrap();
645
646        assert_eq!(config, parsed);
647    }
648}