From 93779164f03f57eec5e4d9ca147e03723261aba9 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 29 Jun 2024 23:49:59 +0800 Subject: [PATCH] Rewrite Popover (#7) https://github.com/huacnlee/gpui-app/assets/5518/dfe0ba05-ecb7-4604-a057-56daed53c16c --- README.md | 1 - crates/ui-story/src/popover_story.rs | 160 ++++++++--- crates/ui/src/button.rs | 12 + crates/ui/src/popover.rs | 409 ++++++++++++++------------- crates/ui/src/styled_ext.rs | 14 +- 5 files changed, 348 insertions(+), 248 deletions(-) diff --git a/README.md b/README.md index 68696890..c5b09506 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,6 @@ There have a part of UI components from [Zed](https://github.com/zed-industries/ - title_bar - picker -- popover (based on popup_menu) > I think we can discuss them with Zed team to change the license to MIT or Apache License for share to community. diff --git a/crates/ui-story/src/popover_story.rs b/crates/ui-story/src/popover_story.rs index 80472ab2..7a124914 100644 --- a/crates/ui-story/src/popover_story.rs +++ b/crates/ui-story/src/popover_story.rs @@ -1,73 +1,147 @@ -use gpui::{div, px, Element, IntoElement, ParentElement as _, Render, Styled as _, ViewContext}; +use gpui::{ + div, px, AnchorCorner, AppContext, DismissEvent, Element, EventEmitter, FocusHandle, + FocusableView, IntoElement, MouseButton, ParentElement as _, Render, Styled as _, View, + ViewContext, VisualContext, WindowContext, +}; use ui::{ button::{Button, ButtonSize}, divider::Divider, h_flex, + input::TextInput, popover::{Popover, PopoverContent}, - v_flex, + v_flex, Clickable, }; use crate::story_case; -pub struct PopoverStory {} +struct Form { + input1: View, +} + +impl Form { + fn new(cx: &mut WindowContext) -> View { + cx.new_view(|cx| Self { + input1: cx.new_view(|cx| TextInput::new(cx)), + }) + } +} + +impl FocusableView for Form { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.input1.focus_handle(cx) + } +} + +impl EventEmitter for Form {} + +impl Render for Form { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .gap_4() + .size_full() + .child("This is a form container.") + .child(self.input1.clone()) + .child( + Button::primary("submit", "Submit") + .on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))), + ) + } +} + +pub struct PopoverStory { + form: View
, +} impl PopoverStory { - pub fn new(_: &mut ViewContext) -> Self { - Self {} + pub fn new(cx: &mut ViewContext) -> Self { + let form = Form::new(cx); + Self { form } } } impl Render for PopoverStory { - fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + let form = self.form.clone(); + story_case( "Popover", "Displays rich content in a portal, triggered by a button.", ) .child( - h_flex().items_center().justify_between().child( - v_flex().gap_4().child( - Popover::new("info-top-left") - .trigger(Button::new("info", "Top Left").width(px(300.))) - .content(|cx| { - PopoverContent::new(|_| { - v_flex().gap_4().child("Hello, this is a Popover.") + h_flex() + .items_center() + .justify_between() + .child( + v_flex().gap_4().child( + Popover::new("info-top-left") + .trigger(Button::new("info-top-left", "Top Left")) + .content(|cx| { + PopoverContent::new(cx, |_| { + v_flex() + .gap_4() + .child("Hello, this is a Popover.") + .child(Divider::horizontal()) + .child( + Button::new("info1", "Yes") + .width(px(80.)) + .size(ButtonSize::Small), + ) + .into_any() + }) + }), + ), + ) + .child( + Popover::new("info-top-right") + .anchor(AnchorCorner::TopRight) + .trigger(Button::new("info-top-right", "Top Right")) + .content(|cx| { + PopoverContent::new(cx, |_| { + v_flex() + .gap_4() + .child("Hello, this is a Popover on the Top Right.") .child(Divider::horizontal()) .child( Button::new("info1", "Yes") .width(px(80.)) - .size(ButtonSize::Small) - ).into_any() - }, - cx, - ) - }) - ).child("This is background text, should be covered by Popover.") - ).child( - Popover::new("info-top-right") - .trigger(Button::new("info", "Top Right").width(px(300.))) - .content(|cx| { - PopoverContent::new(|_| - "Hello, this is a Popover.\nYou can click outside to dissmis.".into_any(), - cx, - ) - }), - ) + .size(ButtonSize::Small), + ) + .into_any() + }) + }), + ), ) .child( div().absolute().bottom_4().left_0().w_full().h_10().child( - h_flex().items_center().justify_between().child( - Popover::new("info-bottom-left") - .trigger(Button::new("pop", "Bottom Left").width(px(300.))) - .content(|cx| { - PopoverContent::new(|_| "这是另外一个 Popover。\n你可以点击外部来关闭。\nThis popover has position bottom_4().left_0().w_full().h_10().".into_any(), cx) - })).child( - Popover::new("info-bottom-right") - .trigger(Button::new("pop", "Bottom Right").width(px(300.))) - .content(|cx| { - PopoverContent::new(|_| "这是另外一个 Popover。\n你可以点击外部来关闭。\nThis popover has position bottom_4().left_0().w_full().h_10().".into_any(), cx) - })) - - + h_flex() + .items_center() + .justify_between() + .child( + Popover::new("info-bottom-left") + .anchor(AnchorCorner::BottomLeft) + .trigger(Button::new("pop", "Popup with Form").width(px(300.))) + .content(move |_| form.clone()), + ) + .child( + Popover::new("info-bottom-right") + .anchor(AnchorCorner::BottomRight) + .mouse_button(MouseButton::Right) + .trigger(Button::new("pop", "Mouse Right Click").width(px(300.))) + .content(|cx| { + PopoverContent::new(cx, |_| { + v_flex() + .gap_4() + .child("Hello, this is a Popover on the Bottom Right.") + .child(Divider::horizontal()) + .child( + Button::new("info1", "Yes") + .width(px(80.)) + .size(ButtonSize::Small), + ) + .into_any() + }) + }), + ), ), ) } diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index 09443147..2bfcb737 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -65,6 +65,18 @@ impl Button { } } + pub fn primary(id: impl Into, label: impl Into) -> Self { + Self::new(id, label).style(ButtonStyle::Primary) + } + + pub fn danger(id: impl Into, label: impl Into) -> Self { + Self::new(id, label).style(ButtonStyle::Danger) + } + + pub fn small(id: impl Into, label: impl Into) -> Self { + Self::new(id, label).size(ButtonSize::Small) + } + pub fn width(mut self, width: impl Into) -> Self { self.width = Some(width.into()); self diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 7a8111fc..ef989fe8 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -1,21 +1,17 @@ use std::{cell::RefCell, rc::Rc}; -use crate::theme::ActiveTheme; -use crate::{Clickable, Selectable, StyledExt}; +use crate::{theme::ActiveTheme, Selectable, StyledExt as _}; use gpui::{ - actions, anchored, deferred, div, prelude::FluentBuilder as _, AnchorCorner, AnyElement, - AppContext, Bounds, DismissEvent, Element, ElementId, EventEmitter, FocusHandle, FocusableView, - HitboxId, InteractiveElement, IntoElement, LayoutId, ManagedView, ParentElement, Pixels, - Render, Style, Styled, View, ViewContext, VisualContext, WindowContext, + actions, anchored, deferred, div, prelude::FluentBuilder, AnchorCorner, AnyElement, AppContext, + Bounds, DismissEvent, DispatchPhase, Element, ElementId, EventEmitter, FocusHandle, + FocusableView, GlobalElementId, Hitbox, InteractiveElement, IntoElement, LayoutId, ManagedView, + MouseButton, MouseDownEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, + View, ViewContext, VisualContext, WindowContext, }; -use gpui::{point, px, DispatchPhase, MouseDownEvent, Point}; -actions!(popover, [Dismiss]); +actions!(popover, [Open, Dismiss]); -pub trait Triggerable: IntoElement + Clickable + Selectable + 'static {} -impl Triggerable for T {} - -pub fn init(_cx: &mut AppContext) {} +pub fn init(_cx: &AppContext) {} pub struct PopoverContent { focus_handle: FocusHandle, @@ -23,7 +19,7 @@ pub struct PopoverContent { } impl PopoverContent { - pub fn new(content: B, cx: &mut WindowContext) -> View + pub fn new(cx: &mut WindowContext, content: B) -> View where B: Fn(&mut WindowContext) -> AnyElement + 'static, { @@ -47,102 +43,72 @@ impl FocusableView for PopoverContent { impl Render for PopoverContent { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() - .id("test") - // .on_action(cx.listener(Self::dissmiss)) - .absolute() - .mt_2() - .elevation_2(cx) - .bg(cx.theme().popover) - .border_1() - .border_color(cx.theme().border) - .p_4() - .max_w_128() - .w_80() - .occlude() - .child(self.content.clone()(cx)) - .on_mouse_down_out(cx.listener(|_, _, cx| cx.emit(DismissEvent))) + div().child(self.content.clone()(cx)) } } pub struct Popover { id: ElementId, anchor: AnchorCorner, - trigger_builder: Option< - Box< - dyn FnOnce( - Rc>>>, - Option View + 'static>>, - ) -> AnyElement - + 'static, - >, - >, - content_builder: Option View + 'static>>, + trigger: Option AnyElement + 'static>>, + content: Option View + 'static>>, + mouse_button: MouseButton, } -impl Popover { +impl Popover +where + M: ManagedView, +{ pub fn new(id: impl Into) -> Self { Self { id: id.into(), - trigger_builder: None, - content_builder: None, anchor: AnchorCorner::TopLeft, + trigger: None, + content: None, + mouse_button: MouseButton::Left, } } - pub fn trigger(mut self, trigger: T) -> Self { - self.trigger_builder = Some(Box::new(|popover, builder| { - let open = popover.borrow_mut().is_some(); - trigger - .selected(open) - .when_some(builder, |this, builder| { - this.on_click(move |_, cx| Self::show_popover(&builder, &popover, cx)) - }) - .into_any_element() - })); - - self - } - - pub fn content( - mut self, - content_builder: impl Fn(&mut WindowContext) -> View + 'static, - ) -> Self { - self.content_builder = Some(Rc::new(content_builder)); - self - } - - /// anchor defines which corner of the menu to anchor to the attachment point - /// (by default the cursor position, but see attach) pub fn anchor(mut self, anchor: AnchorCorner) -> Self { self.anchor = anchor; self } - fn show_popover( - builder: &Rc View + 'static>, - popover: &Rc>>>, - cx: &mut WindowContext, - ) { - let new_popover = (builder)(cx); + /// Set the mouse button to trigger the popover, default is `MouseButton::Left`. + pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self { + self.mouse_button = mouse_button; + self + } - let popover_cloned = popover.clone(); - let prev_focus_handle = cx.focused(); + pub fn trigger(mut self, trigger: T) -> Self + where + T: Selectable + IntoElement + 'static, + { + self.trigger = Some(Box::new(|_| trigger.into_any_element())); + self + } - cx.subscribe(&new_popover, move |modal, _: &DismissEvent, cx| { - if modal.focus_handle(cx).contains_focused(cx) { - if let Some(prev_focus_handle) = prev_focus_handle.as_ref() { - cx.focus(prev_focus_handle); - } - } - *popover_cloned.borrow_mut() = None; - cx.refresh(); - }) - .detach(); + /// Set the content of the popover. + /// + /// The `content` is a closure that returns an `AnyElement`. + pub fn content(mut self, content: C) -> Self + where + C: Fn(&mut WindowContext) -> View + 'static, + { + self.content = Some(Rc::new(content)); + self + } - cx.focus_view(&new_popover); - *popover.borrow_mut() = Some(new_popover); - cx.refresh(); + fn render_trigger(&mut self, cx: &mut WindowContext) -> impl IntoElement { + let base = div().id("popover-trigger"); + + if self.trigger.is_none() { + return base; + } + + let trigger = self.trigger.take().unwrap(); + + base.child((trigger)(cx)).into_element() } fn resolved_corner(&self, bounds: Bounds) -> Point { @@ -155,45 +121,27 @@ impl Popover { .corner(bounds) } - fn resolved_offset(&self, _cx: &WindowContext) -> Point { - let offset = px(0.); - match self.anchor { - AnchorCorner::TopRight | AnchorCorner::BottomRight => point(offset, px(0.)), - AnchorCorner::TopLeft | AnchorCorner::BottomLeft => point(-offset, px(0.)), - } + fn with_element_state( + &mut self, + id: &GlobalElementId, + cx: &mut WindowContext, + f: impl FnOnce(&mut Self, &mut PopoverElementState, &mut WindowContext) -> R, + ) -> R { + cx.with_optional_element_state::, _>( + Some(id), + |element_state, cx| { + let mut element_state = element_state.unwrap().unwrap_or_default(); + let result = f(self, &mut element_state, cx); + (result, Some(element_state)) + }, + ) } } -pub struct PopoverElementState { - trigger_bounds: Option>, - popover: Rc>>>, -} - -impl Clone for PopoverElementState { - fn clone(&self) -> Self { - Self { - popover: Rc::clone(&self.popover), - trigger_bounds: self.trigger_bounds, - } - } -} - -impl Default for PopoverElementState { - fn default() -> Self { - Self { - popover: Rc::default(), - trigger_bounds: None, - } - } -} - -pub struct PopoverFrameState { - trigger_layout_id: Option, - trigger_element: Option, - popover_element: Option, -} - -impl IntoElement for Popover { +impl IntoElement for Popover +where + M: ManagedView, +{ type Element = Self; fn into_element(self) -> Self::Element { @@ -201,9 +149,37 @@ impl IntoElement for Popover { } } +pub struct PopoverElementState { + trigger_layout_id: Option, + popover_element: Option, + trigger_element: Option, + content_view: Rc>>>, + /// Trigger bounds for positioning the popover. + trigger_bounds: Option>, +} + +impl Default for PopoverElementState { + fn default() -> Self { + Self { + trigger_layout_id: None, + popover_element: None, + trigger_element: None, + content_view: Rc::new(RefCell::new(None)), + trigger_bounds: None, + } + } +} + +pub struct PrepaintState { + hitbox: Hitbox, + /// Trigger bounds for limit a rect to handle mouse click. + trigger_bounds: Option>, +} + impl Element for Popover { - type RequestLayoutState = PopoverFrameState; - type PrepaintState = Option; + type RequestLayoutState = PopoverElementState; + + type PrepaintState = PrepaintState; fn id(&self) -> Option { Some(self.id.clone()) @@ -211,109 +187,150 @@ impl Element for Popover { fn request_layout( &mut self, - global_id: Option<&gpui::GlobalElementId>, + id: Option<&gpui::GlobalElementId>, cx: &mut WindowContext, ) -> (gpui::LayoutId, Self::RequestLayoutState) { - cx.with_element_state( - global_id.unwrap(), - |element_state: Option>, cx| { - let element_state = element_state.unwrap_or_default(); + self.with_element_state(id.unwrap(), cx, |this, element_state, cx| { + let mut trigger_element = this.render_trigger(cx).into_any_element(); + let trigger_layout_id = trigger_element.request_layout(cx); - let mut popover_layout_id = None; - let popover_element = element_state.popover.borrow_mut().as_mut().map(|popover| { - let mut anchored = anchored().snap_to_window().anchor(self.anchor); - if let Some(trigger_bounds) = element_state.trigger_bounds { - anchored = anchored.position( - self.resolved_corner(trigger_bounds) + self.resolved_offset(cx), - ); - } - let mut element = deferred(anchored.child(popover.clone())) - .with_priority(1) - .into_any(); + let mut popover_layout_id = None; + let mut popover_element = None; - popover_layout_id = Some(element.request_layout(cx)); - element - }); + if let Some(content_view) = element_state.content_view.borrow_mut().as_mut() { + let content_view_mut = element_state.content_view.clone(); - let mut trigger_element = self.trigger_builder.take().map(|builder| { - builder(element_state.popover.clone(), self.content_builder.clone()) - }); + let mut anchored = anchored().snap_to_window().anchor(this.anchor); + if let Some(trigger_bounds) = element_state.trigger_bounds { + anchored = anchored.position(this.resolved_corner(trigger_bounds)); + } - let trigger_layout_id = trigger_element - .as_mut() - .map(|trigger_element| trigger_element.request_layout(cx)); - - let layout_id = cx.request_layout( - Style::default(), - popover_layout_id.into_iter().chain(trigger_layout_id), - ); - - ( - ( - layout_id, - PopoverFrameState { - trigger_layout_id, - trigger_element, - popover_element, - }, + let mut element = deferred( + anchored.child( + div() + .occlude() + .map(|d| match this.anchor { + AnchorCorner::TopLeft | AnchorCorner::TopRight => d.mt_2(), + AnchorCorner::BottomLeft | AnchorCorner::BottomRight => d.mb_2(), + }) + .elevation_2(cx) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .p_4() + .max_w_128() + .occlude() + .on_mouse_down_out(move |_, cx| { + // Update the element_state.content_view to `None`, + // so that the `paint`` method will not paint it. + *content_view_mut.borrow_mut() = None; + cx.refresh(); + }) + .child(content_view.clone()), ), - element_state, ) - }, - ) + .with_priority(1) + .into_any(); + + popover_layout_id = Some(element.request_layout(cx)); + popover_element = Some(element); + } + + let layout_id = cx.request_layout( + Style::default(), + popover_layout_id.into_iter().chain(Some(trigger_layout_id)), + ); + + ( + layout_id, + PopoverElementState { + trigger_layout_id: Some(trigger_layout_id), + popover_element: popover_element, + trigger_element: Some(trigger_element), + ..Default::default() + }, + ) + }) } fn prepaint( &mut self, - global_id: Option<&gpui::GlobalElementId>, - _: gpui::Bounds, + _id: Option<&gpui::GlobalElementId>, + _bounds: gpui::Bounds, request_layout: &mut Self::RequestLayoutState, cx: &mut WindowContext, ) -> Self::PrepaintState { if let Some(element) = &mut request_layout.trigger_element { element.prepaint(cx); } - if let Some(element) = &mut request_layout.popover_element { element.prepaint(cx); } - request_layout.trigger_layout_id.map(|layout_id| { - let bounds = cx.layout_bounds(layout_id); - cx.with_element_state(global_id.unwrap(), |element_state, _cx| { - let mut element_state: PopoverElementState = element_state.unwrap(); - element_state.trigger_bounds = Some(bounds); - ((), element_state) - }); + let trigger_bounds = request_layout + .trigger_layout_id + .map(|id| cx.layout_bounds(id)); + let hitbox = cx.insert_hitbox(trigger_bounds.unwrap_or_default(), false); - cx.insert_hitbox(bounds, false).id - }) + PrepaintState { + trigger_bounds, + hitbox, + } } fn paint( &mut self, - _: Option<&gpui::GlobalElementId>, - _: gpui::Bounds, + id: Option<&gpui::GlobalElementId>, + _bounds: gpui::Bounds, request_layout: &mut Self::RequestLayoutState, - trigger_hitbox: &mut Option, + prepaint: &mut Self::PrepaintState, cx: &mut WindowContext, ) { - if let Some(element) = &mut request_layout.trigger_element { - element.paint(cx); - } + self.with_element_state(id.unwrap(), cx, |this, element_state, cx| { + element_state.trigger_bounds = prepaint.trigger_bounds; - if let Some(element) = &mut request_layout.popover_element { - element.paint(cx); - - if let Some(hitbox) = *trigger_hitbox { - // Mouse-downing outside the menu dismisses it, so we don't - // want a click on the toggle to re-open it. - cx.on_mouse_event(move |_: &MouseDownEvent, phase, cx| { - if phase == DispatchPhase::Bubble && hitbox.is_hovered(cx) { - cx.stop_propagation() - } - }) + if let Some(mut element) = request_layout.trigger_element.take() { + element.paint(cx); } - } + + if let Some(mut element) = request_layout.popover_element.take() { + element.paint(cx); + return; + } + + 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; + // When mouse click down in the trigger bounds, open the popover. + cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| { + if phase == DispatchPhase::Bubble + && event.button == mouse_button + && hitbox_id.is_hovered(cx) + { + let new_content_view = (content_build)(cx); + let old_content_view1 = old_content_view.clone(); + + let previous_focus_handle = cx.focused(); + cx.subscribe(&new_content_view, move |modal, _: &DismissEvent, cx| { + if modal.focus_handle(cx).contains_focused(cx) { + if let Some(previous_focus_handle) = previous_focus_handle.as_ref() { + cx.focus(previous_focus_handle); + } + } + *old_content_view1.borrow_mut() = None; + cx.refresh(); + }) + .detach(); + + cx.focus_view(&new_content_view); + *old_content_view.borrow_mut() = Some(new_content_view); + cx.refresh(); + } + }); + }); } } diff --git a/crates/ui/src/styled_ext.rs b/crates/ui/src/styled_ext.rs index 5d4b5270..72997182 100644 --- a/crates/ui/src/styled_ext.rs +++ b/crates/ui/src/styled_ext.rs @@ -38,13 +38,11 @@ impl ElevationIndex { } } -fn elevated(this: E, cx: &mut WindowContext, index: ElevationIndex) -> E { - let theme = cx.theme(); - - this.bg(theme.popover) +fn elevated(this: E, cx: &WindowContext, index: ElevationIndex) -> E { + this.bg(cx.theme().popover) .rounded(px(8.)) .border_1() - .border_color(theme.border) + .border_color(cx.theme().border) .shadow(index.shadow()) } @@ -65,17 +63,17 @@ pub trait StyledExt: Styled + Sized { } /// Located above the app background - fn elevation_1(self, cx: &mut WindowContext) -> Self { + fn elevation_1(self, cx: &WindowContext) -> Self { elevated(self, cx, ElevationIndex::Surface) } /// Appear above most UI elements - fn elevation_2(self, cx: &mut WindowContext) -> Self { + fn elevation_2(self, cx: &WindowContext) -> Self { elevated(self, cx, ElevationIndex::PopoverSurface) } // Above all other UI elements and are located above the wash layer - fn elevation_3(self, cx: &mut WindowContext) -> Self { + fn elevation_3(self, cx: &WindowContext) -> Self { elevated(self, cx, ElevationIndex::ModalSurface) } }