Skip to main content

bevy_nested_tooltips/
lib.rs

1//! # Bevy Nested Tooltips
2//!
3//! ## Features
4//! This library strives to handle the logic behind common tooltip features, while you focus on your unique data and design needs.
5//!
6//! - Tooltips can be spawned by hovering or by user pressing the middle mouse button, your choice which and you can change at runtime.
7//! - Nesting to arbitrary levels, the only limitation is memory.
8//! - Despawns if the user hasn't interacted with them in a configurable time period, or they mouse away after interacting with them.
9//! - Locking by pressing of the middle mouse button. using observers you can implement your specific design to inform your users.
10//! - Highlight other Entites using a linked text, highlight designs are up to you.
11//!
12//! ## Usage
13//!
14//! ### Import the prelude
15//! ```rust
16//! use bevy_nested_tooltips::prelude::*;
17//! ```
18//! ### Add the plugin
19//!
20//! ```rust
21//!         .add_plugins((
22//!             NestedTooltipPlugin,
23//!         ))
24//! ```
25//!
26//! ### (Optional) Configure tooltips
27//! ```rust
28//!     commands.insert_resource(TooltipConfiguration {
29//!         activation_method: ActivationMethod::MiddleMouse,
30//!         ..Default::default()
31//!     });
32//! ```
33//!
34//! ### Load your tooltips
35//!
36//! ```rust
37//!     let mut tooltip_map = TooltipMap {
38//!         map: HashMap::new(),
39//!     };
40//!
41//!     tooltip_map.insert(
42//!         "tooltip".into(),
43//!         ToolTipsData::new(
44//!             "ToolTip",
45//!             vec![
46//!                 TooltipsContent::String("A way to give users infomation can be ".into()),
47//!                 TooltipsContent::Term("recursive".into()),
48//!                 TooltipsContent::String(" Press middle mouse button to lock me. ".into()),
49//!             ],
50//!         ),
51//!     );
52//!
53//!     tooltip_map.insert(
54//!         "recursive".into(),
55//!         ToolTipsData::new(
56//!             "Recursive",
57//!             vec![
58//!                 TooltipsContent::String("Tooltips can be ".into()),
59//!                 TooltipsContent::Term("recursive".into()),
60//!                 TooltipsContent::String(
61//!                     " You can highlight specific ui panels with such as the ".into(),
62//!                 ),
63//!                 TooltipsContent::Highlight("sides".into()),
64//!                 TooltipsContent::String(" Press middle mouse button to lock me. ".into()),
65//!             ],
66//!         ),
67//!     );
68//! ```
69//! ### Add links to relevant entities
70//! ```rust
71//! TooltipHighlight(vec!["sides".into()]),
72//! ```
73//! Or
74//! ```rust
75//!  TooltipTermLink::new("tooltip"),
76//! ```
77//!
78//! ### Style your tooltips
79//! Create an observer with at least these parameters.
80//! ```rust
81//! fn style_tooltip(
82//!     new_tooltip: On<TooltipSpawned>,
83//!     tooltip_info: TooltipEntitiesParam,
84//!     mut commands: Commands,
85//! )
86//! ```
87//! Fetch the data.
88//! ```rust
89//!     let tooltip_info = tooltip_info
90//!         .tooltip_child_entities(new_tooltip.entity)
91//!         .unwrap();
92//! ```
93//! Use the entities to style your node using commands or mutatable queries!
94//! ```rust
95//!     commands
96//!         .get_entity(tooltip_info.title_node)
97//!         .unwrap()
98//!         .insert(Node {
99//!             display: Display::Flex,
100//!             justify_content: JustifyContent::Center,
101//!             width: Val::Percent(100.),
102//!             ..Default::default()
103//!         });
104//! ```
105//!
106//! #### React to changes.
107//!
108//! ```rust
109//! // When highlighted change the colour, how you highlight is up to you
110//! // maybe fancy animations
111//! fn add_highlight(side: On<Add, TooltipHighlighting>, mut commands: Commands) {
112//!     commands
113//!         .get_entity(side.entity)
114//!         .unwrap()
115//!         .insert(BackgroundColor(GREEN.into()));
116//! }
117//!
118//! // remove highlighting
119//! fn remove_highlight(side: On<Remove, TooltipHighlighting>, mut commands: Commands) {
120//!     commands
121//!         .get_entity(side.entity)
122//!         .unwrap()
123//!         .insert(BackgroundColor(BLUE.into()));
124//! }
125//! ```
126
127pub mod highlight;
128pub mod layout;
129pub mod query;
130pub mod react;
131pub mod term;
132
133use std::{fmt::Debug, time::Duration};
134
135use bevy_app::{Plugin, PreStartup, Update};
136use bevy_derive::{Deref, DerefMut};
137use bevy_ecs::{
138    component::Component,
139    entity::Entity,
140    event::{EntityEvent, Event},
141    hierarchy::Children,
142    lifecycle::HookContext,
143    observer::{Observer, On},
144    query::{AnyOf, Has, Or, QueryData, With},
145    resource::Resource,
146    schedule::{IntoScheduleConfigs, common_conditions::resource_changed},
147    system::{Commands, Query, Res},
148    template::template,
149    world::{DeferredWorld, World},
150};
151use bevy_scene::{CommandsSceneExt, Scene, bsn, template_value};
152
153use bevy_log::error;
154use bevy_math::{Rect, Vec2};
155use bevy_picking::{
156    Pickable,
157    events::{Click, Drag, Move, Out, Over, Pointer, Press},
158    pointer::PointerButton,
159};
160use bevy_platform::collections::HashMap;
161use bevy_text::TextSpan;
162use bevy_time::{Time, Timer, TimerMode};
163use bevy_ui::{
164    ComputedNode, Display, GlobalZIndex, GridAutoFlow, Node, PositionType, RelativeCursorPosition,
165    UiRect, Val, widget::Text,
166};
167use bevy_window::Window;
168use tiny_bail::prelude::*;
169
170/// An easy way to import commonly used types.
171pub mod prelude {
172    pub use super::{
173        ActivationMethod, ArbitraryTooltip, NestedTooltipPlugin, Tooltip, TooltipConfiguration,
174        TooltipMap, TooltipSpawned, TooltipsContent, TooltipsContentDetail, TooltipsData,
175        highlight::{TooltipHighlight, TooltipHighlightLink},
176        layout::{TooltipStringText, TooltipTextNode, TooltipTitleNode, TooltipTitleText},
177        query::{TooltipEntities, TooltipEntitiesParam},
178        react::{SpawnArbitraryTooltip, SpawnTooltip, TooltipHighlighting, TooltipLocked},
179        term::{TooltipTermLink, TooltipTermLinkRecursive},
180    };
181}
182use prelude::*;
183
184use crate::{highlight::HighlightPlugin, react::SpawnArbitraryTooltip, term::hover_time_spawn};
185
186/// This plugin adds systems and resources that makes the logic work.
187pub struct NestedTooltipPlugin;
188
189impl Plugin for NestedTooltipPlugin {
190    fn build(&self, app: &mut bevy_app::App) {
191        app.add_plugins(HighlightPlugin)
192            .init_resource::<TooltipConfiguration>()
193            .init_resource::<TooltipReference>()
194            .init_resource::<TooltipMap>()
195            .add_systems(PreStartup, setup_component_hooks)
196            .add_systems(Update, tick_timers)
197            .add_systems(
198                Update,
199                update_settings.run_if(resource_changed::<TooltipConfiguration>),
200            )
201            .add_observer(spawn_time_done)
202            .add_observer(requested_spawn)
203            .add_observer(arbitrary_spawn);
204    }
205}
206
207/// Resource that configures the behaviour of tooltips.
208#[derive(Resource, Debug)]
209pub struct TooltipConfiguration {
210    /// See the [`ActivationMethod`] variants.
211    pub activation_method: ActivationMethod,
212
213    /// Maximum amount of time the `ToolTip` will remain around without user interaction.
214    pub interaction_wait_for_time: Duration,
215
216    /// The starting z_index this will be incremented for each recursive tooltip
217    /// increase this if tooltips are not on top and you want to fix that.
218    pub starting_z_index: i32,
219}
220
221impl Default for TooltipConfiguration {
222    fn default() -> Self {
223        Self {
224            activation_method: Default::default(),
225            interaction_wait_for_time: Duration::from_secs_f64(0.8),
226            starting_z_index: 3,
227        }
228    }
229}
230
231/// How a tooltip is triggered by default this is done via hovering
232/// Hovering can be further customised.
233#[derive(Debug, Clone)]
234pub enum ActivationMethod {
235    /// Middle mouse button is pressed.
236    MiddleMouse,
237    /// Mouse is over the `Tooltip` for a duration.
238    Hover { time: Duration },
239}
240
241impl Default for ActivationMethod {
242    fn default() -> Self {
243        ActivationMethod::Hover {
244            time: Duration::from_secs_f64(0.9),
245        }
246    }
247}
248
249/// Default node for the [`Tooltip`] node use this to layout your tooltips without
250/// accidentally moving it's position.
251/// This resource is initialised on adding plugin.
252#[derive(Resource, Debug)]
253pub struct TooltipReference {
254    /// Top level Node this will be copied to the [`Tooltip`] positions will be overwritten
255    pub tooltip_node: Node,
256}
257
258impl TooltipReference {
259    pub fn new(tooltip_node: Node) -> Self {
260        Self { tooltip_node }
261    }
262}
263
264impl Default for TooltipReference {
265    fn default() -> Self {
266        Self {
267            tooltip_node: Node {
268                position_type: PositionType::Absolute,
269                display: Display::Grid,
270                grid_auto_flow: GridAutoFlow::Row,
271                max_width: Val::Vw(35.),
272                min_height: Val::Vh(5.),
273                max_height: Val::Vh(20.),
274                border: UiRect::all(Val::Px(1.)),
275                ..Default::default()
276            },
277        }
278    }
279}
280
281/// Indicates this entity is a tooltip and stores what spawned it
282/// The entity that spawned it is blocked from spawning another tooltip
283/// until this one is finished to prevent tooltip jumping around.
284#[derive(Debug, Component)]
285#[require(RelativeCursorPosition, TooltipPointerPresence)]
286pub struct Tooltip {
287    from_entity: Entity,
288}
289
290impl Tooltip {
291    /// The entity that spawned this tooltip
292    pub fn entity(&self) -> Entity {
293        self.from_entity
294    }
295}
296
297/// When the cursor has gotten sufficently inside the tooltip
298/// leaving will now despawn this tooltip.
299#[derive(Debug, Component)]
300struct TooltipDebounced;
301
302/// This is sent when a [`Tooltip`] is spawned.
303#[derive(Debug, EntityEvent)]
304pub struct TooltipSpawned {
305    pub entity: Entity,
306}
307
308/// If the user hasn't hovered on the tooltip in the specified time despawn it
309/// time is configured in [`TooltipConfiguration`].
310#[derive(Debug, Component)]
311pub struct TooltipWaitForHover {
312    timer: Timer,
313}
314
315/// [`Tooltip`] that spawned nested from this one.
316#[derive(Debug, Component)]
317#[relationship_target(relationship = TooltipsNestedOf)]
318pub struct TooltipsNested(Entity);
319
320/// This [`Tooltip`] is nested under the entities [`Tooltip`].
321#[derive(Debug, Component)]
322#[relationship(relationship_target = TooltipsNested)]
323pub struct TooltipsNestedOf(Entity);
324
325/// Timer added on creating a [`Tooltip`], if the user does not mouseover the tooltip in that
326/// time then it will be despawned.
327#[derive(Debug, Component)]
328pub struct TooltipLinkTimer {
329    timer: Timer,
330}
331
332/// Sent when link has been hovered long enough to spawn [`Tooltip`].
333#[derive(Event)]
334struct TooltipLinkTimeElapsed {
335    term_entity: Entity,
336}
337
338/// Indicates that the pointer has left this link, added when a tooltip is spawned
339/// This is used to stop the tooltip from despawning
340///
341#[derive(Component, Debug, Default, PartialEq)]
342#[component(on_insert = tooltip_presence)]
343enum TooltipPointerPresence {
344    #[default]
345    On,
346    Left,
347}
348
349fn tooltip_presence(mut world: DeferredWorld, HookContext { entity, .. }: HookContext) {
350    r!(world.commands().get_entity(entity))
351        .observe(pointer_left_link)
352        .observe(pointer_over_link);
353}
354
355/// The data of your tooltips.
356/// When a [`TooltipTermLink`] is activated the string inside of it will be used as key
357/// for the hashmap and its result will populate the tooltip.
358///
359/// See [`TooltipsData`].
360#[derive(Resource, Default, Debug, Deref, DerefMut)]
361pub struct TooltipMap {
362    pub map: HashMap<String, TooltipsData>,
363}
364
365/// What is to be included in the [`Tooltip`].
366#[derive(Debug)]
367pub struct TooltipsData {
368    /// The title at the top of the tooltips.
369    pub title: String,
370    /// The rest of the text.
371    pub content: Vec<TooltipsContent>,
372}
373
374impl TooltipsData {
375    pub fn new(title: impl ToString, content: impl IntoTooltipsContent) -> Self {
376        Self {
377            title: title.to_string(),
378            content: content.into_tooltips_content(),
379        }
380    }
381}
382
383/// Used to make creation of simple tooltips easier
384/// This is intended to be used in function arguments
385pub trait IntoTooltipsContent {
386    /// Create `TooltipsContent`
387    fn into_tooltips_content(self) -> Vec<TooltipsContent>;
388}
389
390/// This makes up a part of the tooltips text content.
391/// Each variant outputs text but with different behaviours
392/// See each variants documenation for details.
393#[derive(Clone)]
394pub enum TooltipsContent {
395    /// Displays normal text for the user.
396    String(String),
397    /// Nested information that can spawn's a child tooltip, used as key for [`TooltipMap`].
398    Term(TooltipsContentDetail),
399    /// Adds a highlight Component to all tooltips with [`TooltipHighlight`].
400    Highlight(TooltipsContentDetail),
401    /// Text with a custom scene, use this to add observers for custom behaviour
402    /// Takes in the link string
403    Custom(TooltipsContentDetail, fn(&str) -> Box<dyn Scene>),
404}
405
406impl IntoTooltipsContent for Vec<TooltipsContent> {
407    fn into_tooltips_content(self) -> Vec<TooltipsContent> {
408        self
409    }
410}
411
412impl IntoTooltipsContent for String {
413    fn into_tooltips_content(self) -> Vec<TooltipsContent> {
414        vec![TooltipsContent::String(self)]
415    }
416}
417
418/// This allows the differentiation between the trigger word and displayed variations
419/// for cases such as text
420#[derive(Clone, Debug)]
421pub struct TooltipsContentDetail {
422    /// The identifying word that connects the highlights
423    pub link: String,
424    /// The actual display text, this will often be the same as the link,
425    /// but can change for gramatical reasons such as plurals
426    pub text: String,
427}
428
429impl TooltipsContentDetail {
430    /// Creates new link with the text matching see [`with_alias`] for when you need to change
431    /// text away from actual link
432    pub fn new(link: impl Into<String>) -> Self {
433        let link = link.into();
434        let text = link.clone();
435        Self { link, text }
436    }
437
438    /// Creates a new link with the actual text being display differing
439    /// see [`new`] for a more convenient way to initialise both
440    pub fn with_alias(link: impl Into<String>, text: impl Into<String>) -> Self {
441        let link = link.into();
442        let text = text.into();
443        Self { link, text }
444    }
445}
446
447impl Debug for TooltipsContent {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        match self {
450            Self::String(arg0) => f.debug_tuple("String").field(arg0).finish(),
451            Self::Term(arg0) => f.debug_tuple("Term").field(arg0).finish(),
452            Self::Highlight(arg0) => f.debug_tuple("Highlight").field(arg0).finish(),
453            Self::Custom(arg0, _) => f.debug_tuple("Custom").field(arg0).finish(),
454        }
455    }
456}
457
458/// Marks this `Tooltip` as being spawned via the `SpawnArbitraryTooltip`
459#[derive(Component, Clone, Debug, Default)]
460pub struct ArbitraryTooltip;
461
462/// Marker for Observers related to middle mouse triggering of tooltips
463#[derive(Component)]
464struct NestedTooltipsMiddleMouseObserver;
465
466/// Marker for Observers related to hover triggering of tooltips
467#[derive(Component)]
468struct NestedTooltipsHoverObserver;
469
470/// Setup hooks so that interactions will work
471/// This is based on resource setting
472/// If setting is changed then an update system will set the correct observers
473fn setup_component_hooks(world: &mut World) {
474    world
475        .register_component_hooks::<TooltipTermLink>()
476        .on_insert(|mut world, HookContext { entity, .. }| {
477            let config = rq!(world.get_resource::<TooltipConfiguration>());
478
479            match config.activation_method {
480                ActivationMethod::MiddleMouse => {
481                    let middle_observe = Observer::new(middle_mouse_spawn).with_entity(entity);
482                    world
483                        .commands()
484                        .spawn((middle_observe, NestedTooltipsMiddleMouseObserver));
485                }
486                ActivationMethod::Hover { .. } => {
487                    let hover_spawn_observer = Observer::new(hover_time_spawn).with_entity(entity);
488                    let hover_cancel_observer =
489                        Observer::new(hover_cancel_spawn).with_entity(entity);
490
491                    world
492                        .commands()
493                        .spawn((hover_spawn_observer, NestedTooltipsHoverObserver));
494                    world
495                        .commands()
496                        .spawn((hover_cancel_observer, NestedTooltipsHoverObserver));
497                }
498            }
499        });
500
501    world
502        .register_component_hooks::<TooltipTermLinkRecursive>()
503        .on_insert(|mut world, HookContext { entity, .. }| {
504            let config = rq!(world.get_resource::<TooltipConfiguration>());
505
506            match config.activation_method {
507                ActivationMethod::MiddleMouse => {
508                    let middle_observe = Observer::new(middle_mouse_spawn).with_entity(entity);
509                    world
510                        .commands()
511                        .spawn((middle_observe, NestedTooltipsMiddleMouseObserver));
512                }
513                ActivationMethod::Hover { .. } => {
514                    let hover_spawn_observer = Observer::new(hover_time_spawn).with_entity(entity);
515                    let hover_cancel_observer =
516                        Observer::new(hover_cancel_spawn).with_entity(entity);
517
518                    world
519                        .commands()
520                        .spawn((hover_spawn_observer, NestedTooltipsHoverObserver));
521                    world
522                        .commands()
523                        .spawn((hover_cancel_observer, NestedTooltipsHoverObserver));
524                }
525            }
526        });
527
528    world.register_component_hooks::<Tooltip>().on_insert(
529        |mut world, HookContext { entity, .. }| {
530            world
531                .commands()
532                .entity(entity)
533                .observe(toggle_lock)
534                .observe(move_locked_tooltip)
535                .observe(hover_debounce)
536                .observe(hover_despawn);
537        },
538    );
539}
540
541/// Updates the observers to match user settings
542/// this will despawn unused observers
543#[allow(clippy::type_complexity)]
544fn update_settings(
545    config: Res<TooltipConfiguration>,
546    term_links: Query<Entity, Or<(With<TooltipTermLink>, With<TooltipTermLinkRecursive>)>>,
547    mut commands: Commands,
548) {
549    match config.activation_method {
550        ActivationMethod::MiddleMouse => {
551            let mut middle_observe = Observer::new(middle_mouse_spawn);
552            for entity in term_links {
553                middle_observe.watch_entity(entity);
554            }
555            commands.spawn((middle_observe, NestedTooltipsMiddleMouseObserver));
556        }
557        ActivationMethod::Hover { .. } => {
558            let mut hover_spawn_observer = Observer::new(hover_time_spawn);
559            let mut hover_cancel_observer = Observer::new(hover_cancel_spawn);
560
561            for entity in term_links {
562                hover_spawn_observer.watch_entity(entity);
563                hover_cancel_observer.watch_entity(entity);
564            }
565            commands.spawn((hover_spawn_observer, NestedTooltipsHoverObserver));
566            commands.spawn((hover_cancel_observer, NestedTooltipsHoverObserver));
567        }
568    }
569}
570
571#[derive(QueryData)]
572#[query_data(mutable)]
573struct HoverLinkQuery {
574    link: AnyOf<(&'static TooltipTermLink, &'static TooltipTermLinkRecursive)>,
575    timer: Option<&'static mut TooltipLinkTimer>,
576}
577
578/// Removes hover timer when user's pointer has left.
579#[track_caller]
580fn hover_cancel_spawn(hover: On<Pointer<Out>>, mut commands: Commands) {
581    rq!(commands.get_entity(hover.entity)).remove::<TooltipLinkTimer>();
582}
583
584#[derive(QueryData)]
585#[query_data(mutable)]
586struct SpawnLinksQuery {
587    entity: Entity,
588    link: AnyOf<(&'static TooltipTermLink, &'static TooltipTermLinkRecursive)>,
589    spawn_timer: &'static mut TooltipLinkTimer,
590}
591
592#[derive(QueryData)]
593#[query_data(mutable)]
594struct HoverWaitQuery {
595    entity: Entity,
596    tooltip: &'static Tooltip,
597    wait_for: &'static mut TooltipWaitForHover,
598}
599
600/// Tick timers and if they finish spawn/despawn the releveant tooltip.
601fn tick_timers(
602    mut links_query: Query<SpawnLinksQuery>,
603    mut wait_for_query: Query<HoverWaitQuery>,
604    pointer_presence_query: Query<&TooltipPointerPresence>,
605    time_res: Res<Time>,
606    mut commands: Commands,
607) {
608    for mut links_item in &mut links_query {
609        links_item.spawn_timer.timer.tick(time_res.delta());
610        if links_item.spawn_timer.timer.is_finished() {
611            commands.trigger(TooltipLinkTimeElapsed {
612                term_entity: links_item.entity,
613            });
614            c!(commands.get_entity(links_item.entity)).remove::<TooltipLinkTimer>();
615        }
616    }
617    for mut wait_for_item in &mut wait_for_query {
618        // Skip if the user is still hovering on the link
619        if let Ok(pointer) = pointer_presence_query.get(wait_for_item.tooltip.from_entity)
620            && *pointer == TooltipPointerPresence::On
621        {
622            continue;
623        }
624        wait_for_item.wait_for.timer.tick(time_res.delta());
625        if wait_for_item.wait_for.timer.is_finished() {
626            c!(commands.get_entity(wait_for_item.entity)).try_despawn();
627        }
628    }
629}
630
631#[derive(QueryData)]
632struct ExistingTooltipQuery {
633    entity: Entity,
634    tooltip: &'static Tooltip,
635}
636
637/// Triggered when timer is done, fetch additional data to spawn [`Tooltip`].
638#[allow(clippy::too_many_arguments)]
639fn spawn_time_done(
640    term: On<TooltipLinkTimeElapsed>,
641    links_query: Query<AnyOf<(&TooltipTermLink, &TooltipTermLinkRecursive)>>,
642    existing_tooltips_query: Query<ExistingTooltipQuery>,
643    window_query: Query<&Window>,
644    tooltips_map: Res<TooltipMap>,
645    tooltip_reference: Res<TooltipReference>,
646    tooltip_configuration: Res<TooltipConfiguration>,
647    mut commands: Commands,
648) {
649    let term_entity = term.term_entity;
650
651    for tooltip_item in existing_tooltips_query {
652        let tooltip = tooltip_item.tooltip;
653        if tooltip.from_entity == term_entity {
654            return;
655        }
656    }
657
658    let link_item = r!(links_query.get(term_entity));
659    let (tooltip_term, nested) = match link_item {
660        // Guranteed to have at least one entity
661        (None, None) => {
662            error!("Bevy invariant failed");
663            return;
664        }
665        (None, Some(s)) => (s.linked_string.clone(), Some(s.parent_entity)),
666        (Some(s), None) => (s.linked_string.clone(), None),
667        // Shouldn't have both types of links could be caused by user if they tried hard enough
668        (Some(_), Some(_)) => {
669            error!("Nested tooltips has a bug");
670            return;
671        }
672    };
673
674    // Despawn other top level `Tooltip`s
675    let zindex = match nested {
676        None => {
677            for tooltip_item in existing_tooltips_query {
678                let entity = tooltip_item.entity;
679                c!(commands.get_entity(entity)).try_despawn();
680            }
681            GlobalZIndex(tooltip_configuration.starting_z_index)
682        }
683        Some(_) => GlobalZIndex(
684            existing_tooltips_query.count() as i32 + tooltip_configuration.starting_z_index,
685        ),
686    };
687
688    let Some(tooltip_data) = tooltips_map.get(&tooltip_term) else {
689        error!("Could not find {tooltip_term} in tooltips");
690        return;
691    };
692
693    spawn_tooltip(
694        term.term_entity,
695        tooltip_data,
696        zindex,
697        (),
698        window_query,
699        &tooltip_reference,
700        &tooltip_configuration,
701        &mut commands,
702    );
703}
704
705#[derive(QueryData)]
706struct TooltipDebounceQuery {
707    tooltip: &'static Tooltip,
708    debounced: Has<TooltipDebounced>,
709    cursor: &'static RelativeCursorPosition,
710}
711
712/// This is to debounce the cursor when it lands on the
713/// tooltip, without this it is too easy to accidentally
714/// close the tooltip.
715fn hover_debounce(
716    hover: On<Pointer<Move>>,
717    tooltip_query: Query<TooltipDebounceQuery>,
718    mut commands: Commands,
719) {
720    // Number should not be greater then 0.5
721    // the low the number the more in the tooltip, the pointer needs to be
722    const DEBOUNCE_DIST: f32 = 0.48;
723    let tooltip_item = r!(tooltip_query.get(hover.entity));
724    if tooltip_item.debounced {
725        return;
726    }
727    let normalised = rq!(tooltip_item.cursor.normalized);
728    let bounds = Rect {
729        min: Vec2::new(-DEBOUNCE_DIST, -DEBOUNCE_DIST),
730        max: Vec2::new(DEBOUNCE_DIST, DEBOUNCE_DIST),
731    };
732
733    if bounds.contains(normalised) {
734        r!(commands.get_entity(hover.entity))
735            .insert(TooltipDebounced)
736            .remove::<TooltipWaitForHover>();
737    }
738}
739
740#[derive(QueryData)]
741struct TooltipQuery {
742    tooltip: &'static Tooltip,
743    relative_cursor: &'static RelativeCursorPosition,
744    has_nested: Has<TooltipsNested>,
745    locked: Has<TooltipLocked>,
746    debounced: Has<TooltipDebounced>,
747}
748
749/// When user mouses out of [`Tooltip`] despawn it
750/// unless it has a nested tooltip or the cursor is still on the link
751#[allow(clippy::type_complexity)]
752fn hover_despawn(
753    hover: On<Pointer<Out>>,
754    tooltip_query: Query<TooltipQuery>,
755    link_query: Query<&'static TooltipPointerPresence>,
756    mut commands: Commands,
757) {
758    let tooltip_item = r!(tooltip_query.get(hover.entity));
759
760    // despawns occur at nested level
761    if tooltip_item.has_nested || tooltip_item.locked || !tooltip_item.debounced {
762        return;
763    }
764
765    if tooltip_item.relative_cursor.cursor_over {
766        return;
767    }
768
769    // If the user is still pointing to the definition then don't despawn
770    if let Ok(presence) = link_query.get(tooltip_item.tooltip.from_entity)
771        && *presence == TooltipPointerPresence::On
772    {
773        return;
774    }
775
776    r!(commands.get_entity(hover.entity)).despawn();
777}
778
779/// When user has pressed the middle mouse button on a [`TooltipLink`].
780#[allow(clippy::too_many_arguments)]
781fn middle_mouse_spawn(
782    mut press: On<Pointer<Click>>,
783    links_query: Query<AnyOf<(&TooltipTermLink, &TooltipTermLinkRecursive)>>,
784    existing_tooltips_query: Query<ExistingTooltipQuery>,
785    window_query: Query<&Window>,
786    tooltips_map: Res<TooltipMap>,
787    tooltip_reference: Res<TooltipReference>,
788    tooltip_configuration: Res<TooltipConfiguration>,
789    mut commands: Commands,
790) {
791    // Stop tooltip lock being triggered
792    press.propagate(false);
793    if press.button != PointerButton::Middle {
794        return;
795    }
796
797    let term_entity = press.entity;
798    for tooltip_item in existing_tooltips_query {
799        let tooltip = tooltip_item.tooltip;
800        if tooltip.from_entity == press.entity {
801            return;
802        }
803    }
804
805    let link_item = r!(links_query.get(term_entity));
806    let (tooltip_term, nested) = match link_item {
807        // Guranteed to have at least one entity
808        (None, None) => {
809            error!("Bevy invariant failed");
810            return;
811        }
812        (None, Some(s)) => (s.linked_string.clone(), Some(s.parent_entity)),
813        (Some(s), None) => (s.linked_string.clone(), None),
814        // Shouldn't have both types of links could be caused by user if they tried hard enough
815        (Some(_), Some(_)) => {
816            error!("Nested tooltips has a bug");
817            return;
818        }
819    };
820
821    // Despawn other top level `Tooltip`s
822    let zindex = match nested {
823        None => {
824            for tooltip_item in existing_tooltips_query {
825                let entity = tooltip_item.entity;
826                c!(commands.get_entity(entity)).try_despawn();
827            }
828            GlobalZIndex(tooltip_configuration.starting_z_index)
829        }
830        Some(_) => GlobalZIndex(
831            existing_tooltips_query.count() as i32 + tooltip_configuration.starting_z_index,
832        ),
833    };
834
835    let Some(tooltip_data) = tooltips_map.get(&tooltip_term) else {
836        error!("Could not find {tooltip_term} in tooltips");
837        return;
838    };
839
840    spawn_tooltip(
841        press.entity,
842        tooltip_data,
843        zindex,
844        (),
845        window_query,
846        &tooltip_reference,
847        &tooltip_configuration,
848        &mut commands,
849    );
850}
851
852fn requested_spawn(
853    tooltip_spawn: On<SpawnTooltip>,
854    existing_tooltips_query: Query<ExistingTooltipQuery>,
855    window_query: Query<&Window>,
856    tooltips_map: Res<TooltipMap>,
857    tooltip_reference: Res<TooltipReference>,
858    tooltip_configuration: Res<TooltipConfiguration>,
859    mut commands: Commands,
860) {
861    // Prevent the same entity having two existing tooltips spawned
862    for tooltip_item in existing_tooltips_query {
863        let tooltip = tooltip_item.tooltip;
864        if tooltip.from_entity == tooltip_spawn.entity {
865            return;
866        }
867    }
868
869    let tooltip_term = &tooltip_spawn.term;
870    let Some(tooltip_data) = tooltips_map.get(tooltip_term) else {
871        error!("Could not find {tooltip_term} in tooltips");
872        return;
873    };
874
875    spawn_tooltip(
876        tooltip_spawn.entity,
877        tooltip_data,
878        GlobalZIndex(tooltip_configuration.starting_z_index),
879        (),
880        window_query,
881        &tooltip_reference,
882        &tooltip_configuration,
883        &mut commands,
884    );
885}
886
887fn arbitrary_spawn(
888    tooltip_spawn: On<SpawnArbitraryTooltip>,
889    existing_tooltips_query: Query<ExistingTooltipQuery>,
890    window_query: Query<&Window>,
891    tooltip_reference: Res<TooltipReference>,
892    tooltip_configuration: Res<TooltipConfiguration>,
893    mut commands: Commands,
894) {
895    // Delete any prior tooltips
896    for tooltip_item in existing_tooltips_query {
897        let tooltip = tooltip_item.tooltip;
898        if tooltip.from_entity == tooltip_spawn.entity {
899            c!(commands.get_entity(tooltip_item.entity)).despawn();
900        }
901    }
902
903    spawn_tooltip(
904        tooltip_spawn.entity,
905        &tooltip_spawn.tooltips_data,
906        GlobalZIndex(tooltip_configuration.starting_z_index),
907        bsn! { ArbitraryTooltip },
908        window_query,
909        &tooltip_reference,
910        &tooltip_configuration,
911        &mut commands,
912    );
913}
914
915/// Common logic to spawn [`Tooltip`] should be called when activation method has been satisfied
916/// This also blocks tooltips from spawning if entity has already spawned one.
917#[allow(clippy::too_many_arguments)]
918fn spawn_tooltip(
919    term_entity: Entity,
920    tooltip_data: &TooltipsData,
921    zindex: GlobalZIndex,
922    additional: impl Scene,
923    window_query: Query<&Window>,
924    tooltip_reference: &TooltipReference,
925    tooltip_configuration: &TooltipConfiguration,
926    commands: &mut Commands,
927) {
928    let design_node = position_tooltip(window_query, tooltip_reference);
929
930    let wait_for = tooltip_configuration.interaction_wait_for_time.clone();
931    let title = tooltip_data.title.clone();
932
933    let tooltip_commands = commands.spawn_scene(bsn! {
934        #tooltip
935        additional
936        template_value(design_node)
937        template(move|_|Ok(Tooltip {
938            from_entity: term_entity,
939        }))
940        template(move|_|Ok(TooltipWaitForHover {
941            timer: Timer::new(
942                wait_for,
943                TimerMode::Once,
944            ),
945        }))
946        template_value(zindex)
947        Pickable {
948            should_block_lower: true,
949            is_hoverable: true,
950        }
951        Children[(
952            TooltipTitleNode
953            Node {
954                display: Display::Flex,
955            }
956            Children[
957                (
958                    TooltipTitleText
959                    Text::new(title)
960                )
961            ]
962        ),
963        (
964            TooltipTextNode
965            Node {
966                display: Display::Flex,
967                width: Val::Percent(100.),
968            }
969            Text::new("")
970            Children[
971            {tooltip_data.content.iter().map(|item| -> Box<dyn Scene> {
972                    match item {
973                        TooltipsContent::String(s) => {
974                            let s = s.clone();
975                            Box::new(bsn! {
976                                TooltipStringText
977                                TextSpan::new(s)
978                            })
979                        },
980                        TooltipsContent::Term(s) => {
981                            let link = s.link.clone();
982                            let text = s.text.clone();
983                            Box::new(bsn! {
984                                TooltipTermLinkRecursive{
985                                    parent_entity: #tooltip,
986                                    linked_string:{link.clone()}
987                                }
988                                TextSpan::new(text)
989                            })
990                        },
991                        TooltipsContent::Highlight(s) => {
992                            let link = s.link.clone();
993                            let text = s.text.clone();
994                            Box::new(bsn! {
995                                template(move |_|{
996                                    Ok(TooltipHighlightLink(link.clone()))
997                                })
998                                TextSpan::new(text)
999                            })
1000                        },
1001                        TooltipsContent::Custom(s, scene) => {
1002                            let text = s.text.clone();
1003                            Box::new(bsn! {
1004                                TextSpan::new(text.clone())
1005                                {scene(&s.link)}
1006                            })
1007                        },
1008                    }
1009                }).collect::<Vec<_>>()}
1010            ]
1011        )]
1012    });
1013
1014    let tooltip_id = tooltip_commands.id();
1015
1016    r!(commands.get_entity(term_entity)).insert(TooltipPointerPresence::On);
1017
1018    commands.trigger(TooltipSpawned { entity: tooltip_id });
1019}
1020
1021/// Poistions the [`Tooltip`] relative to the cursor.
1022fn position_tooltip(window_query: Query<&Window>, tooltip_reference: &TooltipReference) -> Node {
1023    let mut design_node = tooltip_reference.tooltip_node.clone();
1024    let window = r!(window_query.single());
1025    let cursor_position = r!(window.cursor_position());
1026
1027    let window_size = window.size();
1028    let half_window_size = window_size / 2.0;
1029    let offset = 8.0;
1030    let (left, right) = if cursor_position.x > half_window_size.x {
1031        (
1032            Val::Auto,
1033            Val::Px(window_size.x - cursor_position.x + offset),
1034        )
1035    } else {
1036        (Val::Px(cursor_position.x + offset), Val::Auto)
1037    };
1038    let (top, bottom) = if cursor_position.y > half_window_size.y {
1039        (
1040            Val::Auto,
1041            Val::Px(window_size.y - cursor_position.y + offset),
1042        )
1043    } else {
1044        (Val::Px(cursor_position.y + offset), Val::Auto)
1045    };
1046
1047    design_node.left = left;
1048    design_node.right = right;
1049    design_node.top = top;
1050    design_node.bottom = bottom;
1051    design_node
1052}
1053
1054/// Updates the the status of the user cursors to indicate leaving
1055fn pointer_left_link(
1056    hover: On<Pointer<Out>>,
1057    mut prescence_query: Query<&mut TooltipPointerPresence>,
1058) {
1059    let mut prescene_item = r!(prescence_query.get_mut(hover.entity));
1060    *prescene_item = TooltipPointerPresence::Left;
1061}
1062fn pointer_over_link(
1063    hover: On<Pointer<Over>>,
1064    mut prescence_query: Query<&mut TooltipPointerPresence>,
1065) {
1066    let mut prescene_item = r!(prescence_query.get_mut(hover.entity));
1067    *prescene_item = TooltipPointerPresence::On;
1068}
1069
1070#[derive(QueryData)]
1071struct LockTooltipQuery {
1072    tooltip: &'static Tooltip,
1073    locked: Has<TooltipLocked>,
1074}
1075
1076/// When user presses middle mouse button add or remove [`TooltipLocked`].
1077fn toggle_lock(
1078    press: On<Pointer<Press>>,
1079    tooltip_query: Query<LockTooltipQuery>,
1080    mut commands: Commands,
1081) {
1082    if press.button == PointerButton::Middle {
1083        let tooltip_item = r!(tooltip_query.get(press.entity));
1084        if tooltip_item.locked {
1085            r!(commands.get_entity(press.entity)).remove::<TooltipLocked>();
1086        } else {
1087            r!(commands.get_entity(press.entity)).insert(TooltipLocked);
1088        }
1089    }
1090}
1091
1092#[derive(QueryData)]
1093#[query_data(mutable)]
1094struct MoveLockedQuery {
1095    tooltip: &'static Tooltip,
1096    locked: Has<TooltipLocked>,
1097    node: &'static mut Node,
1098    computed_node: &'static ComputedNode,
1099}
1100
1101/// Locked tooltips can be moved by clicking and dragging
1102fn move_locked_tooltip(drag: On<Pointer<Drag>>, mut tooltip_query: Query<MoveLockedQuery>) {
1103    if drag.button != PointerButton::Primary {
1104        return;
1105    }
1106    let tooltip_item = r!(tooltip_query.get_mut(drag.entity));
1107    if !tooltip_item.locked {
1108        return;
1109    }
1110
1111    let width = tooltip_item.computed_node.size.x / 3.;
1112    let height = tooltip_item.computed_node.size.y / 3.;
1113
1114    let x = drag.pointer_location.position.x - width;
1115    let y = drag.pointer_location.position.y - height;
1116
1117    let mut node = tooltip_item.node;
1118
1119    node.left = Val::Px(x);
1120    node.right = Val::Auto;
1121    node.top = Val::Px(y);
1122    node.bottom = Val::Auto;
1123}