1use std::rc::Rc;
35
36use gpui::{
37 App, FocusHandle, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
38 prelude::FluentBuilder,
39};
40use gpui_kit_assets::Icon;
41use gpui_kit_semantics::{NodeSpec, Role, Semantic};
42use gpui_kit_theme::{ActiveTheme, ControlSize, Space};
43
44use crate::controls::button::{Button, ButtonJoin, ButtonVariant};
45use crate::foundation::direction::{ActiveDirection, DirectionalExt};
46use crate::foundation::{Disableable, Ident, Selectable, Sizable, StyledExt};
47
48type PressHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
50
51#[derive(IntoElement)]
57pub struct Toggle {
58 ident: Ident,
59 label: Option<SharedString>,
60 name: Option<SharedString>,
61 glyph: Option<Icon>,
62 icon_only: bool,
63 pressed: bool,
64 disabled: bool,
65 size: ControlSize,
66 variant: ButtonVariant,
67 join: ButtonJoin,
68 semantic_parent: Option<SharedString>,
69 focus_handle: Option<FocusHandle>,
70 on_press: Option<PressHandler>,
71}
72
73impl std::fmt::Debug for Toggle {
74 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 formatter
76 .debug_struct("Toggle")
77 .field("ident", &self.ident)
78 .field("label", &self.label)
79 .field("pressed", &self.pressed)
80 .field("disabled", &self.disabled)
81 .field("has_handler", &self.on_press.is_some())
82 .finish()
83 }
84}
85
86impl Toggle {
87 pub fn new(ident: impl Into<Ident>) -> Self {
88 Self {
89 ident: ident.into(),
90 label: None,
91 name: None,
92 glyph: None,
93 icon_only: false,
94 pressed: false,
95 disabled: false,
96 size: ControlSize::Md,
97 variant: ButtonVariant::Ghost,
100 join: ButtonJoin::Alone,
101 semantic_parent: None,
102 focus_handle: None,
103 on_press: None,
104 }
105 }
106
107 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
108 self.label = Some(label.into());
109 self
110 }
111
112 pub fn accessible_name(mut self, name: impl Into<SharedString>) -> Self {
115 self.name = Some(name.into());
116 self
117 }
118
119 pub fn icon(mut self, glyph: Icon) -> Self {
120 self.glyph = Some(glyph);
121 self
122 }
123
124 pub fn icon_only(mut self, glyph: Icon, name: impl Into<SharedString>) -> Self {
130 self.glyph = Some(glyph);
131 self.label = None;
132 self.icon_only = true;
133 self.name = Some(name.into());
134 self
135 }
136
137 pub fn pressed(mut self, pressed: bool) -> Self {
140 self.pressed = pressed;
141 self
142 }
143
144 pub fn variant(mut self, variant: ButtonVariant) -> Self {
145 self.variant = variant;
146 self
147 }
148
149 pub fn secondary(self) -> Self {
150 self.variant(ButtonVariant::Secondary)
151 }
152
153 pub fn ghost(self) -> Self {
154 self.variant(ButtonVariant::Ghost)
155 }
156
157 pub fn join(mut self, join: ButtonJoin) -> Self {
159 self.join = join;
160 self
161 }
162
163 pub fn semantic_parent(mut self, parent: impl Into<SharedString>) -> Self {
164 self.semantic_parent = Some(parent.into());
165 self
166 }
167
168 pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
169 self.focus_handle = Some(handle.clone());
170 self
171 }
172
173 pub fn on_press(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
176 self.on_press = Some(Rc::new(handler));
177 self
178 }
179
180 fn actionable(&self) -> bool {
181 !self.disabled && self.on_press.is_some()
182 }
183}
184
185impl Disableable for Toggle {
186 fn disabled(mut self, disabled: bool) -> Self {
187 self.disabled = disabled;
188 self
189 }
190}
191
192impl Sizable for Toggle {
193 fn control_size(mut self, size: ControlSize) -> Self {
194 self.size = size;
195 self
196 }
197}
198
199impl RenderOnce for Toggle {
200 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
201 let next = !self.pressed;
202 let actionable = self.actionable();
203
204 Button::new(self.ident.clone())
205 .variant(self.variant)
206 .control_size(self.size)
207 .join(self.join)
208 .disabled(self.disabled)
209 .selected(self.pressed)
212 .checked_state(self.pressed)
213 .when_some(self.label.clone(), |button, label| button.label(label))
214 .when_some(self.name.clone(), |button, name| {
215 button.accessible_name(name)
216 })
217 .when_some(self.glyph, |button, glyph| {
218 match (self.icon_only, self.name.clone()) {
219 (true, Some(name)) => button.icon_only(glyph, name),
220 _ => button.icon(glyph),
221 }
222 })
223 .when_some(self.semantic_parent.clone(), |button, parent| {
224 button.semantic_parent(parent)
225 })
226 .when_some(self.focus_handle.as_ref(), |button, handle| {
227 button.track_focus(handle)
228 })
229 .when_some(
230 actionable.then(|| self.on_press.clone()).flatten(),
231 |button, handler| button.on_click(move |window, cx| handler(next, window, cx)),
232 )
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
238pub enum ToggleSelection {
239 AtMostOne,
243 #[default]
245 Any,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct ToggleItem {
251 id: SharedString,
252 label: SharedString,
253 name: Option<SharedString>,
254 icon: Option<Icon>,
255 icon_only: bool,
256 disabled: bool,
257}
258
259impl ToggleItem {
260 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
261 Self {
262 id: id.into(),
263 label: label.into(),
264 name: None,
265 icon: None,
266 icon_only: false,
267 disabled: false,
268 }
269 }
270
271 pub fn glyph(id: impl Into<SharedString>, glyph: Icon, name: impl Into<SharedString>) -> Self {
273 let name = name.into();
274 Self {
275 icon: Some(glyph),
276 icon_only: true,
277 name: Some(name.clone()),
278 ..Self::new(id, name)
279 }
280 }
281
282 pub fn icon(mut self, glyph: Icon) -> Self {
283 self.icon = Some(glyph);
284 self
285 }
286
287 pub fn disabled(mut self, disabled: bool) -> Self {
289 self.disabled = disabled;
290 self
291 }
292
293 pub fn id(&self) -> &SharedString {
294 &self.id
295 }
296
297 pub fn label(&self) -> &SharedString {
298 &self.label
299 }
300
301 pub fn is_disabled(&self) -> bool {
302 self.disabled
303 }
304}
305
306type ChangeHandler = Rc<dyn Fn(Vec<SharedString>, SharedString, &mut Window, &mut App)>;
309
310#[derive(IntoElement)]
320pub struct ToggleGroup {
321 ident: Ident,
322 label: Option<SharedString>,
323 items: Vec<ToggleItem>,
324 pressed: Vec<SharedString>,
325 selection: ToggleSelection,
326 size: ControlSize,
327 variant: ButtonVariant,
328 disabled: bool,
329 on_change: Option<ChangeHandler>,
330}
331
332impl std::fmt::Debug for ToggleGroup {
333 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334 formatter
335 .debug_struct("ToggleGroup")
336 .field("ident", &self.ident)
337 .field("items", &self.items.len())
338 .field("pressed", &self.pressed)
339 .field("selection", &self.selection)
340 .field("disabled", &self.disabled)
341 .field("has_handler", &self.on_change.is_some())
342 .finish()
343 }
344}
345
346impl ToggleGroup {
347 pub fn new(ident: impl Into<Ident>) -> Self {
348 Self {
349 ident: ident.into(),
350 label: None,
351 items: Vec::new(),
352 pressed: Vec::new(),
353 selection: ToggleSelection::default(),
354 size: ControlSize::Md,
355 variant: ButtonVariant::Secondary,
356 disabled: false,
357 on_change: None,
358 }
359 }
360
361 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
363 self.label = Some(label.into());
364 self
365 }
366
367 pub fn items(mut self, items: impl IntoIterator<Item = ToggleItem>) -> Self {
368 self.items = items.into_iter().collect();
369 self
370 }
371
372 pub fn selection(mut self, selection: ToggleSelection) -> Self {
373 self.selection = selection;
374 self
375 }
376
377 pub fn pressed(mut self, ids: impl IntoIterator<Item = SharedString>) -> Self {
380 self.pressed = ids.into_iter().collect();
381 self
382 }
383
384 pub fn pressed_ids<S: AsRef<str>>(mut self, ids: &[S]) -> Self {
385 self.pressed = ids
386 .iter()
387 .map(|id| SharedString::from(id.as_ref().to_string()))
388 .collect();
389 self
390 }
391
392 pub fn variant(mut self, variant: ButtonVariant) -> Self {
393 self.variant = variant;
394 self
395 }
396
397 pub fn on_change(
398 mut self,
399 handler: impl Fn(Vec<SharedString>, SharedString, &mut Window, &mut App) + 'static,
400 ) -> Self {
401 self.on_change = Some(Rc::new(handler));
402 self
403 }
404}
405
406impl Disableable for ToggleGroup {
407 fn disabled(mut self, disabled: bool) -> Self {
408 self.disabled = disabled;
409 self
410 }
411}
412
413impl Sizable for ToggleGroup {
414 fn control_size(mut self, size: ControlSize) -> Self {
415 self.size = size;
416 self
417 }
418}
419
420fn next_set(
425 items: &[ToggleItem],
426 pressed: &[SharedString],
427 id: &SharedString,
428 selection: ToggleSelection,
429) -> Vec<SharedString> {
430 let is_in = pressed.contains(id);
431 match (selection, is_in) {
432 (ToggleSelection::AtMostOne, true) => Vec::new(),
433 (ToggleSelection::AtMostOne, false) => vec![id.clone()],
434 (ToggleSelection::Any, _) => items
435 .iter()
436 .map(|item| &item.id)
437 .filter(|other| {
438 if *other == id {
439 !is_in
440 } else {
441 pressed.contains(other)
442 }
443 })
444 .cloned()
445 .collect(),
446 }
447}
448
449impl RenderOnce for ToggleGroup {
450 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
451 let theme = cx.theme().clone();
452 let parent = self.ident.semantic_id();
453 let last = self.items.len().saturating_sub(1);
454 let actionable = !self.disabled && self.on_change.is_some();
455
456 let toggles = self
457 .items
458 .iter()
459 .enumerate()
460 .map(|(index, item)| {
461 let join = match (index, last) {
462 (_, 0) => ButtonJoin::Alone,
463 (0, _) => ButtonJoin::Leading,
464 (index, last) if index == last => ButtonJoin::Trailing,
465 _ => ButtonJoin::Middle,
466 };
467 let refused = self.disabled || item.disabled;
468 let id = item.id.clone();
469 let handler = actionable
470 .then(|| self.on_change.clone())
471 .flatten()
472 .filter(|_| !item.disabled);
473 let next = next_set(&self.items, &self.pressed, &item.id, self.selection);
474
475 Toggle::new(self.ident.child(item.id.as_ref()))
476 .variant(self.variant)
477 .control_size(self.size)
478 .join(join)
479 .semantic_parent(parent.clone())
480 .pressed(self.pressed.contains(&item.id))
481 .disabled(refused)
482 .when(!item.icon_only, |toggle| toggle.label(item.label.clone()))
483 .when_some(item.name.clone(), |toggle, name| {
484 toggle.accessible_name(name)
485 })
486 .when_some(item.icon, |toggle, glyph| {
487 match (item.icon_only, item.name.clone()) {
488 (true, Some(name)) => toggle.icon_only(glyph, name),
489 _ => toggle.icon(glyph),
490 }
491 })
492 .when_some(handler, |toggle, handler| {
493 toggle.on_press(move |_, window, cx| {
494 handler(next.clone(), id.clone(), window, cx)
495 })
496 })
497 })
498 .collect::<Vec<_>>();
499
500 div()
501 .row_reading(cx.layout_direction())
502 .flex_none()
503 .gap_token(&theme, Space::Xs)
504 .children(toggles)
505 .semantic_in(cx, {
506 let mut spec = NodeSpec::new(parent, Role::Toolbar).disabled(self.disabled);
507 if let Some(label) = self.label.clone() {
508 spec = spec.text(label);
509 }
510 spec
511 })
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 fn items() -> Vec<ToggleItem> {
520 vec![
521 ToggleItem::new("bold", "Bold"),
522 ToggleItem::new("italic", "Italic"),
523 ToggleItem::new("underline", "Underline").disabled(true),
524 ]
525 }
526
527 fn ids(values: &[&str]) -> Vec<SharedString> {
528 values.iter().map(|id| SharedString::from(*id)).collect()
529 }
530
531 #[test]
532 fn several_may_be_in_at_once() {
533 let items = items();
534 let pressed = ids(&["bold"]);
535 assert_eq!(
536 next_set(&items, &pressed, &"italic".into(), ToggleSelection::Any),
537 ids(&["bold", "italic"])
538 );
539 assert_eq!(
540 next_set(&items, &pressed, &"bold".into(), ToggleSelection::Any),
541 Vec::<SharedString>::new()
542 );
543 }
544
545 #[test]
546 fn the_report_follows_the_groups_own_order() {
547 let items = items();
548 let pressed = ids(&["italic"]);
549 assert_eq!(
550 next_set(&items, &pressed, &"bold".into(), ToggleSelection::Any),
551 ids(&["bold", "italic"]),
552 "the order is the group's, not the order they went in"
553 );
554 }
555
556 #[test]
557 fn at_most_one_can_be_emptied_which_is_the_whole_difference() {
558 let items = items();
559 let pressed = ids(&["bold"]);
560 assert_eq!(
561 next_set(
562 &items,
563 &pressed,
564 &"italic".into(),
565 ToggleSelection::AtMostOne
566 ),
567 ids(&["italic"])
568 );
569 assert_eq!(
570 next_set(&items, &pressed, &"bold".into(), ToggleSelection::AtMostOne),
571 Vec::<SharedString>::new(),
572 "a radio group has no move that gets here"
573 );
574 }
575}