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, 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(
133 SharedString::from(format!("color-{}", color.to_hex())),
134 color,
135 )
136 .selected(selected)
137 .h_5()
138 .w_5()
139 .bg(color)
140 .border_1()
141 .border_color(color.darken(0.1))
142 .hover(|this| this.border_color(color.darken(0.3)).bg(color.lighten(0.1)))
143 .active(|this| this.border_color(color.darken(0.5)).bg(color.darken(0.2)))
144 .on_hover(move |color, entered, window, cx| {
145 if entered {
146 hover_state.update(cx, |state, cx| state.preview_color(color, window, cx));
147 }
148 })
149 .on_click(move |color, _, window, cx| {
150 click_state.update(cx, |state, cx| state.select_color(color, window, cx));
151 })
152 }
153
154 fn render_colors(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
155 self.state
156 .update(cx, |state, cx| state.sync_pending_value(window, cx));
157
158 let active_tab = self.state.read(cx).active_tab();
159 let (slider_color, hovered_color) = {
160 let state = self.state.read(cx);
161 let slider_color = state
162 .displayed_color()
163 .unwrap_or_else(|| hsla(0., 0., 0., 1.));
164 (slider_color, state.preview())
165 };
166 let tab_state = self.state.clone();
167
168 v_flex()
169 .p_0p5()
170 .gap_3()
171 .child(
172 TabBar::new("mode")
173 .segmented()
174 .selected_index(active_tab)
175 .on_click(move |ix: &usize, _, cx| {
176 tab_state.update(cx, |state, cx| state.set_active_tab(*ix, cx));
177 })
178 .child(Tab::new().flex_1().label(t!("ColorPicker.Palette")))
179 .child(Tab::new().flex_1().label(t!("ColorPicker.HSLA"))),
180 )
181 .child(match active_tab {
182 0 => self.render_palette_panel(cx).into_any_element(),
183 _ => self
184 .render_slider_tab_panel(slider_color, cx)
185 .into_any_element(),
186 })
187 .when_some(hovered_color, |this, hovered_color| {
188 this.child(Separator::horizontal()).child(
189 h_flex()
190 .gap_2()
191 .items_center()
192 .child(
193 div()
194 .bg(hovered_color)
195 .flex_shrink_0()
196 .border_1()
197 .border_color(hovered_color.darken(0.2))
198 .size_5()
199 .rounded(cx.theme().radius),
200 )
201 .child(Input::new(self.state.read(cx).hex_input()).small().px_2p5()),
202 )
203 })
204 }
205
206 fn render_palette_panel(&self, cx: &mut App) -> impl IntoElement {
207 let featured_colors = self.featured_colors.clone().unwrap_or(vec![
208 cx.theme().red,
209 cx.theme().red_light,
210 cx.theme().blue,
211 cx.theme().blue_light,
212 cx.theme().green,
213 cx.theme().green_light,
214 cx.theme().yellow,
215 cx.theme().yellow_light,
216 cx.theme().cyan,
217 cx.theme().cyan_light,
218 cx.theme().magenta,
219 cx.theme().magenta_light,
220 ]);
221
222 v_flex()
223 .gap_3()
224 .child(
225 h_flex().gap_1().children(
226 featured_colors
227 .iter()
228 .map(|color| self.render_item(*color, cx)),
229 ),
230 )
231 .child(Separator::horizontal())
232 .child(
233 v_flex()
234 .gap_1()
235 .children(color_palettes().iter().map(|sub_colors| {
236 h_flex().gap_1().children(
237 sub_colors
238 .iter()
239 .rev()
240 .map(|color| self.render_item(*color, cx)),
241 )
242 })),
243 )
244 }
245
246 fn render_slider_tab_panel(&self, slider_color: Hsla, cx: &mut App) -> impl IntoElement {
247 let sliders = self.state.read(cx).sliders().clone();
248 let steps = 96usize;
249 let hue_colors = (0..steps)
250 .map(|ix| {
251 let h = ix as f32 / (steps.saturating_sub(1)) as f32;
252 hsla(h, 1.0, 0.5, 1.0)
253 })
254 .collect::<Vec<_>>();
255 let saturation_start = hsla(slider_color.h, 0.0, slider_color.l, 1.0);
256 let saturation_end = hsla(slider_color.h, 1.0, slider_color.l, 1.0);
257 let lightness_colors = (0..steps)
258 .map(|ix| {
259 let l = ix as f32 / (steps.saturating_sub(1)) as f32;
260 hsla(slider_color.h, 1.0, l, 1.0)
261 })
262 .collect::<Vec<_>>();
263 let alpha_start = hsla(slider_color.h, slider_color.s, slider_color.l, 0.0);
264 let alpha_end = hsla(slider_color.h, slider_color.s, slider_color.l, 1.0);
265
266 let label_color = cx.theme().foreground.opacity(0.7);
267
268 v_flex()
269 .gap_2()
270 .child(
271 h_flex()
272 .gap_2()
273 .items_center()
274 .child(
275 div()
276 .min_w_16()
277 .text_xs()
278 .text_color(label_color)
279 .child(t!("ColorPicker.Hue")),
280 )
281 .child(
282 div()
283 .relative()
284 .flex()
285 .items_center()
286 .flex_1()
287 .h_8()
288 .child(self.render_slider_track(hue_colors, cx))
289 .child(
290 Slider::new(sliders.hue())
291 .flex_1()
292 .bg(cx.theme().transparent),
293 ),
294 )
295 .child(
296 div()
297 .w_10()
298 .text_xs()
299 .text_color(label_color)
300 .text_align(TextAlign::Right)
301 .child(format!("{:.0}", slider_color.h * 360.)),
302 ),
303 )
304 .child(
305 h_flex()
306 .gap_2()
307 .items_center()
308 .child(
309 div()
310 .min_w_16()
311 .text_xs()
312 .text_color(label_color)
313 .child(t!("ColorPicker.Saturation")),
314 )
315 .child(
316 div()
317 .relative()
318 .flex()
319 .items_center()
320 .flex_1()
321 .h_8()
322 .child(self.render_slider_track_gradient(
323 saturation_start,
324 saturation_end,
325 cx,
326 ))
327 .child(
328 Slider::new(sliders.saturation())
329 .flex_1()
330 .bg(cx.theme().transparent),
331 ),
332 )
333 .child(
334 div()
335 .w_10()
336 .text_xs()
337 .text_color(label_color)
338 .text_align(TextAlign::Right)
339 .child(format!("{:.0}", slider_color.s * 100.)),
340 ),
341 )
342 .child(
343 h_flex()
344 .gap_2()
345 .items_center()
346 .child(
347 div()
348 .min_w_16()
349 .text_xs()
350 .text_color(label_color)
351 .child(t!("ColorPicker.Lightness")),
352 )
353 .child(
354 div()
355 .relative()
356 .flex()
357 .items_center()
358 .flex_1()
359 .h_8()
360 .child(self.render_slider_track(lightness_colors, cx))
361 .child(
362 Slider::new(sliders.lightness())
363 .flex_1()
364 .bg(cx.theme().transparent),
365 ),
366 )
367 .child(
368 div()
369 .w_10()
370 .text_xs()
371 .text_color(label_color)
372 .text_align(TextAlign::Right)
373 .child(format!("{:.0}", slider_color.l * 100.)),
374 ),
375 )
376 .child(
377 h_flex()
378 .gap_2()
379 .items_center()
380 .child(
381 div()
382 .min_w_16()
383 .text_xs()
384 .text_color(label_color)
385 .child(t!("ColorPicker.Alpha")),
386 )
387 .child(
388 div()
389 .relative()
390 .flex()
391 .items_center()
392 .flex_1()
393 .h_8()
394 .child(self.render_slider_track_gradient(alpha_start, alpha_end, cx))
395 .child(
396 Slider::new(sliders.alpha())
397 .flex_1()
398 .bg(cx.theme().transparent),
399 ),
400 )
401 .child(
402 div()
403 .w_10()
404 .text_xs()
405 .text_color(label_color)
406 .text_align(TextAlign::Right)
407 .child(format!("{:.0}", slider_color.a * 100.)),
408 ),
409 )
410 }
411
412 fn render_slider_track(&self, colors: Vec<Hsla>, _: &App) -> impl IntoElement {
413 h_flex()
414 .absolute()
415 .left_0()
416 .right_0()
417 .h_2_5()
418 .overflow_hidden()
419 .children(
420 colors
421 .into_iter()
422 .map(|color| div().flex_1().h_full().bg(color)),
423 )
424 }
425
426 fn render_slider_track_gradient(&self, start: Hsla, end: Hsla, _: &App) -> impl IntoElement {
427 div()
428 .absolute()
429 .left_0()
430 .right_0()
431 .h_2_5()
432 .overflow_hidden()
433 .bg(linear_gradient(
434 90.,
435 linear_color_stop(start, 0.),
436 linear_color_stop(end, 1.),
437 ))
438 }
439}
440
441impl Sizable for ColorPicker {
442 fn with_size(mut self, size: impl Into<Size>) -> Self {
443 self.size = size.into();
444 self
445 }
446}
447
448impl Focusable for ColorPicker {
449 fn focus_handle(&self, cx: &App) -> FocusHandle {
450 self.state.focus_handle(cx)
451 }
452}
453
454impl Styled for ColorPicker {
455 fn style(&mut self) -> &mut StyleRefinement {
456 &mut self.style
457 }
458}
459
460impl RenderOnce for ColorPicker {
461 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
462 let state = self.state.read(cx);
463 let display_title: SharedString = if let Some(value) = state.value() {
464 value.to_hex()
465 } else {
466 "".to_string()
467 }
468 .into();
469
470 let open = state.is_open();
471 let value = state.value();
472 let focus_handle = self.state.focus_handle(cx);
473 let open_state = self.state.clone();
474 let popover_state = self.state.clone();
475
476 BaseColorPicker::new(self.id.clone())
477 .open(open)
478 .track_focus(&focus_handle)
479 .when_some(
480 self.accessibility_label
481 .clone()
482 .or_else(|| self.label.clone()),
483 |this, label| this.accessibility_label(label),
484 )
485 .on_open_change(move |open, _, cx| {
486 open_state.update(cx, |state, cx| state.set_open(open, cx));
487 })
488 .child(
489 Popover::new("popover")
490 .open(open)
491 .w_72()
492 .on_open_change(move |open: &bool, _, cx| {
493 popover_state.update(cx, |state, cx| state.set_open(*open, cx));
494 })
495 .trigger(ColorPickerButton {
496 id: "trigger".into(),
497 size: self.size,
498 label: self.label.clone(),
499 value,
500 tooltip: if display_title.is_empty() {
501 None
502 } else {
503 Some(display_title.clone())
504 },
505 icon: self.icon.clone(),
506 selected: false,
507 })
508 .child(self.render_colors(window, cx)),
509 )
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use gpui::{AppContext as _, TestAppContext};
516
517 use super::*;
518
519 #[gpui::test]
520 fn an_explicit_accessibility_label_replaces_the_visible_one(cx: &mut TestAppContext) {
521 cx.update(crate::init);
522 let cx = cx.add_empty_window();
523 cx.update(|window, cx| {
524 let state = cx.new(|cx| ColorPickerState::new(window, cx));
525
526 let plain = ColorPicker::new(&state).label("Color");
527 assert_eq!(plain.accessibility_label, None);
528 assert_eq!(plain.label.as_deref(), Some("Color"));
529
530 let named = ColorPicker::new(&state)
531 .label("Color")
532 .accessibility_label("Text color");
533 assert_eq!(
534 named.accessibility_label.as_deref(),
535 Some("Text color"),
536 "an explicit name must win over the visible label"
537 );
538 assert_eq!(
539 named.label.as_deref(),
540 Some("Color"),
541 "and must not change what is drawn"
542 );
543 });
544 }
545}
546
547#[derive(IntoElement)]
548struct ColorPickerButton {
549 id: ElementId,
550 selected: bool,
551 icon: Option<Icon>,
552 value: Option<Hsla>,
553 size: Size,
554 label: Option<SharedString>,
555 tooltip: Option<SharedString>,
556}
557
558impl Selectable for ColorPickerButton {
559 fn selected(mut self, selected: bool) -> Self {
560 self.selected = selected;
561 self
562 }
563
564 fn is_selected(&self) -> bool {
565 self.selected
566 }
567}
568
569impl Sizable for ColorPickerButton {
570 fn with_size(mut self, size: impl Into<Size>) -> Self {
571 self.size = size.into();
572 self
573 }
574}
575
576impl RenderOnce for ColorPickerButton {
577 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
578 let has_icon = self.icon.is_some();
579 h_flex()
580 .id(self.id)
581 .gap_2()
582 .children(self.icon)
583 .when(!has_icon, |this| {
584 this.child(
585 div()
586 .id("square")
587 .bg(cx.theme().tokens.background)
588 .border_1()
589 .border_color(cx.theme().input)
590 .rounded(cx.theme().radius)
591 .overflow_hidden()
592 .size_with(self.size)
593 .when_some(self.value, |this, value| {
594 this.bg(value)
595 .border_color(value.darken(0.3))
596 .when(self.selected, |this| this.border_2())
597 })
598 .when_some(self.tooltip, |this, tooltip| {
599 this.managed_tooltip(move |window, cx| {
600 Tooltip::new(tooltip.clone()).build(window, cx)
601 })
602 }),
603 )
604 })
605 .when_some(self.label, |this, label| this.child(label))
606 }
607}