aven 0.1.12

Local-first task manager CLI and sync server
Documentation
#[cfg(not(test))]
use std::fs;
use std::io::{self, Write};
#[cfg(not(test))]
use std::process::Command as ProcessCommand;
use std::sync::Mutex;
#[cfg(not(test))]
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};
use crossterm::Command;
use crossterm::event::{
    KeyCode, KeyEvent, KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
    PushKeyboardEnhancementFlags,
};
use crossterm::terminal::supports_keyboard_enhancement;
#[cfg(not(test))]
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};

pub(crate) fn is_editor_prefix_key(key: KeyEvent) -> bool {
    key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('x')
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum KeyboardEnhancementMode {
    Kitty,
    ModifyOtherKeys,
}

#[derive(Default)]
struct KeyboardEnhancementState {
    mode: Option<KeyboardEnhancementMode>,
}

impl KeyboardEnhancementState {
    fn enable(&mut self, mode: KeyboardEnhancementMode, writer: &mut impl Write) -> io::Result<()> {
        match mode {
            KeyboardEnhancementMode::Kitty => crossterm::execute!(
                writer,
                PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
            )?,
            KeyboardEnhancementMode::ModifyOtherKeys => {
                crossterm::execute!(writer, SetModifyOtherKeys(true))?
            }
        }
        self.mode = Some(mode);
        Ok(())
    }

    fn disable(&mut self, writer: &mut impl Write) -> io::Result<()> {
        let Some(mode) = self.mode else {
            return Ok(());
        };
        match mode {
            KeyboardEnhancementMode::Kitty => {
                crossterm::execute!(writer, PopKeyboardEnhancementFlags)?
            }
            KeyboardEnhancementMode::ModifyOtherKeys => {
                crossterm::execute!(writer, SetModifyOtherKeys(false))?
            }
        }
        self.mode = None;
        Ok(())
    }
}

struct SetModifyOtherKeys(bool);

impl Command for SetModifyOtherKeys {
    fn write_ansi(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
        writer.write_str(if self.0 { "\x1b[>4;2m" } else { "\x1b[>4m" })
    }
}

static KEYBOARD_ENHANCEMENT: Mutex<KeyboardEnhancementState> =
    Mutex::new(KeyboardEnhancementState { mode: None });

fn keyboard_enhancement() -> io::Result<std::sync::MutexGuard<'static, KeyboardEnhancementState>> {
    KEYBOARD_ENHANCEMENT
        .lock()
        .map_err(|_| io::Error::other("keyboard enhancement state lock is poisoned"))
}

fn detected_keyboard_enhancement() -> Option<KeyboardEnhancementMode> {
    if matches!(supports_keyboard_enhancement(), Ok(true)) {
        Some(KeyboardEnhancementMode::Kitty)
    } else if cfg!(unix) {
        Some(KeyboardEnhancementMode::ModifyOtherKeys)
    } else {
        None
    }
}

pub(crate) struct KeyboardEnhancementGuard {
    active: bool,
}

impl KeyboardEnhancementGuard {
    pub(crate) fn enable() -> Result<Self> {
        let Some(mode) = detected_keyboard_enhancement() else {
            return Ok(Self { active: false });
        };
        keyboard_enhancement()?
            .enable(mode, &mut io::stdout())
            .context("enable terminal keyboard enhancements")?;
        Ok(Self { active: true })
    }

    pub(crate) fn disable(&mut self) -> Result<()> {
        if self.active {
            keyboard_enhancement()?
                .disable(&mut io::stdout())
                .context("disable terminal keyboard enhancements")?;
            self.active = false;
        }
        Ok(())
    }
}

impl Drop for KeyboardEnhancementGuard {
    fn drop(&mut self) {
        let _ = self.disable();
    }
}

#[cfg(not(test))]
pub(crate) fn edit_text_externally(value: String, filename: &str) -> Result<String> {
    let path = temp_editor_path(filename)?;
    fs::write(&path, value)?;
    let result =
        run_external_editor(&path).and_then(|()| fs::read_to_string(&path).map_err(Into::into));
    let _ = fs::remove_file(&path);
    if let Some(parent) = path.parent() {
        let _ = fs::remove_dir(parent);
    }
    result
}

#[cfg(test)]
pub(crate) fn edit_text_externally(value: String, _filename: &str) -> Result<String> {
    Ok(format!("{value} from editor"))
}

#[cfg(not(test))]
fn temp_editor_path(filename: &str) -> io::Result<std::path::PathBuf> {
    let millis = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis();
    let pid = std::process::id();
    let dir = std::env::temp_dir().join(format!("aven-tui-editor-{pid}-{millis}"));
    fs::create_dir(&dir)?;
    Ok(dir.join(filename))
}

#[cfg(not(test))]
fn run_external_editor(path: &std::path::Path) -> Result<()> {
    let restore = suspend_terminal()?;
    let status = external_editor_command(path).status();
    restore()?;
    let status = status?;
    if !status.success() {
        anyhow::bail!("editor exited with {status}");
    }
    Ok(())
}

#[cfg(not(test))]
fn external_editor_command(path: &std::path::Path) -> ProcessCommand {
    let mut command = ProcessCommand::new("sh");
    command
        .arg("-c")
        .arg("exec ${VISUAL:-${EDITOR:-vi}} \"$1\"")
        .arg("sh")
        .arg(path);
    command
}

#[cfg(not(test))]
fn suspend_terminal() -> Result<impl FnOnce() -> Result<()>> {
    let keyboard_mode = {
        let mut state = keyboard_enhancement()?;
        let mode = state.mode;
        state
            .disable(&mut io::stdout())
            .context("suspend terminal keyboard enhancements")?;
        mode
    };
    disable_raw_mode()?;
    crossterm::execute!(io::stdout(), LeaveAlternateScreen)?;
    Ok(move || {
        crossterm::execute!(io::stdout(), EnterAlternateScreen)?;
        enable_raw_mode()?;
        if let Some(mode) = keyboard_mode {
            keyboard_enhancement()?
                .enable(mode, &mut io::stdout())
                .context("resume terminal keyboard enhancements")?;
        }
        Ok(())
    })
}

#[cfg(not(test))]
pub(crate) fn copy_to_clipboard(value: &str) -> Result<()> {
    let mut child = ProcessCommand::new("pbcopy")
        .stdin(std::process::Stdio::piped())
        .spawn()?;
    if let Some(mut stdin) = child.stdin.take() {
        use std::io::Write;
        stdin.write_all(value.as_bytes())?;
    }
    let status = child.wait()?;
    if !status.success() {
        anyhow::bail!("pbcopy exited with {status}");
    }
    Ok(())
}

#[cfg(test)]
pub(crate) fn copy_to_clipboard(_value: &str) -> Result<()> {
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn kitty_keyboard_enhancement_pushes_and_pops_state() {
        let mut state = KeyboardEnhancementState::default();
        let mut output = Vec::new();

        state
            .enable(KeyboardEnhancementMode::Kitty, &mut output)
            .unwrap();
        assert_eq!(state.mode, Some(KeyboardEnhancementMode::Kitty));
        state.disable(&mut output).unwrap();

        assert_eq!(output, b"\x1b[>1u\x1b[<1u");
        assert_eq!(state.mode, None);
    }

    #[test]
    fn failed_restore_keeps_keyboard_state_available_for_retry() {
        struct FailingWriter;

        impl Write for FailingWriter {
            fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
                Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))
            }

            fn flush(&mut self) -> io::Result<()> {
                Ok(())
            }
        }

        let mut state = KeyboardEnhancementState::default();
        let mut output = Vec::new();
        state
            .enable(KeyboardEnhancementMode::Kitty, &mut output)
            .unwrap();

        assert!(state.disable(&mut FailingWriter).is_err());
        assert_eq!(state.mode, Some(KeyboardEnhancementMode::Kitty));
        state.disable(&mut output).unwrap();

        assert_eq!(output, b"\x1b[>1u\x1b[<1u");
        assert_eq!(state.mode, None);
    }

    #[test]
    fn modify_other_keys_enhancement_restores_terminal_mode() {
        let mut state = KeyboardEnhancementState::default();
        let mut output = Vec::new();

        state
            .enable(KeyboardEnhancementMode::ModifyOtherKeys, &mut output)
            .unwrap();
        assert_eq!(state.mode, Some(KeyboardEnhancementMode::ModifyOtherKeys));
        state.disable(&mut output).unwrap();
        state.disable(&mut output).unwrap();

        assert_eq!(output, b"\x1b[>4;2m\x1b[>4m");
        assert_eq!(state.mode, None);
    }
}