use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Context, anyhow};
use crossterm::tty::IsTty;
use crate::cli::WatchArgs;
use crate::color::{ColorProfile, SystemEnv};
use crate::core::child::{ChildSlot, TickOutcome, run_tick, spawn_tick};
use crate::core::duration::parse_interval;
use crate::core::measure::shift_chop;
use crate::core::pager::{PagerCommand, resolve_pagers};
use crate::core::schedule::{Due, TickSchedule};
use crate::core::snapshot::{snapshot_body, snapshot_stamp, write_snapshot};
use crate::core::trigger::{DebounceGate, MtimeWatchSet, TriggerSpec, parse_trigger};
use crate::exit::{AppError, AppResult};
use crate::style_spec::StyleSpec;
use crate::term::history::History;
use crate::term::inline::{InlineRenderer, truncate_to_rows};
use crate::term::marks::{GUTTER_COLS, LineMark, changed_marks, mark_cells, prefix_rows};
use crate::term::scroll::{
HSHIFT_STEP, LiveScroll, ScrollState, ScrollStep, paused_notice, scrolled_notice,
};
use crate::term::tap::TapEvent;
#[cfg(unix)]
use crate::term::tap::{TapChunk, TapScanner, TriggerReader, TtyTap};
#[cfg(unix)]
use crate::term::theme_notify::{OscColorKind, ThemeNotifyGuard, classify_colors, may_subscribe};
use crate::term::tty::{ConsoleUtf8Guard, RawModeGuard};
use crate::theme::{Appearance, AppearanceSource, Palette};
use crate::ui::key::{Key, from_crossterm};
const SLICE: Duration = Duration::from_millis(50);
struct Live {
lines: Vec<String>,
hash: u64,
changed_at: jiff::Timestamp,
since: String,
}
#[cfg_attr(windows, allow(unused_mut))]
pub fn run(args: WatchArgs, profile: ColorProfile, mut palette: Palette) -> AppResult {
let triggers = args
.trigger
.iter()
.map(|spec| parse_trigger(spec))
.collect::<anyhow::Result<Vec<TriggerSpec>>>()?;
let interval = resolve_interval(args.interval.as_deref(), !triggers.is_empty())?;
let debounce = parse_interval(&args.trigger_debounce)?;
let (interrupted, terminated) = register_signals()?;
let stdout = std::io::stdout();
let is_tty = stdout.is_tty();
let mut renderer = InlineRenderer::new(stdout.lock())
.with_cursor_hidden(is_tty && !args.no_hide_cursor)
.with_sync_output(is_tty && !args.no_sync)
.with_clear_screen(is_tty && args.clear);
let interactive = is_tty && !args.once;
if !interactive
&& triggers
.iter()
.any(|trigger| !matches!(trigger, TriggerSpec::File(_)))
{
return Err(
anyhow!("fifo:/fd: triggers need an interactive terminal; use file:PATH").into(),
);
}
let _raw_guard = if interactive {
Some(RawModeGuard::enable().context("enabling raw mode")?)
} else {
None
};
#[cfg(unix)]
let tap = if interactive {
TtyTap::spawn().ok()
} else {
None
};
#[cfg(unix)]
let mut scanner = TapScanner::new();
#[cfg(unix)]
let mut theme_sub = may_subscribe(palette.source, profile, interactive && tap.is_some())
.then(|| ThemeNotifyGuard::subscribe(std::io::stdout()))
.transpose()
.context("subscribing to theme notifications")?;
#[cfg(unix)]
let mut verify = VerifyState::default();
let title_line = args.title.as_ref().map(|title| {
StyleSpec {
bold: true,
..StyleSpec::default()
}
.render(title, profile)
});
let faint = StyleSpec {
faint: true,
..StyleSpec::default()
};
let mut schedule = TickSchedule::new(interval);
#[cfg_attr(windows, allow(clippy::unnecessary_filter_map))]
let mut file_watch = MtimeWatchSet::new(
triggers
.iter()
.filter_map(|trigger| match trigger {
TriggerSpec::File(path) => Some(path.clone()),
#[cfg(unix)]
_ => None,
})
.collect(),
);
file_watch.fired();
let mut gate = DebounceGate::new(debounce);
#[cfg(unix)]
let mut trigger_readers: Vec<ReaderSlot> = {
let wake = tap.as_ref().map(TtyTap::sender);
let mut readers = Vec::new();
for spec in &triggers {
if matches!(spec, TriggerSpec::File(_)) {
continue; }
if let TriggerSpec::Fd(0) = spec {
return Err(anyhow!(
"fd:0 is the terminal's own input while watch reads keys; \
use another descriptor"
)
.into());
}
readers.push(ReaderSlot {
reader: TriggerReader::open(spec, wake.clone())?,
spec: spec.clone(),
ended_seen: false,
});
}
readers
};
let interval_label = args
.interval
.as_deref()
.or(triggers.is_empty().then_some("2s"));
let live_tail = live_suffix(args.once, interval_label, !triggers.is_empty());
let slot = ChildSlot::default();
let _shutdown = slot.guard();
let (tx, rx) = std::sync::mpsc::channel::<TickOutcome>();
let mut previous_key: Option<PaintKey> = None;
let mut live: Option<Live> = None;
let mut pause: Option<PauseState> = None;
let mut live_scroll: Option<LiveScroll> = None;
let mut history = History::new();
let mut view = ViewState {
wrap: !args.no_wrap,
hshift: 0,
gutter: false,
highlight: false,
alt_time: false,
};
loop {
if interrupted.load(Ordering::Relaxed) {
renderer.finish().context("restoring terminal")?;
return Err(AppError::Aborted);
}
if terminated.load(Ordering::Relaxed) {
renderer.finish().context("restoring terminal")?;
return Ok(());
}
if schedule.poll(Instant::now()) == Due::Spawn {
let size = crossterm::terminal::size().unwrap_or((80, 24));
let mut inline = args.once;
if !inline {
let command = build_command(&args, interactive, palette.appearance, size);
inline = spawn_tick(command, slot.clone(), tx.clone()).is_err();
}
if inline {
let command = build_command(&args, interactive, palette.appearance, size);
let _ = tx.send(run_tick(command));
}
}
if let Ok(outcome) = rx.try_recv() {
let (stdout, stderr) = match outcome.spawn_error {
Some(err) if args.once => {
return Err(anyhow!("running {:?}: {err}", args.command[0]).into());
}
Some(err) => (
format!("watch: {:?}: {err}", args.command[0]).into_bytes(),
Vec::new(),
),
None => (outcome.stdout, outcome.stderr),
};
let mut combined = stdout.clone();
combined.extend_from_slice(&stderr);
let hash = signature(&combined);
let now = outcome.at;
let (changed_at, since) = match live.take() {
Some(prev) if prev.hash == hash => (prev.changed_at, prev.since),
_ => (now, local_hms(now)),
};
let size = crossterm::terminal::size().unwrap_or((80, 24));
let current = Live {
lines: compose_frame(title_line.as_ref(), &stdout, &stderr, is_tty),
hash,
changed_at,
since,
};
if is_tty {
history.record(hash, ¤t.lines, now);
}
if let Some(p) = pause.as_mut() {
let window = usize::from(window_rows(args.max_height, size.1));
p.scroll = p.scroll.clamp(p.frozen.len(), window);
}
if let Some(ls) = live_scroll {
let window = usize::from(window_rows(args.max_height, size.1));
let re = ls.reanchor(current.lines.len(), window);
live_scroll = (!re.at_top()).then_some(re);
}
let key = paint_key(
pause.as_ref(),
live_scroll,
current.hash,
palette.appearance,
size,
view,
displayed_age(
pause.as_ref(),
live_scroll,
view.alt_time,
current.changed_at,
),
);
if previous_key != Some(key) {
previous_key = Some(key);
if is_tty {
repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
¤t,
&live_tail,
&palette,
view,
None,
size,
args.max_height,
&faint,
profile,
&history,
)?;
} else {
let mut out = std::io::stdout().lock();
for line in ¤t.lines {
writeln!(out, "{line}").context("writing")?;
}
out.flush().context("flushing")?;
if !stderr.is_empty() {
let mut err = std::io::stderr().lock();
err.write_all(&stderr).context("writing stderr")?;
err.flush().context("flushing stderr")?;
}
}
}
live = Some(current);
if args.once {
break;
}
schedule.completed(Instant::now());
}
if !triggers.is_empty() {
let now = Instant::now();
#[cfg(unix)]
let mut ended_notices: Vec<String> = Vec::new();
#[cfg(unix)]
for slot in &mut trigger_readers {
if slot.reader.fired().swap(false, Ordering::SeqCst) {
gate.fire(now);
}
if !slot.ended_seen && slot.reader.ended().load(Ordering::SeqCst) {
slot.ended_seen = true; ended_notices.push(format!("trigger ended: {}", slot.spec));
}
}
if file_watch.fired() {
gate.fire(now);
}
if gate.due(now) {
schedule.request_respawn();
}
#[cfg(unix)]
if let (false, Some(l)) = (ended_notices.is_empty(), live.as_ref()) {
let notice = ended_notices.join(" · ");
let size = crossterm::terminal::size().unwrap_or((80, 24));
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
l,
&live_tail,
&palette,
view,
Some(notice),
size,
args.max_height,
&faint,
profile,
&history,
)?);
}
}
let nap = schedule.nap(Instant::now(), SLICE);
if !interactive {
std::thread::sleep(nap);
continue;
}
if let (Some(prev), Some(l)) = (previous_key, live.as_ref()) {
let want_age = displayed_age(pause.as_ref(), live_scroll, view.alt_time, l.changed_at);
if prev.age_secs != want_age {
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
l,
&live_tail,
&palette,
view,
None,
crossterm::terminal::size().unwrap_or((80, 24)),
args.max_height,
&faint,
profile,
&history,
)?);
}
}
#[cfg(unix)]
if let Some(sub) = theme_sub.as_mut() {
if verify.pending && verify.in_flight_until.is_none() {
verify.pending = false;
if sub.request_colors().is_ok() {
verify.fg = None;
verify.in_flight_until = Some(Instant::now() + crate::theme::PROBE_TIMEOUT);
}
}
if verify
.in_flight_until
.is_some_and(|until| Instant::now() >= until)
{
verify.in_flight_until = None;
verify.fg = None;
}
}
#[cfg(unix)]
let events = match tap.as_ref() {
Some(tap) => {
let waited = Instant::now();
match tap.recv_timeout(nap) {
Some(TapChunk::Tty(chunk)) => scanner.feed(&chunk),
Some(TapChunk::Trigger) => scanner.idle(waited.elapsed()),
None => scanner.idle(nap),
}
}
None => crossterm_slice(nap)?,
};
#[cfg(windows)]
let events = crossterm_slice(nap)?;
for event in events {
match event {
TapEvent::Key(key) => {
match action_for(key, mode_of(pause.as_ref(), live_scroll)) {
WatchAction::Abort => {
renderer.finish().context("restoring terminal")?;
return Err(AppError::Aborted);
}
WatchAction::Quit => {
renderer.finish().context("restoring terminal")?;
return Ok(());
}
action @ (WatchAction::Page | WatchAction::Help) => {
let Some(live) = live.as_ref() else { continue };
let help;
let content: &[String] = if action == WatchAction::Help {
help = help_lines(&triggers);
&help
} else {
pause.as_ref().map_or(&live.lines, |p| &p.frozen)
};
#[cfg(unix)]
if let Some(sub) = theme_sub.as_mut() {
let _ = sub.suspend();
}
#[cfg(unix)]
let handed_off = tap.as_ref().is_none_or(|tap| tap.pause());
#[cfg(windows)]
let handed_off = true;
let pager_notice = if handed_off {
page_frame(content, &mut renderer)
} else {
Some(
"pager unavailable: the input reader did not yield; try again"
.to_string(),
)
};
#[cfg(unix)]
{
if let Some(tap) = tap.as_ref() {
tap.resume();
}
if let Some(sub) = theme_sub.as_mut() {
let _ = sub.resume();
}
verify = VerifyState::default();
}
let size = crossterm::terminal::size().unwrap_or((80, 24));
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
pager_notice,
size,
args.max_height,
&faint,
profile,
&history,
)?);
}
WatchAction::Scroll(step) => {
let Some(live) = live.as_ref() else { continue };
let size = crossterm::terminal::size().unwrap_or((80, 24));
let window = usize::from(window_rows(args.max_height, size.1));
if let Some(p) = pause.as_mut() {
p.scroll = p.scroll.step(step, p.frozen.len(), window);
} else if let Some(ls) = live_scroll {
let stepped = ls.step(step, live.lines.len(), window);
live_scroll = (!stepped.at_top()).then_some(stepped);
} else {
let ls = LiveScroll::start(step, live.lines.len(), window);
if ls.at_top() {
continue;
}
live_scroll = Some(ls);
}
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
None,
size,
args.max_height,
&faint,
profile,
&history,
)?);
}
WatchAction::Resume => {
let Some(live) = live.as_ref() else { continue };
if pause.take().is_some() {
schedule.request_now();
}
live_scroll = None;
let size = crossterm::terminal::size().unwrap_or((80, 24));
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
None,
size,
args.max_height,
&faint,
profile,
&history,
)?);
}
WatchAction::Freeze => {
let Some(live) = live.as_ref() else { continue };
let size = crossterm::terminal::size().unwrap_or((80, 24));
let window = usize::from(window_rows(args.max_height, size.1));
let offset = live_scroll.map_or(0, LiveScroll::offset);
pause.get_or_insert_with(|| PauseState {
frozen: live.lines.clone(),
scroll: ScrollState::at(offset).clamp(live.lines.len(), window),
content: live.hash,
appearance: palette.appearance,
viewed_at: jiff::Timestamp::now(),
history_seq: history.newest_seq(),
});
live_scroll = None;
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
None,
size,
args.max_height,
&faint,
profile,
&history,
)?);
}
action @ (WatchAction::ScrubBack | WatchAction::ScrubForward) => {
let Some(live) = live.as_ref() else { continue };
let anchor = pause
.as_ref()
.and_then(|p| p.history_seq)
.and_then(|seq| history.nearest(seq).map(|e| e.seq))
.or_else(|| history.newest_seq());
let Some(anchor) = anchor else { continue };
let entry = if action == WatchAction::ScrubBack {
history.prev(anchor)
} else {
history.next(anchor)
};
let Some(entry) = entry else { continue };
let size = crossterm::terminal::size().unwrap_or((80, 24));
let window = usize::from(window_rows(args.max_height, size.1));
let scroll = pause
.as_ref()
.map(|p| p.scroll)
.or_else(|| live_scroll.map(|ls| ScrollState::at(ls.offset())))
.unwrap_or_default();
pause = Some(PauseState {
frozen: entry.frame.clone(),
scroll: scroll.clamp(entry.frame.len(), window),
content: entry.sig,
appearance: pause
.as_ref()
.map_or(palette.appearance, |p| p.appearance),
viewed_at: entry.at,
history_seq: Some(entry.seq),
});
live_scroll = None;
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
None,
size,
args.max_height,
&faint,
profile,
&history,
)?);
}
action @ (WatchAction::ToggleWrap
| WatchAction::ShiftLeft
| WatchAction::ShiftRight
| WatchAction::ToggleGutter
| WatchAction::ToggleHighlight
| WatchAction::ToggleTime) => {
let Some(live) = live.as_ref() else { continue };
match action {
WatchAction::ToggleWrap => view.wrap = !view.wrap,
WatchAction::ToggleGutter => view.gutter = !view.gutter,
WatchAction::ToggleHighlight => {
view.highlight = !view.highlight;
}
WatchAction::ToggleTime => {
view.alt_time = !view.alt_time;
}
WatchAction::ShiftLeft => {
view.hshift = view.hshift.saturating_sub(HSHIFT_STEP);
}
_ => view.hshift += HSHIFT_STEP,
}
let size = crossterm::terminal::size().unwrap_or((80, 24));
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
None,
size,
args.max_height,
&faint,
profile,
&history,
)?);
}
WatchAction::Snapshot => {
let Some(live) = live.as_ref() else { continue };
let text = snapshot_frame(
pause.as_ref().map_or(&live.lines, |p| &p.frozen),
&args,
);
let size = crossterm::terminal::size().unwrap_or((80, 24));
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
Some(text),
size,
args.max_height,
&faint,
profile,
&history,
)?);
}
WatchAction::Ignore => {}
}
}
#[cfg(unix)]
TapEvent::ThemeNotification(_) => {
verify.pending = true;
}
#[cfg(unix)]
TapEvent::OscColor(kind, color) => {
if let Some(verdict) = verify.reply(kind, color)
&& adopt(&mut palette, verdict)
{
let debug_notice = std::env::var_os("RAT_DEBUG_APPEARANCE")
.is_some()
.then(|| format!("appearance → {}", verdict.as_str()));
schedule.request_respawn();
if let Some(live) = live.as_ref() {
let size = crossterm::terminal::size().unwrap_or((80, 24));
previous_key = Some(repaint(
&mut renderer,
pause.as_ref(),
live_scroll,
live,
&live_tail,
&palette,
view,
debug_notice,
size,
args.max_height,
&faint,
profile,
&history,
)?);
}
}
}
#[cfg(windows)]
TapEvent::ThemeNotification(_) | TapEvent::OscColor(..) => {}
}
}
}
renderer.finish().context("restoring terminal")?;
Ok(())
}
#[cfg(unix)]
struct ReaderSlot {
reader: TriggerReader,
spec: TriggerSpec,
ended_seen: bool,
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
enum FrameMode {
Live,
LiveScrolled,
Paused,
}
fn mode_of(pause: Option<&PauseState>, live_scroll: Option<LiveScroll>) -> FrameMode {
if pause.is_some() {
FrameMode::Paused
} else if live_scroll.is_some() {
FrameMode::LiveScrolled
} else {
FrameMode::Live
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
enum WatchAction {
Abort,
Quit,
Page,
Help,
Snapshot,
Resume,
Freeze,
ScrubBack,
ScrubForward,
Scroll(ScrollStep),
ToggleWrap,
ShiftLeft,
ShiftRight,
ToggleGutter,
ToggleHighlight,
ToggleTime,
Ignore,
}
fn action_for(key: Key, mode: FrameMode) -> WatchAction {
match key {
Key::CtrlC => WatchAction::Abort,
Key::Char('q') => WatchAction::Quit,
Key::Char('v') | Key::Enter => WatchAction::Page,
Key::Char('?') => WatchAction::Help,
Key::Char('S') => WatchAction::Snapshot,
Key::Char('j') | Key::Down => WatchAction::Scroll(ScrollStep::LineDown),
Key::Char('k') | Key::Up => WatchAction::Scroll(ScrollStep::LineUp),
Key::Char('d') => WatchAction::Scroll(ScrollStep::HalfDown),
Key::Char('u') => WatchAction::Scroll(ScrollStep::HalfUp),
Key::Char('f') | Key::PageDown => WatchAction::Scroll(ScrollStep::PageDown),
Key::Char('b') | Key::PageUp => WatchAction::Scroll(ScrollStep::PageUp),
Key::Char('g') | Key::Home => WatchAction::Scroll(ScrollStep::Top),
Key::Char('G') | Key::End => WatchAction::Scroll(ScrollStep::Bottom),
Key::Char('w') => WatchAction::ToggleWrap,
Key::Char('h') | Key::Left => WatchAction::ShiftLeft,
Key::Char('l') | Key::Right => WatchAction::ShiftRight,
Key::Char('D') => WatchAction::ToggleGutter,
Key::Char('c') => WatchAction::ToggleHighlight,
Key::Char('t') => WatchAction::ToggleTime,
Key::Esc | Key::Char('F') if mode != FrameMode::Live => WatchAction::Resume,
Key::Char('p') if mode != FrameMode::Paused => WatchAction::Freeze,
Key::Char('<') | Key::Char(',') => WatchAction::ScrubBack,
Key::Char('>') | Key::Char('.') if mode == FrameMode::Paused => WatchAction::ScrubForward,
_ => WatchAction::Ignore,
}
}
struct PauseState {
frozen: Vec<String>,
scroll: ScrollState,
content: u64,
appearance: Appearance,
viewed_at: jiff::Timestamp,
history_seq: Option<u64>,
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
struct ViewState {
wrap: bool,
hshift: usize,
gutter: bool,
highlight: bool,
alt_time: bool,
}
#[derive(Copy, Clone, PartialEq, Debug)]
struct PaintKey {
content: u64,
cols: u16,
rows: u16,
appearance: Appearance,
offset: usize,
paused: bool,
wrap: bool,
hshift: usize,
gutter: bool,
highlight: bool,
alt_time: bool,
age_secs: u64,
}
fn paint_key(
pause: Option<&PauseState>,
live_scroll: Option<LiveScroll>,
live_content: u64,
live_appearance: Appearance,
size: (u16, u16),
view: ViewState,
age_secs: u64,
) -> PaintKey {
let (content, appearance, offset, paused) = match pause {
Some(p) => (p.content, p.appearance, p.scroll.offset(), true),
None => (
live_content,
live_appearance,
live_scroll.map_or(0, LiveScroll::offset),
false,
),
};
PaintKey {
content,
cols: size.0,
rows: size.1,
appearance,
offset,
paused,
wrap: view.wrap,
hshift: view.hshift,
gutter: view.gutter,
highlight: view.highlight,
alt_time: view.alt_time,
age_secs,
}
}
fn age_seconds(t: jiff::Timestamp) -> u64 {
(jiff::Timestamp::now().as_second() - t.as_second()).max(0) as u64
}
fn age_text(age_secs: u64) -> String {
if age_secs < 10 {
"just now".to_string()
} else {
format!(
"{} ago",
crate::core::duration::format_long(age_secs as i64)
)
}
}
fn displayed_age(
pause: Option<&PauseState>,
live_scroll: Option<LiveScroll>,
alt_time: bool,
changed_at: jiff::Timestamp,
) -> u64 {
match (alt_time, pause, live_scroll) {
(true, Some(p), _) => age_seconds(p.viewed_at),
(true, None, None) => age_seconds(changed_at),
_ => 0,
}
}
fn live_time_segment(alt_time: bool, since: &str, live_age_secs: u64) -> String {
if alt_time {
format!("changed {}", age_text(live_age_secs))
} else {
format!("since {since}")
}
}
fn paused_time_segment(alt_time: bool, viewed_at: jiff::Timestamp, age_secs: u64) -> String {
if alt_time {
age_text(age_secs)
} else {
format!("at {}", local_hms(viewed_at))
}
}
fn resolve_interval(user: Option<&str>, triggered: bool) -> anyhow::Result<Option<Duration>> {
match (user, triggered) {
(Some(token), _) => Ok(Some(parse_interval(token)?)),
(None, false) => Ok(Some(Duration::from_secs(2))),
(None, true) => Ok(None),
}
}
fn local_hms(t: jiff::Timestamp) -> String {
t.to_zoned(jiff::tz::TimeZone::system())
.strftime("%H:%M:%S")
.to_string()
}
fn help_lines(triggers: &[TriggerSpec]) -> Vec<String> {
let mut lines: Vec<String> = [
"rat watch — keys",
"",
" q quit",
" v, Enter view the full frame in the pager",
" ? this key reference",
" S snapshot the viewed frame to a file",
"",
" j/k, Up/Down scroll one line (opens a live window)",
" d/u scroll half a window",
" f/b, PgDn/PgUp scroll a full window",
" g, Home top — and back to the live view",
" G, End bottom — stick to the tail",
"",
" p freeze the frame in place (the command keeps running)",
" Esc, F resume the live tail",
" <, , step back through distinct frames",
" >, . step forward again",
"",
" w wrap or chop long lines",
" h/l, Left/Right shift the view horizontally",
" D toggle the change gutter",
" c toggle the change highlights",
" t time style: wall-clock stamps or counting ages",
]
.into_iter()
.map(str::to_string)
.collect();
if !triggers.is_empty() {
lines.push(String::new());
lines.push(" refresh triggers:".to_string());
for spec in triggers {
lines.push(format!(" {spec}"));
}
}
lines
}
fn live_suffix(once: bool, interval: Option<&str>, triggered: bool) -> String {
if once {
return String::new();
}
match (interval, triggered) {
(Some(interval), false) => format!(" · every {interval} · ? help"),
(Some(interval), true) => format!(" · every {interval} or on trigger · ? help"),
(None, true) => " · on trigger · ? help".to_string(),
(None, false) => {
debug_assert!(false, "no interval and no trigger");
String::new()
}
}
}
fn live_notice(hidden: usize, time_seg: &str) -> String {
if hidden > 0 {
format!("… {hidden} more lines · {time_seg}")
} else {
time_seg.to_string()
}
}
#[allow(clippy::too_many_arguments)]
fn repaint(
renderer: &mut InlineRenderer<std::io::StdoutLock<'static>>,
pause: Option<&PauseState>,
live_scroll: Option<LiveScroll>,
live: &Live,
live_tail: &str,
palette: &Palette,
view: ViewState,
notice: Option<String>,
size: (u16, u16),
max_height: Option<u16>,
faint: &StyleSpec,
profile: ColorProfile,
history: &History,
) -> anyhow::Result<PaintKey> {
let age_secs = displayed_age(pause, live_scroll, view.alt_time, live.changed_at);
let key = paint_key(
pause,
live_scroll,
live.hash,
palette.appearance,
size,
view,
age_secs,
);
let (source, offset, mode) = match (pause, live_scroll) {
(Some(p), _) => (p.frozen.as_slice(), p.scroll.offset(), FrameMode::Paused),
(None, Some(ls)) => (live.lines.as_slice(), ls.offset(), FrameMode::LiveScrolled),
(None, None) => (live.lines.as_slice(), 0, FrameMode::Live),
};
let marks: Option<Vec<LineMark>> = (view.gutter || view.highlight).then(|| {
let anchor = match pause {
Some(p) => p
.history_seq
.and_then(|seq| history.nearest(seq).map(|e| e.seq)),
None => history.newest_seq(),
};
let prev = anchor.and_then(|seq| history.prev(seq));
changed_marks(prev.map(|e| e.frame.as_slice()), source)
});
let mark_cell = format!(
"{} ",
StyleSpec {
bold: true,
foreground: Some(palette.accent),
..StyleSpec::default()
}
.render("▌", profile)
);
let time_live = format!(
"{}{live_tail}",
live_time_segment(view.alt_time, &live.since, age_seconds(live.changed_at))
);
let time_paused = pause.map_or_else(
|| age_text(0),
|p| paused_time_segment(view.alt_time, p.viewed_at, age_seconds(p.viewed_at)),
);
paint_frame(
renderer,
source,
offset,
mode,
view,
notice,
size,
max_height,
faint,
profile,
&time_live,
&time_paused,
marks.as_deref(),
&mark_cell,
)?;
Ok(key)
}
fn window_rows(max_height: Option<u16>, rows: u16) -> u16 {
max_height.unwrap_or_else(|| rows.saturating_sub(2))
}
fn compose_frame(
title: Option<&String>,
stdout: &[u8],
stderr: &[u8],
join_stderr: bool,
) -> Vec<String> {
let body = String::from_utf8_lossy(stdout);
let mut lines: Vec<String> = Vec::new();
if let Some(title) = title {
lines.push(title.clone());
}
lines.extend(body.trim_end_matches('\n').split('\n').map(str::to_string));
if join_stderr && !stderr.is_empty() {
let err_body = String::from_utf8_lossy(stderr);
lines.extend(
err_body
.trim_end_matches('\n')
.split('\n')
.map(str::to_string),
);
}
lines
}
#[allow(clippy::too_many_arguments)]
fn paint_frame(
renderer: &mut InlineRenderer<std::io::StdoutLock<'static>>,
lines: &[String],
offset: usize,
mode: FrameMode,
view: ViewState,
notice: Option<String>,
size: (u16, u16),
max_height: Option<u16>,
faint: &StyleSpec,
profile: ColorProfile,
time_live: &str,
time_paused: &str,
marks: Option<&[LineMark]>,
mark_cell: &str,
) -> anyhow::Result<()> {
let (cols, rows) = size;
let max_rows = window_rows(max_height, rows);
let start = match mode {
FrameMode::Live => 0,
FrameMode::LiveScrolled | FrameMode::Paused => offset.min(lines.len()),
};
let content_cols = usize::from(cols).saturating_sub(if view.gutter { GUTTER_COLS } else { 0 });
let highlight = view.highlight && profile != ColorProfile::Ascii;
let spliced = |i: usize, line: &String| -> String {
match (highlight, marks) {
(true, Some(ms)) => mark_cells(
line,
ms.get(start + i).map_or(&[][..], |m| m.cells.as_slice()),
),
_ => line.clone(),
}
};
let (mut kept, hidden) =
if !view.wrap || view.hshift > 0 || mode == FrameMode::LiveScrolled || view.gutter {
let end = (start + usize::from(max_rows)).min(lines.len());
let kept: Vec<String> = lines[start..end]
.iter()
.enumerate()
.map(|(i, line)| shift_chop(&spliced(i, line), view.hshift, content_cols))
.collect();
(kept, lines.len() - end)
} else {
truncate_to_rows(
lines[start..]
.iter()
.enumerate()
.map(|(i, line)| spliced(i, line))
.collect(),
max_rows,
cols,
)
};
if view.gutter
&& let Some(marks) = marks
{
kept = prefix_rows(kept, marks, start, mark_cell);
}
let status = match mode {
FrameMode::Paused => paused_notice(time_paused, offset, kept.len(), lines.len()),
FrameMode::LiveScrolled => scrolled_notice(offset, kept.len(), lines.len()),
FrameMode::Live => live_notice(hidden, time_live),
};
kept.push(faint.render(&status, profile));
if let Some(text) = notice {
kept.push(faint.render(&text, profile));
}
renderer.draw(&kept, cols).context("writing frame")?;
Ok(())
}
fn snapshot_frame(lines: &[String], args: &WatchArgs) -> String {
let dir = args
.snapshot_dir
.clone()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let stamp = snapshot_stamp(&jiff::Timestamp::now().to_zoned(jiff::tz::TimeZone::system()));
let body = snapshot_body(lines, args.snapshot_ansi);
match write_snapshot(&dir, &stamp, &body) {
Ok(path) => format!("snapshot → {}", path.display()),
Err(err) => format!("snapshot failed ({err}) — set --snapshot-dir or RAT_SNAPSHOT_DIR"),
}
}
fn page_frame(
lines: &[String],
renderer: &mut InlineRenderer<std::io::StdoutLock<'static>>,
) -> Option<String> {
let pagers = resolve_pagers(&SystemEnv);
let mut used = pagers.first().map(|p| p.bin.clone()).unwrap_or_default();
let _ = crossterm::terminal::disable_raw_mode();
let _ = renderer.finish();
let _console_utf8 = ConsoleUtf8Guard::enable();
let result = (|| -> std::io::Result<()> {
let (bin, mut child) = spawn_first(&pagers)?;
used = bin;
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_IGN);
}
let write_result = (|| -> std::io::Result<()> {
let mut stdin = child.stdin.take().expect("stdin piped");
for line in lines {
writeln!(stdin, "{line}")?;
}
Ok(())
})();
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
match write_result {
Err(err) if err.kind() != std::io::ErrorKind::BrokenPipe => return Err(err),
_ => {}
}
child.wait()?;
Ok(())
})();
let _ = crossterm::terminal::enable_raw_mode();
renderer.resume_over_own_frame();
match result {
Ok(()) => None,
Err(err) => Some(format!(
"pager {used:?} failed ({err}) — set RAT_PAGER or install less"
)),
}
}
fn spawn_first(pagers: &[PagerCommand]) -> std::io::Result<(String, std::process::Child)> {
let mut last_err =
std::io::Error::new(std::io::ErrorKind::NotFound, "no pager candidates resolved");
for pager in pagers {
match std::process::Command::new(&pager.bin)
.args(&pager.args)
.stdin(std::process::Stdio::piped())
.spawn()
{
Ok(child) => return Ok((pager.bin.clone(), child)),
Err(err) => last_err = err,
}
}
Err(last_err)
}
fn build_command(
args: &WatchArgs,
interactive: bool,
appearance: Appearance,
size: (u16, u16),
) -> std::process::Command {
let mut command = if args.shell {
shell_command(&args.command.join(" "))
} else {
let mut cmd = std::process::Command::new(&args.command[0]);
cmd.args(&args.command[1..]);
cmd
};
if interactive {
command.stdin(std::process::Stdio::null());
}
let (cols, rows) = size;
command.env("RAT_WIDTH", cols.to_string());
command.env("RAT_HEIGHT", rows.to_string());
command.env("RAT_APPEARANCE", appearance.as_str());
command
}
#[cfg(unix)]
fn register_signals() -> Result<(Arc<AtomicBool>, Arc<AtomicBool>), AppError> {
let interrupted = Arc::new(AtomicBool::new(false));
let terminated = Arc::new(AtomicBool::new(false));
signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(&interrupted))
.context("registering SIGINT")?;
signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&terminated))
.context("registering SIGTERM")?;
signal_hook::flag::register(signal_hook::consts::SIGHUP, Arc::clone(&terminated))
.context("registering SIGHUP")?;
Ok((interrupted, terminated))
}
#[cfg(windows)]
fn register_signals() -> Result<(Arc<AtomicBool>, Arc<AtomicBool>), AppError> {
Ok((
Arc::new(AtomicBool::new(false)),
Arc::new(AtomicBool::new(false)),
))
}
fn crossterm_slice(nap: Duration) -> anyhow::Result<Vec<TapEvent>> {
if !crossterm::event::poll(nap).context("polling events")? {
return Ok(Vec::new());
}
let crossterm::event::Event::Key(key_event) =
crossterm::event::read().context("reading event")?
else {
return Ok(Vec::new());
};
match from_crossterm(key_event) {
Some(key) => Ok(vec![TapEvent::Key(key)]),
None => Ok(Vec::new()),
}
}
#[cfg(unix)]
#[derive(Default)]
struct VerifyState {
pending: bool,
fg: Option<xterm_color::Color>,
in_flight_until: Option<Instant>,
}
#[cfg(unix)]
impl VerifyState {
fn reply(&mut self, kind: OscColorKind, color: xterm_color::Color) -> Option<Appearance> {
self.in_flight_until?;
match kind {
OscColorKind::Foreground => {
self.fg = Some(color);
None
}
OscColorKind::Background => {
self.in_flight_until = None;
let verdict = classify_colors(self.fg.as_ref(), &color);
self.fg = None;
Some(verdict)
}
}
}
}
#[cfg_attr(windows, allow(dead_code))] fn adopt(palette: &mut Palette, reported: Appearance) -> bool {
if palette.appearance == reported {
return false;
}
*palette = Palette::builtin(reported, AppearanceSource::Notification);
true
}
fn signature(bytes: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::hash::DefaultHasher::new();
bytes.hash(&mut hasher);
hasher.finish()
}
#[cfg(unix)]
fn shell_command(script: &str) -> std::process::Command {
let mut cmd = std::process::Command::new("sh");
cmd.arg("-c").arg(script);
cmd
}
#[cfg(windows)]
fn shell_command(script: &str) -> std::process::Command {
let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd".to_string());
let mut cmd = std::process::Command::new(shell);
cmd.arg("/C").arg(script);
cmd
}
#[cfg(test)]
mod tests {
use ratatui::style::Color;
use super::*;
const ALL_MODES: [FrameMode; 3] = [FrameMode::Live, FrameMode::LiveScrolled, FrameMode::Paused];
#[test]
fn the_interval_resolves_by_the_trigger_rule() {
let secs = |n| Some(Duration::from_secs(n));
assert_eq!(resolve_interval(Some("5s"), false).unwrap(), secs(5));
assert_eq!(resolve_interval(Some("5s"), true).unwrap(), secs(5));
assert_eq!(resolve_interval(None, false).unwrap(), secs(2));
assert_eq!(resolve_interval(None, true).unwrap(), None); assert!(resolve_interval(Some("bogus"), false).is_err());
}
#[test]
fn todays_keys_mean_the_same_thing_in_every_mode() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::CtrlC, mode), WatchAction::Abort);
assert_eq!(action_for(Key::Char('q'), mode), WatchAction::Quit);
assert_eq!(action_for(Key::Char('v'), mode), WatchAction::Page);
assert_eq!(action_for(Key::Enter, mode), WatchAction::Page);
}
}
#[test]
fn navigation_keys_scroll() {
use crate::term::scroll::ScrollStep;
for mode in ALL_MODES {
for (key, step) in [
(Key::Char('j'), ScrollStep::LineDown),
(Key::Down, ScrollStep::LineDown),
(Key::Char('k'), ScrollStep::LineUp),
(Key::Up, ScrollStep::LineUp),
(Key::Char('d'), ScrollStep::HalfDown),
(Key::Char('u'), ScrollStep::HalfUp),
(Key::Char('f'), ScrollStep::PageDown),
(Key::PageDown, ScrollStep::PageDown),
(Key::Char('b'), ScrollStep::PageUp),
(Key::PageUp, ScrollStep::PageUp),
(Key::Char('g'), ScrollStep::Top),
(Key::Home, ScrollStep::Top),
(Key::Char('G'), ScrollStep::Bottom),
(Key::End, ScrollStep::Bottom),
] {
assert_eq!(
action_for(key, mode),
WatchAction::Scroll(step),
"{key:?} mode={mode:?}"
);
}
}
}
#[test]
fn esc_only_means_something_when_not_live() {
assert_eq!(action_for(Key::Esc, FrameMode::Live), WatchAction::Ignore);
assert_eq!(
action_for(Key::Esc, FrameMode::LiveScrolled),
WatchAction::Resume
);
assert_eq!(action_for(Key::Esc, FrameMode::Paused), WatchAction::Resume);
}
#[test]
fn f_resumes_and_p_freezes() {
assert_eq!(
action_for(Key::Char('F'), FrameMode::Live),
WatchAction::Ignore
);
assert_eq!(
action_for(Key::Char('F'), FrameMode::LiveScrolled),
WatchAction::Resume
);
assert_eq!(
action_for(Key::Char('F'), FrameMode::Paused),
WatchAction::Resume
);
assert_eq!(
action_for(Key::Char('p'), FrameMode::Live),
WatchAction::Freeze
);
assert_eq!(
action_for(Key::Char('p'), FrameMode::LiveScrolled),
WatchAction::Freeze
);
assert_eq!(
action_for(Key::Char('p'), FrameMode::Paused),
WatchAction::Ignore
);
}
#[test]
fn shift_d_toggles_the_gutter_in_every_mode() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('D'), mode), WatchAction::ToggleGutter);
}
for mode in ALL_MODES {
assert_eq!(
action_for(Key::Char('d'), mode),
WatchAction::Scroll(ScrollStep::HalfDown)
);
}
}
#[test]
fn c_toggles_the_highlight_in_every_mode() {
for mode in ALL_MODES {
assert_eq!(
action_for(Key::Char('c'), mode),
WatchAction::ToggleHighlight
);
}
}
#[test]
fn t_toggles_the_time_display_in_every_mode() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('t'), mode), WatchAction::ToggleTime);
}
}
#[test]
fn view_keys_are_view_actions_in_every_mode() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('w'), mode), WatchAction::ToggleWrap);
assert_eq!(action_for(Key::Char('h'), mode), WatchAction::ShiftLeft);
assert_eq!(action_for(Key::Left, mode), WatchAction::ShiftLeft);
assert_eq!(action_for(Key::Char('l'), mode), WatchAction::ShiftRight);
assert_eq!(action_for(Key::Right, mode), WatchAction::ShiftRight);
}
}
#[test]
fn unbound_keys_are_ignored() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('x'), mode), WatchAction::Ignore);
assert_eq!(action_for(Key::Tab, mode), WatchAction::Ignore);
assert_eq!(action_for(Key::Backspace, mode), WatchAction::Ignore);
}
}
#[test]
fn scrub_keys_walk_history() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('<'), mode), WatchAction::ScrubBack);
assert_eq!(action_for(Key::Char(','), mode), WatchAction::ScrubBack);
}
assert_eq!(
action_for(Key::Char('>'), FrameMode::Paused),
WatchAction::ScrubForward
);
assert_eq!(
action_for(Key::Char('.'), FrameMode::Paused),
WatchAction::ScrubForward
);
for mode in [FrameMode::Live, FrameMode::LiveScrolled] {
assert_eq!(action_for(Key::Char('>'), mode), WatchAction::Ignore);
assert_eq!(action_for(Key::Char('.'), mode), WatchAction::Ignore);
}
}
#[test]
fn s_is_the_snapshot_key() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('S'), mode), WatchAction::Snapshot);
assert_eq!(action_for(Key::Char('s'), mode), WatchAction::Ignore);
}
}
#[test]
fn the_live_rows_carry_the_time_segment() {
assert_eq!(live_notice(0, "since 18:47:53"), "since 18:47:53");
assert_eq!(
live_notice(8, "changed 14s ago"),
"… 8 more lines · changed 14s ago"
);
}
#[test]
fn the_live_suffix_names_the_interval_and_help() {
assert_eq!(
live_suffix(false, Some("2s"), false),
" · every 2s · ? help"
);
assert_eq!(
live_suffix(false, Some("500ms"), false),
" · every 500ms · ? help"
);
assert_eq!(live_suffix(true, Some("2s"), false), "");
}
#[test]
fn the_live_suffix_names_the_trigger_modes() {
assert_eq!(
live_suffix(false, Some("60s"), true),
" · every 60s or on trigger · ? help"
);
assert_eq!(live_suffix(false, None, true), " · on trigger · ? help");
assert_eq!(live_suffix(true, None, true), ""); }
#[test]
fn the_help_reference_names_the_trigger_sources() {
let specs = vec![TriggerSpec::File("/tmp/state.json".into())];
let lines = help_lines(&specs);
assert!(
lines
.iter()
.any(|line| line.contains("file:/tmp/state.json")),
"{lines:?}"
);
assert!(
!help_lines(&[]).iter().any(|line| line.contains("trigger")),
"the untriggered reference must not mention triggers"
);
}
#[test]
fn paint_key_matches_the_live_and_paused_shapes() {
let view = ViewState {
wrap: true,
hshift: 4,
gutter: false,
highlight: false,
alt_time: false,
};
let live = paint_key(None, None, 42, Appearance::Dark, (80, 24), view, 14);
assert_eq!(
live,
PaintKey {
content: 42,
cols: 80,
rows: 24,
appearance: Appearance::Dark,
offset: 0,
paused: false,
wrap: true,
hshift: 4,
gutter: false,
highlight: false,
alt_time: false,
age_secs: 14,
}
);
let scroll = ScrollState::default().step(ScrollStep::LineDown, 50, 10);
let p = PauseState {
frozen: vec!["x".to_string()],
scroll,
content: 7,
appearance: Appearance::Light,
viewed_at: jiff::Timestamp::now(),
history_seq: None,
};
let ls = LiveScroll::start(ScrollStep::LineDown, 50, 10);
let scrolled = paint_key(None, Some(ls), 42, Appearance::Dark, (80, 24), view, 14);
assert_eq!(
scrolled,
PaintKey {
content: 42,
cols: 80,
rows: 24,
appearance: Appearance::Dark,
offset: 1,
paused: false,
wrap: true,
hshift: 4,
gutter: false,
highlight: false,
alt_time: false,
age_secs: 14,
}
);
let paused = paint_key(Some(&p), None, 42, Appearance::Dark, (80, 24), view, 14);
assert_eq!(
paused,
PaintKey {
content: 7,
cols: 80,
rows: 24,
appearance: Appearance::Light,
offset: scroll.offset(),
paused: true,
wrap: true,
hshift: 4,
gutter: false,
highlight: false,
alt_time: false,
age_secs: 14,
}
);
}
#[test]
fn the_age_reads_just_now_then_counts() {
assert_eq!(age_text(0), "just now");
assert_eq!(age_text(9), "just now");
assert_eq!(age_text(10), "10s ago");
assert_eq!(age_text(14), "14s ago");
assert_eq!(age_text(75), "1m 15s ago");
}
#[test]
fn the_displayed_age_counts_only_where_the_row_counts() {
let old = jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - 100)
.expect("timestamp");
let p = PauseState {
frozen: vec!["x".to_string()],
scroll: ScrollState::default(),
content: 7,
appearance: Appearance::Dark,
viewed_at: old,
history_seq: None,
};
let ls = LiveScroll::start(ScrollStep::LineDown, 50, 10);
assert!(
displayed_age(Some(&p), None, true, old) >= 100,
"paused flipped counts"
);
assert!(
displayed_age(None, None, true, old) >= 100,
"live flipped counts"
);
assert_eq!(
displayed_age(Some(&p), None, false, old),
0,
"paused default is a stamp"
);
assert_eq!(
displayed_age(None, None, false, old),
0,
"live default is a stamp"
);
assert_eq!(
displayed_age(None, Some(ls), true, old),
0,
"the scrolled row has no time"
);
assert_eq!(displayed_age(None, Some(ls), false, old), 0);
}
#[test]
fn the_live_segment_flips_between_stamp_and_counter() {
assert_eq!(live_time_segment(false, "18:47:53", 999), "since 18:47:53");
assert_eq!(live_time_segment(true, "18:47:53", 0), "changed just now");
assert_eq!(live_time_segment(true, "18:47:53", 14), "changed 14s ago");
assert_eq!(
live_time_segment(true, "18:47:53", 75),
"changed 1m 15s ago"
);
}
#[test]
fn the_paused_segment_stamps_by_default_and_counts_flipped() {
let t = jiff::Timestamp::from_second(1_785_067_200).expect("timestamp");
assert_eq!(
paused_time_segment(false, t, 999),
format!("at {}", local_hms(t))
);
assert_eq!(paused_time_segment(true, t, 3), "just now");
assert_eq!(paused_time_segment(true, t, 14), "14s ago");
}
#[test]
fn local_hms_is_a_wall_clock_stamp() {
let s = local_hms(jiff::Timestamp::from_second(1_785_067_200).expect("timestamp"));
let b = s.as_bytes();
assert_eq!(b.len(), 8, "HH:MM:SS: {s}");
assert!(b[2] == b':' && b[5] == b':', "{s}");
assert!(
[0, 1, 3, 4, 6, 7].iter().all(|&i| b[i].is_ascii_digit()),
"{s}"
);
}
#[test]
fn the_window_is_the_max_height_or_two_short_of_the_screen() {
assert_eq!(window_rows(None, 24), 22);
assert_eq!(window_rows(Some(5), 24), 5);
assert_eq!(window_rows(None, 1), 0);
}
#[test]
fn composing_a_frame_puts_the_title_first_and_stderr_last() {
let title = "T".to_string();
assert_eq!(
compose_frame(Some(&title), b"a\nb\n", b"boom\n", true),
vec!["T", "a", "b", "boom"]
);
assert_eq!(
compose_frame(Some(&title), b"a\nb\n", b"boom\n", false),
vec!["T", "a", "b"]
);
}
#[test]
fn adopting_a_different_appearance_reresolves_the_palette() {
let mut palette = Palette::builtin(Appearance::Dark, AppearanceSource::Osc);
assert!(adopt(&mut palette, Appearance::Light));
assert_eq!(palette.appearance, Appearance::Light);
assert_eq!(palette.source, AppearanceSource::Notification);
assert_eq!(palette.accent, Color::Indexed(129));
}
#[test]
fn adopting_the_current_appearance_changes_nothing() {
let mut palette = Palette::builtin(Appearance::Dark, AppearanceSource::Osc);
assert!(!adopt(&mut palette, Appearance::Dark));
assert_eq!(palette.source, AppearanceSource::Osc);
assert_eq!(palette.accent, Color::Indexed(212));
}
#[test]
fn adopting_back_restores_the_original_tokens() {
let mut palette = Palette::builtin(Appearance::Dark, AppearanceSource::Osc);
assert!(adopt(&mut palette, Appearance::Light));
assert!(adopt(&mut palette, Appearance::Dark));
assert_eq!(palette.appearance, Appearance::Dark);
assert_eq!(palette.accent, Color::Indexed(212));
assert_eq!(palette.on_accent, Color::Indexed(16));
}
#[cfg(unix)]
fn white() -> xterm_color::Color {
xterm_color::Color::rgb(u16::MAX, u16::MAX, u16::MAX)
}
#[cfg(unix)]
fn black() -> xterm_color::Color {
xterm_color::Color::rgb(0, 0, 0)
}
#[cfg(unix)]
#[test]
fn a_reply_nobody_asked_for_is_ignored() {
let mut verify = VerifyState::default();
assert_eq!(verify.reply(OscColorKind::Background, black()), None);
}
#[cfg(unix)]
#[test]
fn a_background_reply_completes_the_exchange() {
use crate::theme::PROBE_TIMEOUT;
let mut verify = VerifyState {
in_flight_until: Some(Instant::now() + PROBE_TIMEOUT),
..VerifyState::default()
};
assert_eq!(verify.reply(OscColorKind::Foreground, white()), None);
assert_eq!(
verify.reply(OscColorKind::Background, black()),
Some(Appearance::Dark)
);
assert!(verify.in_flight_until.is_none());
assert_eq!(verify.reply(OscColorKind::Background, white()), None);
}
#[cfg(unix)]
#[test]
fn a_background_reply_alone_still_classifies() {
use crate::theme::PROBE_TIMEOUT;
let mut verify = VerifyState {
in_flight_until: Some(Instant::now() + PROBE_TIMEOUT),
..VerifyState::default()
};
assert_eq!(
verify.reply(OscColorKind::Background, white()),
Some(Appearance::Light)
);
}
fn watch_args(command: &[&str], shell: bool) -> WatchArgs {
WatchArgs {
interval: Some("2s".to_string()),
trigger: Vec::new(),
trigger_debounce: "250ms".to_string(),
once: false,
clear: false,
no_hide_cursor: false,
no_sync: false,
shell,
title: None,
max_height: None,
snapshot_dir: None,
snapshot_ansi: false,
no_wrap: false,
command: command.iter().map(|s| s.to_string()).collect(),
}
}
fn program_of(cmd: &std::process::Command) -> String {
cmd.get_program().to_string_lossy().into_owned()
}
fn argv_of(cmd: &std::process::Command) -> Vec<String> {
cmd.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
#[test]
fn every_child_is_told_the_frame_size_and_appearance() {
let args = watch_args(&["some-tool", "--flag"], false);
let cmd = build_command(&args, true, Appearance::Light, (100, 40));
let envs: std::collections::HashMap<String, String> = cmd
.get_envs()
.filter_map(|(k, v)| {
Some((
k.to_string_lossy().into_owned(),
v?.to_string_lossy().into_owned(),
))
})
.collect();
assert_eq!(envs.get("RAT_WIDTH").map(String::as_str), Some("100"));
assert_eq!(envs.get("RAT_HEIGHT").map(String::as_str), Some("40"));
assert_eq!(
envs.get("RAT_APPEARANCE").map(String::as_str),
Some("light")
);
}
#[test]
fn direct_mode_runs_the_command_verbatim() {
let args = watch_args(&["some-tool", "--flag", "value"], false);
let cmd = build_command(&args, false, Appearance::Dark, (80, 24));
assert_eq!(program_of(&cmd), "some-tool");
assert_eq!(argv_of(&cmd), ["--flag", "value"]);
}
#[test]
fn question_mark_pages_the_key_help() {
for mode in ALL_MODES {
assert_eq!(action_for(Key::Char('?'), mode), WatchAction::Help);
}
}
#[test]
fn the_help_names_the_key_families() {
let text = help_lines(&[]).join("\n");
for needle in [
"quit",
"pager",
"snapshot",
"freeze the frame in place",
"resume the live tail",
"step back",
"wrap",
"gutter",
"highlights",
"counting ages",
"key reference",
] {
assert!(text.contains(needle), "help must mention {needle:?}");
}
}
#[test]
fn shell_mode_goes_through_the_platform_shell() {
let args = watch_args(&["echo hi"], true);
let cmd = build_command(&args, false, Appearance::Dark, (80, 24));
#[cfg(unix)]
{
assert_eq!(program_of(&cmd), "sh");
assert_eq!(argv_of(&cmd), ["-c", "echo hi"]);
}
#[cfg(windows)]
{
let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd".to_string());
assert_eq!(program_of(&cmd), shell);
assert_eq!(argv_of(&cmd), ["/C", "echo hi"]);
}
}
}