mod app;
mod error;
mod format;
mod sampler;
mod terminal_backend;
mod ui;
use std::{
env,
io::{self, Stdout},
time::Duration,
};
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui_core::terminal::Terminal;
use crate::app::App;
use crate::error::Result;
use crate::terminal_backend::CrosstermBackend;
const DEFAULT_INTERVAL_MS: u64 = 1_000;
const MIN_INTERVAL_MS: u64 = 250;
const MAX_INTERVAL_MS: u64 = 10_000;
#[derive(Debug, PartialEq, Eq)]
struct Args {
interval: u64,
filter: Option<String>,
}
fn main() -> Result<()> {
let Some(args) = parse_args()? else {
return Ok(());
};
let interval = Duration::from_millis(args.interval);
let mut session = TerminalSession::enter()?;
let result = match App::new(interval, args.filter) {
Ok(mut app) => app.run(session.terminal_mut()),
Err(error) => Err(error),
};
let restore_result = session.restore();
restore_result?;
result
}
fn parse_args() -> Result<Option<Args>> {
parse_args_from(env::args().skip(1))
}
fn parse_args_from<I, S>(args: I) -> Result<Option<Args>>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut args = args.into_iter().map(Into::into);
let mut interval = DEFAULT_INTERVAL_MS;
let mut filter = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"-h" | "--help" => {
print_help();
return Ok(None);
}
"-V" | "--version" => {
println!("monitr {}", env!("CARGO_PKG_VERSION"));
return Ok(None);
}
"-i" | "--interval" => {
let Some(value) = args.next() else {
return Err(error::message(format!(
"{arg} requires a millisecond value"
)));
};
interval = parse_interval(&value)?;
}
"-f" | "--filter" => {
let Some(value) = args.next() else {
return Err(error::message(format!("{arg} requires a filter value")));
};
filter = Some(value);
}
_ if arg.starts_with("--interval=") => {
interval = parse_interval(&arg["--interval=".len()..])?;
}
_ if arg.starts_with("--filter=") => {
filter = Some(arg["--filter=".len()..].to_string());
}
_ => {
return Err(error::message(format!(
"unknown option: {arg}. Run monitr --help for usage."
)));
}
}
}
Ok(Some(Args { interval, filter }))
}
fn parse_interval(value: &str) -> Result<u64> {
let interval = value.parse().map_err(|_| {
error::message(format!("invalid interval `{value}`; expected milliseconds"))
})?;
if !(MIN_INTERVAL_MS..=MAX_INTERVAL_MS).contains(&interval) {
return Err(error::message(format!(
"invalid interval `{value}`; expected {MIN_INTERVAL_MS}-{MAX_INTERVAL_MS} milliseconds"
)));
}
Ok(interval)
}
fn print_help() {
println!(
"\
A lightweight macOS activity monitor TUI
Usage: monitr [OPTIONS]
Options:
-i, --interval <MS> Refresh interval in milliseconds ({MIN_INTERVAL_MS}-{MAX_INTERVAL_MS}) [default: {DEFAULT_INTERVAL_MS}]
-f, --filter <FILTER> Start with a process filter
-h, --help Print help
-V, --version Print version"
);
}
type CrosstermTerminal = Terminal<CrosstermBackend<Stdout>>;
struct TerminalSession {
terminal: CrosstermTerminal,
active: bool,
}
impl TerminalSession {
fn enter() -> Result<Self> {
let terminal = enter_terminal()?;
Ok(Self {
terminal,
active: true,
})
}
fn terminal_mut(&mut self) -> &mut CrosstermTerminal {
&mut self.terminal
}
fn restore(&mut self) -> Result<()> {
restore_terminal(&mut self.terminal)?;
self.active = false;
Ok(())
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
if self.active {
let _ = restore_terminal(&mut self.terminal);
}
}
}
fn enter_terminal() -> Result<CrosstermTerminal> {
enable_raw_mode()?;
if let Err(error) = execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture) {
let _ = disable_raw_mode();
return Err(error.into());
}
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = match Terminal::new(backend) {
Ok(terminal) => terminal,
Err(error) => {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
return Err(error.into());
}
};
if let Err(error) = terminal.clear() {
let _ = restore_terminal(&mut terminal);
return Err(error.into());
}
Ok(terminal)
}
fn restore_terminal(terminal: &mut CrosstermTerminal) -> Result<()> {
let raw_result = disable_raw_mode();
let screen_result = execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let cursor_result = terminal.show_cursor();
raw_result?;
screen_result?;
cursor_result?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{Args, parse_args_from, parse_interval};
#[test]
fn parses_default_args() {
assert_eq!(
parse_args_from(Vec::<String>::new()).unwrap(),
Some(Args {
interval: 1000,
filter: None,
})
);
}
#[test]
fn parses_interval_and_filter_args() {
assert_eq!(
parse_args_from(["--interval=750", "--filter", "codex"]).unwrap(),
Some(Args {
interval: 750,
filter: Some("codex".to_string()),
})
);
}
#[test]
fn rejects_invalid_interval() {
let error = parse_interval("fast").unwrap_err().to_string();
assert!(error.contains("invalid interval"));
}
#[test]
fn rejects_out_of_range_interval() {
let error = parse_interval("100").unwrap_err().to_string();
assert!(error.contains("250-10000"));
}
}