tmux_tui/
ui.rs

1//! Rendering. All drawing is implemented as methods on [`App`] so it can read
2//! state directly; no mutation happens here.
3
4use ratatui::layout::{Alignment, Rect};
5use ratatui::style::{Color, Modifier, Style};
6use ratatui::widgets::{Block, Borders, Clear, Paragraph};
7use ratatui::Frame;
8
9use crate::app::{App, WDrag};
10use crate::geometry::{contains, hit, map_rect, rects_for};
11
12impl App {
13    pub fn draw(&self, f: &mut Frame) {
14        let full = f.area();
15        self.last_full.set(full);
16        // Reserve row 0 for the top bar and the bottom two rows for the
17        // status + keys lines, so the canvas never sits under the chrome.
18        let canvas = Rect {
19            x: full.x,
20            y: full.y + 1,
21            width: full.width,
22            height: full.height.saturating_sub(4),
23        };
24        self.last_canvas.set(canvas);
25
26        f.render_widget(Block::default().style(Style::default().bg(Color::Black)), full);
27
28        if self.window_mode {
29            self.draw_windows(f, canvas);
30        } else {
31            self.draw_panes(f, canvas);
32        }
33
34        self.draw_chrome(f, full);
35    }
36
37    fn draw_panes(&self, f: &mut Frame, canvas: Rect) {
38        let rects = rects_for(&self.panes, canvas, self.win_w, self.win_h);
39        let hover = self.drag_src.and_then(|_| hit(&rects, self.cursor.0, self.cursor.1));
40        let sel_idx = self.selected_index();
41
42        for (i, r) in &rects {
43            let p = &self.panes[*i];
44            let is_src = Some(*i) == self.drag_src;
45            let is_hover = Some(*i) == hover && Some(*i) != self.drag_src;
46            let is_sel = Some(*i) == sel_idx;
47            let is_kill =
48                self.pending_kill.as_ref().is_some_and(|(id, is_win)| !*is_win && id == &p.id);
49
50            let border_style = if is_kill {
51                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
52            } else if is_src {
53                Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
54            } else if is_hover {
55                Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
56            } else if is_sel {
57                Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
58            } else if p.active {
59                Style::default().fg(Color::Cyan)
60            } else {
61                Style::default().fg(Color::DarkGray)
62            };
63
64            let mut block = Block::default()
65                .borders(Borders::ALL)
66                .border_style(border_style)
67                .title(format!(" {}:{} ", p.index, p.cmd));
68            if is_hover {
69                block = block.style(Style::default().bg(Color::Rgb(20, 60, 20)));
70            }
71            f.render_widget(block, *r);
72
73            if r.height >= 3 && r.width >= 6 {
74                let label = if is_kill {
75                    "kill?"
76                } else if is_src {
77                    "↕ moving"
78                } else if is_hover {
79                    "drop here"
80                } else if is_sel {
81                    "● selected"
82                } else {
83                    p.id.as_str()
84                };
85                let inner = Rect {
86                    x: r.x + 1,
87                    y: r.y + r.height / 2,
88                    width: r.width.saturating_sub(2),
89                    height: 1,
90                };
91                f.render_widget(Paragraph::new(label).alignment(Alignment::Center), inner);
92            }
93        }
94    }
95
96    fn draw_windows(&self, f: &mut Frame, canvas: Rect) {
97        let mut tiles = self.tiles.borrow_mut();
98        let mut mpanes = self.mpanes.borrow_mut();
99        tiles.clear();
100        mpanes.clear();
101
102        let n = self.windows.len() as u16;
103        if n == 0 {
104            return;
105        }
106        let cols = ((n as f32).sqrt().ceil() as u16).max(1);
107        let rows = n.div_ceil(cols);
108        let cell_w = (canvas.width / cols).max(3);
109        let cell_h = (canvas.height / rows.max(1)).max(3);
110
111        // tile rectangles first, so the hover target can be computed.
112        let mut tr: Vec<Rect> = Vec::with_capacity(self.windows.len());
113        for i in 0..self.windows.len() {
114            let ci = i as u16 % cols;
115            let ri = i as u16 / cols;
116            tr.push(Rect {
117                x: canvas.x + ci * cell_w + 1,
118                y: canvas.y + ri * cell_h,
119                width: cell_w.saturating_sub(2).max(2),
120                height: cell_h.saturating_sub(1).max(2),
121            });
122        }
123
124        let drag_win = match &self.wdrag {
125            Some(WDrag::Win(id)) => Some(id.clone()),
126            _ => None,
127        };
128        let drag_pane = match &self.wdrag {
129            Some(WDrag::Pane { pane, .. }) => Some(pane.clone()),
130            _ => None,
131        };
132        let hover_win = if self.wdrag.is_some() {
133            tr.iter()
134                .position(|r| contains(*r, self.cursor.0, self.cursor.1))
135                .map(|i| self.windows[i].id.clone())
136        } else {
137            None
138        };
139
140        for (i, tile) in tr.iter().enumerate() {
141            let w = &self.windows[i];
142            let is_drag = drag_win.as_deref() == Some(w.id.as_str());
143            let is_hover = hover_win.as_deref() == Some(w.id.as_str()) && !is_drag;
144            let is_sel = self.sel_win.as_deref() == Some(w.id.as_str());
145            let border = if is_drag {
146                Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
147            } else if is_hover {
148                Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
149            } else if is_sel {
150                Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
151            } else if w.active {
152                Style::default().fg(Color::Cyan)
153            } else {
154                Style::default().fg(Color::DarkGray)
155            };
156            let mut block = Block::default()
157                .borders(Borders::ALL)
158                .border_style(border)
159                .title(format!(" {}:{} ", w.index, w.name));
160            if is_hover {
161                block = block.style(Style::default().bg(Color::Rgb(20, 60, 20)));
162            }
163            f.render_widget(block, *tile);
164            tiles.push((w.id.clone(), *tile));
165
166            let inner = Rect {
167                x: tile.x + 1,
168                y: tile.y + 1,
169                width: tile.width.saturating_sub(2),
170                height: tile.height.saturating_sub(2),
171            };
172            if inner.width < 2 || inner.height < 1 {
173                continue;
174            }
175            for p in &w.panes {
176                let mr = map_rect(p, inner, w.w, w.h);
177                let pstyle = if drag_pane.as_deref() == Some(p.id.as_str()) {
178                    Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
179                } else if p.active {
180                    Style::default().fg(Color::Cyan)
181                } else {
182                    Style::default().fg(Color::DarkGray)
183                };
184                f.render_widget(Block::default().borders(Borders::ALL).border_style(pstyle), mr);
185                mpanes.push((w.id.clone(), p.id.clone(), mr));
186            }
187        }
188    }
189
190    fn draw_chrome(&self, f: &mut Frame, full: Rect) {
191        // status line
192        f.render_widget(
193            Paragraph::new(format!("  {}", self.status))
194                .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
195            Rect { x: full.x, y: full.bottom().saturating_sub(2), width: full.width, height: 1 },
196        );
197
198        // keys line (mode-dependent)
199        let keys = if self.window_mode {
200            "  arrows = navigate+switch · Enter = enter window · drag/⇧arrows = reorder · drag pane → window = move · right-click/R = rename · n · x   p panes · ?"
201        } else {
202            "  drag swap · arrows select · ⇧arrows move · |/- split · x kill · R rename   layout 1/2/3/4/5 · ␣ cycle   w windows · ? help · q quit"
203        };
204        f.render_widget(
205            Paragraph::new(keys).style(Style::default().fg(Color::Gray)),
206            Rect { x: full.x, y: full.bottom().saturating_sub(1), width: full.width, height: 1 },
207        );
208
209        // top bar: a reserved row carrying the buttons plus the current
210        // window name (centered), so nothing overlaps the canvas below.
211        let bar = Rect { x: full.x, y: full.y, width: full.width, height: 1 };
212        f.render_widget(Block::default().style(Style::default().bg(Color::Rgb(30, 30, 30))), bar);
213        if let Some(label) = self.active_window_label() {
214            f.render_widget(
215                Paragraph::new(label)
216                    .alignment(Alignment::Center)
217                    .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
218                bar,
219            );
220        }
221
222        // top-bar buttons (drawn over the bar)
223        f.render_widget(
224            Paragraph::new("[ quit ]").style(
225                Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD),
226            ),
227            self.quit_button_rect(),
228        );
229        let mode_label = if self.window_mode { "[ panes ]  " } else { "[ windows ]" };
230        f.render_widget(
231            Paragraph::new(mode_label).style(
232                Style::default().fg(Color::White).bg(Color::Blue).add_modifier(Modifier::BOLD),
233            ),
234            self.mode_button_rect(),
235        );
236        f.render_widget(
237            Paragraph::new("[ ? help ]").style(
238                Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD),
239            ),
240            self.help_button_rect(),
241        );
242
243        self.draw_menu(f);
244        self.draw_help(f);
245        self.draw_confirm(f);
246        self.draw_prompt(f);
247    }
248
249    fn draw_menu(&self, f: &mut Frame) {
250        let Some(menu) = &self.menu else { return };
251        let area = self.menu_rect(menu);
252        f.render_widget(Clear, area);
253        f.render_widget(
254            Block::default()
255                .borders(Borders::ALL)
256                .border_style(Style::default().fg(Color::White))
257                .title(format!(" {} ", menu.title)),
258            area,
259        );
260        for (i, (label, _)) in menu.items.iter().enumerate() {
261            if (i as u16) + 1 >= area.height.saturating_sub(1) {
262                break;
263            }
264            let row = Rect {
265                x: area.x + 1,
266                y: area.y + 1 + i as u16,
267                width: area.width.saturating_sub(2),
268                height: 1,
269            };
270            let style = if i == menu.highlight {
271                Style::default().fg(Color::Black).bg(Color::White)
272            } else {
273                Style::default().fg(Color::Gray)
274            };
275            f.render_widget(Paragraph::new(format!(" {label}")).style(style), row);
276        }
277    }
278
279    fn draw_help(&self, f: &mut Frame) {
280        if !self.show_help {
281            return;
282        }
283        let lines = [
284            "tmux-tui — tmux layout manager        w panes/windows · Tab toggle",
285            "",
286            "Pane mode",
287            "  drag a box / click          swap / select a pane",
288            "  arrows / Shift+arrows       select neighbour / move pane",
289            "  | -                         split left-right / top-bottom",
290            "  x                           kill selected pane",
291            "  1 2 3 4 5 / space           layout presets / cycle",
292            "  R                           rename the current window",
293            "  right-click                 New pane ▸ / Kill / Layout / Rename",
294            "",
295            "Window mode  (w, or [ windows ])",
296            "  arrows                      navigate + switch window live",
297            "  Enter                       enter the focused window (closes)",
298            "  drag window / Shift+arrows  reorder windows",
299            "  drag a pane into another    move pane across windows",
300            "  n / x / R                   new / close / rename window",
301            "  right-click                 Rename / New / Close window",
302            "",
303            "[ quit ] closes · drops are schematic, not live panes (tmux#3503)",
304            "press any key or click to close",
305        ];
306        let full = self.last_full.get();
307        let w = (lines.iter().map(|l| l.chars().count()).max().unwrap_or(40) as u16 + 4)
308            .min(full.width.max(1));
309        let h = (lines.len() as u16 + 2).min(full.height.max(1));
310        let area = Rect {
311            x: full.x + full.width.saturating_sub(w) / 2,
312            y: full.y + full.height.saturating_sub(h) / 2,
313            width: w,
314            height: h,
315        };
316        f.render_widget(Clear, area);
317        f.render_widget(
318            Block::default()
319                .borders(Borders::ALL)
320                .border_style(Style::default().fg(Color::Cyan))
321                .title(" help "),
322            area,
323        );
324        for (i, line) in lines.iter().enumerate() {
325            if (i as u16) + 1 >= area.height.saturating_sub(1) {
326                break;
327            }
328            let row = Rect {
329                x: area.x + 2,
330                y: area.y + 1 + i as u16,
331                width: area.width.saturating_sub(3),
332                height: 1,
333            };
334            f.render_widget(Paragraph::new(*line).style(Style::default().fg(Color::Gray)), row);
335        }
336    }
337
338    fn draw_confirm(&self, f: &mut Frame) {
339        let Some((id, is_win)) = &self.pending_kill else {
340            return;
341        };
342        let (area, yes, no) = self.confirm_rects();
343        let what = if *is_win { "window" } else { "pane" };
344        f.render_widget(Clear, area);
345        f.render_widget(
346            Block::default()
347                .borders(Borders::ALL)
348                .border_style(Style::default().fg(Color::Red))
349                .title(" confirm "),
350            area,
351        );
352        f.render_widget(
353            Paragraph::new(format!("Close {what} {id} ?"))
354                .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
355            Rect { x: area.x + 2, y: area.y + 1, width: area.width.saturating_sub(3), height: 1 },
356        );
357        f.render_widget(
358            Paragraph::new("[ Yes ]").style(
359                Style::default().fg(Color::White).bg(Color::Red).add_modifier(Modifier::BOLD),
360            ),
361            yes,
362        );
363        f.render_widget(
364            Paragraph::new("[ No ]").style(
365                Style::default().fg(Color::Black).bg(Color::White).add_modifier(Modifier::BOLD),
366            ),
367            no,
368        );
369    }
370
371    fn draw_prompt(&self, f: &mut Frame) {
372        let Some(p) = &self.prompt else {
373            return;
374        };
375        let (area, field, ok, cancel) = self.prompt_rects();
376        f.render_widget(Clear, area);
377        f.render_widget(
378            Block::default()
379                .borders(Borders::ALL)
380                .border_style(Style::default().fg(Color::Cyan))
381                .title(" rename window "),
382            area,
383        );
384        f.render_widget(
385            Paragraph::new(format!("window {}", p.win)).style(Style::default().fg(Color::DarkGray)),
386            Rect { x: area.x + 2, y: area.y + 1, width: area.width.saturating_sub(3), height: 1 },
387        );
388        f.render_widget(
389            Paragraph::new(format!("{}▏", p.buf))
390                .style(Style::default().fg(Color::White).bg(Color::Rgb(40, 40, 40))),
391            field,
392        );
393        f.render_widget(
394            Paragraph::new("[ OK ]").style(
395                Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD),
396            ),
397            ok,
398        );
399        f.render_widget(
400            Paragraph::new("[Cancel]").style(
401                Style::default().fg(Color::Black).bg(Color::White).add_modifier(Modifier::BOLD),
402            ),
403            cancel,
404        );
405    }
406}