fluidattacks-designs 0.12.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `Tooltip` — the web floating bubble, on the side the `place` prop picks and
//! with the hover delays `fluidattacks_designs_domain::tooltip` names. Wrap
//! the anchor:
//!
//! ```ignore
//! Tooltip::new("id", "the tip").place(Place::Right).child(anchor)
//! ```

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(),
        }
    }

    /// The bold heading above the tip (web `title`).
    #[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
    }

    /// Render the anchor alone, like the web `disabled`.
    #[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);
    }
}

/// Commit the pending show/hide, unless the pointer changed its mind
/// during the delay.
fn settle(state: &Entity<TipState>, entering: bool, cx: &mut App) {
    state.update(cx, |tip, _| {
        if tip.hovered == entering {
            tip.shown = entering;
        }
    });
}

/// Flip the shown flag after the web's open/close delay. The view id comes
/// from render: gpui forbids `current_view` inside event dispatch.
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();
}

/// The triangle pointing from the bubble toward the anchor.
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 {
            // bubble under the anchor: point up
            Place::Bottom => ((0.0, 1.0), (0.5, 0.0), (1.0, 1.0)),
            // bubble above: point down
            Place::Top => ((0.0, 0.0), (0.5, 1.0), (1.0, 0.0)),
            // bubble to the left: point right
            Place::Left => ((0.0, 0.0), (1.0, 0.5), (0.0, 1.0)),
            // bubble to the right: point left
            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)
}

/// Which of the bubble's own sides faces the anchor, and so is pinned to it.
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,
    }
}

/// The web's `offset`: a step away from the anchor along the axis `place` names.
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)),
    }
}

/// The zero-sized point on the anchor's edge that the bubble and the arrow both
/// hang off.
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)),
    }
}

/// The triangle centered on the anchor's edge, its base against the bubble and
/// its apex just short of the anchor.
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)),
    }
}

/// The bubble hung off the anchor, and the arrow left behind on it.
///
/// `anchored` is what keeps the bubble on screen — `select.rs` hangs its menu
/// off the same element. `snap_to_window` slides it back inside on both axes,
/// which is the web's `shift` across the anchor and *not* its `flip` along it:
/// a bubble with no room on the side it asked for slides over the anchor rather
/// than moving to the opposite side, since gpui cannot flip one axis and slide
/// the other. The arrow stays on the anchor, first so that a bubble which slid
/// that far covers it instead of wearing a stray triangle.
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};

    /// The point sits on the edge `place` names and the bubble hangs by its own
    /// facing side. Either half alone reads as right, and getting one of them
    /// wrong lays the bubble over the control it is explaining.
    #[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");
        }
    }
}