bezel-ui 0.2.26

SwiftUI-flavored components for gpui — popovers, menus, buttons, toggles, and loaders
Documentation
//! Menu layers anchored to a trigger, and their enter and exit motion.

use crate::popover::{self, Chain, placement::Placement, submenu};
use gpui::{
    Anchor, AnyElement, App, AvailableSpace, Bounds, Display, Element, FlexDirection,
    GlobalElementId, InspectorElementId, IntoElement, LayoutId, Pixels, Point, Position,
    SharedString, Style, Window, div, point, prelude::*, px, relative, size,
};
use theme::Theme;

/// Pin a floating layer's origin to the trigger's top-left. The anchored
/// element is absolutely positioned; without explicit insets its *static*
/// position is subject to the trigger's own flex alignment (an `items_center`
/// trigger would vertically center the whole floating layer). A zero-size
/// absolutely-inset wrapper fixes the origin at the corner.
pub(super) fn pinned_layer(layer: AnyElement) -> AnyElement {
    div()
        .absolute()
        .top_0()
        .left_0()
        .size_0()
        .child(layer)
        .into_any_element()
}

/// Eased exit progress (0..=1) for a [`popover::Popup`] closing instant, computed from
/// the wall clock at render time. Monotonic by construction — unlike the
/// animation element's own clock, it can never replay from 0 mid-exit.
pub(super) fn exit_progress(since: web_time::Instant) -> f32 {
    let total = motion::MENU_OUT
        .total()
        .mul_f32(motion::speed_scale())
        .as_secs_f32();
    let raw = if total <= 0.0 {
        1.0
    } else {
        (since.elapsed().as_secs_f32() / total).clamp(0.0, 1.0)
    };
    motion::MENU_OUT.progress(raw)
}

/// The surface under a popover layer, on whichever look [`Theme::menu_style`]
/// names. It needs no exit ramp of its own: the primitive reads the element
/// tree's opacity, so a layer playing `menu_out` fades its surface with
/// everything else in it.
pub(super) fn material_menu(content: AnyElement) -> AnyElement {
    crate::surface::popover(Theme::surface_radius(), content).into_any_element()
}

/// Entrance or exit motion for a popover layer. While exiting (the [`popover::Popup`]
/// closing phase, `exit = Some(progress)`) the content plays
/// [`motion::menu_out`] under a fresh animation id (same-id reuse would
/// inherit the entrance's finished clock and snap to the end state) and gets
/// an occluding overlay on top — the dying menu's rows must not take clicks,
/// and the overlay also keeps stray clicks from reaching whatever sits
/// underneath.
pub(super) fn menu_motion(id: SharedString, exit: Option<f32>, inner: gpui::Div) -> AnyElement {
    if let Some(t) = exit {
        let inner = inner.relative().child(div().absolute().inset_0().occlude());
        motion::menu_out(SharedString::from(format!("{id}-out")), t, inner).into_any_element()
    } else {
        motion::menu_in(id, inner).into_any_element()
    }
}

/// The common floating layer. Trigger wrappers only choose the origin.
pub(super) fn menu_layer(
    id: impl Into<SharedString>,
    content: AnyElement,
    closing: Option<web_time::Instant>,
    anchor: Anchor,
    position: Option<Point<Pixels>>,
    gap: f32,
) -> AnyElement {
    gpui::deferred(Layer {
        content: menu_motion(
            id.into(),
            closing.map(exit_progress),
            div()
                .occlude()
                .flex()
                .flex_col()
                .min_h_0()
                .child(material_menu(content)),
        ),
        anchor,
        position,
        gap,
    })
    .priority(popover::MENU_LAYER)
    .into_any_element()
}

struct Layer {
    content: AnyElement,
    anchor: Anchor,
    position: Option<Point<Pixels>>,
    gap: f32,
}

impl Element for Layer {
    type RequestLayoutState = LayoutId;
    type PrepaintState = ();

    fn id(&self) -> Option<gpui::ElementId> {
        None
    }

    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
        None
    }

    fn request_layout(
        &mut self,
        _: Option<&GlobalElementId>,
        _: Option<&InspectorElementId>,
        window: &mut Window,
        cx: &mut App,
    ) -> (LayoutId, LayoutId) {
        let child = self.content.request_layout(window, cx);
        let layout = window.request_layout(
            Style {
                position: Position::Absolute,
                display: Display::Flex,
                ..Style::default()
            },
            [],
            cx,
        );
        (layout, child)
    }

    fn prepaint(
        &mut self,
        _: Option<&GlobalElementId>,
        _: Option<&InspectorElementId>,
        bounds: Bounds<Pixels>,
        child: &mut LayoutId,
        window: &mut Window,
        cx: &mut App,
    ) {
        let margin = px(popover::SNAP) + window.client_inset().unwrap_or(Pixels::ZERO);
        let mut style = Style {
            display: Display::Flex,
            flex_direction: FlexDirection::Column,
            ..Style::default()
        };
        style.max_size.height = relative(1.).into();
        let constrained = window.request_layout(style, [*child], cx);
        window.compute_layout(
            constrained,
            size(
                AvailableSpace::MaxContent,
                AvailableSpace::Definite(
                    (window.viewport_size().height - margin * 2.).max(Pixels::ZERO),
                ),
            ),
            cx,
        );
        let natural = window.layout_bounds(*child).size;
        let placement = Placement::new(
            self.anchor,
            self.position.unwrap_or(bounds.origin),
            natural.height,
            window.viewport_size(),
            margin,
            px(self.gap),
        );
        // GPUI invalidates descendant bounds when this layout root is recomputed.
        window.compute_layout(
            constrained,
            size(
                AvailableSpace::MaxContent,
                AvailableSpace::Definite(placement.available_height),
            ),
            cx,
        );
        let measured = window.layout_bounds(*child).size;
        let origin = placement.origin(measured);
        self.content
            .prepaint_at(point(origin.x.round(), origin.y.round()), window, cx);
    }

    fn paint(
        &mut self,
        _: Option<&GlobalElementId>,
        _: Option<&InspectorElementId>,
        _: Bounds<Pixels>,
        _: &mut LayoutId,
        _: &mut (),
        window: &mut Window,
        cx: &mut App,
    ) {
        self.content.paint(window, cx);
    }
}

impl IntoElement for Layer {
    type Element = Self;
    fn into_element(self) -> Self {
        self
    }
}

/// Wrap popover content in a floating anchored layer attached to the trigger:
/// the caller `.child(anchored_menu(...))`s this from the trigger element while
/// open. The trigger must be `relative()`, as for every `anchored_menu_*`
/// variant. Plays `menu-in` (0.14s fade + 2px drop); `closing` (the [`popover::Popup`]
/// exit phase) swaps in `menu-out`. Dismissal is the caller's
/// `.on_mouse_down_out` on the content. The layer `.occlude()`s: hitboxes are
/// paint-order only in gpui, so without it clicks on menu rows would ALSO fire
/// whatever clickable sits under the floating layer.
pub fn anchored_menu(
    id: impl Into<SharedString>,
    content: AnyElement,
    closing: Option<web_time::Instant>,
) -> AnyElement {
    pinned_layer(menu_layer(id, content, closing, Anchor::TopLeft, None, 6.0))
}

/// [`anchored_menu`] opening DOWNWARD from the trigger's bottom edge — a
/// dropdown proper (the sidebar's space filter). The default variant pins to
/// the trigger's top-left, which reads fine for context-style menus but
/// covers a button-shaped trigger.
pub fn anchored_menu_below(
    id: impl Into<SharedString>,
    content: AnyElement,
    closing: Option<web_time::Instant>,
) -> AnyElement {
    anchored_menu_below_gap(id, content, closing, 6.0)
}

/// [`anchored_menu_below`] with a caller-chosen trigger→card gap — the
/// changes-header dropdowns hang off a tight titlebar band and need more
/// breathing room than the default 6px (user report; t3code sits near 10).
pub fn anchored_menu_below_gap(
    id: impl Into<SharedString>,
    content: AnyElement,
    closing: Option<web_time::Instant>,
    gap: f32,
) -> AnyElement {
    div()
        .absolute()
        .bottom_0()
        .left_0()
        .size_0()
        .child(menu_layer(id, content, closing, Anchor::TopLeft, None, gap))
        .into_any_element()
}

/// The panel a [`crate::menu::Item::Submenu`] row drops, mounted on that row —
/// which must be `relative()`. Its first row lines up with the row that opened
/// it, and the two cards touch on whichever side [`submenu::place`] put it.
/// `chain` is the open menu's, shared by every panel in it.
///
/// `closing` is the root card's: the panel plays its exit with it.
pub fn anchored_submenu(
    id: impl Into<SharedString>,
    content: AnyElement,
    chain: &Chain,
    closing: Option<web_time::Instant>,
) -> AnyElement {
    let content = div().occlude().child(material_menu(content));
    submenu::layer(
        menu_motion(id.into(), closing.map(exit_progress), content),
        chain,
    )
}

/// [`anchored_menu`] opening UPWARD from the trigger (composer pickers, the
/// user menu — anything anchored near the window bottom).
pub fn anchored_menu_above(
    id: impl Into<SharedString>,
    content: AnyElement,
    closing: Option<web_time::Instant>,
) -> AnyElement {
    pinned_layer(menu_layer(
        id,
        content,
        closing,
        Anchor::BottomLeft,
        None,
        6.0,
    ))
}

/// Open an upward menu at a point inside a relative trigger. Useful for text
/// completions, whose natural anchor is the token/caret rather than the input
/// element's outer edge.
pub fn anchored_menu_above_at(
    id: impl Into<SharedString>,
    position: Point<Pixels>,
    content: AnyElement,
    closing: Option<web_time::Instant>,
) -> AnyElement {
    div()
        .absolute()
        .left(position.x)
        .top(position.y)
        .size_0()
        .child(anchored_menu_above(id, content, closing))
        .into_any_element()
}

/// [`anchored_menu_above`] right-aligned to the trigger's right edge (t3code
/// ComboboxPopup `align="end"` — right-side triggers like the composer's ref
/// picker open leftward instead of running off the window).
pub fn anchored_menu_above_end(
    id: impl Into<SharedString>,
    content: AnyElement,
    closing: Option<web_time::Instant>,
) -> AnyElement {
    div()
        .absolute()
        .top_0()
        .right_0()
        .size_0()
        .child(menu_layer(
            id,
            content,
            closing,
            Anchor::BottomRight,
            None,
            6.0,
        ))
        .into_any_element()
}

/// [`anchored_menu_below`] right-aligned to the trigger's right edge — a row
/// whose menu is opened from a control at its end, so the card drops from that
/// control and opens inward rather than off the trigger's far side.
pub fn anchored_menu_below_end(
    id: impl Into<SharedString>,
    content: AnyElement,
    closing: Option<web_time::Instant>,
) -> AnyElement {
    div()
        .absolute()
        .bottom_0()
        .right_0()
        .size_0()
        .child(menu_layer(
            id,
            content,
            closing,
            Anchor::TopRight,
            None,
            6.0,
        ))
        .into_any_element()
}

/// A floating menu at an explicit window position (context menus). Occludes
/// like [`anchored_menu`] so row clicks never reach elements underneath.
pub fn menu_at(
    id: impl Into<SharedString>,
    position: Point<Pixels>,
    content: AnyElement,
    closing: Option<web_time::Instant>,
) -> AnyElement {
    menu_layer(id, content, closing, Anchor::TopLeft, Some(position), 0.0)
}