use crate::{ActiveTheme, ButtonVariant, button, input, slider};
use gpui_kit::base::{ColorPicker, ColorPickerState, Popup};
use gpui_kit::rems;
use gpui_kit::{App, ElementId, Entity, Focusable, Window, div, prelude::*};
pub fn color_picker(
id: impl Into<ElementId>,
state: &Entity<ColorPickerState>,
window: &mut Window,
cx: &mut App,
) -> ColorPicker {
let id = id.into();
state.update(cx, |state, cx| state.sync_pending_value(window, cx));
let current = state.read(cx);
let open = current.is_open();
let color = current.displayed_color();
let focus = current.focus_handle(cx);
let hex = current.hex_input().clone();
if !open && hex.read(cx).focus_handle(cx).is_focused(window) {
focus.focus(window, cx);
}
let t = cx.macos().clone();
let trigger = button("color-trigger", "Choose color", ButtonVariant::Outline, cx)
.debug_selector(|| "color-picker-trigger".into())
.focusable(false)
.child(
div()
.size(rems(1.))
.rounded(rems(0.25))
.border_1()
.border_color(t.border)
.bg(color.unwrap_or(t.background)),
)
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(gpui_kit::base::actions::Confirm { secondary: false }),
cx,
);
});
let mut popup = Popup::new((id.clone(), "popup"), trigger);
if open {
let render = state.clone();
let close = state.clone();
popup = popup
.offset(rems(0.25).to_pixels(window.rem_size()))
.content_factory(move |window, cx| color_content(&render, window, cx))
.on_dismiss(move |window, cx| {
close.update(cx, |state, cx| {
if state.is_open() {
restore_committed_color(state, window, cx);
state.set_open(false, cx);
}
state.focus_handle(cx).focus(window, cx);
})
});
}
let target = state.clone();
let return_focus = focus.clone();
ColorPicker::new(id)
.open(open)
.track_focus(&focus)
.rounded(rems(0.375))
.focus_visible(|s| s.shadow(t.focus_shadow()))
.accessibility_label("Choose color")
.on_open_change(move |open, window, cx| {
target.update(cx, |state, cx| {
if !open {
restore_committed_color(state, window, cx);
}
state.set_open(open, cx);
});
return_focus.focus(window, cx);
})
.child(popup)
}
fn color_content(
state: &Entity<ColorPickerState>,
window: &mut Window,
cx: &mut App,
) -> gpui_kit::Stateful<gpui_kit::Div> {
let current = state.read(cx);
let focus = current.focus_handle(cx);
let hex = current.hex_input().clone();
let channels = current.sliders().clone();
let hex_text = hex.read(cx).value().to_string();
let digits = hex_text.strip_prefix('#').unwrap_or(&hex_text);
let valid_hex = matches!(digits.len(), 3 | 4 | 6 | 8)
&& digits.bytes().all(|byte| byte.is_ascii_hexdigit());
let t = cx.macos().clone();
if crate::native::is_glass_popup(window) {
let first = window.use_keyed_state("color-native-focus", cx, |_, _| true);
if *first.read(cx) {
first.update(cx, |first, _| *first = false);
hex.update(cx, |hex, cx| hex.focus(window, cx));
}
}
let target = state.clone();
let return_focus = focus.clone();
let mut body = crate::popover::presented_popover_surface(window, cx)
.id("color-editor")
.debug_selector(|| "color-picker-popup".into())
.key_context("macOSPopoverContent")
.w(rems(17.5))
.flex()
.flex_col()
.gap(rems(0.625))
.on_mouse_down_out(move |_, window, cx| {
target.update(cx, |state, cx| {
restore_committed_color(state, window, cx);
state.set_open(false, cx);
});
return_focus.focus(window, cx);
})
.child("Hex color")
.child(input("color-hex", &hex, window, cx))
.when(!hex_text.is_empty() && !valid_hex, |body| {
body.child(
div()
.debug_selector(|| "color-hex-error".into())
.text_color(t.danger)
.child("Use 3, 4, 6 or 8 hexadecimal digits."),
)
});
for (name, channel) in [
("Hue", channels.hue()),
("Saturation", channels.saturation()),
("Lightness", channels.lightness()),
("Opacity", channels.alpha()),
] {
body = body.child(
div().flex().flex_col().gap(rems(0.25)).child(name).child(
div()
.debug_selector(move || format!("color-channel-{name}"))
.child(slider(channel, false, window, cx).px(rems(0.))),
),
);
}
let target = state.clone();
let return_focus = focus.clone();
body = body.child(
button("color-apply", "Apply color", ButtonVariant::Primary, cx)
.debug_selector(|| "color-apply".into())
.disabled(!valid_hex)
.on_click(move |_, window, cx| {
let committed = target.update(cx, |state, cx| {
let text = state.hex_input().read(cx).value().to_string();
state.commit_hex(&text, window, cx).is_some()
});
if committed {
return_focus.focus(window, cx);
}
}),
);
let close = state.clone();
body.on_action(move |_: &gpui_kit::base::actions::Cancel, window, cx| {
cx.stop_propagation();
close.update(cx, |state, cx| {
restore_committed_color(state, window, cx);
state.set_open(false, cx);
});
})
}
fn restore_committed_color(
state: &mut ColorPickerState,
window: &mut Window,
cx: &mut gpui_kit::Context<ColorPickerState>,
) {
if let Some(value) = state.value() {
state.set_value(value, window, cx);
} else {
state.clear_value(window, cx);
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui_kit::{Context, Render, TestAppContext};
struct Harness {
state: Entity<ColorPickerState>,
disabled: bool,
}
impl Render for Harness {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
crate::focus_scope("color-test")
.size_full()
.child(color_picker("test", &self.state, window, cx).disabled(self.disabled))
}
}
#[gpui_kit::test]
fn disabled_and_invalid_drafts_do_not_commit(cx: &mut TestAppContext) {
cx.update(crate::init);
let (view, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| {
ColorPickerState::new(window, cx).default_value(gpui_kit::hsla(0., 1., 0.5, 1.))
});
cx.observe(&state, |_, _, cx| cx.notify()).detach();
Harness {
state,
disabled: true,
}
});
cx.update(|window, cx| window.draw(cx).clear(cx));
let trigger = cx.debug_bounds("color-picker-trigger").unwrap().center();
cx.simulate_click(trigger, Default::default());
cx.simulate_keystrokes("enter");
cx.update(|_, cx| assert!(!view.read(cx).state.read(cx).is_open()));
view.update(cx, |this, cx| {
this.disabled = false;
cx.notify();
});
cx.update(|window, cx| window.draw(cx).clear(cx));
cx.simulate_click(trigger, Default::default());
cx.update(|window, cx| {
window.draw(cx).clear(cx);
let input = view.read(cx).state.read(cx).hex_input().clone();
input.update(cx, |input, cx| {
input.set_value("", window, cx);
input.focus(window, cx);
});
});
cx.simulate_input("#12");
cx.simulate_keystrokes("enter");
cx.update(|window, cx| {
window.draw(cx).clear(cx);
assert!(view.read(cx).state.read(cx).is_open());
});
assert!(cx.debug_bounds("color-hex-error").is_some());
cx.simulate_keystrokes("escape");
cx.update(|window, cx| {
window.draw(cx).clear(cx);
let state = view.read(cx).state.read(cx);
assert!(!state.is_open());
assert_eq!(state.hex_input().read(cx).value().as_ref(), "#FF0000");
});
cx.simulate_click(trigger, Default::default());
cx.update(|window, cx| window.draw(cx).clear(cx));
let opacity = cx.debug_bounds("color-channel-Opacity").unwrap();
cx.simulate_click(
gpui_kit::point(opacity.left() + opacity.size.width / 2., opacity.center().y),
Default::default(),
);
cx.update(|window, cx| {
window.draw(cx).clear(cx);
let state = view.read(cx).state.read(cx);
assert!(state.is_open());
assert!((state.value().unwrap().a - 0.5).abs() < 0.1);
});
cx.update(|window, cx| {
let hex = view.read(cx).state.read(cx).hex_input().clone();
hex.update(cx, |input, cx| {
input.set_value("", window, cx);
input.focus(window, cx);
});
});
cx.simulate_input("#00FF00");
cx.update(|window, cx| window.draw(cx).clear(cx));
let apply = cx.debug_bounds("color-apply").unwrap().center();
cx.simulate_click(apply, Default::default());
cx.update(|window, cx| {
window.draw(cx).clear(cx);
let state = view.read(cx).state.read(cx);
assert!(!state.is_open());
assert_eq!(state.value(), Some(gpui_kit::hsla(1. / 3., 1., 0.5, 1.)));
assert!(state.focus_handle(cx).is_focused(window));
});
}
}