tmux-workspace 0.1.0-alpha.16

Build tmux workspaces from tmuxp-style YAML using libtmux (alpha)
Documentation
mod tail;
mod template;

use std::io::{self, IsTerminal, Write};

use clap::{ArgMatches, parser::ValueSource};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

use super::{CliError, Result, discovery, normalize, output};
use tail::Tail;
use template::{State, Template};

const MAX_ROWS: usize = 128;

#[derive(PartialEq, Eq)]
enum Drawing {
    Ready,
    PartialLine,
    Stopped,
}

pub(super) struct Progress {
    size: (u16, u16),
    lines: usize,
    #[cfg(unix_process_observer)]
    same_stdout: bool,
    color: bool,
    drawing: Drawing,
    template: Template,
    state: State,
    tail: Tail,
    frame: Vec<String>,
}

fn geometry() -> Option<(u16, u16)> {
    let size = rustix::termios::tcgetwinsize(io::stderr()).ok()?;
    (size.ws_col >= 2 && size.ws_row >= 2).then_some((size.ws_col, size.ws_row))
}

#[cfg(unix_process_observer)]
fn shared_terminal() -> bool {
    if !io::stdout().is_terminal() {
        return false;
    }
    match (
        rustix::fs::fstat(io::stdout()),
        rustix::fs::fstat(io::stderr()),
    ) {
        (Ok(out), Ok(err)) => {
            (out.st_dev, out.st_ino, out.st_rdev) == (err.st_dev, err.st_ino, err.st_rdev)
        }
        _ => false,
    }
}

impl Progress {
    pub(super) fn new(args: &ArgMatches, machine: bool) -> Result<Option<Self>> {
        if machine
            || !io::stderr().is_terminal()
            || args.get_flag("no-progress")
            || std::env::var("TMUXP_PROGRESS").as_deref() == Ok("0")
            || std::env::var("TERM").as_deref() == Ok("dumb")
        {
            return Ok(None);
        }
        let Some(size) = geometry() else {
            return Ok(None);
        };
        let lines = if args.value_source("progress-lines") == Some(ValueSource::CommandLine) {
            args.get_one::<i32>("progress-lines").copied().unwrap_or(3)
        } else if let Some(value) = std::env::var_os("TMUXP_PROGRESS_LINES") {
            value
                .to_str()
                .and_then(|value| value.parse::<i32>().ok())
                .filter(|value| *value >= -1)
                .ok_or_else(|| {
                    CliError::usage("TMUXP_PROGRESS_LINES must be an integer of -1 or greater")
                })?
        } else {
            3
        };
        let format = args
            .get_one::<String>("progress-format")
            .cloned()
            .or_else(|| std::env::var("TMUXP_PROGRESS_FORMAT").ok())
            .unwrap_or_else(|| "default".into());
        let available = usize::from(size.1 - 2).min(MAX_ROWS);
        let lines = usize::try_from(lines).map_or(available, |lines| lines.min(available));
        Ok(Some(Self {
            size,
            lines,
            #[cfg(unix_process_observer)]
            same_stdout: shared_terminal(),
            color: output::color_enabled(args, true),
            drawing: Drawing::Ready,
            template: Template::new(&format),
            state: State::default(),
            tail: Tail::new(lines),
            frame: Vec::new(),
        }))
    }

    pub(super) fn start(&mut self, workspace: &normalize::Workspace) -> Result<()> {
        self.clear()?;
        self.tail = Tail::new(self.lines);
        self.state = State {
            path: discovery::masked(&workspace.source),
            session: workspace.name.clone(),
            windows: if workspace.bridge {
                0
            } else {
                workspace.windows.len()
            },
            total_panes: if workspace.bridge {
                0
            } else {
                workspace
                    .windows
                    .iter()
                    .map(|window| window.panes.len())
                    .sum()
            },
            status: if workspace.bridge {
                "Python extensions"
            } else {
                ""
            },
            ..State::default()
        };
        self.draw()
    }

    pub(super) fn window(&mut self, ordinal: usize, window: &normalize::Window) -> Result<()> {
        self.state
            .window
            .clone_from(&window.name.clone().unwrap_or_default());
        self.state.window_index = ordinal;
        self.state.panes = window.panes.len();
        self.state.pane_index = 0;
        self.state.panes_done = 0;
        self.draw()
    }

    pub(super) fn pane(&mut self, ordinal: usize) -> Result<()> {
        self.state.pane_index = ordinal;
        self.draw()
    }

    pub(super) fn pane_done(&mut self) -> Result<()> {
        self.state.panes_done += 1;
        self.state.total_done += 1;
        self.draw()
    }

    pub(super) fn window_done(&mut self) -> Result<()> {
        self.state.windows_done += 1;
        self.draw()
    }

    pub(super) fn finish(&mut self, reused: bool, delegated: bool) -> Result<()> {
        self.state.status = if reused {
            "Reused"
        } else if delegated {
            "Python extensions completed"
        } else {
            "Completed"
        };
        if reused {
            self.state.windows = 0;
            self.state.total_panes = 0;
        }
        self.draw()
    }

    fn unchanged(&mut self) -> bool {
        if self.drawing != Drawing::Stopped && geometry() != Some(self.size) {
            self.drawing = Drawing::Stopped;
            self.frame.clear();
        }
        self.drawing != Drawing::Stopped
    }

    pub(super) fn clear(&mut self) -> Result<()> {
        if self.unchanged() && !self.frame.is_empty() {
            let mut error = io::stderr().lock();
            for _ in &self.frame {
                write!(error, "\x1b[1A\r\x1b[2K")?;
            }
            error.flush()?;
            self.frame.clear();
        }
        Ok(())
    }

    #[cfg(unix_process_observer)]
    pub(super) fn output(&mut self, stream: &str, text: &str) -> Result<bool> {
        if !self.unchanged() || (stream == "stdout" && !self.same_stdout) {
            return Ok(false);
        }
        if self.lines > 0 {
            self.tail
                .append(usize::from(stream == "stderr"), text.as_bytes());
        } else {
            self.clear()?;
            if stream == "stderr" {
                io::stderr().write_all(text.as_bytes())?;
                io::stderr().flush()?;
            } else {
                io::stdout().write_all(text.as_bytes())?;
                io::stdout().flush()?;
            }
            self.drawing = if text.ends_with('\n') {
                Drawing::Ready
            } else {
                Drawing::PartialLine
            };
        }
        self.draw()?;
        Ok(true)
    }

    fn draw(&mut self) -> Result<()> {
        if !self.unchanged() || self.drawing == Drawing::PartialLine {
            return Ok(());
        }
        let mut heading = self.template.render(&self.state);
        if !self.state.status.is_empty() {
            heading.push_str(" ยท ");
            heading.push_str(self.state.status);
        }
        let frame: Vec<_> = std::iter::once(heading.as_str())
            .chain(self.tail.visible())
            .map(|line| clip(line, usize::from(self.size.0 - 1)))
            .collect();
        if frame == self.frame {
            return Ok(());
        }
        self.clear()?;
        let mut error = io::stderr().lock();
        for (index, line) in frame.iter().enumerate() {
            if self.color {
                let style = if index == 0 { "1;36" } else { "2" };
                write!(error, "\x1b[{style}m{line}\x1b[0m\r\n")?;
            } else {
                write!(error, "{line}\r\n")?;
            }
        }
        error.flush()?;
        self.frame = frame;
        Ok(())
    }
}

fn clip(text: &str, width: usize) -> String {
    let safe: String = text
        .chars()
        .filter(|character| !character.is_control())
        .take(8192)
        .collect();
    let mut cells = 0;
    safe.graphemes(true)
        .take_while(|cluster| {
            cells += cluster.width();
            cells <= width
        })
        .collect()
}