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;
const DEMO: &str = include_str!("../assets/demo.json");
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())
}
pub(crate) fn demo() -> Result<Session, AppError> {
let diagram = monospace_description::parse(DEMO)?;
Ok(Session::new(diagram))
}
const USAGE: &str = "usage: monospace [path]";
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("monospace: {error}");
ExitCode::FAILURE
}
}
}
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());
}
};
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,
);
match global.take_failure() {
Some(failure) => Err(failure),
None => outcome,
}
}
fn start(path: Option<&str>) -> Result<Session, AppError> {
match path {
None => Ok(empty_session()),
Some(path) => load(path),
}
}
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))
}
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(())
}
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(())
}
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};
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\":"));
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);
}
}