pinstar 1.1.0

Terminal-based canvas editor for .canvas files
Documentation
//! Standalone bin host: terminal lifecycle, external editor integration and
//! the event loop. All canvas logic comes from the `pinstar` library.

use pinstar::theme::ThemeColors;
use pinstar::{
    PinstarState, Settings, draw_pinstar_view, handle_pinstar_event, handle_pinstar_mouse,
};
use ratatui::Terminal;
use ratatui::prelude::*;
use std::io;
use std::path::PathBuf;
#[cfg(feature = "images")]
use std::sync::mpsc::Receiver;
use std::time::Duration;

pub struct TermGuard {
    terminal: Terminal<CrosstermBackend<io::Stdout>>,
}

impl TermGuard {
    pub fn new() -> anyhow::Result<Self> {
        let mut stdout = io::stdout();
        crossterm::terminal::enable_raw_mode()?;
        crossterm::execute!(stdout, crossterm::event::EnableMouseCapture)?;
        let backend = CrosstermBackend::new(stdout);
        let terminal = Terminal::new(backend)?;
        Ok(Self { terminal })
    }

    pub fn as_mut(&mut self) -> &mut Terminal<CrosstermBackend<io::Stdout>> {
        &mut self.terminal
    }

    fn suspend(&mut self) -> anyhow::Result<()> {
        crossterm::terminal::disable_raw_mode()?;
        crossterm::execute!(io::stdout(), crossterm::event::DisableMouseCapture)?;
        crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?;
        Ok(())
    }

    fn resume(&mut self) -> anyhow::Result<()> {
        crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?;
        crossterm::terminal::enable_raw_mode()?;
        crossterm::execute!(io::stdout(), crossterm::event::EnableMouseCapture)?;
        self.terminal.clear()?;
        Ok(())
    }
}

impl Drop for TermGuard {
    fn drop(&mut self) {
        let _ = crossterm::terminal::disable_raw_mode();
        let _ = crossterm::execute!(io::stdout(), crossterm::event::DisableMouseCapture);
        let _ = crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen);
        let _ = self.terminal.show_cursor();
    }
}

#[cfg(feature = "images")]
struct ImageWorker {
    rx: Receiver<anyhow::Result<pinstar::image::DecodedImage>>,
}

pub fn run_pinstar(path: PathBuf) -> anyhow::Result<()> {
    let mut guard = TermGuard::new()?;
    let mut state = PinstarState::load(&path)?;
    state.settings = Settings {
        enable_image_nodes: cfg!(feature = "images"),
        image_cache_size: 32,
        rename_uses_id: true,
        show_hints: true,
    };
    #[cfg(feature = "images")]
    {
        let (tx, rx) = pinstar::image::spawn_worker();
        state.image_decode_tx = Some(tx);
        state.image_picker = ratatui_image::picker::Picker::from_query_stdio().ok();
        run_loop(&mut guard, &mut state, Some(ImageWorker { rx }), &path)
    }
    #[cfg(not(feature = "images"))]
    run_loop(&mut guard, &mut state, None, &path)
}

/// Native OS file picker: zenity → kdialog → yad, stdin prompt fallback.
/// Returns None when cancelled.
fn pick_image_file() -> Option<PathBuf> {
    for (program, args) in [
        ("zenity", vec!["--file-selection", "--title=Add Image Node"]),
        ("kdialog", vec!["--getopenfilename", "."]),
        ("yad", vec!["--file-selection"]),
    ] {
        if let Ok(out) = std::process::Command::new(program).args(&args).output() {
            let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if out.status.success() && !path.is_empty() {
                return Some(PathBuf::from(path));
            }
            // ponytail: dialog exit-failure (e.g. no DISPLAY) is treated as
            // cancel; stdin fallback only when no dialog binary exists.
            return None;
        }
    }
    // No dialog tool on PATH: fall back to a stdin prompt.
    print!("Image path: ");
    use std::io::Write as _;
    let _ = std::io::stdout().flush();
    let mut input = String::new();
    if std::io::stdin().read_line(&mut input).is_ok() {
        let raw = input.trim();
        let path = raw.strip_prefix("~/").map_or_else(
            || PathBuf::from(raw),
            |rest| {
                std::env::var("HOME")
                    .map(|h| PathBuf::from(h).join(rest))
                    .unwrap_or_else(|_| PathBuf::from(raw))
            },
        );
        if !raw.is_empty() && path.exists() {
            return Some(path);
        }
    }
    None
}

fn run_loop(
    guard: &mut TermGuard,
    state: &mut PinstarState,
    #[cfg(feature = "images")] worker: Option<ImageWorker>,
    #[cfg(not(feature = "images"))] _worker: Option<()>,
    path: &PathBuf,
) -> anyhow::Result<()> {
    let theme = ThemeColors::default();
    let mut running = true;

    let external_editor = std::env::var("VISUAL")
        .or_else(|_| std::env::var("EDITOR"))
        .unwrap_or_else(|_| "vi".to_string());

    while running {
        #[cfg(feature = "images")]
        if let Some(worker) = &worker {
            while let Ok(result) = worker.rx.try_recv() {
                if let (Ok(img), Some(picker)) = (result, state.image_picker.as_ref()) {
                    state.image_cache.install_decoded(img, picker);
                }
            }
        }

        if state.trigger_whole_file_editor {
            state.trigger_whole_file_editor = false;

            let parts: Vec<&str> = external_editor.split_whitespace().collect();
            let (program, editor_args) = parts
                .split_first()
                .map(|(p, a)| (*p, a.to_vec()))
                .unwrap_or(("vi", vec![]));

            let mut command = std::process::Command::new("x-terminal-emulator");
            command.arg("-e").arg(program);
            for arg in &editor_args {
                command.arg(arg);
            }
            command.arg(&state.path);

            if command.spawn().is_err() {
                // If generic x-terminal-emulator isn't present, gracefully degrade
                // to suspended inline terminal edit
                let _ = guard.suspend();
                let mut fallback = std::process::Command::new(program);
                for arg in &editor_args {
                    fallback.arg(arg);
                }
                fallback.arg(&state.path);
                let _ = fallback.status();
                let _ = guard.resume();
                let _ = state.reload();
            }
        }

        if state.trigger_ext_editor {
            state.trigger_ext_editor = false;
            if let Some(node_id) = &state.selection.primary {
                let node_text = state
                    .data
                    .nodes
                    .iter()
                    .find(|n| n.id() == node_id)
                    .map(|n| n.text().to_string())
                    .unwrap_or_default();

                let temp_dir = std::env::temp_dir();
                let temp_id = uuid::Uuid::new_v4().to_string();
                let temp_file_path = temp_dir.join(format!("clin_pinstar_{temp_id}.md"));
                std::fs::write(&temp_file_path, &node_text)?;

                guard.suspend()?;

                let parts: Vec<&str> = external_editor.split_whitespace().collect();
                let (program, editor_args) = parts
                    .split_first()
                    .map(|(p, a)| (*p, a.to_vec()))
                    .unwrap_or(("vi", vec![]));

                let mut command = std::process::Command::new(program);
                for arg in &editor_args {
                    command.arg(arg);
                }
                command.arg(&temp_file_path);
                let _ = command.status();

                guard.resume()?;

                if let Ok(new_text) = std::fs::read_to_string(&temp_file_path)
                    && new_text != node_text
                {
                    for node in &mut state.data.nodes {
                        if node.id() == node_id {
                            node.set_text(new_text);
                            break;
                        }
                    }
                    let _ = state.save();
                }
                let _ = std::fs::remove_file(&temp_file_path);
            }
        }

        if state.trigger_image_picker {
            state.trigger_image_picker = false;
            let pos = state.context_menu_pos;
            let _ = guard.suspend();
            let picked = pick_image_file();
            let _ = guard.resume();
            if let Some(path) = picked {
                state.add_image_node_with(path, pos.0, pos.1);
            }
        }

        if let Ok(metadata) = std::fs::metadata(path) {
            if let Ok(modified) = metadata.modified() {
                if modified > state.last_modified {
                    let _ = state.reload();
                }
            }
        }

        guard.as_mut().draw(|frame| {
            let area = frame.area();
            draw_pinstar_view(frame, state, &theme, area, state.mouse_pos);
        })?;

        if crossterm::event::poll(Duration::from_millis(100))? {
            loop {
                let area = guard.as_mut().size()?;
                match crossterm::event::read()? {
                    crossterm::event::Event::Key(key) => {
                        handle_pinstar_event(state, key, &mut running, area.into());
                    }
                    crossterm::event::Event::Mouse(mouse) => {
                        state.mouse_pos = Some((mouse.column, mouse.row));
                        let _ = handle_pinstar_mouse(state, mouse, area.into());
                    }
                    _ => {}
                }
                if !crossterm::event::poll(Duration::ZERO)? {
                    break;
                }
            }
        }
    }

    let _ = state.save();
    Ok(())
}