use core::time::Duration;
use fluidattacks_designs_domain::tooltip::{
Place, ARROW_ALONG, ARROW_OUT, BG, CLOSE_DELAY_MS, MAX_WIDTH, OFFSET, OPEN_DELAY_MS, PADDING,
RADIUS, TEXT, TITLE_MARGIN_BOTTOM,
};
use fluidattacks_designs_domain::typography::{TextSize, FONT_PRIMARY};
use gpui::prelude::FluentBuilder as _;
use gpui::{
anchored, canvas, deferred, div, point, px, relative, rgb, Anchor, AnyElement, App, Bounds,
ElementId, Entity, EntityId, FontWeight, InteractiveElement as _, IntoElement, ParentElement,
PathBuilder, Pixels, Point, RenderOnce, SharedString, StatefulInteractiveElement as _,
Styled as _, Window,
};
use crate::theme::ActiveTheme as _;
#[derive(Clone, Copy, Default)]
struct TipState {
hovered: bool,
shown: bool,
}
#[derive(IntoElement)]
pub struct Tooltip {
id: ElementId,
title: Option<SharedString>,
tip: SharedString,
place: Place,
disabled: bool,
children: Vec<AnyElement>,
}
impl Tooltip {
#[must_use]
pub fn new(id: impl Into<ElementId>, tip: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
title: None,
tip: tip.into(),
place: Place::default(),
disabled: false,
children: Vec::new(),
}
}
#[must_use]
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub const fn place(mut self, place: Place) -> Self {
self.place = place;
self
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl ParentElement for Tooltip {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
fn settle(state: &Entity<TipState>, entering: bool, cx: &mut App) {
state.update(cx, |tip, _| {
if tip.hovered == entering {
tip.shown = entering;
}
});
}
fn schedule(
state: &Entity<TipState>,
entering: bool,
view: EntityId,
window: &Window,
cx: &mut App,
) {
state.update(cx, |tip, _| tip.hovered = entering);
let delay = if entering {
OPEN_DELAY_MS
} else {
CLOSE_DELAY_MS
};
let state = state.clone();
window
.spawn(cx, async move |task_cx| {
task_cx
.background_executor()
.timer(Duration::from_millis(delay))
.await;
task_cx
.update(|_, app| {
settle(&state, entering, app);
app.notify(view);
})
.ok();
})
.detach();
}
fn arrow(place: Place, bg: u32) -> impl IntoElement {
let (width, height) = match place {
Place::Top | Place::Bottom => (ARROW_ALONG, ARROW_OUT),
Place::Left | Place::Right => (ARROW_OUT, ARROW_ALONG),
};
let corners = move |bounds: &Bounds<Pixels>| {
let x_at = |fraction: f32| px(width.mul_add(fraction, f32::from(bounds.origin.x)));
let y_at = |fraction: f32| px(height.mul_add(fraction, f32::from(bounds.origin.y)));
let (first, apex, last) = match place {
Place::Bottom => ((0.0, 1.0), (0.5, 0.0), (1.0, 1.0)),
Place::Top => ((0.0, 0.0), (0.5, 1.0), (1.0, 0.0)),
Place::Left => ((0.0, 0.0), (1.0, 0.5), (0.0, 1.0)),
Place::Right => ((1.0, 0.0), (0.0, 0.5), (1.0, 1.0)),
};
[
point(x_at(first.0), y_at(first.1)),
point(x_at(apex.0), y_at(apex.1)),
point(x_at(last.0), y_at(last.1)),
]
};
canvas(
|_, _, _| (),
move |bounds, (), window, _cx| {
let [first, apex, last] = corners(&bounds);
let mut path = PathBuilder::fill();
path.move_to(first);
path.line_to(apex);
path.line_to(last);
path.close();
if let Ok(path) = path.build() {
window.paint_path(path, rgb(bg));
}
},
)
.w(px(width))
.h(px(height))
}
fn bubble(title: Option<SharedString>, tip: SharedString, bg: u32, fg: u32) -> gpui::Div {
div()
.flex()
.flex_col()
.max_w(px(MAX_WIDTH))
.rounded(px(RADIUS))
.p(px(PADDING))
.bg(rgb(bg))
.text_color(rgb(fg))
.font_family(FONT_PRIMARY)
.text_size(px(TextSize::Sm.px()))
.line_height(px(TextSize::Sm.line_height_px()))
.when_some(title, |el, title| {
el.child(
div()
.font_weight(FontWeight::BOLD)
.mb(px(TITLE_MARGIN_BOTTOM))
.child(title),
)
})
.child(tip)
}
const fn pinned_side(place: Place) -> Anchor {
match place {
Place::Bottom => Anchor::TopCenter,
Place::Top => Anchor::BottomCenter,
Place::Left => Anchor::RightCenter,
Place::Right => Anchor::LeftCenter,
}
}
fn step(place: Place) -> Point<Pixels> {
match place {
Place::Bottom => point(px(0.0), px(OFFSET)),
Place::Top => point(px(0.0), px(-OFFSET)),
Place::Left => point(px(-OFFSET), px(0.0)),
Place::Right => point(px(OFFSET), px(0.0)),
}
}
fn spot(place: Place) -> gpui::Div {
let spot = div().absolute().w(px(0.0)).h(px(0.0));
match place {
Place::Bottom => spot.top(relative(1.0)).left(relative(0.5)),
Place::Top => spot.top_0().left(relative(0.5)),
Place::Left => spot.left_0().top(relative(0.5)),
Place::Right => spot.left(relative(1.0)).top(relative(0.5)),
}
}
fn pointer(place: Place, bg: u32) -> gpui::Div {
const GAP: f32 = OFFSET - ARROW_OUT;
const CENTERED: f32 = -ARROW_ALONG / 2.0;
let el = div().absolute().child(arrow(place, bg));
match place {
Place::Bottom => el.top(px(GAP)).left(px(CENTERED)),
Place::Top => el.bottom(px(GAP)).left(px(CENTERED)),
Place::Left => el.right(px(GAP)).top(px(CENTERED)),
Place::Right => el.left(px(GAP)).top(px(CENTERED)),
}
}
fn placed(place: Place, bubble: gpui::Div, bg: u32) -> gpui::Div {
spot(place).child(pointer(place, bg)).child(
anchored()
.anchor(pinned_side(place))
.offset(step(place))
.snap_to_window()
.child(bubble),
)
}
impl RenderOnce for Tooltip {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| TipState::default());
let (bg, fg) = (cx.theme().shade(BG), cx.theme().shade(TEXT));
let shown = state.read(cx).shown;
let has_content = self.title.is_some() || !self.tip.is_empty();
let show_bubble = shown && !self.disabled && has_content;
let hover_state = state;
let view = window.current_view();
div()
.id(self.id)
.relative()
.when(!self.disabled, |el| {
el.on_hover(move |entering, hover_window, hover_cx| {
schedule(&hover_state, *entering, view, hover_window, hover_cx);
})
})
.children(self.children)
.when(show_bubble, |el| {
el.child(deferred(placed(
self.place,
bubble(self.title, self.tip, bg, fg),
bg,
)))
})
}
}
#[cfg(test)]
mod tests {
use fluidattacks_designs_domain::tooltip::Place;
use gpui::{px, relative, Anchor, Length, Styled as _};
use super::{pinned_side, spot};
#[test]
fn the_point_and_the_bubble_face_each_other() {
let cases = [
(
Place::Bottom,
Length::from(relative(1.0)),
Length::from(relative(0.5)),
Anchor::TopCenter,
),
(
Place::Top,
px(0.0).into(),
relative(0.5).into(),
Anchor::BottomCenter,
),
(
Place::Left,
relative(0.5).into(),
px(0.0).into(),
Anchor::RightCenter,
),
(
Place::Right,
relative(0.5).into(),
relative(1.0).into(),
Anchor::LeftCenter,
),
];
for (place, top, left, side) in cases {
let mut point = spot(place);
let inset = &point.style().inset;
assert_eq!(inset.top, Some(top), "{place:?} top");
assert_eq!(inset.left, Some(left), "{place:?} left");
assert_eq!(pinned_side(place), side, "{place:?} side");
}
}
}