use std::cell::RefCell;
use std::fmt;
use std::rc::{Rc, Weak};
use frust_core::Curve;
use crate::ChildKey;
use crate::scroll::ScrollInfo;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum ScrollCommand {
JumpTo(f64),
AnimateTo(f64, AnimateTo),
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum ControllerCommand {
Offset(ScrollCommand),
ScrollToItem(ScrollToItem),
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ScrollToItem {
pub(crate) key: ChildKey,
pub(crate) alignment: ItemAlignment,
pub(crate) animated: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ItemAlignment {
#[default]
Start,
Center,
End,
Nearest,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnimateTo {
pub duration_ms: f64,
pub curve: Curve,
}
const ITEM_COMMAND_CAP: usize = 8;
type Listener = Rc<dyn Fn(ScrollInfo)>;
struct Shared {
info: ScrollInfo,
viewport_extent: f64,
attached: Option<u64>,
next_token: u64,
commands: Vec<ControllerCommand>,
listeners: Vec<(u64, Listener)>,
next_listener: u64,
animating: bool,
}
impl Default for Shared {
fn default() -> Self {
Self {
info: ScrollInfo {
offset: 0.0,
max_offset: 0.0,
overscroll: 0.0,
},
viewport_extent: 0.0,
attached: None,
next_token: 0,
commands: Vec::new(),
listeners: Vec::new(),
next_listener: 0,
animating: false,
}
}
}
#[derive(Clone, Default)]
pub struct ScrollController {
shared: Rc<RefCell<Shared>>,
}
impl fmt::Debug for ScrollController {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let shared = self.shared.borrow();
f.debug_struct("ScrollController")
.field("info", &shared.info)
.field("viewport_extent", &shared.viewport_extent)
.field("attached", &shared.attached.is_some())
.field("pending_commands", &shared.commands.len())
.field("listeners", &shared.listeners.len())
.field("animating", &shared.animating)
.finish()
}
}
fn supersede_segment(commands: &mut Vec<ControllerCommand>, start: usize) {
let end = commands[start..]
.iter()
.position(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
.map_or(commands.len(), |index| start + index);
let mut jump = None;
let mut animate = None;
for command in commands.drain(start..end) {
match command {
ControllerCommand::Offset(ScrollCommand::JumpTo(_)) => {
jump = Some(command);
animate = None;
}
ControllerCommand::Offset(ScrollCommand::AnimateTo(..)) => animate = Some(command),
ControllerCommand::ScrollToItem(_) => {
unreachable!("the segment ends before the next item command")
}
}
}
for (offset, command) in [jump, animate].into_iter().flatten().enumerate() {
commands.insert(start + offset, command);
}
}
impl ScrollController {
pub fn new() -> Self {
Self::default()
}
pub fn jump_to(&self, offset: f64) {
self.record(ControllerCommand::Offset(ScrollCommand::JumpTo(offset)));
}
pub fn animate_to(&self, offset: f64, options: AnimateTo) {
self.record(ControllerCommand::Offset(ScrollCommand::AnimateTo(
offset, options,
)));
}
pub fn scroll_to_item(
&self,
key: impl Into<ChildKey>,
alignment: ItemAlignment,
animated: bool,
) {
self.record(ControllerCommand::ScrollToItem(ScrollToItem {
key: key.into(),
alignment,
animated,
}));
}
pub fn is_animating(&self) -> bool {
self.shared.borrow().animating
}
pub fn offset(&self) -> f64 {
self.shared.borrow().info.offset
}
pub fn max_offset(&self) -> f64 {
self.shared.borrow().info.max_offset
}
pub fn viewport_extent(&self) -> f64 {
self.shared.borrow().viewport_extent
}
pub fn is_attached(&self) -> bool {
self.shared.borrow().attached.is_some()
}
pub fn on_change<F: Fn(ScrollInfo) + 'static>(&self, listener: F) -> ScrollSubscription {
let mut shared = self.shared.borrow_mut();
let id = shared.next_listener;
shared.next_listener += 1;
shared.listeners.push((id, Rc::new(listener)));
ScrollSubscription {
shared: Rc::downgrade(&self.shared),
id,
}
}
fn record(&self, command: ControllerCommand) {
let mut shared = self.shared.borrow_mut();
match command {
ControllerCommand::Offset(offset) => {
let segment_start = shared
.commands
.iter()
.rposition(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
.map_or(0, |index| index + 1);
shared.commands.push(ControllerCommand::Offset(offset));
supersede_segment(&mut shared.commands, segment_start);
}
ControllerCommand::ScrollToItem(item) => {
shared.commands.push(ControllerCommand::ScrollToItem(item));
let item_count = shared
.commands
.iter()
.filter(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
.count();
if item_count > ITEM_COMMAND_CAP {
let oldest = shared
.commands
.iter()
.position(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
.expect("item_count > 0 implies at least one ScrollToItem entry");
if let ControllerCommand::ScrollToItem(dropped) = shared.commands.remove(oldest)
{
drop_oldest_item_command(&dropped);
}
supersede_segment(&mut shared.commands, 0);
}
}
}
drop(shared);
frust_core::mark_pending_result_flush();
}
pub(crate) fn same(&self, other: &ScrollController) -> bool {
Rc::ptr_eq(&self.shared, &other.shared)
}
pub(crate) fn bind(&self) -> ScrollBinding {
let mut shared = self.shared.borrow_mut();
let token = shared.next_token;
shared.next_token += 1;
shared.attached = Some(token);
shared.animating = false;
ScrollBinding {
controller: self.clone(),
token,
}
}
}
#[must_use = "dropping a ScrollSubscription unsubscribes its listener at once"]
pub struct ScrollSubscription {
shared: Weak<RefCell<Shared>>,
id: u64,
}
impl fmt::Debug for ScrollSubscription {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ScrollSubscription")
.field("id", &self.id)
.finish()
}
}
impl Drop for ScrollSubscription {
fn drop(&mut self) {
if let Some(shared) = self.shared.upgrade() {
shared
.borrow_mut()
.listeners
.retain(|(id, _)| *id != self.id);
}
}
}
pub(crate) struct ScrollBinding {
controller: ScrollController,
token: u64,
}
impl ScrollBinding {
pub(crate) fn controller(&self) -> &ScrollController {
&self.controller
}
pub(crate) fn is_current(&self) -> bool {
self.controller.shared.borrow().attached == Some(self.token)
}
pub(crate) fn has_pending(&self) -> bool {
let shared = self.controller.shared.borrow();
shared.attached == Some(self.token) && !shared.commands.is_empty()
}
pub(crate) fn take_commands(&self) -> Vec<ScrollCommand> {
self.take_all_commands()
.into_iter()
.filter_map(|command| match command {
ControllerCommand::Offset(command) => Some(command),
ControllerCommand::ScrollToItem(item) => {
ignore_item_command(&item);
None
}
})
.collect()
}
pub(crate) fn take_all_commands(&self) -> Vec<ControllerCommand> {
let mut shared = self.controller.shared.borrow_mut();
if shared.attached != Some(self.token) {
return Vec::new();
}
std::mem::take(&mut shared.commands)
}
pub(crate) fn publish(&self, info: ScrollInfo, viewport_extent: f64) {
let listeners: Vec<Listener> = {
let mut shared = self.controller.shared.borrow_mut();
if shared.attached != Some(self.token) {
return;
}
shared.viewport_extent = viewport_extent;
if shared.info == info {
return;
}
shared.info = info;
shared
.listeners
.iter()
.map(|(_, listener)| Rc::clone(listener))
.collect()
};
for listener in listeners {
listener(info);
}
}
pub(crate) fn set_animating(&self, animating: bool) {
let mut shared = self.controller.shared.borrow_mut();
if shared.attached != Some(self.token) {
return;
}
shared.animating = animating;
}
}
fn ignore_item_command(_item: &ScrollToItem) {
#[cfg(debug_assertions)]
eprintln!(
"frust-widgets: ScrollController::scroll_to_item({:?}, {:?}) ignored: the attached \
surface is a ScrollView, which has no keyed rows (attach the handle to a keyed \
ListView instead)",
_item.key, _item.alignment
);
}
fn drop_oldest_item_command(_item: &ScrollToItem) {
#[cfg(debug_assertions)]
eprintln!(
"frust-widgets: ScrollController::scroll_to_item({:?}, {:?}) dropped: more than \
{ITEM_COMMAND_CAP} item commands were queued with nothing draining them",
_item.key, _item.alignment
);
}
impl Drop for ScrollBinding {
fn drop(&mut self) {
let mut shared = self.controller.shared.borrow_mut();
if shared.attached == Some(self.token) {
shared.attached = None;
shared.animating = false;
}
}
}
#[cfg(test)]
mod tests {
use frust_core::{BoxConstraints, BuildCtx, LayoutCtx, View, Widget};
use kurbo::Size;
use super::*;
use crate::test_support::leaf;
use crate::{ScrollView, scroll_view};
#[test]
fn the_scroll_view_drain_drops_item_commands_and_keeps_offset_order() {
let controller = ScrollController::new();
let binding = controller.bind();
controller.jump_to(10.0);
controller.scroll_to_item(ChildKey::new(7u64), ItemAlignment::Center, true);
controller.jump_to(20.0);
assert!(binding.has_pending());
assert_eq!(
binding.take_commands(),
vec![ScrollCommand::JumpTo(10.0), ScrollCommand::JumpTo(20.0)],
"the item command is filtered out, the offset ones keep their order"
);
assert!(
!binding.has_pending(),
"the item command was drained, not left queued"
);
}
#[test]
fn the_list_drain_keeps_every_command_in_recording_order() {
let controller = ScrollController::new();
let binding = controller.bind();
controller.scroll_to_item("row", ItemAlignment::Nearest, false);
controller.jump_to(5.0);
assert_eq!(
binding.take_all_commands(),
vec![
ControllerCommand::ScrollToItem(ScrollToItem {
key: ChildKey::new("row"),
alignment: ItemAlignment::Nearest,
animated: false,
}),
ControllerCommand::Offset(ScrollCommand::JumpTo(5.0)),
]
);
}
#[test]
fn a_scroll_view_ignores_scroll_to_item() {
let controller = ScrollController::new();
let view: ScrollView<()> = scroll_view(leaf(200.0, 1000.0)).controller(controller.clone());
let mut counter = 0u64;
let mut widget = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
let viewport = BoxConstraints::loose(Size::new(200.0, 100.0));
controller.scroll_to_item(ChildKey::new(3u64), ItemAlignment::Start, false);
widget.layout(&mut LayoutCtx::new(), &viewport);
assert_eq!(
controller.offset(),
0.0,
"an item command moves a ScrollView nowhere"
);
assert_eq!(
controller.max_offset(),
900.0,
"but the surface still publishes"
);
controller.scroll_to_item(ChildKey::new(3u64), ItemAlignment::Start, false);
controller.jump_to(250.0);
widget.layout(&mut LayoutCtx::new(), &viewport);
assert_eq!(
widget.offset(),
250.0,
"the offset command after it still applies"
);
assert_eq!(controller.offset(), 250.0);
}
#[test]
fn a_burst_of_jump_to_calls_coalesces_to_the_last_value() {
let controller = ScrollController::new();
let binding = controller.bind();
for offset in 0..50 {
controller.jump_to(offset as f64);
}
assert_eq!(
binding.take_all_commands(),
vec![ControllerCommand::Offset(ScrollCommand::JumpTo(49.0))],
"a run of same-kind offset commands leaves only the last one queued"
);
}
#[test]
fn jump_to_then_animate_to_keeps_both_in_either_order() {
let options = AnimateTo {
duration_ms: 200.0,
curve: Curve::Linear,
};
let controller = ScrollController::new();
let binding = controller.bind();
controller.jump_to(10.0);
controller.animate_to(20.0, options);
assert_eq!(
binding.take_all_commands(),
vec![
ControllerCommand::Offset(ScrollCommand::JumpTo(10.0)),
ControllerCommand::Offset(ScrollCommand::AnimateTo(20.0, options)),
],
"a jump followed by an animate keeps both, so the tween starts from the jumped position"
);
let controller = ScrollController::new();
let binding = controller.bind();
controller.animate_to(20.0, options);
controller.jump_to(10.0);
assert_eq!(
binding.take_all_commands(),
vec![ControllerCommand::Offset(ScrollCommand::JumpTo(10.0))],
"a jump supersedes an earlier animate — it would have cancelled the tween anyway"
);
}
#[test]
fn alternating_offset_kinds_stay_bounded_to_one_jump_and_one_animate() {
let options = AnimateTo {
duration_ms: 200.0,
curve: Curve::Linear,
};
let controller = ScrollController::new();
let binding = controller.bind();
for round in 0..50 {
controller.jump_to(round as f64);
controller.animate_to(round as f64 + 0.5, options);
}
assert_eq!(
binding.take_all_commands(),
vec![
ControllerCommand::Offset(ScrollCommand::JumpTo(49.0)),
ControllerCommand::Offset(ScrollCommand::AnimateTo(49.5, options)),
],
"whatever order kinds alternate in, a segment keeps one jump and one animate"
);
let controller = ScrollController::new();
let binding = controller.bind();
for round in 0..50 {
controller.jump_to(round as f64);
controller.scroll_to_item(ChildKey::new(round as u64), ItemAlignment::Start, false);
}
let queued = binding.take_all_commands();
assert!(
queued.len() <= 2 * (ITEM_COMMAND_CAP + 1) + ITEM_COMMAND_CAP,
"the whole queue stays under the item cap plus two offsets per segment: {}",
queued.len()
);
assert_eq!(
queued
.iter()
.filter(|command| matches!(command, ControllerCommand::ScrollToItem(_)))
.count(),
ITEM_COMMAND_CAP
);
}
#[test]
fn scroll_to_item_between_two_jump_to_calls_keeps_all_three_in_order() {
let controller = ScrollController::new();
let binding = controller.bind();
controller.jump_to(10.0);
controller.scroll_to_item(ChildKey::new(7u64), ItemAlignment::Center, false);
controller.jump_to(20.0);
assert_eq!(
binding.take_all_commands(),
vec![
ControllerCommand::Offset(ScrollCommand::JumpTo(10.0)),
ControllerCommand::ScrollToItem(ScrollToItem {
key: ChildKey::new(7u64),
alignment: ItemAlignment::Center,
animated: false,
}),
ControllerCommand::Offset(ScrollCommand::JumpTo(20.0)),
],
"an item command in between stops the surrounding jumps from coalescing"
);
}
#[test]
fn the_item_command_cap_drops_the_oldest_and_keeps_the_newest() {
let controller = ScrollController::new();
let binding = controller.bind();
for key in 0..(ITEM_COMMAND_CAP as u64 + 3) {
controller.scroll_to_item(ChildKey::new(key), ItemAlignment::Start, false);
}
let drained = binding.take_all_commands();
assert_eq!(
drained.len(),
ITEM_COMMAND_CAP,
"queuing past the cap drops the oldest rather than growing further"
);
let expected: Vec<ControllerCommand> = (3..(ITEM_COMMAND_CAP as u64 + 3))
.map(|key| {
ControllerCommand::ScrollToItem(ScrollToItem {
key: ChildKey::new(key),
alignment: ItemAlignment::Start,
animated: false,
})
})
.collect();
assert_eq!(
drained, expected,
"the surviving entries are the newest ones, oldest-dropped-first, in recording order"
);
}
#[test]
fn bind_resets_animating_and_drop_clears_it_only_for_the_current_binding() {
let controller = ScrollController::new();
let first = controller.bind();
first.set_animating(true);
assert!(controller.is_animating(), "the current binding set it");
let second = controller.bind();
assert!(
!controller.is_animating(),
"bind() resets animating for the newly attached binding"
);
second.set_animating(true);
assert!(controller.is_animating());
drop(first);
assert!(
controller.is_animating(),
"dropping a non-current binding leaves the current holder's flag alone"
);
assert!(
controller.is_attached(),
"dropping a non-current binding does not detach the handle"
);
drop(second);
assert!(
!controller.is_animating(),
"dropping the current binding clears animating"
);
assert!(!controller.is_attached());
}
}