monospace 0.1.0

The interactive full-screen terminal application: the chrome around a diagram being edited
//! The interactive application: the loop, the terminal it runs on, and the way out.
//!
//! **The only place the two halves meet.** `callbacks` holds what the application does, and
//! `frame` holds what one frame shows; this module holds the loop that runs them and the
//! terminal they run on.
//!
//! The name is the one `AGENTS.md` reserves for the interactive application, and the binary is
//! the whole of it: there is no library here, and the state is private to it.

mod about;
mod callbacks;
mod chrome;
mod config;
mod context;
mod context_table;
mod diagram_state;
mod diagram_widget;
mod dialog;
mod dialog_style;
mod error;
mod event;
mod export;
mod frame;
mod menu;
mod open;
mod overlay;
mod save;
mod shortcuts;
mod state;
mod status;
mod text;
mod vocabulary;

use std::process::ExitCode;

use monospace_editing::Session;
use rat_salsa::poll::{PollCrossterm, PollRendered};
use rat_salsa::{RunConfig, run_tui};

use crate::config::Config;
use crate::context::AppGlobal;
use crate::state::AppState;
use crate::vocabulary::AppError;

/// The application's own demonstration, embedded at compile time so the run with no path works
/// from any working directory and from a binary copied outside a checkout.
///
/// **A copy of the command line's file, and not a path to it.** The command line's demonstration
/// acts on four offsets of its exact diagram, so the two files may diverge: the application only
/// draws what it is given, and a sample chosen for a small region is a later question rather than
/// a coupling declared here.
const DEMO: &str = include_str!("../assets/demo.json");

/// A session over an empty diagram.
///
/// **The diagram's type is not named here**: the session takes what `Default` gives it, and the
/// application never names the model below the editing layer.
pub(crate) fn empty_session() -> Session {
    #[allow(
        clippy::default_trait_access,
        reason = "the diagram's type is not named here: the application depends on the editing \
        layer and not on the model below it"
    )]
    Session::new(Default::default())
}

/// A session over the built-in demonstration description.
///
/// **Embedded at compile time and parsed on demand**, so the demonstration takes the same
/// description reader and produces a fresh session.
pub(crate) fn demo() -> Result<Session, AppError> {
    let diagram = monospace_description::parse(DEMO)?;
    Ok(Session::new(diagram))
}

/// What the application takes on the command line.
const USAGE: &str = "usage: monospace [path]";

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("monospace: {error}");
            ExitCode::FAILURE
        }
    }
}

/// Runs the loop, and reports whatever ended it.
fn run() -> Result<(), AppError> {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let path = match args.as_slice() {
        [] => None,
        [path] => Some(path.as_str()),
        [_, extra, ..] => {
            return Err(format!("unexpected argument `{extra}`\n{USAGE}").into());
        }
    };

    // **Read and parsed before the screen is taken**, so a path that cannot be read is reported
    // on the terminal a person is still looking at. The failure is not a frame, and no snapshot
    // can show it.
    let mut state = AppState::new(start(path)?);
    let mut global = AppGlobal::new(Config::default(), monospace_glyph_sets::all());
    let cfg = RunConfig::default()?.poll(PollCrossterm).poll(PollRendered);

    let outcome = run_tui(
        callbacks::init,
        callbacks::render,
        callbacks::event,
        callbacks::error,
        &mut global,
        &mut state,
        cfg,
    );

    // The failure the error handler took is written out here, with the terminal back: writing it
    // while the loop holds the screen would put it where the next frame paints over it.
    match global.take_failure() {
        Some(failure) => Err(failure),
        None => outcome,
    }
}

/// The session the application starts over: the description at `path`, or an empty diagram.
///
/// **The path is read here and the text parsed in one place**, so a file and the empty diagram
/// take the same reader and there is no second way into the format. The diagram hands back
/// straight into the session and is never named: the editing layer takes it, and the
/// application asks the session for it when it draws.
fn start(path: Option<&str>) -> Result<Session, AppError> {
    match path {
        None => Ok(empty_session()),
        Some(path) => load(path),
    }
}

/// The session a description at `path` describes.
///
/// **The same reader the path argument and the demonstration use**, and the one the open dialog
/// asks for: a file that came from the menu and one that came from the command line cannot
/// disagree about what a description is. **The path is part of both failures**: a file that
/// cannot be read and a text that does not parse both name it, so the dialog can show the reason
/// without knowing which of the two went wrong.
pub(crate) fn load(path: &str) -> Result<Session, AppError> {
    if path.is_empty() {
        return Err("a file name is needed".into());
    }
    let text = std::fs::read_to_string(path).map_err(|error| format!("{path}: {error}"))?;
    let diagram =
        monospace_description::parse(&text).map_err(|error| format!("{path}: {error}"))?;

    Ok(Session::new(diagram))
}

/// Serializes `session`'s current diagram and writes it to `path`.
///
/// **The path is part of every failure**: an empty path, a diagram that cannot be serialized and a
/// file that cannot be written all name it, so the error dialog can show `{path}: {error}` directly.
pub(crate) fn save(path: &str, session: &Session) -> Result<(), AppError> {
    if path.is_empty() {
        return Err(": a file name is needed".into());
    }
    let text = monospace_description::serialize(session.diagram())
        .map_err(|error| format!("{path}: {error}"))?;
    std::fs::write(path, text).map_err(|error| format!("{path}: {error}"))?;
    Ok(())
}

/// Renders `session`'s current diagram over its footprint and writes it to `path`.
///
/// **The path is part of every failure**: an empty path, an unrepresentable span and a file that
/// cannot be written all name it, so the error dialog can show `{path}: {error}` directly. An
/// empty diagram writes an empty file `""`.
pub(crate) fn export_text(path: &str, session: &Session) -> Result<(), AppError> {
    if path.is_empty() {
        return Err(": a file name is needed".into());
    }

    let text =
        render_diagram_text(session.diagram()).map_err(|error| format!("{path}: {error}"))?;
    std::fs::write(path, text).map_err(|error| format!("{path}: {error}"))?;
    Ok(())
}

/// Renders `diagram` over its tight [`monospace_core::FootprintSink`] bounding box, or returns `""`
/// when empty.
fn render_diagram_text(diagram: &monospace_diagram::Diagram) -> Result<String, AppError> {
    use monospace_core::{Buffer, FootprintSink, Pos, Size, Window};

    let mut measure_buf = Buffer::new(Window {
        origin: Pos { x: 0, y: 0 },
        size: Size {
            width: 0,
            height: 0,
        },
    });
    let footprint = {
        let mut sink = FootprintSink::new(&mut measure_buf);
        diagram.draw(&mut sink);
        sink.footprint()
    };

    let Some(footprint) = footprint else {
        return Ok(String::new());
    };

    let width = u16::try_from(i64::from(footprint.max.x) - i64::from(footprint.min.x) + 1)?;
    let height = u16::try_from(i64::from(footprint.max.y) - i64::from(footprint.min.y) + 1)?;
    let window = Window {
        origin: footprint.min,
        size: Size { width, height },
    };

    let mut buffer = Buffer::new(window);
    diagram.draw(&mut buffer);

    let catalog = monospace_glyph_sets::all();
    Ok(monospace_core::render(&buffer, &catalog, window))
}

#[cfg(test)]
mod tests {
    use std::io::Write as _;
    use std::path::PathBuf;

    use super::{demo, export_text, load, save, start};

    /// Writes `contents` to a fresh file under the system temp directory, named after the calling
    /// test so parallel tests never collide.
    fn write_description(name: &str, contents: &str) -> PathBuf {
        let path =
            std::env::temp_dir().join(format!("monospace-test-{name}-{}.json", std::process::id()));
        std::fs::File::create(&path)
            .and_then(|mut file| file.write_all(contents.as_bytes()))
            .expect("temp description file should be writable");
        path
    }

    #[test]
    fn startup_with_no_path_starts_with_a_blank_diagram() {
        let session = start(None).expect("startup with no path succeeds");
        assert_eq!(
            session.diagram().shapes().count(),
            0,
            "startup with no path has shapes in diagram"
        );
    }

    #[test]
    fn the_application_s_own_demonstration_is_a_description_it_reads() {
        assert!(
            demo().is_ok(),
            "the embedded demonstration is not a description this build reads"
        );
    }

    #[test]
    fn the_description_at_the_path_is_read() {
        let path = write_description(
            "reading",
            r#"{
                "next_id": 2,
                "shapes": [ { "kind": "box", "id": 1, "at": { "x": 0, "y": 0 },
                              "size": { "width": 4, "height": 3 }, "stroke": "light" } ]
            }"#,
        );

        assert!(
            start(Some(path.to_str().expect("the temp path is UTF-8"))).is_ok(),
            "a well-formed description at a path was refused"
        );
    }

    #[test]
    fn a_missing_file_fails_naming_the_path() {
        let Err(error) = load("missing.json") else {
            panic!("a missing file succeeded");
        };
        let message = error.to_string();
        assert!(
            message.starts_with("missing.json:"),
            "the error does not start with the path: {message}"
        );
    }

    #[test]
    fn a_malformed_file_fails_naming_the_path() {
        let path = write_description("malformed", "not valid json");
        let path_str = path.to_str().expect("the temp path is UTF-8");
        let Err(error) = load(path_str) else {
            panic!("malformed json succeeded");
        };
        let message = error.to_string();
        assert!(
            message.starts_with(&format!("{path_str}:")),
            "the error does not start with the path: {message}"
        );
    }

    #[test]
    fn an_empty_path_fails() {
        let Err(error) = load("") else {
            panic!("an empty path succeeded");
        };
        assert_eq!(error.to_string(), "a file name is needed");
    }

    #[test]
    fn saving_to_an_empty_path_fails_naming_empty_prefix() {
        let session = start(None).expect("demo starts");
        let Err(error) = save("", &session) else {
            panic!("saving to an empty path succeeded");
        };
        assert_eq!(error.to_string(), ": a file name is needed");
    }

    #[test]
    fn saving_a_session_writes_the_description_and_overwrites() {
        let session = start(None).expect("demo starts");
        let path =
            std::env::temp_dir().join(format!("monospace-test-saving-{}.json", std::process::id()));
        let path_str = path.to_str().expect("valid utf8");

        assert!(save(path_str, &session).is_ok());
        let read = std::fs::read_to_string(&path).expect("file was written");
        assert!(read.contains("\"next_id\":"));

        // Overwrites without error
        assert!(save(path_str, &session).is_ok());
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn exporting_to_an_empty_path_fails_naming_empty_prefix() {
        let session = start(None).expect("blank session starts");
        let Err(error) = export_text("", &session) else {
            panic!("exporting to an empty path succeeded");
        };
        assert_eq!(error.to_string(), ": a file name is needed");
    }

    #[test]
    fn exporting_an_empty_diagram_writes_an_empty_file() {
        let session = start(None).expect("blank session starts");
        let path = std::env::temp_dir().join(format!(
            "monospace-test-export-empty-{}.txt",
            std::process::id()
        ));
        let path_str = path.to_str().expect("valid utf8");

        assert!(export_text(path_str, &session).is_ok());
        let read = std::fs::read_to_string(&path).expect("file was written");
        assert_eq!(read, "");

        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn exporting_a_session_writes_the_footprint_text_and_overwrites() {
        let description = write_description(
            "export-source",
            r#"{
                "next_id": 2,
                "shapes": [ { "kind": "box", "id": 1, "at": { "x": 3, "y": 2 },
                              "size": { "width": 6, "height": 3 }, "stroke": "light", "text": "Node" } ]
            }"#,
        );
        let session =
            start(Some(description.to_str().expect("valid utf8"))).expect("session starts");
        let path = std::env::temp_dir().join(format!(
            "monospace-test-export-box-{}.txt",
            std::process::id()
        ));
        let path_str = path.to_str().expect("valid utf8");
        std::fs::write(&path, "old content").expect("temp file is writable");

        assert!(export_text(path_str, &session).is_ok());
        let read = std::fs::read_to_string(&path).expect("file was written");
        assert_eq!(read, "┌────┐\n│Node│\n└────┘\n");

        let _ = std::fs::remove_file(&description);
        let _ = std::fs::remove_file(&path);
    }
}