use std::time::{Duration, Instant};
use crate::base::{Point, Result, Size};
use crate::input::{Event, KeyEventKind, MouseKind, Parser};
use crate::render::{Cell, FrameDiff, PresentCaps, Presenter, Surface};
use crate::term::{Capabilities, TermRead, Terminal};
use crate::theme::Theme;
use super::identity;
pub trait SplashFrameSource {
fn render(&mut self, t: f32, size: Size, theme: &Theme) -> &Surface;
}
pub enum SplashWait {
Events(Vec<Event>),
Resize(Size),
Idle,
}
pub trait SplashIo {
fn size(&mut self) -> Result<Size>;
fn present(&mut self, bytes: &[u8]) -> Result<()>;
fn wait(&mut self, budget_ms: u64) -> Result<SplashWait>;
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SplashOutcome {
Completed,
Skipped,
CutOff,
SkippedByGate(&'static str),
}
#[derive(Copy, Clone, Debug)]
pub struct SplashOptions {
pub fps: u32,
pub total_ms: u32,
pub hard_cutoff_ms: u32,
pub fade_ms: u32,
}
impl Default for SplashOptions {
fn default() -> Self {
SplashOptions {
fps: 30,
total_ms: identity::SPLASH_TOTAL_MS,
hard_cutoff_ms: 2500,
fade_ms: identity::SKIP_FADE_MS,
}
}
}
pub fn skip_reason(
render_handle_is_tty: bool,
env: &dyn Fn(&str) -> Option<String>,
) -> Option<&'static str> {
if !render_handle_is_tty {
return Some("render handle is not a tty");
}
if env(identity::SPLASH_DISABLE_ENV).is_some_and(|v| !v.is_empty() && v != "0") {
return Some("ABSTRACTTUI_NO_SPLASH is set");
}
if env("NO_COLOR").is_some_and(|v| !v.is_empty()) {
return Some("NO_COLOR is set (a colorless splash is a delay, not an identity)");
}
if env("TERM").as_deref() == Some("dumb") {
return Some("TERM=dumb");
}
None
}
pub fn should_splash(caps: &Capabilities) -> std::result::Result<(), &'static str> {
if let Some(reason) = skip_reason(crate::term::have_tty(), &|k| std::env::var(k).ok()) {
return Err(reason);
}
if caps.dumb {
return Err("capabilities report a dumb terminal");
}
Ok(())
}
fn is_deliberate(event: &Event) -> bool {
match event {
Event::Key(k) => k.kind != KeyEventKind::Release,
Event::Mouse(m) => matches!(
m.kind,
MouseKind::Down
| MouseKind::WheelUp
| MouseKind::WheelDown
| MouseKind::WheelLeft
| MouseKind::WheelRight
),
Event::Paste(_) => true,
_ => false,
}
}
pub fn play(
io: &mut dyn SplashIo,
source: &mut dyn SplashFrameSource,
theme: &Theme,
caps: &PresentCaps,
opts: &SplashOptions,
now_ms: &mut dyn FnMut() -> u64,
) -> Result<SplashOutcome> {
let frame_ms = (1000 / opts.fps.max(1)).max(1) as u64;
let total_ms = opts.total_ms as u64;
let cutoff_ms = opts.hard_cutoff_ms as u64;
let fade_ms = opts.fade_ms.max(1) as u64;
let start = now_ms();
let mut size = io.size()?;
let mut prev = Surface::new(size, Cell::EMPTY);
let mut scratch: Option<Surface> = None; let mut diff = FrameDiff::new();
let mut presenter = Presenter::new();
let mut bytes: Vec<u8> = Vec::with_capacity(16 * 1024);
let mut skip_at: Option<u64> = None;
loop {
let elapsed = now_ms().saturating_sub(start);
if elapsed >= cutoff_ms {
return Ok(SplashOutcome::CutOff);
}
if let Some(s) = skip_at {
if elapsed.saturating_sub(s) >= fade_ms {
return Ok(SplashOutcome::Skipped);
}
} else if elapsed >= total_ms {
return Ok(SplashOutcome::Completed);
}
let t = elapsed as f32 / 1000.0;
let frame = source.render(t, size, theme);
debug_assert_eq!(
frame.size(),
size,
"frame source must honor the requested size"
);
let final_frame: &Surface = if let Some(s) = skip_at {
let k = (elapsed.saturating_sub(s)) as f32 / fade_ms as f32;
let k = crate::anim::Easing::CubicBezier(
identity::EASE_FADE[0],
identity::EASE_FADE[1],
identity::EASE_FADE[2],
identity::EASE_FADE[3],
)
.eval(k);
let buf = scratch.get_or_insert_with(|| Surface::new(size, Cell::EMPTY));
fade_toward_ground(buf, frame, theme, k);
buf
} else {
frame
};
bytes.clear();
let runs = diff.compute_full(&prev, final_frame);
presenter.emit(runs, final_frame, caps, &mut bytes);
io.present(&bytes)?;
prev.blit(final_frame, final_frame.bounds(), Point::new(0, 0));
let after = now_ms().saturating_sub(start);
let boundary = (after / frame_ms + 1) * frame_ms;
let mut budget = boundary - after;
budget = budget.min(cutoff_ms.saturating_sub(after).max(1));
if let Some(s) = skip_at {
budget = budget.min((s + fade_ms).saturating_sub(after).max(1));
}
match io.wait(budget)? {
SplashWait::Events(events) => {
let deliberate = events.iter().any(is_deliberate);
if deliberate {
if skip_at.is_some() {
return Ok(SplashOutcome::Skipped);
}
skip_at = Some(now_ms().saturating_sub(start));
}
}
SplashWait::Resize(new_size) => {
if new_size != size && new_size.w > 0 && new_size.h > 0 {
size = new_size;
prev = Surface::new(size, Cell::EMPTY);
scratch = None;
presenter.invalidate();
}
}
SplashWait::Idle => {}
}
}
}
fn fade_toward_ground(buf: &mut Surface, frame: &Surface, theme: &Theme, k: f32) {
if buf.size() != frame.size() {
*buf = Surface::new(frame.size(), Cell::EMPTY);
}
buf.blit(frame, frame.bounds(), Point::new(0, 0));
let ground = theme.tokens.bg;
let (w, h) = (buf.width(), buf.height());
for y in 0..h {
for x in 0..w {
let Some(cell) = buf.get(x, y) else { continue };
if cell.is_continuation() {
continue;
}
let faded = Cell {
fg: crate::theme::derive::mix(cell.fg, ground, k),
bg: crate::theme::derive::mix(cell.bg, ground, k),
..*cell
};
buf.set(x, y, faded);
}
}
}
pub struct TerminalIo<'a> {
term: &'a mut dyn Terminal,
parser: Parser,
carryover: Vec<Event>,
}
impl<'a> TerminalIo<'a> {
pub fn new(term: &'a mut dyn Terminal) -> Self {
TerminalIo {
term,
parser: Parser::new(),
carryover: Vec::new(),
}
}
pub fn finish(self) -> (Vec<Event>, Parser) {
(self.carryover, self.parser)
}
}
impl SplashIo for TerminalIo<'_> {
fn size(&mut self) -> Result<Size> {
self.term.size()
}
fn present(&mut self, bytes: &[u8]) -> Result<()> {
self.term.write(bytes)?;
self.term.flush()
}
fn wait(&mut self, budget_ms: u64) -> Result<SplashWait> {
let deadline = Instant::now() + Duration::from_millis(budget_ms);
match self.term.read(Some(deadline))? {
TermRead::Input(bytes) => {
let mut events = Vec::new();
self.parser.feed(bytes, &mut events);
let mut deliberate = Vec::new();
for e in events {
if is_deliberate(&e) {
deliberate.push(e);
} else {
self.carryover.push(e);
}
}
if deliberate.is_empty() {
Ok(SplashWait::Idle)
} else {
Ok(SplashWait::Events(deliberate))
}
}
TermRead::Resize(size) => Ok(SplashWait::Resize(size)),
TermRead::Wake => Ok(SplashWait::Idle),
TermRead::Idle => Ok(SplashWait::Idle),
}
}
}
pub fn splash_present_caps(caps: &Capabilities) -> PresentCaps {
PresentCaps::from(caps)
}
pub fn play_source(
term: &mut dyn Terminal,
render_handle_is_tty: bool,
caps: &Capabilities,
theme: &Theme,
source: &mut dyn SplashFrameSource,
) -> Result<(SplashOutcome, Vec<Event>)> {
if let Some(reason) = skip_reason(render_handle_is_tty, &|k| std::env::var(k).ok()) {
return Ok((SplashOutcome::SkippedByGate(reason), Vec::new()));
}
let mut io = TerminalIo::new(term);
let present = splash_present_caps(caps);
let t0 = Instant::now();
let mut clock = move || t0.elapsed().as_millis() as u64;
let outcome = play(
&mut io,
source,
theme,
&present,
&SplashOptions::default(),
&mut clock,
)?;
let (carryover, _parser) = io.finish();
Ok((outcome, carryover))
}
pub fn play_fallback(
term: &mut dyn Terminal,
render_handle_is_tty: bool,
caps: &Capabilities,
theme: &Theme,
) -> Result<(SplashOutcome, Vec<Event>)> {
let mut source = super::fallback2d::FallbackSplash::new();
play_source(term, render_handle_is_tty, caps, theme, &mut source)
}
#[cfg(test)]
#[path = "player_tests.rs"]
mod tests;