use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use web_time::Instant;
use crate::animation::{Easing, Transition};
use crate::callback::Callback;
use crate::core::element::Element;
use crate::core::node::{NodeId, WidgetNode};
use crate::style::{Length, Padding, Style};
use crate::widgets::{Toast, ToastCopyAffordance};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum OverlayScope {
#[default]
RootPortal,
Local,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct OverlayId(u64);
impl OverlayId {
pub(crate) fn value(self) -> u64 {
self.0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum OverlayLayer {
Modal = 0,
Popover = 1,
Toast = 2,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub(crate) enum PointerCapture {
#[default]
None,
RectOnly,
BackdropFullScreen,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ToastPlacement {
TopStart,
TopCenter,
TopEnd,
BottomStart,
BottomCenter,
#[default]
BottomEnd,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum DismissPolicy {
None,
#[default]
ClickOutside,
ClickInside,
ClickOutsideOrEscape,
}
impl DismissPolicy {
pub(crate) fn dismiss_on_click_inside(self) -> bool {
matches!(self, Self::ClickInside)
}
pub(crate) fn dismiss_on_click_outside(self) -> bool {
matches!(self, Self::ClickOutside | Self::ClickOutsideOrEscape)
}
pub(crate) fn dismiss_on_escape(self) -> bool {
matches!(self, Self::ClickOutsideOrEscape)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum OverlayPlacement {
Center {
reserve_height: Option<Length>,
},
Stacked {
placement: ToastPlacement,
gap: u16,
margin: Padding,
},
}
#[derive(Clone)]
pub(crate) struct Portal {
pub(crate) layer: OverlayLayer,
pub(crate) content: Box<Element>,
pub(crate) placement: OverlayPlacement,
pub(crate) dismiss_policy: DismissPolicy,
pub(crate) on_close: Option<Callback<()>>,
pub(crate) backdrop: Option<Style>,
pub(crate) captures_focus: bool,
pub(crate) auto_focus: bool,
pub(crate) captures_pointer: PointerCapture,
}
#[derive(Clone)]
pub(crate) struct PortalNode {
pub(crate) content: Box<NodeId>,
}
impl WidgetNode for PortalNode {}
impl crate::layout::hash::LayoutHash for Portal {
fn layout_hash(
&self,
hasher: &mut impl std::hash::Hasher,
recurse: &dyn Fn(&Element) -> Option<u64>,
) -> Option<()> {
use std::hash::Hash;
self.layer.hash(hasher);
self.captures_focus.hash(hasher);
self.auto_focus.hash(hasher);
self.captures_pointer.hash(hasher);
recurse(self.content.as_ref())?.hash(hasher);
Some(())
}
}
#[derive(Clone)]
pub(crate) struct OverlayEntry {
pub(crate) id: OverlayId,
pub(crate) order: u64,
pub(crate) layer: OverlayLayer,
pub(crate) content: Element,
pub(crate) placement: OverlayPlacement,
pub(crate) dismiss_policy: DismissPolicy,
pub(crate) on_dismiss: Option<Callback<()>>,
pub(crate) created_at: Instant,
pub(crate) timeout: Option<Duration>,
pub(crate) captures_focus: bool,
pub(crate) auto_focus: bool,
pub(crate) backdrop: Option<Style>,
pub(crate) captures_pointer: PointerCapture,
pub(crate) opacity_transition: Option<Transition<f32>>,
transition_tick_at: Option<Instant>,
pub(crate) pending_dismiss: bool,
pub(crate) copy_text: Option<Arc<str>>,
pub(crate) copy_zone_right_padding: Option<u16>,
pub(crate) copy_feedback_until: Option<Instant>,
hover_remaining: Option<Duration>,
}
impl OverlayEntry {
pub(crate) fn opacity(&self) -> f32 {
self.opacity_transition
.as_ref()
.map(Transition::current)
.unwrap_or(if self.pending_dismiss { 0.0 } else { 1.0 })
.clamp(0.0, 1.0)
}
pub(crate) fn copy_feedback_active(&self) -> bool {
self.copy_feedback_until
.is_some_and(|deadline| Instant::now() < deadline)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct TickResult {
pub(crate) dirty: bool,
pub(crate) has_active_transitions: bool,
}
pub(crate) struct OverlayManager {
entries: Vec<OverlayEntry>,
next_id: u64,
generation: u64,
inline_mode: bool,
toast_placement: ToastPlacement,
toast_gap: u16,
toast_margin: Padding,
}
impl OverlayManager {
pub(crate) fn new() -> Self {
Self {
entries: Vec::new(),
next_id: 0,
generation: 0,
inline_mode: false,
toast_placement: ToastPlacement::BottomEnd,
toast_gap: 1,
toast_margin: Padding::BORDER,
}
}
fn enter_transition() -> Transition<f32> {
Transition::new(0.0, 1.0, Duration::from_millis(150), Easing::EaseOutQuad)
}
fn enter_transition_from(from: f32) -> Transition<f32> {
Transition::new(
from.clamp(0.0, 1.0),
1.0,
Duration::from_millis(150),
Easing::EaseOutQuad,
)
}
fn exit_transition(from: f32) -> Transition<f32> {
Transition::new(
from.clamp(0.0, 1.0),
0.0,
Duration::from_millis(100),
Easing::EaseInQuad,
)
}
fn begin_dismiss(entry: &mut OverlayEntry, now: Instant) -> bool {
if entry.pending_dismiss {
return false;
}
let opacity = entry.opacity();
entry.pending_dismiss = true;
entry.opacity_transition = Some(Self::exit_transition(opacity));
entry.transition_tick_at = Some(now);
true
}
pub(crate) fn generation(&self) -> u64 {
self.generation
}
fn bump_generation(&mut self) {
self.generation = self.generation.wrapping_add(1);
}
fn allocate_id(&mut self) -> OverlayId {
let id = OverlayId(self.next_id);
self.next_id = self.next_id.wrapping_add(1);
id
}
pub(crate) fn set_inline_mode(&mut self, inline_mode: bool) {
self.inline_mode = inline_mode;
if inline_mode && !self.entries.is_empty() {
self.entries.clear();
self.bump_generation();
}
}
pub(crate) fn entries(&self) -> &[OverlayEntry] {
&self.entries
}
pub(crate) fn has_active_transitions(&self) -> bool {
self.entries
.iter()
.any(|entry| entry.opacity_transition.is_some())
}
pub(crate) fn push(&mut self, mut entry: OverlayEntry) -> OverlayId {
let id = self.allocate_id();
if self.inline_mode {
return id;
}
entry.id = id;
entry.order = id.value();
entry.created_at = Instant::now();
entry.pending_dismiss = false;
entry.opacity_transition = Some(Self::enter_transition());
entry.transition_tick_at = Some(entry.created_at);
self.entries.push(entry);
self.bump_generation();
id
}
pub(crate) fn dismiss(&mut self, id: OverlayId) -> bool {
if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) {
let changed = Self::begin_dismiss(entry, Instant::now());
if changed {
self.bump_generation();
}
return true;
}
false
}
pub(crate) fn dismiss_immediately(&mut self, id: OverlayId) -> bool {
let Some(index) = self.entries.iter().position(|entry| entry.id == id) else {
return false;
};
let mut entry = self.entries.remove(index);
if let Some(callback) = entry.on_dismiss.take() {
callback.emit(());
}
self.bump_generation();
true
}
pub(crate) fn renew(&mut self, id: OverlayId) -> bool {
let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) else {
return false;
};
if entry.pending_dismiss {
return false;
}
entry.created_at = Instant::now();
if entry.hover_remaining.is_some() {
entry.hover_remaining = entry.timeout;
}
true
}
pub(crate) fn set_hovered_toast(&mut self, hovered: Option<OverlayId>) -> bool {
let now = Instant::now();
let mut dirty = false;
for entry in &mut self.entries {
if entry.layer != OverlayLayer::Toast {
continue;
}
let should_hover = Some(entry.id) == hovered;
if should_hover && entry.hover_remaining.is_none() {
let remaining = if entry.pending_dismiss {
entry
.timeout
.map(|timeout| timeout.min(Duration::from_secs(1)))
} else {
entry
.timeout
.map(|timeout| timeout.saturating_sub(now.duration_since(entry.created_at)))
};
entry.hover_remaining = remaining;
if entry.pending_dismiss {
let opacity = entry.opacity();
entry.pending_dismiss = false;
entry.opacity_transition = Some(Self::enter_transition_from(opacity));
entry.transition_tick_at = Some(now);
dirty = true;
}
} else if !should_hover
&& let Some(remaining) = entry.hover_remaining.take()
&& let Some(timeout) = entry.timeout
{
entry.created_at = now
.checked_sub(timeout.saturating_sub(remaining))
.unwrap_or(now);
}
}
if dirty {
self.bump_generation();
}
dirty
}
pub(crate) fn tick_at(&mut self, now: Instant) -> TickResult {
let mut result = TickResult::default();
self.entries.retain_mut(|entry| {
if !entry.pending_dismiss && entry.hover_remaining.is_none() {
let expired = entry
.timeout
.map(|timeout| now.duration_since(entry.created_at) >= timeout)
.unwrap_or(false);
if expired {
result.dirty |= Self::begin_dismiss(entry, now);
}
}
if let Some(deadline) = entry.copy_feedback_until {
if now >= deadline {
entry.copy_feedback_until = None;
result.dirty = true;
} else {
result.has_active_transitions = true;
}
}
if let Some(transition) = entry.opacity_transition.as_mut() {
let delta = entry
.transition_tick_at
.replace(now)
.map_or(Duration::ZERO, |last| now.saturating_duration_since(last));
let before = transition.current();
let complete = transition.tick(delta);
let after = transition.current();
if (after - before).abs() > f32::EPSILON {
result.dirty = true;
}
if complete {
if entry.pending_dismiss {
if let Some(cb) = entry.on_dismiss.take() {
cb.emit(());
}
result.dirty = true;
return false;
}
entry.opacity_transition = None;
entry.transition_tick_at = None;
} else {
result.has_active_transitions = true;
}
}
true
});
if self.has_active_transitions() {
result.has_active_transitions = true;
}
if result.dirty {
self.bump_generation();
}
result
}
pub(crate) fn trigger_copy_feedback(&mut self, id: OverlayId, duration: Duration) -> bool {
if duration.is_zero() {
return false;
}
let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) else {
return false;
};
if entry.pending_dismiss || entry.copy_text.is_none() {
return false;
}
entry.copy_feedback_until = Some(Instant::now() + duration);
self.bump_generation();
true
}
pub(crate) fn set_toast_placement(&mut self, placement: ToastPlacement) {
self.toast_placement = placement;
}
pub(crate) fn set_toast_gap(&mut self, gap: u16) {
self.toast_gap = gap;
}
pub(crate) fn set_toast_margin(&mut self, margin: Padding) {
self.toast_margin = margin;
}
pub(crate) fn toast_config(&self) -> (ToastPlacement, u16, Padding) {
(self.toast_placement, self.toast_gap, self.toast_margin)
}
pub(crate) fn dismiss_toasts(&mut self) {
let mut changed = false;
let now = Instant::now();
for entry in &mut self.entries {
if entry.layer == OverlayLayer::Toast {
changed |= Self::begin_dismiss(entry, now);
}
}
if changed {
self.bump_generation();
}
}
pub(crate) fn push_toast(&mut self, toast: Toast) -> OverlayId {
if self.inline_mode {
crate::debug::internal_log!("[tui-lipan] inline mode: toast suppressed");
return self.allocate_id();
}
let (placement, gap, margin) = self.toast_config();
let duration = toast.duration;
let copy_text = toast.copyable.then(|| toast.message.clone());
let copy_zone_right_padding = if toast.copyable
&& toast.border
&& matches!(toast.copy_affordance, ToastCopyAffordance::BorderGlyph)
{
Some(toast.header_padding.right)
} else {
None
};
let dismiss_policy = if toast.dismiss_on_click {
DismissPolicy::ClickInside
} else {
DismissPolicy::None
};
let content = toast.into_element();
let entry = OverlayEntry {
id: OverlayId(0),
order: 0,
layer: OverlayLayer::Toast,
content,
placement: OverlayPlacement::Stacked {
placement,
gap,
margin,
},
dismiss_policy,
on_dismiss: None,
created_at: Instant::now(),
timeout: Some(Duration::from_secs_f64(duration)),
captures_focus: false,
auto_focus: false,
backdrop: None,
captures_pointer: PointerCapture::None,
opacity_transition: None,
transition_tick_at: None,
pending_dismiss: false,
copy_text,
copy_zone_right_padding,
copy_feedback_until: None,
hover_remaining: None,
};
self.push(entry)
}
}
pub(crate) fn hovered_toast(
tree: &crate::core::node::NodeTree,
x: u16,
y: u16,
) -> Option<OverlayId> {
tree.overlay_roots().iter().rev().find_map(|root| {
(root.layer == OverlayLayer::Toast
&& tree.is_valid(root.id)
&& tree.node(root.id).rect.contains(x as i16, y as i16))
.then_some(root.overlay_id)
.flatten()
})
}
#[derive(Clone)]
pub struct ToastHandle {
manager: Rc<RefCell<OverlayManager>>,
}
impl ToastHandle {
pub(crate) fn new(manager: Rc<RefCell<OverlayManager>>) -> Self {
Self { manager }
}
pub fn push(&self, toast: Toast) -> OverlayId {
self.manager.borrow_mut().push_toast(toast)
}
pub fn dismiss(&self, id: OverlayId) {
let _ = self.manager.borrow_mut().dismiss(id);
}
pub fn dismiss_immediately(&self, id: OverlayId) {
let _ = self.manager.borrow_mut().dismiss_immediately(id);
}
pub fn renew(&self, id: OverlayId) -> bool {
self.manager.borrow_mut().renew(id)
}
pub fn clear(&self) {
self.manager.borrow_mut().dismiss_toasts();
}
}
#[cfg(test)]
mod tests {
use std::thread;
use super::*;
#[test]
fn toast_copy_feedback_marks_entry_active() {
let mut manager = OverlayManager::new();
let id = manager.push_toast(Toast::new("copy me").copyable(true));
assert!(manager.trigger_copy_feedback(id, Duration::from_millis(150)));
assert!(manager.entries[0].copy_feedback_active());
}
#[test]
fn toast_copy_feedback_tick_clears_expired_flash() {
let mut manager = OverlayManager::new();
let id = manager.push_toast(Toast::new("copy me").copyable(true));
assert!(manager.trigger_copy_feedback(id, Duration::from_millis(1)));
thread::sleep(Duration::from_millis(5));
let tick = manager.tick_at(Instant::now());
assert!(tick.dirty);
assert!(!manager.entries[0].copy_feedback_active());
assert!(manager.entries[0].copy_feedback_until.is_none());
}
#[test]
fn renew_extends_the_countdown_without_disturbing_the_render() {
let mut manager = OverlayManager::new();
let id = manager.push_toast(Toast::new("message").duration(10.0));
settle_entry_transition(&mut manager.entries[0]);
let order = manager.entries[0].order;
let generation = manager.generation();
let created_at = manager.entries[0].created_at;
thread::sleep(Duration::from_millis(5));
assert!(manager.renew(id));
assert!(manager.entries[0].created_at > created_at);
assert_eq!(manager.entries[0].order, order);
assert_eq!(manager.generation(), generation);
assert!(manager.entries[0].opacity_transition.is_none());
}
#[test]
fn renew_keeps_a_toast_alive_past_its_original_timeout() {
let mut manager = OverlayManager::new();
let id = manager.push_toast(Toast::new("held").duration(0.01));
thread::sleep(Duration::from_millis(5));
assert!(manager.renew(id));
thread::sleep(Duration::from_millis(7));
manager.tick_at(Instant::now());
assert!(
!manager.entries[0].pending_dismiss,
"the renewed deadline has not passed yet"
);
}
#[test]
fn renew_reports_failure_for_a_toast_that_cannot_be_extended() {
let mut manager = OverlayManager::new();
let missing = manager.push_toast(Toast::new("gone"));
assert!(manager.dismiss_immediately(missing));
assert!(
!manager.renew(missing),
"an expired toast cannot be renewed"
);
let fading = manager.push_toast(Toast::new("fading"));
assert!(manager.dismiss(fading));
assert!(
!manager.renew(fading),
"a toast mid-exit cannot be pulled back"
);
}
#[test]
fn renew_does_not_reorder_a_toast_among_its_neighbors() {
let mut manager = OverlayManager::new();
let first = manager.push_toast(Toast::new("first"));
let second = manager.push_toast(Toast::new("second"));
assert!(manager.renew(first));
let ids: Vec<_> = manager.entries.iter().map(|entry| entry.id).collect();
assert_eq!(ids, vec![first, second]);
}
#[test]
fn hovering_pauses_and_then_resumes_the_remaining_timeout() {
let mut manager = OverlayManager::new();
let id = manager.push_toast(Toast::new("held").duration(3.0));
settle_entry_transition(&mut manager.entries[0]);
age_entry_timeout(&mut manager.entries[0], Duration::from_millis(2_500));
assert!(!manager.set_hovered_toast(Some(id)));
let remaining = manager.entries[0]
.hover_remaining
.expect("hover should capture the remaining timeout");
assert!(remaining <= Duration::from_millis(500));
age_entry_timeout(&mut manager.entries[0], Duration::from_secs(10));
manager.tick_at(Instant::now());
assert!(!manager.entries[0].pending_dismiss);
assert!(!manager.set_hovered_toast(None));
age_entry_timeout(
&mut manager.entries[0],
remaining + Duration::from_millis(1),
);
manager.tick_at(Instant::now());
assert!(manager.entries[0].pending_dismiss);
}
#[test]
fn hovering_during_exit_revives_toast_with_post_hover_grace() {
let mut manager = OverlayManager::new();
let id = manager.push_toast(Toast::new("caught").duration(3.0));
settle_entry_transition(&mut manager.entries[0]);
age_entry_timeout(&mut manager.entries[0], Duration::from_secs(3));
manager.tick_at(Instant::now());
age_entry_transition(&mut manager.entries[0], Duration::from_millis(50));
manager.tick_at(Instant::now());
let faded_opacity = manager.entries[0].opacity();
assert!(manager.set_hovered_toast(Some(id)));
assert!(!manager.entries[0].pending_dismiss);
assert_eq!(
manager.entries[0].hover_remaining,
Some(Duration::from_secs(1))
);
assert_eq!(manager.entries[0].opacity(), faded_opacity);
assert!(!manager.set_hovered_toast(None));
age_entry_timeout(&mut manager.entries[0], Duration::from_millis(999));
manager.tick_at(Instant::now());
assert!(!manager.entries[0].pending_dismiss);
age_entry_timeout(&mut manager.entries[0], Duration::from_millis(2));
manager.tick_at(Instant::now());
assert!(manager.entries[0].pending_dismiss);
}
#[test]
fn immediate_dismiss_removes_toast_without_an_exit_transition() {
let mut manager = OverlayManager::new();
let first = manager.push_toast(Toast::new("first"));
let second = manager.push_toast(Toast::new("second"));
assert!(manager.dismiss_immediately(first));
assert_eq!(manager.entries.len(), 1);
assert_eq!(manager.entries[0].id, second);
assert!(!manager.entries[0].pending_dismiss);
}
#[test]
fn normal_dismiss_fades_from_the_current_opacity_before_removal() {
let mut manager = OverlayManager::new();
let id = manager.push_toast(Toast::new("message"));
settle_entry_transition(&mut manager.entries[0]);
assert!(manager.dismiss(id));
let entry = &manager.entries[0];
assert!(entry.pending_dismiss);
assert_eq!(entry.opacity(), 1.0);
age_entry_transition(&mut manager.entries[0], Duration::from_millis(50));
manager.tick_at(Instant::now());
assert_eq!(manager.entries.len(), 1);
assert!(manager.entries[0].opacity() > 0.0);
assert!(manager.entries[0].opacity() < 1.0);
age_entry_transition(&mut manager.entries[0], Duration::from_millis(100));
manager.tick_at(Instant::now());
assert!(manager.entries.is_empty());
}
#[test]
fn timeout_starts_exit_transition_without_consuming_it_in_the_same_tick() {
let mut manager = OverlayManager::new();
manager.push_toast(Toast::new("message").duration(0.0));
settle_entry_transition(&mut manager.entries[0]);
manager.tick_at(Instant::now());
assert_eq!(manager.entries.len(), 1);
assert!(manager.entries[0].pending_dismiss);
assert_eq!(manager.entries[0].opacity(), 1.0);
}
fn settle_entry_transition(entry: &mut OverlayEntry) {
entry.opacity_transition = None;
entry.transition_tick_at = None;
}
fn age_entry_transition(entry: &mut OverlayEntry, age: Duration) {
let now = Instant::now();
entry.transition_tick_at = Some(now.checked_sub(age).unwrap_or(now));
}
fn age_entry_timeout(entry: &mut OverlayEntry, age: Duration) {
entry.created_at = entry
.created_at
.checked_sub(age)
.unwrap_or(entry.created_at);
}
}