use super::geometry::{PointPx, RectPx, in_safe_zone};
use super::ranges::{TooltipArea, TooltipRangeId};
use crate::ecs::PointF;
use bevy_ecs::prelude::*;
use std::time::{Duration, Instant};
use tracing::trace;
pub(crate) const FALLBACK_HOVER_TIME: Duration = Duration::from_millis(400);
const PATROL: Duration = Duration::from_millis(100);
const RESHOW: Duration = Duration::from_millis(200);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TooltipTurnToken(u64);
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TooltipEndReason {
LeftSafeZone,
ButtonPressed,
WindowHidden,
WindowDestroyed,
RangeUnregistered,
EnteredOtherRange,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Over {
pub window: Entity,
pub range: TooltipRangeId,
pub area_logical: TooltipArea,
pub has_text: bool,
pub pos_logical: PointF,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Tracked {
Present { range_px: RectPx, receives: bool },
RangeUnregistered,
WindowDestroyed,
WindowHidden,
}
#[derive(Debug)]
pub(crate) struct StepInput {
pub now: Instant,
pub cursor: Option<PointPx>,
pub button_down: bool,
pub over: Option<Over>,
pub tracked: Option<Tracked>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Effect {
ArmDeadline(Instant),
TurnStarted {
token: TooltipTurnToken,
window: Entity,
range: TooltipRangeId,
pos_logical: PointF,
stored: bool,
},
Hide {
token: TooltipTurnToken,
reason: HideReason,
},
TurnEnded {
token: TooltipTurnToken,
window: Entity,
range: TooltipRangeId,
reason: TooltipEndReason,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum HideReason {
End(TooltipEndReason),
Dismissed,
EmptyText,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SupplyDecision {
Show,
HideEmpty,
Stale,
}
#[derive(Debug, Default)]
pub(crate) struct TurnMachine {
state: State,
last_token: u64,
tip: Option<RectPx>,
last_hidden: Option<Instant>,
last_cursor: Option<PointPx>,
}
#[derive(Debug, Default)]
enum State {
#[default]
Idle,
Waiting {
window: Entity,
range: TooltipRangeId,
deadline: Instant,
},
Active(Turn),
Suppressed {
range: TooltipRangeId,
},
}
#[derive(Debug)]
struct Turn {
token: TooltipTurnToken,
window: Entity,
range: TooltipRangeId,
area: TooltipArea,
}
impl TurnMachine {
pub(crate) fn step(
&mut self,
input: &StepInput,
hover_time: &mut dyn FnMut() -> Duration,
) -> Vec<Effect> {
let mut fx = Vec::new();
let moved = input.cursor.is_some() && input.cursor != self.last_cursor;
self.state = match std::mem::take(&mut self.state) {
State::Waiting {
window,
range,
deadline,
} => self.check_waiting(input, moved, window, range, deadline, &mut fx),
State::Active(turn) => self.check_active(input, moved, turn, &mut fx),
other => other,
};
if let State::Suppressed { range } = self.state
&& input.cursor.is_some()
&& input.over.map(|o| o.range) != Some(range)
{
trace!(range = ?range, "[tooltip_turn] 押した範囲から出た");
self.state = State::Idle;
}
if let (State::Idle, Some(over)) = (&self.state, input.over) {
self.state = if input.button_down {
trace!(range = ?over.range, "[tooltip_turn] 押したまま入った(数えない)");
State::Suppressed { range: over.range }
} else {
let deadline = input.now + self.wait_for(over.range, input.now, hover_time());
fx.push(Effect::ArmDeadline(deadline));
State::Waiting {
window: over.window,
range: over.range,
deadline,
}
};
}
if input.cursor.is_some() {
self.last_cursor = input.cursor;
}
fx
}
fn wait_for(&self, range: TooltipRangeId, now: Instant, setting: Duration) -> Duration {
let reshow = self
.last_hidden
.is_some_and(|t| now.saturating_duration_since(t) <= RESHOW);
let wait = if reshow { setting } else { setting * 2 };
trace!(
range = ?range,
wait_ms = wait.as_millis() as u64,
reshow, "[tooltip_turn] 範囲に入った・数え始め"
);
wait
}
fn check_waiting(
&mut self,
input: &StepInput,
moved: bool,
window: Entity,
range: TooltipRangeId,
deadline: Instant,
fx: &mut Vec<Effect>,
) -> State {
let reason = lost(input).or_else(|| match (input.cursor, input.over) {
(None, _) => None,
(Some(_), Some(o)) if o.range == range => None,
(Some(_), _) => Some(left(input, moved)),
});
if let Some(reason) = reason {
trace!(range = ?range, reason = ?reason, "[tooltip_turn] 来なかった");
return match reason {
TooltipEndReason::ButtonPressed => State::Suppressed { range },
_ => State::Idle,
};
}
let over = input.over.filter(|_| input.cursor.is_some());
let Some(over) = over.filter(|_| input.now >= deadline) else {
fx.push(Effect::ArmDeadline(deadline));
return State::Waiting {
window,
range,
deadline,
};
};
self.last_token += 1;
let token = TooltipTurnToken(self.last_token);
trace!(range = ?range, token = token.0, stored = over.has_text, "[tooltip_turn] 来た");
fx.push(Effect::TurnStarted {
token,
window,
range,
pos_logical: over.pos_logical,
stored: over.has_text,
});
fx.push(Effect::ArmDeadline(input.now + PATROL));
State::Active(Turn {
token,
window,
range,
area: over.area_logical,
})
}
fn check_active(
&mut self,
input: &StepInput,
moved: bool,
turn: Turn,
fx: &mut Vec<Effect>,
) -> State {
let reason = lost(input).or_else(|| {
let p = input.cursor?;
let Some(Tracked::Present { range_px, receives }) = input.tracked else {
return None;
};
let other = input.over.is_some_and(|o| o.range != turn.range);
if self.tip.is_some_and(|tip| in_safe_zone(p, tip, None)) {
None
} else if in_safe_zone(p, range_px, None) && !receives {
Some(left(input, moved))
} else if !in_safe_zone(p, range_px, self.tip) {
Some(TooltipEndReason::LeftSafeZone)
} else {
other.then_some(TooltipEndReason::EnteredOtherRange)
}
});
let Some(reason) = reason else {
fx.push(Effect::ArmDeadline(input.now + PATROL));
return State::Active(turn);
};
self.end_turn(turn, reason, input.now, fx)
}
fn end_turn(
&mut self,
turn: Turn,
reason: TooltipEndReason,
now: Instant,
fx: &mut Vec<Effect>,
) -> State {
if self.tip.is_some() {
fx.push(Effect::Hide {
token: turn.token,
reason: HideReason::End(reason),
});
self.set_tip(None, now);
}
trace!(range = ?turn.range, token = turn.token.0, reason = ?reason, "[tooltip_turn] 終わり");
fx.push(Effect::TurnEnded {
token: turn.token,
window: turn.window,
range: turn.range,
reason,
});
match reason {
TooltipEndReason::ButtonPressed => State::Suppressed { range: turn.range },
_ => State::Idle,
}
}
pub(crate) fn supply(
&mut self,
token: TooltipTurnToken,
text_is_empty: bool,
) -> SupplyDecision {
let decision = if !self.is_current(token) {
SupplyDecision::Stale
} else if text_is_empty {
SupplyDecision::HideEmpty
} else {
SupplyDecision::Show
};
trace!(token = token.0, decision = ?decision, "[tooltip_turn] 文字を渡された");
decision
}
pub(crate) fn dismiss(&mut self, token: TooltipTurnToken, now: Instant) -> bool {
let current = self.is_current(token);
if current {
self.set_tip(None, now);
}
trace!(
token = token.0,
current, "[tooltip_turn] 消してと求められた"
);
current
}
pub(crate) fn set_tip(&mut self, tip: Option<RectPx>, now: Instant) {
if tip.is_none() && self.tip.is_some() {
self.last_hidden = Some(now);
}
self.tip = tip;
}
pub(crate) fn on_unregistered(&mut self, id: TooltipRangeId, now: Instant) -> Vec<Effect> {
let mut fx = Vec::new();
match std::mem::take(&mut self.state) {
State::Active(turn) if turn.range == id => {
self.state = self.end_turn(turn, TooltipEndReason::RangeUnregistered, now, &mut fx);
}
State::Waiting { range, .. } if range == id => {
trace!(range = ?range, "[tooltip_turn] 来なかった(登録の取り消し)");
}
other => self.state = other,
}
fx
}
fn is_current(&self, token: TooltipTurnToken) -> bool {
matches!(&self.state, State::Active(t) if t.token == token)
}
pub(crate) fn tracked_range(&self) -> Option<(Entity, TooltipRangeId)> {
match &self.state {
State::Waiting { window, range, .. } => Some((*window, *range)),
State::Active(t) => Some((t.window, t.range)),
_ => None,
}
}
pub(crate) fn active_area(&self) -> Option<TooltipArea> {
match &self.state {
State::Active(t) => Some(t.area),
_ => None,
}
}
pub(crate) fn is_idle(&self) -> bool {
matches!(self.state, State::Idle)
}
}
fn lost(input: &StepInput) -> Option<TooltipEndReason> {
match input.tracked {
Some(Tracked::RangeUnregistered) => Some(TooltipEndReason::RangeUnregistered),
Some(Tracked::WindowDestroyed) => Some(TooltipEndReason::WindowDestroyed),
Some(Tracked::WindowHidden) => Some(TooltipEndReason::WindowHidden),
_ => input.button_down.then_some(TooltipEndReason::ButtonPressed),
}
}
fn left(input: &StepInput, moved: bool) -> TooltipEndReason {
match input.tracked {
Some(Tracked::Present {
receives: false, ..
}) if !moved => TooltipEndReason::WindowHidden,
_ => TooltipEndReason::LeftSafeZone,
}
}
#[cfg(test)]
#[path = "turn_tests.rs"]
mod turn_tests;