Skip to main content

gpui_kit/controls/
split_button.rs

1//! A default action with a menu of alternatives beside it.
2
3use std::rc::Rc;
4
5use gpui::{
6    App, AppContext as _, Context, Entity, FocusHandle, Focusable, InteractiveElement, IntoElement,
7    ParentElement, Render, SharedString, Styled, Window, div, prelude::FluentBuilder,
8};
9use gpui_kit_assets::Icon;
10use gpui_kit_semantics::{NodeSpec, Role, Semantic};
11use gpui_kit_theme::ControlSize;
12
13use crate::controls::button::{Button, ButtonJoin, ButtonVariant, IconButton};
14use crate::foundation::{Disableable, Ident, Sizable, StyledExt};
15use crate::overlay::{Menu, MenuItem};
16use crate::strings::{ActiveStrings, StringKey};
17
18type ClickHandler = Rc<dyn Fn(&mut Window, &mut App)>;
19
20/// One action, plus the ones it stands in for.
21///
22/// The default action and the arrow are separate targets with separate ids,
23/// because they do separate things: clicking the button acts, clicking the
24/// arrow only offers. Refusing the whole control refuses both. Refusing only
25/// the default action — [`SplitButton::default_disabled`] — leaves the arrow
26/// working, because the alternatives may well be exactly what a typist needs
27/// when the usual thing cannot be done.
28pub struct SplitButton {
29    ident: Ident,
30    label: SharedString,
31    icon: Option<Icon>,
32    variant: ButtonVariant,
33    size: ControlSize,
34    disabled: bool,
35    default_disabled: bool,
36    focus_handle: FocusHandle,
37    menu: Entity<Menu>,
38    /// The paint already pushed into the menu trigger, so a frame that
39    /// changes nothing does not ask for another frame.
40    applied_trigger: Option<(ButtonVariant, ControlSize)>,
41    on_click: Option<ClickHandler>,
42}
43
44impl std::fmt::Debug for SplitButton {
45    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        formatter
47            .debug_struct("SplitButton")
48            .field("ident", &self.ident)
49            .field("label", &self.label)
50            .field("disabled", &self.disabled)
51            .field("default_disabled", &self.default_disabled)
52            .field("has_handler", &self.on_click.is_some())
53            .finish()
54    }
55}
56
57impl SplitButton {
58    pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
59        let ident = ident.into();
60        let menu = cx.new(|cx| {
61            Menu::new(ident.child("menu"), window, cx)
62                .trigger_icon(Icon::AltArrowDown)
63                .trigger_name(cx.strings().text(StringKey::MoreActions))
64                .trigger_join(ButtonJoin::Trailing)
65        });
66        Self {
67            ident,
68            label: SharedString::default(),
69            icon: None,
70            variant: ButtonVariant::Secondary,
71            size: ControlSize::Md,
72            disabled: false,
73            default_disabled: false,
74            focus_handle: cx.focus_handle(),
75            menu,
76            applied_trigger: None,
77            on_click: None,
78        }
79    }
80
81    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
82        self.label = label.into();
83        self
84    }
85
86    pub fn icon(mut self, glyph: Icon) -> Self {
87        self.icon = Some(glyph);
88        self
89    }
90
91    pub fn variant(mut self, variant: ButtonVariant) -> Self {
92        self.variant = variant;
93        self
94    }
95
96    pub fn primary(self) -> Self {
97        self.variant(ButtonVariant::Primary)
98    }
99
100    pub fn secondary(self) -> Self {
101        self.variant(ButtonVariant::Secondary)
102    }
103
104    /// The alternatives. They are reported by the menu, as
105    /// [`crate::overlay::MenuEvent::Invoked`].
106    pub fn items(self, items: impl IntoIterator<Item = MenuItem>, cx: &mut Context<Self>) -> Self {
107        let items = items.into_iter().collect();
108        self.menu.update(cx, |menu, cx| menu.set_items(items, cx));
109        self
110    }
111
112    /// What the arrow is named for a reader that cannot see it.
113    pub fn menu_name(self, name: impl Into<SharedString>, cx: &mut Context<Self>) -> Self {
114        self.menu
115            .update(cx, |menu, cx| menu.set_trigger_name(name, cx));
116        self
117    }
118
119    /// Refuses the default action while leaving the alternatives reachable.
120    pub fn default_disabled(mut self, disabled: bool) -> Self {
121        self.default_disabled = disabled;
122        self
123    }
124
125    pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
126        self.on_click = Some(Rc::new(handler));
127        self
128    }
129
130    pub fn menu(&self) -> &Entity<Menu> {
131        &self.menu
132    }
133
134    pub fn is_open(&self, cx: &App) -> bool {
135        self.menu.read(cx).is_open()
136    }
137
138    pub fn open_menu(&self, window: &mut Window, cx: &mut Context<Self>) {
139        if self.disabled {
140            return;
141        }
142        self.menu.update(cx, |menu, cx| menu.open(window, cx));
143    }
144
145    pub fn set_disabled(&mut self, disabled: bool, window: &mut Window, cx: &mut Context<Self>) {
146        self.disabled = disabled;
147        if disabled {
148            self.menu.update(cx, |menu, cx| menu.close(window, cx));
149        }
150        cx.notify();
151    }
152}
153
154impl Disableable for SplitButton {
155    fn disabled(mut self, disabled: bool) -> Self {
156        self.disabled = disabled;
157        self
158    }
159}
160
161impl Sizable for SplitButton {
162    fn control_size(mut self, size: ControlSize) -> Self {
163        self.size = size;
164        self
165    }
166}
167
168impl Focusable for SplitButton {
169    fn focus_handle(&self, _cx: &App) -> FocusHandle {
170        self.focus_handle.clone()
171    }
172}
173
174impl Render for SplitButton {
175    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
176        let paint = (self.variant, self.size);
177        if self.applied_trigger != Some(paint) {
178            self.applied_trigger = Some(paint);
179            self.menu
180                .update(cx, |menu, cx| menu.set_trigger_style(paint.0, paint.1, cx));
181        }
182        let refused = self.disabled || self.default_disabled;
183        let action = Button::new(self.ident.child("action"))
184            .label(self.label.clone())
185            .variant(self.variant)
186            .control_size(self.size)
187            .join(ButtonJoin::Leading)
188            .semantic_parent(self.ident.semantic_id())
189            .disabled(refused)
190            .track_focus(&self.focus_handle)
191            .when_some(self.icon, |button, glyph| button.icon(glyph))
192            .when_some(self.on_click.clone(), |button, handler| {
193                button.on_click(move |window, cx| handler(window, cx))
194            });
195
196        let arrow = if self.disabled {
197            // A refused control still shows both of its targets, so what is
198            // unavailable is visible rather than missing.
199            IconButton::new(
200                self.ident.child("menu").child("trigger"),
201                Icon::AltArrowDown,
202                cx.strings().text(StringKey::MoreActions),
203            )
204            .variant(self.variant)
205            .control_size(self.size)
206            .join(ButtonJoin::Trailing)
207            .semantic_parent(self.ident.semantic_id())
208            .disabled(true)
209            .into_any_element()
210        } else {
211            self.menu.clone().into_any_element()
212        };
213
214        div()
215            .id(self.ident.element_id())
216            .row()
217            .flex_none()
218            .items_start()
219            .child(action)
220            .child(arrow)
221            .semantic_in(
222                cx,
223                NodeSpec::new(self.ident.semantic_id(), Role::Group)
224                    .disabled(self.disabled)
225                    .text(self.label.clone()),
226            )
227    }
228}