use super::geometry::{PointPx, RectPx};
use super::os::{self, OsSample, TipWindow, TooltipOsError};
use super::ranges::{TooltipArea, TooltipRangeId, TooltipRanges};
use super::turn::{Effect, HideReason, Over, StepInput, SupplyDecision, Tracked, TurnMachine};
use super::{OnTooltip, TooltipNotice, TooltipSupply, TooltipTurn, TooltipTurnToken};
use crate::ecs::layout::hit_test_in_window;
use crate::ecs::world::tick_wake;
use crate::ecs::{DPI, PointF, PointerState, Window, WindowHandle, WindowPos, find_owner_window};
use bevy_ecs::prelude::*;
use std::cell::{Cell, RefCell};
use std::time::{Duration, Instant};
use tracing::{debug, warn};
thread_local! {
static PRESSED: Cell<bool> = const { Cell::new(false) };
static TIP_WINDOW: RefCell<TipWindow> = RefCell::new(TipWindow::default());
}
pub(crate) fn note_button_press() {
PRESSED.set(true);
}
pub(crate) type Visible<'a> = &'a dyn Fn(&World, Entity) -> bool;
#[derive(Default)]
pub(crate) struct TooltipSession {
pub(crate) machine: TurnMachine,
pub(crate) note: Option<TurnNote>,
}
#[derive(Clone, Copy)]
pub(crate) struct TurnNote {
pub(crate) token: TooltipTurnToken,
pub(crate) window: Entity,
pub(crate) notify: Option<OnTooltip>,
pub(crate) anchor: PointPx,
pub(crate) anchored: bool,
pub(crate) shown: bool,
pub(crate) topmost_warned: bool,
}
pub(crate) fn decide(
world: &mut World,
session: &mut TooltipSession,
now: Instant,
sample: &mut dyn FnMut() -> OsSample,
visible: Visible<'_>,
hover_time: &mut dyn FnMut() -> Duration,
) -> Vec<Effect> {
let pressed = PRESSED.take();
let candidates: Vec<Entity> = world
.query::<(Entity, &TooltipRanges)>()
.iter(world)
.filter(|(_, t)| !t.is_empty())
.map(|(e, _)| e)
.collect();
if candidates.is_empty() && session.machine.is_idle() {
return Vec::new();
}
let pointed: Vec<Entity> = world
.query_filtered::<Entity, With<PointerState>>()
.iter(world)
.collect();
let world = &*world;
let pointed: Vec<Entity> = pointed
.into_iter()
.filter_map(|e| find_owner_window(world, e))
.collect();
let sees = Sees {
world,
pointed: &pointed,
visible,
};
let os = sample();
if let (Some(note), Some(p)) = (session.note.as_mut(), os.cursor)
&& !note.anchored
{
note.anchor = p;
}
let over = os
.cursor
.and_then(|p| candidates.iter().find_map(|&w| sees.over(w, p)));
let tracked = session
.machine
.tracked_range()
.map(|(w, id)| sees.tracked(w, id, session.machine.active_area(), os.cursor));
let input = StepInput {
now,
cursor: os.cursor,
button_down: os.button_down || pressed,
over,
tracked,
};
let fx = session.machine.step(&input, hover_time);
for e in &fx {
if let (Effect::TurnStarted { token, window, .. }, Some(p)) = (*e, os.cursor) {
session.note = Some(TurnNote {
token,
window,
notify: world.get::<OnTooltip>(window).copied(),
anchor: p,
anchored: false,
shown: false,
topmost_warned: false,
});
}
}
fx
}
pub(crate) fn notice(effect: &Effect) -> Option<TooltipNotice> {
match *effect {
Effect::TurnStarted {
token,
window,
range,
pos_logical,
stored,
} => Some(TooltipNotice::TurnStarted(TooltipTurn {
window,
range,
position: pos_logical,
token,
has_text: stored,
})),
Effect::TurnEnded {
token,
window,
range,
reason,
} => Some(TooltipNotice::TurnEnded {
window,
range,
token,
reason,
}),
Effect::ArmDeadline(_) | Effect::Hide { .. } => None,
}
}
pub(crate) struct Tip<'a> {
pub show: &'a mut dyn FnMut(&str, PointPx) -> Result<RectPx, TooltipOsError>,
pub hide: &'a mut dyn FnMut(),
}
const RETRY: Duration = Duration::from_millis(100);
pub(crate) fn wake_at(deadline: Instant, now: Instant) -> Instant {
if deadline > now {
deadline
} else {
now + RETRY
}
}
pub(crate) fn frame_with(
world: &mut World,
now: Instant,
sample: &mut dyn FnMut() -> OsSample,
visible: Visible<'_>,
hover_time: &mut dyn FnMut() -> Duration,
tip: &mut Tip<'_>,
) {
let Some(mut session) = world.remove_non_send::<TooltipSession>() else {
return;
};
let fx = decide(world, &mut session, now, sample, visible, hover_time);
world.insert_non_send(session);
apply_with(world, fx, now, tip);
}
pub(crate) fn apply_with(world: &mut World, effects: Vec<Effect>, now: Instant, tip: &mut Tip<'_>) {
let Some(mut session) = world.remove_non_send::<TooltipSession>() else {
return;
};
let mut notices = Vec::new();
for e in &effects {
match *e {
Effect::ArmDeadline(at) => tick_wake::arm_deadline(wake_at(at, now)),
Effect::TurnStarted {
window,
range,
stored: true,
..
} => {
let text = world
.get::<TooltipRanges>(window)
.and_then(|t| t.get(range))
.and_then(|r| r.text.clone());
if let Some(text) = text {
let _ = show(&mut session, tip, &text, "stored", now);
}
}
Effect::Hide { token, reason } => hide(&mut session, tip, token, reason),
Effect::TurnStarted { .. } | Effect::TurnEnded { .. } => {}
}
let Some(n) = notice(e) else { continue };
let token = match n {
TooltipNotice::TurnStarted(t) => t.token,
TooltipNotice::TurnEnded { token, .. } => token,
};
let Some(note) = session.note.filter(|note| note.token == token) else {
continue;
};
if matches!(n, TooltipNotice::TurnEnded { .. }) {
session.note = None;
}
notices.extend(note.notify.map(|f| (f, n)));
}
world.insert_non_send(session);
for (notify, n) in notices {
(notify.0)(world, &n);
}
}
pub(crate) fn supply_text_with(
world: &mut World,
token: TooltipTurnToken,
text: &str,
now: Instant,
tip: &mut Tip<'_>,
) -> TooltipSupply {
let mut session = world.get_non_send_mut::<TooltipSession>();
let decision = session.as_mut().map_or(SupplyDecision::Stale, |s| {
s.machine.supply(token, text.is_empty())
});
match (decision, session) {
(SupplyDecision::Show, Some(mut s)) => match show(&mut s, tip, text, "supplied", now) {
Ok(()) => TooltipSupply::Shown,
Err(e) => TooltipSupply::Failed(e),
},
(SupplyDecision::HideEmpty, Some(mut s)) => {
if s.note.is_some_and(|n| n.shown) {
hide(&mut s, tip, token, HideReason::EmptyText);
}
s.machine.set_tip(None, now);
TooltipSupply::Cleared
}
_ => {
debug!(token = ?token, "[tooltip_supply_stale] 終わった出す番の印で文字を渡された");
TooltipSupply::StaleTurn
}
}
}
pub(crate) fn dismiss_with(
world: &mut World,
token: TooltipTurnToken,
now: Instant,
tip: &mut Tip<'_>,
) -> bool {
let Some(mut s) = world.get_non_send_mut::<TooltipSession>() else {
return false;
};
let shown = s.note.is_some_and(|n| n.token == token && n.shown);
if !s.machine.dismiss(token, now) {
return false;
}
if shown {
hide(&mut s, tip, token, HideReason::Dismissed);
}
true
}
pub(crate) fn unregister_with(
world: &mut World,
id: TooltipRangeId,
now: Instant,
tip: &mut Tip<'_>,
) -> bool {
let removed = world
.get_mut::<TooltipRanges>(id.window())
.is_some_and(|mut t| t.remove(id));
if !removed {
return false;
}
let fx = world
.get_non_send_mut::<TooltipSession>()
.map(|mut s| s.machine.on_unregistered(id, now))
.unwrap_or_default();
apply_with(world, fx, now, tip);
true
}
fn show(
session: &mut TooltipSession,
tip: &mut Tip<'_>,
text: &str,
source: &'static str,
now: Instant,
) -> Result<(), TooltipOsError> {
let TooltipSession { machine, note } = session;
let Some(note) = note else {
warn!(
source,
"[tooltip_show_failed] 出す番の控えが無いので出さない"
);
return Err(TooltipOsError::Show { stage: "anchor" });
};
let chars = text.chars().count();
match (tip.show)(text, note.anchor) {
Ok(rect) => {
machine.set_tip(Some(rect), now);
note.anchored = true;
note.shown = true;
debug!(
window = ?note.window,
token = ?note.token,
source,
chars,
"[tooltip_shown] ツールチップを出した"
);
Ok(())
}
Err(e) => {
machine.set_tip(None, now);
note.shown = false;
warn!(
window = ?note.window,
token = ?note.token,
source,
chars,
error = %e,
"[tooltip_show_failed] ツールチップを出せなかった(出す番は続ける)"
);
Err(e)
}
}
}
fn hide(
session: &mut TooltipSession,
tip: &mut Tip<'_>,
token: TooltipTurnToken,
reason: HideReason,
) {
(tip.hide)();
match session.note.as_mut().filter(|n| n.token == token) {
Some(n) => {
n.shown = false;
debug!(window = ?n.window, token = ?token, reason = ?reason, "[tooltip_hidden] ツールチップを消した");
}
None => debug!(token = ?token, reason = ?reason, "[tooltip_hidden] ツールチップを消した"),
}
}
pub(crate) fn tooltip_frame(world: &mut World) {
let now = Instant::now();
let visible = |w: &World, e: Entity| {
w.get::<WindowHandle>(e)
.is_some_and(|h| os::is_visible(h.hwnd))
};
with_os_tip(|tip| {
frame_with(
world,
now,
&mut os::sample,
&visible,
&mut os::read_hover_time,
tip,
)
});
let Some(mut session) = world.get_non_send_mut::<TooltipSession>() else {
return;
};
let Some(note) = session.note.as_mut().filter(|n| n.shown) else {
return;
};
if let Err(e) = TIP_WINDOW.with_borrow(TipWindow::keep_on_top)
&& !note.topmost_warned
{
note.topmost_warned = true;
warn!(
window = ?note.window,
token = ?note.token,
error = %e,
"[tooltip_show_failed] ツールチップを手前へ当て直せなかった(この出す番では以後記録しない)"
);
}
}
pub(crate) fn with_os_tip<R>(f: impl FnOnce(&mut Tip<'_>) -> R) -> R {
let mut show = |text: &str, at: PointPx| TIP_WINDOW.with_borrow_mut(|w| w.show(text, at));
let mut hide = || TIP_WINDOW.with_borrow_mut(TipWindow::hide);
f(&mut Tip {
show: &mut show,
hide: &mut hide,
})
}
struct Sees<'a> {
world: &'a World,
pointed: &'a [Entity],
visible: Visible<'a>,
}
impl Sees<'_> {
fn receives(&self, window: Entity, p: PointPx) -> Option<PointPx> {
if !self.pointed.contains(&window) {
return None;
}
let origin = self.world.get::<WindowPos>(window)?.position?;
let client = PointPx {
x: p.x - origin.x,
y: p.y - origin.y,
};
hit_test_in_window(
self.world,
window,
PointF::new(client.x as f32, client.y as f32),
)?;
(self.visible)(self.world, window).then_some(client)
}
fn over(&self, window: Entity, p: PointPx) -> Option<Over> {
let client = self.receives(window, p)?;
let dpi = self.dpi(window);
let pos = PointF::new(dpi.to_logical_x(client.x), dpi.to_logical_y(client.y));
let (range, r) = self.world.get::<TooltipRanges>(window)?.hit(pos)?;
Some(Over {
window,
range,
area_logical: r.area,
has_text: r.text.is_some(),
pos_logical: pos,
})
}
fn tracked(
&self,
window: Entity,
id: TooltipRangeId,
start: Option<TooltipArea>,
cursor: Option<PointPx>,
) -> Tracked {
if self.world.get::<Window>(window).is_none() {
return Tracked::WindowDestroyed;
}
let Some(reg) = self
.world
.get::<TooltipRanges>(window)
.and_then(|t| t.get(id))
else {
return Tracked::RangeUnregistered;
};
if !(self.visible)(self.world, window) {
return Tracked::WindowHidden;
}
let Some(range_px) = self.range_px(window, start.unwrap_or(reg.area)) else {
return Tracked::WindowHidden;
};
Tracked::Present {
range_px,
receives: cursor.is_some_and(|p| self.receives(window, p).is_some()),
}
}
fn range_px(&self, window: Entity, area: TooltipArea) -> Option<RectPx> {
let pos = self.world.get::<WindowPos>(window)?;
let o = pos.position?;
Some(match area {
TooltipArea::WholeWindow => {
let s = pos.size?;
RectPx {
left: o.x,
top: o.y,
right: o.x + s.width,
bottom: o.y + s.height,
}
}
TooltipArea::Rect(r) => {
let dpi = self.dpi(window);
let x = |v: f32| o.x + dpi.to_physical_x(v);
let y = |v: f32| o.y + dpi.to_physical_y(v);
RectPx {
left: x(r.left),
top: y(r.top),
right: x(r.right),
bottom: y(r.bottom),
}
}
})
}
fn dpi(&self, window: Entity) -> DPI {
self.world.get::<DPI>(window).copied().unwrap_or_default()
}
}