mod app;
mod arrange;
mod calc;
mod chart;
mod clipboard;
mod config;
mod dateinput;
#[cfg(test)]
mod docs;
mod feed;
mod fetch;
mod frame;
mod glyphs;
mod grid;
mod ical;
mod layout_edit;
mod link;
mod migrate;
mod note;
mod panel;
mod picker;
mod plugin;
mod poll;
mod prompt;
mod quote;
mod samples;
mod selection;
mod state;
mod store;
mod task;
mod textarea;
mod textfield;
mod theme;
mod theme_picker;
mod themes;
mod update;
mod upgrade;
mod watch;
mod widgets;
mod zones;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result};
use ratatui::crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
EnableFocusChange, EnableMouseCapture,
};
use ratatui::crossterm::execute;
use crate::app::App;
use crate::config::Config;
const HELP: &str = concat!(
"mirador ",
env!("CARGO_PKG_VERSION"),
" — a personal information dashboard for your terminal
USAGE:
mirador [OPTIONS]
OPTIONS:
-c, --config <PATH> Use a specific config file
--print-config Print the default config to stdout and exit
--config-path Print the resolved config path and exit
--migrate-config Update a config written by an older version
--reset-config Replace the config with the defaults, keeping a copy
--factory-reset Start over: config, preferences, tasks, notes and
watchlist all set aside, nothing deleted
--update Update through the installer or Cargo and exit
-y, --yes Do not ask for confirmation
-h, --help Print this help and exit
-V, --version Print the version and exit
KEYS:
Tab / Shift+Tab Move focus between panels
1 - 9 Jump straight to a panel
w Choose which panels are shown
m Rearrange the panels
t Choose a theme
Ctrl+←→↑↓ Resize the focused panel
? Show all key bindings, and the version
q / Ctrl+C Quit
On first run mirador writes a commented config file you can edit. Run
`mirador --config-path` to find it.
"
);
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Default)]
struct Args {
config: Option<PathBuf>,
print_config: bool,
show_config_path: bool,
migrate_config: bool,
reset_config: bool,
factory_reset: bool,
update: bool,
assume_yes: bool,
help: bool,
version: bool,
}
fn parse_args(raw: impl Iterator<Item = String>) -> Result<Args> {
let mut args = Args::default();
let mut iter = raw.into_iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"-h" | "--help" => args.help = true,
"-V" | "--version" => args.version = true,
"--print-config" => args.print_config = true,
"--config-path" => args.show_config_path = true,
"--migrate-config" => args.migrate_config = true,
"--reset-config" => args.reset_config = true,
"--factory-reset" => args.factory_reset = true,
"--update" => args.update = true,
"-y" | "--yes" => args.assume_yes = true,
"-c" | "--config" => {
let value = iter.next().ok_or_else(|| {
anyhow::anyhow!("`{arg}` needs a path, e.g. `{arg} ~/mirador.toml`")
})?;
args.config = Some(PathBuf::from(value));
}
other if other.starts_with("--config=") => {
args.config = Some(PathBuf::from(&other["--config=".len()..]));
}
other => {
anyhow::bail!("unrecognised argument `{other}`. Run `mirador --help` for usage.")
}
}
}
Ok(args)
}
fn main() -> ExitCode {
upgrade::cleanup_stale();
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("mirador: {error:#}");
ExitCode::FAILURE
}
}
}
fn reset_config(path: &Path, assume_yes: bool) -> Result<()> {
let exists = path.try_exists().unwrap_or(false);
if !assume_yes {
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"refusing to reset {} without a confirmation.\n\nThere is no \
terminal to ask on, so re-run with `--yes` if you mean it.",
path.display()
);
}
if exists {
println!("This replaces {} with the defaults.", path.display());
println!("Your current config will be copied alongside it first.");
} else {
println!("There is no config at {}.", path.display());
println!("This writes the defaults there.");
}
println!("Preferences you changed from the keyboard are put aside too.");
println!("Your tasks, notes and watchlist are left alone.");
print!("Go ahead? [y/N] ");
std::io::stdout().flush().context("writing the prompt")?;
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("reading your answer")?;
if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
println!("Left {} alone.", path.display());
return Ok(());
}
}
let backup = Config::reset(path)?;
println!("Wrote the default config to {}.", path.display());
if let Some(backup) = backup {
println!("Your previous config is at {}.", backup.display());
}
match Config::state_path() {
Ok(state) => match state::clear(&state)? {
Some(moved) => println!(
"Put your remembered preferences aside; they are at {}.",
moved.display()
),
None => println!("There were no remembered preferences to clear."),
},
Err(e) => println!("Could not find the preferences file to clear it: {e}"),
}
println!("Left your tasks, notes and watchlist alone.");
Ok(())
}
fn factory_reset(config_path: &Path, assume_yes: bool) -> Result<()> {
let data_files = Config::owned_data_files().unwrap_or_default();
let present: Vec<&PathBuf> = data_files
.iter()
.filter(|p| p.try_exists().unwrap_or(false))
.collect();
let config_exists = config_path.try_exists().unwrap_or(false);
if !assume_yes {
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"refusing to reset everything without a confirmation.\n\nThere is \
no terminal to ask on, so re-run with `--yes` if you mean it."
);
}
println!("This starts mirador over from scratch.");
println!();
if config_exists {
println!(" Replaced with the defaults:");
println!(" {}", config_path.display());
println!();
}
if present.is_empty() {
println!(" There is nothing else to set aside.");
} else {
println!(" Set aside, each kept as a `.bak` beside itself:");
for path in &present {
println!(" {}", path.display());
}
}
println!();
println!("Nothing is deleted. Your tasks and notes stay on disk under their");
println!("backup names, and mirador starts again with fresh ones.");
print!("Go ahead? [y/N] ");
std::io::stdout().flush().context("writing the prompt")?;
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("reading your answer")?;
if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
println!("Left everything alone.");
return Ok(());
}
}
let backup = Config::reset(config_path)?;
println!("Wrote the default config to {}.", config_path.display());
if let Some(backup) = backup {
println!("Your previous config is at {}.", backup.display());
}
let mut moved = 0usize;
for path in data_files {
if let Some(to) = store::move_aside(&path)? {
println!("Set aside {} -> {}", path.display(), to.display());
moved += 1;
}
}
if moved == 0 {
println!("There was nothing else to set aside.");
}
println!("Next launch starts fresh: example tasks and notes, and the");
println!("watchlist seeded from `[stocks].symbols` in the new config.");
Ok(())
}
fn enable_terminal_modes(mouse: bool) -> Result<()> {
let _ = execute!(std::io::stdout(), EnableFocusChange, EnableBracketedPaste);
{
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = execute!(std::io::stdout(), DisableBracketedPaste, DisableFocusChange);
previous(info);
}));
}
if mouse {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = execute!(std::io::stdout(), DisableMouseCapture);
previous(info);
}));
execute!(std::io::stdout(), EnableMouseCapture).context("enabling mouse reporting")?;
}
Ok(())
}
fn run() -> Result<()> {
let args = parse_args(std::env::args().skip(1))?;
if args.help {
print!("{HELP}");
return Ok(());
}
if args.version {
println!("mirador {}", env!("CARGO_PKG_VERSION"));
return Ok(());
} else if args.update {
return upgrade::run();
}
if args.print_config {
print!("{}", config::DEFAULT_CONFIG);
return Ok(());
}
if args.show_config_path {
let path = match args.config {
Some(p) => p,
None => Config::default_path()?,
};
println!("{}", path.display());
return Ok(());
}
if args.reset_config {
let path = match args.config.clone() {
Some(p) => p,
None => Config::default_path()?,
};
return reset_config(&path, args.assume_yes);
}
if args.factory_reset {
let path = match args.config.clone() {
Some(p) => p,
None => Config::default_path()?,
};
return factory_reset(&path, args.assume_yes);
}
if args.migrate_config {
let path = match args.config {
Some(p) => p,
None => Config::default_path()?,
};
let report = migrate::migrate_file(&path)?;
if report.is_empty() {
println!("{} is already current; nothing to do.", path.display());
} else {
println!("Updated {}:", path.display());
for change in &report.changes {
println!(" - {change}");
}
if let Some(backup) = &report.backup {
println!("\nYour original is at {}.", backup.display());
}
}
return Ok(());
}
let (mut config, config_path) = Config::load(args.config)?;
let state_path = Config::state_path().ok();
let saved = state_path
.as_deref()
.map(crate::state::UiState::load)
.unwrap_or_default();
let baseline = crate::state::UiState::from_config(&config);
config.apply_state(&saved);
config.apply_state_theme(&saved, &config_path);
let mouse = config.general.mouse;
let updates = crate::update::spawn(
config.general.check_for_updates,
Config::update_cache_path().ok(),
);
let mut app = App::new(config)?;
app.watch_for_updates(updates);
app.write_layout_to(config_path);
if let Some(path) = state_path {
app.remember_preferences_at(path, saved, baseline);
}
let mut terminal = ratatui::init();
enable_terminal_modes(mouse)?;
let result = app.run(&mut terminal);
if mouse {
let _ = execute!(std::io::stdout(), DisableMouseCapture);
}
let _ = execute!(std::io::stdout(), DisableBracketedPaste);
let _ = execute!(std::io::stdout(), DisableFocusChange);
ratatui::restore();
result
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Args> {
parse_args(args.iter().map(|s| (*s).to_string()))
}
#[test]
fn no_arguments_is_valid() {
let args = parse(&[]).unwrap();
assert!(args.config.is_none());
assert!(!args.help);
}
#[test]
fn flags_parse_in_both_short_and_long_form() {
assert!(parse(&["-h"]).unwrap().help);
assert!(parse(&["--help"]).unwrap().help);
assert!(parse(&["-V"]).unwrap().version);
assert!(parse(&["--version"]).unwrap().version);
assert!(parse(&["--print-config"]).unwrap().print_config);
assert!(parse(&["--config-path"]).unwrap().show_config_path);
assert!(parse(&["--migrate-config"]).unwrap().migrate_config);
assert!(parse(&["--reset-config"]).unwrap().reset_config);
assert!(parse(&["--factory-reset"]).unwrap().factory_reset);
assert!(parse(&["--update"]).unwrap().update);
assert!(!parse(&["--reset-config"]).unwrap().factory_reset);
assert!(!parse(&["--factory-reset"]).unwrap().reset_config);
assert!(parse(&["-y"]).unwrap().assume_yes);
assert!(parse(&["--yes"]).unwrap().assume_yes);
}
#[test]
fn yes_on_its_own_resets_nothing() {
let args = parse(&["--yes"]).unwrap();
assert!(args.assume_yes);
assert!(!args.reset_config);
}
#[test]
fn config_accepts_separate_and_joined_values() {
assert_eq!(
parse(&["-c", "/tmp/a.toml"]).unwrap().config,
Some(PathBuf::from("/tmp/a.toml"))
);
assert_eq!(
parse(&["--config", "/tmp/b.toml"]).unwrap().config,
Some(PathBuf::from("/tmp/b.toml"))
);
assert_eq!(
parse(&["--config=/tmp/c.toml"]).unwrap().config,
Some(PathBuf::from("/tmp/c.toml"))
);
}
#[test]
fn a_config_flag_without_a_value_explains_itself() {
let err = parse(&["--config"]).expect_err("must fail");
assert!(err.to_string().contains("needs a path"), "got: {err}");
}
#[test]
fn unknown_arguments_point_at_help() {
let err = parse(&["--wat"]).expect_err("must fail");
assert!(err.to_string().contains("--help"), "got: {err}");
}
#[test]
fn the_default_config_is_valid_toml_and_parses_into_config() {
let parsed: Config =
toml::from_str(config::DEFAULT_CONFIG).expect("bundled config must parse");
assert!(!parsed.layout.rows.is_empty());
}
#[test]
fn help_text_documents_every_flag_the_parser_accepts() {
for flag in [
"--config",
"--print-config",
"--config-path",
"--migrate-config",
"--reset-config",
"--factory-reset",
"--update",
"--yes",
"--help",
"--version",
] {
assert!(HELP.contains(flag), "{flag} is undocumented");
}
}
}