use super::*;
pub(crate) const FAST_MODE_RAINBOW_FRAME_INTERVAL: Duration = Duration::from_millis(83);
const SCROLL_COALESCE_MAX_EVENTS: usize = 128;
const SCROLL_COALESCE_MAX_DURATION: Duration = Duration::from_millis(2);
pub(crate) fn prompt_cursor_blink_due(
state: &state::MissionControlState,
last_blink: Instant,
) -> bool {
state.is_prompt_focused()
&& state.prompt_is_empty()
&& last_blink.elapsed() >= PROMPT_CURSOR_BLINK_INTERVAL
}
pub(crate) fn next_ui_timer_timeout(
state: &state::MissionControlState,
last_blink: Instant,
last_footer_git_branch_refresh: Instant,
last_fast_mode_rainbow: Option<Instant>,
now: Instant,
) -> Option<Duration> {
let mut next_due = last_footer_git_branch_refresh + FOOTER_GIT_BRANCH_REFRESH_INTERVAL;
if state.is_prompt_focused() && state.prompt_is_empty() {
next_due = next_due.min(last_blink + PROMPT_CURSOR_BLINK_INTERVAL);
}
if let Some(toast) = &state.toast {
next_due = next_due.min(toast.expires_at());
}
if state.fast_mode_rainbow_active() || state.welcome_animation_active() {
let next_rainbow_due = last_fast_mode_rainbow
.map(|last| last + FAST_MODE_RAINBOW_FRAME_INTERVAL)
.unwrap_or_else(|| now + FAST_MODE_RAINBOW_FRAME_INTERVAL);
next_due = next_due.min(next_rainbow_due);
}
if let Some(timeout) = state.activity_motion_timeout(now) {
next_due = next_due.min(now + timeout);
}
Some(next_due.saturating_duration_since(now))
}
pub(crate) fn tick_fast_mode_rainbow(
state: &mut state::MissionControlState,
last_fast_mode_rainbow: &mut Option<Instant>,
now: Instant,
) -> bool {
if !state.fast_mode_rainbow_active() && !state.welcome_animation_active() {
*last_fast_mode_rainbow = None;
return false;
}
let Some(last_frame) = *last_fast_mode_rainbow else {
*last_fast_mode_rainbow = Some(now);
return false;
};
if now.saturating_duration_since(last_frame) < FAST_MODE_RAINBOW_FRAME_INTERVAL {
return false;
}
*last_fast_mode_rainbow = Some(now);
let rainbow_advanced = state.advance_fast_mode_rainbow_phase();
let welcome_advanced = state.welcome_animation_active();
if welcome_advanced {
state.welcome_animation_phase = state.welcome_animation_phase.wrapping_add(1);
}
rainbow_advanced || welcome_advanced
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum RedrawIntent {
#[default]
None,
ScrollOnly,
Immediate,
}
impl RedrawIntent {
pub(crate) fn request_immediate(&mut self) {
*self = Self::Immediate;
}
pub(crate) fn request_scroll(&mut self) {
if *self == Self::None {
*self = Self::ScrollOnly;
}
}
pub(crate) fn clear(&mut self) {
*self = Self::None;
}
}
pub(crate) fn should_draw_frame(intent: RedrawIntent) -> bool {
matches!(intent, RedrawIntent::Immediate | RedrawIntent::ScrollOnly)
}
pub(crate) fn coalesce_mouse_scroll_event(
first: crossterm::event::MouseEvent,
terminal_area: ratatui::layout::Rect,
state: &state::MissionControlState,
bridged_events: &Receiver<TerminalInputEvent>,
pending_events: &mut VecDeque<TerminalInputEvent>,
) -> (crossterm::event::MouseEvent, u16, usize) {
let Some(first_batch) = mouse_scroll_batch(first, terminal_area, state) else {
return (first, state::MOUSE_WHEEL_SCROLL_ROWS, 0);
};
let mut notches = 1u16;
let mut coalesced_events: usize = 0;
let started = Instant::now();
while coalesced_events < SCROLL_COALESCE_MAX_EVENTS
&& started.elapsed() < SCROLL_COALESCE_MAX_DURATION
{
let Some(event) = pending_events
.pop_front()
.or_else(|| bridged_events.try_recv().ok())
else {
break;
};
let TerminalInputEvent::Event(crossterm::event::Event::Mouse(mouse)) = event else {
pending_events.push_front(event);
break;
};
let Some(next_batch) = mouse_scroll_batch(mouse, terminal_area, state) else {
pending_events.push_front(TerminalInputEvent::Event(crossterm::event::Event::Mouse(
mouse,
)));
break;
};
if !first_batch.coalesces_with(next_batch) {
pending_events.push_front(TerminalInputEvent::Event(crossterm::event::Event::Mouse(
mouse,
)));
break;
}
notches = notches.saturating_add(1);
coalesced_events = coalesced_events.saturating_add(1);
}
(
first,
notches.saturating_mul(state::MOUSE_WHEEL_SCROLL_ROWS),
coalesced_events,
)
}
impl DrainResult {
pub(crate) fn merge(&mut self, other: DrainResult) {
let DrainResult {
changed,
run_finished,
run_finished_worker_ids,
model_catalog_finished,
model_catalog_loaded,
fast_mode_persisted,
theme_catalog_loaded,
theme_persisted,
startup_critical_loaded,
usage_loaded,
startup_decorative_loaded,
custom_provider_finished,
session_preview_loaded,
session_switch_loaded,
session_title_updated,
rewind_finished,
export_finished,
model_selection_finished,
compaction_runtime_refresh,
footer_git_branch_loaded,
} = other;
self.changed |= changed;
self.run_finished |= run_finished;
self.run_finished_worker_ids.extend(run_finished_worker_ids);
self.model_catalog_finished |= model_catalog_finished;
self.model_catalog_loaded.extend(model_catalog_loaded);
self.fast_mode_persisted.extend(fast_mode_persisted);
self.theme_catalog_loaded.extend(theme_catalog_loaded);
self.theme_persisted.extend(theme_persisted);
self.startup_critical_loaded.extend(startup_critical_loaded);
self.usage_loaded.extend(usage_loaded);
self.startup_decorative_loaded
.extend(startup_decorative_loaded);
self.custom_provider_finished
.extend(custom_provider_finished);
self.session_preview_loaded.extend(session_preview_loaded);
self.session_switch_loaded.extend(session_switch_loaded);
self.session_title_updated.extend(session_title_updated);
self.rewind_finished.extend(rewind_finished);
self.export_finished.extend(export_finished);
self.model_selection_finished
.extend(model_selection_finished);
self.compaction_runtime_refresh
.extend(compaction_runtime_refresh);
self.footer_git_branch_loaded
.extend(footer_git_branch_loaded);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drain_result_merge_preserves_every_field() {
let mut result = DrainResult {
run_finished_worker_ids: vec![1],
..Default::default()
};
let other = DrainResult {
changed: true,
run_finished: true,
run_finished_worker_ids: vec![2],
model_catalog_finished: true,
model_catalog_loaded: vec![(3, Err("catalog".to_string()), 4)],
fast_mode_persisted: vec![(5, true, Err("fast mode".to_string()))],
theme_catalog_loaded: vec![(6, Err("theme catalog".to_string()))],
theme_persisted: vec![(7, "theme-id".to_string(), 8, Err("theme save".to_string()))],
startup_critical_loaded: vec![(9, Err("startup critical".to_string()))],
usage_loaded: vec![(
10,
crate::tui::usage::UsageLoadResult::Error("usage".to_string()),
)],
startup_decorative_loaded: vec![(11, Err("startup decorative".to_string()))],
custom_provider_finished: vec![(
"provider".to_string(),
Err("custom provider".to_string()),
)],
session_preview_loaded: vec![(
12,
"session".to_string(),
state::SessionPreviewState::Empty,
)],
session_switch_loaded: vec![(
13,
"session".to_string(),
Err("session switch".to_string()),
)],
session_title_updated: vec![("session".to_string(), "title".to_string())],
footer_git_branch_loaded: vec![(14, Some("main".to_string()))],
..Default::default()
};
result.merge(other);
assert!(result.changed);
assert!(result.run_finished);
assert_eq!(result.run_finished_worker_ids, vec![1, 2]);
assert!(result.model_catalog_finished);
assert_eq!(result.model_catalog_loaded.len(), 1);
assert_eq!(result.model_catalog_loaded[0].0, 3);
assert_eq!(result.model_catalog_loaded[0].2, 4);
assert_eq!(result.fast_mode_persisted.len(), 1);
assert_eq!(result.theme_catalog_loaded.len(), 1);
assert!(matches!(
&result.theme_catalog_loaded[0].1,
Err(error) if error == "theme catalog"
));
assert_eq!(result.theme_persisted.len(), 1);
assert_eq!(result.theme_persisted[0].0, 7);
assert_eq!(result.theme_persisted[0].1, "theme-id");
assert_eq!(result.theme_persisted[0].2, 8);
assert_eq!(result.startup_critical_loaded.len(), 1);
assert_eq!(result.usage_loaded.len(), 1);
assert_eq!(
result.usage_loaded[0].1,
crate::tui::usage::UsageLoadResult::Error("usage".to_string())
);
assert_eq!(result.startup_decorative_loaded.len(), 1);
assert_eq!(result.custom_provider_finished.len(), 1);
assert_eq!(result.session_preview_loaded.len(), 1);
assert_eq!(result.session_switch_loaded.len(), 1);
assert_eq!(
result.session_title_updated,
vec![("session".to_string(), "title".to_string())]
);
assert_eq!(
result.footer_git_branch_loaded,
vec![(14, Some("main".to_string()))]
);
}
#[test]
fn tail_drain_merge_keeps_theme_completion_after_first_event() {
let (sender, receiver) = bounded::<TuiEvent>(2);
sender
.send(TuiEvent::Output(OutputEvent::AssistantDelta {
text: "unrelated".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::ThemeCatalogLoaded {
request_id: 7,
result: Err("theme catalog failed".to_string()),
})
.unwrap();
let mut state = state::MissionControlState::default();
let mut result = DrainResult::default();
apply_tui_event_to_state(&mut state, receiver.try_recv().unwrap(), &mut result);
let started = Instant::now();
let tail = drain_tui_events_budgeted_from(
&receiver,
&mut state,
UI_DRAIN_BUDGET,
1,
started,
|| started,
);
result.merge(tail.result);
assert_eq!(tail.processed, 2);
assert_eq!(result.theme_catalog_loaded.len(), 1);
assert_eq!(result.theme_catalog_loaded[0].0, 7);
assert!(matches!(
&result.theme_catalog_loaded[0].1,
Err(error) if error == "theme catalog failed"
));
}
fn mouse(
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
) -> crossterm::event::MouseEvent {
crossterm::event::MouseEvent {
kind,
column,
row,
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
fn scroll_event(
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
) -> TerminalInputEvent {
TerminalInputEvent::Event(crossterm::event::Event::Mouse(mouse(kind, column, row)))
}
#[test]
fn scroll_coalescing_preserves_distance_and_stops_at_mixed_event() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let state = state::MissionControlState::default();
let (sender, receiver) = bounded(16);
for _ in 0..4 {
sender
.send(scroll_event(
crossterm::event::MouseEventKind::ScrollUp,
1,
4,
))
.unwrap();
}
let key = TerminalInputEvent::Event(crossterm::event::Event::Key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::NONE,
),
));
sender.send(key.clone()).unwrap();
sender
.send(scroll_event(
crossterm::event::MouseEventKind::ScrollUp,
1,
4,
))
.unwrap();
let mut pending = VecDeque::new();
let (_, rows, coalesced) = coalesce_mouse_scroll_event(
mouse(crossterm::event::MouseEventKind::ScrollUp, 1, 4),
area,
&state,
&receiver,
&mut pending,
);
assert_eq!(coalesced, 4);
assert_eq!(rows, 5 * state::MOUSE_WHEEL_SCROLL_ROWS);
assert_eq!(pending.pop_front(), Some(key));
assert!(matches!(
receiver.try_recv(),
Ok(TerminalInputEvent::Event(crossterm::event::Event::Mouse(_)))
));
}
#[test]
fn scroll_coalescing_preserves_direction_and_target_boundaries() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let state = state::MissionControlState::default();
for boundary in [
scroll_event(crossterm::event::MouseEventKind::ScrollDown, 1, 4),
scroll_event(crossterm::event::MouseEventKind::ScrollUp, 61, 18),
] {
let (sender, receiver) = bounded(4);
sender.send(boundary.clone()).unwrap();
let mut pending = VecDeque::new();
let (_, rows, coalesced) = coalesce_mouse_scroll_event(
mouse(crossterm::event::MouseEventKind::ScrollUp, 1, 4),
area,
&state,
&receiver,
&mut pending,
);
assert_eq!(coalesced, 0);
assert_eq!(rows, state::MOUSE_WHEEL_SCROLL_ROWS);
assert_eq!(pending.pop_front(), Some(boundary));
}
}
#[test]
fn scroll_coalescing_yields_with_distance_and_queue_order_intact() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let state = state::MissionControlState::default();
let queued = SCROLL_COALESCE_MAX_EVENTS * 3;
let (sender, receiver) = bounded(queued + 1);
for _ in 0..queued {
sender
.send(scroll_event(
crossterm::event::MouseEventKind::ScrollUp,
1,
4,
))
.unwrap();
}
let boundary = TerminalInputEvent::Event(crossterm::event::Event::Resize(80, 24));
sender.send(boundary.clone()).unwrap();
let mut pending = VecDeque::new();
let mut total_rows = 0usize;
let mut first = mouse(crossterm::event::MouseEventKind::ScrollUp, 1, 4);
loop {
let (_, rows, coalesced) =
coalesce_mouse_scroll_event(first, area, &state, &receiver, &mut pending);
assert!(coalesced <= SCROLL_COALESCE_MAX_EVENTS);
total_rows += usize::from(rows);
let next = pending
.pop_front()
.or_else(|| receiver.try_recv().ok())
.unwrap();
match next {
TerminalInputEvent::Event(crossterm::event::Event::Mouse(mouse)) => first = mouse,
event => {
assert_eq!(event, boundary);
break;
}
}
}
assert_eq!(
total_rows,
(queued + 1) * usize::from(state::MOUSE_WHEEL_SCROLL_ROWS)
);
assert!(pending.is_empty());
assert!(receiver.is_empty());
}
#[test]
fn scroll_coalescing_does_not_skip_older_pending_input() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let state = state::MissionControlState::default();
let (sender, receiver) = bounded(2);
let scroll = scroll_event(crossterm::event::MouseEventKind::ScrollUp, 1, 4);
sender.send(scroll.clone()).unwrap();
let boundary = TerminalInputEvent::Event(crossterm::event::Event::Resize(80, 24));
let mut pending = VecDeque::from([scroll.clone(), boundary.clone(), scroll.clone()]);
let (_, rows, coalesced) = coalesce_mouse_scroll_event(
mouse(crossterm::event::MouseEventKind::ScrollUp, 1, 4),
area,
&state,
&receiver,
&mut pending,
);
assert_eq!(coalesced, 1);
assert_eq!(rows, 2 * state::MOUSE_WHEEL_SCROLL_ROWS);
assert_eq!(pending, VecDeque::from([boundary, scroll.clone()]));
assert_eq!(receiver.try_recv().unwrap(), scroll);
}
#[test]
#[ignore = "release-mode scroll burst measurement; run with --release --ignored --nocapture"]
fn scroll_coalescing_burst_measurement() {
let area = ratatui::layout::Rect::new(0, 0, 100, 30);
let state = state::MissionControlState::default();
for queued in [8usize, 128, 1_023] {
let (sender, receiver) = bounded(1_024);
for _ in 0..queued {
sender
.send(scroll_event(
crossterm::event::MouseEventKind::ScrollUp,
1,
4,
))
.unwrap();
}
let mut pending = VecDeque::new();
let started = Instant::now();
let (_, rows, coalesced) = coalesce_mouse_scroll_event(
mouse(crossterm::event::MouseEventKind::ScrollUp, 1, 4),
area,
&state,
&receiver,
&mut pending,
);
eprintln!(
"scroll_coalescing queued={} coalesced={} rows={} elapsed_us={} pending={}",
queued,
coalesced,
rows,
started.elapsed().as_micros(),
pending.len()
);
assert!(coalesced <= queued.min(SCROLL_COALESCE_MAX_EVENTS));
assert_eq!(receiver.len() + pending.len() + coalesced, queued);
}
}
fn rainbow_state() -> state::MissionControlState {
let mut state = state::MissionControlState {
provider: "openai-codex".to_string(),
model: "gpt-5.5".to_string(),
provider_ready: true,
color_enabled: true,
fast_mode_enabled: true,
fast_mode_effective: true,
focus_pane: state::TuiFocusPane::ActivityTree,
..Default::default()
};
state.transcript.push_back("User: hello");
state
}
fn timeout(state: &state::MissionControlState, now: Instant) -> Duration {
next_ui_timer_timeout(state, now, now, Some(now), now).unwrap()
}
fn welcome_state() -> state::MissionControlState {
state::MissionControlState {
color_enabled: true,
focus_pane: state::TuiFocusPane::ActivityTree,
..Default::default()
}
}
#[test]
fn welcome_animation_ticks_without_fast_mode_and_wraps_once_after_delay() {
let start = Instant::now();
let mut state = welcome_state();
let mut anchor = None;
assert_eq!(state.welcome_animation_phase, 0);
assert_eq!(timeout(&state, start), FAST_MODE_RAINBOW_FRAME_INTERVAL);
assert!(!tick_fast_mode_rainbow(&mut state, &mut anchor, start));
assert!(!tick_fast_mode_rainbow(
&mut state,
&mut anchor,
start + Duration::from_millis(82),
));
assert!(tick_fast_mode_rainbow(
&mut state,
&mut anchor,
start + Duration::from_millis(83),
));
assert_eq!(state.welcome_animation_phase, 1);
assert_eq!(state.fast_mode_rainbow_phase, 0);
state.welcome_animation_phase = usize::MAX;
let delayed = start + Duration::from_secs(10);
assert!(tick_fast_mode_rainbow(&mut state, &mut anchor, delayed));
assert_eq!(state.welcome_animation_phase, 0);
assert_eq!(anchor, Some(delayed));
assert!(!tick_fast_mode_rainbow(&mut state, &mut anchor, delayed));
}
#[test]
fn welcome_animation_stops_with_transcript_reduced_motion_or_no_color() {
let start = Instant::now();
for disable in [
|state: &mut state::MissionControlState| {
state.transcript.push_back("User: hi");
},
|state: &mut state::MissionControlState| state.reduced_motion = true,
|state: &mut state::MissionControlState| state.color_enabled = false,
] {
let mut state = welcome_state();
let mut anchor = Some(start);
assert!(tick_fast_mode_rainbow(
&mut state,
&mut anchor,
start + FAST_MODE_RAINBOW_FRAME_INTERVAL,
));
disable(&mut state);
let stopped = start + Duration::from_secs(1);
assert_eq!(timeout(&state, stopped), FOOTER_GIT_BRANCH_REFRESH_INTERVAL);
assert!(!tick_fast_mode_rainbow(&mut state, &mut anchor, stopped));
assert_eq!(anchor, None);
assert_eq!(state.welcome_animation_phase, 1);
}
}
#[test]
fn shared_animation_tick_advances_both_active_phases() {
let start = Instant::now();
let mut state = welcome_state();
state.fast_mode_enabled = true;
state.fast_mode_effective = true;
state.provider_ready = true;
state.model = "gpt-5.5".into();
let mut anchor = Some(start);
assert!(tick_fast_mode_rainbow(
&mut state,
&mut anchor,
start + FAST_MODE_RAINBOW_FRAME_INTERVAL,
));
assert_eq!(state.welcome_animation_phase, 1);
assert_eq!(state.fast_mode_rainbow_phase, 1);
}
#[test]
fn activity_timer_wakes_without_fast_mode_and_settles_after_completion() {
let now = Instant::now();
let mut state = rainbow_state();
state.fast_mode_enabled = false;
state.start_running_prompt("work".into());
assert_eq!(timeout(&state, now), Duration::from_millis(83));
state.highlight_live_completion(now);
state.clear_running_prompt();
assert_eq!(timeout(&state, now), Duration::from_millis(650));
assert!(state.tick_activity_motion(now + Duration::from_millis(650)));
assert_eq!(timeout(&state, now), FOOTER_GIT_BRANCH_REFRESH_INTERVAL);
}
#[test]
fn fast_mode_rainbow_timer_is_83ms_only_when_active_and_visible() {
let now = Instant::now();
let mut state = rainbow_state();
assert_eq!(timeout(&state, now), FAST_MODE_RAINBOW_FRAME_INTERVAL);
for disable in [
|state: &mut state::MissionControlState| state.fast_mode_enabled = false,
|state: &mut state::MissionControlState| state.fast_mode_effective = false,
|state: &mut state::MissionControlState| state.color_enabled = false,
|state: &mut state::MissionControlState| state.provider_ready = false,
|state: &mut state::MissionControlState| {
state.provider_ready = true;
state.provider.clear();
state.model.clear();
},
] {
state = rainbow_state();
disable(&mut state);
assert_eq!(timeout(&state, now), FOOTER_GIT_BRANCH_REFRESH_INTERVAL);
}
}
#[test]
fn running_prompt_hides_alt_m_but_keeps_fast_rainbow_timer_active() {
let now = Instant::now();
let mut state = rainbow_state();
state.start_running_prompt("inspect".to_string());
assert_eq!(timeout(&state, now), FAST_MODE_RAINBOW_FRAME_INTERVAL);
let mut anchor = Some(now);
assert!(tick_fast_mode_rainbow(
&mut state,
&mut anchor,
now + FAST_MODE_RAINBOW_FRAME_INTERVAL,
));
}
#[test]
fn fast_mode_rainbow_tick_advances_once_and_reanchors_after_delay() {
let start = Instant::now();
let mut state = rainbow_state();
let mut anchor = Some(start);
assert!(!tick_fast_mode_rainbow(
&mut state,
&mut anchor,
start + FAST_MODE_RAINBOW_FRAME_INTERVAL - Duration::from_millis(1),
));
assert!(tick_fast_mode_rainbow(
&mut state,
&mut anchor,
start + FAST_MODE_RAINBOW_FRAME_INTERVAL,
));
assert_eq!(state.fast_mode_rainbow_phase, 1);
let delayed = start + Duration::from_secs(1);
assert!(tick_fast_mode_rainbow(&mut state, &mut anchor, delayed));
assert_eq!(state.fast_mode_rainbow_phase, 2);
assert_eq!(anchor, Some(delayed));
assert!(!tick_fast_mode_rainbow(
&mut state,
&mut anchor,
delayed + FAST_MODE_RAINBOW_FRAME_INTERVAL - Duration::from_millis(1),
));
assert!(tick_fast_mode_rainbow(
&mut state,
&mut anchor,
delayed + FAST_MODE_RAINBOW_FRAME_INTERVAL,
));
assert_eq!(state.fast_mode_rainbow_phase, 3);
}
#[test]
fn fast_mode_rainbow_rearms_without_resetting_phase_or_catching_up() {
let start = Instant::now();
let mut state = rainbow_state();
state.fast_mode_rainbow_phase = 6;
let mut anchor = Some(start);
assert!(tick_fast_mode_rainbow(
&mut state,
&mut anchor,
start + FAST_MODE_RAINBOW_FRAME_INTERVAL,
));
assert_eq!(state.fast_mode_rainbow_phase, 0);
state.fast_mode_effective = false;
let disabled_at = start + Duration::from_secs(10);
assert!(!tick_fast_mode_rainbow(
&mut state,
&mut anchor,
disabled_at,
));
assert_eq!(state.fast_mode_rainbow_phase, 0);
assert_eq!(anchor, None);
state.fast_mode_effective = true;
let reenabled_at = start + Duration::from_secs(20);
assert!(!tick_fast_mode_rainbow(
&mut state,
&mut anchor,
reenabled_at,
));
assert_eq!(anchor, Some(reenabled_at));
assert!(tick_fast_mode_rainbow(
&mut state,
&mut anchor,
reenabled_at + FAST_MODE_RAINBOW_FRAME_INTERVAL,
));
assert_eq!(state.fast_mode_rainbow_phase, 1);
}
#[test]
fn disabled_fast_mode_rainbow_tick_cannot_request_redraw() {
let now = Instant::now();
let mut state = rainbow_state();
state.fast_mode_effective = false;
let mut anchor = Some(now);
let mut redraw = RedrawIntent::None;
if tick_fast_mode_rainbow(
&mut state,
&mut anchor,
now + FAST_MODE_RAINBOW_FRAME_INTERVAL,
) {
redraw.request_immediate();
}
assert_eq!(redraw, RedrawIntent::None);
assert_eq!(state.fast_mode_rainbow_phase, 0);
}
}