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
147pub 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
179pub 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 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}