Files
endorphin-tessera-gui/src/shell/tabs/motion.rs
T
2026-10-25 21:25:43 +08:00

208 lines
7.5 KiB
Rust

//! Interpolated chrome geometry is shared by painting and pointer hit testing.
use super::*;
use std::time::{Duration, Instant};
#[derive(Clone, Default)]
pub struct Visual {
pub rects: Vec<(u64, Rect)>,
pub exiting: Vec<(Tab, Rect)>,
pub viewport: Rect,
pub plus: Rect,
pub hovers: Vec<(u64, f32, f32)>,
pub plus_hover: f32,
}
impl Visual {
pub fn hover(&self, id: u64) -> f32 {
self.hovers
.iter()
.find(|entry| entry.0 == id)
.map_or(0.0, |entry| entry.1)
}
pub fn close_hover(&self, id: u64) -> f32 {
self.hovers
.iter()
.find(|entry| entry.0 == id)
.map_or(0.0, |entry| entry.2)
}
pub fn rect(&self, id: u64, fallback: Rect) -> Rect {
self.rects
.iter()
.find(|(key, _)| *key == id)
.map_or(fallback, |(_, rect)| *rect)
}
pub fn hit(&self, point: Point) -> Option<(u64, bool)> {
if !self.viewport.contains(point) {
return None;
}
self.rects
.iter()
.rev()
.find(|(_, rect)| pill_rect(*rect).contains(point))
.map(|(id, rect)| (*id, close_rect(*rect).contains(point)))
}
}
#[derive(Default)]
pub struct Motion {
hover: super::hover::Hover,
target: Visual,
from: Visual,
current: Visual,
published: Vec<Tab>,
initialized: bool,
direct_scroll: bool,
started: Option<Instant>,
pub offset: Option<f32>,
pub next_frame: Option<Instant>,
}
fn blend(a: Rect, b: Rect, t: f32) -> Rect {
Rect::new(
a.x + (b.x - a.x) * t,
a.y + (b.y - a.y) * t,
a.width + (b.width - a.width) * t,
a.height + (b.height - a.height) * t,
)
}
impl Motion {
pub fn sample_hover(&mut self, visual: &mut Visual, tabs: &[Tab], pointer: Option<Point>) {
let now = Instant::now();
if self.hover.sample(visual, tabs, pointer, now) {
let next = now + Duration::from_millis(16);
self.next_frame = Some(self.next_frame.map_or(next, |old| old.min(next)));
}
}
pub fn scroll_to(&mut self, offset: f32) {
self.offset = Some(offset);
self.direct_scroll = true;
}
fn advance(&mut self, now: Instant) {
let t = self.started.map_or(1.0, |start| {
(now.duration_since(start).as_secs_f32() / 0.22).min(1.0)
});
let eased = 1.0 - (1.0 - t).powi(3);
self.current = Visual {
rects: self
.target
.rects
.iter()
.map(|(id, target)| (*id, blend(self.from.rect(*id, *target), *target, eased)))
.collect(),
exiting: self
.target
.exiting
.iter()
.filter(|_| t < 1.0)
.map(|(tab, target)| {
let from = self
.from
.exiting
.iter()
.find(|(old, _)| old.id == tab.id)
.map_or(*target, |(_, rect)| *rect);
(tab.clone(), blend(from, *target, eased))
})
.collect(),
viewport: blend(self.from.viewport, self.target.viewport, eased),
plus: blend(self.from.plus, self.target.plus, eased),
..Visual::default()
};
self.next_frame = (t < 1.0).then(|| now + Duration::from_millis(16));
}
pub fn sample(&mut self, tabs: &[Tab], offset: Option<f32>, width: f32) -> Visual {
let now = Instant::now();
// Retarget from the geometry at this instant, even during rapid repeated clicks.
self.advance(now);
let mut offset = scroll_offset(tabs, width, offset.or(self.offset));
let selection_changed = self
.published
.iter()
.find(|tab| tab.active)
.map(|tab| tab.id)
!= tabs.iter().find(|tab| tab.active).map(|tab| tab.id);
if selection_changed {
let active = tabs.iter().position(|tab| tab.active).unwrap_or(0) as f32;
let tab_width = tab_width(tabs, width);
let left = active * tab_width;
let right = left + tab_width;
offset = offset.min(left).max(right - viewport(width).width).max(0.0);
}
self.offset = Some(offset);
let mut target = Visual {
rects: tab_rects(Some(offset), tabs, width),
viewport: viewport(width),
plus: plus_rect(Some(offset), tabs, width),
..Visual::default()
};
if !self.initialized || self.direct_scroll {
self.direct_scroll = false;
self.started = None;
self.next_frame = None;
// Startup is already laid out; it is not an insertion of every restored tab.
self.initialized = true;
self.current = target.clone();
self.from = target.clone();
self.target = target;
self.published = tabs.to_vec();
return self.current.clone();
}
if self.target.rects != target.rects
|| self.target.plus != target.plus
|| self.target.viewport != target.viewport
{
self.from = self.current.clone();
for (index, (id, rect)) in target.rects.iter().enumerate() {
if !self.from.rects.iter().any(|(old, _)| old == id) {
// Grow out of the previous trailing edge, not the final compressed x.
let x = target.rects[..index]
.iter()
.rev()
.find_map(|(previous, _)| {
self.from
.rects
.iter()
.find(|(old, _)| old == previous)
.map(|(_, previous)| previous.x + previous.width)
})
.unwrap_or(rect.x);
self.from
.rects
.push((*id, Rect::new(x, rect.y, 0.0, rect.height)));
}
}
for tab in &self.published {
if !tabs.iter().any(|live| live.id == tab.id) {
self.from
.exiting
.push((tab.clone(), self.current.rect(tab.id, Rect::default())));
}
}
// A closed tab shrinks with the gap; it never remains an interactive target.
target.exiting = self
.from
.exiting
.iter()
.filter(|(tab, _)| !tabs.iter().any(|live| live.id == tab.id))
.map(|(tab, rect)| {
let next = self
.published
.iter()
.position(|old| old.id == tab.id)
.and_then(|index| {
self.published[index + 1..]
.iter()
.find(|old| tabs.iter().any(|live| live.id == old.id))
});
let x = next.map_or(target.plus.x - 4.0, |next| target.rect(next.id, *rect).x);
(tab.clone(), Rect::new(x, rect.y, 0.0, rect.height))
})
.collect();
self.target = target;
self.started = Some(now);
}
self.published = tabs.to_vec();
self.advance(now);
self.current.clone()
}
}