1use gpui::{
2 Anchor, App, ElementId, Entity, FocusHandle, Focusable, Hsla, InteractiveElement as _,
3 IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _,
4 StyleRefinement, Styled, TextAlign, Window, div, hsla, linear_color_stop, linear_gradient,
5 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, h_flex,
14 input::Input,
15 popover::Popover,
16 separator::Separator,
17 slider::Slider,
18 tab::{Tab, TabBar},
19 tooltip::{ManagedTooltipExt as _, Tooltip},
20 v_flex,
21};
22
23fn color_palettes() -> Vec<Vec<Hsla>> {
24 use crate::theme::DEFAULT_COLORS;
25 use itertools::Itertools as _;
26
27 macro_rules! c {
28 ($color:tt) => {
29 DEFAULT_COLORS
30 .$color
31 .keys()
32 .sorted()
33 .map(|k| DEFAULT_COLORS.$color.get(k).map(|c| c.hsla).unwrap())
34 .collect::<Vec<_>>()
35 };
36 }
37
38 vec![
39 c!(stone),
40 c!(red),
41 c!(orange),
42 c!(yellow),
43 c!(green),
44 c!(cyan),
45 c!(blue),
46 c!(purple),
47 c!(pink),
48 ]
49}
50
51#[derive(IntoElement)]
53pub struct ColorPicker {
54 id: ElementId,
55 style: StyleRefinement,
56 state: Entity<ColorPickerState>,
57 featured_colors: Option<Vec<Hsla>>,
58 label: Option<SharedString>,
59 accessibility_label: Option<SharedString>,
61 icon: Option<Icon>,
62 size: Size,
63 anchor: Anchor,
64}
65
66impl ColorPicker {
67 pub fn new(state: &Entity<ColorPickerState>) -> Self {
69 Self {
70 id: ("color-picker", state.entity_id()).into(),
71 style: StyleRefinement::default(),
72 state: state.clone(),
73 featured_colors: None,
74 size: Size::Medium,
75 label: None,
76 accessibility_label: None,
77 icon: None,
78 anchor: Anchor::TopLeft,
79 }
80 }
81
82 pub fn featured_colors(mut self, colors: Vec<Hsla>) -> Self {
87 self.featured_colors = Some(colors);
88 self
89 }
90
91 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
96 self.icon = Some(icon.into());
97 self
98 }
99
100 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
104 self.label = Some(label.into());
105 self
106 }
107
108 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
115 self.accessibility_label = Some(label.into());
116 self
117 }
118
119 pub fn anchor(mut self, anchor: Anchor) -> Self {
123 self.anchor = anchor;
124 self
125 }
126
127 fn render_item(&self, id: impl Into<ElementId>, color: Hsla, cx: &mut App) -> ColorSwatch {
128 let selected = self.state.read(cx).value() == Some(color);
129 let hover_state = self.state.clone();
130 let click_state = self.state.clone();
131
132 ColorSwatch::new(id, color)
133 .selected(selected)
134 .h_5()
135 .w_5()
136 .bg(color)
137 .border_1()
138 .border_color(color.darken(0.1))
139 .hover(|this| this.border_color(color.darken(0.3)).bg(color.lighten(0.1)))
140 .active(|this| this.border_color(color.darken(0.5)).bg(color.darken(0.2)))
141 .on_hover(move |color, entered, window, cx| {
142 if entered {
143 hover_state.update(cx, |state, cx| state.preview_color(color, window, cx));
144 }
145 })
146 .on_click(move |color, _, window, cx| {
147 click_state.update(cx, |state, cx| state.select_color(color, window, cx));
148 })
149 }
150
151 fn render_colors(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
152 self.state
153 .update(cx, |state, cx| state.sync_pending_value(window, cx));
154
155 let active_tab = self.state.read(cx).active_tab();
156 let (slider_color, hovered_color) = {
157 let state = self.state.read(cx);
158 let slider_color = state
159 .displayed_color()
160 .unwrap_or_else(|| hsla(0., 0., 0., 1.));
161 (slider_color, state.preview())
162 };
163 let tab_state = self.state.clone();
164
165 v_flex()
166 .p_0p5()
167 .gap_3()
168 .child(
169 TabBar::new("mode")
170 .segmented()
171 .selected_index(active_tab)
172 .on_click(move |ix: &usize, _, cx| {
173 tab_state.update(cx, |state, cx| state.set_active_tab(*ix, cx));
174 })
175 .child(Tab::new().flex_1().label(t!("ColorPicker.Palette")))
176 .child(Tab::new().flex_1().label(t!("ColorPicker.HSLA"))),
177 )
178 .child(match active_tab {
179 0 => self.render_palette_panel(cx).into_any_element(),
180 _ => self
181 .render_slider_tab_panel(slider_color, cx)
182 .into_any_element(),
183 })
184 .when_some(hovered_color, |this, hovered_color| {
185 this.child(Separator::horizontal()).child(
186 h_flex()
187 .gap_2()
188 .items_center()
189 .child(
190 div()
191 .bg(hovered_color)
192 .flex_shrink_0()
193 .border_1()
194 .border_color(hovered_color.darken(0.2))
195 .size_5()
196 .rounded(cx.theme().radius),
197 )
198 .child(Input::new(self.state.read(cx).hex_input()).small().px_2p5()),
199 )
200 })
201 }
202
203 fn render_palette_panel(&self, cx: &mut App) -> impl IntoElement {
204 let featured_colors = self.featured_colors.clone().unwrap_or(vec![
205 cx.theme().red,
206 cx.theme().red_light,
207 cx.theme().blue,
208 cx.theme().blue_light,
209 cx.theme().green,
210 cx.theme().green_light,
211 cx.theme().yellow,
212 cx.theme().yellow_light,
213 cx.theme().cyan,
214 cx.theme().cyan_light,
215 cx.theme().magenta,
216 cx.theme().magenta_light,
217 ]);
218
219 v_flex()
220 .gap_3()
221 .child(
222 h_flex().gap_1().children(
223 featured_colors
224 .iter()
225 .enumerate()
227 .map(|(ix, color)| self.render_item(("featured-color", ix), *color, cx)),
228 ),
229 )
230 .child(Separator::horizontal())
231 .child(
232 v_flex()
233 .gap_1()
234 .children(color_palettes().iter().enumerate().map(|(ix, sub_colors)| {
235 h_flex().id(("palette-row", ix)).gap_1().children(
236 sub_colors.iter().rev().map(|color| {
237 self.render_item(
238 SharedString::from(format!("color-{}", color.to_hex())),
239 *color,
240 cx,
241 )
242 }),
243 )
244 })),
245 )
246 }
247
248 fn render_slider_tab_panel(&self, slider_color: Hsla, cx: &mut App) -> impl IntoElement {
249 let sliders = self.state.read(cx).sliders().clone();
250 let steps = 96usize;
251 let hue_colors = (0..steps)
252 .map(|ix| {
253 let h = ix as f32 / (steps.saturating_sub(1)) as f32;
254 hsla(h, 1.0, 0.5, 1.0)
255 })
256 .collect::<Vec<_>>();
257 let saturation_start = hsla(slider_color.h, 0.0, slider_color.l, 1.0);
258 let saturation_end = hsla(slider_color.h, 1.0, slider_color.l, 1.0);
259 let lightness_colors = (0..steps)
260 .map(|ix| {
261 let l = ix as f32 / (steps.saturating_sub(1)) as f32;
262 hsla(slider_color.h, 1.0, l, 1.0)
263 })
264 .collect::<Vec<_>>();
265 let alpha_start = hsla(slider_color.h, slider_color.s, slider_color.l, 0.0);
266 let alpha_end = hsla(slider_color.h, slider_color.s, slider_color.l, 1.0);
267
268 let label_color = cx.theme().foreground.opacity(0.7);
269
270 v_flex()
271 .gap_2()
272 .child(
273 h_flex()
274 .gap_2()
275 .items_center()
276 .child(
277 div()
278 .min_w_16()
279 .text_xs()
280 .text_color(label_color)
281 .child(t!("ColorPicker.Hue")),
282 )
283 .child(
284 div()
285 .relative()
286 .flex()
287 .items_center()
288 .flex_1()
289 .h_8()
290 .child(self.render_slider_track(hue_colors, cx))
291 .child(
292 Slider::new(sliders.hue())
293 .flex_1()
294 .bg(cx.theme().transparent),
295 ),
296 )
297 .child(
298 div()
299 .w_10()
300 .text_xs()
301 .text_color(label_color)
302 .text_align(TextAlign::Right)
303 .child(format!("{:.0}", slider_color.h * 360.)),
304 ),
305 )
306 .child(
307 h_flex()
308 .gap_2()
309 .items_center()
310 .child(
311 div()
312 .min_w_16()
313 .text_xs()
314 .text_color(label_color)
315 .child(t!("ColorPicker.Saturation")),
316 )
317 .child(
318 div()
319 .relative()
320 .flex()
321 .items_center()
322 .flex_1()
323 .h_8()
324 .child(self.render_slider_track_gradient(
325 saturation_start,
326 saturation_end,
327 cx,
328 ))
329 .child(
330 Slider::new(sliders.saturation())
331 .flex_1()
332 .bg(cx.theme().transparent),
333 ),
334 )
335 .child(
336 div()
337 .w_10()
338 .text_xs()
339 .text_color(label_color)
340 .text_align(TextAlign::Right)
341 .child(format!("{:.0}", slider_color.s * 100.)),
342 ),
343 )
344 .child(
345 h_flex()
346 .gap_2()
347 .items_center()
348 .child(
349 div()
350 .min_w_16()
351 .text_xs()
352 .text_color(label_color)
353 .child(t!("ColorPicker.Lightness")),
354 )
355 .child(
356 div()
357 .relative()
358 .flex()
359 .items_center()
360 .flex_1()
361 .h_8()
362 .child(self.render_slider_track(lightness_colors, cx))
363 .child(
364 Slider::new(sliders.lightness())
365 .flex_1()
366 .bg(cx.theme().transparent),
367 ),
368 )
369 .child(
370 div()
371 .w_10()
372 .text_xs()
373 .text_color(label_color)
374 .text_align(TextAlign::Right)
375 .child(format!("{:.0}", slider_color.l * 100.)),
376 ),
377 )
378 .child(
379 h_flex()
380 .gap_2()
381 .items_center()
382 .child(
383 div()
384 .min_w_16()
385 .text_xs()
386 .text_color(label_color)
387 .child(t!("ColorPicker.Alpha")),
388 )
389 .child(
390 div()
391 .relative()
392 .flex()
393 .items_center()
394 .flex_1()
395 .h_8()
396 .child(self.render_slider_track_gradient(alpha_start, alpha_end, cx))
397 .child(
398 Slider::new(sliders.alpha())
399 .flex_1()
400 .bg(cx.theme().transparent),
401 ),
402 )
403 .child(
404 div()
405 .w_10()
406 .text_xs()
407 .text_color(label_color)
408 .text_align(TextAlign::Right)
409 .child(format!("{:.0}", slider_color.a * 100.)),
410 ),
411 )
412 }
413
414 fn render_slider_track(&self, colors: Vec<Hsla>, _: &App) -> impl IntoElement {
415 h_flex()
416 .absolute()
417 .left_0()
418 .right_0()
419 .h_2_5()
420 .overflow_hidden()
421 .children(
422 colors
423 .into_iter()
424 .map(|color| div().flex_1().h_full().bg(color)),
425 )
426 }
427
428 fn render_slider_track_gradient(&self, start: Hsla, end: Hsla, _: &App) -> impl IntoElement {
429 div()
430 .absolute()
431 .left_0()
432 .right_0()
433 .h_2_5()
434 .overflow_hidden()
435 .bg(linear_gradient(
436 90.,
437 linear_color_stop(start, 0.),
438 linear_color_stop(end, 1.),
439 ))
440 }
441}
442
443impl Sizable for ColorPicker {
444 fn with_size(mut self, size: impl Into<Size>) -> Self {
445 self.size = size.into();
446 self
447 }
448}
449
450impl Focusable for ColorPicker {
451 fn focus_handle(&self, cx: &App) -> FocusHandle {
452 self.state.focus_handle(cx)
453 }
454}
455
456impl Styled for ColorPicker {
457 fn style(&mut self) -> &mut StyleRefinement {
458 &mut self.style
459 }
460}
461
462impl RenderOnce for ColorPicker {
463 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
464 let state = self.state.read(cx);
465 let display_title: SharedString = if let Some(value) = state.value() {
466 value.to_hex()
467 } else {
468 "".to_string()
469 }
470 .into();
471
472 let open = state.is_open();
473 let value = state.value();
474 let focus_handle = self.state.focus_handle(cx);
475 let open_state = self.state.clone();
476 let popover_state = self.state.clone();
477
478 BaseColorPicker::new(self.id.clone())
479 .open(open)
480 .track_focus(&focus_handle)
481 .when_some(
482 self.accessibility_label
483 .clone()
484 .or_else(|| self.label.clone()),
485 |this, label| this.accessibility_label(label),
486 )
487 .on_open_change(move |open, _, cx| {
488 open_state.update(cx, |state, cx| state.set_open(open, cx));
489 })
490 .child(
491 Popover::new("popover")
492 .open(open)
493 .w_72()
494 .on_open_change(move |open: &bool, _, cx| {
495 popover_state.update(cx, |state, cx| state.set_open(*open, cx));
496 })
497 .trigger(ColorPickerButton {
498 id: "trigger".into(),
499 size: self.size,
500 label: self.label.clone(),
501 value,
502 tooltip: if display_title.is_empty() {
503 None
504 } else {
505 Some(display_title.clone())
506 },
507 icon: self.icon.clone(),
508 selected: false,
509 })
510 .child(self.render_colors(window, cx)),
511 )
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use gpui::{AppContext as _, Context, Render, TestAppContext};
518
519 use super::*;
520
521 struct PaletteHarness {
522 state: Entity<ColorPickerState>,
523 }
524
525 impl Render for PaletteHarness {
526 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
527 let color = color_palettes()[0][0];
528 ColorPicker::new(&self.state)
529 .featured_colors(vec![color, color])
530 .render_palette_panel(cx)
531 .into_any_element()
532 }
533 }
534
535 #[gpui::test]
536 fn repeated_palette_colors_have_independent_focus_stops(cx: &mut TestAppContext) {
537 cx.update(crate::init);
538 let (_, cx) = cx.add_window_view(|window, cx| PaletteHarness {
539 state: cx.new(|cx| ColorPickerState::new(window, cx)),
540 });
541 cx.update(|window, cx| {
542 window.draw(cx).clear(cx);
543 let swatch_count = 2 + color_palettes().iter().map(Vec::len).sum::<usize>();
544 let mut focused = Vec::new();
545 for _ in 0..swatch_count {
546 window.focus_next(cx);
547 let handle = window.focused(cx).expect("each swatch is focusable");
548 assert!(
549 !focused.contains(&handle),
550 "equal colors must not share element identity or a focus stop"
551 );
552 focused.push(handle);
553 }
554 window.focus_next(cx);
555 assert_eq!(window.focused(cx), focused.first().cloned());
556 });
557 }
558
559 #[gpui::test]
560 fn an_explicit_accessibility_label_replaces_the_visible_one(cx: &mut TestAppContext) {
561 cx.update(crate::init);
562 let cx = cx.add_empty_window();
563 cx.update(|window, cx| {
564 let state = cx.new(|cx| ColorPickerState::new(window, cx));
565
566 let plain = ColorPicker::new(&state).label("Color");
567 assert_eq!(plain.accessibility_label, None);
568 assert_eq!(plain.label.as_deref(), Some("Color"));
569
570 let named = ColorPicker::new(&state)
571 .label("Color")
572 .accessibility_label("Text color");
573 assert_eq!(
574 named.accessibility_label.as_deref(),
575 Some("Text color"),
576 "an explicit name must win over the visible label"
577 );
578 assert_eq!(
579 named.label.as_deref(),
580 Some("Color"),
581 "and must not change what is drawn"
582 );
583 });
584 }
585}
586
587#[derive(IntoElement)]
588struct ColorPickerButton {
589 id: ElementId,
590 selected: bool,
591 icon: Option<Icon>,
592 value: Option<Hsla>,
593 size: Size,
594 label: Option<SharedString>,
595 tooltip: Option<SharedString>,
596}
597
598impl Selectable for ColorPickerButton {
599 fn selected(mut self, selected: bool) -> Self {
600 self.selected = selected;
601 self
602 }
603
604 fn is_selected(&self) -> bool {
605 self.selected
606 }
607}
608
609impl Sizable for ColorPickerButton {
610 fn with_size(mut self, size: impl Into<Size>) -> Self {
611 self.size = size.into();
612 self
613 }
614}
615
616impl RenderOnce for ColorPickerButton {
617 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
618 let has_icon = self.icon.is_some();
619 h_flex()
620 .id(self.id)
621 .gap_2()
622 .children(self.icon)
623 .when(!has_icon, |this| {
624 this.child(
625 div()
626 .id("square")
627 .bg(cx.theme().tokens.background)
628 .border_1()
629 .border_color(cx.theme().input)
630 .rounded(cx.theme().radius)
631 .overflow_hidden()
632 .size_with(self.size)
633 .when_some(self.value, |this, value| {
634 this.bg(value)
635 .border_color(value.darken(0.3))
636 .when(self.selected, |this| this.border_2())
637 })
638 .when_some(self.tooltip, |this, tooltip| {
639 this.managed_tooltip(move |window, cx| {
640 Tooltip::new(tooltip.clone()).build(window, cx)
641 })
642 }),
643 )
644 })
645 .when_some(self.label, |this, label| this.child(label))
646 }
647}