popover: Improve Popover API. (#1545)
- Add `open`, `on_open_change` method to control open state.
- Add `default_open` method.
## Break Change
This PR to rewrite the API of Popover API to make it easy to use.
- The `content` method now can receive an element directly.
```diff
- .content(|window, cx| {
- cx.new(|cx| {
- PopoverContent::new(window, cx, |_, _| {
- div().child("This popover content.")
- })
- })
- })
+ .content(|state, window, cx| {
+ div().child("This popover content.")
+ })
```
- And you can also just use `child` and `children` to add child
elements.
```rs
Popover::new("my-popover")
.trigger(Button::new("trigger").label("Open Popover"))
.child("This popover content.")
```
- Removed `PopoverContent`, and changed `Popover` default paddings to
`p_3`.
This commit is contained in:
parent
13f25bc4a9
commit
2dbfba3490
4 changed files with 626 additions and 1068 deletions
|
|
@ -1,19 +1,21 @@
|
|||
use gpui::{
|
||||
Action, App, AppContext, Context, Corner, DismissEvent, Element, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, MouseButton,
|
||||
ParentElement as _, Render, Styled as _, Window, actions, div, px,
|
||||
Action, App, AppContext, Context, Corner, DismissEvent, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement as _,
|
||||
Render, Styled as _, Window, actions, div, px,
|
||||
};
|
||||
use gpui_component::{
|
||||
Sizable, WindowExt,
|
||||
ActiveTheme, StyledExt, WindowExt,
|
||||
button::{Button, ButtonVariants as _},
|
||||
divider::Divider,
|
||||
h_flex,
|
||||
input::{Input, InputState},
|
||||
popover::{Popover, PopoverContent},
|
||||
popover::Popover,
|
||||
v_flex,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::section;
|
||||
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = popover_story, no_json)]
|
||||
struct Info(usize);
|
||||
|
|
@ -42,12 +44,14 @@ pub fn init(cx: &mut App) {
|
|||
}
|
||||
|
||||
struct Form {
|
||||
parent: Entity<PopoverStory>,
|
||||
input1: Entity<InputState>,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
fn new(parent: Entity<PopoverStory>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| Self {
|
||||
parent,
|
||||
input1: cx.new(|cx| InputState::new(window, cx)),
|
||||
})
|
||||
}
|
||||
|
|
@ -63,17 +67,24 @@ impl EventEmitter<DismissEvent> for Form {}
|
|||
|
||||
impl Render for Form {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let parent = self.parent.clone();
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.p_4()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.size_full()
|
||||
.child("This is a form container.")
|
||||
.child("Click submit to dismiss the popover.")
|
||||
.child(Input::new(&self.input1))
|
||||
.child(
|
||||
Button::new("submit")
|
||||
.label("Submit")
|
||||
.primary()
|
||||
.on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
parent.update(cx, |this, cx| {
|
||||
this.form_open = false;
|
||||
cx.notify();
|
||||
})
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +92,7 @@ impl Render for Form {
|
|||
pub struct PopoverStory {
|
||||
focus_handle: FocusHandle,
|
||||
form: Entity<Form>,
|
||||
form_open: bool,
|
||||
checked: bool,
|
||||
message: String,
|
||||
}
|
||||
|
|
@ -105,13 +117,14 @@ impl PopoverStory {
|
|||
}
|
||||
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let form = Form::new(window, cx);
|
||||
let form = Form::new(cx.entity(), window, cx);
|
||||
|
||||
cx.focus_self(window);
|
||||
|
||||
Self {
|
||||
form,
|
||||
checked: true,
|
||||
form_open: false,
|
||||
focus_handle: cx.focus_handle(),
|
||||
message: "".to_string(),
|
||||
}
|
||||
|
|
@ -169,138 +182,136 @@ impl Render for PopoverStory {
|
|||
.on_action(cx.listener(Self::on_action_info))
|
||||
.on_action(cx.listener(Self::on_action_toggle_check))
|
||||
.size_full()
|
||||
.min_h(px(400.))
|
||||
.gap_6()
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex().gap_4().child(
|
||||
Popover::new("info-top-left")
|
||||
.trigger(Button::new("info-top-left").outline().label("Top Left"))
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, _| {
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child("Hello, this is a Popover.")
|
||||
.w(px(400.))
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
Button::new("info1")
|
||||
.primary()
|
||||
.label("Ok")
|
||||
.w(px(80.))
|
||||
.small(),
|
||||
)
|
||||
.into_any()
|
||||
})
|
||||
.p_4()
|
||||
.max_w(px(600.))
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Popover::new("info-top-right")
|
||||
.anchor(Corner::TopRight)
|
||||
.trigger(Button::new("info-top-right").outline().label("Top Right"))
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, _| {
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.w_96()
|
||||
.child("Hello, this is a Popover on the Top Right.")
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
Button::new("info1")
|
||||
.primary()
|
||||
.label("Ok")
|
||||
.w(px(80.))
|
||||
.small(),
|
||||
)
|
||||
.into_any()
|
||||
})
|
||||
.p_4()
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().absolute().bottom_4().left_0().w_full().h_10().child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
section("Basic Popover").child(
|
||||
Popover::new("popover-0")
|
||||
.max_w(px(600.))
|
||||
.trigger(Button::new("btn").outline().label("Popover"))
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.w(px(400.))
|
||||
.child("Hello, this is a Popover.")
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
Popover::new("info-bottom-left")
|
||||
.anchor(Corner::BottomLeft)
|
||||
.trigger(
|
||||
Button::new("pop")
|
||||
.outline()
|
||||
.label("Popup with Form")
|
||||
.w(px(300.)),
|
||||
)
|
||||
.content(move |_, _| form.clone()),
|
||||
)
|
||||
.child(
|
||||
Popover::new("info-bottom-right")
|
||||
.anchor(Corner::BottomRight)
|
||||
.mouse_button(MouseButton::Right)
|
||||
.trigger(
|
||||
Button::new("pop")
|
||||
.outline()
|
||||
.label("Mouse Right Click")
|
||||
.w(px(300.)),
|
||||
)
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
"Hello, this is a Popover on the Bottom Right.",
|
||||
)
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("info1")
|
||||
.primary()
|
||||
.label("Ok")
|
||||
.w(px(80.))
|
||||
.small()
|
||||
.on_click(cx.listener(
|
||||
|_, _, window, cx| {
|
||||
window.push_notification(
|
||||
"You have clicked Ok.",
|
||||
cx,
|
||||
);
|
||||
cx.emit(DismissEvent);
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("close")
|
||||
.label("Cancel")
|
||||
.small()
|
||||
.on_click(cx.listener(
|
||||
|_, _, _, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.into_any()
|
||||
})
|
||||
.p_4()
|
||||
})
|
||||
}),
|
||||
"You can put any content here, including text,\
|
||||
buttons, forms, and more.",
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Popover with Form").child(
|
||||
Popover::new("info-bottom-left")
|
||||
.p_0()
|
||||
.text_sm()
|
||||
.trigger(Button::new("pop").outline().label("Popup Form"))
|
||||
.track_focus(&form.focus_handle(cx))
|
||||
.open(self.form_open)
|
||||
.on_open_change(cx.listener(move |this, open, _, cx| {
|
||||
println!("Popover form open changed: {}", open);
|
||||
this.form_open = *open;
|
||||
cx.notify();
|
||||
}))
|
||||
.child(form.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Right click to open Popover").child(
|
||||
Popover::new("popover-right-click")
|
||||
.mouse_button(MouseButton::Right)
|
||||
.trigger(Button::new("btn").outline().label("Right Click Popover"))
|
||||
.max_w(px(600.))
|
||||
.content(|_, _, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child("Hello, this is a Popover on the Bottom Right.")
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
Button::new("info1")
|
||||
.primary()
|
||||
.label("Dismiss")
|
||||
.w(px(80.))
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
window.push_notification(
|
||||
"You have clicked dismiss via DismissEvent.",
|
||||
cx,
|
||||
);
|
||||
cx.emit(DismissEvent);
|
||||
})),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Styling Popover").child(
|
||||
Popover::new("popover-1")
|
||||
.trigger(Button::new("btn").outline().label("Style Popover"))
|
||||
.appearance(false)
|
||||
.py_1()
|
||||
.px_2()
|
||||
.bg(cx.theme().primary)
|
||||
.text_color(cx.theme().primary_foreground)
|
||||
.max_w(px(600.))
|
||||
.rounded_sm()
|
||||
.text_sm()
|
||||
.shadow_2xl()
|
||||
.child("A styled Popover with custom background and text color."),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Default Open").child(
|
||||
Popover::new("default-open-popover")
|
||||
.default_open(true)
|
||||
.trigger(
|
||||
Button::new("default-open-btn")
|
||||
.label("Default Open")
|
||||
.outline(),
|
||||
)
|
||||
.child("This popover is open by default when first rendered."),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Popover Anchor")
|
||||
.min_h(px(320.))
|
||||
.v_flex()
|
||||
.child(
|
||||
div().absolute().top_0().left_0().w_full().h_10().child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
Popover::new("anchor0")
|
||||
.max_w(px(600.))
|
||||
.trigger(Button::new("btn").outline().label("TopLeft"))
|
||||
.child("This is a Popover on the Top Left."),
|
||||
)
|
||||
.child(
|
||||
Popover::new("anchor1")
|
||||
.anchor(Corner::TopRight)
|
||||
.trigger(Button::new("btn").outline().label("TopRight"))
|
||||
.child("This is a Popover on the Top Right."),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().absolute().bottom_0().left_0().w_full().h_10().child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
Popover::new("anchor2")
|
||||
.trigger(Button::new("btn").outline().label("BottomLeft"))
|
||||
.anchor(Corner::BottomLeft)
|
||||
.child("This is a Popover on the Bottom Left."),
|
||||
)
|
||||
.child(
|
||||
Popover::new("anchor3")
|
||||
.anchor(Corner::BottomRight)
|
||||
.trigger(Button::new("btn").outline().label("BottomRight"))
|
||||
.child("This is a Popover on the Bottom Right."),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
use gpui::{Context, Corner, InteractiveElement, IntoElement, SharedString, Styled, Window};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Context, Corner, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
};
|
||||
|
||||
use crate::{button::Button, menu::PopupMenu, popover::Popover, Selectable};
|
||||
|
||||
|
|
@ -8,7 +13,7 @@ pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement +
|
|||
fn dropdown_menu(
|
||||
self,
|
||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Popover<PopupMenu> {
|
||||
) -> DropdownMenuPopover<Self> {
|
||||
self.dropdown_menu_with_anchor(Corner::TopLeft, f)
|
||||
}
|
||||
|
||||
|
|
@ -17,19 +22,116 @@ pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement +
|
|||
mut self,
|
||||
anchor: impl Into<Corner>,
|
||||
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Popover<PopupMenu> {
|
||||
) -> DropdownMenuPopover<Self> {
|
||||
let style = self.style().clone();
|
||||
let id = self.interactivity().element_id.clone();
|
||||
|
||||
Popover::new(SharedString::from(format!("dropdown-menu:{:?}", id)))
|
||||
.appearance(false)
|
||||
.trigger(self)
|
||||
.trigger_style(style)
|
||||
.anchor(anchor.into())
|
||||
.content(move |window, cx| {
|
||||
PopupMenu::build(window, cx, |menu, window, cx| f(menu, window, cx))
|
||||
})
|
||||
DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f).trigger_style(style)
|
||||
}
|
||||
}
|
||||
|
||||
impl DropdownMenu for Button {}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Corner,
|
||||
trigger: T,
|
||||
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
|
||||
}
|
||||
|
||||
impl<T> DropdownMenuPopover<T>
|
||||
where
|
||||
T: Selectable + IntoElement + 'static,
|
||||
{
|
||||
fn new(
|
||||
id: ElementId,
|
||||
anchor: impl Into<Corner>,
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the anchor corner for the dropdown menu popover.
|
||||
pub fn anchor(mut self, anchor: impl Into<Corner>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the style refinement for the dropdown menu trigger.
|
||||
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 gpui::App) -> impl IntoElement {
|
||||
let builder = self.builder.clone();
|
||||
let menu_state =
|
||||
window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
|
||||
Popover::new(SharedString::from(format!("popover:{}", self.id)))
|
||||
.appearance(false)
|
||||
.trigger(self.trigger)
|
||||
.trigger_style(self.style)
|
||||
.anchor(self.anchor)
|
||||
.content(move |_, window, cx| {
|
||||
// Here is special logic to only create the PopupMenu once and reuse it.
|
||||
// Because this `content` will called in every time render, so we need to store the menu
|
||||
// in state to avoid recreating at every render.
|
||||
//
|
||||
// And we also need to rebuild the menu when it is dismissed, to rebuild menu items
|
||||
// dynamically for support `dropdown_menu` method, so we listen for DismissEvent below.
|
||||
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);
|
||||
|
||||
// Listen for dismiss events from the PopupMenu to close the 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()
|
||||
}
|
||||
};
|
||||
|
||||
menu.clone()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +1,60 @@
|
|||
use gpui::{
|
||||
anchored, deferred, div, prelude::FluentBuilder as _, px, AnyElement, App, Bounds, Context,
|
||||
Corner, DismissEvent, DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, GlobalElementId, Hitbox, InteractiveElement as _, IntoElement, KeyBinding, LayoutId,
|
||||
ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, Style,
|
||||
StyleRefinement, Styled, Window,
|
||||
anchored, canvas, deferred, div, prelude::FluentBuilder as _, px, AnyElement, App, Bounds,
|
||||
Context, Corner, DismissEvent, ElementId, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
|
||||
Render, RenderOnce, StyleRefinement, Styled, Subscription, Window,
|
||||
};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{actions::Cancel, Selectable, StyledExt as _};
|
||||
use crate::{actions::Cancel, v_flex, Selectable, StyledExt as _};
|
||||
|
||||
const CONTEXT: &str = "Popover";
|
||||
|
||||
pub(crate) fn init(cx: &mut App) {
|
||||
cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))])
|
||||
}
|
||||
|
||||
/// The content of the popover.
|
||||
pub struct PopoverContent {
|
||||
style: StyleRefinement,
|
||||
focus_handle: FocusHandle,
|
||||
content: Rc<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>,
|
||||
}
|
||||
|
||||
impl PopoverContent {
|
||||
pub fn new<B>(_: &mut Window, cx: &mut App, content: B) -> Self
|
||||
where
|
||||
B: Fn(&mut Window, &mut Context<Self>) -> AnyElement + 'static,
|
||||
{
|
||||
let focus_handle = cx.focus_handle();
|
||||
|
||||
Self {
|
||||
style: StyleRefinement::default(),
|
||||
focus_handle,
|
||||
content: Rc::new(content),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl EventEmitter<DismissEvent> for PopoverContent {}
|
||||
|
||||
impl Focusable for PopoverContent {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for PopoverContent {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PopoverContent {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.p_2()
|
||||
.refine_style(&self.style)
|
||||
.track_focus(&self.focus_handle)
|
||||
.key_context(CONTEXT)
|
||||
.on_action(cx.listener(|_, _: &Cancel, _, cx| {
|
||||
cx.propagate();
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
.child(self.content.clone()(window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// A popover element that can be triggered by a button or any other element.
|
||||
pub struct Popover<M: ManagedView> {
|
||||
#[derive(IntoElement)]
|
||||
pub struct Popover {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Corner,
|
||||
default_open: bool,
|
||||
open: Option<bool>,
|
||||
tracked_focus_handle: Option<FocusHandle>,
|
||||
trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
|
||||
content: Option<Rc<dyn Fn(&mut Window, &mut App) -> Entity<M> + 'static>>,
|
||||
content: Option<
|
||||
Rc<
|
||||
dyn Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement
|
||||
+ 'static,
|
||||
>,
|
||||
>,
|
||||
children: Vec<AnyElement>,
|
||||
/// Style for trigger element.
|
||||
/// This is used for hotfix the trigger element style to support w_full.
|
||||
trigger_style: Option<StyleRefinement>,
|
||||
mouse_button: MouseButton,
|
||||
appearance: bool,
|
||||
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl<M> Popover<M>
|
||||
where
|
||||
M: ManagedView,
|
||||
{
|
||||
impl Popover {
|
||||
/// Create a new Popover with `view` mode.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
anchor: Corner::TopLeft,
|
||||
trigger: None,
|
||||
trigger_style: None,
|
||||
content: None,
|
||||
tracked_focus_handle: None,
|
||||
children: vec![],
|
||||
mouse_button: MouseButton::Left,
|
||||
appearance: true,
|
||||
default_open: false,
|
||||
open: None,
|
||||
on_open_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +82,39 @@ where
|
|||
self
|
||||
}
|
||||
|
||||
/// Set the default open state of the popover, default is `false`.
|
||||
///
|
||||
/// This is only used to initialize the open state of the popover.
|
||||
///
|
||||
/// And please note that if you use the `open` method, this value will be ignored.
|
||||
pub fn default_open(mut self, open: bool) -> Self {
|
||||
self.default_open = open;
|
||||
self
|
||||
}
|
||||
|
||||
/// Force set the open state of the popover.
|
||||
///
|
||||
/// If this is set, the popover will be controlled by this value.
|
||||
///
|
||||
/// NOTE: You must be used in conjunction with `on_open_change` to handle state changes.
|
||||
pub fn open(mut self, open: bool) -> Self {
|
||||
self.open = Some(open);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a callback to be called when the open state changes.
|
||||
///
|
||||
/// The first `&bool` parameter is the **new open state**.
|
||||
///
|
||||
/// This is useful when using the `open` method to control the popover state.
|
||||
pub fn on_open_change<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: Fn(&bool, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
self.on_open_change = Some(Rc::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the style for the trigger element.
|
||||
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
|
||||
self.trigger_style = Some(style);
|
||||
|
|
@ -126,13 +122,14 @@ where
|
|||
}
|
||||
|
||||
/// Set the content of the popover.
|
||||
///
|
||||
/// The `content` is a closure that returns an `AnyElement`.
|
||||
pub fn content<C>(mut self, content: C) -> Self
|
||||
pub fn content<F, E>(mut self, content: F) -> Self
|
||||
where
|
||||
C: Fn(&mut Window, &mut App) -> Entity<M> + 'static,
|
||||
E: IntoElement,
|
||||
F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
|
||||
{
|
||||
self.content = Some(Rc::new(content));
|
||||
self.content = Some(Rc::new(move |state, window, cx| {
|
||||
content(state, window, cx).into_any_element()
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -147,298 +144,246 @@ where
|
|||
self
|
||||
}
|
||||
|
||||
fn render_trigger(&mut self, open: bool, window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let Some(trigger) = self.trigger.take() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
(trigger)(open, window, cx)
|
||||
/// Bind the focus handle to track focus inside the popover.
|
||||
///
|
||||
/// If popover is opened, the focus will be moved to the focus handle.
|
||||
pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
|
||||
self.tracked_focus_handle = Some(handle.clone());
|
||||
self
|
||||
}
|
||||
|
||||
fn resolved_corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> {
|
||||
bounds.corner(match self.anchor {
|
||||
fn resolved_corner(anchor: Corner, bounds: Bounds<Pixels>) -> Point<Pixels> {
|
||||
bounds.corner(match anchor {
|
||||
Corner::TopLeft => Corner::BottomLeft,
|
||||
Corner::TopRight => Corner::BottomRight,
|
||||
Corner::BottomLeft => Corner::TopLeft,
|
||||
Corner::BottomRight => Corner::TopRight,
|
||||
})
|
||||
}
|
||||
|
||||
fn with_element_state<R>(
|
||||
&mut self,
|
||||
id: &GlobalElementId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
f: impl FnOnce(&mut Self, &mut PopoverElementState<M>, &mut Window, &mut App) -> R,
|
||||
) -> R {
|
||||
window.with_optional_element_state::<PopoverElementState<M>, _>(
|
||||
Some(id),
|
||||
|element_state, window| {
|
||||
let mut element_state = element_state.unwrap().unwrap_or_default();
|
||||
let result = f(self, &mut element_state, window, cx);
|
||||
(result, Some(element_state))
|
||||
},
|
||||
)
|
||||
}) + Point {
|
||||
x: px(0.),
|
||||
y: -bounds.size.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> IntoElement for Popover<M>
|
||||
where
|
||||
M: ManagedView,
|
||||
{
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
impl ParentElement for Popover {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PopoverElementState<M> {
|
||||
trigger_layout_id: Option<LayoutId>,
|
||||
popover_layout_id: Option<LayoutId>,
|
||||
popover_element: Option<AnyElement>,
|
||||
trigger_element: Option<AnyElement>,
|
||||
content_view: Rc<RefCell<Option<Entity<M>>>>,
|
||||
/// Trigger bounds for positioning the popover.
|
||||
impl Styled for Popover {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PopoverState {
|
||||
focus_handle: FocusHandle,
|
||||
pub(crate) tracked_focus_handle: Option<FocusHandle>,
|
||||
trigger_bounds: Option<Bounds<Pixels>>,
|
||||
previous_focus: Option<FocusHandle>,
|
||||
open: bool,
|
||||
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||
|
||||
_dismiss_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl<M> Default for PopoverElementState<M> {
|
||||
fn default() -> Self {
|
||||
impl PopoverState {
|
||||
pub fn new(default_open: bool, cx: &mut App) -> Self {
|
||||
Self {
|
||||
trigger_layout_id: None,
|
||||
popover_layout_id: None,
|
||||
popover_element: None,
|
||||
trigger_element: None,
|
||||
content_view: Rc::new(RefCell::new(None)),
|
||||
focus_handle: cx.focus_handle(),
|
||||
tracked_focus_handle: None,
|
||||
trigger_bounds: None,
|
||||
previous_focus: None,
|
||||
open: default_open,
|
||||
on_open_change: None,
|
||||
_dismiss_subscription: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PrepaintState {
|
||||
hitbox: Hitbox,
|
||||
/// Trigger bounds for limit a rect to handle mouse click.
|
||||
trigger_bounds: Option<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
impl<M: ManagedView> Element for Popover<M> {
|
||||
type RequestLayoutState = PopoverElementState<M>;
|
||||
type PrepaintState = PrepaintState;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
/// Check if the popover is open.
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
id: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
|
||||
// FIXME: Remove this and find a better way to handle this.
|
||||
// Apply trigger style, for support w_full for trigger.
|
||||
//
|
||||
// If remove this, the trigger will not support w_full.
|
||||
if let Some(trigger_style) = self.trigger_style.clone() {
|
||||
if let Some(width) = trigger_style.size.width {
|
||||
style.size.width = width;
|
||||
}
|
||||
if let Some(display) = trigger_style.display {
|
||||
style.display = display;
|
||||
}
|
||||
/// Dismiss the popover if it is open.
|
||||
pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.open {
|
||||
self.toggle_open(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
self.with_element_state(
|
||||
id.unwrap(),
|
||||
window,
|
||||
cx,
|
||||
|view, element_state, window, cx| {
|
||||
let mut popover_layout_id = None;
|
||||
let mut popover_element = None;
|
||||
let mut is_open = false;
|
||||
/// Open the popover if it is closed.
|
||||
pub fn show(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.open {
|
||||
self.toggle_open(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(content_view) = element_state.content_view.borrow_mut().as_mut() {
|
||||
is_open = true;
|
||||
fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open = !self.open;
|
||||
if self.open {
|
||||
let state = cx.entity();
|
||||
self.previous_focus = window.focused(cx);
|
||||
self.focus_handle(cx).focus(window);
|
||||
|
||||
let mut anchored = anchored()
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.anchor(view.anchor);
|
||||
if let Some(trigger_bounds) = element_state.trigger_bounds {
|
||||
anchored = anchored.position(view.resolved_corner(trigger_bounds));
|
||||
}
|
||||
|
||||
let mut element = {
|
||||
let content_view_mut = element_state.content_view.clone();
|
||||
let anchor = view.anchor;
|
||||
let appearance = view.appearance;
|
||||
deferred(
|
||||
anchored.child(
|
||||
div()
|
||||
.size_full()
|
||||
.occlude()
|
||||
.tab_group()
|
||||
.when(appearance, |this| this.popover_style(cx))
|
||||
.map(|this| match anchor {
|
||||
Corner::TopLeft | Corner::TopRight => this.top_1(),
|
||||
Corner::BottomLeft | Corner::BottomRight => this.bottom_1(),
|
||||
})
|
||||
.child(content_view.clone())
|
||||
.when(appearance, |this| {
|
||||
this.on_mouse_down_out(move |_, window, _| {
|
||||
// Update the element_state.content_view to `None`,
|
||||
// so that the `paint`` method will not paint it.
|
||||
*content_view_mut.borrow_mut() = None;
|
||||
window.refresh();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.with_priority(1)
|
||||
.into_any()
|
||||
};
|
||||
|
||||
popover_layout_id = Some(element.request_layout(window, cx));
|
||||
popover_element = Some(element);
|
||||
}
|
||||
|
||||
let mut trigger_element = view.render_trigger(is_open, window, cx);
|
||||
let trigger_layout_id = trigger_element.request_layout(window, cx);
|
||||
|
||||
let layout_id = window.request_layout(
|
||||
style,
|
||||
Some(trigger_layout_id).into_iter().chain(popover_layout_id),
|
||||
cx,
|
||||
self._dismiss_subscription =
|
||||
Some(
|
||||
window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
window.refresh();
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
if let Some(previous_focus) = self.previous_focus.take() {
|
||||
window.focus(&previous_focus);
|
||||
}
|
||||
self._dismiss_subscription = None;
|
||||
}
|
||||
|
||||
(
|
||||
layout_id,
|
||||
PopoverElementState {
|
||||
trigger_layout_id: Some(trigger_layout_id),
|
||||
popover_layout_id,
|
||||
popover_element,
|
||||
trigger_element: Some(trigger_element),
|
||||
..Default::default()
|
||||
if let Some(callback) = self.on_open_change.as_ref() {
|
||||
callback(&self.open, window, cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.dismiss(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for PopoverState {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
if let Some(tracked_focus_handle) = &self.tracked_focus_handle {
|
||||
tracked_focus_handle.clone()
|
||||
} else {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PopoverState {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for PopoverState {}
|
||||
|
||||
impl RenderOnce for Popover {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let force_open = self.open;
|
||||
let default_open = self.default_open;
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| {
|
||||
PopoverState::new(default_open, cx)
|
||||
});
|
||||
if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone() {
|
||||
state.update(cx, |state, _| {
|
||||
state.tracked_focus_handle = Some(tracked_focus_handle);
|
||||
state.on_open_change = self.on_open_change.clone();
|
||||
|
||||
if let Some(force_open) = force_open {
|
||||
state.open = force_open;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let open = state.read(cx).open;
|
||||
let focus_handle = state.read(cx).focus_handle.clone();
|
||||
let trigger_bounds = state.read(cx).trigger_bounds;
|
||||
|
||||
let Some(trigger) = self.trigger else {
|
||||
return div().id("empty");
|
||||
};
|
||||
|
||||
let parent_view_id = window.current_view();
|
||||
|
||||
let el = div()
|
||||
.id(self.id)
|
||||
.child((trigger)(open, window, cx))
|
||||
.on_mouse_down(self.mouse_button, {
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.toggle_open(window, cx);
|
||||
});
|
||||
cx.notify(parent_view_id);
|
||||
}
|
||||
})
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let state = state.clone();
|
||||
move |bounds, _, cx| {
|
||||
state.update(cx, |state, _| {
|
||||
state.trigger_bounds = Some(bounds);
|
||||
})
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
},
|
||||
.absolute()
|
||||
.size_full(),
|
||||
);
|
||||
|
||||
if !open {
|
||||
return el;
|
||||
}
|
||||
|
||||
el.child(
|
||||
deferred(
|
||||
anchored()
|
||||
.snap_to_window_with_margin(px(8.))
|
||||
.anchor(self.anchor)
|
||||
.when_some(trigger_bounds, |this, trigger_bounds| {
|
||||
this.position(Self::resolved_corner(self.anchor, trigger_bounds))
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.id("content")
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&focus_handle)
|
||||
.on_action(window.listener_for(&state, PopoverState::on_action_cancel))
|
||||
.size_full()
|
||||
.occlude()
|
||||
.tab_group()
|
||||
.when(self.appearance, |this| this.popover_style(cx).p_3())
|
||||
.map(|this| match self.anchor {
|
||||
Corner::TopLeft | Corner::TopRight => this.top_1(),
|
||||
Corner::BottomLeft | Corner::BottomRight => this.bottom_1(),
|
||||
})
|
||||
.when_some(self.content, |this, content| {
|
||||
this.child(
|
||||
state.update(cx, |state, cx| (content)(state, window, cx)),
|
||||
)
|
||||
})
|
||||
.children(self.children)
|
||||
.when(self.appearance, |this| {
|
||||
let state = state.clone();
|
||||
this.on_mouse_down_out(move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.toggle_open(window, cx);
|
||||
});
|
||||
cx.notify(parent_view_id);
|
||||
})
|
||||
})
|
||||
.on_mouse_down_out({
|
||||
let state = state.clone();
|
||||
move |_, window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
cx.notify(parent_view_id);
|
||||
}
|
||||
})
|
||||
.refine_style(&self.style),
|
||||
),
|
||||
)
|
||||
.with_priority(1),
|
||||
)
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_bounds: gpui::Bounds<gpui::Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
if let Some(element) = &mut request_layout.trigger_element {
|
||||
element.prepaint(window, cx);
|
||||
}
|
||||
if let Some(element) = &mut request_layout.popover_element {
|
||||
element.prepaint(window, cx);
|
||||
}
|
||||
|
||||
let trigger_bounds = request_layout
|
||||
.trigger_layout_id
|
||||
.map(|id| window.layout_bounds(id));
|
||||
|
||||
// Prepare the popover, for get the bounds of it for open window size.
|
||||
let _ = request_layout
|
||||
.popover_layout_id
|
||||
.map(|id| window.layout_bounds(id));
|
||||
|
||||
let hitbox = window.insert_hitbox(
|
||||
trigger_bounds.unwrap_or_default(),
|
||||
gpui::HitboxBehavior::Normal,
|
||||
);
|
||||
|
||||
PrepaintState {
|
||||
trigger_bounds,
|
||||
hitbox,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
id: Option<&GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
self.with_element_state(
|
||||
id.unwrap(),
|
||||
window,
|
||||
cx,
|
||||
|this, element_state, window, cx| {
|
||||
element_state.trigger_bounds = prepaint.trigger_bounds;
|
||||
|
||||
if let Some(mut element) = request_layout.trigger_element.take() {
|
||||
element.paint(window, cx);
|
||||
}
|
||||
|
||||
if let Some(mut element) = request_layout.popover_element.take() {
|
||||
element.paint(window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// When mouse click down in the trigger bounds, open the popover.
|
||||
let Some(content_build) = this.content.take() else {
|
||||
return;
|
||||
};
|
||||
let old_content_view = element_state.content_view.clone();
|
||||
let hitbox_id = prepaint.hitbox.id;
|
||||
let mouse_button = this.mouse_button;
|
||||
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& event.button == mouse_button
|
||||
&& hitbox_id.is_hovered(window)
|
||||
{
|
||||
cx.stop_propagation();
|
||||
window.prevent_default();
|
||||
|
||||
let new_content_view = (content_build)(window, cx);
|
||||
let old_content_view1 = old_content_view.clone();
|
||||
|
||||
let previous_focus_handle = window.focused(cx);
|
||||
|
||||
window
|
||||
.subscribe(
|
||||
&new_content_view,
|
||||
cx,
|
||||
move |dialog, _: &DismissEvent, window, cx| {
|
||||
if dialog.focus_handle(cx).contains_focused(window, cx) {
|
||||
if let Some(previous_focus_handle) =
|
||||
previous_focus_handle.as_ref()
|
||||
{
|
||||
window.focus(previous_focus_handle);
|
||||
}
|
||||
}
|
||||
*old_content_view1.borrow_mut() = None;
|
||||
|
||||
window.refresh();
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
window.focus(&new_content_view.focus_handle(cx));
|
||||
*old_content_view.borrow_mut() = Some(new_content_view);
|
||||
window.refresh();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,62 +10,59 @@ Popover component for displaying floating content that appears when interacting
|
|||
## Import
|
||||
|
||||
```rust
|
||||
use gpui_component::popover::{Popover, PopoverContent};
|
||||
use gpui_component::popover::{Popover};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Popover
|
||||
|
||||
:::info
|
||||
Any element that implements [Selectable] can be used as a trigger, for example, a [Button].
|
||||
|
||||
Any element that implements [RenderOnce] or [Render] can be used as popover content, use `.child(...)` to add children directly.
|
||||
:::
|
||||
|
||||
```rust
|
||||
use gpui::ParentElement as _;
|
||||
use gpui_component::{button::Button, popover::Popover};
|
||||
|
||||
Popover::new("basic-popover")
|
||||
.trigger(Button::new("trigger").label("Click me").outline())
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, _| {
|
||||
div()
|
||||
.p_4()
|
||||
.child("Hello, this is a popover!")
|
||||
.into_any()
|
||||
})
|
||||
})
|
||||
})
|
||||
.child("Hello, this is a popover!")
|
||||
.child("It appears when you click the button.")
|
||||
```
|
||||
|
||||
### Popover with Custom Positioning
|
||||
|
||||
The `anchor` method allows you to specify where the popover appears relative to the trigger element, this anchor point can be one of the four corners: TopLeft, TopRight, BottomLeft, BottomRight.
|
||||
|
||||
```rust
|
||||
use gpui::Corner;
|
||||
|
||||
Popover::new("positioned-popover")
|
||||
.anchor(Corner::TopRight)
|
||||
.trigger(Button::new("top-right").label("Top Right").outline())
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, _| {
|
||||
div()
|
||||
.p_4()
|
||||
.w_64()
|
||||
.child("This popover appears at the top right")
|
||||
.into_any()
|
||||
})
|
||||
})
|
||||
})
|
||||
.child("This popover appears at the top right")
|
||||
```
|
||||
|
||||
### Form in Popover
|
||||
### View in Popover
|
||||
|
||||
You can add any `Entity<T>` that implemented [Render] as the popover content.
|
||||
|
||||
```rust
|
||||
let form = Form::new(window, cx);
|
||||
let view = cx.new(|_| MyView::new());
|
||||
|
||||
Popover::new("form-popover")
|
||||
.anchor(Corner::BottomLeft)
|
||||
.trigger(Button::new("show-form").label("Open Form").outline())
|
||||
.content(move |_, _| form.clone())
|
||||
.child(view.clone())
|
||||
```
|
||||
|
||||
### Right-Click Popover
|
||||
|
||||
Sometimes you may want to show a popover on right-click, for example, to create a special your ownen context menu. The `mouse_button` method allows you to specify which mouse button triggers the popover.
|
||||
|
||||
```rust
|
||||
use gpui::MouseButton;
|
||||
|
||||
|
|
@ -73,595 +70,98 @@ Popover::new("context-menu")
|
|||
.anchor(Corner::BottomRight)
|
||||
.mouse_button(MouseButton::Right)
|
||||
.trigger(Button::new("right-click").label("Right Click Me").outline())
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child("Context Menu")
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
Button::new("action")
|
||||
.label("Perform Action")
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
window.push_notification("Action performed!", cx);
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
)
|
||||
.into_any()
|
||||
})
|
||||
.p_4()
|
||||
})
|
||||
})
|
||||
.child("Context Menu")
|
||||
.child(Divider::horizontal())
|
||||
.child("This is a custom context menu.")
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
### Dismiss Popover manually
|
||||
|
||||
### Rich Content Popover
|
||||
If you want to dismiss the popover programmatically from within the content, you can emit a `DismissEvent`. In this case, you should use `content` method to create the popover content so you have access to the `cx: &mut Context<PopoverState>`.
|
||||
|
||||
```rust
|
||||
Popover::new("rich-content")
|
||||
.trigger(Button::new("info").icon(IconName::Info).outline())
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, cx| {
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.max_w(px(400.))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(Icon::new(IconName::Info).size_5())
|
||||
.child("Information")
|
||||
.text_lg()
|
||||
.font_semibold()
|
||||
)
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
div()
|
||||
.child("This is detailed information about the feature.")
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("learn-more")
|
||||
.label("Learn More")
|
||||
.small()
|
||||
.primary()
|
||||
)
|
||||
.child(
|
||||
Button::new("close")
|
||||
.label("Close")
|
||||
.small()
|
||||
.on_click(cx.listener(|_, _, _, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
)
|
||||
)
|
||||
.into_any()
|
||||
})
|
||||
.p_4()
|
||||
})
|
||||
use gpui_component::{DismissEvent, popover::Popover};
|
||||
|
||||
Popover::new("dismiss-popover")
|
||||
.trigger(Button::new("dismiss").label("Dismiss Popover").outline())
|
||||
.content(|_, cx| {
|
||||
div()
|
||||
.child("Click the button below to dismiss this popover.")
|
||||
.child(
|
||||
Button::new("close-btn")
|
||||
.label("Close Popover")
|
||||
.on_click(cx.listener(|_, _, _, cx| {
|
||||
// NOTE: Here `cx` is `&mut Context<PopoverState>` type, so we can emit DismissEvent.
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
### Unstyled Popover
|
||||
### Styling Popover
|
||||
|
||||
Like the others components in GPUI Component, the `appearance(false)` method can be used to disable the default styling of the popover, allowing you to fully customize its appearance.
|
||||
|
||||
And the `Popover` has implemented the [Styled] trait, so you can use all the styling methods provided by GPUI to style the popover content as you like.
|
||||
|
||||
```rust
|
||||
// For custom styled popovers or when you want full control
|
||||
Popover::new("custom-popover")
|
||||
.appearance(false)
|
||||
.trigger(Button::new("custom").label("Custom Style"))
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, cx| {
|
||||
div()
|
||||
.bg(cx.theme().accent)
|
||||
.text_color(cx.theme().accent_foreground)
|
||||
.p_6()
|
||||
.rounded_xl()
|
||||
.shadow_2xl()
|
||||
.child("Fully custom styled popover")
|
||||
.into_any()
|
||||
})
|
||||
})
|
||||
})
|
||||
.bg(cx.theme().accent)
|
||||
.text_color(cx.theme().accent_foreground)
|
||||
.p_6()
|
||||
.rounded_xl()
|
||||
.shadow_2xl()
|
||||
.child("Fully custom styled popover")
|
||||
```
|
||||
|
||||
### Popover with Different Triggers
|
||||
### Control Open State
|
||||
|
||||
There have `open` and `on_open_change` methods to control the open state of the popover programmatically.
|
||||
|
||||
This is useful when you want to synchronize the popover's open state with other UI elements or application state.
|
||||
|
||||
:::tip
|
||||
When you use `open` to control the popover's open state, that means you have take full control of it,
|
||||
so you need to update the state in `on_open_change` callback to keep the popover working correctly.
|
||||
:::
|
||||
|
||||
```rust
|
||||
// Button trigger
|
||||
Popover::new("button-trigger")
|
||||
.trigger(Button::new("btn").label("Button Trigger"))
|
||||
.content(content_fn)
|
||||
use gpui_component::popover::Popover;
|
||||
|
||||
// Custom element trigger
|
||||
Popover::new("div-trigger")
|
||||
.trigger(
|
||||
div()
|
||||
.p_2()
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(px(4.))
|
||||
.child("Click this div")
|
||||
.cursor_pointer()
|
||||
)
|
||||
.content(content_fn)
|
||||
|
||||
// Icon trigger
|
||||
Popover::new("icon-trigger")
|
||||
.trigger(Icon::new(IconName::HelpCircle).size_5())
|
||||
.content(content_fn)
|
||||
```
|
||||
|
||||
### Dismissible Popover with Actions
|
||||
|
||||
```rust
|
||||
Popover::new("action-popover")
|
||||
.trigger(Button::new("actions").label("Show Actions"))
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child("Choose an action:")
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.label("Copy")
|
||||
.small()
|
||||
.w_full()
|
||||
.justify_start()
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
window.push_notification("Copied!", cx);
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
)
|
||||
.child(
|
||||
Button::new("paste")
|
||||
.label("Paste")
|
||||
.small()
|
||||
.w_full()
|
||||
.justify_start()
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
window.push_notification("Pasted!", cx);
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
)
|
||||
.child(
|
||||
Button::new("delete")
|
||||
.label("Delete")
|
||||
.small()
|
||||
.w_full()
|
||||
.justify_start()
|
||||
.destructive()
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
window.push_notification("Deleted!", cx);
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
)
|
||||
)
|
||||
.into_any()
|
||||
})
|
||||
.p_2()
|
||||
.min_w(px(120.))
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Trigger Styling
|
||||
|
||||
```rust
|
||||
// Full width trigger
|
||||
Popover::new("full-width")
|
||||
.trigger_style(StyleRefinement {
|
||||
size: Size { width: Some(relative(1.0)), ..Default::default() },
|
||||
..Default::default()
|
||||
})
|
||||
.trigger(Button::new("full").label("Full Width Button"))
|
||||
.content(content_fn)
|
||||
|
||||
// Custom display
|
||||
Popover::new("flex-trigger")
|
||||
.trigger_style(StyleRefinement {
|
||||
display: Some(Display::Flex),
|
||||
..Default::default()
|
||||
})
|
||||
.trigger(Button::new("flex").label("Flex Button"))
|
||||
.content(content_fn)
|
||||
```
|
||||
|
||||
## Positioning and Anchoring
|
||||
|
||||
### Anchor Positions
|
||||
|
||||
```rust
|
||||
use gpui::Corner;
|
||||
|
||||
// Top left (default)
|
||||
.anchor(Corner::TopLeft) // Popover appears below trigger, aligned to left
|
||||
|
||||
// Top right
|
||||
.anchor(Corner::TopRight) // Popover appears below trigger, aligned to right
|
||||
|
||||
// Bottom left
|
||||
.anchor(Corner::BottomLeft) // Popover appears above trigger, aligned to left
|
||||
|
||||
// Bottom right
|
||||
.anchor(Corner::BottomRight) // Popover appears above trigger, aligned to right
|
||||
```
|
||||
|
||||
### Positioning Behavior
|
||||
|
||||
The popover automatically:
|
||||
|
||||
- Snaps to window edges with 8px margin
|
||||
- Adjusts position to stay within viewport
|
||||
- Resolves anchor position relative to trigger bounds
|
||||
- Handles collision detection with window boundaries
|
||||
|
||||
## Trigger Methods
|
||||
|
||||
### Mouse Button Configuration
|
||||
|
||||
```rust
|
||||
use gpui::MouseButton;
|
||||
|
||||
// Left click (default)
|
||||
.mouse_button(MouseButton::Left)
|
||||
|
||||
// Right click for context menus
|
||||
.mouse_button(MouseButton::Right)
|
||||
|
||||
// Middle click
|
||||
.mouse_button(MouseButton::Middle)
|
||||
```
|
||||
|
||||
### Selectable Triggers
|
||||
|
||||
The trigger element must implement the `Selectable` trait. Most UI components like `Button`, `div`, etc. support this:
|
||||
|
||||
```rust
|
||||
// Button automatically supports selection state
|
||||
.trigger(Button::new("btn").label("Click me"))
|
||||
|
||||
// Custom elements with selection state
|
||||
.trigger(my_custom_element.selected(is_selected))
|
||||
```
|
||||
|
||||
## Custom Content
|
||||
|
||||
### PopoverContent Builder
|
||||
|
||||
The `PopoverContent` provides a flexible way to create popover content:
|
||||
|
||||
```rust
|
||||
PopoverContent::new(window, cx, |window, cx| {
|
||||
// Return any element that implements IntoElement
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child("Content goes here")
|
||||
.child(Button::new("action").label("Action"))
|
||||
.into_any()
|
||||
})
|
||||
```
|
||||
|
||||
### Content Styling
|
||||
|
||||
PopoverContent can be styled using the `Styled` trait:
|
||||
|
||||
```rust
|
||||
PopoverContent::new(window, cx, content_fn)
|
||||
.p_6() // Custom padding
|
||||
.max_w(px(500.)) // Maximum width
|
||||
.bg(cx.theme().card) // Custom background
|
||||
```
|
||||
|
||||
### Reusable Content Components
|
||||
|
||||
```rust
|
||||
// Create reusable content components
|
||||
struct InfoPopover {
|
||||
title: String,
|
||||
description: String,
|
||||
struct MyView {
|
||||
popover_open: bool,
|
||||
}
|
||||
|
||||
impl InfoPopover {
|
||||
fn render(&self, _: &mut Window, cx: &mut Context<PopoverContent>) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.text_lg()
|
||||
.font_semibold()
|
||||
.child(&self.title)
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(&self.description)
|
||||
)
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
|
||||
// Use in popover
|
||||
.content(|window, cx| {
|
||||
let info = InfoPopover {
|
||||
title: "Feature Info".to_string(),
|
||||
description: "This feature helps you...".to_string(),
|
||||
};
|
||||
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, move |window, cx| {
|
||||
info.render(window, cx)
|
||||
})
|
||||
})
|
||||
})
|
||||
Popover::new("controlled-popover")
|
||||
.open(self.open)
|
||||
.on_open_change(cx.listener(|this, open: &bool, _, cx| {
|
||||
this.popover_open = *open;
|
||||
cx.notify();
|
||||
}))
|
||||
.trigger(Button::new("control-btn").label("Control Popover").outline())
|
||||
.child("This popover's open state is controlled programmatically.")
|
||||
```
|
||||
|
||||
### Default Styling
|
||||
### Default Open
|
||||
|
||||
When not using `no_style()`, popovers automatically apply:
|
||||
The `default_open` method allows you to set the initial open state of the popover when it is first rendered.
|
||||
|
||||
Please note that if you use the `open` method to control the popover's open state, the `default_open` setting will be ignored.
|
||||
|
||||
```rust
|
||||
.bg(cx.theme().popover) // Background color
|
||||
.text_color(cx.theme().popover_foreground) // Text color
|
||||
.border_1() // 1px border
|
||||
.border_color(cx.theme().border) // Border color
|
||||
.shadow_lg() // Large shadow
|
||||
use gpui_component::popover::Popover;
|
||||
|
||||
Popover::new("default-open-popover")
|
||||
.default_open(true)
|
||||
.trigger(Button::new("default-open-btn").label("Default Open").outline())
|
||||
.child("This popover is open by default when first rendered.")
|
||||
```
|
||||
|
||||
### Dismissal Events
|
||||
|
||||
Popovers can be dismissed by:
|
||||
|
||||
- Clicking outside the popover (when styled)
|
||||
- Pressing the Escape key
|
||||
- Emitting a `DismissEvent` from content
|
||||
|
||||
```rust
|
||||
// Emit DismissEvent to close popover
|
||||
cx.emit(DismissEvent)
|
||||
|
||||
// Subscribe to dismissal in content
|
||||
window.subscribe(&content_view, cx, |_, _: &DismissEvent, window, cx| {
|
||||
// Handle popover dismissal
|
||||
});
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Tooltip-style Popover
|
||||
|
||||
```rust
|
||||
Popover::new("tooltip")
|
||||
.trigger(
|
||||
div()
|
||||
.child("Hover me")
|
||||
.p_2()
|
||||
.cursor_help()
|
||||
)
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, _| {
|
||||
div()
|
||||
.p_2()
|
||||
.text_xs()
|
||||
.child("This is helpful information")
|
||||
.into_any()
|
||||
})
|
||||
.max_w(px(200.))
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Dropdown Menu
|
||||
|
||||
```rust
|
||||
Popover::new("dropdown")
|
||||
.anchor(Corner::BottomLeft)
|
||||
.trigger(
|
||||
Button::new("menu")
|
||||
.label("Menu")
|
||||
.icon_after(IconName::ChevronDown)
|
||||
)
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, cx| {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(menu_item("New File", IconName::Plus, cx))
|
||||
.child(menu_item("Open File", IconName::FolderOpen, cx))
|
||||
.child(Divider::horizontal())
|
||||
.child(menu_item("Settings", IconName::Settings, cx))
|
||||
.into_any()
|
||||
})
|
||||
.p_1()
|
||||
.min_w(px(150.))
|
||||
})
|
||||
})
|
||||
|
||||
fn menu_item(label: &str, icon: IconName, cx: &Context<PopoverContent>) -> impl IntoElement {
|
||||
Button::new(label.to_lowercase().replace(" ", "-"))
|
||||
.icon(icon)
|
||||
.label(label)
|
||||
.small()
|
||||
.ghost()
|
||||
.w_full()
|
||||
.justify_start()
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
window.push_notification(format!("{} clicked", label), cx);
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
### Confirmation Popover
|
||||
|
||||
```rust
|
||||
Popover::new("confirm-delete")
|
||||
.anchor(Corner::TopRight)
|
||||
.trigger(
|
||||
Button::new("delete")
|
||||
.icon(IconName::Trash)
|
||||
.destructive()
|
||||
)
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, cx| {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(Icon::new(IconName::AlertTriangle).text_color(cx.theme().warning))
|
||||
.child("Confirm Deletion")
|
||||
.font_semibold()
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.child("Are you sure you want to delete this item? This action cannot be undone.")
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.small()
|
||||
.on_click(cx.listener(|_, _, _, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
)
|
||||
.child(
|
||||
Button::new("confirm")
|
||||
.label("Delete")
|
||||
.small()
|
||||
.destructive()
|
||||
.on_click(cx.listener(|_, _, window, cx| {
|
||||
window.push_notification("Item deleted", cx);
|
||||
cx.emit(DismissEvent);
|
||||
}))
|
||||
)
|
||||
)
|
||||
.into_any()
|
||||
})
|
||||
.p_4()
|
||||
.max_w(px(300.))
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### User Profile Popover
|
||||
|
||||
```rust
|
||||
Popover::new("user-profile")
|
||||
.anchor(Corner::BottomRight)
|
||||
.trigger(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.rounded(px(6.))
|
||||
.hover(|this, cx| this.bg(cx.theme().muted))
|
||||
.cursor_pointer()
|
||||
.child(Avatar::new("user").name("John Doe").size(Size::Small))
|
||||
.child("John Doe")
|
||||
)
|
||||
.content(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
PopoverContent::new(window, cx, |_, cx| {
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.child(Avatar::new("user").name("John Doe"))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child("John Doe")
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child("john.doe@example.com")
|
||||
)
|
||||
)
|
||||
)
|
||||
.child(Divider::horizontal())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(profile_menu_item("Profile", IconName::User, cx))
|
||||
.child(profile_menu_item("Settings", IconName::Settings, cx))
|
||||
.child(profile_menu_item("Help", IconName::HelpCircle, cx))
|
||||
.child(Divider::horizontal())
|
||||
.child(profile_menu_item("Sign Out", IconName::LogOut, cx))
|
||||
)
|
||||
.into_any()
|
||||
})
|
||||
.p_3()
|
||||
.w(px(240.))
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Efficient Content Creation
|
||||
|
||||
```rust
|
||||
// Good: Lazy content creation
|
||||
.content(|window, cx| {
|
||||
// Content is only created when popover opens
|
||||
cx.new(|cx| expensive_content_creation(window, cx))
|
||||
})
|
||||
|
||||
// Avoid: Pre-creating content
|
||||
let content = expensive_content_creation(); // Created immediately
|
||||
.content(move |_, _| content.clone())
|
||||
```
|
||||
|
||||
### Memory Management
|
||||
|
||||
```rust
|
||||
// The popover automatically manages content lifecycle:
|
||||
// - Content is created when popover opens
|
||||
// - Content is destroyed when popover closes
|
||||
// - No memory leaks from unclosed popovers
|
||||
|
||||
// For complex content, consider cleanup:
|
||||
window.subscribe(&content_view, cx, |_, _: &DismissEvent, _, _| {
|
||||
// Cleanup resources when popover closes
|
||||
});
|
||||
```
|
||||
|
||||
### Styling Performance
|
||||
|
||||
```rust
|
||||
// Good: Use theme colors for consistency
|
||||
.bg(cx.theme().popover)
|
||||
.text_color(cx.theme().popover_foreground)
|
||||
|
||||
// Good: Minimal custom styling
|
||||
PopoverContent::new(window, cx, content_fn)
|
||||
.p_4() // Simple padding
|
||||
|
||||
// Avoid: Complex nested styling in hot paths
|
||||
```
|
||||
[Button]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.Button.html
|
||||
[Selectable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Selectable.html
|
||||
[Render]: https://docs.rs/gpui/latest/gpui/trait.Render.html
|
||||
[RenderOnce]: https://docs.rs/gpui/latest/gpui/trait.RenderOnce.html
|
||||
[Styled]: https://docs.rs/gpui/latest/gpui/trait.Styled.html
|
||||
|
|
|
|||
Loading…
Reference in a new issue