1use gpui::{
2 Anchor, AnyElement, App, ElementId, Entity, FocusHandle, Focusable, Hsla,
3 InteractiveElement as _, IntoElement, ParentElement, RenderOnce, SharedString,
4 StatefulInteractiveElement as _, StyleRefinement, Styled, TextAlign, Window, div, hsla,
5 linear_color_stop, linear_gradient, prelude::FluentBuilder as _,
6};
7use rust_i18n::t;
8
9use gpui_base::{ColorPicker as BaseColorPicker, ColorSwatch};
10pub use gpui_base::{ColorPickerEvent, ColorPickerState};
11
12use crate::{
13 ActiveTheme as _, Colorize as _, Icon, Selectable, Sizable, Size, StyleSized, StyledExt as _,
14 ThemeStyled as _, h_flex,
15 input::{Input, input_style},
16 popover::Popover,
17 select::Caret,
18 separator::Separator,
19 slider::Slider,
20 tab::{Tab, TabBar},
21 tooltip::{ManagedTooltipExt as _, Tooltip},
22 v_flex,
23};
24
25fn color_palettes() -> Vec<Vec<Hsla>> {
26 use crate::theme::DEFAULT_COLORS;
27 use itertools::Itertools as _;
28
29 macro_rules! c {
30 ($color:tt) => {
31 DEFAULT_COLORS
32 .$color
33 .keys()
34 .sorted()
35 .map(|k| DEFAULT_COLORS.$color.get(k).map(|c| c.hsla).unwrap())
36 .collect::<Vec<_>>()
37 };
38 }
39
40 vec![
41 c!(stone),
42 c!(red),
43 c!(orange),
44 c!(yellow),
45 c!(green),
46 c!(cyan),
47 c!(blue),
48 c!(purple),
49 c!(pink),
50 ]
51}
52
53#[derive(IntoElement)]
55pub struct ColorPicker {
56 id: ElementId,
57 style: StyleRefinement,
58 state: Entity<ColorPickerState>,
59 featured_colors: Option<Vec<Hsla>>,
60 label: Option<SharedString>,
61 accessibility_label: Option<SharedString>,
63 icon: Option<Icon>,
64 size: Size,
65 anchor: Anchor,
66 field: bool,
68 placeholder: Option<SharedString>,
69}
70
71impl ColorPicker {
72 pub fn new(state: &Entity<ColorPickerState>) -> Self {
74 Self {
75 id: ("color-picker", state.entity_id()).into(),
76 style: StyleRefinement::default(),
77 state: state.clone(),
78 featured_colors: None,
79 size: Size::Medium,
80 label: None,
81 accessibility_label: None,
82 icon: None,
83 anchor: Anchor::TopLeft,
84 field: false,
85 placeholder: None,
86 }
87 }
88
89 pub fn featured_colors(mut self, colors: Vec<Hsla>) -> Self {
94 self.featured_colors = Some(colors);
95 self
96 }
97
98 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
103 self.icon = Some(icon.into());
104 self
105 }
106
107 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
111 self.label = Some(label.into());
112 self
113 }
114
115 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
122 self.accessibility_label = Some(label.into());
123 self
124 }
125
126 pub fn anchor(mut self, anchor: Anchor) -> Self {
130 self.anchor = anchor;
131 self
132 }
133
134 fn render_item(&self, id: impl Into<ElementId>, color: Hsla, cx: &mut App) -> ColorSwatch {
135 let selected = self.state.read(cx).value() == Some(color);
136 let hover_state = self.state.clone();
137 let click_state = self.state.clone();
138
139 ColorSwatch::new(id, color)
140 .selected(selected)
141 .h_5()
142 .w_5()
143 .bg(color)
144 .border_1()
145 .border_color(color.darken(0.1))
146 .hover(|this| this.border_color(color.darken(0.3)).bg(color.lighten(0.1)))
147 .active(|this| this.border_color(color.darken(0.5)).bg(color.darken(0.2)))
148 .on_hover(move |color, entered, window, cx| {
149 if entered {
150 hover_state.update(cx, |state, cx| state.preview_color(color, window, cx));
151 }
152 })
153 .on_click(move |color, _, window, cx| {
154 click_state.update(cx, |state, cx| state.select_color(color, window, cx));
155 })
156 }
157
158 fn render_colors(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
159 self.state
160 .update(cx, |state, cx| state.sync_pending_value(window, cx));
161
162 let active_tab = self.state.read(cx).active_tab();
163 let (slider_color, hovered_color) = {
164 let state = self.state.read(cx);
165 let slider_color = state
166 .displayed_color()
167 .unwrap_or_else(|| hsla(0., 0., 0., 1.));
168 (slider_color, state.preview())
169 };
170 let tab_state = self.state.clone();
171
172 v_flex()
173 .p_0p5()
174 .gap_3()
175 .child(
176 TabBar::new("mode")
177 .segmented()
178 .selected_index(active_tab)
179 .on_click(move |ix: &usize, _, cx| {
180 tab_state.update(cx, |state, cx| state.set_active_tab(*ix, cx));
181 })
182 .child(Tab::new().flex_1().label(t!("ColorPicker.Palette")))
183 .child(Tab::new().flex_1().label(t!("ColorPicker.HSLA"))),
184 )
185 .child(match active_tab {
186 0 => self.render_palette_panel(cx).into_any_element(),
187 _ => self
188 .render_slider_tab_panel(slider_color, cx)
189 .into_any_element(),
190 })
191 .when_some(hovered_color, |this, hovered_color| {
192 this.child(Separator::horizontal()).child(
193 h_flex()
194 .gap_2()
195 .items_center()
196 .child(
197 div()
198 .bg(hovered_color)
199 .flex_shrink_0()
200 .border_1()
201 .border_color(hovered_color.darken(0.2))
202 .size_5()
203 .rounded(cx.theme().radius),
204 )
205 .child(Input::new(self.state.read(cx).hex_input()).small().px_2p5()),
206 )
207 })
208 }
209
210 fn render_palette_panel(&self, cx: &mut App) -> impl IntoElement {
211 let featured_colors = self.featured_colors.clone().unwrap_or(vec![
212 cx.theme().red,
213 cx.theme().red_light,
214 cx.theme().blue,
215 cx.theme().blue_light,
216 cx.theme().green,
217 cx.theme().green_light,
218 cx.theme().yellow,
219 cx.theme().yellow_light,
220 cx.theme().cyan,
221 cx.theme().cyan_light,
222 cx.theme().magenta,
223 cx.theme().magenta_light,
224 ]);
225
226 v_flex()
227 .gap_3()
228 .child(
229 h_flex().gap_1().children(
230 featured_colors
231 .iter()
232 .enumerate()
234 .map(|(ix, color)| self.render_item(("featured-color", ix), *color, cx)),
235 ),
236 )
237 .child(Separator::horizontal())
238 .child(
239 v_flex()
240 .gap_1()
241 .children(color_palettes().iter().enumerate().map(|(ix, sub_colors)| {
242 h_flex().id(("palette-row", ix)).gap_1().children(
243 sub_colors.iter().rev().map(|color| {
244 self.render_item(
245 SharedString::from(format!("color-{}", color.to_hex())),
246 *color,
247 cx,
248 )
249 }),
250 )
251 })),
252 )
253 }
254
255 fn render_slider_tab_panel(&self, slider_color: Hsla, cx: &mut App) -> impl IntoElement {
256 let sliders = self.state.read(cx).sliders().clone();
257 let steps = 96usize;
258 let hue_colors = (0..steps)
259 .map(|ix| {
260 let h = ix as f32 / (steps.saturating_sub(1)) as f32;
261 hsla(h, 1.0, 0.5, 1.0)
262 })
263 .collect::<Vec<_>>();
264 let saturation_start = hsla(slider_color.h, 0.0, slider_color.l, 1.0);
265 let saturation_end = hsla(slider_color.h, 1.0, slider_color.l, 1.0);
266 let lightness_colors = (0..steps)
267 .map(|ix| {
268 let l = ix as f32 / (steps.saturating_sub(1)) as f32;
269 hsla(slider_color.h, 1.0, l, 1.0)
270 })
271 .collect::<Vec<_>>();
272 let alpha_start = hsla(slider_color.h, slider_color.s, slider_color.l, 0.0);
273 let alpha_end = hsla(slider_color.h, slider_color.s, slider_color.l, 1.0);
274
275 let label_color = cx.theme().foreground.opacity(0.7);
276
277 v_flex()
278 .gap_2()
279 .child(
280 h_flex()
281 .gap_2()
282 .items_center()
283 .child(
284 div()
285 .min_w_16()
286 .text_xs()
287 .text_color(label_color)
288 .child(t!("ColorPicker.Hue")),
289 )
290 .child(
291 div()
292 .relative()
293 .flex()
294 .items_center()
295 .flex_1()
296 .h_8()
297 .child(self.render_slider_track(hue_colors, cx))
298 .child(
299 Slider::new(sliders.hue())
300 .flex_1()
301 .bg(cx.theme().transparent),
302 ),
303 )
304 .child(
305 div()
306 .w_10()
307 .text_xs()
308 .text_color(label_color)
309 .text_align(TextAlign::Right)
310 .child(format!("{:.0}", slider_color.h * 360.)),
311 ),
312 )
313 .child(
314 h_flex()
315 .gap_2()
316 .items_center()
317 .child(
318 div()
319 .min_w_16()
320 .text_xs()
321 .text_color(label_color)
322 .child(t!("ColorPicker.Saturation")),
323 )
324 .child(
325 div()
326 .relative()
327 .flex()
328 .items_center()
329 .flex_1()
330 .h_8()
331 .child(self.render_slider_track_gradient(
332 saturation_start,
333 saturation_end,
334 cx,
335 ))
336 .child(
337 Slider::new(sliders.saturation())
338 .flex_1()
339 .bg(cx.theme().transparent),
340 ),
341 )
342 .child(
343 div()
344 .w_10()
345 .text_xs()
346 .text_color(label_color)
347 .text_align(TextAlign::Right)
348 .child(format!("{:.0}", slider_color.s * 100.)),
349 ),
350 )
351 .child(
352 h_flex()
353 .gap_2()
354 .items_center()
355 .child(
356 div()
357 .min_w_16()
358 .text_xs()
359 .text_color(label_color)
360 .child(t!("ColorPicker.Lightness")),
361 )
362 .child(
363 div()
364 .relative()
365 .flex()
366 .items_center()
367 .flex_1()
368 .h_8()
369 .child(self.render_slider_track(lightness_colors, cx))
370 .child(
371 Slider::new(sliders.lightness())
372 .flex_1()
373 .bg(cx.theme().transparent),
374 ),
375 )
376 .child(
377 div()
378 .w_10()
379 .text_xs()
380 .text_color(label_color)
381 .text_align(TextAlign::Right)
382 .child(format!("{:.0}", slider_color.l * 100.)),
383 ),
384 )
385 .child(
386 h_flex()
387 .gap_2()
388 .items_center()
389 .child(
390 div()
391 .min_w_16()
392 .text_xs()
393 .text_color(label_color)
394 .child(t!("ColorPicker.Alpha")),
395 )
396 .child(
397 div()
398 .relative()
399 .flex()
400 .items_center()
401 .flex_1()
402 .h_8()
403 .child(self.render_slider_track_gradient(alpha_start, alpha_end, cx))
404 .child(
405 Slider::new(sliders.alpha())
406 .flex_1()
407 .bg(cx.theme().transparent),
408 ),
409 )
410 .child(
411 div()
412 .w_10()
413 .text_xs()
414 .text_color(label_color)
415 .text_align(TextAlign::Right)
416 .child(format!("{:.0}", slider_color.a * 100.)),
417 ),
418 )
419 }
420
421 fn render_slider_track(&self, colors: Vec<Hsla>, _: &App) -> impl IntoElement {
422 h_flex()
423 .absolute()
424 .left_0()
425 .right_0()
426 .h_2_5()
427 .overflow_hidden()
428 .children(
429 colors
430 .into_iter()
431 .map(|color| div().flex_1().h_full().bg(color)),
432 )
433 }
434
435 fn render_slider_track_gradient(&self, start: Hsla, end: Hsla, _: &App) -> impl IntoElement {
436 div()
437 .absolute()
438 .left_0()
439 .right_0()
440 .h_2_5()
441 .overflow_hidden()
442 .bg(linear_gradient(
443 90.,
444 linear_color_stop(start, 0.),
445 linear_color_stop(end, 1.),
446 ))
447 }
448}
449
450impl Sizable for ColorPicker {
451 fn with_size(mut self, size: impl Into<Size>) -> Self {
452 self.size = size.into();
453 self
454 }
455}
456
457impl Focusable for ColorPicker {
458 fn focus_handle(&self, cx: &App) -> FocusHandle {
459 self.state.focus_handle(cx)
460 }
461}
462
463impl Styled for ColorPicker {
464 fn style(&mut self) -> &mut StyleRefinement {
465 &mut self.style
466 }
467}
468
469impl RenderOnce for ColorPicker {
470 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
471 let state = self.state.read(cx);
472 let display_title: SharedString = if let Some(value) = state.value() {
473 value.to_hex()
474 } else {
475 "".to_string()
476 }
477 .into();
478
479 let open = state.is_open();
480 let value = state.value();
481 let focus_handle = self.state.focus_handle(cx);
482 let focused = focus_handle.is_focused(window);
483 let open_state = self.state.clone();
484 let popover_state = self.state.clone();
485
486 BaseColorPicker::new(self.id.clone())
487 .when(self.field, |this| this.w_full())
488 .refine_style(&self.style)
489 .open(open)
490 .track_focus(&focus_handle)
491 .when_some(
492 self.accessibility_label
493 .clone()
494 .or_else(|| self.label.clone()),
495 |this, label| this.accessibility_label(label),
496 )
497 .on_open_change(move |open, _, cx| {
498 open_state.update(cx, |state, cx| state.set_open(open, cx));
499 })
500 .child(
501 Popover::new("popover")
502 .open(open)
503 .w_72()
504 .on_open_change(move |open: &bool, _, cx| {
505 popover_state.update(cx, |state, cx| state.set_open(*open, cx));
506 })
507 .when(self.field, |this| {
508 this.trigger_style(StyleRefinement::default().w_full())
509 })
510 .trigger(ColorPickerButton {
511 id: "trigger".into(),
512 size: self.size,
513 label: self.label.clone(),
514 value,
515 tooltip: if display_title.is_empty() || self.field {
516 None
517 } else {
518 Some(display_title.clone())
519 },
520 icon: self.icon.clone(),
521 selected: false,
522 field: self.field,
523 focused,
524 placeholder: self.placeholder.clone(),
525 })
526 .when(open, |this| this.child(self.render_colors(window, cx))),
529 )
530 }
531}
532
533#[derive(IntoElement)]
540pub struct ColorSelect {
541 picker: ColorPicker,
542}
543
544impl ColorSelect {
545 pub fn new(state: &Entity<ColorPickerState>) -> Self {
547 let mut picker = ColorPicker::new(state);
548 picker.id = ("color-select", state.entity_id()).into();
549 picker.field = true;
550 Self { picker }
551 }
552
553 pub fn featured_colors(mut self, colors: Vec<Hsla>) -> Self {
555 self.picker = self.picker.featured_colors(colors);
556 self
557 }
558
559 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
563 self.picker.placeholder = Some(placeholder.into());
564 self
565 }
566
567 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
569 self.picker = self.picker.accessibility_label(label);
570 self
571 }
572}
573
574impl Sizable for ColorSelect {
575 fn with_size(mut self, size: impl Into<Size>) -> Self {
576 self.picker = self.picker.with_size(size);
577 self
578 }
579}
580
581impl Focusable for ColorSelect {
582 fn focus_handle(&self, cx: &App) -> FocusHandle {
583 self.picker.focus_handle(cx)
584 }
585}
586
587impl Styled for ColorSelect {
588 fn style(&mut self) -> &mut StyleRefinement {
589 self.picker.style()
590 }
591}
592
593impl RenderOnce for ColorSelect {
594 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
595 self.picker.render(window, cx)
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use gpui::{AppContext as _, Context, Render, TestAppContext};
602
603 use super::*;
604
605 struct PaletteHarness {
606 state: Entity<ColorPickerState>,
607 }
608
609 impl Render for PaletteHarness {
610 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
611 let color = color_palettes()[0][0];
612 ColorPicker::new(&self.state)
613 .featured_colors(vec![color, color])
614 .render_palette_panel(cx)
615 .into_any_element()
616 }
617 }
618
619 #[gpui::test]
620 fn repeated_palette_colors_have_independent_focus_stops(cx: &mut TestAppContext) {
621 cx.update(crate::init);
622 let (_, cx) = cx.add_window_view(|window, cx| PaletteHarness {
623 state: cx.new(|cx| ColorPickerState::new(window, cx)),
624 });
625 cx.update(|window, cx| {
626 window.draw(cx).clear(cx);
627 let swatch_count = 2 + color_palettes().iter().map(Vec::len).sum::<usize>();
628 let mut focused = Vec::new();
629 for _ in 0..swatch_count {
630 window.focus_next(cx);
631 let handle = window.focused(cx).expect("each swatch is focusable");
632 assert!(
633 !focused.contains(&handle),
634 "equal colors must not share element identity or a focus stop"
635 );
636 focused.push(handle);
637 }
638 window.focus_next(cx);
639 assert_eq!(window.focused(cx), focused.first().cloned());
640 });
641 }
642
643 #[gpui::test]
644 fn an_explicit_accessibility_label_replaces_the_visible_one(cx: &mut TestAppContext) {
645 cx.update(crate::init);
646 let cx = cx.add_empty_window();
647 cx.update(|window, cx| {
648 let state = cx.new(|cx| ColorPickerState::new(window, cx));
649
650 let plain = ColorPicker::new(&state).label("Color");
651 assert_eq!(plain.accessibility_label, None);
652 assert_eq!(plain.label.as_deref(), Some("Color"));
653
654 let named = ColorPicker::new(&state)
655 .label("Color")
656 .accessibility_label("Text color");
657 assert_eq!(
658 named.accessibility_label.as_deref(),
659 Some("Text color"),
660 "an explicit name must win over the visible label"
661 );
662 assert_eq!(
663 named.label.as_deref(),
664 Some("Color"),
665 "and must not change what is drawn"
666 );
667 });
668 }
669
670 #[gpui::test]
671 fn test_color_select_builder(cx: &mut TestAppContext) {
672 cx.update(crate::init);
673 let cx = cx.add_empty_window();
674 cx.update(|window, cx| {
675 let state = cx.new(|cx| ColorPickerState::new(window, cx));
676 let select = ColorSelect::new(&state)
677 .large()
678 .placeholder("Pick a color")
679 .accessibility_label("Theme color")
680 .featured_colors(vec![hsla(0., 1., 0.5, 1.)]);
681
682 assert!(select.picker.field);
683 assert_eq!(select.picker.size, Size::Large);
684 assert_eq!(select.picker.placeholder.as_deref(), Some("Pick a color"));
685 assert_eq!(
686 select.picker.accessibility_label.as_deref(),
687 Some("Theme color")
688 );
689 assert_eq!(
690 select.picker.featured_colors.as_ref().map(Vec::len),
691 Some(1)
692 );
693 });
694 }
695
696 struct SelectHarness {
697 state: Entity<ColorPickerState>,
698 }
699
700 impl Render for SelectHarness {
701 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
702 div().w(gpui::px(400.)).child(ColorSelect::new(&self.state))
703 }
704 }
705
706 #[gpui::test]
707 fn a_click_anywhere_on_the_color_select_opens_the_picker(cx: &mut TestAppContext) {
708 cx.update(crate::init);
709 let (view, cx) = cx.add_window_view(|window, cx| SelectHarness {
710 state: cx.new(|cx| ColorPickerState::new(window, cx)),
711 });
712 cx.update(|window, cx| window.draw(cx).clear(cx));
713 let open = cx.update(|_, cx| view.read(cx).state.read(cx).is_open());
714 assert!(!open, "the picker starts closed");
715
716 cx.simulate_click(
718 gpui::point(gpui::px(300.), gpui::px(16.)),
719 Default::default(),
720 );
721 cx.update(|window, cx| window.draw(cx).clear(cx));
722 let open = cx.update(|_, cx| view.read(cx).state.read(cx).is_open());
723 assert!(open, "the whole field is the trigger, like a Select");
724 }
725}
726
727#[derive(IntoElement)]
728struct ColorPickerButton {
729 id: ElementId,
730 selected: bool,
731 icon: Option<Icon>,
732 value: Option<Hsla>,
733 size: Size,
734 label: Option<SharedString>,
735 tooltip: Option<SharedString>,
736 field: bool,
737 focused: bool,
738 placeholder: Option<SharedString>,
739}
740
741impl Selectable for ColorPickerButton {
742 fn selected(mut self, selected: bool) -> Self {
743 self.selected = selected;
744 self
745 }
746
747 fn is_selected(&self) -> bool {
748 self.selected
749 }
750}
751
752impl Sizable for ColorPickerButton {
753 fn with_size(mut self, size: impl Into<Size>) -> Self {
754 self.size = size.into();
755 self
756 }
757}
758
759impl ColorPickerButton {
760 fn render_field(self, window: &mut Window, cx: &mut App) -> AnyElement {
763 let (bg, fg) = input_style(false, cx);
764 let outline_visible = self.selected || self.focused;
765
766 h_flex()
767 .id(self.id)
768 .w_full()
769 .gap_2()
770 .bg(bg)
771 .text_color(fg)
772 .border_1()
773 .border_color(cx.theme().input)
774 .rounded(cx.theme().radius)
775 .input_size(self.size)
776 .input_text_size(self.size)
777 .when(outline_visible, |this| {
778 this.border_color(cx.theme().ring)
779 .focus_ring_style(window, cx)
780 })
781 .child(
782 div()
783 .id("swatch")
784 .flex_shrink_0()
785 .border_1()
786 .border_color(cx.theme().input)
787 .rounded(cx.theme().radius / 2.)
788 .map(|this| match self.size {
789 Size::XSmall => this.size_3(),
790 Size::Small => this.size_3p5(),
791 Size::Large => this.size_5(),
792 _ => this.size_4(),
793 })
794 .when_some(self.value, |this, value| {
795 this.bg(value).border_color(value.darken(0.3))
796 }),
797 )
798 .child(
799 div()
800 .flex_1()
801 .min_w_0()
802 .overflow_hidden()
803 .whitespace_nowrap()
804 .map(|this| match self.value {
805 Some(value) => this.child(value.to_hex()),
806 None => this.text_color(cx.theme().muted_foreground).child(
807 self.placeholder
808 .unwrap_or_else(|| t!("Select.placeholder").into()),
809 ),
810 }),
811 )
812 .child(Caret::new(self.size).text_color(cx.theme().muted_foreground))
813 .into_any_element()
814 }
815}
816
817impl RenderOnce for ColorPickerButton {
818 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
819 if self.field {
820 return self.render_field(window, cx);
821 }
822
823 let has_icon = self.icon.is_some();
824 h_flex()
825 .id(self.id)
826 .gap_2()
827 .children(self.icon)
828 .when(!has_icon, |this| {
829 this.child(
830 div()
831 .id("square")
832 .bg(cx.theme().tokens.background)
833 .border_1()
834 .border_color(cx.theme().input)
835 .rounded(cx.theme().radius)
836 .overflow_hidden()
837 .size_with(self.size)
838 .when_some(self.value, |this, value| {
839 this.bg(value)
840 .border_color(value.darken(0.3))
841 .when(self.selected, |this| this.border_2())
842 })
843 .when_some(self.tooltip, |this, tooltip| {
844 this.managed_tooltip(move |window, cx| {
845 Tooltip::new(tooltip.clone()).build(window, cx)
846 })
847 }),
848 )
849 })
850 .when_some(self.label, |this, label| this.child(label))
851 .into_any_element()
852 }
853}