use std::time::Duration;
use crossterm::event::{KeyCode, KeyModifiers};
use rand::{RngExt, seq::IndexedRandom};
use crate::{
cli::{
Cli, DEMO_KEYS, KEYS, StatusAlign, StatusPos, StatusTime, TitleSize, format_clock, format_coarse, format_span,
format_ticks, parse_duration,
},
clock::{Snapshot, Timer},
control::json_str,
effects::{self, Moment, Pools},
font,
scene::{Scene, Target, wave},
term::{Frame, str_width},
theme::{Rgb, Theme},
};
const ALIGN_LEAD: f64 = 5.7;
const DONE_FLASH_FOR: f64 = 3.0;
#[derive(Clone, Debug)]
pub struct Step {
pub dur: Duration,
pub label: Option<String>,
pub theme: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Event {
SegmentDone { done: Option<String>, next: Option<String> },
Completed { done: Option<String> },
}
struct Pending {
fx: Box<dyn effects::Effect>,
end_left: f64,
dur: f64,
}
struct Demo {
kinds: Vec<effects::Kind>,
pick: usize,
auto: bool,
full: Duration,
}
impl Demo {
const GAP: f64 = 1.2;
fn new(start: &[effects::Kind], full: Duration) -> Self {
use clap::ValueEnum;
let kinds: Vec<effects::Kind> = effects::Kind::value_variants()
.iter()
.copied()
.filter(|k| *k != effects::Kind::Off)
.collect();
let pick = start
.first()
.and_then(|k| kinds.iter().position(|x| x == k))
.unwrap_or(0);
Demo {
kinds,
pick,
auto: false,
full,
}
}
fn kind(&self) -> effects::Kind {
self.kinds[self.pick]
}
fn name(&self) -> String {
use clap::ValueEnum;
self.kind()
.to_possible_value()
.map(|v| v.get_name().to_string())
.unwrap_or_default()
}
fn step(&mut self, forward: bool) {
self.pick = cycle(self.pick, self.kinds.len(), forward);
}
}
fn cycle(i: usize, len: usize, forward: bool) -> usize {
if forward { (i + 1) % len } else { (i + len - 1) % len }
}
fn random_other(rng: &mut impl rand::Rng, i: usize, len: usize) -> usize {
if len < 2 {
return i;
}
(i + rng.random_range(1..len)) % len
}
#[derive(Debug)]
pub enum Reply {
Ok,
Stop,
}
pub struct App<'a> {
pub cli: &'a Cli,
pools: Pools,
themes: &'a [Theme],
pub term_bg: Option<Rgb>,
pub name: Option<String>,
theme: usize,
base_theme: usize,
plan: Vec<Step>,
seg: usize,
timer: Timer,
left: Duration,
elapsed: Duration,
target: Target,
layout_key: Option<(String, Option<String>, i32, i32, bool)>,
pub frame: Frame,
effect: Option<(Box<dyn effects::Effect>, f64)>,
pending: Option<Pending>,
next_mark: Option<f64>,
last_effect: Option<effects::Kind>,
next_effect_at: f64,
next_moment: Moment,
finished_at: Option<f64>,
switched_at: Option<f64>,
title_large: bool,
status_time: StatusTime,
help: bool,
demo: Option<Demo>,
pub viewing: bool,
}
impl<'a> App<'a> {
pub fn new(cli: &'a Cli, pools: Pools, themes: &'a [Theme], theme: usize, plan: Vec<Step>, timer: Timer) -> Self {
let demo = cli.demo.then(|| Demo::new(&cli.effect, plan[0].dur));
let mut app = App {
cli,
pools,
themes,
term_bg: None,
name: None,
theme: plan[0].theme.unwrap_or(theme),
base_theme: theme,
plan,
seg: 0,
timer,
left: Duration::ZERO,
elapsed: Duration::ZERO,
target: Target::empty(),
layout_key: None,
frame: Frame::new(0, 0),
effect: None,
pending: None,
next_mark: None,
last_effect: None,
next_effect_at: 0.0, next_moment: Moment::Intro,
finished_at: None,
switched_at: None,
title_large: cli.title_size == TitleSize::Large,
status_time: cli.status_time,
help: false,
demo,
viewing: false,
};
app.sample();
app
}
fn is_last(&self) -> bool {
self.seg + 1 >= self.plan.len()
}
pub fn completed(&self) -> bool {
self.finished_at.is_some() && self.is_last()
}
fn waiting(&self) -> bool {
self.finished_at.is_some() && !self.is_last()
}
fn title(&self) -> Option<&str> {
self.plan[self.seg].label.as_deref().or(self.cli.title.as_deref())
}
fn sample(&mut self) {
self.elapsed = self.timer.elapsed();
self.left = self.timer.total.saturating_sub(self.elapsed);
}
pub fn tick(&mut self, now: f64) -> Option<Event> {
self.sample();
if let (Some(demo), Some(at)) = (&self.demo, self.finished_at)
&& now - at > DONE_FLASH_FOR + 1.0
{
self.timer = Timer::new(demo.full);
self.finished_at = None;
self.next_effect_at = now;
self.sample();
return None;
}
if self.finished_at.is_some() || !self.left.is_zero() {
return None;
}
if self.viewing && !self.is_last() && !self.cli.confirm {
return None;
}
self.next_moment = Moment::Zero;
let done = self.plan[self.seg].label.clone();
if self.is_last() {
self.finished_at = Some(now);
self.next_effect_at = now;
return Some(Event::Completed { done });
}
let next = self.plan[self.seg + 1].label.clone();
if self.cli.confirm {
self.finished_at = Some(now);
self.next_effect_at = now;
} else {
self.switched_at = Some(now);
self.go_to(self.seg + 1, now);
}
Some(Event::SegmentDone { done, next })
}
fn go_to(&mut self, seg: usize, now: f64) {
self.seg = seg.min(self.plan.len() - 1);
self.timer = Timer::new(self.plan[self.seg].dur);
self.finished_at = None;
self.theme = self.plan[self.seg].theme.unwrap_or(self.base_theme);
self.effect = None;
self.pending = None;
self.next_mark = None;
self.next_effect_at = now;
self.next_moment = Moment::Zero;
self.sample();
}
fn next(&mut self, now: f64) -> bool {
if self.is_last() {
return false;
}
self.go_to(self.seg + 1, now);
true
}
fn back(&mut self, now: f64) {
self.go_to(self.seg.saturating_sub(1), now);
}
fn restart(&mut self, now: f64) {
self.pending = None;
self.next_mark = None;
self.timer = Timer::new(self.timer.total);
self.finished_at = None;
self.next_effect_at = now;
self.next_moment = Moment::Intro;
self.sample();
}
fn add(&mut self, d: Duration) {
self.timer.total += d;
self.finished_at = None;
self.sample();
}
fn sub(&mut self, d: Duration) {
self.timer.total = self.timer.total.saturating_sub(d);
self.sample();
}
fn set_paused(&mut self, paused: bool) {
if self.finished_at.is_none() && self.timer.paused() != paused {
self.timer.toggle();
}
}
pub fn on_key(&mut self, code: KeyCode, mods: KeyModifiers, now: f64) -> bool {
if self.demo.is_some() && !self.help && self.on_demo_key(code, now) {
return false;
}
match code {
KeyCode::Char('c') if mods.contains(KeyModifiers::CONTROL) => return true,
KeyCode::Char('h' | '?') => self.help = !self.help,
KeyCode::Char('q') | KeyCode::Esc if self.help => self.help = false,
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Char(' ' | 'p' | 'n') | KeyCode::Enter if self.waiting() => {
self.next(now);
}
KeyCode::Char(' ' | 'p') => self.set_paused(!self.timer.paused()),
KeyCode::Char('n') => {
self.next(now);
}
KeyCode::Char('b') => self.back(now),
KeyCode::Char('e') | KeyCode::Enter => self.effect_now(now),
KeyCode::Char('t') => {
self.base_theme = (self.theme + 1) % self.themes.len();
self.theme = self.base_theme;
}
KeyCode::Char('s') => self.title_large = !self.title_large,
KeyCode::Char('c') => self.status_time = self.status_time.next(),
KeyCode::Char('+' | '=') => self.add(Duration::from_secs(60)),
KeyCode::Char('-' | '_') => self.sub(Duration::from_secs(60)),
KeyCode::Char('r') => self.restart(now),
_ => {}
}
false
}
fn on_demo_key(&mut self, code: KeyCode, now: f64) -> bool {
let Some(demo) = &mut self.demo else { return false };
match code {
KeyCode::Left | KeyCode::Right => {
self.base_theme = cycle(self.theme, self.themes.len(), code == KeyCode::Right);
self.theme = self.base_theme;
return true;
}
KeyCode::Up | KeyCode::Down => demo.step(code == KeyCode::Down),
KeyCode::Char('a') => {
demo.auto = !demo.auto;
return true;
}
KeyCode::Char('r') => demo.pick = random_other(&mut rand::rng(), demo.pick, demo.kinds.len()),
KeyCode::Char('f') => {
self.timer = Timer::new(Duration::from_secs_f64(self.final_window() + 2.0));
self.finished_at = None;
self.sample();
}
KeyCode::Char('e' | ' ') | KeyCode::Enter => {}
_ => return false,
}
self.effect = None;
self.effect_now(now);
true
}
pub fn demo_choice(&self) -> Option<(String, String)> {
let demo = self.demo.as_ref()?;
Some((self.themes[self.theme].name.clone(), demo.name()))
}
fn effect_now(&mut self, now: f64) {
self.next_effect_at = now;
self.next_mark = None;
}
pub fn remote(&mut self, line: &str, now: f64) -> Result<Reply, String> {
let mut words = line.split_whitespace();
let cmd = words.next().unwrap_or_default();
let arg = words.collect::<Vec<_>>().join(" ");
let dur = || parse_duration(&arg).map_err(|e| format!("`{cmd}` needs a duration: {e}"));
match cmd {
"pause" => self.set_paused(true),
"resume" | "toggle" | "next" if self.waiting() => {
self.next(now);
}
"resume" => self.set_paused(false),
"toggle" => self.set_paused(!self.timer.paused()),
"add" => self.add(dur()?),
"sub" => self.sub(dur()?),
"restart" => self.restart(now),
"next" => {
if !self.next(now) {
return Err("already on the last segment".to_string());
}
}
"back" => self.back(now),
"effect" => self.effect_now(now),
"stop" | "quit" => return Ok(Reply::Stop),
"" => return Err("empty command".to_string()),
other => {
return Err(format!(
"unknown command `{other}` (try pause, resume, toggle, add 5m, sub 1m, restart, next, back, effect, stop)"
));
}
}
self.sample();
Ok(Reply::Ok)
}
pub fn animating(&self, now: f64) -> bool {
let flashing = |at: Option<f64>| at.is_some_and(|at| now - at < DONE_FLASH_FOR);
self.effect.is_some()
|| self.cli.decimals > 0
|| (!self.cli.no_tick_fx && self.target.ticking(now))
|| (self.cli.alert > 0.0 && self.urgency() > 0.0)
|| flashing(self.finished_at)
|| flashing(self.switched_at)
}
fn hour_width(&self) -> usize {
if self.cli.pad_hours { 2 } else { 1 }
}
fn clock_text(&self, secs: u64) -> String {
format_clock(secs, (secs >= 3600).then_some(self.hour_width()))
}
fn left_secs(&self) -> u64 {
self.left.as_secs() + u64::from(self.left.subsec_nanos() > 0)
}
fn overtime_secs(&self) -> u64 {
self.elapsed.saturating_sub(self.timer.total).as_secs()
}
fn final_window(&self) -> f64 {
const DEFAULT_WINDOW: f64 = 10.0;
if self.cli.alert > 0.0 {
self.cli.alert
} else {
DEFAULT_WINDOW
}
}
fn in_final_seconds(&self) -> bool {
self.finished_at.is_none() && self.left.as_secs_f64() <= self.final_window()
}
fn effect_choices(&self, cap: f32) -> Vec<effects::Kind> {
let budget = if self.finished_at.is_some() || self.timer.paused() {
f32::INFINITY
} else {
(self.left.as_secs_f64() - self.final_window()) as f32
}
.min(cap);
if let Some(demo) = &self.demo {
let kind = demo.kind();
return if kind.max_duration() <= budget {
vec![kind]
} else {
vec![]
};
}
let fits: Vec<effects::Kind> = self
.pools
.get(self.next_moment)
.iter()
.copied()
.filter(|k| k.max_duration() <= budget)
.collect();
if fits.len() > 1 {
fits.into_iter().filter(|k| Some(*k) != self.last_effect).collect()
} else {
fits
}
}
fn urgency(&self) -> f32 {
let window = self.cli.alert;
if window <= 0.0 || self.finished_at.is_some() {
return 0.0;
}
let left = self.left.as_secs_f64();
if left > window {
0.0
} else {
(1.0 - left / window) as f32
}
}
fn state_and_clock(&self) -> (&'static str, String) {
if self.completed() {
("done", format!("+{}", self.clock_text(self.overtime_secs())))
} else if self.waiting() {
("waiting", format!("+{}", self.clock_text(self.overtime_secs())))
} else if self.timer.paused() {
("paused", self.clock_text(self.left_secs()))
} else if self.urgency() > 0.0 || (self.cli.alert <= 0.0 && self.in_final_seconds()) {
("alert", self.clock_text(self.left_secs()))
} else {
("running", self.clock_text(self.left_secs()))
}
}
pub fn status_json(&self) -> String {
let (class, clock) = self.state_and_clock();
let text = match self.plan[self.seg].label.as_deref() {
Some(label) => format!("{label} {clock}"),
None => clock,
};
let mut tip: Vec<String> = vec![];
if let Some(t) = self.title() {
tip.push(t.to_string());
}
if self.plan.len() > 1 {
tip.push(format!("segment {}/{}", self.seg + 1, self.plan.len()));
}
tip.extend(self.upcoming_text());
tip.extend(self.state_text());
tip.extend(self.name.as_ref().map(|n| format!("name: {n}")));
let pct = if self.timer.total.is_zero() {
100
} else {
((self.elapsed.as_secs_f64() / self.timer.total.as_secs_f64()).min(1.0) * 100.0).round() as u32
};
let name = self.name.as_deref().map_or("null".to_string(), json_str);
format!(
r#"{{"text":{},"tooltip":{},"class":{},"alt":{},"percentage":{pct},"name":{name}}}"#,
json_str(&text),
json_str(&tip.join("\n")),
json_str(class),
json_str(class),
)
}
pub fn handover_state(&self) -> String {
let s = self.timer.snapshot();
format!(
"{} {} {} {} {} {}",
self.seg,
s.total.as_nanos(),
s.elapsed.as_nanos(),
s.at.as_nanos(),
u8::from(s.paused),
u8::from(s.synced)
)
}
fn parse_state(&self, state: &str) -> Result<(usize, Snapshot), String> {
let bad = || format!("bad handover state {state:?}");
let f: Vec<&str> = state.split(' ').collect();
let [seg, total, elapsed, at, paused, synced] = f[..] else {
return Err(bad());
};
let nanos = |v: &str| -> Result<Duration, String> {
let n: u64 = v.parse().map_err(|_| bad())?;
Ok(Duration::from_nanos(n))
};
let flag = |v: &str| match v {
"0" => Ok(false),
"1" => Ok(true),
_ => Err(bad()),
};
let seg: usize = seg.parse().map_err(|_| bad())?;
if seg >= self.plan.len() {
return Err(bad());
}
let snapshot = Snapshot {
total: nanos(total)?,
elapsed: nanos(elapsed)?,
at: nanos(at)?,
paused: flag(paused)?,
synced: flag(synced)?,
};
Ok((seg, snapshot))
}
pub fn resume(&mut self, state: &str) -> Result<(), String> {
let (seg, snapshot) = self.parse_state(state)?;
if seg != self.seg {
self.go_to(seg, 0.0);
}
if !snapshot.synced {
self.timer = Timer::resumed(&snapshot);
}
self.next_moment = Moment::Intro;
self.sample();
if self.left.is_zero() {
self.finished_at = Some(-DONE_FLASH_FOR);
}
Ok(())
}
pub fn sync(&mut self, state: &str, now: f64) -> Result<(), String> {
let (seg, snapshot) = self.parse_state(state)?;
if seg != self.seg {
let moved_on = seg == self.seg + 1 && self.left.is_zero();
self.go_to(seg, now);
if moved_on {
self.switched_at = Some(now);
}
}
if !snapshot.synced {
self.timer = Timer::resumed(&snapshot);
}
self.sample();
if !self.left.is_zero() {
self.finished_at = None;
}
Ok(())
}
pub fn remote_line_for(&self, code: KeyCode) -> Option<&'static str> {
match code {
KeyCode::Char(' ' | 'p') => Some("toggle"),
KeyCode::Char('n') => Some("next"),
KeyCode::Enter if self.waiting() => Some("next"),
KeyCode::Char('b') => Some("back"),
KeyCode::Char('+' | '=') => Some("add 1m"),
KeyCode::Char('-' | '_') => Some("sub 1m"),
KeyCode::Char('r') => Some("restart"),
_ => None,
}
}
pub fn info_line(&self, headless: bool) -> String {
let (class, clock) = self.state_and_clock();
let title = self.title().unwrap_or_default().replace(['\t', '\n', '\r'], " ");
let seg = if self.plan.len() > 1 {
format!("{}/{}", self.seg + 1, self.plan.len())
} else {
String::new()
};
let mode = if headless { "headless" } else { "window" };
format!("{mode}\t{class}\t{clock}\t{title}\t{seg}")
}
fn state_text(&self) -> Option<String> {
if self.completed() {
let done = self.cli.done_text.as_deref().unwrap_or("time's up");
return Some(format!("{done} +{}", self.clock_text(self.overtime_secs())));
}
if self.waiting() {
let next = self.plan[self.seg + 1].label.as_deref().unwrap_or("next");
return Some(format!(
"time's up +{} · n: start {next}",
self.clock_text(self.overtime_secs())
));
}
match (&self.cli.until, self.timer.synced(), self.timer.paused()) {
(Some(u), true, _) => Some(format!("until {u}")),
(Some(u), false, true) => Some(format!("paused · no longer synced to {u}")),
(Some(u), false, false) => {
let fmt = if u.matches(':').count() == 2 {
"%H:%M:%S"
} else {
"%H:%M"
};
let ends = chrono::Local::now() + self.left;
Some(format!("now ends {} (was {u})", ends.format(fmt)))
}
(None, _, true) => Some("paused".to_string()),
(None, _, false) => None,
}
}
fn sequence_text(&self) -> Option<String> {
let n = self.plan.len();
if n < 2 {
return None;
}
if n > 12 {
return Some(format!("{}/{n}", self.seg + 1));
}
let dots: Vec<&str> = (0..n)
.map(|i| match i.cmp(&self.seg) {
std::cmp::Ordering::Less => "●",
std::cmp::Ordering::Equal if self.finished_at.is_some() => "●",
std::cmp::Ordering::Equal => "◐",
std::cmp::Ordering::Greater => "○",
})
.collect();
Some(dots.join(" "))
}
fn upcoming(&self) -> Option<(Option<&str>, String, usize)> {
if self.is_last() || self.waiting() {
return None;
}
let next = &self.plan[self.seg + 1];
let theme = next.theme.unwrap_or(self.base_theme);
Some((next.label.as_deref(), format_span(next.dur.as_secs()), theme))
}
fn upcoming_text(&self) -> Option<String> {
let (label, span, _) = self.upcoming()?;
Some(match label {
Some(l) => format!("next: {l} {span}"),
None => format!("next: {span}"),
})
}
pub fn resize(&mut self, w: u16, h: u16) {
self.frame = Frame::new(w, h);
self.target = Target::empty(); }
fn remaining_ticks(&self) -> u64 {
let unit = 1_000_000_000 / 10u128.pow(u32::from(self.cli.decimals));
self.left.as_nanos().div_ceil(unit) as u64
}
fn layout(&mut self, now: f64) -> bool {
let cli = self.cli;
let text = self.clock_face();
let (sw, sh) = (self.frame.w, self.frame.h);
let key = (text, self.title().map(str::to_string), sw, sh, self.title_large);
if self.layout_key.as_ref() == Some(&key) && self.target.w > 0 {
return false;
}
let text = &key.0;
let fx_until = text.find('.').unwrap_or(usize::MAX);
let title = key.1.as_deref().map(|t| self.title_glyphs(t));
let title_h = title.as_ref().map_or(0, |g| g.h as i32 + 1);
let edges = self.edge_rows().0 + self.edge_rows().1;
let glyphs = font::render(
text,
cli.font,
(sw - 2).max(1) as usize,
(sh - self.footer_height() - edges - title_h - 2).max(1) as usize,
cli.scale.map(usize::from),
);
let (glyphs, title_row) = match &title {
Some(t) => font::stack(&glyphs, 1, t),
None => (glyphs, usize::MAX),
};
self.target.title_row = title_row;
let relayout = self.target.update(glyphs, now, fx_until);
self.layout_key = Some(key);
relayout
}
fn clock_face(&self) -> String {
if self.completed()
&& let Some(text) = &self.cli.done_text
{
return text.clone();
}
if self.cli.hide_seconds && self.left_secs() >= 3600 {
return format_coarse(self.left_secs());
}
let ticks = self.remaining_ticks();
let secs = ticks / 10u64.pow(u32::from(self.cli.decimals));
let hours = secs >= 3600 || self.timer.total.as_secs() >= 3600 && self.finished_at.is_none();
format_ticks(ticks, self.cli.decimals, hours.then_some(self.hour_width()))
}
pub fn draw(&mut self, now: f64, rng: &mut impl rand::Rng) {
let cli = self.cli;
if self.layout(now) {
self.effect = None;
self.pending = None;
self.next_mark = None;
self.next_effect_at = now;
}
let (sw, sh) = (self.frame.w, self.frame.h);
let (tw, th) = (self.target.w as i32, self.target.h as i32);
let ox = (sw - tw) / 2;
let (top, bottom) = self.edge_rows();
let oy = top + (sh - top - bottom - th - self.footer_height()) / 2;
let sc = Scene {
t: &self.target,
ox,
oy,
sw,
sh,
theme: &self.themes[self.theme],
now,
paused: self.timer.paused(),
done: self.finished_at.is_some(),
tick_fx: !cli.no_tick_fx,
urgency: self.urgency(),
};
if self.in_final_seconds() {
self.effect = None;
self.pending = None;
} else if let Some(p) = self.pending.take() {
let lead = self.left.as_secs_f64() - p.end_left;
if lead <= p.dur {
self.effect = Some((p.fx, now - (p.dur - lead.max(0.0))));
} else {
self.pending = Some(p);
}
} else if self.effect.is_none() {
let aligned = cli
.effect_align
.filter(|_| self.finished_at.is_none() && self.demo.is_none());
let left = self.left.as_secs_f64();
let due = match (aligned, self.next_mark) {
(Some(_), Some(mark)) => left <= mark + ALIGN_LEAD,
_ => now >= self.next_effect_at,
};
if due {
let cap = match (aligned, self.next_mark) {
(Some(_), Some(mark)) => (left - mark) as f32,
_ => f32::INFINITY,
};
let kind = self.effect_choices(cap).choose(rng).copied();
self.next_moment = Moment::Replay;
match (kind, aligned) {
(Some(kind), Some(unit)) => {
self.last_effect = Some(kind);
let seed: u64 = rng.random();
let dur = f64::from(effects::measure(kind, &sc, seed));
let unit = unit as f64;
let end_left = self.next_mark.unwrap_or(((left - dur) / unit).floor() * unit);
if end_left >= self.final_window() && left - dur >= end_left - 1e-6 {
let fx = effects::make_seeded(kind, &sc, seed);
self.pending = Some(Pending { fx, end_left, dur });
}
self.next_mark = self.mark_after(end_left);
if self.next_mark.is_none() {
self.next_effect_at = f64::INFINITY;
}
}
(Some(kind), None) => {
self.last_effect = Some(kind);
self.effect = Some((effects::make(kind, &sc, rng), now));
}
(None, Some(_)) if self.next_mark.is_some() => {
self.next_mark = self.next_mark.and_then(|m| self.mark_after(m));
}
(None, _) => self.next_effect_at = self.next_replay(now),
}
}
}
self.frame.clear();
let done = if let Some((fx, start)) = &mut self.effect {
fx.draw(&sc, (now - *start) as f32, &mut self.frame)
} else {
sc.draw_settled(&mut self.frame);
false
};
if done {
self.effect = None;
self.next_effect_at = self.next_replay(now);
if let Some(demo) = self.demo.as_mut().filter(|d| d.auto) {
demo.step(true);
}
}
self.frame.bg = self.alert_bg(now);
self.draw_footer(now, oy + th + 1, ox, tw);
self.draw_demo_bar();
if self.help {
self.draw_help();
}
}
fn next_replay(&self, now: f64) -> f64 {
if self.demo.is_some() {
return now + Demo::GAP;
}
let every = if self.finished_at.is_some() {
6.0
} else {
self.cli.every
};
if every > 0.0 { now + every } else { f64::INFINITY }
}
fn mark_after(&self, mark: f64) -> Option<f64> {
let unit = self.cli.effect_align? as f64;
if self.cli.every <= 0.0 {
return None;
}
let step = (self.cli.every / unit).ceil().max(1.0) * unit;
Some(mark - step).filter(|&m| m >= self.final_window())
}
fn alert_bg(&self, now: f64) -> Option<Rgb> {
if self.cli.alert <= 0.0 || self.timer.paused() {
return None;
}
let theme = &self.themes[self.theme];
let recent = |at: Option<f64>| at.filter(|at| now - at < DONE_FLASH_FOR);
let (since_pulse, strength) = match recent(self.finished_at).or(recent(self.switched_at)) {
Some(at) => ((((now - at) * 2.0).fract() / 2.0) as f32, 0.75),
None if self.finished_at.is_some() => return None,
None => {
let urgency = self.urgency();
if urgency <= 0.0 {
return None;
}
((1.0 - self.left.as_secs_f64().fract()) as f32, 0.25 + 0.4 * urgency)
}
};
let k = (1.0 - since_pulse * 2.5).max(0.0);
let rest = self.term_bg.unwrap_or(theme.bg);
(k > 0.0).then(|| rest.lerp(theme.alert, k * k * strength))
}
fn progress(&self) -> f32 {
let part = |done: Duration, total: Duration| {
if total.is_zero() {
1.0
} else {
(done.as_secs_f64() / total.as_secs_f64()).min(1.0)
}
};
if !self.cli.bar_total || self.plan.len() < 2 {
return part(self.elapsed, self.timer.total) as f32;
}
let before: Duration = self.plan[..self.seg].iter().map(|s| s.dur).sum();
let after: Duration = self.plan[self.seg + 1..].iter().map(|s| s.dur).sum();
let total = before + self.timer.total + after;
part(before + self.elapsed.min(self.timer.total), total) as f32
}
fn draw_footer(&mut self, now: f64, y: i32, ox: i32, tw: i32) {
let theme = &self.themes[self.theme];
let sw = self.frame.w;
let status_row = if self.cli.bar { y + 2 } else { y };
if self.cli.bar {
let bw = tw.max(20).min(sw - 4);
let filled = (self.progress() * bw as f32).round() as i32;
let bx = ox + (tw - bw) / 2;
let drift = (now * 0.025).fract() as f32;
for i in 0..bw {
let (ch, col) = if i < filled {
('━', theme.gradient_at(i as f32 / bw as f32 * 0.55 + drift))
} else {
('─', theme.dim.scale(0.6))
};
self.frame.put(bx + i, y, ch, col);
}
}
let time = match self.status_time {
StatusTime::Off => None,
StatusTime::Now => Some(chrono::Local::now().format("%H:%M").to_string()),
StatusTime::Left if self.finished_at.is_some() => None,
StatusTime::Left => Some(format!("{} left", self.clock_text(self.left_secs()))),
};
let col = if self.finished_at.is_some() {
theme.flash.lerp(theme.dim, 0.5 + 0.5 * wave(now, 5.0))
} else {
theme.dim
};
let mut parts: Vec<Vec<(String, Rgb)>> = [time, self.sequence_text()]
.into_iter()
.flatten()
.map(|t| vec![(t, col)])
.collect();
if let Some((label, span, next_theme)) = self.upcoming() {
let accent = self.themes[next_theme].gradient_at(0.0);
parts.push(match label {
Some(l) => vec![
("next: ".into(), col),
(l.to_string(), accent),
(format!(" {span}"), col),
],
None => vec![("next: ".into(), col), (span, accent)],
});
}
parts.extend(self.state_text().map(|t| vec![(t, col)]));
if parts.is_empty() {
return;
}
let spans: Vec<(String, Rgb)> = parts
.into_iter()
.enumerate()
.flat_map(|(i, part)| {
let sep = (i > 0).then(|| (" · ".to_string(), col));
sep.into_iter().chain(part)
})
.collect();
let w: i32 = spans.iter().map(|(t, _)| str_width(t)).sum();
let (sh, margin) = (self.frame.h, 2);
let (left, right, row) = match self.cli.status_pos {
StatusPos::Below => (ox, ox + tw, status_row),
StatusPos::Top => (margin, sw - margin, 1),
StatusPos::Bottom => (margin, sw - margin, sh - 2 - self.edge_rows().1 + 2),
};
let mut x = match self.cli.status_align {
StatusAlign::Left => left,
StatusAlign::Center => left + (right - left - w) / 2,
StatusAlign::Right => right - w,
};
for (text, c) in spans {
self.frame.text(x, row, &text, c);
x += str_width(&text);
}
}
fn draw_help(&mut self) {
let theme = &self.themes[self.theme];
let keys = self.keys();
let key_w = keys.iter().map(|(k, _)| str_width(k)).max().unwrap_or(0);
let desc_w = keys.iter().map(|(_, d)| str_width(d)).max().unwrap_or(0);
let (w, h) = (key_w + desc_w + 3 + 6, keys.len() as i32 + 4);
if w > self.frame.w || h > self.frame.h {
self.draw_help_compact(key_w);
return;
}
let (x0, y0) = ((self.frame.w - w) / 2, (self.frame.h - h) / 2);
let border = theme.dim;
for y in y0..y0 + h {
for x in x0..x0 + w {
let ch = match (x - x0, y - y0) {
(0, 0) => '╭',
(dx, 0) if dx == w - 1 => '╮',
(0, dy) if dy == h - 1 => '╰',
(dx, dy) if dx == w - 1 && dy == h - 1 => '╯',
(_, 0) => '─',
(_, dy) if dy == h - 1 => '─',
(0, _) => '│',
(dx, _) if dx == w - 1 => '│',
_ => ' ',
};
self.frame.put(x, y, ch, border);
}
}
let title = " keys ";
self.frame.text(x0 + (w - str_width(title)) / 2, y0, title, theme.flash);
for (i, (key, desc)) in keys.iter().enumerate() {
let y = y0 + 2 + i as i32;
let col = theme.gradient_at(i as f32 / keys.len() as f32 * 0.5);
self.frame.text(x0 + 3, y, key, col);
self.frame
.text(x0 + 3 + key_w + 3, y, desc, theme.flash.lerp(theme.dim, 0.35));
}
}
fn draw_help_compact(&mut self, key_w: i32) {
let theme = &self.themes[self.theme];
let (sw, sh) = (self.frame.w, self.frame.h);
let keys = self.keys();
let n = keys.len() as i32;
let y0 = ((sh - n) / 2).max(0);
for y in y0..(y0 + n).min(sh) {
for x in 0..sw {
self.frame.put(x, y, ' ', theme.dim);
}
}
let desc_x = 1 + key_w + 2;
let room = (sw - desc_x).max(0) as usize;
for (i, (key, desc)) in keys.iter().enumerate() {
let y = y0 + i as i32;
self.frame.text(1, y, key, theme.gradient_at(i as f32 / n as f32 * 0.5));
let desc: String = if desc.chars().count() > room {
desc.chars().take(room.saturating_sub(1)).chain(['…']).collect()
} else {
(*desc).to_string()
};
self.frame.text(desc_x, y, &desc, theme.flash.lerp(theme.dim, 0.35));
}
}
fn keys(&self) -> &'static [(&'static str, &'static str)] {
if self.demo.is_some() { DEMO_KEYS } else { KEYS }
}
fn draw_demo_bar(&mut self) {
let Some(demo) = &self.demo else { return };
let theme = &self.themes[self.theme];
let (key, label, value) = (theme.flash, theme.dim, theme.gradient_at(0.0));
let effect = demo.name();
let count = |i: usize, n: usize| format!(" {}/{n}", i + 1);
let mut parts: Vec<(String, Rgb)> = vec![
("← → ".into(), key),
("theme ".into(), label),
(theme.name.clone(), value),
(count(self.theme, self.themes.len()), label),
(" ↑ ↓ ".into(), key),
("effect ".into(), label),
(effect, theme.gradient_at(0.3)),
(count(demo.pick, demo.kinds.len()), label),
];
let hints: [(String, Rgb); 8] = [
(" enter ".into(), key),
("replay ".into(), label),
("a ".into(), key),
(format!("auto {} ", if demo.auto { "on" } else { "off" }), label),
("f ".into(), key),
("final seconds ".into(), label),
("r ".into(), key),
("random effect · h keys".into(), label),
];
let width = |p: &[(String, Rgb)]| p.iter().map(|(t, _)| str_width(t)).sum::<i32>();
let sw = self.frame.w;
if width(&parts) + width(&hints) <= sw - 2 {
parts.extend(hints);
} else if width(&parts) > sw - 2 {
parts = vec![parts[2].clone(), (" · ".into(), label), parts[6].clone()];
}
let mut x = (sw - width(&parts)) / 2;
let y = self.frame.h - 2;
for (text, col) in parts {
self.frame.text(x, y, &text, col);
x += str_width(&text);
}
}
fn edge_rows(&self) -> (i32, i32) {
let demo = if self.demo.is_some() { 2 } else { 0 };
match self.cli.status_pos {
StatusPos::Below => (0, demo),
StatusPos::Top => (2, demo),
StatusPos::Bottom => (0, 2 + demo),
}
}
fn footer_height(&self) -> i32 {
let status = if self.cli.status_pos == StatusPos::Below { 2 } else { 0 };
i32::from(self.cli.bar) * 2 + status
}
fn title_glyphs(&self, title: &str) -> font::Glyphs {
if self.title_large && font::block_supports(title) && font::block_width(title, 1) as i32 <= self.frame.w - 4 {
font::block(title, 1)
} else {
font::plain(title)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn app_with<'a>(cli: &'a Cli, themes: &'a [Theme], mins: &[u64]) -> App<'a> {
let plan: Vec<Step> = mins
.iter()
.enumerate()
.map(|(i, m)| Step {
dur: Duration::from_secs(m * 60),
label: Some(format!("s{i}")),
theme: None,
})
.collect();
let timer = Timer::new(plan[0].dur);
let one = || vec![effects::Kind::Decrypt];
App::new(
cli,
Pools {
intro: one(),
replay: one(),
zero: one(),
},
themes,
0,
plan,
timer,
)
}
#[test]
fn handover_carries_on_where_it_stood() {
let cli = Cli::try_parse_from(["t-", "1m"]).unwrap();
let themes = crate::theme::builtin();
let mut old = app_with(&cli, &themes, &[1, 3]);
old.remote("next", 0.0).unwrap();
old.remote("add 2m", 0.0).unwrap();
old.remote("pause", 0.0).unwrap();
old.timer = Timer::resumed(&crate::clock::Snapshot {
elapsed: Duration::from_secs(40),
..old.timer.snapshot()
});
let mut new = app_with(&cli, &themes, &[1, 3]);
new.resume(&old.handover_state()).unwrap();
new.sample();
assert_eq!((new.seg, new.timer.total), (1, Duration::from_secs(5 * 60)));
assert_eq!((new.elapsed, new.timer.paused()), (Duration::from_secs(40), true));
assert_eq!((new.finished_at, new.tick(0.0)), (None, None));
let mut old = app_with(&cli, &themes, &[1]);
old.remote("sub 2m", 0.0).unwrap();
assert!(old.tick(0.0).is_some());
let mut new = app_with(&cli, &themes, &[1]);
new.resume(&old.handover_state()).unwrap();
assert!(new.completed());
assert_eq!(new.tick(0.0), None);
let mut new = app_with(&cli, &themes, &[1]);
for bad in ["", "1 2 3", "5 1 1 1 0 0", "0 1 1 1 2 0", "0 x 1 1 0 0"] {
assert!(new.resume(bad).is_err(), "{bad:?}");
}
}
#[test]
fn viewer_follows_the_timer_it_shows() {
let cli = Cli::try_parse_from(["t-", "1m"]).unwrap();
let themes = crate::theme::builtin();
let mut owner = app_with(&cli, &themes, &[1, 3]);
let mut view = app_with(&cli, &themes, &[1, 3]);
view.viewing = true;
view.resume(&owner.handover_state()).unwrap();
owner.remote("pause", 0.0).unwrap();
owner.remote("add 2m", 0.0).unwrap();
view.sync(&owner.handover_state(), 1.0).unwrap();
assert_eq!(
(view.timer.paused(), view.timer.total),
(true, Duration::from_secs(3 * 60))
);
owner.remote("sub 5m", 0.0).unwrap();
view.sync(&owner.handover_state(), 2.0).unwrap();
assert_eq!((view.tick(2.0), view.seg), (None, 0));
assert_eq!(owner.tick(2.0).map(|_| owner.seg), Some(1));
view.sync(&owner.handover_state(), 2.1).unwrap();
assert_eq!((view.seg, view.switched_at), (1, Some(2.1)));
assert_eq!(view.remote_line_for(KeyCode::Char(' ')), Some("toggle"));
assert_eq!(view.remote_line_for(KeyCode::Char('+')), Some("add 1m"));
assert_eq!(view.remote_line_for(KeyCode::Enter), None);
assert_eq!(view.remote_line_for(KeyCode::Char('e')), None);
assert_eq!(view.remote_line_for(KeyCode::Char('q')), None);
}
#[test]
fn sequence_advances_and_completes() {
let cli = Cli::try_parse_from(["t-", "1m"]).unwrap();
let themes = crate::theme::builtin();
let mut app = app_with(&cli, &themes, &[1, 2]);
assert_eq!(app.tick(0.0), None);
assert_eq!(app.upcoming_text().as_deref(), Some("next: s1 2m"));
assert!(app.status_json().contains(r"segment 1/2\nnext: s1 2m"));
assert!(app.status_json().ends_with(r#""name":null}"#));
app.name = Some("tea".into());
assert!(app.status_json().contains(r#"next: s1 2m\nname: tea","#));
assert!(app.status_json().ends_with(r#""name":"tea"}"#));
assert_eq!(app.info_line(true), "headless\trunning\t01:00\ts0\t1/2");
app.remote("sub 5m", 0.0).ok();
assert_eq!(
app.tick(1.0),
Some(Event::SegmentDone {
done: Some("s0".into()),
next: Some("s1".into())
})
);
assert_eq!((app.seg, app.title()), (1, Some("s1")));
assert_eq!(app.upcoming_text(), None);
assert!(!app.completed());
app.remote("sub 5m", 2.0).ok();
assert_eq!(
app.tick(3.0),
Some(Event::Completed {
done: Some("s1".into())
})
);
assert!(app.completed());
assert!(app.status_json().contains(r#""class":"done""#));
}
#[test]
fn confirm_waits_for_next() {
let cli = Cli::try_parse_from(["t-", "1m", "--confirm"]).unwrap();
let themes = crate::theme::builtin();
let mut app = app_with(&cli, &themes, &[1, 1]);
app.remote("sub 5m", 0.0).ok();
assert!(matches!(app.tick(1.0), Some(Event::SegmentDone { .. })));
assert!(app.waiting() && app.seg == 0);
assert_eq!(app.upcoming_text(), None, "the waiting line already names it");
assert!(app.status_json().contains(r#""class":"waiting""#));
app.remote("toggle", 2.0).ok();
assert!(!app.waiting() && app.seg == 1);
}
#[test]
fn quit_keys_close_the_help_popup_first() {
let cli = Cli::try_parse_from(["t-", "1m"]).unwrap();
let themes = crate::theme::builtin();
let mut app = app_with(&cli, &themes, &[1]);
let none = KeyModifiers::NONE;
for quit in [KeyCode::Char('q'), KeyCode::Esc] {
assert!(!app.on_key(KeyCode::Char('h'), none, 0.0));
assert!(!app.on_key(quit, none, 0.0), "{quit:?} should only close the popup");
assert!(!app.help);
}
assert!(
app.on_key(KeyCode::Char('q'), none, 0.0),
"q quits once the popup is closed"
);
}
#[test]
fn short_timers_get_short_effects_or_none() {
use effects::Kind;
let cli = Cli::try_parse_from(["t-", "1m"]).unwrap();
let themes = crate::theme::builtin();
let all = || vec![Kind::Highlight, Kind::Decrypt, Kind::Blackhole, Kind::Matrix];
let with_secs = |secs: u64| {
let plan = vec![Step {
dur: Duration::from_secs(secs),
label: None,
theme: None,
}];
let timer = Timer::new(plan[0].dur);
App::new(
&cli,
Pools {
intro: all(),
replay: all(),
zero: all(),
},
&themes,
0,
plan,
timer,
)
};
assert_eq!(with_secs(10).effect_choices(f32::INFINITY), []);
assert_eq!(with_secs(12).effect_choices(f32::INFINITY), [Kind::Highlight]);
assert_eq!(
with_secs(14).effect_choices(f32::INFINITY),
[Kind::Highlight, Kind::Decrypt]
);
assert_eq!(with_secs(60).effect_choices(f32::INFINITY).len(), 4);
}
#[test]
fn aligned_effects_step_by_every_rounded_up() {
let themes = crate::theme::builtin();
let mark_after = |args: &[&str], mark: f64| {
let cli = Cli::try_parse_from([&["t-", "1h"], args].concat()).unwrap();
app_with(&cli, &themes, &[60]).mark_after(mark)
};
assert_eq!(
mark_after(&["--effect-align", "10", "--every", "15"], 1500.0),
Some(1480.0)
);
assert_eq!(
mark_after(&["--effect-align", "30", "--every", "10"], 1500.0),
Some(1470.0)
);
assert_eq!(
mark_after(&["--effect-align", "30", "--every", "30"], 1500.0),
Some(1470.0)
);
assert_eq!(mark_after(&["--effect-align", "5", "--every", "12"], 40.0), Some(25.0));
assert_eq!(mark_after(&["--effect-align", "10", "--every", "0"], 1500.0), None);
assert_eq!(mark_after(&["--effect-align", "10", "--every", "15"], 25.0), None);
assert_eq!(mark_after(&["--every", "15"], 1500.0), None);
}
#[test]
fn demo_keys_pick_theme_and_effect() {
use effects::Kind;
let cli = Cli::try_parse_from(["t-", "--demo", "--effect", "rain"]).unwrap();
let themes = crate::theme::builtin();
let mut app = app_with(&cli, &themes, &[25]);
let key = |app: &mut App, code| assert!(!app.on_key(code, KeyModifiers::NONE, 1.0));
let kinds = app.demo.as_ref().unwrap().kinds.len();
assert_eq!(app.demo.as_ref().unwrap().kind(), Kind::Rain, "starts on --effect");
assert!(!app.demo.as_ref().unwrap().kinds.contains(&Kind::Off));
key(&mut app, KeyCode::Left);
assert_eq!(app.theme, themes.len() - 1);
key(&mut app, KeyCode::Right);
key(&mut app, KeyCode::Right);
assert_eq!(app.theme, 1);
key(&mut app, KeyCode::Down);
assert_eq!(app.demo.as_ref().unwrap().kind(), Kind::Beams);
for _ in 0..kinds {
key(&mut app, KeyCode::Up);
}
assert_eq!(app.demo.as_ref().unwrap().kind(), Kind::Beams);
assert_eq!(app.effect_choices(f32::INFINITY), [Kind::Beams]);
assert_eq!(app.demo_choice(), Some((themes[1].name.clone(), "beams".to_string())));
key(&mut app, KeyCode::Char('r'));
assert_eq!(app.theme, 1);
assert_ne!(app.demo.as_ref().unwrap().pick, 2);
app.demo.as_mut().unwrap().pick = 2;
key(&mut app, KeyCode::Char('f'));
assert_eq!(app.timer.total, Duration::from_secs(12));
app.timer = Timer::new(Duration::ZERO);
assert!(matches!(app.tick(2.0), Some(Event::Completed { .. })));
assert_eq!(app.tick(2.0 + DONE_FLASH_FOR + 1.5), None);
assert_eq!((app.timer.total, app.finished_at), (Duration::from_secs(25 * 60), None));
key(&mut app, KeyCode::Char('h'));
assert!(app.help);
key(&mut app, KeyCode::Char('q'));
assert!(app.on_key(KeyCode::Char('q'), KeyModifiers::NONE, 1.0));
}
#[test]
fn remote_commands() {
let cli = Cli::try_parse_from(["t-", "1m"]).unwrap();
let themes = crate::theme::builtin();
let mut app = app_with(&cli, &themes, &[1]);
app.remote("pause", 0.0).ok();
assert!(app.timer.paused());
assert!(app.status_json().contains(r#""class":"paused""#));
app.remote("add 2m", 0.0).ok();
assert_eq!(app.timer.total, Duration::from_secs(180));
assert!(matches!(app.remote("stop", 0.0), Ok(Reply::Stop)));
assert!(app.remote("next", 0.0).unwrap_err().contains("last segment"));
assert!(app.remote("add soon", 0.0).unwrap_err().contains("needs a duration"));
assert!(app.remote("dance", 0.0).unwrap_err().contains("unknown command"));
}
}