use slint::{Timer, TimerMode};
use std::{
cell::RefCell,
rc::{Rc, Weak},
time::Duration,
};
const DRAIN_INTERVAL: Duration = Duration::from_millis(16);
#[derive(Debug, Clone, PartialEq)]
pub(super) enum EditIntent {
NudgeAngle { targets: Vec<usize>, delta_deg: f64 },
CutOff { count: i32 },
RetargetCrown,
}
impl EditIntent {
fn merge(&mut self, incoming: &Self) -> bool {
match (self, incoming) {
(
Self::NudgeAngle { targets, delta_deg },
Self::NudgeAngle {
targets: other_targets,
delta_deg: other_delta,
},
) if targets == other_targets => {
*delta_deg += other_delta;
true
}
(Self::CutOff { count }, Self::CutOff { count: other_count }) => {
*count = *other_count;
true
}
(Self::RetargetCrown, Self::RetargetCrown) => true,
_ => false,
}
}
}
pub(super) struct EditIntentQueue {
pending: RefCell<Option<EditIntent>>,
on_drain: Box<dyn Fn(EditIntent)>,
timer: Timer,
self_weak: Weak<Self>,
}
impl EditIntentQueue {
pub(super) fn new(on_drain: impl Fn(EditIntent) + 'static) -> Rc<Self> {
Rc::new_cyclic(|weak_self| Self {
pending: RefCell::new(None),
on_drain: Box::new(on_drain),
timer: Timer::default(),
self_weak: weak_self.clone(),
})
}
pub(super) fn post(&self, intent: EditIntent) {
let was_empty = {
let mut pending = self.pending.borrow_mut();
let was_empty = pending.is_none();
let merged = pending
.as_mut()
.is_some_and(|existing| existing.merge(&intent));
if !merged {
*pending = Some(intent);
}
was_empty
};
if !was_empty {
return;
}
let queue = self.self_weak.clone();
self.timer
.start(TimerMode::Repeated, DRAIN_INTERVAL, move || {
let Some(queue) = queue.upgrade() else {
return;
};
let Some(intent) = queue.pending.borrow_mut().take() else {
queue.timer.stop();
return;
};
(queue.on_drain)(intent);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn coalesce(initial: EditIntent, posts: impl IntoIterator<Item = EditIntent>) -> EditIntent {
let mut pending = initial;
for intent in posts {
if !pending.merge(&intent) {
pending = intent;
}
}
pending
}
#[test]
fn fifty_nudges_on_the_same_tier_coalesce_to_one_summed_delta() {
let first = EditIntent::NudgeAngle {
targets: vec![3],
delta_deg: 0.5,
};
let rest = (0..49).map(|_| EditIntent::NudgeAngle {
targets: vec![3],
delta_deg: 0.5,
});
let result = coalesce(first, rest);
assert_eq!(
result,
EditIntent::NudgeAngle {
targets: vec![3],
delta_deg: 25.0
},
"50 nudges of 0.5 deg each must coalesce to one intent summing to 25.0 deg"
);
}
#[test]
fn a_nudge_burst_on_a_different_tier_replaces_rather_than_merges() {
let first = EditIntent::NudgeAngle {
targets: vec![1],
delta_deg: 1.0,
};
let result = coalesce(
first,
[EditIntent::NudgeAngle {
targets: vec![2],
delta_deg: 2.0,
}],
);
assert_eq!(
result,
EditIntent::NudgeAngle {
targets: vec![2],
delta_deg: 2.0
}
);
}
#[test]
fn a_cutoff_slider_burst_drains_to_the_last_value() {
let first = EditIntent::CutOff { count: 10 };
let result = coalesce(first, (11..=40).map(|count| EditIntent::CutOff { count }));
assert_eq!(result, EditIntent::CutOff { count: 40 });
}
#[test]
fn retarget_crown_posts_always_coalesce_to_one_marker() {
let result = coalesce(EditIntent::RetargetCrown, [EditIntent::RetargetCrown; 0]);
assert_eq!(result, EditIntent::RetargetCrown);
let result = coalesce(
EditIntent::RetargetCrown,
std::iter::repeat_n(EditIntent::RetargetCrown, 20),
);
assert_eq!(result, EditIntent::RetargetCrown);
}
}