kr580 2.0.0

Desktop KR580VM80 / Intel 8080 emulator.
Documentation
use std::time::{Duration, Instant};

pub(crate) const SETTINGS_SAVED_NOTICE_DURATION: Duration = Duration::from_secs(2);
const ENTER_DURATION: Duration = Duration::from_millis(180);
const EXIT_DURATION: Duration = Duration::from_millis(240);
const REFRESH_PULSE_SPLIT: f32 = 0.4;

#[derive(Clone, Copy, Debug)]
pub(crate) struct SettingsSavedNotice {
    started_at: Instant,
    entrance_from: SettingsSavedNoticePresentation,
    refreshed: bool,
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct SettingsSavedNoticePresentation {
    pub(crate) opacity: f32,
    pub(crate) offset_y: f32,
}

const INITIAL_PRESENTATION: SettingsSavedNoticePresentation = SettingsSavedNoticePresentation {
    opacity: 0.2,
    offset_y: -10.0,
};
const REFRESH_PULSE_PRESENTATION: SettingsSavedNoticePresentation =
    SettingsSavedNoticePresentation {
        opacity: 0.92,
        offset_y: -2.0,
    };
const VISIBLE_PRESENTATION: SettingsSavedNoticePresentation = SettingsSavedNoticePresentation {
    opacity: 1.0,
    offset_y: 0.0,
};

impl SettingsSavedNotice {
    pub(crate) fn new(started_at: Instant) -> Self {
        Self {
            started_at,
            entrance_from: INITIAL_PRESENTATION,
            refreshed: false,
        }
    }

    pub(crate) fn restarted(self, started_at: Instant) -> Self {
        Self {
            started_at,
            entrance_from: self.presentation(started_at),
            refreshed: true,
        }
    }

    pub(crate) fn is_expired(self, now: Instant) -> bool {
        now.saturating_duration_since(self.started_at) >= SETTINGS_SAVED_NOTICE_DURATION
    }

    pub(crate) fn presentation(self, now: Instant) -> SettingsSavedNoticePresentation {
        let elapsed = now.saturating_duration_since(self.started_at);
        if elapsed < ENTER_DURATION {
            let progress = elapsed.as_secs_f32() / ENTER_DURATION.as_secs_f32();
            return self.entrance_presentation(progress);
        }

        let exit_started_at = SETTINGS_SAVED_NOTICE_DURATION - EXIT_DURATION;
        if elapsed >= exit_started_at {
            let progress = ((elapsed - exit_started_at).as_secs_f32()
                / EXIT_DURATION.as_secs_f32())
            .clamp(0.0, 1.0);
            let eased = progress.powi(3);
            return SettingsSavedNoticePresentation {
                opacity: 1.0 - eased,
                offset_y: -4.0 * eased,
            };
        }

        VISIBLE_PRESENTATION
    }

    fn entrance_presentation(self, progress: f32) -> SettingsSavedNoticePresentation {
        if !self.refreshed {
            return interpolate_presentation(
                self.entrance_from,
                VISIBLE_PRESENTATION,
                ease_out_cubic(progress),
            );
        }

        if progress < REFRESH_PULSE_SPLIT {
            return interpolate_presentation(
                self.entrance_from,
                REFRESH_PULSE_PRESENTATION,
                smoothstep(progress / REFRESH_PULSE_SPLIT),
            );
        }

        interpolate_presentation(
            REFRESH_PULSE_PRESENTATION,
            VISIBLE_PRESENTATION,
            smoothstep((progress - REFRESH_PULSE_SPLIT) / (1.0 - REFRESH_PULSE_SPLIT)),
        )
    }
}

fn interpolate_presentation(
    from: SettingsSavedNoticePresentation,
    to: SettingsSavedNoticePresentation,
    progress: f32,
) -> SettingsSavedNoticePresentation {
    SettingsSavedNoticePresentation {
        opacity: interpolate(from.opacity, to.opacity, progress),
        offset_y: interpolate(from.offset_y, to.offset_y, progress),
    }
}

fn interpolate(from: f32, to: f32, progress: f32) -> f32 {
    from + (to - from) * progress
}

fn ease_out_cubic(progress: f32) -> f32 {
    1.0 - (1.0 - progress).powi(3)
}

fn smoothstep(progress: f32) -> f32 {
    progress * progress * (3.0 - 2.0 * progress)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn presentation_enters_holds_and_expires_in_two_seconds() {
        let started_at = Instant::now();
        let notice = SettingsSavedNotice::new(started_at);

        let entering = notice.presentation(started_at);
        assert_eq!(entering.opacity, 0.2);
        assert_eq!(entering.offset_y, -10.0);

        let holding = notice.presentation(started_at + Duration::from_secs(1));
        assert_eq!(holding.opacity, 1.0);
        assert_eq!(holding.offset_y, 0.0);

        let exiting = notice.presentation(started_at + Duration::from_millis(1_900));
        assert!(exiting.opacity < 1.0);
        assert!(exiting.offset_y < 0.0);
        assert!(!notice.is_expired(started_at + Duration::from_millis(1_999)));
        assert!(notice.is_expired(started_at + SETTINGS_SAVED_NOTICE_DURATION));
    }

    #[test]
    fn restarting_notice_preserves_current_frame_and_full_lifetime() {
        let first_start = Instant::now();
        let replacement_start = first_start + Duration::from_secs(1);
        let first = SettingsSavedNotice::new(first_start);
        let replacement = first.restarted(replacement_start);

        assert_eq!(
            replacement.presentation(replacement_start),
            first.presentation(replacement_start)
        );
        assert!(!replacement.is_expired(first_start + SETTINGS_SAVED_NOTICE_DURATION));
        assert!(replacement.is_expired(replacement_start + SETTINGS_SAVED_NOTICE_DURATION));
    }

    #[test]
    fn restart_from_visible_state_uses_a_subtle_pulse() {
        let started_at = Instant::now();
        let notice = SettingsSavedNotice::new(started_at);
        let restart_at = started_at + Duration::from_secs(1);
        let restarted = notice.restarted(restart_at);

        assert_eq!(restarted.presentation(restart_at), VISIBLE_PRESENTATION);
        let pulse = restarted.presentation(restart_at + Duration::from_millis(90));
        assert!((0.91..0.95).contains(&pulse.opacity));
        assert!((-2.1..-1.0).contains(&pulse.offset_y));
        assert_eq!(
            restarted.presentation(restart_at + ENTER_DURATION),
            VISIBLE_PRESENTATION
        );
    }

    #[test]
    fn repeated_restarts_remain_continuous_during_entry() {
        let started_at = Instant::now();
        let first = SettingsSavedNotice::new(started_at);
        let first_restart_at = started_at + Duration::from_millis(60);
        let first_restarted = first.restarted(first_restart_at);
        let second_restart_at = first_restart_at + Duration::from_millis(40);
        let before_second_restart = first_restarted.presentation(second_restart_at);
        let second_restarted = first_restarted.restarted(second_restart_at);

        assert_eq!(
            second_restarted.presentation(second_restart_at),
            before_second_restart
        );
        assert_eq!(
            second_restarted.presentation(second_restart_at + ENTER_DURATION),
            SettingsSavedNoticePresentation {
                opacity: 1.0,
                offset_y: 0.0,
            }
        );
    }
}