qqq-cli 0.1.0

Local-first task queue for coding agent sessions
mod clipboard;
pub mod draft;
mod render;

use anyhow::{Result, bail, ensure};
use crossterm::{
    cursor::Show,
    event::{
        self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEventKind,
        KeyModifiers,
    },
    execute,
    style::ResetColor,
    terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use draft::{Composition, Draft};
use std::io::{self, IsTerminal};

struct TerminalGuard {
    color: bool,
}
impl TerminalGuard {
    fn enter() -> Result<Self> {
        ensure!(
            io::stdin().is_terminal() && io::stderr().is_terminal(),
            "Interactive editor requires terminal input and stderr"
        );
        terminal::enable_raw_mode()?;
        let guard = Self {
            color: crate::output::color_enabled(io::stderr().is_terminal()),
        };
        execute!(
            io::stderr(),
            EnterAlternateScreen,
            EnableBracketedPaste,
            Show
        )?;
        Ok(guard)
    }
}
impl Drop for TerminalGuard {
    fn drop(&mut self) {
        if self.color {
            let _ = execute!(io::stderr(), ResetColor);
        }
        let _ = execute!(
            io::stderr(),
            DisableBracketedPaste,
            LeaveAlternateScreen,
            Show
        );
        let _ = terminal::disable_raw_mode();
    }
}
fn paste(draft: &mut Draft, text: &str) -> Result<()> {
    if let Some(image) = clipboard::path_image(text)? {
        draft.image(image)?;
    } else {
        draft.paste(text);
    }
    Ok(())
}
pub fn compose(description: &str) -> Result<Composition> {
    let terminal = TerminalGuard::enter()?;
    let mut draft = Draft::new(description);
    let mut top = 0;
    let mut message = String::new();
    let mut confirm_discard = false;
    loop {
        let size = terminal::size()?;
        let layout = render::Layout::new(&draft.fragments(), size.0 as usize);
        let footer = if confirm_discard {
            if usize::from(size.0) < "Discard draft? (y/N)".len() {
                "Discard? y/N"
            } else {
                "Discard draft? (y/N)"
            }
        } else {
            &message
        };
        render::draw(
            &mut io::stderr(),
            &layout,
            draft.cursor(),
            &mut top,
            size,
            footer,
            terminal.color,
        )?;
        match event::read()? {
            Event::Paste(text) if !confirm_discard => {
                message = paste(&mut draft, &text)
                    .err()
                    .map_or_else(String::new, |error| format!("{error:#}"));
            }
            Event::Key(key) if key.kind != KeyEventKind::Release => {
                let control = key.modifiers.contains(KeyModifiers::CONTROL);
                if confirm_discard {
                    if control && key.code == KeyCode::Char('c') {
                        bail!("Editor cancelled; task not saved");
                    }
                    if !key
                        .modifiers
                        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
                    {
                        match key.code {
                            KeyCode::Char('y' | 'Y') => bail!("Editor cancelled; task not saved"),
                            KeyCode::Char('n' | 'N') | KeyCode::Enter | KeyCode::Esc => {
                                confirm_discard = false;
                                message.clear();
                            }
                            _ => (),
                        }
                    }
                    continue;
                }
                if control {
                    match key.code {
                        KeyCode::Char('s') => match draft.finish() {
                            Ok(composition) => return Ok(composition),
                            Err(error) => message = error.to_string(),
                        },
                        KeyCode::Char('c') => bail!("Editor cancelled; task not saved"),
                        KeyCode::Char('v') => {
                            let result = clipboard::read().and_then(|value| match value {
                                clipboard::Paste::Text(text) => paste(&mut draft, &text),
                                clipboard::Paste::Image(image) => draft.image(image),
                            });
                            message = result
                                .err()
                                .map_or_else(String::new, |error| format!("{error:#}"));
                        }
                        KeyCode::Char('a') => draft.home(),
                        KeyCode::Char('e') => draft.end(),
                        _ => (),
                    }
                    continue;
                }
                message.clear();
                match key.code {
                    KeyCode::Esc if draft.is_empty() => bail!("Editor cancelled; task not saved"),
                    KeyCode::Esc => confirm_discard = true,
                    KeyCode::Char(character)
                        if !key
                            .modifiers
                            .intersects(KeyModifiers::ALT | KeyModifiers::SUPER) =>
                    {
                        draft.insert(&character.to_string())
                    }
                    KeyCode::Enter => draft.insert("\n"),
                    KeyCode::Tab => draft.insert("\t"),
                    KeyCode::Backspace => draft.backspace(),
                    KeyCode::Delete => draft.delete(),
                    KeyCode::Left => draft.left(),
                    KeyCode::Right => draft.right(),
                    KeyCode::Home => draft.home(),
                    KeyCode::End => draft.end(),
                    KeyCode::Up | KeyCode::Down => {
                        let (row, column) = layout.positions[draft.cursor()];
                        let target = if key.code == KeyCode::Up {
                            row.saturating_sub(1)
                        } else {
                            row + 1
                        };
                        let cursor = layout.nearest(target, column);
                        if cursor != draft.cursor() {
                            draft.set_cursor(cursor);
                        } else if key.code == KeyCode::Down {
                            draft.right();
                        } else {
                            draft.left();
                        }
                    }
                    _ => (),
                }
            }
            _ => (),
        }
    }
}