tmux_tui/
main.rs

1//! tmux-tui — a mouse-driven drag-and-drop layout manager for tmux.
2//!
3//! Two views, toggled with Tab (or the `[ windows ]` / `[ panes ]` button):
4//!
5//! * Pane mode — the current window's panes as a scaled schematic. Drag to
6//!   swap, click to select, `|`/`-` to split, `x` to kill, `1`..`5`/space to
7//!   change layout, right-click for a context menu.
8//! * Window mode (mission control) — every window as a tile drawn with its own
9//!   mini pane-layout. Drag a window onto another to reorder, drag a pane into
10//!   another window to relocate it, click to switch, right-click to
11//!   rename / create / close.
12//!
13//! You drag schematic boxes, not the live pane contents — true live-pane
14//! dragging is the thing tmux core itself can't do yet (tmux#3503).
15
16mod app;
17mod geometry;
18mod menu;
19mod tmux;
20mod ui;
21
22use std::io::{self, stdout, Stdout};
23
24use ratatui::backend::CrosstermBackend;
25use ratatui::crossterm::{
26    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
27    execute,
28    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
29};
30use ratatui::Terminal;
31
32use app::App;
33
34fn run(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
35    let mut app = App::new()?;
36    loop {
37        terminal.draw(|f| app.draw(f))?;
38
39        match event::read()? {
40            Event::Key(k) => {
41                // rename prompt intercepts keys (text input)
42                if app.prompt.is_some() {
43                    match k.code {
44                        KeyCode::Enter => app.confirm_rename()?,
45                        KeyCode::Esc => {
46                            app.prompt = None;
47                            app.status = "rename cancelled".into();
48                        }
49                        KeyCode::Backspace => {
50                            if let Some(p) = &mut app.prompt {
51                                p.buf.pop();
52                            }
53                        }
54                        KeyCode::Char(c) => {
55                            if let Some(p) = &mut app.prompt {
56                                p.buf.push(c);
57                            }
58                        }
59                        _ => {}
60                    }
61                    continue;
62                }
63                // confirm-kill intercepts keys
64                if app.pending_kill.is_some() {
65                    match k.code {
66                        KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
67                            app.confirm_kill()?
68                        }
69                        _ => {
70                            app.pending_kill = None;
71                            app.status = "cancelled".into();
72                        }
73                    }
74                    continue;
75                }
76                if app.show_help {
77                    app.show_help = false;
78                    continue;
79                }
80                if app.menu.is_some() {
81                    match k.code {
82                        KeyCode::Esc | KeyCode::Char('q') => app.menu = None,
83                        KeyCode::Up => {
84                            if let Some(menu) = &mut app.menu {
85                                menu.highlight = menu.highlight.saturating_sub(1);
86                            }
87                        }
88                        KeyCode::Down => {
89                            if let Some(menu) = &mut app.menu {
90                                if menu.highlight + 1 < menu.items.len() {
91                                    menu.highlight += 1;
92                                }
93                            }
94                        }
95                        KeyCode::Enter => {
96                            let h = app.menu.as_ref().map(|m| m.highlight).unwrap_or(0);
97                            app.activate_menu(h)?;
98                        }
99                        _ => {}
100                    }
101                    continue;
102                }
103                // Global keys, then per-mode dispatch.
104                match k.code {
105                    KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
106                    KeyCode::Char('?') => app.show_help = true,
107                    KeyCode::Tab => app.set_window_mode(!app.window_mode)?,
108                    KeyCode::Char('w') => app.set_window_mode(true)?,
109                    KeyCode::Char('p') => app.set_window_mode(false)?,
110                    KeyCode::Char('r') => {
111                        app.refresh()?;
112                        app.status = "refreshed".into();
113                    }
114                    KeyCode::Char('R') => {
115                        if let Some(id) = app.active_window_id() {
116                            app.open_rename(id);
117                        }
118                    }
119                    _ if app.window_mode => app.window_key(k)?,
120                    _ => app.pane_key(k)?,
121                }
122            }
123            Event::Mouse(m) => app.on_mouse(m)?,
124            _ => {}
125        }
126
127        if app.should_quit {
128            return Ok(());
129        }
130    }
131}
132
133fn main() -> io::Result<()> {
134    if std::env::var_os("TMUX").is_none() {
135        eprintln!("tmux-tui: not inside a tmux session ($TMUX unset). Run it from tmux.");
136        std::process::exit(1);
137    }
138
139    enable_raw_mode()?;
140    let mut out = stdout();
141    execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
142    let backend = CrosstermBackend::new(out);
143    let mut terminal = Terminal::new(backend)?;
144
145    let res = run(&mut terminal);
146
147    disable_raw_mode()?;
148    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
149    terminal.show_cursor()?;
150    res
151}