use std::{
env, io,
str::FromStr,
time::{Duration, Instant},
};
use anyhow::{Context, Result};
use clap::Parser;
use crossterm::{
cursor::{Hide, Show},
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::style::Color;
use ratatui::{Terminal, backend::CrosstermBackend, layout::Rect};
use tmux_expose::{input, model::App, tmux, ui};
#[derive(Debug, Parser)]
#[command(version, about = "Mission Control-style tmux session switcher")]
struct Cli {
#[arg(long, default_value_t = 500, value_name = "MS", value_parser = clap::value_parser!(u64).range(1..))]
refresh_interval: u64,
#[arg(long, value_name = "COLS", value_parser = clap::value_parser!(u16).range(1..))]
thumbnail_width: Option<u16>,
#[arg(long, value_name = "N", value_parser = clap::value_parser!(u16).range(1..))]
columns: Option<u16>,
#[arg(long, value_name = "COLOR", value_parser = parse_color)]
selected_color: Option<Color>,
#[arg(long, value_name = "COLOR", value_parser = parse_color)]
attached_color: Option<Color>,
#[arg(long, value_name = "COLOR", value_parser = parse_color)]
inactive_color: Option<Color>,
}
fn parse_color(value: &str) -> Result<Color, String> {
if let Ok(color) = Color::from_str(value) {
return Ok(color);
}
if let Some(index) = value
.strip_prefix("colour")
.or_else(|| value.strip_prefix("color"))
.and_then(|digits| digits.parse::<u8>().ok())
{
return Ok(Color::Indexed(index));
}
Err(format!(
"invalid color {value:?}; expected a name (e.g. `yellow`), \
an index (`208` or `colour208`), or a hex value (`#rrggbb`)"
))
}
struct TerminalGuard;
impl TerminalGuard {
fn enter() -> Result<Self> {
enable_raw_mode().context("failed to enable raw mode")?;
let guard = Self;
execute!(io::stdout(), EnterAlternateScreen, Hide, EnableMouseCapture)
.context("failed to enter alternate screen")?;
Ok(guard)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
Show,
LeaveAlternateScreen,
DisableMouseCapture
);
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
let current_session_name = tmux::current_session_name().unwrap_or(None);
let current_session_id = tmux::current_session_id().unwrap_or(None);
let mut app = match tmux::list_sessions() {
Ok(sessions) => App::new(sessions, current_session_name),
Err(error) => {
let mut app = App::new(Vec::new(), current_session_name);
app.error = Some(format!("{error}\n\nPress q or Esc to quit."));
app
}
};
let mut colors = ui::CardColors::default();
if let Some(color) = cli.selected_color {
colors.selected = color;
}
if let Some(color) = cli.attached_color {
colors.attached = color;
}
if let Some(color) = cli.inactive_color {
colors.inactive = color;
}
let _guard = TerminalGuard::enter()?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend).context("failed to create terminal")?;
let refresh_interval = Duration::from_millis(cli.refresh_interval);
let toggle_key = env::var("TMUX_EXPOSE_TOGGLE_KEY")
.ok()
.and_then(|key| input::ToggleKey::from_tmux_key(&key));
let mut last_refresh = Instant::now();
loop {
let forced_columns = cli.columns.map(usize::from);
terminal
.draw(|frame| ui::render(frame, &app, colors, cli.thumbnail_width, forced_columns))?;
if app.should_quit {
break;
}
if app.should_switch {
if let Some(session) = app.selected_session() {
let selected_name = session.name.clone();
let selected_target = session.id.clone();
if app.current_session_name.as_deref() == Some(selected_name.as_str()) {
break;
}
match tmux::switch_client(&selected_target) {
Ok(()) => break,
Err(error) => {
app.error = Some(format!("{error}\n\nPress q or Esc to quit."));
app.should_switch = false;
}
}
} else {
app.should_switch = false;
}
}
if event::poll(Duration::from_millis(50))? {
match event::read()? {
Event::Key(key)
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
{
let columns = current_columns(
&terminal,
app.visible_session_count(),
cli.thumbnail_width,
forced_columns,
)?;
input::handle_key_with_toggle(&mut app, key, columns, toggle_key);
}
Event::Mouse(mouse) => {
let grid_area = current_grid_area(&terminal)?;
input::handle_mouse(
&mut app,
mouse,
grid_area,
cli.thumbnail_width,
forced_columns,
);
}
Event::Resize(_, _) => {}
_ => {}
}
}
if last_refresh.elapsed() >= refresh_interval {
match tmux::list_sessions_skipping_preview_for(current_session_id.as_deref()) {
Ok(sessions) => {
app.replace_sessions_preserving_preview_for(
sessions,
current_session_id.as_deref(),
);
app.error = None;
}
Err(error) => {
app.error = Some(format!("{error}\n\nPress q or Esc to quit."));
}
}
last_refresh = Instant::now();
}
}
Ok(())
}
fn current_columns(
terminal: &Terminal<CrosstermBackend<io::Stdout>>,
session_count: usize,
min_card_width: Option<u16>,
forced_columns: Option<usize>,
) -> Result<usize> {
let area = current_grid_area(terminal)?;
let grid = ui::calculate_grid(area, session_count, min_card_width, forced_columns);
Ok(grid.columns)
}
fn current_grid_area(terminal: &Terminal<CrosstermBackend<io::Stdout>>) -> Result<Rect> {
let area = terminal.size().context("failed to read terminal size")?;
Ok(Rect::new(0, 0, area.width, area.height.saturating_sub(1)))
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[test]
fn thumbnail_width_defaults_to_fit_screen_mode() {
let cli = Cli::parse_from(["tmux-expose"]);
assert_eq!(cli.thumbnail_width, None);
}
#[test]
fn parses_thumbnail_width_option() {
let cli = Cli::parse_from(["tmux-expose", "--thumbnail-width", "48"]);
assert_eq!(cli.thumbnail_width, Some(48));
}
#[test]
fn parses_forced_columns_option() {
let cli = Cli::parse_from(["tmux-expose", "--columns", "2"]);
assert_eq!(cli.columns, Some(2));
}
#[test]
fn color_options_default_to_none() {
let cli = Cli::parse_from(["tmux-expose"]);
assert_eq!(cli.selected_color, None);
assert_eq!(cli.attached_color, None);
assert_eq!(cli.inactive_color, None);
}
#[test]
fn parses_named_color() {
let cli = Cli::parse_from(["tmux-expose", "--selected-color", "cyan"]);
assert_eq!(cli.selected_color, Some(Color::Cyan));
}
#[test]
fn parses_indexed_color() {
let cli = Cli::parse_from(["tmux-expose", "--attached-color", "208"]);
assert_eq!(cli.attached_color, Some(Color::Indexed(208)));
}
#[test]
fn parses_all_color_options() {
let cli = Cli::parse_from([
"tmux-expose",
"--selected-color",
"magenta",
"--attached-color",
"green",
"--inactive-color",
"white",
]);
assert_eq!(cli.selected_color, Some(Color::Magenta));
assert_eq!(cli.attached_color, Some(Color::Green));
assert_eq!(cli.inactive_color, Some(Color::White));
}
#[test]
fn parses_hex_color() {
let cli = Cli::parse_from(["tmux-expose", "--selected-color", "#ff8700"]);
assert_eq!(cli.selected_color, Some(Color::Rgb(255, 135, 0)));
}
#[test]
fn parses_tmux_style_indexed_color() {
assert_eq!(parse_color("colour208"), Ok(Color::Indexed(208)));
assert_eq!(parse_color("color208"), Ok(Color::Indexed(208)));
assert_eq!(parse_color("208"), Ok(Color::Indexed(208)));
}
#[test]
fn rejects_invalid_color() {
let result = Cli::try_parse_from(["tmux-expose", "--selected-color", "not-a-color"]);
assert!(result.is_err());
assert!(parse_color("colourize").is_err());
assert!(parse_color("colour999").is_err());
}
}