1use std::marker::PhantomData;
2use std::sync::Arc;
3
4use gpui3::{DefiniteLength, Hsla, Interactive, MouseButton, WindowContext};
5
6use crate::prelude::*;
7use crate::{h_stack, theme, Icon, IconColor, IconElement, Label, LabelColor, LabelSize};
8
9#[derive(Default, PartialEq, Clone, Copy)]
10pub enum IconPosition {
11 #[default]
12 Left,
13 Right,
14}
15
16#[derive(Default, Copy, Clone, PartialEq)]
17pub enum ButtonVariant {
18 #[default]
19 Ghost,
20 Filled,
21}
22
23struct ButtonHandlers<S: 'static + Send + Sync> {
24 click: Option<Arc<dyn Fn(&mut S, &mut ViewContext<S>) + 'static + Send + Sync>>,
25}
26
27impl<S: 'static + Send + Sync> Default for ButtonHandlers<S> {
28 fn default() -> Self {
29 Self { click: None }
30 }
31}
32
33#[derive(Element)]
34pub struct Button<S: 'static + Send + Sync + Clone> {
35 state_type: PhantomData<S>,
36 label: String,
37 variant: ButtonVariant,
38 state: InteractionState,
39 icon: Option<Icon>,
40 icon_position: Option<IconPosition>,
41 width: Option<DefiniteLength>,
42 handlers: ButtonHandlers<S>,
43}
44
45impl<S: 'static + Send + Sync + Clone> Button<S> {
46 pub fn new<L>(label: L) -> Self
47 where
48 L: Into<String>,
49 {
50 Self {
51 state_type: PhantomData,
52 label: label.into(),
53 variant: Default::default(),
54 state: Default::default(),
55 icon: None,
56 icon_position: None,
57 width: Default::default(),
58 handlers: ButtonHandlers::default(),
59 }
60 }
61
62 pub fn ghost<L>(label: L) -> Self
63 where
64 L: Into<String>,
65 {
66 Self::new(label).variant(ButtonVariant::Ghost)
67 }
68
69 pub fn variant(mut self, variant: ButtonVariant) -> Self {
70 self.variant = variant;
71 self
72 }
73
74 pub fn state(mut self, state: InteractionState) -> Self {
75 self.state = state;
76 self
77 }
78
79 pub fn icon(mut self, icon: Icon) -> Self {
80 self.icon = Some(icon);
81 self
82 }
83
84 pub fn icon_position(mut self, icon_position: IconPosition) -> Self {
85 if self.icon.is_none() {
86 panic!("An icon must be present if an icon_position is provided.");
87 }
88 self.icon_position = Some(icon_position);
89 self
90 }
91
92 pub fn width(mut self, width: Option<DefiniteLength>) -> Self {
93 self.width = width;
94 self
95 }
96
97 pub fn on_click(
98 mut self,
99 handler: impl Fn(&mut S, &mut ViewContext<S>) + 'static + Send + Sync,
100 ) -> Self {
101 self.handlers.click = Some(Arc::new(handler));
102 self
103 }
104
105 fn background_color(&self, cx: &mut ViewContext<S>) -> Hsla {
106 let theme = theme(cx);
107 let system_color = SystemColor::new();
108
109 match (self.variant, self.state) {
110 (ButtonVariant::Ghost, InteractionState::Hovered) => {
111 theme.lowest.base.hovered.background
112 }
113 (ButtonVariant::Ghost, InteractionState::Active) => {
114 theme.lowest.base.pressed.background
115 }
116 (ButtonVariant::Filled, InteractionState::Enabled) => {
117 theme.lowest.on.default.background
118 }
119 (ButtonVariant::Filled, InteractionState::Hovered) => {
120 theme.lowest.on.hovered.background
121 }
122 (ButtonVariant::Filled, InteractionState::Active) => theme.lowest.on.pressed.background,
123 (ButtonVariant::Filled, InteractionState::Disabled) => {
124 theme.lowest.on.disabled.background
125 }
126 _ => system_color.transparent,
127 }
128 }
129
130 fn label_color(&self) -> LabelColor {
131 match self.state {
132 InteractionState::Disabled => LabelColor::Disabled,
133 _ => Default::default(),
134 }
135 }
136
137 fn icon_color(&self) -> IconColor {
138 match self.state {
139 InteractionState::Disabled => IconColor::Disabled,
140 _ => Default::default(),
141 }
142 }
143
144 fn border_color(&self, cx: &WindowContext) -> Hsla {
145 let theme = theme(cx);
146 let system_color = SystemColor::new();
147
148 match self.state {
149 InteractionState::Focused => theme.lowest.accent.default.border,
150 _ => system_color.transparent,
151 }
152 }
153
154 fn render_label(&self) -> Label<S> {
155 Label::new(self.label.clone())
156 .size(LabelSize::Small)
157 .color(self.label_color())
158 }
159
160 fn render_icon(&self, icon_color: IconColor) -> Option<IconElement<S>> {
161 self.icon.map(|i| IconElement::new(i).color(icon_color))
162 }
163
164 fn render(&mut self, cx: &mut ViewContext<S>) -> impl Element<State = S> {
165 let theme = theme(cx);
166 let icon_color = self.icon_color();
167 let system_color = SystemColor::new();
168 let border_color = self.border_color(cx);
169
170 let mut el = h_stack()
171 .h_6()
172 .px_1()
173 .items_center()
174 .rounded_md()
175 .border()
176 .border_color(border_color)
177 .fill(self.background_color(cx));
178
179 match (self.icon, self.icon_position) {
180 (Some(_), Some(IconPosition::Left)) => {
181 el = el
182 .gap_1()
183 .child(self.render_label())
184 .children(self.render_icon(icon_color))
185 }
186 (Some(_), Some(IconPosition::Right)) => {
187 el = el
188 .gap_1()
189 .children(self.render_icon(icon_color))
190 .child(self.render_label())
191 }
192 (_, _) => el = el.child(self.render_label()),
193 }
194
195 if let Some(width) = self.width {
196 el = el.w(width).justify_center();
197 }
198
199 if let Some(click_handler) = self.handlers.click.clone() {
200 el = el.on_click(MouseButton::Left, move |state, event, cx| {
201 click_handler(state, cx);
202 });
203 }
204
205 el
206 }
207}
208
209#[cfg(feature = "stories")]
210pub use stories::*;
211
212#[cfg(feature = "stories")]
213mod stories {
214 use gpui3::rems;
215 use strum::IntoEnumIterator;
216
217 use crate::{h_stack, v_stack, LabelColor, LabelSize, Story};
218
219 use super::*;
220
221 #[derive(Element)]
222 pub struct ButtonStory<S: 'static + Send + Sync + Clone> {
223 state_type: PhantomData<S>,
224 }
225
226 impl<S: 'static + Send + Sync + Clone> ButtonStory<S> {
227 pub fn new() -> Self {
228 Self {
229 state_type: PhantomData,
230 }
231 }
232
233 fn render(&mut self, cx: &mut ViewContext<S>) -> impl Element<State = S> {
234 let states = InteractionState::iter();
235
236 Story::container(cx)
237 .child(Story::title_for::<_, Button<S>>(cx))
238 .child(
239 div()
240 .flex()
241 .gap_8()
242 .child(
243 div()
244 .child(Story::label(cx, "Ghost (Default)"))
245 .child(h_stack().gap_2().children(states.clone().map(|state| {
246 v_stack()
247 .gap_1()
248 .child(
249 Label::new(state.to_string())
250 .color(LabelColor::Muted)
251 .size(LabelSize::Small),
252 )
253 .child(
254 Button::new("Label")
255 .variant(ButtonVariant::Ghost)
256 .state(state),
257 )
258 })))
259 .child(Story::label(cx, "Ghost – Left Icon"))
260 .child(h_stack().gap_2().children(states.clone().map(|state| {
261 v_stack()
262 .gap_1()
263 .child(
264 Label::new(state.to_string())
265 .color(LabelColor::Muted)
266 .size(LabelSize::Small),
267 )
268 .child(
269 Button::new("Label")
270 .variant(ButtonVariant::Ghost)
271 .icon(Icon::Plus)
272 .icon_position(IconPosition::Left)
273 .state(state),
274 )
275 })))
276 .child(Story::label(cx, "Ghost – Right Icon"))
277 .child(h_stack().gap_2().children(states.clone().map(|state| {
278 v_stack()
279 .gap_1()
280 .child(
281 Label::new(state.to_string())
282 .color(LabelColor::Muted)
283 .size(LabelSize::Small),
284 )
285 .child(
286 Button::new("Label")
287 .variant(ButtonVariant::Ghost)
288 .icon(Icon::Plus)
289 .icon_position(IconPosition::Right)
290 .state(state),
291 )
292 }))),
293 )
294 .child(
295 div()
296 .child(Story::label(cx, "Filled"))
297 .child(h_stack().gap_2().children(states.clone().map(|state| {
298 v_stack()
299 .gap_1()
300 .child(
301 Label::new(state.to_string())
302 .color(LabelColor::Muted)
303 .size(LabelSize::Small),
304 )
305 .child(
306 Button::new("Label")
307 .variant(ButtonVariant::Filled)
308 .state(state),
309 )
310 })))
311 .child(Story::label(cx, "Filled – Left Button"))
312 .child(h_stack().gap_2().children(states.clone().map(|state| {
313 v_stack()
314 .gap_1()
315 .child(
316 Label::new(state.to_string())
317 .color(LabelColor::Muted)
318 .size(LabelSize::Small),
319 )
320 .child(
321 Button::new("Label")
322 .variant(ButtonVariant::Filled)
323 .icon(Icon::Plus)
324 .icon_position(IconPosition::Left)
325 .state(state),
326 )
327 })))
328 .child(Story::label(cx, "Filled – Right Button"))
329 .child(h_stack().gap_2().children(states.clone().map(|state| {
330 v_stack()
331 .gap_1()
332 .child(
333 Label::new(state.to_string())
334 .color(LabelColor::Muted)
335 .size(LabelSize::Small),
336 )
337 .child(
338 Button::new("Label")
339 .variant(ButtonVariant::Filled)
340 .icon(Icon::Plus)
341 .icon_position(IconPosition::Right)
342 .state(state),
343 )
344 }))),
345 )
346 .child(
347 div()
348 .child(Story::label(cx, "Fixed With"))
349 .child(h_stack().gap_2().children(states.clone().map(|state| {
350 v_stack()
351 .gap_1()
352 .child(
353 Label::new(state.to_string())
354 .color(LabelColor::Muted)
355 .size(LabelSize::Small),
356 )
357 .child(
358 Button::new("Label")
359 .variant(ButtonVariant::Filled)
360 .state(state)
361 .width(Some(rems(6.).into())),
362 )
363 })))
364 .child(Story::label(cx, "Fixed With – Left Icon"))
365 .child(h_stack().gap_2().children(states.clone().map(|state| {
366 v_stack()
367 .gap_1()
368 .child(
369 Label::new(state.to_string())
370 .color(LabelColor::Muted)
371 .size(LabelSize::Small),
372 )
373 .child(
374 Button::new("Label")
375 .variant(ButtonVariant::Filled)
376 .state(state)
377 .icon(Icon::Plus)
378 .icon_position(IconPosition::Left)
379 .width(Some(rems(6.).into())),
380 )
381 })))
382 .child(Story::label(cx, "Fixed With – Right Icon"))
383 .child(h_stack().gap_2().children(states.clone().map(|state| {
384 v_stack()
385 .gap_1()
386 .child(
387 Label::new(state.to_string())
388 .color(LabelColor::Muted)
389 .size(LabelSize::Small),
390 )
391 .child(
392 Button::new("Label")
393 .variant(ButtonVariant::Filled)
394 .state(state)
395 .icon(Icon::Plus)
396 .icon_position(IconPosition::Right)
397 .width(Some(rems(6.).into())),
398 )
399 }))),
400 ),
401 )
402 .child(Story::label(cx, "Button with `on_click`"))
403 .child(
404 Button::new("Label")
405 .variant(ButtonVariant::Ghost)
406 .on_click(|_view, _cx| println!("Button clicked.")),
407 )
408 }
409 }
410}