rgpui 1.3.0

GUI UI framework
Documentation
//! 下拉菜单,为按钮和其他交互元素提供下拉菜单支持的 trait。

use std::rc::Rc;

use crate::{
    Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
    RenderOnce, SharedString, StyleRefinement, Styled, Window,
};

use crate::{Button, Popover, PopupMenu, Selectable};

/// 用于按钮和其他交互元素的下拉菜单 trait
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
    /// 使用给定项创建下拉菜单,锚定在 TopLeft 角
    fn dropdown_menu(
        self,
        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
    ) -> DropdownMenuPopover<Self> {
        self.dropdown_menu_with_anchor(Anchor::TopLeft, f)
    }

    /// 使用给定项创建下拉菜单,锚定在给定的角
    fn dropdown_menu_with_anchor(
        mut self,
        anchor: impl Into<Anchor>,
        f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
    ) -> DropdownMenuPopover<Self> {
        let style = self.style().clone();
        let id = self.interactivity().element_id.clone();

        DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f).trigger_style(style)
    }
}

impl DropdownMenu for Button {}

/// 下拉菜单 Popover 元素
#[derive(IntoElement)]
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
    id: ElementId,
    style: StyleRefinement,
    anchor: Anchor,
    trigger: T,
    builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
    /// 弹出内容最小宽度是否跟随触发器宽度(见 [`Popover::match_trigger_width`])。
    match_trigger_width: bool,
}

impl<T> DropdownMenuPopover<T>
where
    T: Selectable + IntoElement + 'static,
{
    fn new(
        id: ElementId,
        anchor: impl Into<Anchor>,
        trigger: T,
        builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
    ) -> Self {
        Self {
            id: SharedString::from(format!("dropdown-menu:{:?}", id)).into(),
            style: StyleRefinement::default(),
            anchor: anchor.into(),
            trigger,
            builder: Rc::new(builder),
            match_trigger_width: false,
        }
    }

    /// 设置下拉菜单 Popover 的锚点角
    pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
        self.anchor = anchor.into();
        self
    }

    /// 设置弹出内容最小宽度是否跟随触发器宽度,默认为 `false`
    ///
    /// `Select` 下拉列表应开启;普通操作菜单保持内容宽度。
    pub fn match_trigger_width(mut self, match_width: bool) -> Self {
        self.match_trigger_width = match_width;
        self
    }

    /// 设置下拉菜单触发器元素的样式细化
    fn trigger_style(mut self, style: StyleRefinement) -> Self {
        self.style = style;
        self
    }
}

#[derive(Default)]
struct DropdownMenuState {
    menu: Option<Entity<PopupMenu>>,
}

impl<T> RenderOnce for DropdownMenuPopover<T>
where
    T: Selectable + IntoElement + 'static,
{
    fn render(self, window: &mut Window, cx: &mut crate::App) -> impl IntoElement {
        let builder = self.builder.clone();
        let menu_state =
            window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
        let match_trigger_width = self.match_trigger_width;

        Popover::new(SharedString::from(format!("popover:{}", self.id)))
            .appearance(false)
            .overlay_closable(false)
            .match_trigger_width(match_trigger_width)
            .trigger(self.trigger)
            .trigger_style(self.style)
            .anchor(self.anchor)
            .content(move |state, window, cx| {
                // 特殊逻辑:只创建一次 PopupMenu 并复用。
                // 因为此 `content` 在每次渲染时都会被调用,所以需要将菜单存储在
                // 状态中以避免每次渲染时重新创建。
                //
                // 并且当菜单关闭时需要重建菜单,以动态重建菜单项来支持
                // `dropdown_menu` 方法,因此下面监听 DismissEvent。
                let menu = match menu_state.read(cx).menu.clone() {
                    Some(menu) => menu,
                    None => {
                        let builder = builder.clone();
                        let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
                            builder(menu, window, cx)
                        });
                        menu_state.update(cx, |state, _| {
                            state.menu = Some(menu.clone());
                        });
                        menu.focus_handle(cx).focus(window, cx);

                        // 监听 PopupMenu 的关闭事件以关闭 Popover
                        let popover_state = cx.entity();
                        window
                            .subscribe(&menu, cx, {
                                let menu_state = menu_state.clone();
                                move |_, _: &DismissEvent, window, cx| {
                                    popover_state.update(cx, |state, cx| {
                                        state.dismiss(window, cx);
                                    });
                                    menu_state.update(cx, |state, _| {
                                        state.menu = None;
                                    });
                                }
                            })
                            .detach();

                        menu.clone()
                    }
                };

                // 跟随触发器宽度:菜单最小/最大宽度同步触发器,
                // 否则外层卡片撑宽后,选项高亮仍被默认 500px 上限卡住。
                if match_trigger_width {
                    let width = state.trigger_width();
                    menu.update(cx, |menu, _| {
                        menu.set_min_width(width);
                        menu.set_max_width(width);
                    });
                }

                menu
            })
    }
}