1use std::cell::{Cell, RefCell};
5use std::io;
6
7use ratatui::crossterm::event::{
8 KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
9};
10use ratatui::layout::Rect;
11
12use crate::geometry::{contains, hit, mpane_at, rects_for, tile_at};
13use crate::menu::{build_menu_items, Action, Menu};
14use crate::tmux::{self, Pane, Win};
15
16pub enum WDrag {
18 Win(String),
20 Pane { win: String, pane: String },
22}
23
24pub struct Prompt {
26 pub win: String,
27 pub buf: String,
28}
29
30pub struct App {
31 pub win_w: u16,
33 pub win_h: u16,
34 pub panes: Vec<Pane>,
35 pub drag_src: Option<usize>,
36 pub selected: Option<String>, pub windows: Vec<Win>,
39 pub window_mode: bool,
40 pub wdrag: Option<WDrag>,
41 pub sel_win: Option<String>, pub tiles: RefCell<Vec<(String, Rect)>>, pub mpanes: RefCell<Vec<(String, String, Rect)>>, pub pending_kill: Option<(String, bool)>, pub prompt: Option<Prompt>,
47 pub menu: Option<Menu>,
48 pub show_help: bool,
49 pub should_quit: bool,
50 pub cursor: (u16, u16),
51 pub status: String,
52 pub last_canvas: Cell<Rect>,
53 pub last_full: Cell<Rect>,
54}
55
56impl App {
57 pub fn new() -> io::Result<Self> {
58 let (win_w, win_h, panes) = tmux::query()?;
59 Ok(Self {
60 win_w,
61 win_h,
62 panes,
63 drag_src: None,
64 selected: None,
65 windows: tmux::query_windows().unwrap_or_default(),
66 window_mode: false,
67 wdrag: None,
68 sel_win: None,
69 tiles: RefCell::new(Vec::new()),
70 mpanes: RefCell::new(Vec::new()),
71 pending_kill: None,
72 prompt: None,
73 menu: None,
74 show_help: false,
75 should_quit: false,
76 cursor: (0, 0),
77 status: "ready".into(),
78 last_canvas: Cell::new(Rect::default()),
79 last_full: Cell::new(Rect::default()),
80 })
81 }
82
83 pub fn refresh(&mut self) -> io::Result<()> {
84 let (w, h, p) = tmux::query()?;
85 self.win_w = w;
86 self.win_h = h;
87 self.panes = p;
88 self.windows = tmux::query_windows().unwrap_or_default();
89 self.drag_src = None;
90 self.wdrag = None;
91 if let Some(sel) = &self.selected {
92 if !self.panes.iter().any(|p| &p.id == sel) {
93 self.selected = None;
94 }
95 }
96 if let Some(sw) = &self.sel_win {
97 if !self.windows.iter().any(|w| &w.id == sw) {
98 self.sel_win = None;
99 }
100 }
101 Ok(())
102 }
103
104 pub fn selected_index(&self) -> Option<usize> {
105 self.selected.as_ref().and_then(|id| self.panes.iter().position(|p| &p.id == id))
106 }
107
108 fn target(&self) -> Option<String> {
110 self.selected.clone().or_else(|| self.panes.iter().find(|p| p.active).map(|p| p.id.clone()))
111 }
112
113 pub fn active_window_id(&self) -> Option<String> {
115 self.windows.iter().find(|w| w.active).map(|w| w.id.clone())
116 }
117
118 pub fn active_window_label(&self) -> Option<String> {
120 self.windows.iter().find(|w| w.active).map(|w| format!(" {}:{} ", w.index, w.name))
121 }
122
123 pub fn split(&mut self, horizontal: bool) -> io::Result<()> {
126 let dir = if horizontal { "-h" } else { "-v" };
127 self.new_pane(dir, false)
128 }
129
130 pub fn new_pane(&mut self, flag: &str, before: bool) -> io::Result<()> {
133 let Some(t) = self.target() else {
134 self.status = "no pane to split from".into();
135 return Ok(());
136 };
137 match tmux::split(&t, flag, before)? {
138 Ok(new_id) => {
139 self.refresh()?;
140 if !new_id.is_empty() {
141 self.selected = Some(new_id);
142 }
143 self.status = "new pane".into();
144 }
145 Err(e) => self.status = format!("split failed: {e}"),
146 }
147 Ok(())
148 }
149
150 pub fn request_kill(&mut self) {
151 if self.panes.len() <= 1 {
152 self.status = "only one pane — refusing to kill the last one".into();
153 return;
154 }
155 match self.target() {
156 Some(id) => {
157 self.status = format!("kill {id}? Yes / No");
158 self.pending_kill = Some((id, false));
159 }
160 None => self.status = "no pane selected".into(),
161 }
162 }
163
164 pub fn set_layout(&mut self, name: &str, label: &str) -> io::Result<()> {
165 match tmux::select_layout(name)? {
166 Ok(_) => {
167 self.refresh()?;
168 self.status = format!("layout: {label}");
169 }
170 Err(e) => self.status = format!("layout failed: {e}"),
171 }
172 Ok(())
173 }
174
175 pub fn cycle_layout(&mut self) -> io::Result<()> {
176 tmux::next_layout()?;
177 self.refresh()?;
178 self.status = "cycled layout".into();
179 Ok(())
180 }
181
182 pub fn select_window(&mut self, id: &str) -> io::Result<()> {
185 tmux::select_window(id)?;
186 self.refresh()?;
187 self.sel_win = Some(id.to_string());
188 self.status = format!("window {id}");
189 Ok(())
190 }
191
192 pub fn swap_windows(&mut self, a: &str, b: &str) -> io::Result<()> {
193 tmux::swap_window(a, b)?;
194 self.refresh()?;
195 self.status = format!("reordered {a} ⇄ {b}");
196 Ok(())
197 }
198
199 pub fn move_pane_to_window(&mut self, pane: &str, win: &str) -> io::Result<()> {
200 match tmux::join_pane(pane, win)? {
201 Ok(_) => {
202 self.refresh()?;
203 self.status = format!("moved {pane} → window {win}");
204 }
205 Err(e) => self.status = format!("move failed: {e}"),
206 }
207 Ok(())
208 }
209
210 pub fn new_window(&mut self) -> io::Result<()> {
211 tmux::new_window()?;
212 self.refresh()?;
213 self.status = "new window".into();
214 Ok(())
215 }
216
217 pub fn request_kill_window(&mut self, id: String) {
218 if self.windows.len() <= 1 {
219 self.status = "only one window — refusing to close the last one".into();
220 return;
221 }
222 self.status = format!("close window {id}? Yes / No");
223 self.pending_kill = Some((id, true));
224 }
225
226 pub fn open_rename(&mut self, id: String) {
227 let name =
228 self.windows.iter().find(|w| w.id == id).map(|w| w.name.clone()).unwrap_or_default();
229 self.status = "rename: type a name, Enter to confirm".into();
230 self.prompt = Some(Prompt { win: id, buf: name });
231 }
232
233 pub fn confirm_rename(&mut self) -> io::Result<()> {
234 if let Some(p) = self.prompt.take() {
235 if !p.buf.is_empty() {
236 tmux::rename_window(&p.win, &p.buf)?;
237 self.status = format!("renamed → {}", p.buf);
238 } else {
239 self.status = "rename cancelled (empty)".into();
240 }
241 self.refresh()?;
242 }
243 Ok(())
244 }
245
246 pub fn confirm_kill(&mut self) -> io::Result<()> {
249 if let Some((id, is_win)) = self.pending_kill.take() {
250 if is_win {
251 tmux::kill_window(&id)?;
252 if self.sel_win.as_deref() == Some(id.as_str()) {
253 self.sel_win = None;
254 }
255 self.status = format!("closed window {id}");
256 } else {
257 tmux::kill_pane(&id)?;
258 if self.selected.as_deref() == Some(id.as_str()) {
259 self.selected = None;
260 }
261 self.status = format!("killed {id}");
262 }
263 self.refresh()?;
264 }
265 Ok(())
266 }
267
268 pub fn open_menu(&mut self, name: &str, x: u16, y: u16, target: Option<String>) {
271 let (title, items) = build_menu_items(name, &target, self.panes.len(), self.windows.len());
272 self.menu = Some(Menu { x, y, target, title, items, highlight: 0 });
273 }
274
275 pub fn menu_rect(&self, menu: &Menu) -> Rect {
276 let full = self.last_full.get();
277 let label_w = menu.items.iter().map(|(l, _)| l.chars().count()).max().unwrap_or(10) as u16;
278 let w = (label_w + 4).min(full.width.max(1));
279 let h = (menu.items.len() as u16 + 2).min(full.height.max(1));
280 let x = menu.x.min(full.right().saturating_sub(w));
281 let y = menu.y.min(full.bottom().saturating_sub(h));
282 Rect { x, y, width: w, height: h }
283 }
284
285 pub fn menu_item_at(&self, col: u16, row: u16) -> Option<usize> {
286 let menu = self.menu.as_ref()?;
287 let area = self.menu_rect(menu);
288 if !contains(area, col, row) {
289 return None;
290 }
291 let i = row.checked_sub(area.y + 1)? as usize;
292 (i < menu.items.len()).then_some(i)
293 }
294
295 pub fn activate_menu(&mut self, idx: usize) -> io::Result<()> {
296 let Some(menu) = self.menu.as_ref() else {
297 return Ok(());
298 };
299 let Some((_, action)) = menu.items.get(idx).cloned() else {
300 return Ok(());
301 };
302 let target = menu.target.clone();
303
304 if let Action::Submenu(name) = action {
306 let (title, items) =
307 build_menu_items(name, &target, self.panes.len(), self.windows.len());
308 if let Some(menu) = &mut self.menu {
309 menu.title = title;
310 menu.items = items;
311 menu.highlight = 0;
312 }
313 return Ok(());
314 }
315
316 self.menu = None;
317 match action {
318 Action::Kill => {
319 self.selected = target;
320 self.request_kill();
321 }
322 Action::Layout(name, label) => self.set_layout(name, label)?,
323 Action::NewPane(flag, before) => {
324 self.selected = target;
325 self.new_pane(flag, before)?;
326 }
327 Action::RenameWindow => {
328 let id = if self.window_mode { target } else { self.active_window_id() };
331 if let Some(id) = id {
332 self.open_rename(id);
333 }
334 }
335 Action::NewWindow => self.new_window()?,
336 Action::KillWindow => {
337 if let Some(t) = target {
338 self.request_kill_window(t);
339 }
340 }
341 Action::Submenu(_) => unreachable!(),
342 }
343 Ok(())
344 }
345
346 pub fn quit_button_rect(&self) -> Rect {
349 let full = self.last_full.get();
350 let w = 8u16.min(full.width.max(1));
351 Rect { x: full.x, y: full.y, width: w, height: 1 }
352 }
353
354 pub fn mode_button_rect(&self) -> Rect {
355 let full = self.last_full.get();
356 let w = 11u16.min(full.width.max(1));
357 let x = (full.x + 9).min(full.right().saturating_sub(w));
358 Rect { x, y: full.y, width: w, height: 1 }
359 }
360
361 pub fn help_button_rect(&self) -> Rect {
362 let full = self.last_full.get();
363 let w = 10u16.min(full.width.max(1));
364 Rect { x: full.right().saturating_sub(w), y: full.y, width: w, height: 1 }
365 }
366
367 pub fn confirm_rects(&self) -> (Rect, Rect, Rect) {
369 let full = self.last_full.get();
370 let w = 34u16.min(full.width.max(1));
371 let h = 5u16.min(full.height.max(1));
372 let area = Rect {
373 x: full.x + full.width.saturating_sub(w) / 2,
374 y: full.y + full.height.saturating_sub(h) / 2,
375 width: w,
376 height: h,
377 };
378 let by = area.y + area.height.saturating_sub(2);
379 let yes = Rect { x: area.x + 4, y: by, width: 7, height: 1 };
380 let no = Rect { x: area.x + area.width.saturating_sub(10), y: by, width: 6, height: 1 };
381 (area, yes, no)
382 }
383
384 pub fn prompt_rects(&self) -> (Rect, Rect, Rect, Rect) {
386 let full = self.last_full.get();
387 let w = 44u16.min(full.width.max(1));
388 let h = 6u16.min(full.height.max(1));
389 let area = Rect {
390 x: full.x + full.width.saturating_sub(w) / 2,
391 y: full.y + full.height.saturating_sub(h) / 2,
392 width: w,
393 height: h,
394 };
395 let field =
396 Rect { x: area.x + 2, y: area.y + 2, width: area.width.saturating_sub(4), height: 1 };
397 let by = area.y + area.height.saturating_sub(2);
398 let ok = Rect { x: area.x + 4, y: by, width: 6, height: 1 };
399 let cancel =
400 Rect { x: area.x + area.width.saturating_sub(12), y: by, width: 10, height: 1 };
401 (area, field, ok, cancel)
402 }
403
404 pub fn on_mouse(&mut self, m: MouseEvent) -> io::Result<()> {
407 self.cursor = (m.column, m.row);
408 let left_down = matches!(m.kind, MouseEventKind::Down(MouseButton::Left));
409
410 if self.prompt.is_some() {
412 if left_down {
413 let (_, _, ok, cancel) = self.prompt_rects();
414 if contains(ok, m.column, m.row) {
415 self.confirm_rename()?;
416 } else if contains(cancel, m.column, m.row) {
417 self.prompt = None;
418 self.status = "rename cancelled".into();
419 }
420 }
421 return Ok(());
422 }
423
424 if self.pending_kill.is_some() {
426 if left_down {
427 let (_, yes, no) = self.confirm_rects();
428 if contains(yes, m.column, m.row) {
429 self.confirm_kill()?;
430 } else if contains(no, m.column, m.row) {
431 self.pending_kill = None;
432 self.status = "cancelled".into();
433 }
434 }
435 return Ok(());
436 }
437
438 if self.show_help {
440 if left_down {
441 self.show_help = false;
442 }
443 return Ok(());
444 }
445
446 if left_down && contains(self.help_button_rect(), m.column, m.row) {
448 self.show_help = true;
449 self.menu = None;
450 return Ok(());
451 }
452 if left_down && contains(self.quit_button_rect(), m.column, m.row) {
453 self.should_quit = true;
454 return Ok(());
455 }
456 if left_down && contains(self.mode_button_rect(), m.column, m.row) {
457 self.set_window_mode(!self.window_mode)?;
458 return Ok(());
459 }
460
461 if self.menu.is_some() {
463 match m.kind {
464 MouseEventKind::Down(MouseButton::Left) => {
465 match self.menu_item_at(m.column, m.row) {
466 Some(idx) => self.activate_menu(idx)?,
467 None => self.menu = None,
468 }
469 return Ok(());
470 }
471 MouseEventKind::Down(MouseButton::Right) => self.menu = None,
472 _ => return Ok(()),
473 }
474 }
475
476 if self.window_mode {
477 self.on_mouse_windows(m)
478 } else {
479 self.on_mouse_panes(m)
480 }
481 }
482
483 fn on_mouse_panes(&mut self, m: MouseEvent) -> io::Result<()> {
484 let rects = rects_for(&self.panes, self.last_canvas.get(), self.win_w, self.win_h);
485 match m.kind {
486 MouseEventKind::Down(MouseButton::Right) => {
487 let target = hit(&rects, m.column, m.row).map(|i| self.panes[i].id.clone());
488 self.open_menu("root", m.column, m.row, target);
489 }
490 MouseEventKind::Down(MouseButton::Left) => {
491 self.drag_src = hit(&rects, m.column, m.row);
492 }
493 MouseEventKind::Up(MouseButton::Left) => {
494 if let Some(src) = self.drag_src {
495 match hit(&rects, m.column, m.row) {
496 Some(dst) if dst != src => {
497 let s = self.panes[src].id.clone();
498 let t = self.panes[dst].id.clone();
499 tmux::swap_pane(&s, &t)?;
500 self.refresh()?;
501 self.selected = Some(s.clone());
502 self.status = format!("swapped {s} ⇄ {t}");
503 }
504 Some(same) => {
505 let id = self.panes[same].id.clone();
506 self.status = format!("selected {id}");
507 self.selected = Some(id);
508 }
509 None => self.status = "dropped outside — cancelled".into(),
510 }
511 }
512 self.drag_src = None;
513 }
514 _ => {}
515 }
516 Ok(())
517 }
518
519 fn on_mouse_windows(&mut self, m: MouseEvent) -> io::Result<()> {
520 let tiles = self.tiles.borrow().clone();
521 let mpanes = self.mpanes.borrow().clone();
522 match m.kind {
523 MouseEventKind::Down(MouseButton::Right) => {
524 let target = tile_at(&tiles, m.column, m.row);
525 self.open_menu("win", m.column, m.row, target);
526 }
527 MouseEventKind::Down(MouseButton::Left) => {
528 if let Some((win, pane)) = mpane_at(&mpanes, m.column, m.row) {
531 self.wdrag = Some(WDrag::Pane { win, pane });
532 } else if let Some(win) = tile_at(&tiles, m.column, m.row) {
533 self.wdrag = Some(WDrag::Win(win));
534 }
535 }
536 MouseEventKind::Up(MouseButton::Left) => {
537 let drop = tile_at(&tiles, m.column, m.row);
538 match self.wdrag.take() {
539 Some(WDrag::Win(src)) => match drop {
540 Some(dst) if dst != src => self.swap_windows(&src, &dst)?,
541 Some(_) => self.select_window(&src)?,
542 None => {}
543 },
544 Some(WDrag::Pane { win, pane }) => match drop {
545 Some(dst) if dst != win => self.move_pane_to_window(&pane, &dst)?,
546 Some(same) => self.select_window(&same)?,
547 None => {}
548 },
549 None => {}
550 }
551 }
552 _ => {}
553 }
554 Ok(())
555 }
556
557 pub fn set_window_mode(&mut self, on: bool) -> io::Result<()> {
561 self.window_mode = on;
562 self.menu = None;
563 self.refresh()?;
564 if on && self.sel_win.is_none() {
565 self.sel_win = self.active_window_id();
566 }
567 self.status = if on { "window mode" } else { "pane mode" }.into();
568 Ok(())
569 }
570
571 fn win_cols(&self) -> i32 {
573 ((self.windows.len() as f64).sqrt().ceil() as i32).max(1)
574 }
575
576 fn focused_window_index(&self) -> i32 {
578 self.sel_win
579 .as_ref()
580 .and_then(|id| self.windows.iter().position(|w| &w.id == id))
581 .or_else(|| self.windows.iter().position(|w| w.active))
582 .unwrap_or(0) as i32
583 }
584
585 pub fn select_dir(&mut self, dir: &str) -> io::Result<()> {
587 tmux::select_pane_dir(dir)?;
588 self.refresh()?;
589 self.selected = self.panes.iter().find(|p| p.active).map(|p| p.id.clone());
590 Ok(())
591 }
592
593 pub fn swap_dir(&mut self, dir: &str) -> io::Result<()> {
595 let Some(src) = self.target() else {
596 return Ok(());
597 };
598 tmux::select_pane_dir(dir)?;
599 let (_, _, panes) = tmux::query()?;
600 if let Some(nbr) = panes.iter().find(|p| p.active).map(|p| p.id.clone()) {
601 if nbr != src {
602 tmux::swap_pane(&src, &nbr)?;
603 tmux::select_pane(&src)?;
604 self.status = format!("moved {src}");
605 }
606 }
607 self.refresh()?;
608 self.selected = Some(src);
609 Ok(())
610 }
611
612 pub fn win_nav(&mut self, dx: i32, dy: i32) -> io::Result<()> {
615 if self.windows.is_empty() {
616 return Ok(());
617 }
618 let n = self.windows.len() as i32;
619 let idx = (self.focused_window_index() + dx + dy * self.win_cols()).clamp(0, n - 1);
620 let id = self.windows[idx as usize].id.clone();
621 self.select_window(&id)
622 }
623
624 pub fn win_swap(&mut self, dx: i32, dy: i32) -> io::Result<()> {
626 if self.windows.is_empty() {
627 return Ok(());
628 }
629 let n = self.windows.len() as i32;
630 let cur = self.focused_window_index();
631 let tgt = cur + dx + dy * self.win_cols();
632 if tgt < 0 || tgt >= n || tgt == cur {
633 return Ok(());
634 }
635 let a = self.windows[cur as usize].id.clone();
636 let b = self.windows[tgt as usize].id.clone();
637 self.swap_windows(&a, &b)?;
638 self.sel_win = Some(a);
639 Ok(())
640 }
641
642 pub fn pane_key(&mut self, k: KeyEvent) -> io::Result<()> {
644 let shift = k.modifiers.contains(KeyModifiers::SHIFT);
645 match k.code {
646 KeyCode::Left => self.move_or_select("-L", shift)?,
647 KeyCode::Right => self.move_or_select("-R", shift)?,
648 KeyCode::Up => self.move_or_select("-U", shift)?,
649 KeyCode::Down => self.move_or_select("-D", shift)?,
650 KeyCode::Char('|') => self.split(true)?,
651 KeyCode::Char('-') => self.split(false)?,
652 KeyCode::Char('x') | KeyCode::Char('d') => self.request_kill(),
653 KeyCode::Char('1') => self.set_layout("even-horizontal", "side-by-side")?,
654 KeyCode::Char('2') => self.set_layout("even-vertical", "stacked")?,
655 KeyCode::Char('3') => self.set_layout("main-vertical", "main-left")?,
656 KeyCode::Char('4') => self.set_layout("main-horizontal", "main-top")?,
657 KeyCode::Char('5') => self.set_layout("tiled", "tiled")?,
658 KeyCode::Char(' ') => self.cycle_layout()?,
659 _ => {}
660 }
661 Ok(())
662 }
663
664 fn move_or_select(&mut self, dir: &str, shift: bool) -> io::Result<()> {
665 if shift {
666 self.swap_dir(dir)
667 } else {
668 self.select_dir(dir)
669 }
670 }
671
672 pub fn window_key(&mut self, k: KeyEvent) -> io::Result<()> {
674 let shift = k.modifiers.contains(KeyModifiers::SHIFT);
675 let (dx, dy) = match k.code {
676 KeyCode::Left => (-1, 0),
677 KeyCode::Right => (1, 0),
678 KeyCode::Up => (0, -1),
679 KeyCode::Down => (0, 1),
680 KeyCode::Enter => {
681 if let Some(id) = self.sel_win.clone() {
683 self.select_window(&id)?;
684 }
685 self.should_quit = true;
686 return Ok(());
687 }
688 KeyCode::Char('n') => return self.new_window(),
689 KeyCode::Char('x') | KeyCode::Char('d') => {
690 if let Some(id) = self.sel_win.clone().or_else(|| self.active_window_id()) {
691 self.request_kill_window(id);
692 }
693 return Ok(());
694 }
695 _ => return Ok(()),
696 };
697 if shift {
698 self.win_swap(dx, dy)?;
699 } else {
700 self.win_nav(dx, dy)?;
701 }
702 Ok(())
703 }
704}