From 3e8efdd0efcbcdd50a0b81e0d81413cbd4026b0e Mon Sep 17 00:00:00 2001 From: reya Date: Thu, 19 Feb 2026 08:17:46 +0700 Subject: [PATCH] update components --- crates/chat/src/lib.rs | 10 + crates/chat/src/room.rs | 2 +- crates/chat_ui/src/lib.rs | 29 +- crates/coop/src/sidebar/entry.rs | 8 - crates/coop/src/workspace.rs | 4 +- crates/dock/src/panel.rs | 2 +- crates/dock/src/tab_panel.rs | 4 +- crates/ui/src/anchored.rs | 333 +++++++++++ crates/ui/src/dropdown.rs | 811 --------------------------- crates/ui/src/geometry.rs | 297 ++++++++++ crates/ui/src/index_path.rs | 69 +++ crates/ui/src/lib.rs | 9 +- crates/ui/src/list/cache.rs | 221 ++++++++ crates/ui/src/list/delegate.rs | 171 ++++++ crates/ui/src/list/list.rs | 780 +++++++++++++++----------- crates/ui/src/list/list_item.rs | 114 ++-- crates/ui/src/list/loading.rs | 2 +- crates/ui/src/list/mod.rs | 22 +- crates/ui/src/list/separator_item.rs | 44 ++ crates/ui/src/menu/app_menu_bar.rs | 102 ++-- crates/ui/src/menu/context_menu.rs | 227 +++++--- crates/ui/src/menu/dropdown_menu.rs | 141 +++++ crates/ui/src/menu/menu_item.rs | 22 +- crates/ui/src/menu/mod.rs | 9 +- crates/ui/src/menu/popup_menu.rs | 707 +++++++++++++---------- crates/ui/src/modal.rs | 7 +- crates/ui/src/popover.rs | 710 +++++++++++------------ crates/ui/src/scroll/scrollable.rs | 367 ++++++------ crates/ui/src/scroll/scrollbar.rs | 367 +++++++----- crates/ui/src/skeleton.rs | 4 +- crates/ui/src/styled.rs | 85 +-- 31 files changed, 3240 insertions(+), 2440 deletions(-) create mode 100644 crates/ui/src/anchored.rs delete mode 100644 crates/ui/src/dropdown.rs create mode 100644 crates/ui/src/geometry.rs create mode 100644 crates/ui/src/index_path.rs create mode 100644 crates/ui/src/list/cache.rs create mode 100644 crates/ui/src/list/delegate.rs create mode 100644 crates/ui/src/list/separator_item.rs create mode 100644 crates/ui/src/menu/dropdown_menu.rs diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 07807b3..ab4b494 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -82,10 +82,20 @@ impl ChatRegistry { /// Create a new chat registry instance fn new(window: &mut Window, cx: &mut Context) -> Self { let nostr = NostrRegistry::global(cx); + let nip65 = nostr.read(cx).nip65_state(); let nip17 = nostr.read(cx).nip17_state(); let mut subscriptions = smallvec![]; + subscriptions.push( + // Observe the nip65 state and load chat rooms on every state change + cx.observe(&nip65, |this, state, cx| { + if state.read(cx).idle() { + this.reset(cx); + } + }), + ); + subscriptions.push( // Observe the nip17 state and load chat rooms on every state change cx.observe(&nip17, |this, _state, cx| { diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 993c533..dbd13d9 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -53,7 +53,7 @@ impl SendReport { /// Returns true if the send was successful. pub fn success(&self) -> bool { if let Some(output) = self.output.as_ref() { - !output.success.is_empty() + !output.failed.is_empty() } else { false } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index f686fdb..2a6f21c 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -26,11 +26,10 @@ use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; -use ui::context_menu::ContextMenuExt; use ui::indicator::Indicator; use ui::input::{InputEvent, InputState, TextInput}; +use ui::menu::{ContextMenuExt, DropdownMenu}; use ui::notification::Notification; -use ui::popup_menu::PopupMenuExt; use ui::{ h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension, @@ -392,7 +391,15 @@ impl ChatPanel { self.reports_by_id .read_blocking() .get(id) - .is_some_and(|reports| reports.iter().all(|r| r.success())) + .is_some_and(|reports| reports.iter().any(|r| r.success())) + } + + /// Check if a message failed to send by its ID + fn sent_failed(&self, id: &EventId) -> bool { + self.reports_by_id + .read_blocking() + .get(id) + .is_some_and(|reports| reports.iter().all(|r| !r.success())) } /// Get all sent reports for a message by its ID @@ -622,6 +629,9 @@ impl ChatPanel { // Check if message is sent successfully let sent_success = self.sent_success(&id); + // Check if message is sent failed + let sent_failed = self.sent_failed(&id); + // Hide avatar setting let hide_avatar = AppSettings::get_hide_avatar(cx); @@ -679,7 +689,7 @@ impl ChatPanel { this.children(self.render_message_replies(replies, cx)) }) .child(rendered_text) - .when(!sent_success, |this| { + .when(sent_failed, |this| { this.child(deferred(self.render_message_reports(&id, cx))) }), ), @@ -966,9 +976,9 @@ impl ChatPanel { .icon(IconName::Ellipsis) .small() .ghost() - .popup_menu({ + .dropdown_menu({ let id = id.to_owned(); - move |this, _, _| this.menu("Seen on", Box::new(SeenOn(id))) + move |this, _window, _cx| this.menu("Seen on", Box::new(SeenOn(id))) }), ) .group_hover("", |this| this.visible()) @@ -1152,7 +1162,8 @@ impl Render for ChatPanel { this.render_message(ix, window, cx) }), ) - .flex_1(), + .flex_1() + .size_full(), ) .child( v_flex() @@ -1192,10 +1203,10 @@ impl Render for ChatPanel { .icon(IconName::Emoji) .ghost() .large() - .popup_menu_with_anchor( + .dropdown_menu_with_anchor( gpui::Corner::BottomLeft, move |this, _window, _cx| { - this.axis(gpui::Axis::Horizontal) + this//.axis(gpui::Axis::Horizontal) .menu("👍", Box::new(Command::Insert("👍"))) .menu("👎", Box::new(Command::Insert("👎"))) .menu("😄", Box::new(Command::Insert("😄"))) diff --git a/crates/coop/src/sidebar/entry.rs b/crates/coop/src/sidebar/entry.rs index e8c25d9..1f5bd35 100644 --- a/crates/coop/src/sidebar/entry.rs +++ b/crates/coop/src/sidebar/entry.rs @@ -1,7 +1,6 @@ use std::rc::Rc; use chat::RoomKind; -use chat_ui::{CopyPublicKey, OpenPublicKey}; use dock::ClosePanel; use gpui::prelude::FluentBuilder; use gpui::{ @@ -12,7 +11,6 @@ use nostr_sdk::prelude::*; use settings::AppSettings; use theme::ActiveTheme; use ui::avatar::Avatar; -use ui::context_menu::ContextMenuExt; use ui::modal::ModalButtonProps; use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension}; @@ -153,12 +151,6 @@ impl RenderOnce for RoomEntry { ), ) .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .when_some(public_key, |this, public_key| { - this.context_menu(move |this, _window, _cx| { - this.menu("View Profile", Box::new(OpenPublicKey(public_key))) - .menu("Copy Public Key", Box::new(CopyPublicKey(public_key))) - }) - }) .when_some(self.handler, |this, handler| { this.on_click(move |event, window, cx| { handler(event, window, cx); diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index e2ee5e3..32057f4 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -16,7 +16,7 @@ use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT}; use titlebar::TitleBar; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; -use ui::popup_menu::PopupMenuExt; +use ui::menu::DropdownMenu; use ui::{h_flex, v_flex, Root, Sizable, WindowExtension}; use crate::panels::greeter; @@ -184,7 +184,7 @@ impl Workspace { .caret() .compact() .transparent() - .popup_menu(move |this, _window, _cx| { + .dropdown_menu(move |this, _window, _cx| { this.label(profile.name()) .separator() .menu("Profile", Box::new(ClosePanel)) diff --git a/crates/dock/src/panel.rs b/crates/dock/src/panel.rs index 00bec24..1e5c8d3 100644 --- a/crates/dock/src/panel.rs +++ b/crates/dock/src/panel.rs @@ -3,7 +3,7 @@ use gpui::{ SharedString, Window, }; use ui::button::Button; -use ui::popup_menu::PopupMenu; +use ui::menu::PopupMenu; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PanelEvent { diff --git a/crates/dock/src/tab_panel.rs b/crates/dock/src/tab_panel.rs index 94d829c..88c3ead 100644 --- a/crates/dock/src/tab_panel.rs +++ b/crates/dock/src/tab_panel.rs @@ -9,7 +9,7 @@ use gpui::{ }; use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT}; use ui::button::{Button, ButtonVariants as _}; -use ui::popup_menu::{PopupMenu, PopupMenuExt}; +use ui::menu::{DropdownMenu, PopupMenu}; use ui::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt}; use crate::dock::DockPlacement; @@ -454,7 +454,7 @@ impl TabPanel { .small() .ghost() .rounded() - .popup_menu({ + .dropdown_menu({ let zoomable = state.zoomable; let closable = state.closable; diff --git a/crates/ui/src/anchored.rs b/crates/ui/src/anchored.rs new file mode 100644 index 0000000..0b471b9 --- /dev/null +++ b/crates/ui/src/anchored.rs @@ -0,0 +1,333 @@ +//! This is a fork of gpui's anchored element that adds support for offsetting +//! https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/elements/anchored.rs +use gpui::{ + point, px, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, Half, + InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style, + Window, +}; +use smallvec::SmallVec; + +use crate::Anchor; + +/// The state that the anchored element element uses to track its children. +pub struct AnchoredState { + child_layout_ids: SmallVec<[LayoutId; 4]>, +} + +/// An anchored element that can be used to display UI that +/// will avoid overflowing the window bounds. +pub(crate) struct Anchored { + children: SmallVec<[AnyElement; 2]>, + anchor_corner: Anchor, + fit_mode: AnchoredFitMode, + anchor_position: Option>, + position_mode: AnchoredPositionMode, + offset: Option>, +} + +/// anchored gives you an element that will avoid overflowing the window bounds. +/// Its children should have no margin to avoid measurement issues. +pub(crate) fn anchored() -> Anchored { + Anchored { + children: SmallVec::new(), + anchor_corner: Anchor::TopLeft, + fit_mode: AnchoredFitMode::SwitchAnchor, + anchor_position: None, + position_mode: AnchoredPositionMode::Window, + offset: None, + } +} + +#[allow(dead_code)] +impl Anchored { + /// Sets which corner of the anchored element should be anchored to the current position. + pub fn anchor(mut self, anchor: Anchor) -> Self { + self.anchor_corner = anchor; + self + } + + /// Sets the position in window coordinates + /// (otherwise the location the anchored element is rendered is used) + pub fn position(mut self, anchor: Point) -> Self { + self.anchor_position = Some(anchor); + self + } + + /// Offset the final position by this amount. + /// Useful when you want to anchor to an element but offset from it, such as in PopoverMenu. + pub fn offset(mut self, offset: Point) -> Self { + self.offset = Some(offset); + self + } + + /// Sets the position mode for this anchored element. Local will have this + /// interpret its [`Anchored::position`] as relative to the parent element. + /// While Window will have it interpret the position as relative to the window. + pub fn position_mode(mut self, mode: AnchoredPositionMode) -> Self { + self.position_mode = mode; + self + } + + /// Snap to window edge instead of switching anchor corner when an overflow would occur. + pub fn snap_to_window(mut self) -> Self { + self.fit_mode = AnchoredFitMode::SnapToWindow; + self + } + + /// Snap to window edge and leave some margins. + pub fn snap_to_window_with_margin(mut self, edges: impl Into>) -> Self { + self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into()); + self + } +} + +impl ParentElement for Anchored { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl Element for Anchored { + type PrepaintState = (); + type RequestLayoutState = AnchoredState; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let child_layout_ids = self + .children + .iter_mut() + .map(|child| child.request_layout(window, cx)) + .collect::>(); + + let anchored_style = Style { + position: Position::Absolute, + display: Display::Flex, + ..Style::default() + }; + + let layout_id = window.request_layout(anchored_style, child_layout_ids.iter().copied(), cx); + + (layout_id, AnchoredState { child_layout_ids }) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) { + if request_layout.child_layout_ids.is_empty() { + return; + } + + let mut child_min = point(Pixels::MAX, Pixels::MAX); + let mut child_max = Point::default(); + for child_layout_id in &request_layout.child_layout_ids { + let child_bounds = window.layout_bounds(*child_layout_id); + child_min = child_min.min(&child_bounds.origin); + child_max = child_max.max(&child_bounds.bottom_right()); + } + let size: Size = (child_max - child_min).into(); + + let (origin, mut desired) = self.position_mode.get_position_and_bounds( + self.anchor_position, + self.anchor_corner, + size, + bounds, + self.offset, + ); + + let limits = Bounds { + origin: Point::default(), + size: window.viewport_size(), + }; + + if self.fit_mode == AnchoredFitMode::SwitchAnchor { + let mut anchor_corner = self.anchor_corner; + + if desired.left() < limits.left() || desired.right() > limits.right() { + let switched = Bounds::from_corner_and_size( + anchor_corner + .other_side_corner_along(Axis::Horizontal) + .into(), + origin, + size, + ); + if !(switched.left() < limits.left() || switched.right() > limits.right()) { + anchor_corner = anchor_corner.other_side_corner_along(Axis::Horizontal); + desired = switched + } + } + + if desired.top() < limits.top() || desired.bottom() > limits.bottom() { + let switched = Bounds::from_corner_and_size( + anchor_corner.other_side_corner_along(Axis::Vertical).into(), + origin, + size, + ); + if !(switched.top() < limits.top() || switched.bottom() > limits.bottom()) { + desired = switched; + } + } + } + + let client_inset = window.client_inset().unwrap_or(px(0.)); + let edges = match self.fit_mode { + AnchoredFitMode::SnapToWindowWithMargin(edges) => edges, + _ => Edges::default(), + } + .map(|edge| *edge + client_inset); + + // Snap the horizontal edges of the anchored element to the horizontal edges of the window if + // its horizontal bounds overflow, aligning to the left if it is wider than the limits. + if desired.right() > limits.right() { + desired.origin.x -= desired.right() - limits.right() + edges.right; + } + if desired.left() < limits.left() { + desired.origin.x = limits.origin.x + edges.left; + } + + // Snap the vertical edges of the anchored element to the vertical edges of the window if + // its vertical bounds overflow, aligning to the top if it is taller than the limits. + if desired.bottom() > limits.bottom() { + desired.origin.y -= desired.bottom() - limits.bottom() + edges.bottom; + } + if desired.top() < limits.top() { + desired.origin.y = limits.origin.y + edges.top; + } + + let offset = desired.origin - bounds.origin; + let offset = point(offset.x.round(), offset.y.round()); + + window.with_element_offset(offset, |window| { + for child in &mut self.children { + child.prepaint(window, cx); + } + }) + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + for child in &mut self.children { + child.paint(window, cx); + } + } +} + +impl IntoElement for Anchored { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +/// Which algorithm to use when fitting the anchored element to be inside the window. +#[allow(dead_code)] +#[derive(Copy, Clone, PartialEq)] +pub enum AnchoredFitMode { + /// Snap the anchored element to the window edge. + SnapToWindow, + /// Snap to window edge and leave some margins. + SnapToWindowWithMargin(Edges), + /// Switch which corner anchor this anchored element is attached to. + SwitchAnchor, +} + +/// Which algorithm to use when positioning the anchored element. +#[allow(dead_code)] +#[derive(Copy, Clone, PartialEq)] +pub enum AnchoredPositionMode { + /// Position the anchored element relative to the window. + Window, + /// Position the anchored element relative to its parent. + Local, +} + +impl AnchoredPositionMode { + fn get_position_and_bounds( + &self, + anchor_position: Option>, + anchor_corner: Anchor, + size: Size, + bounds: Bounds, + offset: Option>, + ) -> (Point, Bounds) { + let offset = offset.unwrap_or_default(); + + match self { + AnchoredPositionMode::Window => { + let anchor_position = anchor_position.unwrap_or(bounds.origin); + let bounds = + Self::from_corner_and_size(anchor_corner, anchor_position + offset, size); + (anchor_position, bounds) + } + AnchoredPositionMode::Local => { + let anchor_position = anchor_position.unwrap_or_default(); + let bounds = Self::from_corner_and_size( + anchor_corner, + bounds.origin + anchor_position + offset, + size, + ); + (anchor_position, bounds) + } + } + } + + // Ref https://github.com/zed-industries/zed/blob/b06f4088a3565c5e30663106ff79c1ced645d87a/crates/gpui/src/geometry.rs#L863 + fn from_corner_and_size( + anchor: Anchor, + origin: Point, + size: Size, + ) -> Bounds { + let origin = match anchor { + Anchor::TopLeft => origin, + Anchor::TopCenter => Point { + x: origin.x - size.width.half(), + y: origin.y, + }, + Anchor::TopRight => Point { + x: origin.x - size.width, + y: origin.y, + }, + Anchor::BottomLeft => Point { + x: origin.x, + y: origin.y - size.height, + }, + Anchor::BottomCenter => Point { + x: origin.x - size.width.half(), + y: origin.y - size.height, + }, + Anchor::BottomRight => Point { + x: origin.x - size.width, + y: origin.y - size.height, + }, + }; + + Bounds { origin, size } + } +} diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs deleted file mode 100644 index ce09a67..0000000 --- a/crates/ui/src/dropdown.rs +++ /dev/null @@ -1,811 +0,0 @@ -use gpui::prelude::FluentBuilder; -use gpui::{ - anchored, canvas, deferred, div, px, rems, AnyElement, App, AppContext, Bounds, ClickEvent, - Context, DismissEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, Pixels, Render, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, -}; -use theme::ActiveTheme; - -use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; -use crate::input::clear_button::clear_button; -use crate::list::{List, ListDelegate, ListItem}; -use crate::{h_flex, v_flex, Disableable as _, Icon, IconName, Sizable, Size, StyleSized}; - -const CONTEXT: &str = "Dropdown"; - -#[derive(Clone)] -pub enum ListEvent { - /// Single click or move to selected row. - SelectItem(usize), - /// Double click on the row. - ConfirmItem(usize), - // Cancel the selection. - Cancel, -} - -pub fn init(cx: &mut App) { - cx.bind_keys([ - KeyBinding::new("up", SelectUp, Some(CONTEXT)), - KeyBinding::new("down", SelectDown, Some(CONTEXT)), - KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), - KeyBinding::new( - "secondary-enter", - Confirm { secondary: true }, - Some(CONTEXT), - ), - KeyBinding::new("escape", Cancel, Some(CONTEXT)), - ]) -} - -/// A trait for items that can be displayed in a dropdown. -pub trait DropdownItem { - type Value: Clone; - fn title(&self) -> SharedString; - /// Customize the display title used to selected item in Dropdown Input. - /// - /// If return None, the title will be used. - fn display_title(&self) -> Option { - None - } - fn value(&self) -> &Self::Value; -} - -impl DropdownItem for String { - type Value = Self; - - fn title(&self) -> SharedString { - SharedString::from(self.to_string()) - } - - fn value(&self) -> &Self::Value { - self - } -} - -impl DropdownItem for SharedString { - type Value = Self; - - fn title(&self) -> SharedString { - SharedString::from(self.to_string()) - } - - fn value(&self) -> &Self::Value { - self - } -} - -pub trait DropdownDelegate: Sized { - type Item: DropdownItem; - - fn len(&self) -> usize; - - fn is_empty(&self) -> bool { - self.len() == 0 - } - - fn get(&self, ix: usize) -> Option<&Self::Item>; - - fn position(&self, value: &V) -> Option - where - Self::Item: DropdownItem, - V: PartialEq, - { - (0..self.len()).find(|&i| self.get(i).is_some_and(|item| item.value() == value)) - } - - fn can_search(&self) -> bool { - false - } - - fn perform_search(&mut self, _query: &str, _window: &mut Window, _: &mut App) -> Task<()> { - Task::ready(()) - } -} - -impl DropdownDelegate for Vec { - type Item = T; - - fn len(&self) -> usize { - self.len() - } - - fn get(&self, ix: usize) -> Option<&Self::Item> { - self.as_slice().get(ix) - } - - fn position(&self, value: &V) -> Option - where - Self::Item: DropdownItem, - V: PartialEq, - { - self.iter().position(|v| v.value() == value) - } -} - -struct DropdownListDelegate { - delegate: D, - dropdown: WeakEntity>, - selected_index: Option, -} - -impl ListDelegate for DropdownListDelegate -where - D: DropdownDelegate + 'static, -{ - type Item = ListItem; - - fn items_count(&self, _: &App) -> usize { - self.delegate.len() - } - - fn render_item( - &self, - ix: usize, - _: &mut gpui::Window, - cx: &mut gpui::Context>, - ) -> Option { - let selected = self.selected_index == Some(ix); - let size = self - .dropdown - .upgrade() - .map_or(Size::Medium, |dropdown| dropdown.read(cx).size); - - if let Some(item) = self.delegate.get(ix) { - let list_item = ListItem::new(("list-item", ix)) - .check_icon(IconName::Check) - .selected(selected) - .input_font_size(size) - .list_size(size) - .child(div().whitespace_nowrap().child(item.title().to_string())); - Some(list_item) - } else { - None - } - } - - fn cancel(&mut self, window: &mut Window, cx: &mut Context>) { - let dropdown = self.dropdown.clone(); - cx.defer_in(window, move |_, window, cx| { - _ = dropdown.update(cx, |this, cx| { - this.open = false; - this.focus(window, cx); - }); - }); - } - - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - let selected_value = self - .selected_index - .and_then(|ix| self.delegate.get(ix)) - .map(|item| item.value().clone()); - let dropdown = self.dropdown.clone(); - - cx.defer_in(window, move |_, window, cx| { - _ = dropdown.update(cx, |this, cx| { - cx.emit(DropdownEvent::Confirm(selected_value.clone())); - this.selected_value = selected_value; - this.open = false; - this.focus(window, cx); - }); - }); - } - - fn perform_search( - &mut self, - query: &str, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - self.dropdown.upgrade().map_or(Task::ready(()), |dropdown| { - dropdown.update(cx, |_, cx| self.delegate.perform_search(query, window, cx)) - }) - } - - fn set_selected_index( - &mut self, - ix: Option, - _: &mut Window, - _: &mut Context>, - ) { - self.selected_index = ix; - } - - fn render_empty(&self, window: &mut Window, cx: &mut Context>) -> impl IntoElement { - if let Some(empty) = self - .dropdown - .upgrade() - .and_then(|dropdown| dropdown.read(cx).empty.as_ref()) - { - empty(window, cx).into_any_element() - } else { - h_flex() - .justify_center() - .py_6() - .text_color(cx.theme().text_muted) - .child(Icon::new(IconName::Loader).size(px(28.))) - .into_any_element() - } - } -} - -pub enum DropdownEvent { - Confirm(Option<::Value>), -} - -type DropdownStateEmpty = Option AnyElement>>; - -/// State of the [`Dropdown`]. -pub struct DropdownState { - focus_handle: FocusHandle, - list: Entity>>, - size: Size, - empty: DropdownStateEmpty, - /// Store the bounds of the input - bounds: Bounds, - open: bool, - selected_value: Option<::Value>, - _subscriptions: Vec, -} - -/// A Dropdown element. -#[derive(IntoElement)] -pub struct Dropdown { - id: ElementId, - state: Entity>, - size: Size, - icon: Option, - cleanable: bool, - placeholder: Option, - title_prefix: Option, - empty: Option, - width: Length, - menu_width: Length, - disabled: bool, -} - -pub struct SearchableVec { - items: Vec, - matched_items: Vec, -} - -impl SearchableVec { - pub fn new(items: impl Into>) -> Self { - let items = items.into(); - Self { - items: items.clone(), - matched_items: items, - } - } -} - -impl DropdownDelegate for SearchableVec { - type Item = T; - - fn len(&self) -> usize { - self.matched_items.len() - } - - fn get(&self, ix: usize) -> Option<&Self::Item> { - self.matched_items.get(ix) - } - - fn position(&self, value: &V) -> Option - where - Self::Item: DropdownItem, - V: PartialEq, - { - for (ix, item) in self.matched_items.iter().enumerate() { - if item.value() == value { - return Some(ix); - } - } - - None - } - - fn can_search(&self) -> bool { - true - } - - fn perform_search(&mut self, query: &str, _window: &mut Window, _: &mut App) -> Task<()> { - self.matched_items = self - .items - .iter() - .filter(|item| item.title().to_lowercase().contains(&query.to_lowercase())) - .cloned() - .collect(); - - Task::ready(()) - } -} - -impl From> for SearchableVec { - fn from(items: Vec) -> Self { - Self { - items: items.clone(), - matched_items: items, - } - } -} - -impl DropdownState -where - D: DropdownDelegate + 'static, -{ - pub fn new( - delegate: D, - selected_index: Option, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let focus_handle = cx.focus_handle(); - let delegate = DropdownListDelegate { - delegate, - dropdown: cx.entity().downgrade(), - selected_index, - }; - - let searchable = delegate.delegate.can_search(); - - let list = cx.new(|cx| { - let mut list = List::new(delegate, window, cx) - .max_h(rems(20.)) - .reset_on_cancel(false); - if !searchable { - list = list.no_query(); - } - list - }); - - let _subscriptions = vec![ - cx.on_blur(&list.focus_handle(cx), window, Self::on_blur), - cx.on_blur(&focus_handle, window, Self::on_blur), - ]; - - let mut this = Self { - focus_handle, - list, - size: Size::Medium, - selected_value: None, - open: false, - bounds: Bounds::default(), - empty: None, - _subscriptions, - }; - this.set_selected_index(selected_index, window, cx); - this - } - - pub fn empty(mut self, f: F) -> Self - where - E: IntoElement, - F: Fn(&Window, &App) -> E + 'static, - { - self.empty = Some(Box::new(move |window, cx| f(window, cx).into_any_element())); - self - } - - pub fn set_selected_index( - &mut self, - selected_index: Option, - window: &mut Window, - cx: &mut Context, - ) { - self.list.update(cx, |list, cx| { - list.set_selected_index(selected_index, window, cx); - }); - self.update_selected_value(window, cx); - } - - pub fn set_selected_value( - &mut self, - selected_value: &::Value, - window: &mut Window, - cx: &mut Context, - ) where - <::Item as DropdownItem>::Value: PartialEq, - { - let delegate = self.list.read(cx).delegate(); - let selected_index = delegate.delegate.position(selected_value); - self.set_selected_index(selected_index, window, cx); - } - - pub fn selected_index(&self, cx: &App) -> Option { - self.list.read(cx).selected_index() - } - - fn update_selected_value(&mut self, _: &Window, cx: &App) { - self.selected_value = self - .selected_index(cx) - .and_then(|ix| self.list.read(cx).delegate().delegate.get(ix)) - .map(|item| item.value().clone()); - } - - pub fn selected_value(&self) -> Option<&::Value> { - self.selected_value.as_ref() - } - - pub fn focus(&self, window: &mut Window, cx: &mut App) { - self.focus_handle.focus(window, cx); - } - - fn on_blur(&mut self, window: &mut Window, cx: &mut Context) { - // When the dropdown and dropdown menu are both not focused, close the dropdown menu. - if self.list.focus_handle(cx).is_focused(window) || self.focus_handle.is_focused(window) { - return; - } - - self.open = false; - cx.notify(); - } - - fn up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { - if !self.open { - return; - } - - self.list.focus_handle(cx).focus(window, cx); - cx.propagate(); - } - - fn down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { - if !self.open { - self.open = true; - } - - self.list.focus_handle(cx).focus(window, cx); - cx.propagate(); - } - - fn enter(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - // Propagate the event to the parent view, for example to the Modal to support ENTER to confirm. - cx.propagate(); - - if !self.open { - self.open = true; - cx.notify(); - } else { - self.list.focus_handle(cx).focus(window, cx); - } - } - - fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - cx.stop_propagation(); - - self.open = !self.open; - if self.open { - self.list.focus_handle(cx).focus(window, cx); - } - cx.notify(); - } - - fn escape(&mut self, _: &Cancel, _: &mut Window, cx: &mut Context) { - if !self.open { - cx.propagate(); - } - - self.open = false; - cx.notify(); - } - - fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { - self.set_selected_index(None, window, cx); - cx.emit(DropdownEvent::Confirm(None)); - } - - /// Set the items for the dropdown. - pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context) - where - D: DropdownDelegate + 'static, - { - self.list.update(cx, |list, _| { - list.delegate_mut().delegate = items; - }); - } -} - -impl Render for DropdownState -where - D: DropdownDelegate + 'static, -{ - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - Empty - } -} - -impl Dropdown -where - D: DropdownDelegate + 'static, -{ - pub fn new(state: &Entity>) -> Self { - Self { - id: ("dropdown", state.entity_id()).into(), - state: state.clone(), - placeholder: None, - size: Size::Medium, - icon: None, - cleanable: false, - title_prefix: None, - empty: None, - width: Length::Auto, - menu_width: Length::Auto, - disabled: false, - } - } - - /// Set the width of the dropdown input, default: Length::Auto - pub fn width(mut self, width: impl Into) -> Self { - self.width = width.into(); - self - } - - /// Set the width of the dropdown menu, default: Length::Auto - pub fn menu_width(mut self, width: impl Into) -> Self { - self.menu_width = width.into(); - self - } - - /// Set the placeholder for display when dropdown value is empty. - pub fn placeholder(mut self, placeholder: impl Into) -> Self { - self.placeholder = Some(placeholder.into()); - self - } - - /// Set the right icon for the dropdown input, instead of the default arrow icon. - pub fn icon(mut self, icon: impl Into) -> Self { - self.icon = Some(icon.into()); - self - } - - /// Set title prefix for the dropdown. - /// - /// e.g.: Country: United States - /// - /// You should set the label is `Country: ` - pub fn title_prefix(mut self, prefix: impl Into) -> Self { - self.title_prefix = Some(prefix.into()); - self - } - - /// Set true to show the clear button when the input field is not empty. - pub fn cleanable(mut self) -> Self { - self.cleanable = true; - self - } - - /// Set the disable state for the dropdown. - pub fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } - - pub fn empty(mut self, el: impl IntoElement) -> Self { - self.empty = Some(el.into_any_element()); - self - } - - /// Returns the title element for the dropdown input. - fn display_title(&self, _: &Window, cx: &App) -> impl IntoElement { - let default_title = div() - .text_color(cx.theme().text_accent) - .child( - self.placeholder - .clone() - .unwrap_or_else(|| "Please select".into()), - ) - .when(self.disabled, |this| this.text_color(cx.theme().text_muted)); - - let Some(selected_index) = &self.state.read(cx).selected_index(cx) else { - return default_title; - }; - - let Some(title) = self - .state - .read(cx) - .list - .read(cx) - .delegate() - .delegate - .get(*selected_index) - .map(|item| { - if let Some(el) = item.display_title() { - el - } else if let Some(prefix) = self.title_prefix.as_ref() { - format!("{}{}", prefix, item.title()).into_any_element() - } else { - item.title().into_any_element() - } - }) - else { - return default_title; - }; - - div() - .when(self.disabled, |this| this.text_color(cx.theme().text_muted)) - .child(title) - } -} - -impl Sizable for Dropdown -where - D: DropdownDelegate + 'static, -{ - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl EventEmitter> for DropdownState where D: DropdownDelegate + 'static {} -impl EventEmitter for DropdownState where D: DropdownDelegate + 'static {} -impl Focusable for DropdownState -where - D: DropdownDelegate, -{ - fn focus_handle(&self, cx: &App) -> FocusHandle { - if self.open { - self.list.focus_handle(cx) - } else { - self.focus_handle.clone() - } - } -} -impl Focusable for Dropdown -where - D: DropdownDelegate, -{ - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.state.focus_handle(cx) - } -} - -impl RenderOnce for Dropdown -where - D: DropdownDelegate + 'static, -{ - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let is_focused = self.focus_handle(cx).is_focused(window); - // If the size has change, set size to self.list, to change the QueryInput size. - let old_size = self.state.read(cx).list.read(cx).size; - if old_size != self.size { - self.state - .read(cx) - .list - .clone() - .update(cx, |this, cx| this.set_size(self.size, window, cx)); - self.state.update(cx, |this, _| { - this.size = self.size; - }); - } - - let state = self.state.read(cx); - let show_clean = self.cleanable && state.selected_index(cx).is_some(); - let bounds = state.bounds; - let allow_open = !(state.open || self.disabled); - let outline_visible = state.open || is_focused && !self.disabled; - let popup_radius = cx.theme().radius.min(px(8.)); - - div() - .id(self.id.clone()) - .key_context(CONTEXT) - .track_focus(&self.focus_handle(cx)) - .on_action(window.listener_for(&self.state, DropdownState::up)) - .on_action(window.listener_for(&self.state, DropdownState::down)) - .on_action(window.listener_for(&self.state, DropdownState::enter)) - .on_action(window.listener_for(&self.state, DropdownState::escape)) - .size_full() - .relative() - .input_font_size(self.size) - .child( - div() - .id(ElementId::Name(format!("{}-input", self.id).into())) - .relative() - .flex() - .items_center() - .justify_between() - .bg(cx.theme().background) - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .when(cx.theme().shadow, |this| this.shadow_sm()) - .overflow_hidden() - .input_font_size(self.size) - .map(|this| match self.width { - Length::Definite(l) => this.flex_none().w(l), - Length::Auto => this.w_full(), - }) - .when(outline_visible, |this| this.border_color(cx.theme().ring)) - .input_size(self.size) - .when(allow_open, |this| { - this.on_click(window.listener_for(&self.state, DropdownState::toggle_menu)) - }) - .child( - h_flex() - .w_full() - .items_center() - .justify_between() - .gap_1() - .child( - div() - .w_full() - .overflow_hidden() - .whitespace_nowrap() - .truncate() - .child(self.display_title(window, cx)), - ) - .when(show_clean, |this| { - this.child(clear_button(cx).map(|this| { - if self.disabled { - this.disabled(true) - } else { - this.on_click( - window.listener_for(&self.state, DropdownState::clean), - ) - } - })) - }) - .when(!show_clean, |this| { - let icon = match self.icon.clone() { - Some(icon) => icon, - None => { - if state.open { - Icon::new(IconName::CaretUp) - } else { - Icon::new(IconName::CaretDown) - } - } - }; - - this.child(icon.xsmall().text_color(match self.disabled { - true => cx.theme().text_placeholder, - false => cx.theme().text_muted, - })) - }), - ) - .child( - canvas( - { - let state = self.state.clone(); - move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds) - }, - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ), - ) - .when(state.open, |this| { - this.child( - deferred( - anchored().snap_to_window_with_margin(px(8.)).child( - div() - .occlude() - .map(|this| match self.menu_width { - Length::Auto => this.w(bounds.size.width), - Length::Definite(w) => this.w(w), - }) - .child( - v_flex() - .occlude() - .mt_1p5() - .bg(cx.theme().background) - .border_1() - .border_color(cx.theme().border) - .rounded(popup_radius) - .when(cx.theme().shadow, |this| this.shadow_md()) - .child(state.list.clone()), - ) - .on_mouse_down_out(window.listener_for( - &self.state, - |this, _, window, cx| { - this.escape(&Cancel, window, cx); - }, - )), - ), - ) - .with_priority(1), - ) - }) - } -} diff --git a/crates/ui/src/geometry.rs b/crates/ui/src/geometry.rs new file mode 100644 index 0000000..6f967bd --- /dev/null +++ b/crates/ui/src/geometry.rs @@ -0,0 +1,297 @@ +use std::fmt::{self, Debug, Display, Formatter}; + +use gpui::{AbsoluteLength, Axis, Corner, Length, Pixels}; +use serde::{Deserialize, Serialize}; + +/// A enum for defining the placement of the element. +/// +/// See also: [`Side`] if you need to define the left, right side. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Placement { + #[serde(rename = "top")] + Top, + #[serde(rename = "bottom")] + Bottom, + #[serde(rename = "left")] + Left, + #[serde(rename = "right")] + Right, +} + +impl Display for Placement { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Placement::Top => write!(f, "Top"), + Placement::Bottom => write!(f, "Bottom"), + Placement::Left => write!(f, "Left"), + Placement::Right => write!(f, "Right"), + } + } +} + +impl Placement { + #[inline] + pub fn is_horizontal(&self) -> bool { + match self { + Placement::Left | Placement::Right => true, + _ => false, + } + } + + #[inline] + pub fn is_vertical(&self) -> bool { + match self { + Placement::Top | Placement::Bottom => true, + _ => false, + } + } + + #[inline] + pub fn axis(&self) -> Axis { + match self { + Placement::Top | Placement::Bottom => Axis::Vertical, + Placement::Left | Placement::Right => Axis::Horizontal, + } + } +} + +/// The anchor position of an element. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum Anchor { + #[default] + #[serde(rename = "top-left")] + TopLeft, + #[serde(rename = "top-center")] + TopCenter, + #[serde(rename = "top-right")] + TopRight, + #[serde(rename = "bottom-left")] + BottomLeft, + #[serde(rename = "bottom-center")] + BottomCenter, + #[serde(rename = "bottom-right")] + BottomRight, +} + +impl Display for Anchor { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Anchor::TopLeft => write!(f, "TopLeft"), + Anchor::TopCenter => write!(f, "TopCenter"), + Anchor::TopRight => write!(f, "TopRight"), + Anchor::BottomLeft => write!(f, "BottomLeft"), + Anchor::BottomCenter => write!(f, "BottomCenter"), + Anchor::BottomRight => write!(f, "BottomRight"), + } + } +} + +impl Anchor { + /// Returns true if the anchor is at the top. + #[inline] + pub fn is_top(&self) -> bool { + matches!(self, Self::TopLeft | Self::TopCenter | Self::TopRight) + } + + /// Returns true if the anchor is at the bottom. + #[inline] + pub fn is_bottom(&self) -> bool { + matches!( + self, + Self::BottomLeft | Self::BottomCenter | Self::BottomRight + ) + } + + /// Returns true if the anchor is at the left. + #[inline] + pub fn is_left(&self) -> bool { + matches!(self, Self::TopLeft | Self::BottomLeft) + } + + /// Returns true if the anchor is at the right. + #[inline] + pub fn is_right(&self) -> bool { + matches!(self, Self::TopRight | Self::BottomRight) + } + + /// Returns true if the anchor is at the center. + #[inline] + pub fn is_center(&self) -> bool { + matches!(self, Self::TopCenter | Self::BottomCenter) + } + + /// Swaps the vertical position of the anchor. + pub fn swap_vertical(&self) -> Self { + match self { + Anchor::TopLeft => Anchor::BottomLeft, + Anchor::TopCenter => Anchor::BottomCenter, + Anchor::TopRight => Anchor::BottomRight, + Anchor::BottomLeft => Anchor::TopLeft, + Anchor::BottomCenter => Anchor::TopCenter, + Anchor::BottomRight => Anchor::TopRight, + } + } + + /// Swaps the horizontal position of the anchor. + pub fn swap_horizontal(&self) -> Self { + match self { + Anchor::TopLeft => Anchor::TopRight, + Anchor::TopCenter => Anchor::TopCenter, + Anchor::TopRight => Anchor::TopLeft, + Anchor::BottomLeft => Anchor::BottomRight, + Anchor::BottomCenter => Anchor::BottomCenter, + Anchor::BottomRight => Anchor::BottomLeft, + } + } + + pub(crate) fn other_side_corner_along(&self, axis: Axis) -> Anchor { + match axis { + Axis::Vertical => match self { + Self::TopLeft => Self::BottomLeft, + Self::TopCenter => Self::BottomCenter, + Self::TopRight => Self::BottomRight, + Self::BottomLeft => Self::TopLeft, + Self::BottomCenter => Self::TopCenter, + Self::BottomRight => Self::TopRight, + }, + Axis::Horizontal => match self { + Self::TopLeft => Self::TopRight, + Self::TopCenter => Self::TopCenter, + Self::TopRight => Self::TopLeft, + Self::BottomLeft => Self::BottomRight, + Self::BottomCenter => Self::BottomCenter, + Self::BottomRight => Self::BottomLeft, + }, + } + } +} + +impl From for Anchor { + fn from(corner: Corner) -> Self { + match corner { + Corner::TopLeft => Anchor::TopLeft, + Corner::TopRight => Anchor::TopRight, + Corner::BottomLeft => Anchor::BottomLeft, + Corner::BottomRight => Anchor::BottomRight, + } + } +} + +impl From for Corner { + fn from(anchor: Anchor) -> Self { + match anchor { + Anchor::TopLeft => Corner::TopLeft, + Anchor::TopRight => Corner::TopRight, + Anchor::BottomLeft => Corner::BottomLeft, + Anchor::BottomRight => Corner::BottomRight, + Anchor::TopCenter => Corner::TopLeft, + Anchor::BottomCenter => Corner::BottomLeft, + } + } +} + +/// A enum for defining the side of the element. +/// +/// See also: [`Placement`] if you need to define the 4 edges. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Side { + #[serde(rename = "left")] + Left, + #[serde(rename = "right")] + Right, +} + +impl Side { + /// Returns true if the side is left. + #[inline] + pub fn is_left(&self) -> bool { + matches!(self, Self::Left) + } + + /// Returns true if the side is right. + #[inline] + pub fn is_right(&self) -> bool { + matches!(self, Self::Right) + } +} + +/// A trait to extend the [`Axis`] enum with utility methods. +pub trait AxisExt { + fn is_horizontal(self) -> bool; + fn is_vertical(self) -> bool; +} + +impl AxisExt for Axis { + #[inline] + fn is_horizontal(self) -> bool { + self == Axis::Horizontal + } + + #[inline] + fn is_vertical(self) -> bool { + self == Axis::Vertical + } +} + +/// A trait for converting [`Pixels`] to `f32` and `f64`. +pub trait PixelsExt { + fn as_f32(&self) -> f32; + fn as_f64(self) -> f64; +} +impl PixelsExt for Pixels { + fn as_f32(&self) -> f32 { + f32::from(self) + } + + fn as_f64(self) -> f64 { + f64::from(self) + } +} + +/// A trait to extend the [`Length`] enum with utility methods. +pub trait LengthExt { + /// Converts the [`Length`] to [`Pixels`] based on a given `base_size` and `rem_size`. + /// + /// If the [`Length`] is [`Length::Auto`], it returns `None`. + fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option; +} + +impl LengthExt for Length { + fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option { + match self { + Length::Auto => None, + Length::Definite(len) => Some(len.to_pixels(base_size, rem_size)), + } + } +} + +/// A struct for defining the edges of an element. +/// +/// A extend version of [`gpui::Edges`] to serialize/deserialize. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)] +#[repr(C)] +pub struct Edges { + /// The size of the top edge. + pub top: T, + /// The size of the right edge. + pub right: T, + /// The size of the bottom edge. + pub bottom: T, + /// The size of the left edge. + pub left: T, +} + +impl Edges +where + T: Clone + Debug + Default + PartialEq, +{ + /// Creates a new `Edges` instance with all edges set to the same value. + pub fn all(value: T) -> Self { + Self { + top: value.clone(), + right: value.clone(), + bottom: value.clone(), + left: value, + } + } +} diff --git a/crates/ui/src/index_path.rs b/crates/ui/src/index_path.rs new file mode 100644 index 0000000..987412e --- /dev/null +++ b/crates/ui/src/index_path.rs @@ -0,0 +1,69 @@ +use std::fmt::{Debug, Display}; + +use gpui::ElementId; + +/// Represents an index path in a list, which consists of a section index, +/// +/// The default values for section, row, and column are all set to 0. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct IndexPath { + /// The section index. + pub section: usize, + /// The item index in the section. + pub row: usize, + /// The column index. + pub column: usize, +} + +impl From for ElementId { + fn from(path: IndexPath) -> Self { + ElementId::Name(format!("index-path({},{},{})", path.section, path.row, path.column).into()) + } +} + +impl Display for IndexPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "IndexPath(section: {}, row: {}, column: {})", + self.section, self.row, self.column + ) + } +} + +impl IndexPath { + /// Create a new index path with the specified section and row. + /// + /// The `section` is set to 0 by default. + /// The `column` is set to 0 by default. + pub fn new(row: usize) -> Self { + IndexPath { + section: 0, + row, + ..Default::default() + } + } + + /// Set the section for the index path. + pub fn section(mut self, section: usize) -> Self { + self.section = section; + self + } + + /// Set the row for the index path. + pub fn row(mut self, row: usize) -> Self { + self.row = row; + self + } + + /// Set the column for the index path. + pub fn column(mut self, column: usize) -> Self { + self.column = column; + self + } + + /// Check if the self is equal to the given index path (Same section and row). + pub fn eq_row(&self, index: IndexPath) -> bool { + self.section == index.section && self.row == index.row + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index dce8d13..b6e8b97 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,9 +1,11 @@ +pub use anchored::*; pub use element_ext::ElementExt; pub use event::InteractiveElementExt; pub use focusable::FocusableCycle; +pub use geometry::*; pub use icon::*; +pub use index_path::IndexPath; pub use kbd::*; -pub use menu::{context_menu, popup_menu}; pub use root::{window_paddings, Root}; pub use styled::*; pub use window_ext::*; @@ -16,7 +18,6 @@ pub mod avatar; pub mod button; pub mod checkbox; pub mod divider; -pub mod dropdown; pub mod history; pub mod indicator; pub mod input; @@ -30,10 +31,13 @@ pub mod skeleton; pub mod switch; pub mod tooltip; +mod anchored; mod element_ext; mod event; mod focusable; +mod geometry; mod icon; +mod index_path; mod kbd; mod root; mod styled; @@ -44,7 +48,6 @@ mod window_ext; /// This must be called before using any of the UI components. /// You can initialize the UI module at your application's entry point. pub fn init(cx: &mut gpui::App) { - dropdown::init(cx); input::init(cx); list::init(cx); modal::init(cx); diff --git a/crates/ui/src/list/cache.rs b/crates/ui/src/list/cache.rs new file mode 100644 index 0000000..3de7a8c --- /dev/null +++ b/crates/ui/src/list/cache.rs @@ -0,0 +1,221 @@ +use std::rc::Rc; + +use gpui::{App, Pixels, Size}; + +use crate::IndexPath; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RowEntry { + Entry(IndexPath), + SectionHeader(usize), + SectionFooter(usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct MeasuredEntrySize { + pub(crate) item_size: Size, + pub(crate) section_header_size: Size, + pub(crate) section_footer_size: Size, +} + +impl RowEntry { + #[inline] + #[allow(unused)] + pub(crate) fn is_section_header(&self) -> bool { + matches!(self, RowEntry::SectionHeader(_)) + } + + pub(crate) fn eq_index_path(&self, path: &IndexPath) -> bool { + match self { + RowEntry::Entry(index_path) => index_path == path, + RowEntry::SectionHeader(_) | RowEntry::SectionFooter(_) => false, + } + } + + #[allow(unused)] + pub(crate) fn index(&self) -> IndexPath { + match self { + RowEntry::Entry(index_path) => *index_path, + RowEntry::SectionHeader(ix) => IndexPath::default().section(*ix), + RowEntry::SectionFooter(ix) => IndexPath::default().section(*ix), + } + } + + #[inline] + #[allow(unused)] + pub(crate) fn is_section_footer(&self) -> bool { + matches!(self, RowEntry::SectionFooter(_)) + } + + #[inline] + pub(crate) fn is_entry(&self) -> bool { + matches!(self, RowEntry::Entry(_)) + } + + #[inline] + #[allow(unused)] + pub(crate) fn section_ix(&self) -> Option { + match self { + RowEntry::SectionHeader(ix) | RowEntry::SectionFooter(ix) => Some(*ix), + _ => None, + } + } +} + +#[derive(Default, Clone)] +pub(crate) struct RowsCache { + /// Only have section's that have rows. + pub(crate) entities: Rc>, + pub(crate) items_count: usize, + /// The sections, the item is number of rows in each section. + pub(crate) sections: Rc>, + pub(crate) entries_sizes: Rc>>, + measured_size: MeasuredEntrySize, +} + +impl RowsCache { + pub(crate) fn get(&self, flatten_ix: usize) -> Option { + self.entities.get(flatten_ix).cloned() + } + + /// Returns the number of flattened rows (Includes header, item, footer). + pub(crate) fn len(&self) -> usize { + self.entities.len() + } + + /// Return the number of items in the cache. + pub(crate) fn items_count(&self) -> usize { + self.items_count + } + + /// Returns the index of the Entry with given path in the flattened rows. + pub(crate) fn position_of(&self, path: &IndexPath) -> Option { + self.entities + .iter() + .position(|p| p.is_entry() && p.eq_index_path(path)) + } + + /// Return prev row, if the row is the first in the first section, goes to the last row. + /// + /// Empty rows section are skipped. + pub(crate) fn prev(&self, path: Option) -> IndexPath { + let path = path.unwrap_or_default(); + let Some(pos) = self.position_of(&path) else { + return self + .entities + .iter() + .rfind(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default(); + }; + + if let Some(path) = self + .entities + .iter() + .take(pos) + .rev() + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + { + path + } else { + self.entities + .iter() + .rfind(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default() + } + } + + /// Returns the next row, if the row is the last in the last section, goes to the first row. + /// + /// Empty rows section are skipped. + pub(crate) fn next(&self, path: Option) -> IndexPath { + let Some(mut path) = path else { + return IndexPath::default(); + }; + + let Some(pos) = self.position_of(&path) else { + return self + .entities + .iter() + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default(); + }; + + if let Some(next_path) = self + .entities + .iter() + .skip(pos + 1) + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + { + path = next_path; + } else { + path = self + .entities + .iter() + .find(|entry| entry.is_entry()) + .map(|entry| entry.index()) + .unwrap_or_default() + } + + path + } + + pub(crate) fn prepare_if_needed( + &mut self, + sections_count: usize, + measured_size: MeasuredEntrySize, + cx: &App, + rows_count_f: F, + ) where + F: Fn(usize, &App) -> usize, + { + let mut new_sections = vec![]; + for section_ix in 0..sections_count { + new_sections.push(rows_count_f(section_ix, cx)); + } + + let need_update = new_sections != *self.sections || self.measured_size != measured_size; + + if !need_update { + return; + } + + let mut entries_sizes = vec![]; + let mut total_items_count = 0; + self.measured_size = measured_size; + self.sections = Rc::new(new_sections); + self.entities = Rc::new( + self.sections + .iter() + .enumerate() + .flat_map(|(section, items_count)| { + total_items_count += items_count; + let mut children = vec![]; + if *items_count == 0 { + return children; + } + + children.push(RowEntry::SectionHeader(section)); + entries_sizes.push(measured_size.section_header_size); + for row in 0..*items_count { + children.push(RowEntry::Entry(IndexPath { + section, + row, + ..Default::default() + })); + entries_sizes.push(measured_size.item_size); + } + children.push(RowEntry::SectionFooter(section)); + entries_sizes.push(measured_size.section_footer_size); + children + }) + .collect(), + ); + self.entries_sizes = Rc::new(entries_sizes); + self.items_count = total_items_count; + } +} diff --git a/crates/ui/src/list/delegate.rs b/crates/ui/src/list/delegate.rs new file mode 100644 index 0000000..2899d2f --- /dev/null +++ b/crates/ui/src/list/delegate.rs @@ -0,0 +1,171 @@ +use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Task, Window}; +use theme::ActiveTheme; + +use crate::list::loading::Loading; +use crate::list::ListState; +use crate::{h_flex, Icon, IconName, IndexPath, Selectable}; + +/// A delegate for the List. +#[allow(unused)] +pub trait ListDelegate: Sized + 'static { + type Item: Selectable + IntoElement; + + /// When Query Input change, this method will be called. + /// You can perform search here. + fn perform_search( + &mut self, + query: &str, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + Task::ready(()) + } + + /// Return the number of sections in the list, default is 1. + /// + /// Min value is 1. + fn sections_count(&self, cx: &App) -> usize { + 1 + } + + /// Return the number of items in the section at the given index. + /// + /// NOTE: Only the sections with items_count > 0 will be rendered. If the section has 0 items, + /// the section header and footer will also be skipped. + fn items_count(&self, section: usize, cx: &App) -> usize; + + /// Render the item at the given index. + /// + /// Return None will skip the item. + /// + /// NOTE: Every item should have same height. + fn render_item( + &mut self, + ix: IndexPath, + window: &mut Window, + cx: &mut Context>, + ) -> Option; + + /// Render the section header at the given index, default is None. + /// + /// NOTE: Every header should have same height. + fn render_section_header( + &mut self, + section: usize, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + None:: + } + + /// Render the section footer at the given index, default is None. + /// + /// NOTE: Every footer should have same height. + fn render_section_footer( + &mut self, + section: usize, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + None:: + } + + /// Return a Element to show when list is empty. + fn render_empty( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + h_flex() + .size_full() + .justify_center() + .text_color(cx.theme().text_muted.opacity(0.6)) + .child(Icon::new(IconName::Inbox).size_12()) + .into_any_element() + } + + /// Returns Some(AnyElement) to render the initial state of the list. + /// + /// This can be used to show a view for the list before the user has + /// interacted with it. + /// + /// For example: The last search results, or the last selected item. + /// + /// Default is None, that means no initial state. + fn render_initial( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + None + } + + /// Returns the loading state to show the loading view. + fn loading(&self, cx: &App) -> bool { + false + } + + /// Returns a Element to show when loading, default is built-in Skeleton + /// loading view. + fn render_loading( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + Loading + } + + /// Set the selected index, just store the ix, don't confirm. + fn set_selected_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context>, + ); + + /// Set the index of the item that has been right clicked. + fn set_right_clicked_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context>, + ) { + } + + /// Set the confirm and give the selected index, + /// this is means user have clicked the item or pressed Enter. + /// + /// This will always to `set_selected_index` before confirm. + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + } + + /// Cancel the selection, e.g.: Pressed ESC. + fn cancel(&mut self, window: &mut Window, cx: &mut Context>) {} + + /// Return true to enable load more data when scrolling to the bottom. + /// + /// Default: false + fn has_more(&self, cx: &App) -> bool { + false + } + + /// Returns a threshold value (n entities), of course, + /// when scrolling to the bottom, the remaining number of rows + /// triggers `load_more`. + /// + /// This should smaller than the total number of first load rows. + /// + /// Default: 20 entities (section header, footer and row) + fn load_more_threshold(&self) -> usize { + 20 + } + + /// Load more data when the table is scrolled to the bottom. + /// + /// This will performed in a background task. + /// + /// This is always called when the table is near the bottom, + /// so you must check if there is more data to load or lock + /// the loading state. + fn load_more(&mut self, window: &mut Window, cx: &mut Context>) {} +} diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index 79d52ab..1c2870a 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -3,21 +3,23 @@ use std::time::Duration; use gpui::prelude::FluentBuilder; use gpui::{ - div, px, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, - Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior, - MouseButton, MouseDownEvent, ParentElement, Render, ScrollStrategy, Styled, Subscription, Task, - UniformListScrollHandle, Window, + div, px, size, uniform_list, App, AppContext, AvailableSpace, ClickEvent, Context, + DefiniteLength, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, KeyBinding, Length, ListSizingBehavior, MouseButton, + ParentElement, Render, RenderOnce, ScrollStrategy, SharedString, StatefulInteractiveElement, + StyleRefinement, Styled, Subscription, Task, UniformListScrollHandle, Window, }; use smol::Timer; use theme::ActiveTheme; -use super::loading::Loading; use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; use crate::input::{InputEvent, InputState, TextInput}; -use crate::scroll::{Scrollbar, ScrollbarState}; -use crate::{v_flex, Icon, IconName, Sizable as _, Size}; +use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache}; +use crate::list::ListDelegate; +use crate::scroll::{Scrollbar, ScrollbarHandle}; +use crate::{v_flex, Icon, IconName, IndexPath, Selectable, Sizable, Size, StyledExt}; -pub fn init(cx: &mut App) { +pub(crate) fn init(cx: &mut App) { let context: Option<&str> = Some("List"); cx.bind_keys([ KeyBinding::new("escape", Cancel, context), @@ -31,138 +33,57 @@ pub fn init(cx: &mut App) { #[derive(Clone)] pub enum ListEvent { /// Move to select item. - Select(usize), + Select(IndexPath), /// Click on item or pressed Enter. - Confirm(usize), + Confirm(IndexPath), /// Pressed ESC to deselect the item. Cancel, } -/// A delegate for the List. -#[allow(unused)] -pub trait ListDelegate: Sized + 'static { - type Item: IntoElement; - - /// When Query Input change, this method will be called. - /// You can perform search here. - fn perform_search( - &mut self, - query: &str, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - Task::ready(()) - } - - /// Return the number of items in the list. - fn items_count(&self, cx: &App) -> usize; - - /// Render the item at the given index. - /// - /// Return None will skip the item. - fn render_item( - &self, - ix: usize, - window: &mut Window, - cx: &mut Context>, - ) -> Option; - - /// Return a Element to show when list is empty. - fn render_empty(&self, window: &mut Window, cx: &mut Context>) -> impl IntoElement { - div() - } - - /// Returns Some(AnyElement) to render the initial state of the list. - /// - /// This can be used to show a view for the list before the user has interacted with it. - /// - /// For example: The last search results, or the last selected item. - /// - /// Default is None, that means no initial state. - fn render_initial( - &self, - window: &mut Window, - cx: &mut Context>, - ) -> Option { - None - } - - /// Returns the loading state to show the loading view. - fn loading(&self, cx: &App) -> bool { - false - } - - /// Returns a Element to show when loading, default is built-in Skeleton loading view. - fn render_loading( - &self, - window: &mut Window, - cx: &mut Context>, - ) -> impl IntoElement { - Loading - } - - /// Set the selected index, just store the ix, don't confirm. - fn set_selected_index( - &mut self, - ix: Option, - window: &mut Window, - cx: &mut Context>, - ); - - /// Set the confirm and give the selected index, this is means user have clicked the item or pressed Enter. - /// - /// This will always to `set_selected_index` before confirm. - fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) {} - - /// Cancel the selection, e.g.: Pressed ESC. - fn cancel(&mut self, window: &mut Window, cx: &mut Context>) {} - - /// Return true to enable load more data when scrolling to the bottom. - /// - /// Default: true - fn can_load_more(&self, cx: &App) -> bool { - true - } - - /// Returns a threshold value (n rows), of course, when scrolling to the bottom, - /// the remaining number of rows triggers `load_more`. - /// This should smaller than the total number of first load rows. - /// - /// Default: 20 rows - fn load_more_threshold(&self) -> usize { - 20 - } - - /// Load more data when the table is scrolled to the bottom. - /// - /// This will performed in a background task. - /// - /// This is always called when the table is near the bottom, - /// so you must check if there is more data to load or lock the loading state. - fn load_more(&mut self, window: &mut Window, cx: &mut Context>) {} +struct ListOptions { + size: Size, + scrollbar_visible: bool, + search_placeholder: Option, + max_height: Option, + paddings: EdgesRefinement, } -pub struct List { - focus_handle: FocusHandle, +impl Default for ListOptions { + fn default() -> Self { + Self { + size: Size::default(), + scrollbar_visible: true, + max_height: None, + search_placeholder: None, + paddings: EdgesRefinement::default(), + } + } +} + +/// The state for List. +/// +/// List required all items has the same height. +pub struct ListState { + pub(crate) focus_handle: FocusHandle, + pub(crate) query_input: Entity, + options: ListOptions, delegate: D, - max_height: Option, - query_input: Option>, last_query: Option, - selectable: bool, - querying: bool, - scrollbar_visible: bool, - vertical_scroll_handle: UniformListScrollHandle, - scrollbar_state: ScrollbarState, - pub(crate) size: Size, - selected_index: Option, - right_clicked_index: Option, + scroll_handle: UniformListScrollHandle, + rows_cache: RowsCache, + selected_index: Option, + item_to_measure_index: IndexPath, + deferred_scroll_to_index: Option<(IndexPath, ScrollStrategy)>, + mouse_right_clicked_index: Option, reset_on_cancel: bool, + searchable: bool, + selectable: bool, _search_task: Task<()>, _load_more_task: Task<()>, _query_input_subscription: Subscription, } -impl List +impl ListState where D: ListDelegate, { @@ -173,18 +94,18 @@ where Self { focus_handle: cx.focus_handle(), + options: ListOptions::default(), delegate, - query_input: Some(query_input), + rows_cache: RowsCache::default(), + query_input, last_query: None, selected_index: None, - right_clicked_index: None, - vertical_scroll_handle: UniformListScrollHandle::new(), - scrollbar_state: ScrollbarState::default(), - max_height: None, - scrollbar_visible: true, selectable: true, - querying: false, - size: Size::default(), + searchable: false, + item_to_measure_index: IndexPath::default(), + deferred_scroll_to_index: None, + mouse_right_clicked_index: None, + scroll_handle: UniformListScrollHandle::new(), reset_on_cancel: true, _search_task: Task::ready(()), _load_more_task: Task::ready(()), @@ -192,25 +113,17 @@ where } } - /// Set the size - pub fn set_size(&mut self, size: Size, _: &mut Window, _: &mut Context) { - self.size = size; - } - - pub fn max_h(mut self, height: impl Into) -> Self { - self.max_height = Some(height.into()); + /// Sets whether the list is searchable, default is `false`. + /// + /// When `true`, there will be a search input at the top of the list. + pub fn searchable(mut self, searchable: bool) -> Self { + self.searchable = searchable; self } - /// Set the visibility of the scrollbar, default is true. - pub fn scrollbar_visible(mut self, visible: bool) -> Self { - self.scrollbar_visible = visible; - self - } - - pub fn no_query(mut self) -> Self { - self.query_input = None; - self + pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context) { + self.searchable = searchable; + cx.notify(); } /// Sets whether the list is selectable, default is true. @@ -219,20 +132,10 @@ where self } - pub fn set_query_input( - &mut self, - query_input: Entity, - window: &mut Window, - cx: &mut Context, - ) { - self._query_input_subscription = - cx.subscribe_in(&query_input, window, Self::on_query_input_event); - self.query_input = Some(query_input); - } - - /// Get the query input entity. - pub fn query_input(&self) -> Option<&Entity> { - self.query_input.as_ref() + /// Sets whether the list is selectable, default is true. + pub fn set_selectable(&mut self, selectable: bool, cx: &mut Context) { + self.selectable = selectable; + cx.notify(); } pub fn delegate(&self) -> &D { @@ -243,57 +146,105 @@ where &mut self.delegate } + /// Focus the list, if the list is searchable, focus the search input. pub fn focus(&mut self, window: &mut Window, cx: &mut App) { self.focus_handle(cx).focus(window, cx); } - /// Set the selected index of the list, this will also scroll to the selected item. - pub fn set_selected_index( + /// Return true if either the list or the search input is focused. + pub(crate) fn is_focused(&self, window: &Window, cx: &App) -> bool { + self.focus_handle.is_focused(window) || self.query_input.focus_handle(cx).is_focused(window) + } + + /// Set the selected index of the list, + /// this will also scroll to the selected item. + pub(crate) fn _set_selected_index( &mut self, - ix: Option, + ix: Option, window: &mut Window, cx: &mut Context, ) { + if !self.selectable { + return; + } + self.selected_index = ix; self.delegate.set_selected_index(ix, window, cx); self.scroll_to_selected_item(window, cx); } - pub fn selected_index(&self) -> Option { + /// Set the selected index of the list, + /// this method will not scroll to the selected item. + pub fn set_selected_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.selected_index = ix; + self.delegate.set_selected_index(ix, window, cx); + } + + pub fn selected_index(&self) -> Option { self.selected_index } - fn render_scrollbar( - &self, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - if !self.scrollbar_visible { - return None; - } + /// Set the index of the item that has been right clicked. + pub fn set_right_clicked_index( + &mut self, + ix: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.mouse_right_clicked_index = ix; + self.delegate.set_right_clicked_index(ix, window, cx); + } - Some(Scrollbar::uniform_scroll( - &self.scrollbar_state, - &self.vertical_scroll_handle, - )) + /// Returns the index of the item that has been right clicked. + pub fn right_clicked_index(&self) -> Option { + self.mouse_right_clicked_index + } + + /// Set a specific list item for measurement. + pub fn set_item_to_measure_index( + &mut self, + ix: IndexPath, + _: &mut Window, + cx: &mut Context, + ) { + self.item_to_measure_index = ix; + cx.notify(); } /// Scroll to the item at the given index. - pub fn scroll_to_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context) { - self.vertical_scroll_handle - .scroll_to_item(ix, ScrollStrategy::Top); + pub fn scroll_to_item( + &mut self, + ix: IndexPath, + strategy: ScrollStrategy, + _: &mut Window, + cx: &mut Context, + ) { + if ix.section == 0 && ix.row == 0 { + // If the item is the first item, scroll to the top. + let mut offset = self.scroll_handle.offset(); + offset.y = px(0.); + self.scroll_handle.set_offset(offset); + cx.notify(); + return; + } + self.deferred_scroll_to_index = Some((ix, strategy)); cx.notify(); } /// Get scroll handle pub fn scroll_handle(&self) -> &UniformListScrollHandle { - &self.vertical_scroll_handle + &self.scroll_handle } - fn scroll_to_selected_item(&mut self, _window: &mut Window, _cx: &mut Context) { + pub fn scroll_to_selected_item(&mut self, _: &mut Window, cx: &mut Context) { if let Some(ix) = self.selected_index { - self.vertical_scroll_handle - .scroll_to_item(ix, ScrollStrategy::Top); + self.deferred_scroll_to_index = Some((ix, ScrollStrategy::Top)); + cx.notify(); } } @@ -308,33 +259,31 @@ where InputEvent::Change => { let text = state.read(cx).value(); let text = text.trim().to_string(); - if Some(&text) == self.last_query.as_ref() { return; } - self.set_querying(true, window, cx); + self.set_searching(true, window, cx); let search = self.delegate.perform_search(&text, window, cx); - if self.delegate.items_count(cx) > 0 { - self.set_selected_index(Some(0), window, cx); + if self.rows_cache.len() > 0 { + self._set_selected_index(Some(IndexPath::default()), window, cx); } else { - self.set_selected_index(None, window, cx); + self._set_selected_index(None, window, cx); } self._search_task = cx.spawn_in(window, async move |this, window| { search.await; _ = this.update_in(window, |this, _, _| { - this.vertical_scroll_handle - .scroll_to_item(0, ScrollStrategy::Top); + this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); this.last_query = Some(text); }); // Always wait 100ms to avoid flicker Timer::after(Duration::from_millis(100)).await; _ = this.update_in(window, |this, window, cx| { - this.set_querying(false, window, cx); + this.set_searching(false, window, cx); }); }); } @@ -349,26 +298,27 @@ where } } - fn set_querying(&mut self, querying: bool, _window: &mut Window, cx: &mut Context) { - self.querying = querying; - if let Some(input) = &self.query_input { - input.update(cx, |input, cx| input.set_loading(querying, cx)) - } - cx.notify(); + fn set_searching(&mut self, searching: bool, _window: &mut Window, cx: &mut Context) { + self.query_input + .update(cx, |input, cx| input.set_loading(searching, cx)); } - /// Dispatch delegate's `load_more` method when the visible range is near the end. + /// Dispatch delegate's `load_more` method when the + /// visible range is near the end. fn load_more_if_need( &mut self, - items_count: usize, + entities_count: usize, visible_end: usize, window: &mut Window, cx: &mut Context, ) { + // FIXME: Here need void sections items count. + let threshold = self.delegate.load_more_threshold(); - // Securely handle subtract logic to prevent attempt to subtract with overflow - if visible_end >= items_count.saturating_sub(threshold) { - if !self.delegate.can_load_more(cx) { + // Securely handle subtract logic to prevent attempt + // to subtract with overflow + if visible_end >= entities_count.saturating_sub(threshold) { + if !self.delegate.has_more(cx) { return; } @@ -386,12 +336,9 @@ where } fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - if self.selected_index.is_none() { - cx.propagate(); - } - + cx.propagate(); if self.reset_on_cancel { - self.set_selected_index(None, window, cx); + self._set_selected_index(None, window, cx); } self.delegate.cancel(window, cx); @@ -405,7 +352,7 @@ where window: &mut Window, cx: &mut Context, ) { - if self.delegate.items_count(cx) == 0 { + if self.rows_cache.len() == 0 { return; } @@ -420,7 +367,11 @@ where cx.notify(); } - fn select_item(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + fn select_item(&mut self, ix: IndexPath, window: &mut Window, cx: &mut Context) { + if !self.selectable { + return; + } + self.selected_index = Some(ix); self.delegate.set_selected_index(Some(ix), window, cx); self.scroll_to_selected_item(window, cx); @@ -428,222 +379,365 @@ where cx.notify(); } - fn on_select_prev(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { - let items_count = self.delegate.items_count(cx); - if items_count == 0 { + pub(crate) fn on_action_select_prev( + &mut self, + _: &SelectUp, + window: &mut Window, + cx: &mut Context, + ) { + if self.rows_cache.len() == 0 { return; } - let mut selected_index = self.selected_index.unwrap_or(0); - if selected_index > 0 { - selected_index -= 1; - } else { - selected_index = items_count - 1; - } - self.select_item(selected_index, window, cx); + let prev_ix = self.rows_cache.prev(self.selected_index); + self.select_item(prev_ix, window, cx); } - fn on_select_next(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { - let items_count = self.delegate.items_count(cx); - if items_count == 0 { + pub(crate) fn on_action_select_next( + &mut self, + _: &SelectDown, + window: &mut Window, + cx: &mut Context, + ) { + if self.rows_cache.len() == 0 { return; } - let selected_index; - if let Some(ix) = self.selected_index { - if ix < items_count - 1 { - selected_index = ix + 1; - } else { - // When the last item is selected, select the first item. - selected_index = 0; - } - } else { - // When no selected index, select the first item. - selected_index = 0; + let next_ix = self.rows_cache.next(self.selected_index); + self.select_item(next_ix, window, cx); + } + + fn prepare_items_if_needed(&mut self, window: &mut Window, cx: &mut Context) { + let sections_count = self.delegate.sections_count(cx).max(1); + let mut measured_size = MeasuredEntrySize::default(); + + // Measure the item_height and section header/footer height. + let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent); + measured_size.item_size = self + .render_list_item(self.item_to_measure_index, window, cx) + .into_any_element() + .layout_as_root(available_space, window, cx); + + if let Some(mut el) = self + .delegate + .render_section_header(0, window, cx) + .map(|r| r.into_any_element()) + { + measured_size.section_header_size = el.layout_as_root(available_space, window, cx); + } + if let Some(mut el) = self + .delegate + .render_section_footer(0, window, cx) + .map(|r| r.into_any_element()) + { + measured_size.section_footer_size = el.layout_as_root(available_space, window, cx); } - self.select_item(selected_index, window, cx); + self.rows_cache + .prepare_if_needed(sections_count, measured_size, cx, |section_ix, cx| { + self.delegate.items_count(section_ix, cx) + }); } fn render_list_item( &mut self, - ix: usize, + ix: IndexPath, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let selected = self.selected_index == Some(ix); - let right_clicked = self.right_clicked_index == Some(ix); + let selectable = self.selectable; + let selected = self.selected_index.map(|s| s.eq_row(ix)).unwrap_or(false); + let mouse_right_clicked = self + .mouse_right_clicked_index + .map(|s| s.eq_row(ix)) + .unwrap_or(false); + let id = SharedString::from(format!("list-item-{}", ix)); div() - .id("list-item") + .id(id) .w_full() .relative() - .children(self.delegate.render_item(ix, window, cx)) - .when(self.selectable, |this| { - this.when(selected || right_clicked, |this| { - this.child( - div() - .absolute() - .top(px(0.)) - .left(px(0.)) - .right(px(0.)) - .bottom(px(0.)) - .when(selected, |this| this.bg(cx.theme().element_background)) - .border_1() - .border_color(cx.theme().border_selected), - ) - }) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, ev: &MouseDownEvent, window, cx| { - this.right_clicked_index = None; - this.selected_index = Some(ix); - this.on_action_confirm( - &Confirm { - secondary: ev.modifiers.secondary(), - }, - window, - cx, - ); - }), - ) + .overflow_hidden() + .children(self.delegate.render_item(ix, window, cx).map(|item| { + item.selected(selected) + .secondary_selected(mouse_right_clicked) + })) + .when(selectable, |this| { + this.on_click(cx.listener(move |this, e: &ClickEvent, window, cx| { + this.set_right_clicked_index(None, window, cx); + this.selected_index = Some(ix); + this.on_action_confirm( + &Confirm { + secondary: e.modifiers().secondary(), + }, + window, + cx, + ); + })) .on_mouse_down( MouseButton::Right, - cx.listener(move |this, _, _, cx| { - this.right_clicked_index = Some(ix); + cx.listener(move |this, _, window, cx| { + this.set_right_clicked_index(Some(ix), window, cx); cx.notify(); }), ) }) } + + fn render_items( + &mut self, + items_count: usize, + entities_count: usize, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let rows_cache = self.rows_cache.clone(); + let scrollbar_visible = self.options.scrollbar_visible; + let scroll_handle = self.scroll_handle.clone(); + + v_flex() + .flex_grow() + .relative() + .size_full() + .when_some(self.options.max_height, |this, h| this.max_h(h)) + .overflow_hidden() + .when(items_count == 0, |this| { + this.child(self.delegate.render_empty(window, cx)) + }) + .when(items_count > 0, { + |this| { + this.child( + uniform_list( + "virtual-list", + rows_cache.items_count(), + cx.processor(move |this, range: Range, window, cx| { + this.load_more_if_need(entities_count, range.end, window, cx); + + // NOTE: Here the v_virtual_list would not able to have gap_y, + // because the section header, footer is always have rendered as a empty child item, + // even the delegate give a None result. + + range + .map(|ix| { + let Some(entry) = rows_cache.get(ix) else { + return div(); + }; + + div().children(match entry { + RowEntry::Entry(index) => Some( + this.render_list_item(index, window, cx) + .into_any_element(), + ), + RowEntry::SectionHeader(section_ix) => this + .delegate_mut() + .render_section_header(section_ix, window, cx) + .map(|r| r.into_any_element()), + RowEntry::SectionFooter(section_ix) => this + .delegate_mut() + .render_section_footer(section_ix, window, cx) + .map(|r| r.into_any_element()), + }) + }) + .collect::>() + }), + ) + .when(self.options.max_height.is_some(), |this| { + this.with_sizing_behavior(ListSizingBehavior::Infer) + }) + .track_scroll(&scroll_handle) + .into_any_element(), + ) + } + }) + .when(scrollbar_visible, |this| { + this.child(Scrollbar::vertical(&scroll_handle)) + }) + } } -impl Focusable for List +impl Focusable for ListState where D: ListDelegate, { fn focus_handle(&self, cx: &App) -> FocusHandle { - if let Some(query_input) = &self.query_input { - query_input.focus_handle(cx) + if self.searchable { + self.query_input.focus_handle(cx) } else { self.focus_handle.clone() } } } -impl EventEmitter for List where D: ListDelegate {} -impl Render for List +impl EventEmitter for ListState where D: ListDelegate {} +impl Render for ListState where D: ListDelegate, { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let vertical_scroll_handle = self.vertical_scroll_handle.clone(); - let items_count = self.delegate.items_count(cx); - let loading = self.delegate.loading(cx); - let sizing_behavior = if self.max_height.is_some() { - ListSizingBehavior::Infer + self.prepare_items_if_needed(window, cx); + + // Scroll to the selected item if it is set. + if let Some((ix, strategy)) = self.deferred_scroll_to_index.take() { + if let Some(item_ix) = self.rows_cache.position_of(&ix) { + self.scroll_handle.scroll_to_item(item_ix, strategy); + } + } + + let loading = self.delegate().loading(cx); + let query_input = if self.searchable { + // sync placeholder + if let Some(placeholder) = &self.options.search_placeholder { + self.query_input.update(cx, |input, cx| { + input.set_placeholder(placeholder.clone(), window, cx); + }); + } + Some(self.query_input.clone()) } else { - ListSizingBehavior::Auto + None }; - let initial_view = if let Some(input) = &self.query_input { + let loading_view = if loading { + Some(self.delegate.render_loading(window, cx).into_any_element()) + } else { + None + }; + let initial_view = if let Some(input) = &query_input { if input.read(cx).value().is_empty() { - self.delegate().render_initial(window, cx) + self.delegate.render_initial(window, cx) } else { None } } else { None }; + let items_count = self.rows_cache.items_count(); + let entities_count = self.rows_cache.len(); + let mouse_right_clicked_index = self.mouse_right_clicked_index; v_flex() .key_context("List") - .id("list") + .id("list-state") .track_focus(&self.focus_handle) .size_full() .relative() .overflow_hidden() - .when_some(self.query_input.clone(), |this, input| { + .when_some(query_input, |this, input| { this.child( div() - .map(|this| match self.size { - Size::Small => this.py_0().px_1p5(), - _ => this.py_1().px_2(), + .map(|this| match self.options.size { + Size::Small => this.px_1p5(), + _ => this.px_2(), }) .border_b_1() .border_color(cx.theme().border) .child( TextInput::new(&input) - .with_size(self.size) + .with_size(self.options.size) + .appearance(false) + .cleanable() + .p_0() .prefix( Icon::new(IconName::Search).text_color(cx.theme().text_muted), - ) - .cleanable() - .appearance(false), + ), ), ) }) - .when(loading, |this| { - this.child(self.delegate().render_loading(window, cx)) - }) .when(!loading, |this| { this.on_action(cx.listener(Self::on_action_cancel)) .on_action(cx.listener(Self::on_action_confirm)) - .on_action(cx.listener(Self::on_select_next)) - .on_action(cx.listener(Self::on_select_prev)) + .on_action(cx.listener(Self::on_action_select_next)) + .on_action(cx.listener(Self::on_action_select_prev)) .map(|this| { if let Some(view) = initial_view { this.child(view) } else { - this.child( - v_flex() - .flex_grow() - .relative() - .when_some(self.max_height, |this, h| this.max_h(h)) - .overflow_hidden() - .when(items_count == 0, |this| { - this.child(self.delegate().render_empty(window, cx)) - }) - .when(items_count > 0, |this| { - this.child( - uniform_list( - "list", - items_count, - cx.processor( - move |list, range: Range, window, cx| { - list.load_more_if_need( - items_count, - range.end, - window, - cx, - ); - - range - .map(|ix| { - list.render_list_item( - ix, window, cx, - ) - }) - .collect::>() - }, - ), - ) - .flex_grow() - .with_sizing_behavior(sizing_behavior) - .track_scroll(&vertical_scroll_handle) - .into_any_element(), - ) - }) - .children(self.render_scrollbar(window, cx)), - ) + this.child(self.render_items(items_count, entities_count, window, cx)) } }) // Click out to cancel right clicked row - .when(self.right_clicked_index.is_some(), |this| { - this.on_mouse_down_out(cx.listener(|this, _, _, cx| { - this.right_clicked_index = None; + .when(mouse_right_clicked_index.is_some(), |this| { + this.on_mouse_down_out(cx.listener(|this, _, window, cx| { + this.set_right_clicked_index(None, window, cx); cx.notify(); })) }) }) + .children(loading_view) + } +} + +/// The List element. +#[derive(IntoElement)] +pub struct List { + state: Entity>, + style: StyleRefinement, + options: ListOptions, +} + +impl List +where + D: ListDelegate + 'static, +{ + /// Create a new List element with the given ListState entity. + pub fn new(state: &Entity>) -> Self { + Self { + state: state.clone(), + style: StyleRefinement::default(), + options: ListOptions::default(), + } + } + + /// Set whether the scrollbar is visible, default is `true`. + pub fn scrollbar_visible(mut self, visible: bool) -> Self { + self.options.scrollbar_visible = visible; + self + } + + /// Sets the placeholder text for the search input. + pub fn search_placeholder(mut self, placeholder: impl Into) -> Self { + self.options.search_placeholder = Some(placeholder.into()); + self + } +} + +impl Styled for List +where + D: ListDelegate + 'static, +{ + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Sizable for List +where + D: ListDelegate + 'static, +{ + fn with_size(mut self, size: impl Into) -> Self { + self.options.size = size.into(); + self + } +} + +impl RenderOnce for List +where + D: ListDelegate + 'static, +{ + fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement { + // Take paddings, max_height to options, and clear them from style, + // because they would be applied to the inner virtual list. + self.options.paddings = self.style.padding.clone(); + self.options.max_height = self.style.max_size.height; + self.style.padding = EdgesRefinement::default(); + self.style.max_size.height = None; + + self.state.update(cx, |state, _| { + state.options = self.options; + }); + + div() + .id("list") + .size_full() + .refine_style(&self.style) + .child(self.state.clone()) } } diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs index 6a14edb..c016c4b 100644 --- a/crates/ui/src/list/list_item.rs +++ b/crates/ui/src/list/list_item.rs @@ -1,39 +1,54 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, MouseButton, - MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, Styled, - Window, + div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, + MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, }; use smallvec::SmallVec; use theme::ActiveTheme; -use crate::{h_flex, Disableable, Icon, IconName, Selectable, Sizable as _}; +use crate::{h_flex, Disableable, Icon, Selectable, Sizable as _, StyledExt}; -type OnClick = Option>; -type OnMouseEnter = Option>; -type Suffix = Option AnyElement + 'static>>; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum ListItemMode { + #[default] + Entry, + Separator, +} + +impl ListItemMode { + #[inline] + fn is_separator(&self) -> bool { + matches!(self, ListItemMode::Separator) + } +} #[derive(IntoElement)] pub struct ListItem { base: Stateful
, + mode: ListItemMode, + style: StyleRefinement, disabled: bool, selected: bool, + secondary_selected: bool, confirmed: bool, check_icon: Option, - on_click: OnClick, - on_mouse_enter: OnMouseEnter, - suffix: Suffix, + on_click: Option>, + on_mouse_enter: Option>, + suffix: Option AnyElement + 'static>>, children: SmallVec<[AnyElement; 2]>, } impl ListItem { pub fn new(id: impl Into) -> Self { let id: ElementId = id.into(); - Self { - base: h_flex().id(id).gap_x_1().py_1().px_2().text_base(), + mode: ListItemMode::Entry, + base: h_flex().id(id), + style: StyleRefinement::default(), disabled: false, selected: false, + secondary_selected: false, confirmed: false, on_click: None, on_mouse_enter: None, @@ -43,9 +58,15 @@ impl ListItem { } } + /// Set this list item to as a separator, it not able to be selected. + pub fn separator(mut self) -> Self { + self.mode = ListItemMode::Separator; + self + } + /// Set to show check icon, default is None. - pub fn check_icon(mut self, icon: IconName) -> Self { - self.check_icon = Some(Icon::new(icon)); + pub fn check_icon(mut self, icon: impl Into) -> Self { + self.check_icon = Some(icon.into()); self } @@ -111,11 +132,16 @@ impl Selectable for ListItem { fn is_selected(&self) -> bool { self.selected } + + fn secondary_selected(mut self, selected: bool) -> Self { + self.secondary_selected = selected; + self + } } impl Styled for ListItem { fn style(&mut self) -> &mut gpui::StyleRefinement { - self.base.style() + &mut self.style } } @@ -127,35 +153,37 @@ impl ParentElement for ListItem { impl RenderOnce for ListItem { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let is_active = self.selected || self.confirmed; + let is_active = self.confirmed || self.selected; + + let corner_radii = self.style.corner_radii.clone(); + + let mut selected_style = StyleRefinement::default(); + selected_style.corner_radii = corner_radii; + + let is_selectable = !(self.disabled || self.mode.is_separator()); self.base + .relative() + .gap_x_1() + .py_1() + .px_3() + .text_base() .text_color(cx.theme().text) .relative() .items_center() .justify_between() - .when_some(self.on_click, |this, on_click| { - if !self.disabled { - this.cursor_pointer() - .on_mouse_down(MouseButton::Left, move |_, _window, cx| { - cx.stop_propagation(); - }) - .on_click(on_click) - } else { - this - } + .refine_style(&self.style) + .when(is_selectable, |this| { + this.when_some(self.on_click, |this, on_click| this.on_click(on_click)) + .when_some(self.on_mouse_enter, |this, on_mouse_enter| { + this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx)) + }) + .when(!is_active, |this| { + this.hover(|this| this.bg(cx.theme().ghost_element_hover)) + }) }) - .when(is_active, |this| this.bg(cx.theme().element_active)) - .when(!is_active && !self.disabled, |this| { - this.hover(|this| this.bg(cx.theme().elevated_surface_background)) - }) - // Mouse enter - .when_some(self.on_mouse_enter, |this, on_mouse_enter| { - if !self.disabled { - this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx)) - } else { - this - } + .when(!is_selectable, |this| { + this.text_color(cx.theme().text_muted) }) .child( h_flex() @@ -177,5 +205,17 @@ impl RenderOnce for ListItem { }), ) .when_some(self.suffix, |this, suffix| this.child(suffix(window, cx))) + .map(|this| { + if is_selectable && (self.selected || self.secondary_selected) { + let bg = if self.selected { + cx.theme().ghost_element_active + } else { + cx.theme().ghost_element_background + }; + this.bg(bg) + } else { + this + } + }) } } diff --git a/crates/ui/src/list/loading.rs b/crates/ui/src/list/loading.rs index 8c3fa21..9ad64d0 100644 --- a/crates/ui/src/list/loading.rs +++ b/crates/ui/src/list/loading.rs @@ -17,7 +17,7 @@ impl RenderOnce for LoadingItem { .gap_1p5() .overflow_hidden() .child(Skeleton::new().h_5().w_48().max_w_full()) - .child(Skeleton::new().secondary(true).h_3().w_64().max_w_full()), + .child(Skeleton::new().secondary().h_3().w_64().max_w_full()), ) } } diff --git a/crates/ui/src/list/mod.rs b/crates/ui/src/list/mod.rs index 88baf0f..dbba734 100644 --- a/crates/ui/src/list/mod.rs +++ b/crates/ui/src/list/mod.rs @@ -1,7 +1,27 @@ -#[allow(clippy::module_inception)] +pub(crate) mod cache; +mod delegate; mod list; mod list_item; mod loading; +mod separator_item; +pub use delegate::*; pub use list::*; pub use list_item::*; +pub use separator_item::*; +use serde::{Deserialize, Serialize}; + +/// Settings for List. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListSettings { + /// Whether to use active highlight style on ListItem, default + pub active_highlight: bool, +} + +impl Default for ListSettings { + fn default() -> Self { + Self { + active_highlight: true, + } + } +} diff --git a/crates/ui/src/list/separator_item.rs b/crates/ui/src/list/separator_item.rs new file mode 100644 index 0000000..4e73f7e --- /dev/null +++ b/crates/ui/src/list/separator_item.rs @@ -0,0 +1,44 @@ +use gpui::{AnyElement, ParentElement, RenderOnce, StyleRefinement}; +use smallvec::SmallVec; + +use crate::list::ListItem; +use crate::{Selectable, StyledExt}; + +pub struct ListSeparatorItem { + style: StyleRefinement, + children: SmallVec<[AnyElement; 2]>, +} + +impl ListSeparatorItem { + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + children: SmallVec::new(), + } + } +} + +impl ParentElement for ListSeparatorItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Selectable for ListSeparatorItem { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } +} + +impl RenderOnce for ListSeparatorItem { + fn render(self, _: &mut gpui::Window, _: &mut gpui::App) -> impl gpui::IntoElement { + ListItem::new("separator") + .refine_style(&self.style) + .children(self.children) + .disabled(true) + } +} diff --git a/crates/ui/src/menu/app_menu_bar.rs b/crates/ui/src/menu/app_menu_bar.rs index 9548a49..5ca920b 100644 --- a/crates/ui/src/menu/app_menu_bar.rs +++ b/crates/ui/src/menu/app_menu_bar.rs @@ -1,16 +1,17 @@ use gpui::prelude::FluentBuilder; use gpui::{ anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, - Focusable, InteractiveElement as _, IntoElement, KeyBinding, OwnedMenu, ParentElement, Render, - SharedString, StatefulInteractiveElement, Styled, Subscription, Window, + Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu, + ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, }; use crate::actions::{Cancel, SelectLeft, SelectRight}; use crate::button::{Button, ButtonVariants}; -use crate::popup_menu::PopupMenu; +use crate::menu::PopupMenu; use crate::{h_flex, Selectable, Sizable}; const CONTEXT: &str = "AppMenuBar"; + pub fn init(cx: &mut App) { cx.bind_keys([ KeyBinding::new("escape", Cancel, Some(CONTEXT)), @@ -22,67 +23,74 @@ pub fn init(cx: &mut App) { /// The application menu bar, for Windows and Linux. pub struct AppMenuBar { menus: Vec>, - selected_ix: Option, + selected_index: Option, } impl AppMenuBar { /// Create a new app menu bar. - pub fn new(window: &mut Window, cx: &mut App) -> Entity { + pub fn new(cx: &mut App) -> Entity { cx.new(|cx| { - let menu_bar = cx.entity(); - let menus = cx - .get_menus() - .unwrap_or_default() - .iter() - .enumerate() - .map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), window, cx)) - .collect(); - - Self { - selected_ix: None, - menus, - } + let mut this = Self { + selected_index: None, + menus: Vec::new(), + }; + this.reload(cx); + this }) } - fn move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context) { - let Some(selected_ix) = self.selected_ix else { + /// Reload the menus from the app. + pub fn reload(&mut self, cx: &mut Context) { + let menu_bar = cx.entity(); + self.menus = cx + .get_menus() + .unwrap_or_default() + .iter() + .enumerate() + .map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), cx)) + .collect(); + self.selected_index = None; + cx.notify(); + } + + fn on_move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context) { + let Some(selected_index) = self.selected_index else { return; }; - let new_ix = if selected_ix == 0 { + let new_ix = if selected_index == 0 { self.menus.len().saturating_sub(1) } else { - selected_ix.saturating_sub(1) + selected_index.saturating_sub(1) }; - self.set_selected_ix(Some(new_ix), window, cx); + self.set_selected_index(Some(new_ix), window, cx); } - fn move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context) { - let Some(selected_ix) = self.selected_ix else { + fn on_move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context) { + let Some(selected_index) = self.selected_index else { return; }; - let new_ix = if selected_ix + 1 >= self.menus.len() { + let new_ix = if selected_index + 1 >= self.menus.len() { 0 } else { - selected_ix + 1 + selected_index + 1 }; - self.set_selected_ix(Some(new_ix), window, cx); + self.set_selected_index(Some(new_ix), window, cx); } - fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - self.set_selected_ix(None, window, cx); + fn on_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + self.set_selected_index(None, window, cx); } - fn set_selected_ix(&mut self, ix: Option, _: &mut Window, cx: &mut Context) { - self.selected_ix = ix; + fn set_selected_index(&mut self, ix: Option, _: &mut Window, cx: &mut Context) { + self.selected_index = ix; cx.notify(); } #[inline] fn has_activated_menu(&self) -> bool { - self.selected_ix.is_some() + self.selected_index.is_some() } } @@ -91,9 +99,9 @@ impl Render for AppMenuBar { h_flex() .id("app-menu-bar") .key_context(CONTEXT) - .on_action(cx.listener(Self::move_left)) - .on_action(cx.listener(Self::move_right)) - .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::on_move_left)) + .on_action(cx.listener(Self::on_move_right)) + .on_action(cx.listener(Self::on_cancel)) .size_full() .gap_x_1() .overflow_x_scroll() @@ -117,7 +125,6 @@ impl AppMenu { ix: usize, menu: &OwnedMenu, menu_bar: Entity, - _: &mut Window, cx: &mut App, ) -> Entity { let name = menu.name.clone(); @@ -173,7 +180,7 @@ impl AppMenu { self._subscription.take(); self.popup_menu.take(); self.menu_bar.update(cx, |state, cx| { - state.cancel(&Cancel, window, cx); + state.on_cancel(&Cancel, window, cx); }); } @@ -183,11 +190,11 @@ impl AppMenu { window: &mut Window, cx: &mut Context, ) { - let is_selected = self.menu_bar.read(cx).selected_ix == Some(self.ix); + let is_selected = self.menu_bar.read(cx).selected_index == Some(self.ix); - self.menu_bar.update(cx, |state, cx| { + _ = self.menu_bar.update(cx, |state, cx| { let new_ix = if is_selected { None } else { Some(self.ix) }; - state.set_selected_ix(new_ix, window, cx); + state.set_selected_index(new_ix, window, cx); }); } @@ -201,8 +208,8 @@ impl AppMenu { return; } - self.menu_bar.update(cx, |state, cx| { - state.set_selected_ix(Some(self.ix), window, cx); + _ = self.menu_bar.update(cx, |state, cx| { + state.set_selected_index(Some(self.ix), window, cx); }); } } @@ -210,7 +217,7 @@ impl AppMenu { impl Render for AppMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let menu_bar = self.menu_bar.read(cx); - let is_selected = menu_bar.selected_ix == Some(self.ix); + let is_selected = menu_bar.selected_index == Some(self.ix); div() .id(self.ix) @@ -219,10 +226,15 @@ impl Render for AppMenu { Button::new("menu") .small() .py_0p5() - .xsmall() + .compact() .ghost() .label(self.name.clone()) .selected(is_selected) + .on_mouse_down(MouseButton::Left, |_, window, cx| { + // Stop propagation to avoid dragging the window. + window.prevent_default(); + cx.stop_propagation(); + }) .on_click(cx.listener(Self::handle_trigger_click)), ) .on_hover(cx.listener(Self::handle_hover)) diff --git a/crates/ui/src/menu/context_menu.rs b/crates/ui/src/menu/context_menu.rs index 2984adc..1a492fb 100644 --- a/crates/ui/src/menu/context_menu.rs +++ b/crates/ui/src/menu/context_menu.rs @@ -3,49 +3,65 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, deferred, div, px, relative, AnyElement, App, Context, Corner, DismissEvent, Element, - ElementId, Entity, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, - IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Position, Stateful, - Style, Subscription, Window, + anchored, deferred, div, px, AnyElement, App, Context, Corner, DismissEvent, Element, + ElementId, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, + InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, + StyleRefinement, Styled, Subscription, Window, }; -use crate::popup_menu::PopupMenu; +use crate::menu::PopupMenu; -pub trait ContextMenuExt: ParentElement + Sized { +/// A extension trait for adding a context menu to an element. +pub trait ContextMenuExt: ParentElement + Styled { + /// Add a context menu to the element. + /// + /// This will changed the element to be `relative` positioned, and add a child `ContextMenu` element. + /// Because the `ContextMenu` element is positioned `absolute`, it will not affect the layout of the parent element. fn context_menu( self, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - self.child(ContextMenu::new("context-menu").menu(f)) + ) -> ContextMenu + where + Self: Sized, + { + // Generate a unique ID based on the element's memory address to ensure + // each context menu has its own state and doesn't share with others + let id = format!("context-menu-{:p}", &self as *const _); + ContextMenu::new(id, self).menu(f) } } -impl ContextMenuExt for Stateful where E: ParentElement {} +impl ContextMenuExt for E {} /// A context menu that can be shown on right-click. -#[allow(clippy::type_complexity)] -pub struct ContextMenu { +pub struct ContextMenu { id: ElementId, - menu: - Option) -> PopupMenu + 'static>>, + element: Option, + menu: Option) -> PopupMenu>>, + // This is not in use, just for style refinement forwarding. + _ignore_style: StyleRefinement, anchor: Corner, } -impl ContextMenu { - pub fn new(id: impl Into) -> Self { +impl ContextMenu { + /// Create a new context menu with the given ID. + pub fn new(id: impl Into, element: E) -> Self { Self { id: id.into(), + element: Some(element), menu: None, anchor: Corner::TopLeft, + _ignore_style: StyleRefinement::default(), } } + /// Build the context menu using the given builder function. #[must_use] - pub fn menu(mut self, builder: F) -> Self + fn menu(mut self, builder: F) -> Self where F: Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, { - self.menu = Some(Box::new(builder)); + self.menu = Some(Rc::new(builder)); self } @@ -67,7 +83,25 @@ impl ContextMenu { } } -impl IntoElement for ContextMenu { +impl ParentElement for ContextMenu { + fn extend(&mut self, elements: impl IntoIterator) { + if let Some(element) = &mut self.element { + element.extend(elements); + } + } +} + +impl Styled for ContextMenu { + fn style(&mut self) -> &mut StyleRefinement { + if let Some(element) = &mut self.element { + element.style() + } else { + &mut self._ignore_style + } + } +} + +impl IntoElement for ContextMenu { type Element = Self; fn into_element(self) -> Self::Element { @@ -83,14 +117,14 @@ struct ContextMenuSharedState { } pub struct ContextMenuState { - menu_element: Option, + element: Option, shared_state: Rc>, } impl Default for ContextMenuState { fn default() -> Self { Self { - menu_element: None, + element: None, shared_state: Rc::new(RefCell::new(ContextMenuSharedState { menu_view: None, open: false, @@ -101,8 +135,8 @@ impl Default for ContextMenuState { } } -impl Element for ContextMenu { - type PrepaintState = (); +impl Element for ContextMenu { + type PrepaintState = Hitbox; type RequestLayoutState = ContextMenuState; fn id(&self) -> Option { @@ -113,7 +147,6 @@ impl Element for ContextMenu { None } - #[allow(clippy::field_reassign_with_default)] fn request_layout( &mut self, id: Option<&gpui::GlobalElementId>, @@ -121,71 +154,73 @@ impl Element for ContextMenu { window: &mut Window, cx: &mut App, ) -> (gpui::LayoutId, Self::RequestLayoutState) { - let mut style = Style::default(); - // Set the layout style relative to the table view to get same size. - style.position = Position::Absolute; - style.flex_grow = 1.0; - style.flex_shrink = 1.0; - style.size.width = relative(1.).into(); - style.size.height = relative(1.).into(); - let anchor = self.anchor; self.with_element_state( id.unwrap(), window, cx, - |_, state: &mut ContextMenuState, window, cx| { + |this, state: &mut ContextMenuState, window, cx| { let (position, open) = { let shared_state = state.shared_state.borrow(); (shared_state.position, shared_state.open) }; let menu_view = state.shared_state.borrow().menu_view.clone(); - let (menu_element, menu_layout_id) = if open { + let mut menu_element = None; + if open { let has_menu_item = menu_view .as_ref() .map(|menu| !menu.read(cx).is_empty()) .unwrap_or(false); if has_menu_item { - let mut menu_element = deferred( - anchored() - .position(position) - .snap_to_window_with_margin(px(8.)) - .anchor(anchor) - .when_some(menu_view, |this, menu| { - // Focus the menu, so that can be handle the action. - if !menu.focus_handle(cx).contains_focused(window, cx) { - menu.focus_handle(cx).focus(window, cx); - } + menu_element = Some( + deferred( + anchored().child( + div() + .w(window.bounds().size.width) + .h(window.bounds().size.height) + .on_scroll_wheel(|_, _, cx| { + cx.stop_propagation(); + }) + .child( + anchored() + .position(position) + .snap_to_window_with_margin(px(8.)) + .anchor(anchor) + .when_some(menu_view, |this, menu| { + // Focus the menu, so that can be handle the action. + if !menu + .focus_handle(cx) + .contains_focused(window, cx) + { + menu.focus_handle(cx).focus(window, cx); + } - this.child(div().occlude().child(menu.clone())) - }), - ) - .with_priority(1) - .into_any(); - - let menu_layout_id = menu_element.request_layout(window, cx); - (Some(menu_element), Some(menu_layout_id)) - } else { - (None, None) + this.child(menu.clone()) + }), + ), + ), + ) + .with_priority(1) + .into_any(), + ); } - } else { - (None, None) - }; - - let mut layout_ids = vec![]; - if let Some(menu_layout_id) = menu_layout_id { - layout_ids.push(menu_layout_id); } - let layout_id = window.request_layout(style, layout_ids, cx); + let mut element = this + .element + .take() + .expect("Element should exists.") + .children(menu_element) + .into_any_element(); + + let layout_id = element.request_layout(window, cx); ( layout_id, ContextMenuState { - menu_element, - + element: Some(element), ..Default::default() }, ) @@ -197,33 +232,33 @@ impl Element for ContextMenu { &mut self, _: Option<&gpui::GlobalElementId>, _: Option<&InspectorElementId>, - _: gpui::Bounds, + bounds: gpui::Bounds, request_layout: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { - if let Some(menu_element) = &mut request_layout.menu_element { - menu_element.prepaint(window, cx); + if let Some(element) = &mut request_layout.element { + element.prepaint(window, cx); } + window.insert_hitbox(bounds, HitboxBehavior::Normal) } fn paint( &mut self, id: Option<&gpui::GlobalElementId>, _: Option<&InspectorElementId>, - bounds: gpui::Bounds, + _: gpui::Bounds, request_layout: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, + hitbox: &mut Self::PrepaintState, window: &mut Window, cx: &mut App, ) { - if let Some(menu_element) = &mut request_layout.menu_element { - menu_element.paint(window, cx); + if let Some(element) = &mut request_layout.element { + element.paint(window, cx); } - let Some(builder) = self.menu.take() else { - return; - }; + // Take the builder before setting up element state to avoid borrow issues + let builder = self.menu.clone(); self.with_element_state( id.unwrap(), @@ -232,33 +267,53 @@ impl Element for ContextMenu { |_view, state: &mut ContextMenuState, window, _| { let shared_state = state.shared_state.clone(); + let hitbox = hitbox.clone(); // When right mouse click, to build content menu, and show it at the mouse position. window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { if phase.bubble() && event.button == MouseButton::Right - && bounds.contains(&event.position) + && hitbox.is_hovered(window) { { let mut shared_state = shared_state.borrow_mut(); + // Clear any existing menu view to allow immediate replacement + // Set the new position and open the menu + shared_state.menu_view = None; + shared_state._subscription = None; shared_state.position = event.position; shared_state.open = true; } - let menu = PopupMenu::build(window, cx, |menu, window, cx| { - (builder)(menu, window, cx) - }); - - let _subscription = window.subscribe(&menu, cx, { + // Use defer to build the menu in the next frame, avoiding race conditions + window.defer(cx, { let shared_state = shared_state.clone(); - move |_, _: &DismissEvent, window, _| { - shared_state.borrow_mut().open = false; - window.refresh(); + let builder = builder.clone(); + move |window, cx| { + let menu = PopupMenu::build(window, cx, move |menu, window, cx| { + let Some(build) = &builder else { + return menu; + }; + build(menu, window, cx) + }); + + // Set up the subscription for dismiss handling + let _subscription = window.subscribe(&menu, cx, { + let shared_state = shared_state.clone(); + move |_, _: &DismissEvent, window, _cx| { + shared_state.borrow_mut().open = false; + window.refresh(); + } + }); + + // Update the shared state with the built menu and subscription + { + let mut state = shared_state.borrow_mut(); + state.menu_view = Some(menu.clone()); + state._subscription = Some(_subscription); + window.refresh(); + } } }); - - shared_state.borrow_mut().menu_view = Some(menu.clone()); - shared_state.borrow_mut()._subscription = Some(_subscription); - window.refresh(); } }); }, diff --git a/crates/ui/src/menu/dropdown_menu.rs b/crates/ui/src/menu/dropdown_menu.rs new file mode 100644 index 0000000..7e422d8 --- /dev/null +++ b/crates/ui/src/menu/dropdown_menu.rs @@ -0,0 +1,141 @@ +use std::rc::Rc; + +use gpui::{ + Context, Corner, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement, + RenderOnce, SharedString, StyleRefinement, Styled, Window, +}; + +use crate::button::Button; +use crate::menu::PopupMenu; +use crate::popover::Popover; +use crate::Selectable; + +/// A dropdown menu trait for buttons and other interactive elements +pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { + /// Create a dropdown menu with the given items, anchored to the TopLeft corner + fn dropdown_menu( + self, + f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> DropdownMenuPopover { + self.dropdown_menu_with_anchor(Corner::TopLeft, f) + } + + /// Create a dropdown menu with the given items, anchored to the given corner + fn dropdown_menu_with_anchor( + mut self, + anchor: impl Into, + f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> DropdownMenuPopover { + 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 {} + +#[derive(IntoElement)] +pub struct DropdownMenuPopover { + id: ElementId, + style: StyleRefinement, + anchor: Corner, + trigger: T, + builder: Rc) -> PopupMenu>, +} + +impl DropdownMenuPopover +where + T: Selectable + IntoElement + 'static, +{ + fn new( + id: ElementId, + anchor: impl Into, + trigger: T, + builder: impl Fn(PopupMenu, &mut Window, &mut Context) -> 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) -> 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>, +} + +impl RenderOnce for DropdownMenuPopover +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) + .overlay_closable(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, cx); + + // 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() + }) + } +} diff --git a/crates/ui/src/menu/menu_item.rs b/crates/ui/src/menu/menu_item.rs index 95f2b7f..f387100 100644 --- a/crates/ui/src/menu/menu_item.rs +++ b/crates/ui/src/menu/menu_item.rs @@ -10,7 +10,6 @@ use theme::ActiveTheme; use crate::{h_flex, Disableable, StyledExt}; #[derive(IntoElement)] -#[allow(clippy::type_complexity)] pub(crate) struct MenuItemElement { id: ElementId, group_name: SharedString, @@ -23,7 +22,8 @@ pub(crate) struct MenuItemElement { } impl MenuItemElement { - pub fn new(id: impl Into, group_name: impl Into) -> Self { + /// Create a new MenuItem with the given ID and group name. + pub(crate) fn new(id: impl Into, group_name: impl Into) -> Self { let id: ElementId = id.into(); Self { id: id.clone(), @@ -38,17 +38,19 @@ impl MenuItemElement { } /// Set ListItem as the selected item style. - pub fn selected(mut self, selected: bool) -> Self { + pub(crate) fn selected(mut self, selected: bool) -> Self { self.selected = selected; self } - pub fn disabled(mut self, disabled: bool) -> Self { + /// Set the disabled state of the MenuItem. + pub(crate) fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self } - pub fn on_click( + /// Set a handler for when the MenuItem is clicked. + pub(crate) fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { @@ -88,7 +90,7 @@ impl RenderOnce for MenuItemElement { h_flex() .id(self.id) .group(&self.group_name) - .gap_x_2() + .gap_x_1() .py_1() .px_2() .text_base() @@ -102,12 +104,12 @@ impl RenderOnce for MenuItemElement { }) .when(!self.disabled, |this| { this.group_hover(self.group_name, |this| { - this.bg(cx.theme().elevated_surface_background) - .text_color(cx.theme().text) + this.bg(cx.theme().element_background) + .text_color(cx.theme().element_foreground) }) .when(self.selected, |this| { - this.bg(cx.theme().elevated_surface_background) - .text_color(cx.theme().text) + this.bg(cx.theme().element_background) + .text_color(cx.theme().element_foreground) }) .when_some(self.on_click, |this, on_click| { this.on_mouse_down(MouseButton::Left, move |_, _, cx| { diff --git a/crates/ui/src/menu/mod.rs b/crates/ui/src/menu/mod.rs index 0d91c7f..3152a15 100644 --- a/crates/ui/src/menu/mod.rs +++ b/crates/ui/src/menu/mod.rs @@ -1,12 +1,15 @@ use gpui::App; mod app_menu_bar; +mod context_menu; +mod dropdown_menu; mod menu_item; - -pub mod context_menu; -pub mod popup_menu; +mod popup_menu; pub use app_menu_bar::AppMenuBar; +pub use context_menu::{ContextMenu, ContextMenuExt, ContextMenuState}; +pub use dropdown_menu::DropdownMenu; +pub use popup_menu::{PopupMenu, PopupMenuItem}; pub(crate) fn init(cx: &mut App) { app_menu_bar::init(cx); diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 2624f6e..f177e44 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -2,20 +2,19 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, Axis, Bounds, Context, + anchored, div, px, rems, Action, AnyElement, App, AppContext, Bounds, ClickEvent, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, IntoElement, KeyBinding, MouseDownEvent, OwnedMenuItem, ParentElement, - Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, - WeakEntity, Window, + Pixels, Point, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, + Subscription, WeakEntity, Window, }; use theme::ActiveTheme; use crate::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp}; -use crate::button::Button; +use crate::kbd::Kbd; use crate::menu::menu_item::MenuItemElement; -use crate::popover::Popover; -use crate::scroll::{Scrollbar, ScrollbarState}; -use crate::{h_flex, v_flex, Icon, IconName, Kbd, Selectable, Side, Sizable as _, Size, StyledExt}; +use crate::scroll::ScrollableElement; +use crate::{h_flex, v_flex, ElementExt, Icon, IconName, Side, Sizable as _, Size, StyledExt}; const CONTEXT: &str = "PopupMenu"; @@ -30,57 +29,35 @@ pub fn init(cx: &mut App) { ]); } -pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement + 'static { - /// Create a popup menu with the given items, anchored to the TopLeft corner - fn popup_menu( - self, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Popover { - self.popup_menu_with_anchor(Corner::TopLeft, f) - } - - /// Create a popup menu with the given items, anchored to the given corner - fn popup_menu_with_anchor( - mut self, - anchor: impl Into, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Popover { - let style = self.style().clone(); - let id = self.interactivity().element_id.clone(); - - Popover::new(SharedString::from(format!("popup-menu:{id:?}"))) - .no_style() - .trigger(self) - .trigger_style(style) - .anchor(anchor.into()) - .content(move |window, cx| { - PopupMenu::build(window, cx, |menu, window, cx| f(menu, window, cx)) - }) - } -} - -impl PopupMenuExt for Button {} - -#[allow(clippy::type_complexity)] -pub(crate) enum PopupMenuItem { +/// An menu item in a popup menu. +pub enum PopupMenuItem { + /// A menu separator item. Separator, + /// A non-interactive label item. Label(SharedString), + /// A standard menu item. Item { icon: Option, label: SharedString, disabled: bool, + checked: bool, is_link: bool, action: Option>, // For link item - handler: Option>, + handler: Option>, }, + /// A menu item with custom element render. ElementItem { icon: Option, disabled: bool, - action: Box, + checked: bool, + action: Option>, render: Box AnyElement + 'static>, - handler: Option>, + handler: Option>, }, + /// A submenu item that opens another popup menu. + /// + /// NOTE: This is only supported when the parent menu is not `scrollable`. Submenu { icon: Option, label: SharedString, @@ -89,7 +66,166 @@ pub(crate) enum PopupMenuItem { }, } +impl FluentBuilder for PopupMenuItem {} impl PopupMenuItem { + /// Create a new menu item with the given label. + #[inline] + pub fn new(label: impl Into) -> Self { + PopupMenuItem::Item { + icon: None, + label: label.into(), + disabled: false, + checked: false, + action: None, + is_link: false, + handler: None, + } + } + + /// Create a new menu item with custom element render. + #[inline] + pub fn element(builder: F) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + PopupMenuItem::ElementItem { + icon: None, + disabled: false, + checked: false, + action: None, + render: Box::new(move |window, cx| builder(window, cx).into_any_element()), + handler: None, + } + } + + /// Create a new submenu item that opens another popup menu. + #[inline] + pub fn submenu(label: impl Into, menu: Entity) -> Self { + PopupMenuItem::Submenu { + icon: None, + label: label.into(), + disabled: false, + menu, + } + } + + /// Create a separator menu item. + #[inline] + pub fn separator() -> Self { + PopupMenuItem::Separator + } + + /// Creates a label menu item. + #[inline] + pub fn label(label: impl Into) -> Self { + PopupMenuItem::Label(label.into()) + } + + /// Set the icon for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`]. + pub fn icon(mut self, icon: impl Into) -> Self { + match &mut self { + PopupMenuItem::Item { icon: i, .. } => { + *i = Some(icon.into()); + } + PopupMenuItem::ElementItem { icon: i, .. } => { + *i = Some(icon.into()); + } + PopupMenuItem::Submenu { icon: i, .. } => { + *i = Some(icon.into()); + } + _ => {} + } + self + } + + /// Set the action for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. + pub fn action(mut self, action: Box) -> Self { + match &mut self { + PopupMenuItem::Item { action: a, .. } => { + *a = Some(action); + } + PopupMenuItem::ElementItem { action: a, .. } => { + *a = Some(action); + } + _ => {} + } + self + } + + /// Set the disabled state for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`]. + pub fn disabled(mut self, disabled: bool) -> Self { + match &mut self { + PopupMenuItem::Item { disabled: d, .. } => { + *d = disabled; + } + PopupMenuItem::ElementItem { disabled: d, .. } => { + *d = disabled; + } + PopupMenuItem::Submenu { disabled: d, .. } => { + *d = disabled; + } + _ => {} + } + self + } + + /// Set checked state for the menu item. + /// + /// NOTE: If `check_side` is [`Side::Left`], the icon will replace with a check icon. + pub fn checked(mut self, checked: bool) -> Self { + match &mut self { + PopupMenuItem::Item { checked: c, .. } => { + *c = checked; + } + PopupMenuItem::ElementItem { checked: c, .. } => { + *c = checked; + } + _ => {} + } + self + } + + /// Add a click handler for the menu item. + /// + /// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`]. + pub fn on_click(mut self, handler: F) -> Self + where + F: Fn(&ClickEvent, &mut Window, &mut App) + 'static, + { + match &mut self { + PopupMenuItem::Item { handler: h, .. } => { + *h = Some(Rc::new(handler)); + } + PopupMenuItem::ElementItem { handler: h, .. } => { + *h = Some(Rc::new(handler)); + } + _ => {} + } + self + } + + /// Create a link menu item. + #[inline] + pub fn link(label: impl Into, href: impl Into) -> Self { + let href = href.into(); + PopupMenuItem::Item { + icon: None, + label: label.into(), + disabled: false, + checked: false, + action: None, + is_link: true, + handler: Some(Rc::new(move |_, _, cx| cx.open_url(&href))), + } + } + #[inline] fn is_clickable(&self) -> bool { !matches!(self, PopupMenuItem::Separator) @@ -112,6 +248,28 @@ impl PopupMenuItem { fn is_separator(&self) -> bool { matches!(self, PopupMenuItem::Separator) } + + fn has_left_icon(&self, check_side: Side) -> bool { + match self { + PopupMenuItem::Item { icon, checked, .. } => { + icon.is_some() || (check_side.is_left() && *checked) + } + PopupMenuItem::ElementItem { icon, checked, .. } => { + icon.is_some() || (check_side.is_left() && *checked) + } + PopupMenuItem::Submenu { icon, .. } => icon.is_some(), + _ => false, + } + } + + #[inline] + fn is_checked(&self) -> bool { + match self { + PopupMenuItem::Item { checked, .. } => *checked, + PopupMenuItem::ElementItem { checked, .. } => *checked, + _ => false, + } + } } pub struct PopupMenu { @@ -119,23 +277,19 @@ pub struct PopupMenu { pub(crate) menu_items: Vec, /// The focus handle of Entity to handle actions. pub(crate) action_context: Option, - - axis_items: Axis, - has_icon: bool, selected_index: Option, min_width: Option, max_width: Option, max_height: Option, bounds: Bounds, size: Size, - scrollable: bool, - external_link_icon: bool, - scroll_handle: ScrollHandle, - scroll_state: ScrollbarState, + check_side: Side, /// The parent menu of this menu, if this is a submenu parent_menu: Option>, - + scrollable: bool, + external_link_icon: bool, + scroll_handle: ScrollHandle, // This will update on render submenu_anchor: (Corner, Pixels), @@ -147,18 +301,16 @@ impl PopupMenu { Self { focus_handle: cx.focus_handle(), action_context: None, - axis_items: Axis::Vertical, parent_menu: None, menu_items: Vec::new(), selected_index: None, min_width: None, max_width: None, max_height: None, - has_icon: false, + check_side: Side::Left, bounds: Bounds::default(), scrollable: false, scroll_handle: ScrollHandle::default(), - scroll_state: ScrollbarState::default(), external_link_icon: true, size: Size::default(), submenu_anchor: (Corner::TopLeft, Pixels::ZERO), @@ -184,12 +336,6 @@ impl PopupMenu { self } - /// Set the axis of the popup menu, default is vertical - pub fn axis(mut self, axis: Axis) -> Self { - self.axis_items = axis; - self - } - /// Set min width of the popup menu, default is 120px pub fn min_w(mut self, width: impl Into) -> Self { self.min_width = Some(width.into()); @@ -211,8 +357,14 @@ impl PopupMenu { /// Set the menu to be scrollable to show vertical scrollbar. /// /// NOTE: If this is true, the sub-menus will cannot be support. - pub fn scrollable(mut self) -> Self { - self.scrollable = true; + pub fn scrollable(mut self, scrollable: bool) -> Self { + self.scrollable = scrollable; + self + } + + /// Set the side to show check icon, default is `Side::Left`. + pub fn check_side(mut self, side: Side) -> Self { + self.check_side = side; self } @@ -234,7 +386,7 @@ impl PopupMenu { action: Box, enable: bool, ) -> Self { - self.add_menu_item(label, None, action, !enable); + self.add_menu_item(label, None, action, !enable, false); self } @@ -245,13 +397,13 @@ impl PopupMenu { action: Box, disabled: bool, ) -> Self { - self.add_menu_item(label, None, action, disabled); + self.add_menu_item(label, None, action, disabled, false); self } /// Add label pub fn label(mut self, label: impl Into) -> Self { - self.menu_items.push(PopupMenuItem::Label(label.into())); + self.menu_items.push(PopupMenuItem::label(label.into())); self } @@ -268,14 +420,8 @@ impl PopupMenu { disabled: bool, ) -> Self { let href = href.into(); - self.menu_items.push(PopupMenuItem::Item { - icon: None, - label: label.into(), - disabled, - action: None, - is_link: true, - handler: Some(Rc::new(move |_, cx| cx.open_url(&href))), - }); + self.menu_items + .push(PopupMenuItem::link(label, href).disabled(disabled)); self } @@ -290,7 +436,7 @@ impl PopupMenu { } /// Add Menu to open link with icon and disabled state - pub fn link_with_icon_and_disabled( + fn link_with_icon_and_disabled( mut self, label: impl Into, icon: impl Into, @@ -298,14 +444,11 @@ impl PopupMenu { disabled: bool, ) -> Self { let href = href.into(); - self.menu_items.push(PopupMenuItem::Item { - icon: Some(icon.into()), - label: label.into(), - disabled, - action: None, - is_link: true, - handler: Some(Rc::new(move |_, cx| cx.open_url(&href))), - }); + self.menu_items.push( + PopupMenuItem::link(label, href) + .icon(icon) + .disabled(disabled), + ); self } @@ -327,7 +470,7 @@ impl PopupMenu { action: Box, disabled: bool, ) -> Self { - self.add_menu_item(label, Some(icon.into()), action, disabled); + self.add_menu_item(label, Some(icon.into()), action, disabled, false); self } @@ -349,12 +492,7 @@ impl PopupMenu { action: Box, disabled: bool, ) -> Self { - if checked { - self.add_menu_item(label, Some(IconName::Check.into()), action, disabled); - } else { - self.add_menu_item(label, None, action, disabled); - } - + self.add_menu_item(label, None, action, disabled, checked); self } @@ -395,29 +533,6 @@ impl PopupMenu { self.menu_element_with_icon_and_disabled(icon, action, false, builder) } - /// Add Menu Item with custom element render with icon and disabled state - pub fn menu_element_with_icon_and_disabled( - mut self, - icon: impl Into, - action: Box, - disabled: bool, - builder: F, - ) -> Self - where - F: Fn(&mut Window, &mut App) -> E + 'static, - E: IntoElement, - { - self.menu_items.push(PopupMenuItem::ElementItem { - render: Box::new(move |window, cx| builder(window, cx).into_any_element()), - action, - icon: Some(icon.into()), - disabled, - handler: None, - }); - self.has_icon = true; - self - } - /// Add Menu Item with custom element render with check state pub fn menu_element_with_check( self, @@ -432,8 +547,29 @@ impl PopupMenu { self.menu_element_with_check_and_disabled(checked, action, false, builder) } + /// Add Menu Item with custom element render with icon and disabled state + fn menu_element_with_icon_and_disabled( + mut self, + icon: impl Into, + action: Box, + disabled: bool, + builder: F, + ) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + self.menu_items.push( + PopupMenuItem::element(builder) + .action(action) + .icon(icon) + .disabled(disabled), + ); + self + } + /// Add Menu Item with custom element render with check state and disabled state - pub fn menu_element_with_check_and_disabled( + fn menu_element_with_check_and_disabled( mut self, checked: bool, action: Box, @@ -444,31 +580,12 @@ impl PopupMenu { F: Fn(&mut Window, &mut App) -> E + 'static, E: IntoElement, { - if checked { - self.menu_items.push(PopupMenuItem::ElementItem { - render: Box::new(move |window, cx| builder(window, cx).into_any_element()), - action, - handler: None, - icon: Some(IconName::Check.into()), - disabled, - }); - self.has_icon = true; - } else { - self.menu_items.push(PopupMenuItem::ElementItem { - render: Box::new(move |window, cx| builder(window, cx).into_any_element()), - action, - handler: None, - icon: None, - disabled, - }); - } - self - } - - /// Use small size, the menu item will have smaller height. - #[allow(dead_code)] - pub(crate) fn small(mut self) -> Self { - self.size = Size::Small; + self.menu_items.push( + PopupMenuItem::element(builder) + .action(action) + .checked(checked) + .disabled(disabled), + ); self } @@ -482,7 +599,7 @@ impl PopupMenu { return self; } - self.menu_items.push(PopupMenuItem::Separator); + self.menu_items.push(PopupMenuItem::separator()); self } @@ -497,36 +614,11 @@ impl PopupMenu { self.submenu_with_icon(None, label, window, cx, f) } - /// Add a Submenu item with disabled state - pub fn submenu_with_disabled( - self, - label: impl Into, - disabled: bool, - window: &mut Window, - cx: &mut Context, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - self.submenu_with_icon_with_disabled(None, label, disabled, window, cx, f) - } - /// Add a Submenu item with icon pub fn submenu_with_icon( - self, - icon: Option, - label: impl Into, - window: &mut Window, - cx: &mut Context, - f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, - ) -> Self { - self.submenu_with_icon_with_disabled(icon, label, false, window, cx, f) - } - - /// Add a Submenu item with icon and disabled state - pub fn submenu_with_icon_with_disabled( mut self, icon: Option, label: impl Into, - disabled: bool, window: &mut Window, cx: &mut Context, f: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, @@ -537,12 +629,22 @@ impl PopupMenu { view.parent_menu = Some(parent_menu); }); - self.menu_items.push(PopupMenuItem::Submenu { - icon, - label: label.into(), - menu: submenu, - disabled, - }); + self.menu_items.push( + PopupMenuItem::submenu(label, submenu).when_some(icon, |this, icon| this.icon(icon)), + ); + self + } + + /// Add menu item. + pub fn item(mut self, item: impl Into) -> Self { + let item: PopupMenuItem = item.into(); + self.menu_items.push(item); + self + } + + /// Use small size, the menu item will have smaller height. + pub(crate) fn small(mut self) -> Self { + self.size = Size::Small; self } @@ -552,19 +654,15 @@ impl PopupMenu { icon: Option, action: Box, disabled: bool, + checked: bool, ) -> &mut Self { - if icon.is_some() { - self.has_icon = true; - } - - self.menu_items.push(PopupMenuItem::Item { - icon, - label: label.into(), - disabled, - action: Some(action.boxed_clone()), - is_link: false, - handler: None, - }); + self.menu_items.push( + PopupMenuItem::new(label) + .when_some(icon, |item, icon| item.icon(icon)) + .disabled(disabled) + .checked(checked) + .action(action), + ); self } @@ -579,9 +677,12 @@ impl PopupMenu { { for item in items { match item.into() { - OwnedMenuItem::Action { name, action, .. } => { - self = self.menu(name, action.boxed_clone()) - } + OwnedMenuItem::Action { + name, + action, + checked, + .. + } => self = self.menu_with_check(name, checked, action.boxed_clone()), OwnedMenuItem::Separator => { self = self.separator(); } @@ -633,39 +734,41 @@ impl PopupMenu { } fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - if let Some(index) = self.selected_index { - let item = self.menu_items.get(index); + match self.selected_index { + Some(index) => { + let item = self.menu_items.get(index); + match item { + Some(PopupMenuItem::Item { + handler, action, .. + }) => { + if let Some(handler) = handler { + handler(&ClickEvent::default(), window, cx); + } else if let Some(action) = action.as_ref() { + self.dispatch_confirm_action(action, window, cx); + } - match item { - Some(PopupMenuItem::Item { - handler, action, .. - }) => { - if let Some(handler) = handler { - handler(window, cx); - } else if let Some(action) = action.as_ref() { - self.dispatch_confirm_action(action.as_ref(), window, cx); + self.dismiss(&Cancel, window, cx) } - - self.dismiss(&Cancel, window, cx) - } - Some(PopupMenuItem::ElementItem { - handler, action, .. - }) => { - if let Some(handler) = handler { - handler(window, cx); - } else { - self.dispatch_confirm_action(action.as_ref(), window, cx); + Some(PopupMenuItem::ElementItem { + handler, action, .. + }) => { + if let Some(handler) = handler { + handler(&ClickEvent::default(), window, cx); + } else if let Some(action) = action.as_ref() { + self.dispatch_confirm_action(action, window, cx); + } + self.dismiss(&Cancel, window, cx) } - self.dismiss(&Cancel, window, cx) + _ => {} } - _ => {} } + _ => {} } } fn dispatch_confirm_action( &self, - action: &dyn Action, + action: &Box, window: &mut Window, cx: &mut Context, ) { @@ -776,7 +879,7 @@ impl PopupMenu { return true; } - false + return false; } fn _unselect_submenu(&mut self, _: &mut Window, cx: &mut Context) -> bool { @@ -788,7 +891,7 @@ impl PopupMenu { return true; } - false + return false; } fn _focus_parent_menu(&mut self, window: &mut Window, cx: &mut Context) { @@ -844,12 +947,39 @@ impl PopupMenu { }); } + fn handle_dismiss( + &mut self, + position: &Point, + window: &mut Window, + cx: &mut Context, + ) { + // Do not dismiss, if click inside the parent menu + if let Some(parent) = self.parent_menu.as_ref() { + if let Some(parent) = parent.upgrade() { + if parent.read(cx).bounds.contains(position) { + return; + } + } + } + + self.dismiss(&Cancel, window, cx); + } + + fn on_mouse_down_out( + &mut self, + e: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + self.handle_dismiss(&e.position, window, cx); + } + fn render_key_binding( &self, action: Option>, window: &mut Window, _: &mut Context, - ) -> Option { + ) -> Option { let action = action?; match self @@ -871,22 +1001,24 @@ impl PopupMenu { fn render_icon( has_icon: bool, + checked: bool, icon: Option, - _window: &mut Window, - _cx: &mut Context, + _: &mut Window, + _: &mut Context, ) -> Option { if !has_icon { return None; } - let icon = h_flex() - .w_3p5() - .h_3p5() - .justify_center() - .text_sm() - .when_some(icon, |this, icon| this.child(icon.clone().small())); + let icon = if let Some(icon) = icon { + icon.clone() + } else if checked { + Icon::new(IconName::Check) + } else { + Icon::empty() + }; - Some(icon) + Some(icon.xsmall()) } #[inline] @@ -916,22 +1048,28 @@ impl PopupMenu { &self, ix: usize, item: &PopupMenuItem, - state: ItemState, + options: RenderOptions, window: &mut Window, cx: &mut Context, - ) -> impl IntoElement { + ) -> MenuItemElement { + let has_left_icon = options.has_left_icon; + let is_left_check = options.check_side.is_left() && item.is_checked(); + let right_check_icon = if options.check_side.is_right() && item.is_checked() { + Some(Icon::new(IconName::Check).xsmall()) + } else { + None + }; + + let selected = self.selected_index == Some(ix); const EDGE_PADDING: Pixels = px(4.); const INNER_PADDING: Pixels = px(8.); - let has_icon = self.has_icon; - let selected = self.selected_index == Some(ix); - let is_submenu = matches!(item, PopupMenuItem::Submenu { .. }); - let group_name = format!("popup-menu-item-{ix}"); + let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix); let (item_height, radius) = match self.size { - Size::Small => (px(20.), state.radius.half()), - _ => (px(26.), state.radius), + Size::Small => (px(20.), options.radius.half()), + _ => (px(26.), options.radius), }; let this = MenuItemElement::new(ix, &group_name) @@ -959,16 +1097,16 @@ impl PopupMenu { .p_0() .my_0p5() .mx_neg_1() - .h(px(1.)) - .bg(cx.theme().border) + .border_b(px(2.)) + .border_color(cx.theme().border) .disabled(true), PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child( h_flex() .cursor_default() .items_center() - .font_semibold() - .text_xs() - .child(label.clone()), + .gap_x_1() + .children(Self::render_icon(has_left_icon, false, None, window, cx)) + .child(div().flex_1().child(label.clone())), ), PopupMenuItem::ElementItem { render, @@ -988,8 +1126,15 @@ impl PopupMenu { .min_h(item_height) .items_center() .gap_x_1() - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) - .child((render)(window, cx)), + .children(Self::render_icon( + has_left_icon, + is_left_check, + icon.clone(), + window, + cx, + )) + .child((render)(window, cx)) + .children(right_check_icon.map(|icon| icon.ml_3())), ), PopupMenuItem::Item { icon, @@ -1010,14 +1155,22 @@ impl PopupMenu { }) .disabled(*disabled) .h(item_height) - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) + .gap_x_1() + .children(Self::render_icon( + has_left_icon, + is_left_check, + icon.clone(), + window, + cx, + )) .child( h_flex() .w_full() - .gap_2() + .gap_3() .items_center() .justify_between() .when(!show_link_icon, |this| this.child(label.clone())) + .children(right_check_icon) .when(show_link_icon, |this| { this.child( h_flex() @@ -1050,7 +1203,13 @@ impl PopupMenu { .size_full() .items_center() .gap_x_1() - .children(Self::render_icon(has_icon, icon.clone(), window, cx)) + .children(Self::render_icon( + has_left_icon, + false, + icon.clone(), + window, + cx, + )) .child( h_flex() .flex_1() @@ -1058,7 +1217,11 @@ impl PopupMenu { .items_center() .justify_between() .child(label.clone()) - .child(IconName::CaretRight), + .child( + Icon::new(IconName::CaretRight) + .xsmall() + .text_color(cx.theme().text_muted), + ), ), ) .when(selected, |this| { @@ -1085,9 +1248,7 @@ impl PopupMenu { } impl FluentBuilder for PopupMenu {} - impl EventEmitter for PopupMenu {} - impl Focusable for PopupMenu { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() @@ -1095,7 +1256,9 @@ impl Focusable for PopupMenu { } #[derive(Clone, Copy)] -struct ItemState { +struct RenderOptions { + has_left_icon: bool, + check_side: Side, radius: Pixels, } @@ -1103,15 +1266,23 @@ impl Render for PopupMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.update_submenu_menu_anchor(window); - let max_width = self.max_width(); + let view = cx.entity().clone(); + let items_count = self.menu_items.len(); + let max_height = self.max_height.unwrap_or_else(|| { let window_half_height = window.window_bounds().get_bounds().size.height * 0.5; window_half_height.min(px(450.)) }); - let view = cx.entity().clone(); - let items_count = self.menu_items.len(); - let item_state = ItemState { + let has_left_icon = self + .menu_items + .iter() + .any(|item| item.has_left_icon(self.check_side)); + + let max_width = self.max_width(); + let options = RenderOptions { + has_left_icon, + check_side: self.check_side, radius: cx.theme().radius.min(px(8.)), }; @@ -1125,31 +1296,14 @@ impl Render for PopupMenu { .on_action(cx.listener(Self::select_right)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::dismiss)) - .on_mouse_down_out(cx.listener(|this, ev: &MouseDownEvent, window, cx| { - // Do not dismiss, if click inside the parent menu - if let Some(parent) = this.parent_menu.as_ref() { - if let Some(parent) = parent.upgrade() { - if parent.read(cx).bounds.contains(&ev.position) { - return; - } - } - } - - this.dismiss(&Cancel, window, cx); - })) + .on_mouse_down_out(cx.listener(Self::on_mouse_down_out)) .popover_style(cx) .text_color(cx.theme().text) .relative() + .occlude() .child( - div() + v_flex() .id("items") - .map(|this| { - if self.axis_items == Axis::Vertical { - this.flex().flex_col() - } else { - this.flex().flex_row().items_center() - } - }) .p_1() .gap_y_0p5() .min_w(rems(8.)) @@ -1166,28 +1320,13 @@ impl Render for PopupMenu { .enumerate() // Ignore last separator .filter(|(ix, item)| !(*ix + 1 == items_count && item.is_separator())) - .map(|(ix, item)| self.render_item(ix, item, item_state, window, cx)), + .map(|(ix, item)| self.render_item(ix, item, options, window, cx)), ) - .child({ - canvas( - move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds), - |_, _, _, _| {}, - ) - .absolute() - .size_full() - }), + .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)), ) .when(self.scrollable, |this| { // TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed. - this.child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child(Scrollbar::vertical(&self.scroll_state, &self.scroll_handle)), - ) + this.vertical_scrollbar(&self.scroll_handle) }) } } diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs index 226dcee..1e445c4 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/modal.rs @@ -3,7 +3,7 @@ use std::time::Duration; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, div, hsla, point, px, Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, + anchored, div, hsla, point, px, Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled, Window, @@ -13,6 +13,7 @@ use theme::ActiveTheme; use crate::actions::{Cancel, Confirm}; use crate::animation::cubic_bezier; use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _}; +use crate::scroll::ScrollableElement; use crate::{h_flex, v_flex, IconName, Root, Sizable, StyledExt, WindowExtension}; const CONTEXT: &str = "Modal"; @@ -489,13 +490,13 @@ impl RenderOnce for Modal { .w_full() .h_auto() .flex_1() - .relative() .overflow_hidden() .child( v_flex() .pr(padding_right) .pl(padding_left) - .scrollable(Axis::Vertical) + .size_full() + .overflow_y_scrollbar() .child(self.content), ), ) diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index df42c93..fd76050 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -1,129 +1,75 @@ -use std::cell::RefCell; use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - actions, anchored, deferred, div, px, AnyElement, App, Bounds, Context, Corner, DismissEvent, - DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable, - GlobalElementId, Hitbox, HitboxBehavior, InteractiveElement as _, IntoElement, KeyBinding, - LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, - ScrollHandle, StatefulInteractiveElement, Style, StyleRefinement, Styled, Window, + deferred, div, px, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, + EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, + MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, + Styled, Subscription, Window, }; -use crate::{Selectable, StyledExt as _}; +use crate::actions::Cancel; +use crate::{anchored, v_flex, Anchor, ElementExt, Selectable, StyledExt as _}; const CONTEXT: &str = "Popover"; -actions!(popover, [Escape]); - -pub fn init(cx: &mut App) { - cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))]) +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))]) } -type PopoverChild = Rc) -> AnyElement>; - -pub struct PopoverContent { - focus_handle: FocusHandle, - scroll_handle: ScrollHandle, - max_width: Option, - max_height: Option, - scrollable: bool, - child: PopoverChild, -} - -impl PopoverContent { - pub fn new(_window: &mut Window, cx: &mut App, content: B) -> Self - where - B: Fn(&mut Window, &mut Context) -> AnyElement + 'static, - { - let focus_handle = cx.focus_handle(); - let scroll_handle = ScrollHandle::default(); - - Self { - focus_handle, - scroll_handle, - child: Rc::new(content), - max_width: None, - max_height: None, - scrollable: false, - } - } - - pub fn max_w(mut self, max_width: Pixels) -> Self { - self.max_width = Some(max_width); - self - } - - pub fn max_h(mut self, max_height: Pixels) -> Self { - self.max_height = Some(max_height); - self - } - - pub fn scrollable(mut self) -> Self { - self.scrollable = true; - self - } -} - -impl EventEmitter for PopoverContent {} - -impl Focusable for PopoverContent { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for PopoverContent { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .id("popup-content") - .track_focus(&self.focus_handle) - .key_context(CONTEXT) - .on_action(cx.listener(|_, _: &Escape, _, cx| cx.emit(DismissEvent))) - .p_2() - .when(self.scrollable, |this| { - this.overflow_y_scroll().track_scroll(&self.scroll_handle) - }) - .when_some(self.max_width, |this, v| this.max_w(v)) - .when_some(self.max_height, |this, v| this.max_h(v)) - .child(self.child.clone()(window, cx)) - } -} - -type Trigger = Option AnyElement + 'static>>; -type Content = Option Entity + 'static>>; - -pub struct Popover { +/// A popover element that can be triggered by a button or any other element. +#[derive(IntoElement)] +pub struct Popover { id: ElementId, - anchor: Corner, - trigger: Trigger, - content: Content, + style: StyleRefinement, + anchor: Anchor, + default_open: bool, + open: Option, + tracked_focus_handle: Option, + trigger: Option AnyElement + 'static>>, + content: Option< + Rc< + dyn Fn(&mut PopoverState, &mut Window, &mut Context) -> AnyElement + + 'static, + >, + >, + children: Vec, /// Style for trigger element. /// This is used for hotfix the trigger element style to support w_full. trigger_style: Option, mouse_button: MouseButton, - no_style: bool, + appearance: bool, + overlay_closable: bool, + on_open_change: Option>, } -impl Popover -where - M: ManagedView, -{ +impl Popover { /// Create a new Popover with `view` mode. pub fn new(id: impl Into) -> Self { Self { id: id.into(), - anchor: Corner::TopLeft, + style: StyleRefinement::default(), + anchor: Anchor::TopLeft, trigger: None, trigger_style: None, content: None, + tracked_focus_handle: None, + children: vec![], mouse_button: MouseButton::Left, - no_style: false, + appearance: true, + overlay_closable: true, + default_open: false, + open: None, + on_open_change: None, } } - pub fn anchor(mut self, anchor: Corner) -> Self { - self.anchor = anchor; + /// Set the anchor corner of the popover, default is `Corner::TopLeft`. + /// + /// This method is kept for backward compatibility with `Corner` type. + /// Internally, it converts `Corner` to `Anchor`. + pub fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); self } @@ -133,29 +79,75 @@ where self } + /// Set the trigger element of the popover. pub fn trigger(mut self, trigger: T) -> Self where T: Selectable + IntoElement + 'static, { self.trigger = Some(Box::new(|is_open, _, _| { - trigger.selected(is_open).into_any_element() + let selected = trigger.is_selected(); + trigger.selected(selected || is_open).into_any_element() })); 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(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); self } - /// Set the content of the popover. + /// Set whether clicking outside the popover will dismiss it, default is `true`. + pub fn overlay_closable(mut self, closable: bool) -> Self { + self.overlay_closable = closable; + self + } + + /// Set the content builder for content of the Popover. /// - /// The `content` is a closure that returns an `AnyElement`. - pub fn content(mut self, content: C) -> Self + /// This callback will called every time on render the popover. + /// So, you should avoid creating new elements or entities in the content closure. + pub fn content(mut self, content: F) -> Self where - C: Fn(&mut Window, &mut App) -> Entity + 'static, + E: IntoElement, + F: Fn(&mut PopoverState, &mut Window, &mut Context) -> 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 } @@ -165,302 +157,264 @@ where /// /// - The popover will not have a bg, border, shadow, or padding. /// - The click out of the popover will not dismiss it. - pub fn no_style(mut self) -> Self { - self.no_style = true; + pub fn appearance(mut self, appearance: bool) -> Self { + self.appearance = appearance; self } - fn render_trigger(&mut self, is_open: bool, window: &mut Window, cx: &mut App) -> AnyElement { - let Some(trigger) = self.trigger.take() else { - return div().into_any_element(); + /// Bind the focus handle to receive focus when the popover is opened. + /// If you not set this, a new focus handle will be created for the popover to + /// + /// 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(anchor: Anchor, trigger_bounds: Bounds) -> Point { + let offset = if anchor.is_center() { + gpui::point(trigger_bounds.size.width.half(), px(0.)) + } else { + Point::default() }; - (trigger)(is_open, window, cx) - } - - fn resolved_corner(&self, bounds: Bounds) -> Point { - bounds.corner(match self.anchor { - Corner::TopLeft => Corner::BottomLeft, - Corner::TopRight => Corner::BottomRight, - Corner::BottomLeft => Corner::TopLeft, - Corner::BottomRight => Corner::TopRight, - }) - } - - fn with_element_state( - &mut self, - id: &GlobalElementId, - window: &mut Window, - cx: &mut App, - f: impl FnOnce(&mut Self, &mut PopoverElementState, &mut Window, &mut App) -> R, - ) -> R { - window.with_optional_element_state::, _>( - 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)) - }, - ) + trigger_bounds.corner(anchor.swap_vertical().into()) + + offset + + Point { + x: px(0.), + y: -trigger_bounds.size.height, + } } } -impl IntoElement for Popover -where - M: ManagedView, -{ - type Element = Self; - - fn into_element(self) -> Self::Element { - self +impl ParentElement for Popover { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); } } -pub struct PopoverElementState { - trigger_layout_id: Option, - popover_layout_id: Option, - popover_element: Option, - trigger_element: Option, - content_view: Rc>>>, - /// Trigger bounds for positioning the popover. - trigger_bounds: Option>, +impl Styled for Popover { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } } -impl Default for PopoverElementState { - fn default() -> Self { +pub struct PopoverState { + focus_handle: FocusHandle, + pub(crate) tracked_focus_handle: Option, + trigger_bounds: Bounds, + open: bool, + on_open_change: Option>, + + _dismiss_subscription: Option, +} + +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)), - 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 PrepaintState = PrepaintState; - type RequestLayoutState = PopoverElementState; - - fn id(&self) -> Option { - Some(self.id.clone()) - } - - 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; - } - } - - 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; - - if let Some(content_view) = element_state.content_view.borrow_mut().as_mut() { - is_open = true; - - 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 no_style = view.no_style; - deferred( - anchored.child( - div() - .size_full() - .occlude() - .when(!no_style, |this| this.popover_style(cx)) - .map(|this| match anchor { - Corner::TopLeft | Corner::TopRight => this.top_1p5(), - Corner::BottomLeft | Corner::BottomRight => { - this.bottom_1p5() - } - }) - .child(content_view.clone()) - .when(!no_style, |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, - ); - - ( - layout_id, - PopoverElementState { - trigger_layout_id: Some(trigger_layout_id), - popover_layout_id, - popover_element, - trigger_element: Some(trigger_element), - ..Default::default() - }, - ) - }, - ) - } - - fn prepaint( - &mut self, - _id: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _bounds: gpui::Bounds, - 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(), HitboxBehavior::Normal); - - PrepaintState { - trigger_bounds, - hitbox, + focus_handle: cx.focus_handle(), + tracked_focus_handle: None, + trigger_bounds: Bounds::default(), + open: default_open, + on_open_change: None, + _dismiss_subscription: None, } } - fn paint( - &mut self, - id: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _bounds: Bounds, - 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; + /// Check if the popover is open. + pub fn is_open(&self) -> bool { + self.open + } - if let Some(mut element) = request_layout.trigger_element.take() { - element.paint(window, cx); - } + /// Dismiss the popover if it is open. + pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context) { + if self.open { + self.toggle_open(window, cx); + } + } - if let Some(mut element) = request_layout.popover_element.take() { - element.paint(window, cx); - return; - } + /// Open the popover if it is closed. + pub fn show(&mut self, window: &mut Window, cx: &mut Context) { + if !self.open { + self.toggle_open(window, cx); + } + } - // 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(); + fn toggle_open(&mut self, window: &mut Window, cx: &mut Context) { + self.open = !self.open; + if self.open { + let state = cx.entity(); + let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone() + { + tracked_focus_handle + } else { + self.focus_handle.clone() + }; + focus_handle.focus(window, cx); - 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 |modal, _: &DismissEvent, window, cx| { - if modal.focus_handle(cx).contains_focused(window, cx) { - if let Some(previous_focus_handle) = - previous_focus_handle.as_ref() - { - window.focus(previous_focus_handle, cx); - } - } - *old_content_view1.borrow_mut() = None; - - window.refresh(); - }, - ) - .detach(); - - window.focus(&new_content_view.focus_handle(cx), cx); - *old_content_view.borrow_mut() = Some(new_content_view); + 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 { + self._dismiss_subscription = None; + } + + if let Some(callback) = self.on_open_change.as_ref() { + callback(&self.open, window, cx); + } + cx.notify(); + } + + fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + self.dismiss(window, cx); + } +} + +impl Focusable for PopoverState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PopoverState { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +impl EventEmitter for PopoverState {} + +impl Popover { + pub(crate) fn render_popover( + anchor: Anchor, + trigger_bounds: Bounds, + content: E, + _: &mut Window, + _: &mut App, + ) -> Deferred + where + E: IntoElement + 'static, + { + deferred( + anchored() + .snap_to_window_with_margin(px(8.)) + .anchor(anchor) + .position(Self::resolved_corner(anchor, trigger_bounds)) + .child(div().relative().child(content)), + ) + .with_priority(1) + } + + pub(crate) fn render_popover_content( + anchor: Anchor, + appearance: bool, + _: &mut Window, + cx: &mut App, + ) -> Stateful
{ + v_flex() + .id("content") + .occlude() + .tab_group() + .when(appearance, |this| this.popover_style(cx).p_3()) + .map(|this| match anchor { + Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(), + Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(), + }) + } +} + +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 tracked_focus_handle = self.tracked_focus_handle.clone(); + let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| { + PopoverState::new(default_open, cx) + }); + + state.update(cx, |state, _| { + if let Some(tracked_focus_handle) = tracked_focus_handle { + 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| { + cx.stop_propagation(); + state.update(cx, |state, cx| { + // We force set open to false to toggle it correctly. + // Because if the mouse down out will toggle open first. + state.open = open; + state.toggle_open(window, cx); + }); + cx.notify(parent_view_id); + } + }) + .on_prepaint({ + let state = state.clone(); + move |bounds, _, cx| { + state.update(cx, |state, _| { + state.trigger_bounds = bounds; + }) + } + }); + + if !open { + return el; + } + + let popover_content = + Self::render_popover_content(self.anchor, self.appearance, window, cx) + .track_focus(&focus_handle) + .key_context(CONTEXT) + .on_action(window.listener_for(&state, PopoverState::on_action_cancel)) + .when_some(self.content, |this, content| { + this.child(state.update(cx, |state, cx| (content)(state, window, cx))) + }) + .children(self.children) + .when(self.overlay_closable, |this| { + this.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); + + el.child(Self::render_popover( + self.anchor, + trigger_bounds, + popover_content, + window, + cx, + )) } } diff --git a/crates/ui/src/scroll/scrollable.rs b/crates/ui/src/scroll/scrollable.rs index 0738775..821694e 100644 --- a/crates/ui/src/scroll/scrollable.rs +++ b/crates/ui/src/scroll/scrollable.rs @@ -1,232 +1,209 @@ +use std::panic::Location; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder; use gpui::{ - div, relative, AnyElement, App, Bounds, Div, Element, ElementId, GlobalElementId, - InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, ParentElement, - Pixels, Position, ScrollHandle, SharedString, Size, Stateful, StatefulInteractiveElement, - Style, StyleRefinement, Styled, Window, + div, App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, + ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, }; -use super::{Scrollbar, ScrollbarAxis, ScrollbarState}; +use super::{Scrollbar, ScrollbarAxis}; +use crate::scroll::ScrollbarHandle; +use crate::StyledExt; -/// A scroll view is a container that allows the user to scroll through a large amount of content. -pub struct Scrollable { +/// A trait for elements that can be made scrollable with scrollbars. +pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element { + /// Adds a scrollbar to the element. + #[track_caller] + fn scrollbar( + self, + scroll_handle: &H, + axis: impl Into, + ) -> Self { + self.child(ScrollbarLayer { + id: "scrollbar_layer".into(), + axis: axis.into(), + scroll_handle: Rc::new(scroll_handle.clone()), + }) + } + + /// Adds a vertical scrollbar to the element. + #[track_caller] + fn vertical_scrollbar(self, scroll_handle: &H) -> Self { + self.scrollbar(scroll_handle, ScrollbarAxis::Vertical) + } + /// Adds a horizontal scrollbar to the element. + #[track_caller] + fn horizontal_scrollbar(self, scroll_handle: &H) -> Self { + self.scrollbar(scroll_handle, ScrollbarAxis::Horizontal) + } + + /// Almost equivalent to [`StatefulInteractiveElement::overflow_scroll`], but adds scrollbars. + #[track_caller] + fn overflow_scrollbar(self) -> Scrollable { + Scrollable::new(self, ScrollbarAxis::Both) + } + + /// Almost equivalent to [`StatefulInteractiveElement::overflow_x_scroll`], but adds Horizontal scrollbar. + #[track_caller] + fn overflow_x_scrollbar(self) -> Scrollable { + Scrollable::new(self, ScrollbarAxis::Horizontal) + } + + /// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar. + #[track_caller] + fn overflow_y_scrollbar(self) -> Scrollable { + Scrollable::new(self, ScrollbarAxis::Vertical) + } +} + +/// A scrollable element wrapper that adds scrollbars to an interactive element. +#[derive(IntoElement)] +pub struct Scrollable { id: ElementId, - element: Option, + element: E, axis: ScrollbarAxis, - /// This is a fake element to handle Styled, InteractiveElement, not used. - _element: Stateful
, } impl Scrollable where - E: Element, + E: InteractiveElement + Styled + ParentElement + Element, { - pub(crate) fn new(axis: impl Into, element: E) -> Self { - let id = ElementId::Name(SharedString::from( - format!("scrollable-{:?}", element.id(),), - )); - + #[track_caller] + fn new(element: E, axis: impl Into) -> Self { + let caller = Location::caller(); Self { - element: Some(element), - _element: div().id("fake"), - id, + id: ElementId::CodeLocation(*caller), + element, axis: axis.into(), } } - - /// Set only a vertical scrollbar. - pub fn vertical(mut self) -> Self { - self.set_axis(ScrollbarAxis::Vertical); - self - } - - /// Set only a horizontal scrollbar. - /// In current implementation, this is not supported yet. - pub fn horizontal(mut self) -> Self { - self.set_axis(ScrollbarAxis::Horizontal); - self - } - - /// Set the axis of the scroll view. - pub fn set_axis(&mut self, axis: impl Into) { - self.axis = axis.into(); - } - - fn with_element_state( - &mut self, - id: &GlobalElementId, - window: &mut Window, - cx: &mut App, - f: impl FnOnce(&mut Self, &mut ScrollViewState, &mut Window, &mut App) -> R, - ) -> R { - window.with_optional_element_state::( - 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)) - }, - ) - } -} - -pub struct ScrollViewState { - state: ScrollbarState, - handle: ScrollHandle, -} - -impl Default for ScrollViewState { - fn default() -> Self { - Self { - handle: ScrollHandle::new(), - state: ScrollbarState::default(), - } - } -} - -impl ParentElement for Scrollable -where - E: Element + ParentElement, -{ - fn extend(&mut self, elements: impl IntoIterator) { - if let Some(element) = &mut self.element { - element.extend(elements); - } - } } impl Styled for Scrollable where - E: Element + Styled, + E: InteractiveElement + Styled + ParentElement + Element, { fn style(&mut self) -> &mut StyleRefinement { - if let Some(element) = &mut self.element { - element.style() - } else { - self._element.style() - } + self.element.style() } } -impl InteractiveElement for Scrollable +impl ParentElement for Scrollable where - E: Element + InteractiveElement, + E: InteractiveElement + Styled + ParentElement + Element, { - fn interactivity(&mut self) -> &mut Interactivity { - if let Some(element) = &mut self.element { - element.interactivity() - } else { - self._element.interactivity() - } - } -} -impl StatefulInteractiveElement for Scrollable where E: Element + StatefulInteractiveElement {} - -impl IntoElement for Scrollable -where - E: Element, -{ - type Element = Self; - - fn into_element(self) -> Self::Element { - self + fn extend(&mut self, elements: impl IntoIterator) { + self.element.extend(elements) } } -impl Element for Scrollable +impl InteractiveElement for Scrollable
{ + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.element.interactivity() + } +} + +impl InteractiveElement for Scrollable> { + fn interactivity(&mut self) -> &mut gpui::Interactivity { + self.element.interactivity() + } +} + +impl RenderOnce for Scrollable where - E: Element, + E: InteractiveElement + Styled + ParentElement + Element + 'static, { - type PrepaintState = ScrollViewState; - type RequestLayoutState = AnyElement; + fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let scroll_handle = window + .use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::default()) + .read(cx) + .clone(); - fn id(&self) -> Option { - Some(self.id.clone()) - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - id: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let style = Style { - position: Position::Relative, - flex_grow: 1.0, - flex_shrink: 1.0, - size: Size { - width: relative(1.).into(), - height: relative(1.).into(), - }, + // Inherit the size from the element style. + let style = StyleRefinement { + size: self.element.style().size.clone(), ..Default::default() }; - let axis = self.axis; - let scroll_id = self.id.clone(); - let content = self.element.take().map(|c| c.into_any_element()); - - self.with_element_state(id.unwrap(), window, cx, |_, element_state, window, cx| { - let mut element = div() - .relative() - .size_full() - .overflow_hidden() - .child( - div() - .id(scroll_id) - .track_scroll(&element_state.handle) - .overflow_scroll() - .relative() - .size_full() - .child(div().children(content)), - ) - .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child( - Scrollbar::both(&element_state.state, &element_state.handle).axis(axis), - ), - ) - .into_any_element(); - - let element_id = element.request_layout(window, cx); - let layout_id = window.request_layout(style, vec![element_id], cx); - - (layout_id, element) - }) - } - - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - element: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - element.prepaint(window, cx); - // do nothing - ScrollViewState::default() - } - - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - element: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - element.paint(window, cx) + div() + .id(self.id) + .size_full() + .refine_style(&style) + .relative() + .child( + div() + .id("scroll-area") + .flex() + .size_full() + .track_scroll(&scroll_handle) + .map(|this| match self.axis { + ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(), + ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(), + ScrollbarAxis::Both => this.overflow_scroll(), + }) + .child( + self.element + // Refine element size to `flex_1`. + .size_auto() + .flex_1(), + ), + ) + .child(render_scrollbar( + "scrollbar", + &scroll_handle, + self.axis, + window, + cx, + )) } } + +impl ScrollableElement for Div {} +impl ScrollableElement for Stateful +where + E: ParentElement + Styled + Element, + Self: InteractiveElement, +{ +} + +#[derive(IntoElement)] +struct ScrollbarLayer { + id: ElementId, + axis: ScrollbarAxis, + scroll_handle: Rc, +} + +impl RenderOnce for ScrollbarLayer +where + H: ScrollbarHandle + Clone + 'static, +{ + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + render_scrollbar(self.id, self.scroll_handle.as_ref(), self.axis, window, cx) + } +} + +#[inline] +#[track_caller] +fn render_scrollbar( + id: impl Into, + scroll_handle: &H, + axis: ScrollbarAxis, + window: &mut Window, + cx: &mut App, +) -> Div { + // Do not render scrollbar when inspector is picking elements, + // to allow us to pick the background elements. + let is_inspector_picking = window.is_inspector_picking(cx); + if is_inspector_picking { + return div(); + } + + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::new(scroll_handle).id(id).axis(axis)) +} diff --git a/crates/ui/src/scroll/scrollbar.rs b/crates/ui/src/scroll/scrollbar.rs index 394dff7..16d37ba 100644 --- a/crates/ui/src/scroll/scrollbar.rs +++ b/crates/ui/src/scroll/scrollbar.rs @@ -1,43 +1,50 @@ use std::cell::Cell; use std::ops::Deref; +use std::panic::Location; use std::rc::Rc; use std::time::{Duration, Instant}; use gpui::{ fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner, - CursorStyle, Edges, Element, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, - IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, - Position, ScrollHandle, ScrollWheelEvent, Size, UniformListScrollHandle, Window, + CursorStyle, Edges, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, + InspectorElementId, IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, + UniformListScrollHandle, Window, }; -use theme::ActiveTheme; +use theme::{ActiveTheme, ScrollbarMode}; use crate::AxisExt; -const WIDTH: Pixels = px(2. * 2. + 8.); +/// The width of the scrollbar (THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH) +const WIDTH: Pixels = px(4. * 2. + 8.); const MIN_THUMB_SIZE: f32 = 48.; const THUMB_WIDTH: Pixels = px(6.); const THUMB_RADIUS: Pixels = px(6. / 2.); -const THUMB_INSET: Pixels = px(2.); +const THUMB_INSET: Pixels = px(4.); const THUMB_ACTIVE_WIDTH: Pixels = px(8.); const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.); -const THUMB_ACTIVE_INSET: Pixels = px(2.); +const THUMB_ACTIVE_INSET: Pixels = px(4.); const FADE_OUT_DURATION: f32 = 3.0; const FADE_OUT_DELAY: f32 = 2.0; -pub trait ScrollHandleOffsetable { +/// A trait for scroll handles that can get and set offset. +pub trait ScrollbarHandle: 'static { + /// Get the current offset of the scroll handle. fn offset(&self) -> Point; + /// Set the offset of the scroll handle. fn set_offset(&self, offset: Point); - fn is_uniform_list(&self) -> bool { - false - } /// The full size of the content, including padding. fn content_size(&self) -> Size; + /// Called when start dragging the scrollbar thumb. + fn start_drag(&self) {} + /// Called when end dragging the scrollbar thumb. + fn end_drag(&self) {} } -impl ScrollHandleOffsetable for ScrollHandle { +impl ScrollbarHandle for ScrollHandle { fn offset(&self) -> Point { self.offset() } @@ -51,7 +58,7 @@ impl ScrollHandleOffsetable for ScrollHandle { } } -impl ScrollHandleOffsetable for UniformListScrollHandle { +impl ScrollbarHandle for UniformListScrollHandle { fn offset(&self) -> Point { self.0.borrow().base_handle.offset() } @@ -60,21 +67,41 @@ impl ScrollHandleOffsetable for UniformListScrollHandle { self.0.borrow_mut().base_handle.set_offset(offset) } - fn is_uniform_list(&self) -> bool { - true - } - fn content_size(&self) -> Size { let base_handle = &self.0.borrow().base_handle; base_handle.max_offset() + base_handle.bounds().size } } -#[derive(Debug, Clone)] -pub struct ScrollbarState(Rc>); +impl ScrollbarHandle for ListState { + fn offset(&self) -> Point { + self.scroll_px_offset_for_scrollbar() + } + fn set_offset(&self, offset: Point) { + self.set_offset_from_scrollbar(offset); + } + + fn content_size(&self) -> Size { + self.viewport_bounds().size + self.max_offset_for_scrollbar() + } + + fn start_drag(&self) { + self.scrollbar_drag_started(); + } + + fn end_drag(&self) { + self.scrollbar_drag_ended(); + } +} + +#[doc(hidden)] +#[derive(Debug, Clone)] +struct ScrollbarState(Rc>); + +#[doc(hidden)] #[derive(Debug, Clone, Copy)] -pub struct ScrollbarStateInner { +struct ScrollbarStateInner { hovered_axis: Option, hovered_on_thumb: Option, dragged_axis: Option, @@ -83,6 +110,7 @@ pub struct ScrollbarStateInner { last_scroll_time: Option, // Last update offset last_update: Instant, + idle_timer_scheduled: bool, } impl Default for ScrollbarState { @@ -95,6 +123,7 @@ impl Default for ScrollbarState { last_scroll_offset: point(px(0.), px(0.)), last_scroll_time: None, last_update: Instant::now(), + idle_timer_scheduled: false, }))) } } @@ -138,8 +167,10 @@ impl ScrollbarStateInner { fn with_hovered_on_thumb(&self, axis: Option) -> Self { let mut state = *self; state.hovered_on_thumb = axis; - if self.is_scrollbar_visible() && axis.is_some() { - state.last_scroll_time = Some(std::time::Instant::now()); + if self.is_scrollbar_visible() { + if axis.is_some() { + state.last_scroll_time = Some(std::time::Instant::now()); + } } state } @@ -167,6 +198,12 @@ impl ScrollbarStateInner { state } + fn with_idle_timer_scheduled(&self, scheduled: bool) -> Self { + let mut state = *self; + state.idle_timer_scheduled = scheduled; + state + } + fn is_scrollbar_visible(&self) -> bool { // On drag if self.dragged_axis.is_some() { @@ -182,10 +219,14 @@ impl ScrollbarStateInner { } } +/// Scrollbar axis. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScrollbarAxis { + /// Vertical scrollbar. Vertical, + /// Horizontal scrollbar. Horizontal, + /// Show both vertical and horizontal scrollbars. Both, } @@ -200,25 +241,30 @@ impl From for ScrollbarAxis { impl ScrollbarAxis { /// Return true if the scrollbar axis is vertical. + #[inline] pub fn is_vertical(&self) -> bool { matches!(self, Self::Vertical) } /// Return true if the scrollbar axis is horizontal. + #[inline] pub fn is_horizontal(&self) -> bool { matches!(self, Self::Horizontal) } /// Return true if the scrollbar axis is both vertical and horizontal. + #[inline] pub fn is_both(&self) -> bool { matches!(self, Self::Both) } + /// Return true if the scrollbar has vertical axis. #[inline] pub fn has_vertical(&self) -> bool { matches!(self, Self::Vertical | Self::Both) } + /// Return true if the scrollbar has horizontal axis. #[inline] pub fn has_horizontal(&self) -> bool { matches!(self, Self::Horizontal | Self::Both) @@ -238,9 +284,10 @@ impl ScrollbarAxis { /// Scrollbar control for scroll-area or a uniform-list. pub struct Scrollbar { + pub(crate) id: ElementId, axis: ScrollbarAxis, - scroll_handle: Rc>, - state: ScrollbarState, + scrollbar_mode: Option, + scroll_handle: Rc, scroll_size: Option>, /// Maximum frames per second for scrolling by drag. Default is 120 FPS. /// @@ -250,50 +297,46 @@ pub struct Scrollbar { } impl Scrollbar { - fn new( - axis: impl Into, - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { + /// Create a new scrollbar. + /// + /// This will have both vertical and horizontal scrollbars. + #[track_caller] + pub fn new(scroll_handle: &H) -> Self { + let caller = Location::caller(); Self { - state: state.clone(), - axis: axis.into(), - scroll_handle: Rc::new(Box::new(scroll_handle.clone())), + id: ElementId::CodeLocation(*caller), + axis: ScrollbarAxis::Both, + scrollbar_mode: None, + scroll_handle: Rc::new(scroll_handle.clone()), max_fps: 120, scroll_size: None, } } - /// Create with vertical and horizontal scrollbar. - pub fn both( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Both, state, scroll_handle) - } - /// Create with horizontal scrollbar. - pub fn horizontal( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Horizontal, state, scroll_handle) + #[track_caller] + pub fn horizontal(scroll_handle: &H) -> Self { + Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal) } /// Create with vertical scrollbar. - pub fn vertical( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Vertical, state, scroll_handle) + #[track_caller] + pub fn vertical(scroll_handle: &H) -> Self { + Self::new(scroll_handle).axis(ScrollbarAxis::Vertical) } - /// Create vertical scrollbar for uniform list. - pub fn uniform_scroll( - state: &ScrollbarState, - scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), - ) -> Self { - Self::new(ScrollbarAxis::Vertical, state, scroll_handle) + /// Set a specific element id, default is the [`Location::caller`]. + /// + /// NOTE: In most cases, you don't need to set a specific id for scrollbar. + pub fn id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + /// Set the scrollbar show mode [`ScrollbarShow`], if not set use the `cx.theme().scrollbar_show`. + pub fn scrollbar_mode(mut self, mode: ScrollbarMode) -> Self { + self.scrollbar_mode = Some(mode); + self } /// Set a special scroll size of the content area, default is None. @@ -315,11 +358,16 @@ impl Scrollbar { /// If you have very high CPU usage, consider reducing this value to improve performance. /// /// Available values: 30..120 - pub fn max_fps(mut self, max_fps: usize) -> Self { + pub(crate) fn max_fps(mut self, max_fps: usize) -> Self { self.max_fps = max_fps.clamp(30, 120); self } + // Get the width of the scrollbar. + pub(crate) const fn width() -> Pixels { + WIDTH + } + fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { ( cx.theme().scrollbar_thumb_hover_background, @@ -353,11 +401,28 @@ impl Scrollbar { ) } - fn style_for_idle(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { - let (width, inset, radius) = if cx.theme().scrollbar_mode.is_scrolling() { - (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS) - } else { - (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS) + fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { + let scrollbar_mode = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); + let (width, inset, radius) = match scrollbar_mode { + ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), + _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), + }; + + ( + cx.theme().scrollbar_thumb_background, + cx.theme().scrollbar_track_background, + gpui::transparent_black(), + width, + inset, + radius, + ) + } + + fn style_for_idle(&self, _cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { + let scrollbar_mode = self.scrollbar_mode.unwrap_or(ScrollbarMode::Always); + let (width, inset, radius) = match scrollbar_mode { + ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), + _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), }; ( @@ -379,11 +444,14 @@ impl IntoElement for Scrollbar { } } +#[doc(hidden)] pub struct PrepaintState { hitbox: Hitbox, + scrollbar_state: ScrollbarState, states: Vec, } +#[doc(hidden)] pub struct AxisPrepaintState { axis: Axis, bar_hitbox: Hitbox, @@ -406,7 +474,7 @@ impl Element for Scrollbar { type RequestLayoutState = (); fn id(&self) -> Option { - None + Some(self.id.clone()) } fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { @@ -420,16 +488,12 @@ impl Element for Scrollbar { window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { - let style = gpui::Style { - position: Position::Absolute, - flex_grow: 1.0, - flex_shrink: 1.0, - size: gpui::Size { - width: relative(1.).into(), - height: relative(1.).into(), - }, - ..Default::default() - }; + let mut style = Style::default(); + style.position = Position::Absolute; + style.flex_grow = 1.0; + style.flex_shrink = 1.0; + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); (window.request_layout(style, None, cx), ()) } @@ -447,6 +511,11 @@ impl Element for Scrollbar { window.insert_hitbox(bounds, HitboxBehavior::Normal) }); + let state = window + .use_state(cx, |_, _| ScrollbarState::default()) + .read(cx) + .clone(); + let mut states = vec![]; let mut has_both = self.axis.is_both(); let scroll_size = self @@ -470,9 +539,8 @@ impl Element for Scrollbar { }; // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible. - let margin_end = if has_both && !is_vertical { - THUMB_ACTIVE_WIDTH + WIDTH } else { px(0.) }; @@ -512,11 +580,12 @@ impl Element for Scrollbar { }, }; - let state = self.state.clone(); - let is_always_to_show = cx.theme().scrollbar_mode.is_always(); - let is_hover_to_show = cx.theme().scrollbar_mode.is_hover(); + let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); + let is_always_to_show = scrollbar_show.is_always(); + let is_hover_to_show = scrollbar_show.is_hover(); let is_hovered_on_bar = state.get().hovered_axis == Some(axis); let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis); + let is_offset_changed = state.get().last_scroll_offset != self.scroll_handle.offset(); let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) = if state.get().dragged_axis == Some(axis) { @@ -527,38 +596,47 @@ impl Element for Scrollbar { } else { Self::style_for_hovered_bar(cx) } + } else if is_offset_changed { + self.style_for_normal(cx) } else if is_always_to_show { - #[allow(clippy::if_same_then_else)] if is_hovered_on_thumb { Self::style_for_hovered_thumb(cx) } else { Self::style_for_hovered_bar(cx) } } else { - let mut idle_state = Self::style_for_idle(cx); + let mut idle_state = self.style_for_idle(cx); // Delay 2s to fade out the scrollbar thumb (in 1s) if let Some(last_time) = state.get().last_scroll_time { let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); - if elapsed < FADE_OUT_DURATION { - if is_hovered_on_bar { - state.set(state.get().with_last_scroll_time(Some(Instant::now()))); - idle_state = if is_hovered_on_thumb { - Self::style_for_hovered_thumb(cx) - } else { - Self::style_for_hovered_bar(cx) - }; + if is_hovered_on_bar { + state.set(state.get().with_last_scroll_time(Some(Instant::now()))); + idle_state = if is_hovered_on_thumb { + Self::style_for_hovered_thumb(cx) } else { - if elapsed < FADE_OUT_DELAY { - idle_state.0 = cx.theme().scrollbar_thumb_background; - } else { - // opacity = 1 - (x - 2)^10 - let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10); - idle_state.0 = - cx.theme().scrollbar_thumb_background.opacity(opacity); - }; + Self::style_for_hovered_bar(cx) + }; + } else if elapsed < FADE_OUT_DELAY { + idle_state.0 = cx.theme().scrollbar_thumb_background; - window.request_animation_frame(); + if !state.get().idle_timer_scheduled { + let state = state.clone(); + state.set(state.get().with_idle_timer_scheduled(true)); + let current_view = window.current_view(); + let next_delay = Duration::from_secs_f32(FADE_OUT_DELAY - elapsed); + window + .spawn(cx, async move |cx| { + cx.background_executor().timer(next_delay).await; + state.set(state.get().with_idle_timer_scheduled(false)); + cx.update(|_, cx| cx.notify(current_view)).ok(); + }) + .detach(); } + } else if elapsed < FADE_OUT_DURATION { + let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10); + idle_state.0 = cx.theme().scrollbar_thumb_background.opacity(opacity); + + window.request_animation_frame(); } } @@ -617,7 +695,11 @@ impl Element for Scrollbar { }) } - PrepaintState { hitbox, states } + PrepaintState { + hitbox, + states, + scrollbar_state: state, + } } fn paint( @@ -630,19 +712,21 @@ impl Element for Scrollbar { window: &mut Window, cx: &mut App, ) { + let scrollbar_state = &prepaint.scrollbar_state; + let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode); let view_id = window.current_view(); let hitbox_bounds = prepaint.hitbox.bounds; - let is_visible = - self.state.get().is_scrollbar_visible() || cx.theme().scrollbar_mode.is_always(); - let is_hover_to_show = cx.theme().scrollbar_mode.is_hover(); + let is_visible = scrollbar_state.get().is_scrollbar_visible() || scrollbar_show.is_always(); + let is_hover_to_show = scrollbar_show.is_hover(); // Update last_scroll_time when offset is changed. - if self.scroll_handle.offset() != self.state.get().last_scroll_offset { - self.state.set( - self.state + if self.scroll_handle.offset() != scrollbar_state.get().last_scroll_offset { + scrollbar_state.set( + scrollbar_state .get() .with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())), ); + cx.notify(view_id); } window.with_content_mask( @@ -652,7 +736,10 @@ impl Element for Scrollbar { |window| { for state in prepaint.states.iter() { let axis = state.axis; - let radius = state.radius; + let mut radius = state.radius; + if cx.theme().radius.is_zero() { + radius = px(0.); + } let bounds = state.bounds; let thumb_bounds = state.thumb_bounds; let scroll_area_size = state.scroll_size; @@ -670,11 +757,20 @@ impl Element for Scrollbar { bounds, corner_radii: (0.).into(), background: gpui::transparent_black().into(), - border_widths: Edges { - top: px(0.), - right: px(0.), - bottom: px(0.), - left: px(0.), + border_widths: if is_vertical { + Edges { + top: px(0.), + right: px(0.), + bottom: px(0.), + left: px(0.), + } + } else { + Edges { + top: px(0.), + right: px(0.), + bottom: px(0.), + left: px(0.), + } }, border_color: state.border, border_style: BorderStyle::default(), @@ -686,19 +782,18 @@ impl Element for Scrollbar { }); window.on_mouse_event({ - let state = self.state.clone(); + let state = scrollbar_state.clone(); let scroll_handle = self.scroll_handle.clone(); move |event: &ScrollWheelEvent, phase, _, cx| { - if phase.bubble() - && hitbox_bounds.contains(&event.position) - && scroll_handle.offset() != state.get().last_scroll_offset - { - state.set(state.get().with_last_scroll( - scroll_handle.offset(), - Some(Instant::now()), - )); - cx.notify(view_id); + if phase.bubble() && hitbox_bounds.contains(&event.position) { + if scroll_handle.offset() != state.get().last_scroll_offset { + state.set(state.get().with_last_scroll( + scroll_handle.offset(), + Some(Instant::now()), + )); + cx.notify(view_id); + } } } }); @@ -707,7 +802,7 @@ impl Element for Scrollbar { if is_hover_to_show || is_visible { window.on_mouse_event({ - let state = self.state.clone(); + let state = scrollbar_state.clone(); let scroll_handle = self.scroll_handle.clone(); move |event: &MouseDownEvent, phase, _, cx| { @@ -718,6 +813,7 @@ impl Element for Scrollbar { // click on the thumb bar, set the drag position let pos = event.position - thumb_bounds.origin; + scroll_handle.start_drag(); state.set(state.get().with_drag_pos(axis, pos)); cx.notify(view_id); @@ -755,7 +851,7 @@ impl Element for Scrollbar { window.on_mouse_event({ let scroll_handle = self.scroll_handle.clone(); - let state = self.state.clone(); + let state = scrollbar_state.clone(); let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64); move |event: &MouseMoveEvent, _, _, cx| { @@ -770,11 +866,13 @@ impl Element for Scrollbar { if state.get().hovered_axis != Some(axis) { notify = true; } - } else if state.get().hovered_axis == Some(axis) - && state.get().hovered_axis.is_some() - { - state.set(state.get().with_hovered(None)); - notify = true; + } else { + if state.get().hovered_axis == Some(axis) { + if state.get().hovered_axis.is_some() { + state.set(state.get().with_hovered(None)); + notify = true; + } + } } // Update hovered state for scrollbar thumb @@ -783,13 +881,18 @@ impl Element for Scrollbar { state.set(state.get().with_hovered_on_thumb(Some(axis))); notify = true; } - } else if state.get().hovered_on_thumb == Some(axis) { - state.set(state.get().with_hovered_on_thumb(None)); - notify = true; + } else { + if state.get().hovered_on_thumb == Some(axis) { + state.set(state.get().with_hovered_on_thumb(None)); + notify = true; + } } // Move thumb position on dragging if state.get().dragged_axis == Some(axis) && event.dragging() { + // Stop the event propagation to avoid selecting text or other side effects. + cx.stop_propagation(); + // drag_pos is the position of the mouse down event // We need to keep the thumb bar still at the origin down position let drag_pos = state.get().drag_pos; @@ -836,10 +939,12 @@ impl Element for Scrollbar { }); window.on_mouse_event({ - let state = self.state.clone(); + let state = scrollbar_state.clone(); + let scroll_handle = self.scroll_handle.clone(); move |_event: &MouseUpEvent, phase, _, cx| { if phase.bubble() { + scroll_handle.end_drag(); state.set(state.get().with_unset_drag_pos()); cx.notify(view_id); } diff --git a/crates/ui/src/skeleton.rs b/crates/ui/src/skeleton.rs index ef09230..441296d 100644 --- a/crates/ui/src/skeleton.rs +++ b/crates/ui/src/skeleton.rs @@ -22,8 +22,8 @@ impl Skeleton { } } - pub fn secondary(mut self, secondary: bool) -> Self { - self.secondary = secondary; + pub fn secondary(mut self) -> Self { + self.secondary = true; self } } diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index febd958..16c6515 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -1,11 +1,7 @@ -use std::fmt::{self, Display, Formatter}; - -use gpui::{div, px, App, Axis, Div, Element, Pixels, Refineable, StyleRefinement, Styled}; +use gpui::{div, px, App, Div, Pixels, Refineable, StyleRefinement, Styled}; use serde::{Deserialize, Serialize}; use theme::ActiveTheme; -use crate::scroll::{Scrollable, ScrollbarAxis}; - /// Returns a `Div` as horizontal flex layout. pub fn h_flex() -> Div { div().h_flex() @@ -50,17 +46,6 @@ pub trait StyledExt: Styled + Sized { self.flex().flex_col() } - /// Wraps the element in a ScrollView. - /// - /// Current this is only have a vertical scrollbar. - #[inline] - fn scrollable(self, axis: impl Into) -> Scrollable - where - Self: Element, - { - Scrollable::new(axis, self) - } - font_weight!(font_thin, THIN); font_weight!(font_extralight, EXTRA_LIGHT); font_weight!(font_light, LIGHT); @@ -259,74 +244,6 @@ impl StyleSized for T { } } -pub trait AxisExt { - fn is_horizontal(&self) -> bool; - fn is_vertical(&self) -> bool; -} - -impl AxisExt for Axis { - fn is_horizontal(&self) -> bool { - self == &Axis::Horizontal - } - - fn is_vertical(&self) -> bool { - self == &Axis::Vertical - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Placement { - Top, - Bottom, - Left, - Right, -} - -impl Display for Placement { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - Placement::Top => write!(f, "Top"), - Placement::Bottom => write!(f, "Bottom"), - Placement::Left => write!(f, "Left"), - Placement::Right => write!(f, "Right"), - } - } -} - -impl Placement { - pub fn is_horizontal(&self) -> bool { - matches!(self, Placement::Left | Placement::Right) - } - - pub fn is_vertical(&self) -> bool { - matches!(self, Placement::Top | Placement::Bottom) - } - - pub fn axis(&self) -> Axis { - match self { - Placement::Top | Placement::Bottom => Axis::Vertical, - Placement::Left | Placement::Right => Axis::Horizontal, - } - } -} - -/// A enum for defining the side of the element. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Side { - Left, - Right, -} - -impl Side { - pub(crate) fn is_left(&self) -> bool { - matches!(self, Self::Left) - } - - pub(crate) fn is_right(&self) -> bool { - matches!(self, Self::Right) - } -} - /// A trait for defining element that can be collapsed. pub trait Collapsible { fn collapsed(self, collapsed: bool) -> Self;