feat: revamp the chat panel ui #7

Merged
reya merged 9 commits from revamp-chat-ui into master 2026-02-19 07:25:08 +00:00
31 changed files with 3240 additions and 2440 deletions
Showing only changes of commit 3e8efdd0ef - Show all commits

View File

@@ -82,10 +82,20 @@ impl ChatRegistry {
/// Create a new chat registry instance /// Create a new chat registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let nip65 = nostr.read(cx).nip65_state();
let nip17 = nostr.read(cx).nip17_state(); let nip17 = nostr.read(cx).nip17_state();
let mut subscriptions = smallvec![]; 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( subscriptions.push(
// Observe the nip17 state and load chat rooms on every state change // Observe the nip17 state and load chat rooms on every state change
cx.observe(&nip17, |this, _state, cx| { cx.observe(&nip17, |this, _state, cx| {

View File

@@ -53,7 +53,7 @@ impl SendReport {
/// Returns true if the send was successful. /// Returns true if the send was successful.
pub fn success(&self) -> bool { pub fn success(&self) -> bool {
if let Some(output) = self.output.as_ref() { if let Some(output) = self.output.as_ref() {
!output.success.is_empty() !output.failed.is_empty()
} else { } else {
false false
} }

View File

@@ -26,11 +26,10 @@ use state::NostrRegistry;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::context_menu::ContextMenuExt;
use ui::indicator::Indicator; use ui::indicator::Indicator;
use ui::input::{InputEvent, InputState, TextInput}; use ui::input::{InputEvent, InputState, TextInput};
use ui::menu::{ContextMenuExt, DropdownMenu};
use ui::notification::Notification; use ui::notification::Notification;
use ui::popup_menu::PopupMenuExt;
use ui::{ use ui::{
h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, h_flex, v_flex, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
WindowExtension, WindowExtension,
@@ -392,7 +391,15 @@ impl ChatPanel {
self.reports_by_id self.reports_by_id
.read_blocking() .read_blocking()
.get(id) .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 /// Get all sent reports for a message by its ID
@@ -622,6 +629,9 @@ impl ChatPanel {
// Check if message is sent successfully // Check if message is sent successfully
let sent_success = self.sent_success(&id); let sent_success = self.sent_success(&id);
// Check if message is sent failed
let sent_failed = self.sent_failed(&id);
// Hide avatar setting // Hide avatar setting
let hide_avatar = AppSettings::get_hide_avatar(cx); let hide_avatar = AppSettings::get_hide_avatar(cx);
@@ -679,7 +689,7 @@ impl ChatPanel {
this.children(self.render_message_replies(replies, cx)) this.children(self.render_message_replies(replies, cx))
}) })
.child(rendered_text) .child(rendered_text)
.when(!sent_success, |this| { .when(sent_failed, |this| {
this.child(deferred(self.render_message_reports(&id, cx))) this.child(deferred(self.render_message_reports(&id, cx)))
}), }),
), ),
@@ -966,9 +976,9 @@ impl ChatPanel {
.icon(IconName::Ellipsis) .icon(IconName::Ellipsis)
.small() .small()
.ghost() .ghost()
.popup_menu({ .dropdown_menu({
let id = id.to_owned(); 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()) .group_hover("", |this| this.visible())
@@ -1152,7 +1162,8 @@ impl Render for ChatPanel {
this.render_message(ix, window, cx) this.render_message(ix, window, cx)
}), }),
) )
.flex_1(), .flex_1()
.size_full(),
) )
.child( .child(
v_flex() v_flex()
@@ -1192,10 +1203,10 @@ impl Render for ChatPanel {
.icon(IconName::Emoji) .icon(IconName::Emoji)
.ghost() .ghost()
.large() .large()
.popup_menu_with_anchor( .dropdown_menu_with_anchor(
gpui::Corner::BottomLeft, gpui::Corner::BottomLeft,
move |this, _window, _cx| { 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("👎"))) .menu("👎", Box::new(Command::Insert("👎")))
.menu("😄", Box::new(Command::Insert("😄"))) .menu("😄", Box::new(Command::Insert("😄")))

View File

@@ -1,7 +1,6 @@
use std::rc::Rc; use std::rc::Rc;
use chat::RoomKind; use chat::RoomKind;
use chat_ui::{CopyPublicKey, OpenPublicKey};
use dock::ClosePanel; use dock::ClosePanel;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -12,7 +11,6 @@ use nostr_sdk::prelude::*;
use settings::AppSettings; use settings::AppSettings;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::context_menu::ContextMenuExt;
use ui::modal::ModalButtonProps; use ui::modal::ModalButtonProps;
use ui::{h_flex, Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension}; 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)) .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| { .when_some(self.handler, |this, handler| {
this.on_click(move |event, window, cx| { this.on_click(move |event, window, cx| {
handler(event, window, cx); handler(event, window, cx);

View File

@@ -16,7 +16,7 @@ use theme::{ActiveTheme, SIDEBAR_WIDTH, TITLEBAR_HEIGHT};
use titlebar::TitleBar; use titlebar::TitleBar;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::popup_menu::PopupMenuExt; use ui::menu::DropdownMenu;
use ui::{h_flex, v_flex, Root, Sizable, WindowExtension}; use ui::{h_flex, v_flex, Root, Sizable, WindowExtension};
use crate::panels::greeter; use crate::panels::greeter;
@@ -184,7 +184,7 @@ impl Workspace {
.caret() .caret()
.compact() .compact()
.transparent() .transparent()
.popup_menu(move |this, _window, _cx| { .dropdown_menu(move |this, _window, _cx| {
this.label(profile.name()) this.label(profile.name())
.separator() .separator()
.menu("Profile", Box::new(ClosePanel)) .menu("Profile", Box::new(ClosePanel))

View File

@@ -3,7 +3,7 @@ use gpui::{
SharedString, Window, SharedString, Window,
}; };
use ui::button::Button; use ui::button::Button;
use ui::popup_menu::PopupMenu; use ui::menu::PopupMenu;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelEvent { pub enum PanelEvent {

View File

@@ -9,7 +9,7 @@ use gpui::{
}; };
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT}; use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TABBAR_HEIGHT};
use ui::button::{Button, ButtonVariants as _}; 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 ui::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt};
use crate::dock::DockPlacement; use crate::dock::DockPlacement;
@@ -454,7 +454,7 @@ impl TabPanel {
.small() .small()
.ghost() .ghost()
.rounded() .rounded()
.popup_menu({ .dropdown_menu({
let zoomable = state.zoomable; let zoomable = state.zoomable;
let closable = state.closable; let closable = state.closable;

333
crates/ui/src/anchored.rs Normal file
View File

@@ -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<Point<Pixels>>,
position_mode: AnchoredPositionMode,
offset: Option<Point<Pixels>>,
}
/// 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<Pixels>) -> 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<Pixels>) -> 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<Edges<Pixels>>) -> Self {
self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into());
self
}
}
impl ParentElement for Anchored {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl Element for Anchored {
type PrepaintState = ();
type RequestLayoutState = AnchoredState;
fn id(&self) -> Option<gpui::ElementId> {
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::<SmallVec<_>>();
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<Pixels>,
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<Pixels> = (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<Pixels>,
_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<Pixels>),
/// 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<Point<Pixels>>,
anchor_corner: Anchor,
size: Size<Pixels>,
bounds: Bounds<Pixels>,
offset: Option<Point<Pixels>>,
) -> (Point<Pixels>, Bounds<Pixels>) {
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<Pixels>,
size: Size<Pixels>,
) -> Bounds<Pixels> {
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 }
}
}

View File

@@ -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<AnyElement> {
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<V>(&self, value: &V) -> Option<usize>
where
Self::Item: DropdownItem<Value = V>,
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<T: DropdownItem> DropdownDelegate for Vec<T> {
type Item = T;
fn len(&self) -> usize {
self.len()
}
fn get(&self, ix: usize) -> Option<&Self::Item> {
self.as_slice().get(ix)
}
fn position<V>(&self, value: &V) -> Option<usize>
where
Self::Item: DropdownItem<Value = V>,
V: PartialEq,
{
self.iter().position(|v| v.value() == value)
}
}
struct DropdownListDelegate<D: DropdownDelegate + 'static> {
delegate: D,
dropdown: WeakEntity<DropdownState<D>>,
selected_index: Option<usize>,
}
impl<D> ListDelegate for DropdownListDelegate<D>
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<List<Self>>,
) -> Option<Self::Item> {
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<List<Self>>) {
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<List<Self>>) {
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<List<Self>>,
) -> 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<usize>,
_: &mut Window,
_: &mut Context<List<Self>>,
) {
self.selected_index = ix;
}
fn render_empty(&self, window: &mut Window, cx: &mut Context<List<Self>>) -> 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<D: DropdownDelegate + 'static> {
Confirm(Option<<D::Item as DropdownItem>::Value>),
}
type DropdownStateEmpty = Option<Box<dyn Fn(&Window, &App) -> AnyElement>>;
/// State of the [`Dropdown`].
pub struct DropdownState<D: DropdownDelegate + 'static> {
focus_handle: FocusHandle,
list: Entity<List<DropdownListDelegate<D>>>,
size: Size,
empty: DropdownStateEmpty,
/// Store the bounds of the input
bounds: Bounds<Pixels>,
open: bool,
selected_value: Option<<D::Item as DropdownItem>::Value>,
_subscriptions: Vec<Subscription>,
}
/// A Dropdown element.
#[derive(IntoElement)]
pub struct Dropdown<D: DropdownDelegate + 'static> {
id: ElementId,
state: Entity<DropdownState<D>>,
size: Size,
icon: Option<Icon>,
cleanable: bool,
placeholder: Option<SharedString>,
title_prefix: Option<SharedString>,
empty: Option<AnyElement>,
width: Length,
menu_width: Length,
disabled: bool,
}
pub struct SearchableVec<T> {
items: Vec<T>,
matched_items: Vec<T>,
}
impl<T: DropdownItem + Clone> SearchableVec<T> {
pub fn new(items: impl Into<Vec<T>>) -> Self {
let items = items.into();
Self {
items: items.clone(),
matched_items: items,
}
}
}
impl<T: DropdownItem + Clone> DropdownDelegate for SearchableVec<T> {
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<V>(&self, value: &V) -> Option<usize>
where
Self::Item: DropdownItem<Value = V>,
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<Vec<SharedString>> for SearchableVec<SharedString> {
fn from(items: Vec<SharedString>) -> Self {
Self {
items: items.clone(),
matched_items: items,
}
}
}
impl<D> DropdownState<D>
where
D: DropdownDelegate + 'static,
{
pub fn new(
delegate: D,
selected_index: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<E, F>(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<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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: &<D::Item as DropdownItem>::Value,
window: &mut Window,
cx: &mut Context<Self>,
) where
<<D as DropdownDelegate>::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<usize> {
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<&<D::Item as DropdownItem>::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<Self>) {
// 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<Self>) {
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<Self>) {
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<Self>) {
// 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<Self>) {
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<Self>) {
if !self.open {
cx.propagate();
}
self.open = false;
cx.notify();
}
fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
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<Self>)
where
D: DropdownDelegate + 'static,
{
self.list.update(cx, |list, _| {
list.delegate_mut().delegate = items;
});
}
}
impl<D> Render for DropdownState<D>
where
D: DropdownDelegate + 'static,
{
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
impl<D> Dropdown<D>
where
D: DropdownDelegate + 'static,
{
pub fn new(state: &Entity<DropdownState<D>>) -> 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<Length>) -> 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<Length>) -> 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<SharedString>) -> 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<Icon>) -> 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<SharedString>) -> 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<D> Sizable for Dropdown<D>
where
D: DropdownDelegate + 'static,
{
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl<D> EventEmitter<DropdownEvent<D>> for DropdownState<D> where D: DropdownDelegate + 'static {}
impl<D> EventEmitter<DismissEvent> for DropdownState<D> where D: DropdownDelegate + 'static {}
impl<D> Focusable for DropdownState<D>
where
D: DropdownDelegate,
{
fn focus_handle(&self, cx: &App) -> FocusHandle {
if self.open {
self.list.focus_handle(cx)
} else {
self.focus_handle.clone()
}
}
}
impl<D> Focusable for Dropdown<D>
where
D: DropdownDelegate,
{
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.state.focus_handle(cx)
}
}
impl<D> RenderOnce for Dropdown<D>
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),
)
})
}
}

297
crates/ui/src/geometry.rs Normal file
View File

@@ -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<Corner> 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<Anchor> 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<Pixels>;
}
impl LengthExt for Length {
fn to_pixels(&self, base_size: AbsoluteLength, rem_size: Pixels) -> Option<Pixels> {
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<T: Clone + Debug + Default + PartialEq> {
/// 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<T> Edges<T>
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,
}
}
}

View File

@@ -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<IndexPath> 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
}
}

View File

@@ -1,9 +1,11 @@
pub use anchored::*;
pub use element_ext::ElementExt; pub use element_ext::ElementExt;
pub use event::InteractiveElementExt; pub use event::InteractiveElementExt;
pub use focusable::FocusableCycle; pub use focusable::FocusableCycle;
pub use geometry::*;
pub use icon::*; pub use icon::*;
pub use index_path::IndexPath;
pub use kbd::*; pub use kbd::*;
pub use menu::{context_menu, popup_menu};
pub use root::{window_paddings, Root}; pub use root::{window_paddings, Root};
pub use styled::*; pub use styled::*;
pub use window_ext::*; pub use window_ext::*;
@@ -16,7 +18,6 @@ pub mod avatar;
pub mod button; pub mod button;
pub mod checkbox; pub mod checkbox;
pub mod divider; pub mod divider;
pub mod dropdown;
pub mod history; pub mod history;
pub mod indicator; pub mod indicator;
pub mod input; pub mod input;
@@ -30,10 +31,13 @@ pub mod skeleton;
pub mod switch; pub mod switch;
pub mod tooltip; pub mod tooltip;
mod anchored;
mod element_ext; mod element_ext;
mod event; mod event;
mod focusable; mod focusable;
mod geometry;
mod icon; mod icon;
mod index_path;
mod kbd; mod kbd;
mod root; mod root;
mod styled; mod styled;
@@ -44,7 +48,6 @@ mod window_ext;
/// This must be called before using any of the UI components. /// This must be called before using any of the UI components.
/// You can initialize the UI module at your application's entry point. /// You can initialize the UI module at your application's entry point.
pub fn init(cx: &mut gpui::App) { pub fn init(cx: &mut gpui::App) {
dropdown::init(cx);
input::init(cx); input::init(cx);
list::init(cx); list::init(cx);
modal::init(cx); modal::init(cx);

221
crates/ui/src/list/cache.rs Normal file
View File

@@ -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<Pixels>,
pub(crate) section_header_size: Size<Pixels>,
pub(crate) section_footer_size: Size<Pixels>,
}
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<usize> {
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<Vec<RowEntry>>,
pub(crate) items_count: usize,
/// The sections, the item is number of rows in each section.
pub(crate) sections: Rc<Vec<usize>>,
pub(crate) entries_sizes: Rc<Vec<Size<Pixels>>>,
measured_size: MeasuredEntrySize,
}
impl RowsCache {
pub(crate) fn get(&self, flatten_ix: usize) -> Option<RowEntry> {
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<usize> {
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>) -> 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>) -> 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<F>(
&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;
}
}

View File

@@ -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<ListState<Self>>,
) -> 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<ListState<Self>>,
) -> Option<Self::Item>;
/// 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<ListState<Self>>,
) -> Option<impl IntoElement> {
None::<AnyElement>
}
/// 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<ListState<Self>>,
) -> Option<impl IntoElement> {
None::<AnyElement>
}
/// Return a Element to show when list is empty.
fn render_empty(
&mut self,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> 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<ListState<Self>>,
) -> Option<AnyElement> {
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<ListState<Self>>,
) -> impl IntoElement {
Loading
}
/// Set the selected index, just store the ix, don't confirm.
fn set_selected_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
);
/// Set the index of the item that has been right clicked.
fn set_right_clicked_index(
&mut self,
ix: Option<IndexPath>,
window: &mut Window,
cx: &mut Context<ListState<Self>>,
) {
}
/// 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<ListState<Self>>) {
}
/// Cancel the selection, e.g.: Pressed ESC.
fn cancel(&mut self, window: &mut Window, cx: &mut Context<ListState<Self>>) {}
/// 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<ListState<Self>>) {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,54 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, MouseButton, div, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement,
MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _, Styled, MouseMoveEvent, ParentElement, RenderOnce, Stateful, StatefulInteractiveElement as _,
Window, StyleRefinement, Styled, Window,
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use theme::ActiveTheme; 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<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
type OnMouseEnter = Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>; enum ListItemMode {
type Suffix = Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>; #[default]
Entry,
Separator,
}
impl ListItemMode {
#[inline]
fn is_separator(&self) -> bool {
matches!(self, ListItemMode::Separator)
}
}
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct ListItem { pub struct ListItem {
base: Stateful<Div>, base: Stateful<Div>,
mode: ListItemMode,
style: StyleRefinement,
disabled: bool, disabled: bool,
selected: bool, selected: bool,
secondary_selected: bool,
confirmed: bool, confirmed: bool,
check_icon: Option<Icon>, check_icon: Option<Icon>,
on_click: OnClick, on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_mouse_enter: OnMouseEnter, on_mouse_enter: Option<Box<dyn Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static>>,
suffix: Suffix, suffix: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>,
} }
impl ListItem { impl ListItem {
pub fn new(id: impl Into<ElementId>) -> Self { pub fn new(id: impl Into<ElementId>) -> Self {
let id: ElementId = id.into(); let id: ElementId = id.into();
Self { 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, disabled: false,
selected: false, selected: false,
secondary_selected: false,
confirmed: false, confirmed: false,
on_click: None, on_click: None,
on_mouse_enter: 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. /// Set to show check icon, default is None.
pub fn check_icon(mut self, icon: IconName) -> Self { pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
self.check_icon = Some(Icon::new(icon)); self.check_icon = Some(icon.into());
self self
} }
@@ -111,11 +132,16 @@ impl Selectable for ListItem {
fn is_selected(&self) -> bool { fn is_selected(&self) -> bool {
self.selected self.selected
} }
fn secondary_selected(mut self, selected: bool) -> Self {
self.secondary_selected = selected;
self
}
} }
impl Styled for ListItem { impl Styled for ListItem {
fn style(&mut self) -> &mut gpui::StyleRefinement { 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 { impl RenderOnce for ListItem {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { 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 self.base
.relative()
.gap_x_1()
.py_1()
.px_3()
.text_base()
.text_color(cx.theme().text) .text_color(cx.theme().text)
.relative() .relative()
.items_center() .items_center()
.justify_between() .justify_between()
.when_some(self.on_click, |this, on_click| { .refine_style(&self.style)
if !self.disabled { .when(is_selectable, |this| {
this.cursor_pointer() this.when_some(self.on_click, |this, on_click| this.on_click(on_click))
.on_mouse_down(MouseButton::Left, move |_, _window, cx| { .when_some(self.on_mouse_enter, |this, on_mouse_enter| {
cx.stop_propagation(); this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
}) })
.on_click(on_click) .when(!is_active, |this| {
} else { this.hover(|this| this.bg(cx.theme().ghost_element_hover))
this })
}
}) })
.when(is_active, |this| this.bg(cx.theme().element_active)) .when(!is_selectable, |this| {
.when(!is_active && !self.disabled, |this| { this.text_color(cx.theme().text_muted)
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
}
}) })
.child( .child(
h_flex() h_flex()
@@ -177,5 +205,17 @@ impl RenderOnce for ListItem {
}), }),
) )
.when_some(self.suffix, |this, suffix| this.child(suffix(window, cx))) .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
}
})
} }
} }

View File

@@ -17,7 +17,7 @@ impl RenderOnce for LoadingItem {
.gap_1p5() .gap_1p5()
.overflow_hidden() .overflow_hidden()
.child(Skeleton::new().h_5().w_48().max_w_full()) .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()),
) )
} }
} }

View File

@@ -1,7 +1,27 @@
#[allow(clippy::module_inception)] pub(crate) mod cache;
mod delegate;
mod list; mod list;
mod list_item; mod list_item;
mod loading; mod loading;
mod separator_item;
pub use delegate::*;
pub use list::*; pub use list::*;
pub use list_item::*; 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,
}
}
}

View File

@@ -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<Item = AnyElement>) {
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)
}
}

View File

@@ -1,16 +1,17 @@
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity, anchored, deferred, div, px, App, AppContext as _, ClickEvent, Context, DismissEvent, Entity,
Focusable, InteractiveElement as _, IntoElement, KeyBinding, OwnedMenu, ParentElement, Render, Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, OwnedMenu,
SharedString, StatefulInteractiveElement, Styled, Subscription, Window, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
}; };
use crate::actions::{Cancel, SelectLeft, SelectRight}; use crate::actions::{Cancel, SelectLeft, SelectRight};
use crate::button::{Button, ButtonVariants}; use crate::button::{Button, ButtonVariants};
use crate::popup_menu::PopupMenu; use crate::menu::PopupMenu;
use crate::{h_flex, Selectable, Sizable}; use crate::{h_flex, Selectable, Sizable};
const CONTEXT: &str = "AppMenuBar"; const CONTEXT: &str = "AppMenuBar";
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
cx.bind_keys([ cx.bind_keys([
KeyBinding::new("escape", Cancel, Some(CONTEXT)), KeyBinding::new("escape", Cancel, Some(CONTEXT)),
@@ -22,67 +23,74 @@ pub fn init(cx: &mut App) {
/// The application menu bar, for Windows and Linux. /// The application menu bar, for Windows and Linux.
pub struct AppMenuBar { pub struct AppMenuBar {
menus: Vec<Entity<AppMenu>>, menus: Vec<Entity<AppMenu>>,
selected_ix: Option<usize>, selected_index: Option<usize>,
} }
impl AppMenuBar { impl AppMenuBar {
/// Create a new app menu bar. /// Create a new app menu bar.
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> { pub fn new(cx: &mut App) -> Entity<Self> {
cx.new(|cx| { cx.new(|cx| {
let menu_bar = cx.entity(); let mut this = Self {
let menus = cx selected_index: None,
.get_menus() menus: Vec::new(),
.unwrap_or_default() };
.iter() this.reload(cx);
.enumerate() this
.map(|(ix, menu)| AppMenu::new(ix, menu, menu_bar.clone(), window, cx))
.collect();
Self {
selected_ix: None,
menus,
}
}) })
} }
fn move_left(&mut self, _: &SelectLeft, window: &mut Window, cx: &mut Context<Self>) { /// Reload the menus from the app.
let Some(selected_ix) = self.selected_ix else { pub fn reload(&mut self, cx: &mut Context<Self>) {
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<Self>) {
let Some(selected_index) = self.selected_index else {
return; return;
}; };
let new_ix = if selected_ix == 0 { let new_ix = if selected_index == 0 {
self.menus.len().saturating_sub(1) self.menus.len().saturating_sub(1)
} else { } 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<Self>) { fn on_move_right(&mut self, _: &SelectRight, window: &mut Window, cx: &mut Context<Self>) {
let Some(selected_ix) = self.selected_ix else { let Some(selected_index) = self.selected_index else {
return; return;
}; };
let new_ix = if selected_ix + 1 >= self.menus.len() { let new_ix = if selected_index + 1 >= self.menus.len() {
0 0
} else { } 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>) { fn on_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
self.set_selected_ix(None, window, cx); self.set_selected_index(None, window, cx);
} }
fn set_selected_ix(&mut self, ix: Option<usize>, _: &mut Window, cx: &mut Context<Self>) { fn set_selected_index(&mut self, ix: Option<usize>, _: &mut Window, cx: &mut Context<Self>) {
self.selected_ix = ix; self.selected_index = ix;
cx.notify(); cx.notify();
} }
#[inline] #[inline]
fn has_activated_menu(&self) -> bool { 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() h_flex()
.id("app-menu-bar") .id("app-menu-bar")
.key_context(CONTEXT) .key_context(CONTEXT)
.on_action(cx.listener(Self::move_left)) .on_action(cx.listener(Self::on_move_left))
.on_action(cx.listener(Self::move_right)) .on_action(cx.listener(Self::on_move_right))
.on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::on_cancel))
.size_full() .size_full()
.gap_x_1() .gap_x_1()
.overflow_x_scroll() .overflow_x_scroll()
@@ -117,7 +125,6 @@ impl AppMenu {
ix: usize, ix: usize,
menu: &OwnedMenu, menu: &OwnedMenu,
menu_bar: Entity<AppMenuBar>, menu_bar: Entity<AppMenuBar>,
_: &mut Window,
cx: &mut App, cx: &mut App,
) -> Entity<Self> { ) -> Entity<Self> {
let name = menu.name.clone(); let name = menu.name.clone();
@@ -173,7 +180,7 @@ impl AppMenu {
self._subscription.take(); self._subscription.take();
self.popup_menu.take(); self.popup_menu.take();
self.menu_bar.update(cx, |state, cx| { 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, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
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) }; 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; return;
} }
self.menu_bar.update(cx, |state, cx| { _ = self.menu_bar.update(cx, |state, cx| {
state.set_selected_ix(Some(self.ix), window, cx); state.set_selected_index(Some(self.ix), window, cx);
}); });
} }
} }
@@ -210,7 +217,7 @@ impl AppMenu {
impl Render for AppMenu { impl Render for AppMenu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let menu_bar = self.menu_bar.read(cx); 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() div()
.id(self.ix) .id(self.ix)
@@ -219,10 +226,15 @@ impl Render for AppMenu {
Button::new("menu") Button::new("menu")
.small() .small()
.py_0p5() .py_0p5()
.xsmall() .compact()
.ghost() .ghost()
.label(self.name.clone()) .label(self.name.clone())
.selected(is_selected) .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_click(cx.listener(Self::handle_trigger_click)),
) )
.on_hover(cx.listener(Self::handle_hover)) .on_hover(cx.listener(Self::handle_hover))

View File

@@ -3,49 +3,65 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
anchored, deferred, div, px, relative, AnyElement, App, Context, Corner, DismissEvent, Element, anchored, deferred, div, px, AnyElement, App, Context, Corner, DismissEvent, Element,
ElementId, Entity, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, ElementId, Entity, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Position, Stateful, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
Style, Subscription, Window, 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( fn context_menu(
self, self,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self { ) -> ContextMenu<Self>
self.child(ContextMenu::new("context-menu").menu(f)) 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<E> ContextMenuExt for Stateful<E> where E: ParentElement {} impl<E: ParentElement + Styled> ContextMenuExt for E {}
/// A context menu that can be shown on right-click. /// A context menu that can be shown on right-click.
#[allow(clippy::type_complexity)] pub struct ContextMenu<E: ParentElement + Styled + Sized> {
pub struct ContextMenu {
id: ElementId, id: ElementId,
menu: element: Option<E>,
Option<Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>>, menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
// This is not in use, just for style refinement forwarding.
_ignore_style: StyleRefinement,
anchor: Corner, anchor: Corner,
} }
impl ContextMenu { impl<E: ParentElement + Styled> ContextMenu<E> {
pub fn new(id: impl Into<ElementId>) -> Self { /// Create a new context menu with the given ID.
pub fn new(id: impl Into<ElementId>, element: E) -> Self {
Self { Self {
id: id.into(), id: id.into(),
element: Some(element),
menu: None, menu: None,
anchor: Corner::TopLeft, anchor: Corner::TopLeft,
_ignore_style: StyleRefinement::default(),
} }
} }
/// Build the context menu using the given builder function.
#[must_use] #[must_use]
pub fn menu<F>(mut self, builder: F) -> Self fn menu<F>(mut self, builder: F) -> Self
where where
F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static, F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
{ {
self.menu = Some(Box::new(builder)); self.menu = Some(Rc::new(builder));
self self
} }
@@ -67,7 +83,25 @@ impl ContextMenu {
} }
} }
impl IntoElement for ContextMenu { impl<E: ParentElement + Styled> ParentElement for ContextMenu<E> {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
if let Some(element) = &mut self.element {
element.extend(elements);
}
}
}
impl<E: ParentElement + Styled> Styled for ContextMenu<E> {
fn style(&mut self) -> &mut StyleRefinement {
if let Some(element) = &mut self.element {
element.style()
} else {
&mut self._ignore_style
}
}
}
impl<E: ParentElement + Styled + IntoElement + 'static> IntoElement for ContextMenu<E> {
type Element = Self; type Element = Self;
fn into_element(self) -> Self::Element { fn into_element(self) -> Self::Element {
@@ -83,14 +117,14 @@ struct ContextMenuSharedState {
} }
pub struct ContextMenuState { pub struct ContextMenuState {
menu_element: Option<AnyElement>, element: Option<AnyElement>,
shared_state: Rc<RefCell<ContextMenuSharedState>>, shared_state: Rc<RefCell<ContextMenuSharedState>>,
} }
impl Default for ContextMenuState { impl Default for ContextMenuState {
fn default() -> Self { fn default() -> Self {
Self { Self {
menu_element: None, element: None,
shared_state: Rc::new(RefCell::new(ContextMenuSharedState { shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
menu_view: None, menu_view: None,
open: false, open: false,
@@ -101,8 +135,8 @@ impl Default for ContextMenuState {
} }
} }
impl Element for ContextMenu { impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
type PrepaintState = (); type PrepaintState = Hitbox;
type RequestLayoutState = ContextMenuState; type RequestLayoutState = ContextMenuState;
fn id(&self) -> Option<ElementId> { fn id(&self) -> Option<ElementId> {
@@ -113,7 +147,6 @@ impl Element for ContextMenu {
None None
} }
#[allow(clippy::field_reassign_with_default)]
fn request_layout( fn request_layout(
&mut self, &mut self,
id: Option<&gpui::GlobalElementId>, id: Option<&gpui::GlobalElementId>,
@@ -121,71 +154,73 @@ impl Element for ContextMenu {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) { ) -> (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; let anchor = self.anchor;
self.with_element_state( self.with_element_state(
id.unwrap(), id.unwrap(),
window, window,
cx, cx,
|_, state: &mut ContextMenuState, window, cx| { |this, state: &mut ContextMenuState, window, cx| {
let (position, open) = { let (position, open) = {
let shared_state = state.shared_state.borrow(); let shared_state = state.shared_state.borrow();
(shared_state.position, shared_state.open) (shared_state.position, shared_state.open)
}; };
let menu_view = state.shared_state.borrow().menu_view.clone(); 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 let has_menu_item = menu_view
.as_ref() .as_ref()
.map(|menu| !menu.read(cx).is_empty()) .map(|menu| !menu.read(cx).is_empty())
.unwrap_or(false); .unwrap_or(false);
if has_menu_item { if has_menu_item {
let mut menu_element = deferred( menu_element = Some(
anchored() deferred(
.position(position) anchored().child(
.snap_to_window_with_margin(px(8.)) div()
.anchor(anchor) .w(window.bounds().size.width)
.when_some(menu_view, |this, menu| { .h(window.bounds().size.height)
// Focus the menu, so that can be handle the action. .on_scroll_wheel(|_, _, cx| {
if !menu.focus_handle(cx).contains_focused(window, cx) { cx.stop_propagation();
menu.focus_handle(cx).focus(window, cx); })
} .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())) this.child(menu.clone())
}), }),
) ),
.with_priority(1) ),
.into_any(); )
.with_priority(1)
let menu_layout_id = menu_element.request_layout(window, cx); .into_any(),
(Some(menu_element), Some(menu_layout_id)) );
} else {
(None, None)
} }
} 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, layout_id,
ContextMenuState { ContextMenuState {
menu_element, element: Some(element),
..Default::default() ..Default::default()
}, },
) )
@@ -197,33 +232,33 @@ impl Element for ContextMenu {
&mut self, &mut self,
_: Option<&gpui::GlobalElementId>, _: Option<&gpui::GlobalElementId>,
_: Option<&InspectorElementId>, _: Option<&InspectorElementId>,
_: gpui::Bounds<gpui::Pixels>, bounds: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState, request_layout: &mut Self::RequestLayoutState,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Self::PrepaintState { ) -> Self::PrepaintState {
if let Some(menu_element) = &mut request_layout.menu_element { if let Some(element) = &mut request_layout.element {
menu_element.prepaint(window, cx); element.prepaint(window, cx);
} }
window.insert_hitbox(bounds, HitboxBehavior::Normal)
} }
fn paint( fn paint(
&mut self, &mut self,
id: Option<&gpui::GlobalElementId>, id: Option<&gpui::GlobalElementId>,
_: Option<&InspectorElementId>, _: Option<&InspectorElementId>,
bounds: gpui::Bounds<gpui::Pixels>, _: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState, request_layout: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState, hitbox: &mut Self::PrepaintState,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) { ) {
if let Some(menu_element) = &mut request_layout.menu_element { if let Some(element) = &mut request_layout.element {
menu_element.paint(window, cx); element.paint(window, cx);
} }
let Some(builder) = self.menu.take() else { // Take the builder before setting up element state to avoid borrow issues
return; let builder = self.menu.clone();
};
self.with_element_state( self.with_element_state(
id.unwrap(), id.unwrap(),
@@ -232,33 +267,53 @@ impl Element for ContextMenu {
|_view, state: &mut ContextMenuState, window, _| { |_view, state: &mut ContextMenuState, window, _| {
let shared_state = state.shared_state.clone(); 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. // 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| { window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
if phase.bubble() if phase.bubble()
&& event.button == MouseButton::Right && event.button == MouseButton::Right
&& bounds.contains(&event.position) && hitbox.is_hovered(window)
{ {
{ {
let mut shared_state = shared_state.borrow_mut(); 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.position = event.position;
shared_state.open = true; shared_state.open = true;
} }
let menu = PopupMenu::build(window, cx, |menu, window, cx| { // Use defer to build the menu in the next frame, avoiding race conditions
(builder)(menu, window, cx) window.defer(cx, {
});
let _subscription = window.subscribe(&menu, cx, {
let shared_state = shared_state.clone(); let shared_state = shared_state.clone();
move |_, _: &DismissEvent, window, _| { let builder = builder.clone();
shared_state.borrow_mut().open = false; move |window, cx| {
window.refresh(); 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();
} }
}); });
}, },

View File

@@ -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>) -> PopupMenu + 'static,
) -> DropdownMenuPopover<Self> {
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<Corner>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> DropdownMenuPopover<Self> {
let style = self.style().clone();
let id = self.interactivity().element_id.clone();
DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f).trigger_style(style)
}
}
impl DropdownMenu for Button {}
#[derive(IntoElement)]
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
id: ElementId,
style: StyleRefinement,
anchor: Corner,
trigger: T,
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
}
impl<T> DropdownMenuPopover<T>
where
T: Selectable + IntoElement + 'static,
{
fn new(
id: ElementId,
anchor: impl Into<Corner>,
trigger: T,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
Self {
id: SharedString::from(format!("dropdown-menu:{:?}", id)).into(),
style: StyleRefinement::default(),
anchor: anchor.into(),
trigger,
builder: Rc::new(builder),
}
}
/// Set the anchor corner for the dropdown menu popover.
pub fn anchor(mut self, anchor: impl Into<Corner>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the style refinement for the dropdown menu trigger.
fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.style = style;
self
}
}
#[derive(Default)]
struct DropdownMenuState {
menu: Option<Entity<PopupMenu>>,
}
impl<T> RenderOnce for DropdownMenuPopover<T>
where
T: Selectable + IntoElement + 'static,
{
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let builder = self.builder.clone();
let menu_state =
window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
Popover::new(SharedString::from(format!("popover:{}", self.id)))
.appearance(false)
.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()
})
}
}

View File

@@ -10,7 +10,6 @@ use theme::ActiveTheme;
use crate::{h_flex, Disableable, StyledExt}; use crate::{h_flex, Disableable, StyledExt};
#[derive(IntoElement)] #[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub(crate) struct MenuItemElement { pub(crate) struct MenuItemElement {
id: ElementId, id: ElementId,
group_name: SharedString, group_name: SharedString,
@@ -23,7 +22,8 @@ pub(crate) struct MenuItemElement {
} }
impl MenuItemElement { impl MenuItemElement {
pub fn new(id: impl Into<ElementId>, group_name: impl Into<SharedString>) -> Self { /// Create a new MenuItem with the given ID and group name.
pub(crate) fn new(id: impl Into<ElementId>, group_name: impl Into<SharedString>) -> Self {
let id: ElementId = id.into(); let id: ElementId = id.into();
Self { Self {
id: id.clone(), id: id.clone(),
@@ -38,17 +38,19 @@ impl MenuItemElement {
} }
/// Set ListItem as the selected item style. /// 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.selected = selected;
self 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.disabled = disabled;
self self
} }
pub fn on_click( /// Set a handler for when the MenuItem is clicked.
pub(crate) fn on_click(
mut self, mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self { ) -> Self {
@@ -88,7 +90,7 @@ impl RenderOnce for MenuItemElement {
h_flex() h_flex()
.id(self.id) .id(self.id)
.group(&self.group_name) .group(&self.group_name)
.gap_x_2() .gap_x_1()
.py_1() .py_1()
.px_2() .px_2()
.text_base() .text_base()
@@ -102,12 +104,12 @@ impl RenderOnce for MenuItemElement {
}) })
.when(!self.disabled, |this| { .when(!self.disabled, |this| {
this.group_hover(self.group_name, |this| { this.group_hover(self.group_name, |this| {
this.bg(cx.theme().elevated_surface_background) this.bg(cx.theme().element_background)
.text_color(cx.theme().text) .text_color(cx.theme().element_foreground)
}) })
.when(self.selected, |this| { .when(self.selected, |this| {
this.bg(cx.theme().elevated_surface_background) this.bg(cx.theme().element_background)
.text_color(cx.theme().text) .text_color(cx.theme().element_foreground)
}) })
.when_some(self.on_click, |this, on_click| { .when_some(self.on_click, |this, on_click| {
this.on_mouse_down(MouseButton::Left, move |_, _, cx| { this.on_mouse_down(MouseButton::Left, move |_, _, cx| {

View File

@@ -1,12 +1,15 @@
use gpui::App; use gpui::App;
mod app_menu_bar; mod app_menu_bar;
mod context_menu;
mod dropdown_menu;
mod menu_item; mod menu_item;
mod popup_menu;
pub mod context_menu;
pub mod popup_menu;
pub use app_menu_bar::AppMenuBar; 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) { pub(crate) fn init(cx: &mut App) {
app_menu_bar::init(cx); app_menu_bar::init(cx);

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ 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, BoxShadow, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding,
MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled,
Window, Window,
@@ -13,6 +13,7 @@ use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm}; use crate::actions::{Cancel, Confirm};
use crate::animation::cubic_bezier; use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _}; use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
use crate::scroll::ScrollableElement;
use crate::{h_flex, v_flex, IconName, Root, Sizable, StyledExt, WindowExtension}; use crate::{h_flex, v_flex, IconName, Root, Sizable, StyledExt, WindowExtension};
const CONTEXT: &str = "Modal"; const CONTEXT: &str = "Modal";
@@ -489,13 +490,13 @@ impl RenderOnce for Modal {
.w_full() .w_full()
.h_auto() .h_auto()
.flex_1() .flex_1()
.relative()
.overflow_hidden() .overflow_hidden()
.child( .child(
v_flex() v_flex()
.pr(padding_right) .pr(padding_right)
.pl(padding_left) .pl(padding_left)
.scrollable(Axis::Vertical) .size_full()
.overflow_y_scrollbar()
.child(self.content), .child(self.content),
), ),
) )

View File

@@ -1,129 +1,75 @@
use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
actions, anchored, deferred, div, px, AnyElement, App, Bounds, Context, Corner, DismissEvent, deferred, div, px, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId,
DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding,
GlobalElementId, Hitbox, HitboxBehavior, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement,
LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render, Styled, Subscription, Window,
ScrollHandle, StatefulInteractiveElement, Style, StyleRefinement, Styled, Window,
}; };
use crate::{Selectable, StyledExt as _}; use crate::actions::Cancel;
use crate::{anchored, v_flex, Anchor, ElementExt, Selectable, StyledExt as _};
const CONTEXT: &str = "Popover"; const CONTEXT: &str = "Popover";
actions!(popover, [Escape]); pub(crate) fn init(cx: &mut App) {
cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))])
pub fn init(cx: &mut App) {
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
} }
type PopoverChild<T> = Rc<dyn Fn(&mut Window, &mut Context<T>) -> AnyElement>; /// A popover element that can be triggered by a button or any other element.
#[derive(IntoElement)]
pub struct PopoverContent { pub struct Popover {
focus_handle: FocusHandle,
scroll_handle: ScrollHandle,
max_width: Option<Pixels>,
max_height: Option<Pixels>,
scrollable: bool,
child: PopoverChild<Self>,
}
impl PopoverContent {
pub fn new<B>(_window: &mut Window, cx: &mut App, content: B) -> Self
where
B: Fn(&mut Window, &mut Context<Self>) -> 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<DismissEvent> 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<Self>) -> 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<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>;
type Content<M> = Option<Rc<dyn Fn(&mut Window, &mut App) -> Entity<M> + 'static>>;
pub struct Popover<M: ManagedView> {
id: ElementId, id: ElementId,
anchor: Corner, style: StyleRefinement,
trigger: Trigger, anchor: Anchor,
content: Content<M>, default_open: bool,
open: Option<bool>,
tracked_focus_handle: Option<FocusHandle>,
trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
content: Option<
Rc<
dyn Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> AnyElement
+ 'static,
>,
>,
children: Vec<AnyElement>,
/// Style for trigger element. /// Style for trigger element.
/// This is used for hotfix the trigger element style to support w_full. /// This is used for hotfix the trigger element style to support w_full.
trigger_style: Option<StyleRefinement>, trigger_style: Option<StyleRefinement>,
mouse_button: MouseButton, mouse_button: MouseButton,
no_style: bool, appearance: bool,
overlay_closable: bool,
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
} }
impl<M> Popover<M> impl Popover {
where
M: ManagedView,
{
/// Create a new Popover with `view` mode. /// Create a new Popover with `view` mode.
pub fn new(id: impl Into<ElementId>) -> Self { pub fn new(id: impl Into<ElementId>) -> Self {
Self { Self {
id: id.into(), id: id.into(),
anchor: Corner::TopLeft, style: StyleRefinement::default(),
anchor: Anchor::TopLeft,
trigger: None, trigger: None,
trigger_style: None, trigger_style: None,
content: None, content: None,
tracked_focus_handle: None,
children: vec![],
mouse_button: MouseButton::Left, 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 { /// Set the anchor corner of the popover, default is `Corner::TopLeft`.
self.anchor = anchor; ///
/// This method is kept for backward compatibility with `Corner` type.
/// Internally, it converts `Corner` to `Anchor`.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self self
} }
@@ -133,29 +79,75 @@ where
self self
} }
/// Set the trigger element of the popover.
pub fn trigger<T>(mut self, trigger: T) -> Self pub fn trigger<T>(mut self, trigger: T) -> Self
where where
T: Selectable + IntoElement + 'static, T: Selectable + IntoElement + 'static,
{ {
self.trigger = Some(Box::new(|is_open, _, _| { 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 self
} }
/// Set the default open state of the popover, default is `false`.
///
/// This is only used to initialize the open state of the popover.
///
/// And please note that if you use the `open` method, this value will be ignored.
pub fn default_open(mut self, open: bool) -> Self {
self.default_open = open;
self
}
/// Force set the open state of the popover.
///
/// If this is set, the popover will be controlled by this value.
///
/// NOTE: You must be used in conjunction with `on_open_change` to handle state changes.
pub fn open(mut self, open: bool) -> Self {
self.open = Some(open);
self
}
/// Add a callback to be called when the open state changes.
///
/// The first `&bool` parameter is the **new open state**.
///
/// This is useful when using the `open` method to control the popover state.
pub fn on_open_change<F>(mut self, callback: F) -> Self
where
F: Fn(&bool, &mut Window, &mut App) + 'static,
{
self.on_open_change = Some(Rc::new(callback));
self
}
/// Set the style for the trigger element.
pub fn trigger_style(mut self, style: StyleRefinement) -> Self { pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.trigger_style = Some(style); self.trigger_style = Some(style);
self 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`. /// This callback will called every time on render the popover.
pub fn content<C>(mut self, content: C) -> Self /// So, you should avoid creating new elements or entities in the content closure.
pub fn content<F, E>(mut self, content: F) -> Self
where where
C: Fn(&mut Window, &mut App) -> Entity<M> + 'static, E: IntoElement,
F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
{ {
self.content = Some(Rc::new(content)); self.content = Some(Rc::new(move |state, window, cx| {
content(state, window, cx).into_any_element()
}));
self self
} }
@@ -165,302 +157,264 @@ where
/// ///
/// - The popover will not have a bg, border, shadow, or padding. /// - The popover will not have a bg, border, shadow, or padding.
/// - The click out of the popover will not dismiss it. /// - The click out of the popover will not dismiss it.
pub fn no_style(mut self) -> Self { pub fn appearance(mut self, appearance: bool) -> Self {
self.no_style = true; self.appearance = appearance;
self self
} }
fn render_trigger(&mut self, is_open: bool, window: &mut Window, cx: &mut App) -> AnyElement { /// Bind the focus handle to receive focus when the popover is opened.
let Some(trigger) = self.trigger.take() else { /// If you not set this, a new focus handle will be created for the popover to
return div().into_any_element(); ///
/// 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<Pixels>) -> Point<Pixels> {
let offset = if anchor.is_center() {
gpui::point(trigger_bounds.size.width.half(), px(0.))
} else {
Point::default()
}; };
(trigger)(is_open, window, cx) trigger_bounds.corner(anchor.swap_vertical().into())
} + offset
+ Point {
fn resolved_corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> { x: px(0.),
bounds.corner(match self.anchor { y: -trigger_bounds.size.height,
Corner::TopLeft => Corner::BottomLeft, }
Corner::TopRight => Corner::BottomRight,
Corner::BottomLeft => Corner::TopLeft,
Corner::BottomRight => Corner::TopRight,
})
}
fn with_element_state<R>(
&mut self,
id: &GlobalElementId,
window: &mut Window,
cx: &mut App,
f: impl FnOnce(&mut Self, &mut PopoverElementState<M>, &mut Window, &mut App) -> R,
) -> R {
window.with_optional_element_state::<PopoverElementState<M>, _>(
Some(id),
|element_state, window| {
let mut element_state = element_state.unwrap().unwrap_or_default();
let result = f(self, &mut element_state, window, cx);
(result, Some(element_state))
},
)
} }
} }
impl<M> IntoElement for Popover<M> impl ParentElement for Popover {
where fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
M: ManagedView, self.children.extend(elements);
{
type Element = Self;
fn into_element(self) -> Self::Element {
self
} }
} }
pub struct PopoverElementState<M> { impl Styled for Popover {
trigger_layout_id: Option<LayoutId>, fn style(&mut self) -> &mut StyleRefinement {
popover_layout_id: Option<LayoutId>, &mut self.style
popover_element: Option<AnyElement>, }
trigger_element: Option<AnyElement>,
content_view: Rc<RefCell<Option<Entity<M>>>>,
/// Trigger bounds for positioning the popover.
trigger_bounds: Option<Bounds<Pixels>>,
} }
impl<M> Default for PopoverElementState<M> { pub struct PopoverState {
fn default() -> Self { focus_handle: FocusHandle,
pub(crate) tracked_focus_handle: Option<FocusHandle>,
trigger_bounds: Bounds<Pixels>,
open: bool,
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
_dismiss_subscription: Option<Subscription>,
}
impl PopoverState {
pub fn new(default_open: bool, cx: &mut App) -> Self {
Self { Self {
trigger_layout_id: None, focus_handle: cx.focus_handle(),
popover_layout_id: None, tracked_focus_handle: None,
popover_element: None, trigger_bounds: Bounds::default(),
trigger_element: None, open: default_open,
content_view: Rc::new(RefCell::new(None)), on_open_change: None,
trigger_bounds: None, _dismiss_subscription: None,
}
}
}
pub struct PrepaintState {
hitbox: Hitbox,
/// Trigger bounds for limit a rect to handle mouse click.
trigger_bounds: Option<Bounds<Pixels>>,
}
impl<M: ManagedView> Element for Popover<M> {
type PrepaintState = PrepaintState;
type RequestLayoutState = PopoverElementState<M>;
fn id(&self) -> Option<ElementId> {
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<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
if let Some(element) = &mut request_layout.trigger_element {
element.prepaint(window, cx);
}
if let Some(element) = &mut request_layout.popover_element {
element.prepaint(window, cx);
}
let trigger_bounds = request_layout
.trigger_layout_id
.map(|id| window.layout_bounds(id));
// Prepare the popover, for get the bounds of it for open window size.
let _ = request_layout
.popover_layout_id
.map(|id| window.layout_bounds(id));
let hitbox =
window.insert_hitbox(trigger_bounds.unwrap_or_default(), HitboxBehavior::Normal);
PrepaintState {
trigger_bounds,
hitbox,
} }
} }
fn paint( /// Check if the popover is open.
&mut self, pub fn is_open(&self) -> bool {
id: Option<&GlobalElementId>, self.open
_: Option<&gpui::InspectorElementId>, }
_bounds: Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
self.with_element_state(
id.unwrap(),
window,
cx,
|this, element_state, window, cx| {
element_state.trigger_bounds = prepaint.trigger_bounds;
if let Some(mut element) = request_layout.trigger_element.take() { /// Dismiss the popover if it is open.
element.paint(window, cx); pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
} if self.open {
self.toggle_open(window, cx);
}
}
if let Some(mut element) = request_layout.popover_element.take() { /// Open the popover if it is closed.
element.paint(window, cx); pub fn show(&mut self, window: &mut Window, cx: &mut Context<Self>) {
return; if !self.open {
} self.toggle_open(window, cx);
}
}
// When mouse click down in the trigger bounds, open the popover. fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(content_build) = this.content.take() else { self.open = !self.open;
return; if self.open {
}; let state = cx.entity();
let old_content_view = element_state.content_view.clone(); let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone()
let hitbox_id = prepaint.hitbox.id; {
let mouse_button = this.mouse_button; tracked_focus_handle
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { } else {
if phase == DispatchPhase::Bubble self.focus_handle.clone()
&& event.button == mouse_button };
&& hitbox_id.is_hovered(window) focus_handle.focus(window, cx);
{
cx.stop_propagation();
window.prevent_default();
let new_content_view = (content_build)(window, cx); self._dismiss_subscription =
let old_content_view1 = old_content_view.clone(); Some(
window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| {
let previous_focus_handle = window.focused(cx); state.update(cx, |state, cx| {
state.dismiss(window, 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);
window.refresh(); 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>) {
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<Self>) -> impl IntoElement {
div()
}
}
impl EventEmitter<DismissEvent> for PopoverState {}
impl Popover {
pub(crate) fn render_popover<E>(
anchor: Anchor,
trigger_bounds: Bounds<Pixels>,
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<Div> {
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,
))
} }
} }

View File

@@ -1,232 +1,209 @@
use std::panic::Location;
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, relative, AnyElement, App, Bounds, Div, Element, ElementId, GlobalElementId, div, App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, ParentElement, ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window,
Pixels, Position, ScrollHandle, SharedString, Size, Stateful, StatefulInteractiveElement,
Style, 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. /// A trait for elements that can be made scrollable with scrollbars.
pub struct Scrollable<E> { pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
/// Adds a scrollbar to the element.
#[track_caller]
fn scrollbar<H: ScrollbarHandle + Clone>(
self,
scroll_handle: &H,
axis: impl Into<ScrollbarAxis>,
) -> 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<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
self.scrollbar(scroll_handle, ScrollbarAxis::Vertical)
}
/// Adds a horizontal scrollbar to the element.
#[track_caller]
fn horizontal_scrollbar<H: ScrollbarHandle + Clone>(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<Self> {
Scrollable::new(self, ScrollbarAxis::Both)
}
/// Almost equivalent to [`StatefulInteractiveElement::overflow_x_scroll`], but adds Horizontal scrollbar.
#[track_caller]
fn overflow_x_scrollbar(self) -> Scrollable<Self> {
Scrollable::new(self, ScrollbarAxis::Horizontal)
}
/// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar.
#[track_caller]
fn overflow_y_scrollbar(self) -> Scrollable<Self> {
Scrollable::new(self, ScrollbarAxis::Vertical)
}
}
/// A scrollable element wrapper that adds scrollbars to an interactive element.
#[derive(IntoElement)]
pub struct Scrollable<E: InteractiveElement + Styled + ParentElement + Element> {
id: ElementId, id: ElementId,
element: Option<E>, element: E,
axis: ScrollbarAxis, axis: ScrollbarAxis,
/// This is a fake element to handle Styled, InteractiveElement, not used.
_element: Stateful<Div>,
} }
impl<E> Scrollable<E> impl<E> Scrollable<E>
where where
E: Element, E: InteractiveElement + Styled + ParentElement + Element,
{ {
pub(crate) fn new(axis: impl Into<ScrollbarAxis>, element: E) -> Self { #[track_caller]
let id = ElementId::Name(SharedString::from( fn new(element: E, axis: impl Into<ScrollbarAxis>) -> Self {
format!("scrollable-{:?}", element.id(),), let caller = Location::caller();
));
Self { Self {
element: Some(element), id: ElementId::CodeLocation(*caller),
_element: div().id("fake"), element,
id,
axis: axis.into(), 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<ScrollbarAxis>) {
self.axis = axis.into();
}
fn with_element_state<R>(
&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::<ScrollViewState, _>(
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<E> ParentElement for Scrollable<E>
where
E: Element + ParentElement,
{
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
if let Some(element) = &mut self.element {
element.extend(elements);
}
}
} }
impl<E> Styled for Scrollable<E> impl<E> Styled for Scrollable<E>
where where
E: Element + Styled, E: InteractiveElement + Styled + ParentElement + Element,
{ {
fn style(&mut self) -> &mut StyleRefinement { fn style(&mut self) -> &mut StyleRefinement {
if let Some(element) = &mut self.element { self.element.style()
element.style()
} else {
self._element.style()
}
} }
} }
impl<E> InteractiveElement for Scrollable<E> impl<E> ParentElement for Scrollable<E>
where where
E: Element + InteractiveElement, E: InteractiveElement + Styled + ParentElement + Element,
{ {
fn interactivity(&mut self) -> &mut Interactivity { fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
if let Some(element) = &mut self.element { self.element.extend(elements)
element.interactivity()
} else {
self._element.interactivity()
}
}
}
impl<E> StatefulInteractiveElement for Scrollable<E> where E: Element + StatefulInteractiveElement {}
impl<E> IntoElement for Scrollable<E>
where
E: Element,
{
type Element = Self;
fn into_element(self) -> Self::Element {
self
} }
} }
impl<E> Element for Scrollable<E> impl InteractiveElement for Scrollable<Div> {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.element.interactivity()
}
}
impl InteractiveElement for Scrollable<Stateful<Div>> {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.element.interactivity()
}
}
impl<E> RenderOnce for Scrollable<E>
where where
E: Element, E: InteractiveElement + Styled + ParentElement + Element + 'static,
{ {
type PrepaintState = ScrollViewState; fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
type RequestLayoutState = AnyElement; let scroll_handle = window
.use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::default())
.read(cx)
.clone();
fn id(&self) -> Option<ElementId> { // Inherit the size from the element style.
Some(self.id.clone()) let style = StyleRefinement {
} size: self.element.style().size.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(),
},
..Default::default() ..Default::default()
}; };
let axis = self.axis; div()
let scroll_id = self.id.clone(); .id(self.id)
let content = self.element.take().map(|c| c.into_any_element()); .size_full()
.refine_style(&style)
self.with_element_state(id.unwrap(), window, cx, |_, element_state, window, cx| { .relative()
let mut element = div() .child(
.relative() div()
.size_full() .id("scroll-area")
.overflow_hidden() .flex()
.child( .size_full()
div() .track_scroll(&scroll_handle)
.id(scroll_id) .map(|this| match self.axis {
.track_scroll(&element_state.handle) ScrollbarAxis::Vertical => this.flex_col().overflow_y_scroll(),
.overflow_scroll() ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
.relative() ScrollbarAxis::Both => this.overflow_scroll(),
.size_full() })
.child(div().children(content)), .child(
) self.element
.child( // Refine element size to `flex_1`.
div() .size_auto()
.absolute() .flex_1(),
.top_0() ),
.left_0() )
.right_0() .child(render_scrollbar(
.bottom_0() "scrollbar",
.child( &scroll_handle,
Scrollbar::both(&element_state.state, &element_state.handle).axis(axis), self.axis,
), window,
) cx,
.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<Pixels>,
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<Pixels>,
element: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
element.paint(window, cx)
} }
} }
impl ScrollableElement for Div {}
impl<E> ScrollableElement for Stateful<E>
where
E: ParentElement + Styled + Element,
Self: InteractiveElement,
{
}
#[derive(IntoElement)]
struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
id: ElementId,
axis: ScrollbarAxis,
scroll_handle: Rc<H>,
}
impl<H> RenderOnce for ScrollbarLayer<H>
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<H: ScrollbarHandle + Clone>(
id: impl Into<ElementId>,
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))
}

View File

@@ -1,43 +1,50 @@
use std::cell::Cell; use std::cell::Cell;
use std::ops::Deref; use std::ops::Deref;
use std::panic::Location;
use std::rc::Rc; use std::rc::Rc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use gpui::{ use gpui::{
fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner, fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner,
CursorStyle, Edges, Element, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, CursorStyle, Edges, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, InspectorElementId, IntoElement, IsZero, LayoutId, ListState, MouseDownEvent, MouseMoveEvent,
Position, ScrollHandle, ScrollWheelEvent, Size, UniformListScrollHandle, Window, MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style,
UniformListScrollHandle, Window,
}; };
use theme::ActiveTheme; use theme::{ActiveTheme, ScrollbarMode};
use crate::AxisExt; 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 MIN_THUMB_SIZE: f32 = 48.;
const THUMB_WIDTH: Pixels = px(6.); const THUMB_WIDTH: Pixels = px(6.);
const THUMB_RADIUS: Pixels = px(6. / 2.); 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_WIDTH: Pixels = px(8.);
const THUMB_ACTIVE_RADIUS: Pixels = px(8. / 2.); 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_DURATION: f32 = 3.0;
const FADE_OUT_DELAY: f32 = 2.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<Pixels>; fn offset(&self) -> Point<Pixels>;
/// Set the offset of the scroll handle.
fn set_offset(&self, offset: Point<Pixels>); fn set_offset(&self, offset: Point<Pixels>);
fn is_uniform_list(&self) -> bool {
false
}
/// The full size of the content, including padding. /// The full size of the content, including padding.
fn content_size(&self) -> Size<Pixels>; fn content_size(&self) -> Size<Pixels>;
/// 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<Pixels> { fn offset(&self) -> Point<Pixels> {
self.offset() self.offset()
} }
@@ -51,7 +58,7 @@ impl ScrollHandleOffsetable for ScrollHandle {
} }
} }
impl ScrollHandleOffsetable for UniformListScrollHandle { impl ScrollbarHandle for UniformListScrollHandle {
fn offset(&self) -> Point<Pixels> { fn offset(&self) -> Point<Pixels> {
self.0.borrow().base_handle.offset() self.0.borrow().base_handle.offset()
} }
@@ -60,21 +67,41 @@ impl ScrollHandleOffsetable for UniformListScrollHandle {
self.0.borrow_mut().base_handle.set_offset(offset) self.0.borrow_mut().base_handle.set_offset(offset)
} }
fn is_uniform_list(&self) -> bool {
true
}
fn content_size(&self) -> Size<Pixels> { fn content_size(&self) -> Size<Pixels> {
let base_handle = &self.0.borrow().base_handle; let base_handle = &self.0.borrow().base_handle;
base_handle.max_offset() + base_handle.bounds().size base_handle.max_offset() + base_handle.bounds().size
} }
} }
#[derive(Debug, Clone)] impl ScrollbarHandle for ListState {
pub struct ScrollbarState(Rc<Cell<ScrollbarStateInner>>); fn offset(&self) -> Point<Pixels> {
self.scroll_px_offset_for_scrollbar()
}
fn set_offset(&self, offset: Point<Pixels>) {
self.set_offset_from_scrollbar(offset);
}
fn content_size(&self) -> Size<Pixels> {
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<Cell<ScrollbarStateInner>>);
#[doc(hidden)]
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct ScrollbarStateInner { struct ScrollbarStateInner {
hovered_axis: Option<Axis>, hovered_axis: Option<Axis>,
hovered_on_thumb: Option<Axis>, hovered_on_thumb: Option<Axis>,
dragged_axis: Option<Axis>, dragged_axis: Option<Axis>,
@@ -83,6 +110,7 @@ pub struct ScrollbarStateInner {
last_scroll_time: Option<Instant>, last_scroll_time: Option<Instant>,
// Last update offset // Last update offset
last_update: Instant, last_update: Instant,
idle_timer_scheduled: bool,
} }
impl Default for ScrollbarState { impl Default for ScrollbarState {
@@ -95,6 +123,7 @@ impl Default for ScrollbarState {
last_scroll_offset: point(px(0.), px(0.)), last_scroll_offset: point(px(0.), px(0.)),
last_scroll_time: None, last_scroll_time: None,
last_update: Instant::now(), last_update: Instant::now(),
idle_timer_scheduled: false,
}))) })))
} }
} }
@@ -138,8 +167,10 @@ impl ScrollbarStateInner {
fn with_hovered_on_thumb(&self, axis: Option<Axis>) -> Self { fn with_hovered_on_thumb(&self, axis: Option<Axis>) -> Self {
let mut state = *self; let mut state = *self;
state.hovered_on_thumb = axis; state.hovered_on_thumb = axis;
if self.is_scrollbar_visible() && axis.is_some() { if self.is_scrollbar_visible() {
state.last_scroll_time = Some(std::time::Instant::now()); if axis.is_some() {
state.last_scroll_time = Some(std::time::Instant::now());
}
} }
state state
} }
@@ -167,6 +198,12 @@ impl ScrollbarStateInner {
state 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 { fn is_scrollbar_visible(&self) -> bool {
// On drag // On drag
if self.dragged_axis.is_some() { if self.dragged_axis.is_some() {
@@ -182,10 +219,14 @@ impl ScrollbarStateInner {
} }
} }
/// Scrollbar axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollbarAxis { pub enum ScrollbarAxis {
/// Vertical scrollbar.
Vertical, Vertical,
/// Horizontal scrollbar.
Horizontal, Horizontal,
/// Show both vertical and horizontal scrollbars.
Both, Both,
} }
@@ -200,25 +241,30 @@ impl From<Axis> for ScrollbarAxis {
impl ScrollbarAxis { impl ScrollbarAxis {
/// Return true if the scrollbar axis is vertical. /// Return true if the scrollbar axis is vertical.
#[inline]
pub fn is_vertical(&self) -> bool { pub fn is_vertical(&self) -> bool {
matches!(self, Self::Vertical) matches!(self, Self::Vertical)
} }
/// Return true if the scrollbar axis is horizontal. /// Return true if the scrollbar axis is horizontal.
#[inline]
pub fn is_horizontal(&self) -> bool { pub fn is_horizontal(&self) -> bool {
matches!(self, Self::Horizontal) matches!(self, Self::Horizontal)
} }
/// Return true if the scrollbar axis is both vertical and horizontal. /// Return true if the scrollbar axis is both vertical and horizontal.
#[inline]
pub fn is_both(&self) -> bool { pub fn is_both(&self) -> bool {
matches!(self, Self::Both) matches!(self, Self::Both)
} }
/// Return true if the scrollbar has vertical axis.
#[inline] #[inline]
pub fn has_vertical(&self) -> bool { pub fn has_vertical(&self) -> bool {
matches!(self, Self::Vertical | Self::Both) matches!(self, Self::Vertical | Self::Both)
} }
/// Return true if the scrollbar has horizontal axis.
#[inline] #[inline]
pub fn has_horizontal(&self) -> bool { pub fn has_horizontal(&self) -> bool {
matches!(self, Self::Horizontal | Self::Both) matches!(self, Self::Horizontal | Self::Both)
@@ -238,9 +284,10 @@ impl ScrollbarAxis {
/// Scrollbar control for scroll-area or a uniform-list. /// Scrollbar control for scroll-area or a uniform-list.
pub struct Scrollbar { pub struct Scrollbar {
pub(crate) id: ElementId,
axis: ScrollbarAxis, axis: ScrollbarAxis,
scroll_handle: Rc<Box<dyn ScrollHandleOffsetable>>, scrollbar_mode: Option<ScrollbarMode>,
state: ScrollbarState, scroll_handle: Rc<dyn ScrollbarHandle>,
scroll_size: Option<Size<Pixels>>, scroll_size: Option<Size<Pixels>>,
/// Maximum frames per second for scrolling by drag. Default is 120 FPS. /// Maximum frames per second for scrolling by drag. Default is 120 FPS.
/// ///
@@ -250,50 +297,46 @@ pub struct Scrollbar {
} }
impl Scrollbar { impl Scrollbar {
fn new( /// Create a new scrollbar.
axis: impl Into<ScrollbarAxis>, ///
state: &ScrollbarState, /// This will have both vertical and horizontal scrollbars.
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), #[track_caller]
) -> Self { pub fn new<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
let caller = Location::caller();
Self { Self {
state: state.clone(), id: ElementId::CodeLocation(*caller),
axis: axis.into(), axis: ScrollbarAxis::Both,
scroll_handle: Rc::new(Box::new(scroll_handle.clone())), scrollbar_mode: None,
scroll_handle: Rc::new(scroll_handle.clone()),
max_fps: 120, max_fps: 120,
scroll_size: None, 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. /// Create with horizontal scrollbar.
pub fn horizontal( #[track_caller]
state: &ScrollbarState, pub fn horizontal<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), Self::new(scroll_handle).axis(ScrollbarAxis::Horizontal)
) -> Self {
Self::new(ScrollbarAxis::Horizontal, state, scroll_handle)
} }
/// Create with vertical scrollbar. /// Create with vertical scrollbar.
pub fn vertical( #[track_caller]
state: &ScrollbarState, pub fn vertical<H: ScrollbarHandle + Clone>(scroll_handle: &H) -> Self {
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), Self::new(scroll_handle).axis(ScrollbarAxis::Vertical)
) -> Self {
Self::new(ScrollbarAxis::Vertical, state, scroll_handle)
} }
/// Create vertical scrollbar for uniform list. /// Set a specific element id, default is the [`Location::caller`].
pub fn uniform_scroll( ///
state: &ScrollbarState, /// NOTE: In most cases, you don't need to set a specific id for scrollbar.
scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), pub fn id(mut self, id: impl Into<ElementId>) -> Self {
) -> Self { self.id = id.into();
Self::new(ScrollbarAxis::Vertical, state, scroll_handle) 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. /// 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. /// If you have very high CPU usage, consider reducing this value to improve performance.
/// ///
/// Available values: 30..120 /// 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.max_fps = max_fps.clamp(30, 120);
self 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) { fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
( (
cx.theme().scrollbar_thumb_hover_background, cx.theme().scrollbar_thumb_hover_background,
@@ -353,11 +401,28 @@ impl Scrollbar {
) )
} }
fn style_for_idle(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { fn style_for_normal(&self, cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) {
let (width, inset, radius) = if cx.theme().scrollbar_mode.is_scrolling() { let scrollbar_mode = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
(THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS) let (width, inset, radius) = match scrollbar_mode {
} else { ScrollbarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS),
(THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_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 { pub struct PrepaintState {
hitbox: Hitbox, hitbox: Hitbox,
scrollbar_state: ScrollbarState,
states: Vec<AxisPrepaintState>, states: Vec<AxisPrepaintState>,
} }
#[doc(hidden)]
pub struct AxisPrepaintState { pub struct AxisPrepaintState {
axis: Axis, axis: Axis,
bar_hitbox: Hitbox, bar_hitbox: Hitbox,
@@ -406,7 +474,7 @@ impl Element for Scrollbar {
type RequestLayoutState = (); type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> { fn id(&self) -> Option<gpui::ElementId> {
None Some(self.id.clone())
} }
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
@@ -420,16 +488,12 @@ impl Element for Scrollbar {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) { ) -> (LayoutId, Self::RequestLayoutState) {
let style = gpui::Style { let mut style = Style::default();
position: Position::Absolute, style.position = Position::Absolute;
flex_grow: 1.0, style.flex_grow = 1.0;
flex_shrink: 1.0, style.flex_shrink = 1.0;
size: gpui::Size { style.size.width = relative(1.).into();
width: relative(1.).into(), style.size.height = relative(1.).into();
height: relative(1.).into(),
},
..Default::default()
};
(window.request_layout(style, None, cx), ()) (window.request_layout(style, None, cx), ())
} }
@@ -447,6 +511,11 @@ impl Element for Scrollbar {
window.insert_hitbox(bounds, HitboxBehavior::Normal) window.insert_hitbox(bounds, HitboxBehavior::Normal)
}); });
let state = window
.use_state(cx, |_, _| ScrollbarState::default())
.read(cx)
.clone();
let mut states = vec![]; let mut states = vec![];
let mut has_both = self.axis.is_both(); let mut has_both = self.axis.is_both();
let scroll_size = self 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. // 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 { let margin_end = if has_both && !is_vertical {
THUMB_ACTIVE_WIDTH WIDTH
} else { } else {
px(0.) px(0.)
}; };
@@ -512,11 +580,12 @@ impl Element for Scrollbar {
}, },
}; };
let state = self.state.clone(); let scrollbar_show = self.scrollbar_mode.unwrap_or(cx.theme().scrollbar_mode);
let is_always_to_show = cx.theme().scrollbar_mode.is_always(); let is_always_to_show = scrollbar_show.is_always();
let is_hover_to_show = cx.theme().scrollbar_mode.is_hover(); let is_hover_to_show = scrollbar_show.is_hover();
let is_hovered_on_bar = state.get().hovered_axis == Some(axis); 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_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) = let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) =
if state.get().dragged_axis == Some(axis) { if state.get().dragged_axis == Some(axis) {
@@ -527,38 +596,47 @@ impl Element for Scrollbar {
} else { } else {
Self::style_for_hovered_bar(cx) Self::style_for_hovered_bar(cx)
} }
} else if is_offset_changed {
self.style_for_normal(cx)
} else if is_always_to_show { } else if is_always_to_show {
#[allow(clippy::if_same_then_else)]
if is_hovered_on_thumb { if is_hovered_on_thumb {
Self::style_for_hovered_thumb(cx) Self::style_for_hovered_thumb(cx)
} else { } else {
Self::style_for_hovered_bar(cx) Self::style_for_hovered_bar(cx)
} }
} else { } 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) // Delay 2s to fade out the scrollbar thumb (in 1s)
if let Some(last_time) = state.get().last_scroll_time { if let Some(last_time) = state.get().last_scroll_time {
let elapsed = Instant::now().duration_since(last_time).as_secs_f32(); let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
if elapsed < FADE_OUT_DURATION { if is_hovered_on_bar {
if is_hovered_on_bar { state.set(state.get().with_last_scroll_time(Some(Instant::now())));
state.set(state.get().with_last_scroll_time(Some(Instant::now()))); idle_state = if is_hovered_on_thumb {
idle_state = if is_hovered_on_thumb { Self::style_for_hovered_thumb(cx)
Self::style_for_hovered_thumb(cx)
} else {
Self::style_for_hovered_bar(cx)
};
} else { } else {
if elapsed < FADE_OUT_DELAY { Self::style_for_hovered_bar(cx)
idle_state.0 = cx.theme().scrollbar_thumb_background; };
} else { } else if elapsed < FADE_OUT_DELAY {
// opacity = 1 - (x - 2)^10 idle_state.0 = cx.theme().scrollbar_thumb_background;
let opacity = 1.0 - (elapsed - FADE_OUT_DELAY).powi(10);
idle_state.0 =
cx.theme().scrollbar_thumb_background.opacity(opacity);
};
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( fn paint(
@@ -630,19 +712,21 @@ impl Element for Scrollbar {
window: &mut Window, window: &mut Window,
cx: &mut App, 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 view_id = window.current_view();
let hitbox_bounds = prepaint.hitbox.bounds; let hitbox_bounds = prepaint.hitbox.bounds;
let is_visible = let is_visible = scrollbar_state.get().is_scrollbar_visible() || scrollbar_show.is_always();
self.state.get().is_scrollbar_visible() || cx.theme().scrollbar_mode.is_always(); let is_hover_to_show = scrollbar_show.is_hover();
let is_hover_to_show = cx.theme().scrollbar_mode.is_hover();
// Update last_scroll_time when offset is changed. // Update last_scroll_time when offset is changed.
if self.scroll_handle.offset() != self.state.get().last_scroll_offset { if self.scroll_handle.offset() != scrollbar_state.get().last_scroll_offset {
self.state.set( scrollbar_state.set(
self.state scrollbar_state
.get() .get()
.with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())), .with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())),
); );
cx.notify(view_id);
} }
window.with_content_mask( window.with_content_mask(
@@ -652,7 +736,10 @@ impl Element for Scrollbar {
|window| { |window| {
for state in prepaint.states.iter() { for state in prepaint.states.iter() {
let axis = state.axis; 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 bounds = state.bounds;
let thumb_bounds = state.thumb_bounds; let thumb_bounds = state.thumb_bounds;
let scroll_area_size = state.scroll_size; let scroll_area_size = state.scroll_size;
@@ -670,11 +757,20 @@ impl Element for Scrollbar {
bounds, bounds,
corner_radii: (0.).into(), corner_radii: (0.).into(),
background: gpui::transparent_black().into(), background: gpui::transparent_black().into(),
border_widths: Edges { border_widths: if is_vertical {
top: px(0.), Edges {
right: px(0.), top: px(0.),
bottom: px(0.), right: px(0.),
left: 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_color: state.border,
border_style: BorderStyle::default(), border_style: BorderStyle::default(),
@@ -686,19 +782,18 @@ impl Element for Scrollbar {
}); });
window.on_mouse_event({ window.on_mouse_event({
let state = self.state.clone(); let state = scrollbar_state.clone();
let scroll_handle = self.scroll_handle.clone(); let scroll_handle = self.scroll_handle.clone();
move |event: &ScrollWheelEvent, phase, _, cx| { move |event: &ScrollWheelEvent, phase, _, cx| {
if phase.bubble() if phase.bubble() && hitbox_bounds.contains(&event.position) {
&& hitbox_bounds.contains(&event.position) if scroll_handle.offset() != state.get().last_scroll_offset {
&& scroll_handle.offset() != state.get().last_scroll_offset state.set(state.get().with_last_scroll(
{ scroll_handle.offset(),
state.set(state.get().with_last_scroll( Some(Instant::now()),
scroll_handle.offset(), ));
Some(Instant::now()), cx.notify(view_id);
)); }
cx.notify(view_id);
} }
} }
}); });
@@ -707,7 +802,7 @@ impl Element for Scrollbar {
if is_hover_to_show || is_visible { if is_hover_to_show || is_visible {
window.on_mouse_event({ window.on_mouse_event({
let state = self.state.clone(); let state = scrollbar_state.clone();
let scroll_handle = self.scroll_handle.clone(); let scroll_handle = self.scroll_handle.clone();
move |event: &MouseDownEvent, phase, _, cx| { move |event: &MouseDownEvent, phase, _, cx| {
@@ -718,6 +813,7 @@ impl Element for Scrollbar {
// click on the thumb bar, set the drag position // click on the thumb bar, set the drag position
let pos = event.position - thumb_bounds.origin; let pos = event.position - thumb_bounds.origin;
scroll_handle.start_drag();
state.set(state.get().with_drag_pos(axis, pos)); state.set(state.get().with_drag_pos(axis, pos));
cx.notify(view_id); cx.notify(view_id);
@@ -755,7 +851,7 @@ impl Element for Scrollbar {
window.on_mouse_event({ window.on_mouse_event({
let scroll_handle = self.scroll_handle.clone(); 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); let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64);
move |event: &MouseMoveEvent, _, _, cx| { move |event: &MouseMoveEvent, _, _, cx| {
@@ -770,11 +866,13 @@ impl Element for Scrollbar {
if state.get().hovered_axis != Some(axis) { if state.get().hovered_axis != Some(axis) {
notify = true; notify = true;
} }
} else if state.get().hovered_axis == Some(axis) } else {
&& state.get().hovered_axis.is_some() if state.get().hovered_axis == Some(axis) {
{ if state.get().hovered_axis.is_some() {
state.set(state.get().with_hovered(None)); state.set(state.get().with_hovered(None));
notify = true; notify = true;
}
}
} }
// Update hovered state for scrollbar thumb // Update hovered state for scrollbar thumb
@@ -783,13 +881,18 @@ impl Element for Scrollbar {
state.set(state.get().with_hovered_on_thumb(Some(axis))); state.set(state.get().with_hovered_on_thumb(Some(axis)));
notify = true; notify = true;
} }
} else if state.get().hovered_on_thumb == Some(axis) { } else {
state.set(state.get().with_hovered_on_thumb(None)); if state.get().hovered_on_thumb == Some(axis) {
notify = true; state.set(state.get().with_hovered_on_thumb(None));
notify = true;
}
} }
// Move thumb position on dragging // Move thumb position on dragging
if state.get().dragged_axis == Some(axis) && event.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 // drag_pos is the position of the mouse down event
// We need to keep the thumb bar still at the origin down position // We need to keep the thumb bar still at the origin down position
let drag_pos = state.get().drag_pos; let drag_pos = state.get().drag_pos;
@@ -836,10 +939,12 @@ impl Element for Scrollbar {
}); });
window.on_mouse_event({ 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| { move |_event: &MouseUpEvent, phase, _, cx| {
if phase.bubble() { if phase.bubble() {
scroll_handle.end_drag();
state.set(state.get().with_unset_drag_pos()); state.set(state.get().with_unset_drag_pos());
cx.notify(view_id); cx.notify(view_id);
} }

View File

@@ -22,8 +22,8 @@ impl Skeleton {
} }
} }
pub fn secondary(mut self, secondary: bool) -> Self { pub fn secondary(mut self) -> Self {
self.secondary = secondary; self.secondary = true;
self self
} }
} }

View File

@@ -1,11 +1,7 @@
use std::fmt::{self, Display, Formatter}; use gpui::{div, px, App, Div, Pixels, Refineable, StyleRefinement, Styled};
use gpui::{div, px, App, Axis, Div, Element, Pixels, Refineable, StyleRefinement, Styled};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::scroll::{Scrollable, ScrollbarAxis};
/// Returns a `Div` as horizontal flex layout. /// Returns a `Div` as horizontal flex layout.
pub fn h_flex() -> Div { pub fn h_flex() -> Div {
div().h_flex() div().h_flex()
@@ -50,17 +46,6 @@ pub trait StyledExt: Styled + Sized {
self.flex().flex_col() 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<ScrollbarAxis>) -> Scrollable<Self>
where
Self: Element,
{
Scrollable::new(axis, self)
}
font_weight!(font_thin, THIN); font_weight!(font_thin, THIN);
font_weight!(font_extralight, EXTRA_LIGHT); font_weight!(font_extralight, EXTRA_LIGHT);
font_weight!(font_light, LIGHT); font_weight!(font_light, LIGHT);
@@ -259,74 +244,6 @@ impl<T: Styled> StyleSized<T> 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. /// A trait for defining element that can be collapsed.
pub trait Collapsible { pub trait Collapsible {
fn collapsed(self, collapsed: bool) -> Self; fn collapsed(self, collapsed: bool) -> Self;