use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::time::{Duration, SystemTime};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use tachyonfx::{Effect as Fx, Interpolation, fx};
use super::app::{App, Overlay, View};
use super::format::WORKING;
use super::pipeline::{STAGES, StageState};
use super::theme::{ColorLevel, LookEnv, Theme};
use super::training::RunActivity;
use super::widgets::bar::round_to;
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub(super) const SPIN: Duration = Duration::from_millis(80);
pub(super) const LOADING_AFTER: Duration = Duration::from_millis(150);
pub(super) const FAST: Duration = Duration::from_millis(33);
const TAU: f64 = 0.25;
const SETTLED: f64 = 0.001;
const VIEW_FADE: Duration = Duration::from_millis(160);
const OVERLAY_FADE: Duration = Duration::from_millis(140);
const TOAST_FADE: Duration = Duration::from_millis(200);
const PULSE_HALF: Duration = Duration::from_millis(1200);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Purpose {
View,
Overlay,
Toast,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) struct Areas {
pub(super) body: Rect,
pub(super) overlay: Option<Rect>,
pub(super) toast: Option<Rect>,
pub(super) pulse: Option<Rect>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Colors {
bg: Color,
surface: Color,
dim: Color,
}
#[derive(Debug, Clone)]
struct Running {
fx: Fx,
started: Duration,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Seen {
view: Option<View>,
overlay: Option<String>,
status: Option<(String, SystemTime)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Bar {
Stage(usize),
Step,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MotionLevel {
Off,
Reduced,
On,
}
impl MotionLevel {
pub(super) fn detect(env: &LookEnv, color: ColorLevel) -> Self {
if color == ColorLevel::Mono {
return Self::Off;
}
if let Some(level) = env.motion.as_deref().and_then(Self::chosen) {
return level;
}
if env.ssh { Self::Reduced } else { Self::On }
}
pub(super) fn chosen(value: &OsStr) -> Option<Self> {
match value.to_str()? {
"on" => Some(Self::On),
"reduced" => Some(Self::Reduced),
"off" => Some(Self::Off),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub(super) struct Motion {
level: MotionLevel,
clock: Duration,
bars: BTreeMap<Bar, f64>,
cells: BTreeMap<Bar, u16>,
colors: Option<Colors>,
effects: BTreeMap<Purpose, Running>,
pulse: Option<Fx>,
seen: Seen,
}
impl Motion {
pub(super) fn new(level: MotionLevel) -> Self {
Self {
level,
clock: Duration::ZERO,
bars: BTreeMap::new(),
cells: BTreeMap::new(),
colors: None,
effects: BTreeMap::new(),
pulse: None,
seen: Seen::default(),
}
}
pub(super) fn colored(mut self, theme: &Theme) -> Self {
let colors = match (theme.base.bg, theme.surface.bg, theme.accent_dim.fg) {
(Some(bg), Some(surface), Some(dim)) => Some(Colors { bg, surface, dim }),
_ => None,
};
let on = self.level == MotionLevel::On && theme.level == ColorLevel::TrueColor;
self.colors = colors.filter(|_| on);
self.pulse = self
.colors
.map(|colors| fx::fade_to_fg(colors.dim, (PULSE_HALF, Interpolation::SineInOut)));
self
}
pub(super) fn pulses(&self) -> bool {
self.pulse.is_some()
}
fn start(&mut self, purpose: Purpose) {
let Some(colors) = self.colors else {
return;
};
let fx = match purpose {
Purpose::View => {
fx::fade_from(colors.bg, colors.bg, (VIEW_FADE, Interpolation::QuadOut))
},
Purpose::Overlay => fx::fade_from(
colors.surface,
colors.surface,
(OVERLAY_FADE, Interpolation::QuadOut),
),
Purpose::Toast => fx::fade_from_fg(colors.bg, (TOAST_FADE, Interpolation::QuadOut)),
};
self.effects.insert(
purpose,
Running {
fx,
started: self.clock,
},
);
}
fn observe(&mut self, now: Seen) {
if self.seen.view.is_some() && self.seen.view != now.view {
self.start(Purpose::View);
}
match &now.overlay {
Some(_) if now.overlay != self.seen.overlay => self.start(Purpose::Overlay),
Some(_) => {},
None => {
self.effects.remove(&Purpose::Overlay);
},
}
if now.status.is_some() && now.status != self.seen.status {
self.start(Purpose::Toast);
}
self.seen = now;
}
fn settle_effects(&mut self) -> bool {
let ran = !self.effects.is_empty();
let clock = self.clock;
self.effects.retain(|_, running| {
let length = running
.fx
.timer()
.map_or(Duration::ZERO, |timer| timer.duration());
clock.saturating_sub(running.started) < length
});
ran
}
fn pulse_age(&self) -> Duration {
let period = PULSE_HALF.as_millis() * 2;
let phase = u64::try_from(self.clock.as_millis() % period).unwrap_or(0);
let half = u64::try_from(PULSE_HALF.as_millis()).unwrap_or(0);
Duration::from_millis(if phase <= half {
phase
} else {
2 * half - phase
})
}
pub(super) fn apply(&self, buffer: &mut Buffer, areas: &Areas) {
for (purpose, running) in &self.effects {
let area = match purpose {
Purpose::View => Some(areas.body),
Purpose::Overlay => areas.overlay,
Purpose::Toast => areas.toast,
};
if let Some(area) = area {
let mut fx = running.fx.clone();
fx.process(self.clock.saturating_sub(running.started), buffer, area);
}
}
if let (Some(pulse), Some(area)) = (&self.pulse, areas.pulse) {
let mut fx = pulse.clone();
fx.process(self.pulse_age(), buffer, area);
}
}
pub(super) fn level(&self) -> MotionLevel {
self.level
}
pub(super) fn clock(&self) -> Duration {
self.clock
}
pub(super) fn spinner(&self) -> &'static str {
if self.level == MotionLevel::Off {
return WORKING;
}
let frame = self.clock.as_millis() / SPIN.as_millis() % 10;
SPINNER[usize::try_from(frame).unwrap_or(0)]
}
pub(super) fn bar(&mut self, key: Bar, target: f64, cells: u16) -> f64 {
self.cells.insert(key, cells);
self.shown(key, target)
}
fn shown(&self, key: Bar, target: f64) -> f64 {
if self.level == MotionLevel::Off {
return target;
}
self.bars
.get(&key)
.map_or(target, |shown| shown.min(target))
}
fn ease(&mut self, targets: &[(Bar, f64, bool)], elapsed: Duration) -> bool {
let closed = 1.0 - (-elapsed.as_secs_f64() / TAU).exp();
let mut moved = false;
for (key, target, snap) in targets {
let shown = *self.bars.entry(*key).or_insert(*target);
let next = if *snap || *target <= shown {
*target
} else {
let eased = shown + (*target - shown) * closed;
if *target - eased < SETTLED {
*target
} else {
eased
}
};
moved |= self.apart(*key, shown, next);
self.bars.insert(*key, next);
}
let shown = |key: &Bar| targets.iter().any(|(target, _, _)| target == key);
self.bars.retain(|key, _| shown(key));
self.cells.retain(|key, _| shown(key));
moved
}
fn apart(&self, key: Bar, one: f64, other: f64) -> bool {
match self.cells.get(&key) {
Some(cells) => {
let eighths = u32::from(*cells) * 8;
let at = |value: f64| round_to(value.clamp(0.0, 1.0) * f64::from(eighths), eighths);
at(one) != at(other)
},
None => (one - other).abs() >= SETTLED,
}
}
fn easing(&self, targets: &[(Bar, f64, bool)]) -> bool {
self.level != MotionLevel::Off
&& targets.iter().any(|(key, target, _)| {
self.bars
.get(key)
.is_some_and(|shown| self.apart(*key, *shown, *target))
})
}
pub(super) fn shows_load(&self, started: Option<Duration>) -> bool {
self.level == MotionLevel::Off
|| started.is_none_or(|at| self.clock.saturating_sub(at) >= LOADING_AFTER)
}
}
impl App {
pub(super) fn frame_period(&self) -> Option<Duration> {
if self.motion.level() == MotionLevel::Off {
return None;
}
if !self.motion.effects.is_empty() || self.motion.easing(&self.bar_targets()) {
return Some(FAST);
}
(!self.work().is_empty() || self.pulse_shown()).then_some(SPIN)
}
pub(super) fn pulse_shown(&self) -> bool {
self.motion.pulses()
&& self.overlay.is_none()
&& self.view == View::Training
&& self.training.selected_activity() == RunActivity::Followed
}
pub(super) fn observe_motion(&mut self) {
let overlay = self.overlay.as_ref().map(|overlay| match overlay {
Overlay::Help => "help".to_string(),
Overlay::Menu(_) => "menu".to_string(),
Overlay::Confirm(confirm) => confirm.title.clone(),
Overlay::Picker(picking) => format!("picker {:?}", picking.kind),
});
let status = self
.status
.as_ref()
.map(|status| (status.text.clone(), status.at));
self.motion.observe(Seen {
view: Some(self.view),
overlay,
status,
});
}
fn bar_targets(&self) -> Vec<(Bar, f64, bool)> {
let mut targets = Vec::new();
match self.view {
View::Pipeline => {},
View::Training => {
if let Some(ratio) = self.training.selected_ratio() {
targets.push((Bar::Step, ratio, false));
}
return targets;
},
View::Project | View::Dataset | View::Logs | View::Compare => return targets,
}
for (index, stage) in STAGES.iter().enumerate() {
let row = self.pipeline.row(*stage);
let ended = match row.state {
StageState::Running => false,
StageState::Done | StageState::Stopped => true,
StageState::Idle | StageState::Pending => continue,
};
targets.push((Bar::Stage(index), row.ratio(), ended));
}
targets
}
pub(super) fn on_frame(&mut self, elapsed: Duration) {
let spinner = self.motion.spinner();
let loading = self.motion.shows_load(self.load_at);
self.motion.clock = self.motion.clock.saturating_add(elapsed);
let working = !self.work().is_empty();
if working && self.motion.spinner() != spinner {
self.dirty = true;
}
let targets = self.bar_targets();
if self.motion.ease(&targets, elapsed) {
self.dirty = true;
}
if self.load.is_some() && self.motion.shows_load(self.load_at) != loading {
self.dirty = true;
}
if self.motion.settle_effects() || self.pulse_shown() {
self.dirty = true;
}
}
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use crossterm::event::KeyCode;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Cell;
use super::*;
use crate::tui::app::Severity;
use crate::tui::snapshots::{
app, dataset_app, draw, key, pipeline_running, text, training_app,
};
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn env(motion: Option<&str>, ssh: bool) -> LookEnv {
LookEnv {
motion: motion.map(OsString::from),
ssh,
..LookEnv::default()
}
}
#[test]
fn motion_is_on_reduced_over_ssh_and_off_without_color() {
let detect = |env: &LookEnv, color| MotionLevel::detect(env, color);
let color = ColorLevel::TrueColor;
assert_eq!(detect(&env(None, false), color), MotionLevel::On);
assert_eq!(detect(&env(None, true), color), MotionLevel::Reduced);
assert_eq!(detect(&env(Some("on"), true), color), MotionLevel::On);
assert_eq!(detect(&env(Some("off"), false), color), MotionLevel::Off);
assert_eq!(
detect(&env(Some("reduced"), false), color),
MotionLevel::Reduced
);
assert_eq!(
detect(&env(Some("fast"), false), color),
MotionLevel::On,
"ignored"
);
assert_eq!(
detect(&env(Some("on"), false), ColorLevel::Mono),
MotionLevel::Off,
"NO_COLOR wins"
);
assert_eq!(
detect(&env(None, false), ColorLevel::Named),
MotionLevel::On
);
}
#[test]
fn the_spinner_turns_every_80_ms() {
let mut motion = Motion::new(MotionLevel::On);
assert_eq!(motion.spinner(), "⠋");
motion.clock = Duration::from_millis(79);
assert_eq!(motion.spinner(), "⠋");
motion.clock = Duration::from_millis(80);
assert_eq!(motion.spinner(), "⠙");
motion.clock = Duration::from_millis(80 * 13);
assert_eq!(motion.spinner(), "⠸", "frame 3 of the second turn");
motion.level = MotionLevel::Off;
assert_eq!(motion.spinner(), WORKING);
}
#[test]
fn nothing_wakes_the_loop_when_nothing_moves() {
let mut app = app();
app.motion = Motion::new(MotionLevel::On);
assert_eq!(app.frame_period(), None, "idle");
pipeline_running(&mut app);
assert_eq!(app.frame_period(), Some(SPIN), "a spinner shows");
app.motion = Motion::new(MotionLevel::Off);
assert_eq!(app.frame_period(), None, "motion off");
}
#[test]
fn a_frame_redraws_only_when_the_spinner_moves() {
let mut app = app();
app.motion = Motion::new(MotionLevel::Reduced);
pipeline_running(&mut app);
app.dirty = false;
app.on_frame(Duration::from_millis(40));
assert!(!app.dirty, "same spinner frame");
app.on_frame(Duration::from_millis(40));
assert!(app.dirty, "next spinner frame");
assert_eq!(app.motion.clock(), Duration::from_millis(80));
}
#[test]
fn a_bar_eases_toward_its_value_and_lands_on_it() {
let mut motion = Motion::new(MotionLevel::Reduced);
let step = |ratio, snap| [(Bar::Step, ratio, snap)];
assert!(
!motion.ease(&step(0.2, false), Duration::ZERO),
"a new bar starts at its value"
);
assert!(
(motion.shown(Bar::Step, 0.6) - 0.2).abs() < 1e-9,
"not eased yet"
);
assert!(motion.easing(&step(0.6, false)));
assert!(motion.ease(&step(0.6, false), Duration::from_millis(250)));
let one_tau = 0.2 + 0.4 * (1.0 - (-1.0_f64).exp());
assert!((motion.shown(Bar::Step, 0.6) - one_tau).abs() < 1e-9);
motion.ease(&step(0.6, false), Duration::from_secs(10));
assert!(
(motion.shown(Bar::Step, 0.6) - 0.6).abs() < f64::EPSILON,
"landed"
);
assert!(!motion.easing(&step(0.6, false)));
motion.ease(&step(0.1, false), Duration::from_millis(16));
assert!(
(motion.shown(Bar::Step, 0.1) - 0.1).abs() < f64::EPSILON,
"a drop lands at once"
);
motion.ease(&step(0.9, true), Duration::from_millis(16));
assert!(
(motion.shown(Bar::Step, 0.9) - 0.9).abs() < f64::EPSILON,
"an ended stage lands"
);
motion.ease(&[], Duration::from_millis(16));
assert!(
(motion.shown(Bar::Step, 0.4) - 0.4).abs() < f64::EPSILON,
"forgotten: new again"
);
let off = Motion::new(MotionLevel::Off);
assert!((off.shown(Bar::Step, 0.7) - 0.7).abs() < f64::EPSILON);
}
fn answers_done(app: &mut App) {
for n in 120..200 {
app.pipeline.event(&crate::events::Event::ItemDone {
stage: crate::events::Stage::Answers,
id: format!("item{n}"),
usage: None,
cost: None,
excluded: false,
});
}
}
#[test]
fn frames_come_fast_while_a_bar_eases() -> TestResult {
let mut app = app();
app.motion = Motion::new(MotionLevel::On);
pipeline_running(&mut app);
app.view = View::Pipeline;
app.on_frame(Duration::ZERO);
draw(&mut app, 80, 24)?;
assert_eq!(
app.frame_period(),
Some(SPIN),
"settled: the spinner's pace"
);
answers_done(&mut app);
assert_eq!(app.frame_period(), Some(FAST), "easing to 200/400");
app.on_frame(Duration::from_secs(10));
assert_eq!(app.frame_period(), Some(SPIN));
Ok(())
}
#[test]
fn a_bar_out_of_view_never_speeds_the_frames_up() {
let mut app = app();
app.motion = Motion::new(MotionLevel::On);
pipeline_running(&mut app);
assert_eq!(app.view, View::Dataset);
app.on_frame(Duration::ZERO);
answers_done(&mut app);
app.on_frame(Duration::from_millis(1));
assert_eq!(app.frame_period(), Some(SPIN), "the spinner's pace only");
app.view = View::Pipeline;
app.on_frame(Duration::from_millis(1));
assert_eq!(app.frame_period(), Some(SPIN), "seen at its value");
}
#[test]
fn a_bar_redraws_only_when_it_moves_by_an_eighth_of_a_cell() -> TestResult {
let mut app = app();
app.motion = Motion::new(MotionLevel::On);
pipeline_running(&mut app);
app.view = View::Pipeline;
app.on_frame(Duration::ZERO);
draw(&mut app, 80, 24)?;
answers_done(&mut app);
app.dirty = false;
app.on_frame(Duration::from_micros(10));
assert!(!app.dirty, "the same eighth on screen");
assert_eq!(app.frame_period(), Some(FAST), "still easing");
app.on_frame(Duration::from_millis(30));
assert!(app.dirty, "an eighth further");
Ok(())
}
#[test]
fn a_load_is_shown_after_150_ms() -> TestResult {
let mut app = app();
app.motion = Motion::new(MotionLevel::On);
app.start();
let shown = |app: &mut App| -> Result<bool, Box<dyn std::error::Error>> {
Ok(text(&draw(app, 80, 24)?)
.join("\n")
.contains("reading data/"))
};
assert!(!shown(&mut app)?);
app.on_frame(Duration::from_millis(80));
assert!(!shown(&mut app)?);
app.dirty = false;
app.on_frame(Duration::from_millis(80));
assert!(app.dirty);
assert!(shown(&mut app)?);
let mut still = self::app();
still.start();
assert!(shown(&mut still)?, "at once when motion is off");
let rows = text(&draw(&mut still, 80, 24)?).join("\n");
assert!(rows.contains("… reading data/ "), "one ellipsis: {rows}");
Ok(())
}
fn moving(mut app: App) -> App {
app.motion = Motion::new(MotionLevel::On).colored(&app.theme);
app
}
const BG: Color = Color::Rgb(0x19, 0x11, 0x14);
fn cell(terminal: &Terminal<TestBackend>, x: u16, y: u16) -> Result<Cell, String> {
terminal
.backend()
.buffer()
.cell((x, y))
.cloned()
.ok_or_else(|| format!("no cell at {x}, {y}"))
}
#[test]
fn a_view_fades_in_from_the_background() -> TestResult {
let mut app = moving(dataset_app());
draw(&mut app, 80, 24)?;
app.on_input(&key(KeyCode::Char('3')));
let terminal = draw(&mut app, 80, 24)?;
for y in 1..23 {
for x in 0..80 {
let cell = cell(&terminal, x, y)?;
assert_eq!((cell.fg, cell.bg), (BG, BG), "at {x}, {y}");
}
}
assert_eq!(app.frame_period(), Some(FAST));
app.on_frame(VIEW_FADE);
let faded = draw(&mut app, 80, 24)?;
let mut still = dataset_app();
still.on_input(&key(KeyCode::Char('3')));
assert_eq!(
faded.backend().buffer(),
draw(&mut still, 80, 24)?.backend().buffer()
);
assert_eq!(app.frame_period(), None, "idle once the fade ended");
Ok(())
}
#[test]
fn an_overlay_fades_in_and_closes_at_once() -> TestResult {
let mut app = moving(app());
draw(&mut app, 80, 24)?;
app.on_input(&key(KeyCode::Char('?')));
let surface = Color::Rgb(0x20, 0x13, 0x18);
let rows = text(&draw(&mut app, 80, 24)?);
let y = rows
.iter()
.position(|row| row.contains("Everywhere"))
.ok_or("no help")?;
let x = rows
.get(y)
.ok_or("no row")?
.chars()
.position(|c| c == 'E')
.ok_or("no E")?;
let (x, y) = (u16::try_from(x)?, u16::try_from(y)?);
assert_eq!(cell(&draw(&mut app, 80, 24)?, x, y)?.fg, surface);
app.on_frame(OVERLAY_FADE);
let title = app.theme.title.fg.ok_or("no title color")?;
assert_eq!(cell(&draw(&mut app, 80, 24)?, x, y)?.fg, title);
app.on_input(&key(KeyCode::Char('?')));
app.on_input(&key(KeyCode::Esc));
draw(&mut app, 80, 24)?;
assert!(
!app.motion.effects.contains_key(&Purpose::Overlay),
"closed at once"
);
Ok(())
}
#[test]
fn a_new_status_fades_in() -> TestResult {
let mut app = moving(app());
draw(&mut app, 80, 24)?;
app.say(Severity::Info, "deletion saved");
assert_eq!(cell(&draw(&mut app, 80, 24)?, 1, 23)?.fg, BG);
app.on_frame(TOAST_FADE);
let ok = app.theme.ok.fg.ok_or("no ok color")?;
assert_eq!(cell(&draw(&mut app, 80, 24)?, 1, 23)?.fg, ok);
Ok(())
}
#[test]
fn the_followed_run_pulses_between_two_crimsons() -> TestResult {
let mut app = moving(training_app()?);
let bright = app.theme.title.fg.ok_or("no bright")?;
let dim = app.theme.accent_dim.fg.ok_or("no dim")?;
let rows = text(&draw(&mut app, 80, 24)?);
let y = rows
.iter()
.position(|row| row.contains('●'))
.ok_or("no marker")?;
let y = u16::try_from(y)?;
assert_eq!(cell(&draw(&mut app, 80, 24)?, 2, y)?.symbol(), "●");
assert_eq!(cell(&draw(&mut app, 80, 24)?, 2, y)?.fg, bright);
app.on_frame(PULSE_HALF);
assert_eq!(cell(&draw(&mut app, 80, 24)?, 2, y)?.fg, dim);
app.on_frame(PULSE_HALF);
assert_eq!(cell(&draw(&mut app, 80, 24)?, 2, y)?.fg, bright);
assert!(app.pulse_shown());
app.on_input(&key(KeyCode::Char('c')));
assert!(app.overlay.is_some(), "the cancel dialog");
assert!(!app.pulse_shown(), "under an overlay");
app.on_frame(PULSE_HALF);
let marker = cell(&draw(&mut app, 80, 24)?, 2, y)?;
assert_eq!(marker.symbol(), "●");
assert_eq!(
Some(marker.fg),
app.theme.dim.fg,
"dimmed with the view, never pulsed"
);
app.overlay = None;
app.training.tasks.clear();
assert!(!app.pulse_shown(), "no run followed");
Ok(())
}
fn settled(terminal: &Terminal<TestBackend>) -> Vec<(String, ratatui::style::Style)> {
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| {
let symbol = if SPINNER.contains(&cell.symbol()) {
WORKING
} else {
cell.symbol()
};
(symbol.to_string(), cell.style())
})
.collect()
}
#[test]
fn motion_on_settles_to_what_motion_off_draws() -> TestResult {
let changes = |app: &mut App| -> TestResult {
app.view = View::Dataset;
draw(app, 80, 24)?;
app.on_input(&key(KeyCode::Char('4')));
app.say(Severity::Warn, "refused: a stage is running");
app.overlay = Some(Overlay::Help);
draw(app, 80, 24)?;
Ok(())
};
let mut on = moving(training_app()?);
changes(&mut on)?;
on.on_frame(PULSE_HALF * 20);
let mut off = training_app()?;
changes(&mut off)?;
assert_eq!(
settled(&draw(&mut on, 80, 24)?),
settled(&draw(&mut off, 80, 24)?)
);
Ok(())
}
#[test]
fn a_fade_never_changes_a_symbol() -> TestResult {
let changes = |app: &mut App| -> TestResult {
draw(app, 80, 24)?;
app.on_input(&key(KeyCode::Char('3')));
app.say(Severity::Info, "deletion saved");
app.overlay = Some(Overlay::Help);
Ok(())
};
let mut on = moving(dataset_app());
changes(&mut on)?;
let mut off = dataset_app();
changes(&mut off)?;
let still = text(&draw(&mut off, 80, 24)?);
assert_eq!(text(&draw(&mut on, 80, 24)?), still, "as they start");
on.on_frame(OVERLAY_FADE / 2);
assert!(!on.motion.effects.is_empty(), "halfway");
assert_eq!(text(&draw(&mut on, 80, 24)?), still, "halfway");
Ok(())
}
#[test]
fn nothing_wakes_the_loop_on_a_run_nothing_follows() -> TestResult {
let mut app = moving(training_app()?);
draw(&mut app, 80, 24)?;
assert_eq!(app.frame_period(), Some(SPIN), "the followed run pulses");
app.training.tasks.clear();
assert_eq!(app.view, View::Training);
assert!(app.motion.pulses());
assert_eq!(app.frame_period(), None, "no run followed: nothing moves");
Ok(())
}
#[test]
fn color_effects_need_motion_on_and_24_bit_color() {
let truecolor = Theme::new(ColorLevel::TrueColor);
assert!(Motion::new(MotionLevel::On).colored(&truecolor).pulses());
assert!(
!Motion::new(MotionLevel::Reduced)
.colored(&truecolor)
.pulses()
);
let indexed = Theme::new(ColorLevel::Indexed);
assert!(!Motion::new(MotionLevel::On).colored(&indexed).pulses());
assert!(
!Motion::new(MotionLevel::On)
.colored(&Theme::mono())
.pulses()
);
}
}