use alloc::string::String;
use denise::{Point, Radius, Rect, Role, Size, Theme};
use denise_render::Canvas;
use denise_text::{TextEngine, TextStyle};
use crate::overlay::{Side, anchored};
const DELAY_MS: u64 = 600;
const GAP: i32 = 6;
const PADDING: i32 = 6;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Phase {
Idle,
Waiting { due_ms: u64 },
Shown,
}
#[derive(Clone, Debug)]
pub(crate) struct Tooltip {
pub(crate) phase: Phase,
shown: Option<(String, Rect)>,
style: TextStyle,
}
impl Tooltip {
pub(crate) fn new() -> Self {
Self {
phase: Phase::Idle,
shown: None,
style: TextStyle::built_in(14),
}
}
#[inline]
pub(crate) fn is_shown(&self) -> bool {
self.phase == Phase::Shown
}
pub(crate) fn hover_changed(&mut self, has_tooltip: bool, now_ms: u64) -> bool {
let was_shown = self.phase == Phase::Shown;
self.phase = if has_tooltip {
Phase::Waiting {
due_ms: now_ms.saturating_add(DELAY_MS),
}
} else {
Phase::Idle
};
if was_shown {
self.shown = None;
}
was_shown
}
pub(crate) fn dismiss(&mut self) -> bool {
let was_shown = self.phase == Phase::Shown;
self.phase = Phase::Idle;
self.shown = None;
was_shown
}
pub(crate) fn dismiss_wanted(&self, event: &denise::InputEvent) -> bool {
use denise::InputEvent;
self.phase != Phase::Idle
&& matches!(
event,
InputEvent::PointerButton { .. }
| InputEvent::TouchDown { .. }
| InputEvent::Key { .. }
| InputEvent::Text { .. }
| InputEvent::PointerScroll { .. }
| InputEvent::PointerLeft
| InputEvent::SurfaceResized { .. }
)
}
#[inline]
pub(crate) fn next_wake(&self) -> Option<u64> {
match self.phase {
Phase::Waiting { due_ms } => Some(due_ms),
_ => None,
}
}
pub(crate) fn tick(&mut self, now_ms: u64, text: Option<&str>, anchor: Rect) -> bool {
let Phase::Waiting { due_ms } = self.phase else {
return false;
};
if now_ms < due_ms {
return false;
}
let Some(text) = text.filter(|text| !text.is_empty()) else {
self.phase = Phase::Idle;
return false;
};
self.phase = Phase::Shown;
self.shown = Some((String::from(text), anchor));
true
}
pub(crate) fn bounds(&self, surface: Size, engine: &mut TextEngine) -> Option<Rect> {
let (text, anchor) = self.shown.as_ref()?;
Some(place(surface, *anchor, text, self.style, engine))
}
pub(crate) fn paint(
&self,
theme: &Theme,
surface: Size,
engine: &mut TextEngine,
canvas: &mut Canvas<'_>,
) {
let Some((text, anchor)) = self.shown.as_ref() else {
return;
};
let bounds = place(surface, *anchor, text, self.style, engine);
if bounds.is_empty() {
return;
}
let (fill, content) = theme.pair(Role::Neutral);
canvas.fill_rounded_rect(bounds, theme.radius(Radius::Field), fill);
engine.draw(
canvas,
self.style,
Point::new(bounds.x + PADDING, bounds.y + PADDING),
text,
content,
);
}
}
fn place(
surface: Size,
anchor: Rect,
text: &str,
style: TextStyle,
engine: &mut TextEngine,
) -> Rect {
let extent = engine.measure(style, text);
let size = Size::new(
extent.width + PADDING as u32 * 2,
extent.height + PADDING as u32 * 2,
);
anchored(surface, anchor, size, Side::Below, GAP)
}
#[cfg(test)]
mod tests {
use super::*;
fn engine() -> TextEngine {
TextEngine::new()
}
const SURFACE: Size = Size::new(400, 240);
const ANCHOR: Rect = Rect::new(100, 100, 80, 30);
#[test]
fn the_bubble_waits_for_the_pointer_to_rest() {
let mut tooltip = Tooltip::new();
assert_eq!(tooltip.phase, Phase::Idle);
assert_eq!(tooltip.next_wake(), None, "an idle tree wakes for nothing");
tooltip.hover_changed(true, 1_000);
assert_eq!(tooltip.next_wake(), Some(1_000 + DELAY_MS));
assert!(!tooltip.tick(1_000, Some("Lagre"), ANCHOR), "not yet");
assert!(!tooltip.tick(1_000 + DELAY_MS - 1, Some("Lagre"), ANCHOR));
assert!(tooltip.tick(1_000 + DELAY_MS, Some("Lagre"), ANCHOR), "now");
assert_eq!(tooltip.phase, Phase::Shown);
assert_eq!(tooltip.next_wake(), None, "a shown bubble wants no more");
}
#[test]
fn hovering_something_without_a_tooltip_wakes_nothing() {
let mut tooltip = Tooltip::new();
tooltip.hover_changed(false, 1_000);
assert_eq!(tooltip.phase, Phase::Idle);
assert_eq!(tooltip.next_wake(), None);
assert!(!tooltip.tick(9_999, Some("Lagre"), ANCHOR));
}
#[test]
fn moving_on_takes_the_bubble_away() {
let mut tooltip = Tooltip::new();
tooltip.hover_changed(true, 0);
tooltip.tick(DELAY_MS, Some("Lagre"), ANCHOR);
assert_eq!(tooltip.phase, Phase::Shown);
assert!(tooltip.hover_changed(false, DELAY_MS), "it was showing");
assert_eq!(tooltip.phase, Phase::Idle);
assert!(tooltip.shown.is_none(), "and it let go of the text");
assert!(
!tooltip.hover_changed(false, DELAY_MS),
"nothing was showing the second time"
);
}
#[test]
fn a_press_or_a_key_dismisses_it() {
let mut tooltip = Tooltip::new();
tooltip.hover_changed(true, 0);
tooltip.tick(DELAY_MS, Some("Lagre"), ANCHOR);
assert!(tooltip.dismiss(), "it was showing");
assert_eq!(tooltip.phase, Phase::Idle);
assert!(!tooltip.dismiss());
tooltip.hover_changed(true, 0);
assert!(tooltip.next_wake().is_some());
tooltip.dismiss();
assert_eq!(tooltip.next_wake(), None);
}
#[test]
fn moving_between_two_tooltips_restarts_the_wait() {
let mut tooltip = Tooltip::new();
tooltip.hover_changed(true, 0);
tooltip.tick(DELAY_MS, Some("Lagre"), ANCHOR);
assert_eq!(tooltip.phase, Phase::Shown);
tooltip.hover_changed(true, DELAY_MS);
assert_eq!(
tooltip.next_wake(),
Some(DELAY_MS * 2),
"the second wait starts from the move"
);
assert!(!tooltip.tick(DELAY_MS, Some("Avbryt"), ANCHOR), "not yet");
}
#[test]
fn a_tooltip_removed_mid_wait_shows_nothing() {
let mut tooltip = Tooltip::new();
tooltip.hover_changed(true, 0);
assert!(!tooltip.tick(DELAY_MS, None, ANCHOR));
assert_eq!(tooltip.phase, Phase::Idle);
tooltip.hover_changed(true, 0);
assert!(
!tooltip.tick(DELAY_MS, Some(""), ANCHOR),
"nor an empty one"
);
}
#[test]
fn the_bubble_is_placed_below_and_flips_near_the_edge() {
let mut engine = engine();
let below = place(
SURFACE,
ANCHOR,
"Lagre",
TextStyle::built_in(14),
&mut engine,
);
assert_eq!(below.y, ANCHOR.bottom() + GAP);
assert!(below.width > 0 && below.height > 0);
let low = Rect::new(100, 210, 80, 25);
let above = place(SURFACE, low, "Lagre", TextStyle::built_in(14), &mut engine);
assert_eq!(above.bottom(), low.y - GAP, "flipped above");
for anchor in [
Rect::new(-40, -40, 30, 20),
Rect::new(380, 220, 30, 20),
Rect::new(0, 0, 0, 0),
] {
let rect = place(
SURFACE,
anchor,
"Lagre lenge",
TextStyle::built_in(14),
&mut engine,
);
assert!(rect.x >= 0 && rect.y >= 0, "{anchor:?} gave {rect:?}");
assert!(
rect.right() <= SURFACE.width as i32 && rect.bottom() <= SURFACE.height as i32,
"{anchor:?} gave {rect:?}"
);
}
}
#[test]
fn the_bubble_has_padding_round_its_text() {
let mut engine = engine();
let style = TextStyle::built_in(14);
let extent = engine.measure(style, "Lagre");
let bubble = place(SURFACE, ANCHOR, "Lagre", style, &mut engine);
assert_eq!(bubble.width, extent.width as i32 + PADDING * 2);
assert_eq!(bubble.height, extent.height as i32 + PADDING * 2);
}
#[test]
fn a_longer_tooltip_makes_a_wider_bubble() {
let mut engine = engine();
let style = TextStyle::built_in(14);
let short = place(SURFACE, ANCHOR, "Ja", style, &mut engine);
let long = place(SURFACE, ANCHOR, "Lagre endringene", style, &mut engine);
assert!(long.width > short.width);
}
}