delta-gitu 0.1.13

An experimental fork of gitu (a git client inspired by Magit), published as a library for delta to embed
Documentation
use crate::style::{Color, Modifier, Style};
use crate::{Res, config::Config, error::Error};
use crossterm::{
    QueueableCommand,
    cursor::{self, MoveTo},
    event::{DisableMouseCapture, EnableMouseCapture, Event},
    style::{Attribute, Colors, Print, ResetColor, SetAttribute, SetColors},
    terminal::{
        Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
        enable_raw_mode,
    },
};
use std::io::{self, Stdout, Write, stdout};
use std::time::Duration;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

pub type Term = TermBackend;

pub fn backend() -> TermBackend {
    TermBackend::Crossterm(stdout())
}

pub enum TermBackend {
    Crossterm(Stdout),
    #[allow(dead_code)]
    Test {
        buffer: TestBuffer,
        events: Vec<Event>,
    },
}

#[derive(Clone, PartialEq)]
pub struct TestCell {
    pub symbol: String,
    /// The OSC-8 link the cell was printed inside, if any.
    pub link: Option<String>,
    pub fg: Color,
    pub bg: Color,
    pub modifier: Modifier,
}

impl Default for TestCell {
    fn default() -> Self {
        TestCell {
            symbol: " ".to_string(),
            link: None,
            fg: Color::Reset,
            bg: Color::Reset,
            modifier: Modifier::empty(),
        }
    }
}

impl TestCell {
    fn set(&mut self, symbol: &str, style: &Style) {
        self.symbol.clear();
        self.symbol.push_str(symbol);

        if let Some(fg) = style.fg {
            self.fg = fg;
        }
        if let Some(bg) = style.bg {
            self.bg = bg;
        }
        self.modifier.insert(style.add_modifier);
        self.modifier.remove(style.sub_modifier);
    }
}

pub struct TestBuffer {
    pub cells: Vec<TestCell>,
    pub width: u16,
    pub height: u16,
    cursor: (u16, u16),
    /// The link printing is currently inside, if any.
    link: Option<String>,
}

impl TestBuffer {
    pub fn new(width: u16, height: u16) -> Self {
        TestBuffer {
            cells: vec![TestCell::default(); width as usize * height as usize],
            width,
            height,
            cursor: (0, 0),
            link: None,
        }
    }

    fn clear(&mut self) {
        self.cells.fill(TestCell::default());
    }

    fn cell_mut(&mut self, x: u16, y: u16) -> Option<&mut TestCell> {
        if x >= self.width || y >= self.height {
            return None;
        }

        self.cells
            .get_mut(y as usize * self.width as usize + x as usize)
    }

    fn print(&mut self, text: &str, style: &Style) {
        let (mut x, y) = self.cursor;

        for grapheme in text.graphemes(true) {
            let grapheme_width = grapheme.width() as u16;
            if grapheme_width == 0 || x >= self.width {
                continue;
            }

            let link = self.link.clone();
            if let Some(cell) = self.cell_mut(x, y) {
                *cell = TestCell::default();
                cell.set(grapheme, style);
                cell.link = link;
            }
            x += 1;

            for _ in 1..grapheme_width {
                if x >= self.width {
                    break;
                }

                if let Some(cell) = self.cell_mut(x, y) {
                    *cell = TestCell::default();
                }
                x += 1;
            }
        }

        self.cursor = (x, y);
    }
}

impl TermBackend {
    pub(crate) fn queue_move_cursor(&mut self, x: u16, y: u16) -> Res<()> {
        match self {
            TermBackend::Crossterm(t) => crossterm::queue!(t, MoveTo(x, y)).map_err(Error::Term),
            TermBackend::Test { buffer, .. } => {
                buffer.cursor = (x, y);
                Ok(())
            }
        }
    }

    /// Open or close an OSC-8 hyperlink around what is printed next. The
    /// sequence takes up no columns, so the test terminal records only that the
    /// link is open.
    pub(crate) fn queue_link(&mut self, uri: Option<&str>) -> Res<()> {
        match self {
            TermBackend::Crossterm(t) => {
                let uri = uri.unwrap_or("");
                crossterm::queue!(t, Print(format!("\x1b]8;;{uri}\x1b\\"))).map_err(Error::Term)
            }
            TermBackend::Test { buffer, .. } => {
                buffer.link = uri.map(str::to_owned);
                Ok(())
            }
        }
    }

    pub(crate) fn queue_newline(&mut self) -> Res<()> {
        match self {
            TermBackend::Crossterm(t) => {
                crossterm::queue!(t, ResetColor, SetAttribute(Attribute::Reset), Print("\n"))
                    .map_err(Error::Term)
            }
            TermBackend::Test { buffer, .. } => {
                buffer.cursor = (0, buffer.cursor.1 + 1);
                Ok(())
            }
        }
    }

    pub fn queue_print(&mut self, text: &str, style: &Style) -> Res<()> {
        match self {
            TermBackend::Crossterm(t) => {
                print_crossterm_span(text, style, t).map_err(Error::Term)?;

                Ok(())
            }
            TermBackend::Test { buffer, .. } => {
                buffer.print(text, style);
                Ok(())
            }
        }
    }
}

const ATTRS: &[(Modifier, Attribute)] = &[
    (Modifier::BOLD, Attribute::Bold),
    (Modifier::DIM, Attribute::Dim),
    (Modifier::ITALIC, Attribute::Italic),
    (Modifier::UNDERLINED, Attribute::Underlined),
    (Modifier::SLOW_BLINK, Attribute::SlowBlink),
    (Modifier::RAPID_BLINK, Attribute::RapidBlink),
    (Modifier::REVERSED, Attribute::Reverse),
    (Modifier::HIDDEN, Attribute::Hidden),
    (Modifier::CROSSED_OUT, Attribute::CrossedOut),
];

fn print_crossterm_span(text: &str, style: &Style, t: &mut Stdout) -> Result<(), io::Error> {
    let fg = style.fg.unwrap_or(Color::Reset);
    let bg = style.bg.unwrap_or(Color::Reset);

    crossterm::queue!(t, SetAttribute(Attribute::Reset))?;

    for (modifier, attribute) in ATTRS {
        if style.add_modifier.contains(*modifier) {
            crossterm::queue!(t, SetAttribute(*attribute))?;
        }
    }

    crossterm::queue!(t, SetColors(Colors::new(fg.into(), bg.into())))?;
    crossterm::queue!(t, Print(text))?;
    Ok(())
}

impl TermBackend {
    pub(crate) fn size(&self) -> io::Result<(u16, u16)> {
        match self {
            TermBackend::Crossterm(_) => crossterm::terminal::size(),
            TermBackend::Test { buffer, .. } => Ok((buffer.width, buffer.height)),
        }
    }

    pub(crate) fn clear(&mut self) -> io::Result<()> {
        match self {
            TermBackend::Crossterm(t) => crossterm::queue!(t, Clear(ClearType::All)),
            TermBackend::Test { buffer, .. } => {
                buffer.clear();
                Ok(())
            }
        }
    }

    pub(crate) fn flush(&mut self) -> io::Result<()> {
        match self {
            TermBackend::Crossterm(t) => t.flush(),
            TermBackend::Test { .. } => Ok(()),
        }
    }
}

impl TermBackend {
    pub fn setup_term(&mut self, config: &Config) -> io::Result<()> {
        match self {
            TermBackend::Crossterm(crossterm_backend) => {
                enable_raw_mode()?;
                crossterm_backend.queue(EnterAlternateScreen)?;
                crossterm_backend.queue(cursor::Hide)?;
                if config.general.mouse_support {
                    crossterm_backend.queue(EnableMouseCapture)?;
                }
            }
            TermBackend::Test { .. } => {}
        }

        self.flush()
    }

    pub fn reset_term(&mut self, config: &Config) -> io::Result<()> {
        match self {
            TermBackend::Crossterm(crossterm_backend) => {
                if config.general.mouse_support {
                    crossterm_backend.queue(DisableMouseCapture)?;
                }
                crossterm_backend.queue(cursor::Show)?;
                crossterm_backend.queue(LeaveAlternateScreen)?;
                disable_raw_mode()?;
            }
            TermBackend::Test { .. } => {}
        }

        self.flush()
    }

    /// Stop gitu as job control stops a program on ctrl+z, giving the
    /// terminal back until the shell continues it, and then take it again. The
    /// test terminal has no job control, and stopping would stop the tests.
    pub(crate) fn suspend(&mut self, config: &Config) -> io::Result<()> {
        if let TermBackend::Test { .. } = self {
            return Ok(());
        }
        self.reset_term(config)?;
        #[cfg(unix)]
        // SAFETY: raising a signal touches no memory of this process.
        unsafe {
            libc::raise(libc::SIGTSTP);
        }
        self.setup_term(config)?;
        self.clear()
    }

    pub(crate) fn reset_term_stay_on_alt_screeen(
        &mut self,
        config: &Config,
    ) -> Result<(), io::Error> {
        match self {
            TermBackend::Crossterm(crossterm_backend) => {
                if config.general.mouse_support {
                    crossterm_backend.queue(DisableMouseCapture)?;
                }
                crossterm_backend.queue(cursor::Show)?;
                disable_raw_mode()?;
            }
            TermBackend::Test { .. } => {}
        }

        self.flush()
    }

    pub fn poll_event(&self, timeout: Duration) -> Res<bool> {
        match self {
            TermBackend::Crossterm(_) => crossterm::event::poll(timeout).map_err(Error::Term),
            TermBackend::Test { events, .. } => {
                if events.is_empty() {
                    Err(Error::NoMoreEvents)
                } else {
                    Ok(true)
                }
            }
        }
    }

    /// Whether an event is waiting to be read, without waiting for one.
    pub fn has_pending_event(&self) -> Res<bool> {
        match self {
            TermBackend::Crossterm(_) => {
                crossterm::event::poll(Duration::ZERO).map_err(Error::Term)
            }
            TermBackend::Test { events, .. } => Ok(!events.is_empty()),
        }
    }

    pub fn hung_up(&self) -> bool {
        match self {
            #[cfg(unix)]
            TermBackend::Crossterm(t) => hung_up(std::os::fd::AsFd::as_fd(t)),
            #[cfg(not(unix))]
            TermBackend::Crossterm(_) => false,
            TermBackend::Test { .. } => false,
        }
    }

    pub fn read_event(&mut self) -> Res<Event> {
        match self {
            TermBackend::Crossterm(_) => crossterm::event::read().map_err(Error::Term),
            TermBackend::Test { events, .. } => events.pop().ok_or(Error::NoMoreEvents),
        }
    }
}

/// Whether the terminal behind `fd` has gone, as when its window is closed.
/// Reading it then gives end of file, which crossterm reports as no input, so
/// gitu must ask. Without this, gitu as git's pager in a closed tmux pane spins
/// forever: git, the pane's session leader, takes the SIGHUP and waits for its
/// pager rather than passing it on.
#[cfg(unix)]
fn hung_up(fd: std::os::fd::BorrowedFd) -> bool {
    use std::os::fd::AsRawFd;
    let mut pollfd = libc::pollfd {
        fd: fd.as_raw_fd(),
        events: libc::POLLOUT,
        revents: 0,
    };
    let ready = unsafe { libc::poll(&mut pollfd, 1, 0) };
    ready == 1 && pollfd.revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0
}

#[cfg(all(test, unix))]
mod tests {
    use super::hung_up;
    use std::os::fd::{AsFd, FromRawFd, OwnedFd};

    #[test]
    fn a_terminal_has_hung_up_once_its_other_end_is_closed() {
        let (mut master, mut slave) = (0, 0);
        let opened = unsafe {
            libc::openpty(
                &mut master,
                &mut slave,
                std::ptr::null_mut(),
                std::ptr::null_mut(),
                std::ptr::null_mut(),
            )
        };
        assert_eq!(opened, 0);
        let (master, slave) =
            unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) };

        assert!(!hung_up(slave.as_fd()));
        drop(master);
        assert!(hung_up(slave.as_fd()));
    }
}