diff --git a/AGENTS.md b/AGENTS.md
index 2569b0d..1ee590d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -47,6 +47,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- An overlay takes focus with `primitives::take_focus` and returns it with `give_back` on every close path.
- A state change made outside render calls `cx.notify()`, or nothing redraws.
- The macOS share picker draws its content in another process; a capture shows only its frame.
+- NSColorSampler runs in another process too. The harness cannot pick or cancel with it; `read_srgb` is tested on a constructed `NSColor` instead.
- Every text field is a `forms::TextInput` entity. Its keys live under the `ElyInput` context, bound in `init`. Wrappers take Up, Down, Enter and Backspace first with `capture_action`.
- `TextInput::bounds_for` reads the last layout. Text changed this frame clamps to the laid-out end until the next paint.
- The harness types with `dispatch_keystroke` through `AnyWindowHandle`, which leaves the root view free to redraw.
@@ -56,6 +57,9 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger.
- A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`).
- Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint.
+- Thumbs stay inside their component's box: the track is padded by half a thumb (`Slider`, `ColorPicker`, `GradientEditor`).
+- gpui's `Hsla::opacity` scales alpha; `alpha` sets it.
+- A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one.
- Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones.
- Ids inside a reusable component carry its owner's id or `EntityId`. Twin ids share focus and click state.
- Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders.
diff --git a/Cargo.lock b/Cargo.lock
index 9a5c720..50cddf6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1543,6 +1543,7 @@ name = "ely-gpui-component"
version = "0.1.0"
dependencies = [
"anyhow",
+ "block",
"cocoa 0.26.0",
"core-foundation 0.10.0",
"core-graphics 0.24.0",
diff --git a/Cargo.toml b/Cargo.toml
index 954318a..fbc100f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,6 +27,7 @@ smallvec = "1"
unicode-segmentation = "1"
[target.'cfg(target_os = "macos")'.dependencies]
+block = "0.1"
cocoa = "0.26"
objc = "0.2"
raw-window-handle = "0.6"
diff --git a/TASKS.md b/TASKS.md
index 1ed211e..d60e703 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -25,7 +25,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T06a Forms · Text
- [x] T06b Forms · Selection
- [x] T06c Forms · Date & Time
-- [ ] T06d Forms · Color
+- [x] T06d Forms · Color
- [ ] T06e Forms · Files
- [ ] T06f Forms · Other
- [ ] T06g Forms · Structure
@@ -128,3 +128,5 @@ Per-component lines live in `tasks/`. Tags there:
| T06c | 1 | FAIL | range preview reseeded the month; disabled pickers still committed; month buttons left the cursor; hour column scrolled before layout; `*/1` day fields were not wildcards; next(0) ran on; clock fell back to UTC; zone offsets went stale; "never runs" overclaimed |
| T06c | 2 | FAIL | the description of an unstarred full day field ignored the OR rule |
| T06c | 3 | PASS | |
+| T06d | 1 | FAIL | a zero-size checker hung the paint loop; a dragged stop lost its grip after passing another; `opacity(1.0)` kept alpha, so opaque pickers stayed see-through; inset thumbs drifted from the drawn gradients; swatch fills and checkers crossed rounded corners; the thumb ring was a literal white |
+| T06d | 2 | PASS | |
diff --git a/assets/icons/pipette.svg b/assets/icons/pipette.svg
new file mode 100644
index 0000000..dbf7c72
--- /dev/null
+++ b/assets/icons/pipette.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/examples/gallery/pages/forms/colors.rs b/examples/gallery/pages/forms/colors.rs
new file mode 100644
index 0000000..36d95bd
--- /dev/null
+++ b/examples/gallery/pages/forms/colors.rs
@@ -0,0 +1,140 @@
+use ely_gpui_component::{
+ forms::{ColorPalette, ColorPicker, ColorSwatch, EyeDropper, GradientEditor, GradientStop},
+ theme::{ActiveTheme, ControlSize},
+};
+use gpui::{App, Hsla, IntoElement, ParentElement, Styled, Window, div, px, rgb, rgba};
+
+use super::choices::{keep, set};
+use crate::{
+ probe::probe,
+ ui::{code, section, specimen, specimens},
+};
+
+fn hex(color: Hsla) -> String {
+ let rgba = color.to_rgb();
+ let byte = |value: f32| (value * 255.0).round() as u8;
+ format!(
+ "#{:02x}{:02x}{:02x}",
+ byte(rgba.r),
+ byte(rgba.g),
+ byte(rgba.b)
+ )
+}
+
+pub fn picker(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let color = keep("color-value", || Hsla::from(rgb(0x3772bb)), window, cx);
+ let now = *color.read(cx);
+ section(
+ "ColorPicker",
+ "Saturation and value on the plane, hue and alpha on the rails. Type it as hex, RGB or HSV; drag a letter to scrub.",
+ cx,
+ )
+ .child(specimen(
+ format!("{} at {:.0}%", hex(now), now.a * 100.0),
+ probe(
+ "color-picker",
+ div().w(px(260.0)).child(
+ ColorPicker::new("color-value", now)
+ .on_change(move |next, _, cx| set(&color, next, cx)),
+ ),
+ ),
+ cx,
+ ))
+}
+
+pub fn swatches(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let chart = cx.theme().colors.chart;
+ let names = [
+ "Blue", "Teal", "Ochre", "Rose", "Violet", "Green", "Clay", "Cyan",
+ ];
+ let named: Vec<(&str, Hsla)> = names.into_iter().zip(chart).collect();
+ let chosen = keep("palette-color", || chart[0], window, cx);
+ let (now, picked) = (
+ *chosen.read(cx),
+ keep("dropper-color", || None::, window, cx),
+ );
+ let dropped = *picked.read(cx);
+ section(
+ "ColorSwatch / ColorPalette / EyeDropper",
+ "Named patches; see-through colors sit on a checkerboard. The dropper reads the screen.",
+ cx,
+ )
+ .child(
+ specimens()
+ .child(specimen(
+ "palette",
+ probe(
+ "palette",
+ ColorPalette::new("palette-color", named)
+ .selected(now)
+ .on_change(move |next, _, cx| set(&chosen, next, cx)),
+ ),
+ cx,
+ ))
+ .child(specimen(
+ "swatches",
+ div()
+ .flex()
+ .gap_2()
+ .child(ColorSwatch::new("swatch-solid", rgb(0x181613)).size(ControlSize::Md))
+ .child(ColorSwatch::new("swatch-glass", rgba(0x3772bb66)).size(ControlSize::Md))
+ .child(
+ ColorSwatch::new("swatch-chosen", rgb(0x267b4c))
+ .size(ControlSize::Md)
+ .selected(true),
+ ),
+ cx,
+ ))
+ .child(specimen(
+ dropped.map_or("eye dropper".to_string(), hex),
+ div()
+ .flex()
+ .items_center()
+ .gap_2()
+ .child(probe(
+ "dropper",
+ EyeDropper::new("eye-dropper")
+ .on_pick(move |next, _, cx| set(&picked, Some(next), cx)),
+ ))
+ .children(
+ dropped
+ .map(|color| ColorSwatch::new("dropped", color).size(ControlSize::Md)),
+ ),
+ cx,
+ )),
+ )
+}
+
+pub fn gradient(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let stops = keep(
+ "gradient-stops",
+ || {
+ vec![
+ GradientStop {
+ at: 0.0,
+ color: rgb(0x181613).into(),
+ },
+ GradientStop {
+ at: 1.0,
+ color: rgb(0xe9e7e5).into(),
+ },
+ ]
+ },
+ window,
+ cx,
+ );
+ let now = stops.read(cx).clone();
+ section(
+ "GradientEditor",
+ "Click the bar to add a stop, drag stops to move them, Delete removes the chosen one.",
+ cx,
+ )
+ .child(probe(
+ "gradient",
+ div().w(px(360.0)).child(
+ GradientEditor::new("gradient-stops", now)
+ .on_change(move |next, _, cx| set(&stops, next.to_vec(), cx)),
+ ),
+ ))
+ .child(code("GradientStop { at: 0.5, color }", cx))
+}
diff --git a/examples/gallery/pages/forms/mod.rs b/examples/gallery/pages/forms/mod.rs
index 4bdf8d7..6d8528d 100644
--- a/examples/gallery/pages/forms/mod.rs
+++ b/examples/gallery/pages/forms/mod.rs
@@ -1,4 +1,5 @@
mod choices;
+mod colors;
mod dates;
mod formats;
mod numbers;
@@ -216,6 +217,26 @@ const SCRIPT: &[Step] = &[
Step::Type("*/15 9-17 * * 1-5"),
Step::Wait(200),
Step::Shot("cron"),
+ Step::DownAt("color-picker", 60.0, 40.0),
+ Step::DragTo("color-picker", 200.0, 60.0),
+ Step::UpAt("color-picker", 200.0, 60.0),
+ Step::Wait(300),
+ Step::Shot("color"),
+ Step::DownAt("color-picker", 100.0, 228.0),
+ Step::UpAt("color-picker", 100.0, 228.0),
+ Step::Wait(200),
+ Step::Key("down"),
+ Step::Key("enter"),
+ Step::Wait(300),
+ Step::Shot("color-rgb"),
+ Step::DownAt("palette", 50.0, 14.0),
+ Step::UpAt("palette", 50.0, 14.0),
+ Step::Wait(300),
+ Step::Shot("swatches"),
+ Step::DownAt("gradient", 108.0, 16.0),
+ Step::UpAt("gradient", 108.0, 16.0),
+ Step::Wait(300),
+ Step::Shot("gradient"),
Step::Rest,
];
@@ -259,5 +280,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(dates::periods(window, cx))
.child(dates::more(window, cx))
.child(dates::cron(window, cx))
+ .child(colors::picker(window, cx))
+ .child(colors::swatches(window, cx))
+ .child(colors::gradient(window, cx))
.into_any_element()
}
diff --git a/scripts/check.sh b/scripts/check.sh
index 0bdbeb1..4270d67 100755
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -12,7 +12,7 @@ for f in $files AGENTS.md README.md TASKS.md tasks/*.md; do
n=$(wc -l < "$f")
[ "$n" -le 500 ] || { echo "$f has $n lines"; fail=1; }
done
-components=$(find src -name '*.rs' ! -path 'src/theme/tokens.rs' ! -path 'src/motion/*')
+components=$(find src -name '*.rs' ! -path 'src/theme/tokens.rs' ! -path 'src/motion/*' ! -path '*/tests/*')
raw=$(awk '/#\[cfg\(test\)\]/{nextfile} /(^|[^.[:alnum:]_])px\(/{print FILENAME":"FNR": "$0}' $components)
[ -z "$raw" ] || { echo "raw px in components:"; echo "$raw"; fail=1; }
exit $fail
diff --git a/src/forms/color/dropper.rs b/src/forms/color/dropper.rs
new file mode 100644
index 0000000..aadc48c
--- /dev/null
+++ b/src/forms/color/dropper.rs
@@ -0,0 +1,134 @@
+use std::rc::Rc;
+
+use gpui::{AnyWindowHandle, App, AsyncApp, ElementId, Hsla, IntoElement, RenderOnce, Window};
+
+use crate::{
+ buttons::{ButtonVariant, IconButton},
+ primitives::IconName,
+};
+
+type OnColor = Rc;
+
+/// A button that picks a color from anywhere on screen. macOS only.
+#[derive(IntoElement)]
+pub struct EyeDropper {
+ id: ElementId,
+ on_pick: Option,
+}
+
+impl EyeDropper {
+ pub fn new(id: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ on_pick: None,
+ }
+ }
+
+ pub fn on_pick(mut self, handler: impl Fn(Hsla, &mut Window, &mut App) + 'static) -> Self {
+ self.on_pick = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for EyeDropper {
+ fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
+ let on_pick = self.on_pick;
+ let available = cfg!(target_os = "macos");
+ IconButton::new(self.id, IconName::Pipette)
+ .variant(ButtonVariant::Ghost)
+ .tooltip(if available {
+ "Pick a color from the screen"
+ } else {
+ "Picking from the screen needs macOS"
+ })
+ .disabled(!available)
+ .on_click(move |_, window, cx| {
+ sample(window.window_handle(), cx.to_async(), on_pick.clone())
+ })
+ }
+}
+
+/// An `NSColor` as sRGB, or `None` when AppKit cannot convert it.
+#[cfg(target_os = "macos")]
+unsafe fn read_srgb(color: cocoa::base::id) -> Option {
+ use cocoa::base::{id, nil};
+ use objc::{class, msg_send, sel, sel_impl};
+ unsafe {
+ let space: id = msg_send![class!(NSColorSpace), sRGBColorSpace];
+ let srgb: id = msg_send![color, colorUsingColorSpace: space];
+ if srgb == nil {
+ return None;
+ }
+ let (r, g, b, a): (f64, f64, f64, f64) = (
+ msg_send![srgb, redComponent],
+ msg_send![srgb, greenComponent],
+ msg_send![srgb, blueComponent],
+ msg_send![srgb, alphaComponent],
+ );
+ Some(gpui::Rgba {
+ r: r as f32,
+ g: g as f32,
+ b: b as f32,
+ a: a as f32,
+ })
+ }
+}
+
+/// Opens AppKit's color sampler; its answer comes back on the main thread.
+#[cfg(target_os = "macos")]
+fn sample(window: AnyWindowHandle, cx: AsyncApp, on_pick: Option) {
+ use block::ConcreteBlock;
+ use cocoa::base::{id, nil};
+ use objc::{class, msg_send, sel, sel_impl};
+
+ log::info!("eye dropper: sampling");
+ unsafe {
+ let sampler: id = msg_send![class!(NSColorSampler), new];
+ let handler = ConcreteBlock::new(move |color: id| {
+ let _: () = msg_send![sampler, release];
+ if color == nil {
+ log::info!("eye dropper: cancelled");
+ return;
+ }
+ let Some(rgba) = read_srgb(color) else {
+ log::error!("eye dropper: the color has no sRGB form");
+ return;
+ };
+ log::info!("eye dropper: picked {}", super::hex(rgba));
+ let Some(on_pick) = on_pick.clone() else {
+ return;
+ };
+ let mut cx = cx.clone();
+ if let Err(error) =
+ window.update(&mut cx, |_, window, cx| on_pick(rgba.into(), window, cx))
+ {
+ log::error!("eye dropper: the window closed first: {error:#}");
+ }
+ })
+ .copy();
+ let _: () = msg_send![sampler, showSamplerWithSelectionHandler: &*handler];
+ }
+}
+
+#[cfg(not(target_os = "macos"))]
+fn sample(_: AnyWindowHandle, _: AsyncApp, _: Option) {
+ unreachable!("the eye dropper stays disabled off macOS");
+}
+
+#[cfg(all(test, target_os = "macos"))]
+mod tests {
+ use cocoa::base::id;
+ use objc::{class, msg_send, sel, sel_impl};
+
+ use super::read_srgb;
+
+ #[test]
+ fn a_display_p3_color_reads_back_as_srgb() {
+ let color: id = unsafe {
+ msg_send![class!(NSColor), colorWithDisplayP3Red: 1.0f64 green: 0.0f64 blue: 0.0f64 alpha: 0.5f64]
+ };
+ let srgb = unsafe { read_srgb(color) }.expect("P3 red converts to sRGB");
+ assert!(srgb.r > 0.99 && srgb.g < 0.01 && srgb.b < 0.01, "{srgb:?}");
+ assert!((srgb.a - 0.5).abs() < 1e-6);
+ }
+}
diff --git a/src/forms/color/fields.rs b/src/forms/color/fields.rs
new file mode 100644
index 0000000..bd6ed2a
--- /dev/null
+++ b/src/forms/color/fields.rs
@@ -0,0 +1,150 @@
+use std::rc::Rc;
+
+use gpui::{App, Div, ElementId, Entity, ParentElement, SharedString, Styled, Window, div};
+
+use super::{
+ super::{Choice, Input, ScrubInput, Select, TextInput},
+ Hsva,
+};
+use crate::theme::{ActiveTheme, ControlSize};
+
+/// How a color picker's typed fields spell the color.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub(crate) enum Format {
+ #[default]
+ Hex,
+ Rgb,
+ Hsv,
+}
+
+impl Format {
+ const ALL: [Self; 3] = [Self::Hex, Self::Rgb, Self::Hsv];
+
+ fn label(self) -> &'static str {
+ match self {
+ Self::Hex => "HEX",
+ Self::Rgb => "RGB",
+ Self::Hsv => "HSV",
+ }
+ }
+}
+
+pub(crate) type Commit = Rc;
+pub(crate) type SetFormat = Rc;
+
+/// A select of the three formats.
+pub(crate) fn format_select(id: &ElementId, format: Format, set: SetFormat) -> Select {
+ Select::new(
+ (id.clone(), "format"),
+ Format::ALL.map(|format| Choice::new(format.label(), format.label())),
+ )
+ .selected(format.label())
+ .size(ControlSize::Md)
+ .on_change(move |label: &SharedString, window, cx| {
+ let format = Format::ALL
+ .into_iter()
+ .find(|format| format.label() == label.as_ref())
+ .expect("a label from the list");
+ log::info!("color picker: typed as {}", format.label());
+ set(format, window, cx);
+ })
+}
+
+/// One labeled number of the RGB or HSV stack.
+fn part(
+ id: ElementId,
+ label: &'static str,
+ value: f32,
+ max: f32,
+ set: impl Fn(f32) -> Hsva + 'static,
+ commit: Commit,
+) -> ScrubInput {
+ ScrubInput::new(id, label, f64::from(value.round()))
+ .range(0.0, f64::from(max))
+ .on_change(move |value, window, cx| commit(set(value as f32), window, cx))
+}
+
+/// The fields for `format`: one hex field, or three numbers.
+pub(crate) fn typed_fields(
+ id: &ElementId,
+ format: Format,
+ color: Hsva,
+ hex_field: &Entity,
+ commit: Commit,
+ cx: &App,
+) -> Div {
+ let stack = div().flex().flex_col().gap_1();
+ match format {
+ Format::Hex => stack.child(
+ div()
+ .font_family(cx.theme().mono_family.clone())
+ .child(Input::new(hex_field)),
+ ),
+ Format::Rgb => {
+ let rgba = color.to_rgba();
+ let channel = move |pick: fn(&mut gpui::Rgba) -> &mut f32| {
+ move |value: f32| {
+ let mut next = rgba;
+ *pick(&mut next) = value / 255.0;
+ Hsva::from_rgba(next, color.h)
+ }
+ };
+ stack
+ .child(part(
+ (id.clone(), "red").into(),
+ "R",
+ rgba.r * 255.0,
+ 255.0,
+ channel(|c| &mut c.r),
+ commit.clone(),
+ ))
+ .child(part(
+ (id.clone(), "green").into(),
+ "G",
+ rgba.g * 255.0,
+ 255.0,
+ channel(|c| &mut c.g),
+ commit.clone(),
+ ))
+ .child(part(
+ (id.clone(), "blue").into(),
+ "B",
+ rgba.b * 255.0,
+ 255.0,
+ channel(|c| &mut c.b),
+ commit,
+ ))
+ }
+ Format::Hsv => stack
+ .child(part(
+ (id.clone(), "hue").into(),
+ "H",
+ color.h,
+ 360.0,
+ move |h| Hsva { h, ..color },
+ commit.clone(),
+ ))
+ .child(part(
+ (id.clone(), "saturation").into(),
+ "S",
+ color.s * 100.0,
+ 100.0,
+ move |s| Hsva {
+ s: s / 100.0,
+ ..color
+ },
+ commit.clone(),
+ ))
+ .child(part(
+ (id.clone(), "value").into(),
+ "V",
+ color.v * 100.0,
+ 100.0,
+ move |v| Hsva {
+ v: v / 100.0,
+ ..color
+ },
+ commit,
+ )),
+ }
+}
diff --git a/src/forms/color/gradient.rs b/src/forms/color/gradient.rs
new file mode 100644
index 0000000..027bc57
--- /dev/null
+++ b/src/forms/color/gradient.rs
@@ -0,0 +1,334 @@
+use std::rc::Rc;
+
+use gpui::{
+ App, Bounds, DragMoveEvent, ElementId, EmptyView, Entity, EntityId, Hsla, InteractiveElement,
+ IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, StatefulInteractiveElement,
+ Styled, Window, canvas, div, linear_color_stop, linear_gradient, prelude::*, relative,
+};
+
+use super::{super::check::tab_stop, ColorPicker, swatch::checker};
+use crate::{
+ layout::seeded::{Seeded, use_seeded},
+ theme::{ActiveTheme, ControlSize, Mix, Radius},
+};
+
+/// A color at a place along a gradient, from 0 to 1.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct GradientStop {
+ pub at: f32,
+ pub color: Hsla,
+}
+
+/// The color at `t` along stops sorted by place.
+pub(crate) fn color_at(stops: &[GradientStop], t: f32) -> Hsla {
+ let first = stops.first().expect("a gradient has stops");
+ if t <= first.at {
+ return first.color;
+ }
+ for pair in stops.windows(2) {
+ let (from, to) = (pair[0], pair[1]);
+ if t <= to.at {
+ let span = (to.at - from.at).max(f32::EPSILON);
+ return from.color.mix(&to.color, (t - from.at) / span);
+ }
+ }
+ stops.last().expect("a gradient has stops").color
+}
+
+/// `stops` with `ix` moved to `t`, still sorted, and where `ix` landed.
+pub(crate) fn moved(stops: &[GradientStop], ix: usize, t: f32) -> (Vec, usize) {
+ let mut next = stops.to_vec();
+ let mut stop = next.remove(ix);
+ stop.at = t.clamp(0.0, 1.0);
+ let landed = next.partition_point(|other| other.at <= stop.at);
+ next.insert(landed, stop);
+ (next, landed)
+}
+
+/// `stops` with a new stop at `t`, colored as the gradient is there.
+pub(crate) fn added(stops: &[GradientStop], t: f32) -> (Vec, usize) {
+ let stop = GradientStop {
+ at: t.clamp(0.0, 1.0),
+ color: color_at(stops, t),
+ };
+ let mut next = stops.to_vec();
+ let landed = next.partition_point(|other| other.at <= stop.at);
+ next.insert(landed, stop);
+ (next, landed)
+}
+
+/// A stop handle being dragged; the grabbed stop is the chosen one.
+struct Handle {
+ owner: EntityId,
+}
+
+type OnStops = Rc;
+type SetStops = Rc, usize, &mut Window, &mut App)>;
+type Live = Entity>>;
+
+/// The stops and the chosen index as they stand when a handler runs.
+fn now(live: &Live, chosen: &Entity, cx: &App) -> (Vec, usize) {
+ let stops = live.read(cx).value.clone();
+ let at = (*chosen.read(cx)).min(stops.len() - 1);
+ (stops, at)
+}
+
+/// A gradient bar with stops beneath. Drag a stop, click the bar to add one, Delete removes it.
+#[derive(IntoElement)]
+pub struct GradientEditor {
+ id: ElementId,
+ stops: Vec,
+ on_change: Option,
+}
+
+impl GradientEditor {
+ pub fn new(id: impl Into, stops: impl IntoIterator- ) -> Self {
+ let stops: Vec = stops.into_iter().collect();
+ assert!(stops.len() >= 2, "a gradient needs two stops");
+ assert!(
+ stops.windows(2).all(|pair| pair[0].at <= pair[1].at)
+ && stops.iter().all(|stop| (0.0..=1.0).contains(&stop.at)),
+ "gradient stops must be sorted within 0 to 1"
+ );
+ Self {
+ id: id.into(),
+ stops,
+ on_change: None,
+ }
+ }
+
+ pub fn on_change(
+ mut self,
+ handler: impl Fn(&[GradientStop], &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_change = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for GradientEditor {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let live = use_seeded((self.id.clone(), "stops"), self.stops, window, cx);
+ let chosen = window.use_keyed_state((self.id.clone(), "chosen"), cx, |_, _| 0usize);
+ let (stops, at) = now(&live, &chosen, cx);
+ let count = stops.len();
+ let bar = window.use_keyed_state((self.id.clone(), "bar"), cx, |_, _| {
+ Bounds::::default()
+ });
+ let owner = window
+ .use_keyed_state((self.id.clone(), "drag"), cx, |_, _| ())
+ .entity_id();
+ let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
+ let focused = focus.is_focused(window);
+ let commit: SetStops = {
+ let (id, live, chosen, on_change) = (
+ self.id.clone(),
+ live.clone(),
+ chosen.clone(),
+ self.on_change,
+ );
+ Rc::new(move |next, ix, window, cx| {
+ log::info!(
+ "gradient editor {id:?}: {} stops, stop {ix} chosen",
+ next.len()
+ );
+ chosen.update(cx, |chosen, cx| {
+ *chosen = ix;
+ cx.notify();
+ });
+ live.update(cx, |live, cx| {
+ live.value = next.clone();
+ cx.notify();
+ });
+ if let Some(on_change) = &on_change {
+ on_change(&next, window, cx);
+ }
+ })
+ };
+ let theme = cx.theme();
+ let colors = &theme.colors;
+ let (knob, small) = (theme.slider_thumb(), theme.radius(Radius::Sm));
+ let mut spans: Vec<(f32, f32, Hsla, Hsla)> = Vec::new();
+ let (first, last) = (stops[0], stops[count - 1]);
+ if first.at > 0.0 {
+ spans.push((0.0, first.at, first.color, first.color));
+ }
+ spans.extend(
+ stops
+ .windows(2)
+ .map(|pair| (pair[0].at, pair[1].at, pair[0].color, pair[1].color)),
+ );
+ if last.at < 1.0 {
+ spans.push((last.at, 1.0, last.color, last.color));
+ }
+ let edge = spans.len() - 1;
+ let bands = spans.into_iter().enumerate().map(|(ix, (from, to, a, b))| {
+ div()
+ .absolute()
+ .top_0()
+ .bottom_0()
+ .left(relative(from))
+ .w(relative(to - from))
+ .bg(linear_gradient(
+ 90.0,
+ linear_color_stop(a, 0.0),
+ linear_color_stop(b, 1.0),
+ ))
+ .when(ix == 0, |band| band.rounded_l(small))
+ .when(ix == edge, |band| band.rounded_r(small))
+ });
+ let (measure, press) = (bar.clone(), bar.clone());
+ let (add_live, add_chosen, add) = (live.clone(), chosen.clone(), commit.clone());
+ let strip = div()
+ .id((self.id.clone(), "bar"))
+ .relative()
+ .w_full()
+ .h(theme.control_height(ControlSize::Md))
+ .cursor_crosshair()
+ .child(checker(small, cx))
+ .children(bands)
+ .child(
+ canvas(
+ move |bounds, _, cx| {
+ if *measure.read(cx) != bounds {
+ measure.update(cx, |bar, _| *bar = bounds);
+ }
+ },
+ |_, _, _, _| {},
+ )
+ .absolute()
+ .size_full(),
+ )
+ .on_mouse_down(MouseButton::Left, move |event, window, cx| {
+ let bounds = *press.read(cx);
+ let t = (event.position.x - bounds.left()) / bounds.size.width;
+ let (stops, _) = now(&add_live, &add_chosen, cx);
+ let (next, ix) = added(&stops, t);
+ add(next, ix, window, cx);
+ });
+ let handles = stops.iter().enumerate().map(|(ix, stop)| {
+ let pick = chosen.clone();
+ div()
+ .id(("stop", ix))
+ .absolute()
+ .top_0()
+ .left(relative(stop.at))
+ .ml(knob * -0.5)
+ .size(knob)
+ .rounded(small)
+ .border_2()
+ .border_color(if ix == at {
+ if focused { colors.focus } else { colors.accent }
+ } else {
+ colors.border_strong
+ })
+ .bg(stop.color)
+ .cursor_grab()
+ .on_drag(Handle { owner }, |_, _, _, cx| cx.new(|_| EmptyView))
+ .on_mouse_down(MouseButton::Left, move |_, _, cx| {
+ pick.update(cx, |chosen, cx| {
+ *chosen = ix;
+ cx.notify();
+ })
+ })
+ });
+ let (drag_live, drag_chosen, drag) = (live.clone(), chosen.clone(), commit.clone());
+ let (key_live, key_chosen, keys) = (live.clone(), chosen.clone(), commit.clone());
+ let rail = div()
+ .id((self.id.clone(), "stops"))
+ .track_focus(&focus)
+ .relative()
+ .w_full()
+ .h(knob)
+ .on_drag_move(move |event: &DragMoveEvent, window, cx| {
+ if event.drag(cx).owner != owner {
+ return;
+ }
+ let bounds = event.bounds;
+ let t = (event.event.position.x - bounds.left()) / bounds.size.width;
+ let (stops, at) = now(&drag_live, &drag_chosen, cx);
+ let (next, ix) = moved(&stops, at, t);
+ drag(next, ix, window, cx);
+ })
+ .on_key_down(move |event, window, cx| {
+ let (stops, at) = now(&key_live, &key_chosen, cx);
+ let (next, ix) = match event.keystroke.key.as_str() {
+ "left" => moved(&stops, at, stops[at].at - 0.01),
+ "right" => moved(&stops, at, stops[at].at + 0.01),
+ "delete" | "backspace" if stops.len() > 2 => {
+ let mut next = stops;
+ next.remove(at);
+ (next, at.saturating_sub(1))
+ }
+ _ => return,
+ };
+ cx.stop_propagation();
+ keys(next, ix, window, cx);
+ })
+ .children(handles);
+ let (edit_live, edit_chosen, edit) = (live, chosen, commit);
+ div()
+ .id(self.id.clone())
+ .flex()
+ .flex_col()
+ .gap_2()
+ .w_full()
+ .child(
+ div()
+ .flex()
+ .flex_col()
+ .gap_2()
+ .px(knob * 0.5)
+ .child(strip)
+ .child(rail),
+ )
+ .child(
+ ColorPicker::new((self.id, "color"), stops[at].color).on_change(
+ move |color, window, cx| {
+ let (mut next, at) = now(&edit_live, &edit_chosen, cx);
+ next[at].color = color;
+ edit(next, at, window, cx);
+ },
+ ),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use gpui::{Hsla, black, white};
+
+ use super::{GradientStop, added, color_at, moved};
+
+ fn stops() -> Vec {
+ vec![
+ GradientStop {
+ at: 0.0,
+ color: black(),
+ },
+ GradientStop {
+ at: 1.0,
+ color: white(),
+ },
+ ]
+ }
+
+ #[test]
+ fn colors_blend_between_stops() {
+ let middle: Hsla = color_at(&stops(), 0.5);
+ assert!((middle.l - 0.5).abs() < 0.05);
+ assert_eq!(color_at(&stops(), -1.0), black());
+ }
+
+ #[test]
+ fn adding_and_moving_keep_stops_sorted() {
+ let (three, ix) = added(&stops(), 0.25);
+ assert_eq!((three.len(), ix, three[1].at), (3, 1, 0.25));
+ let (swapped, landed) = moved(&three, 0, 0.5);
+ assert_eq!(landed, 1);
+ assert_eq!(
+ swapped.iter().map(|stop| stop.at).collect::>(),
+ [0.25, 0.5, 1.0]
+ );
+ }
+}
diff --git a/src/forms/color/mod.rs b/src/forms/color/mod.rs
new file mode 100644
index 0000000..20383d8
--- /dev/null
+++ b/src/forms/color/mod.rs
@@ -0,0 +1,159 @@
+mod dropper;
+mod fields;
+mod gradient;
+mod picker;
+mod swatch;
+
+use gpui::Rgba;
+
+pub use dropper::EyeDropper;
+pub use gradient::{GradientEditor, GradientStop};
+pub use picker::ColorPicker;
+pub use swatch::{ColorPalette, ColorSwatch};
+
+/// A color by hue in degrees, then saturation, value and alpha from 0 to 1.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) struct Hsva {
+ pub h: f32,
+ pub s: f32,
+ pub v: f32,
+ pub a: f32,
+}
+
+impl Hsva {
+ pub(crate) fn to_rgba(self) -> Rgba {
+ let h = self.h.rem_euclid(360.0) / 60.0;
+ let c = self.v * self.s;
+ let x = c * (1.0 - (h % 2.0 - 1.0).abs());
+ let (r, g, b) = match h as u8 {
+ 0 => (c, x, 0.0),
+ 1 => (x, c, 0.0),
+ 2 => (0.0, c, x),
+ 3 => (0.0, x, c),
+ 4 => (x, 0.0, c),
+ _ => (c, 0.0, x),
+ };
+ let m = self.v - c;
+ Rgba {
+ r: r + m,
+ g: g + m,
+ b: b + m,
+ a: self.a,
+ }
+ }
+
+ /// The HSV form of `rgba`; a gray keeps `hue`, which it cannot carry itself.
+ pub(crate) fn from_rgba(rgba: Rgba, hue: f32) -> Self {
+ let max = rgba.r.max(rgba.g).max(rgba.b);
+ let min = rgba.r.min(rgba.g).min(rgba.b);
+ let delta = max - min;
+ let h = if delta == 0.0 {
+ hue
+ } else if max == rgba.r {
+ 60.0 * ((rgba.g - rgba.b) / delta).rem_euclid(6.0)
+ } else if max == rgba.g {
+ 60.0 * ((rgba.b - rgba.r) / delta + 2.0)
+ } else {
+ 60.0 * ((rgba.r - rgba.g) / delta + 4.0)
+ };
+ Self {
+ h,
+ s: if max == 0.0 { 0.0 } else { delta / max },
+ v: max,
+ a: rgba.a,
+ }
+ }
+}
+
+/// Reads `#rgb`, `#rrggbb` or `#rrggbbaa`, with or without the hash.
+pub(crate) fn parse_hex(text: &str) -> Result {
+ let digits = text.trim().trim_start_matches('#');
+ let long: String = match digits.len() {
+ 3 => digits
+ .chars()
+ .flat_map(|ch| [ch, ch])
+ .chain("ff".chars())
+ .collect(),
+ 6 => format!("{digits}ff"),
+ 8 => digits.to_string(),
+ _ => return Err(format!("{text:?} is not a hex color")),
+ };
+ let value =
+ u32::from_str_radix(&long, 16).map_err(|_| format!("{text:?} is not a hex color"))?;
+ let channel = |shift: u32| ((value >> shift) & 0xff) as f32 / 255.0;
+ Ok(Rgba {
+ r: channel(24),
+ g: channel(16),
+ b: channel(8),
+ a: channel(0),
+ })
+}
+
+/// `#rrggbb`, with alpha appended when it is not opaque.
+pub(crate) fn hex(rgba: Rgba) -> String {
+ let byte = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
+ let base = format!(
+ "#{:02x}{:02x}{:02x}",
+ byte(rgba.r),
+ byte(rgba.g),
+ byte(rgba.b)
+ );
+ if byte(rgba.a) == 255 {
+ base
+ } else {
+ format!("{base}{:02x}", byte(rgba.a))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use gpui::Rgba;
+
+ use super::{Hsva, hex, parse_hex};
+
+ fn close(a: Rgba, b: Rgba) -> bool {
+ [(a.r, b.r), (a.g, b.g), (a.b, b.b), (a.a, b.a)]
+ .iter()
+ .all(|(x, y)| (x - y).abs() < 1e-4)
+ }
+
+ #[test]
+ fn hsv_round_trips_and_grays_keep_their_hue() {
+ let orange = Hsva {
+ h: 30.0,
+ s: 1.0,
+ v: 1.0,
+ a: 1.0,
+ };
+ assert!(close(
+ orange.to_rgba(),
+ Rgba {
+ r: 1.0,
+ g: 0.5,
+ b: 0.0,
+ a: 1.0
+ }
+ ));
+ let back = Hsva::from_rgba(orange.to_rgba(), 0.0);
+ assert!((back.h - 30.0).abs() < 1e-3 && (back.s - 1.0).abs() < 1e-4);
+ let gray = Hsva::from_rgba(
+ Rgba {
+ r: 0.5,
+ g: 0.5,
+ b: 0.5,
+ a: 1.0,
+ },
+ 210.0,
+ );
+ assert_eq!((gray.h, gray.s), (210.0, 0.0));
+ }
+
+ #[test]
+ fn hex_reads_three_six_and_eight_digits() {
+ assert_eq!(hex(parse_hex("#0af").unwrap()), "#00aaff");
+ assert_eq!(hex(parse_hex("3772bb").unwrap()), "#3772bb");
+ assert_eq!(hex(parse_hex("#3772bb80").unwrap()), "#3772bb80");
+ assert!(parse_hex("#12345").is_err());
+ assert!(parse_hex("#zzzzzz").is_err());
+ }
+}
diff --git a/src/forms/color/picker.rs b/src/forms/color/picker.rs
new file mode 100644
index 0000000..bcba330
--- /dev/null
+++ b/src/forms/color/picker.rs
@@ -0,0 +1,408 @@
+use std::rc::Rc;
+
+use gpui::{
+ AnyElement, App, Bounds, Context, DragMoveEvent, ElementId, EmptyView, Entity, EntityId, Hsla,
+ InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce,
+ StatefulInteractiveElement, Styled, Subscription, Window, black, canvas, div,
+ linear_color_stop, linear_gradient, prelude::*, relative, white,
+};
+
+use super::{
+ super::{InputEvent, TextInput},
+ ColorSwatch, EyeDropper, Hsva,
+ fields::{Commit, Format, SetFormat, format_select, typed_fields},
+ hex, parse_hex,
+ swatch::checker,
+};
+use crate::theme::{ActiveTheme, ControlSize, Elevation, Radius};
+
+type OnColor = Rc;
+
+/// Which part of the picker a drag or click moves.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) enum Area {
+ Plane,
+ Hue,
+ Alpha,
+}
+
+/// `color` after pointing at `at` inside an area's `bounds`.
+pub(crate) fn pointed(color: Hsva, area: Area, bounds: Bounds, at: Point) -> Hsva {
+ let x = ((at.x - bounds.left()) / bounds.size.width).clamp(0.0, 1.0);
+ let y = ((at.y - bounds.top()) / bounds.size.height).clamp(0.0, 1.0);
+ match area {
+ Area::Plane => Hsva {
+ s: x,
+ v: 1.0 - y,
+ ..color
+ },
+ Area::Hue => Hsva {
+ h: x * 360.0,
+ ..color
+ },
+ Area::Alpha => Hsva { a: x, ..color },
+ }
+}
+
+struct Grip {
+ owner: EntityId,
+ area: Area,
+}
+
+/// The picker's own color, which keeps a hue through grays, its hex field, and where its areas sit.
+struct Picking {
+ color: Hsva,
+ committed: Hsla,
+ field: Entity,
+ format: Format,
+ opaque: bool,
+ on_change: Option,
+ areas: [Bounds; 3],
+ _events: Subscription,
+}
+
+impl Picking {
+ fn commit(&mut self, color: Hsva, window: &mut Window, cx: &mut Context) {
+ let color = if self.opaque {
+ Hsva { a: 1.0, ..color }
+ } else {
+ color
+ };
+ let value: Hsla = color.to_rgba().into();
+ self.color = color;
+ self.committed = value;
+ log::debug!("color picker: {}", hex(color.to_rgba()));
+ if let Some(on_change) = self.on_change.clone() {
+ on_change(value, window, cx);
+ }
+ cx.notify();
+ }
+}
+
+fn picking(id: &ElementId, value: Hsla, window: &mut Window, cx: &mut App) -> Entity {
+ window.use_keyed_state(
+ (id.clone(), "picking"),
+ cx,
+ |window, cx: &mut Context| {
+ let field = cx.new(|cx| TextInput::new(window, cx).max_len(9));
+ let events = cx.subscribe_in(&field, window, |picking, field, event, window, cx| {
+ if !matches!(event, InputEvent::Submit | InputEvent::Blur) {
+ return;
+ }
+ let text = field.read(cx).text().to_string();
+ match parse_hex(&text) {
+ Ok(rgba) => {
+ let color = Hsva::from_rgba(rgba, picking.color.h);
+ picking.commit(color, window, cx);
+ }
+ Err(problem) => {
+ log::info!("color picker: {problem}; kept the last color");
+ let shown = hex(picking.color.to_rgba());
+ field.update(cx, |field, cx| field.set_text(shown, cx));
+ }
+ }
+ });
+ Picking {
+ color: Hsva::from_rgba(value.to_rgb(), 0.0),
+ committed: value,
+ field,
+ format: Format::default(),
+ opaque: false,
+ on_change: None,
+ areas: [Bounds::default(); 3],
+ _events: events,
+ }
+ },
+ )
+}
+
+/// A color by saturation and value, hue and alpha, or typed as hex.
+#[derive(IntoElement)]
+pub struct ColorPicker {
+ id: ElementId,
+ value: Hsla,
+ alpha: bool,
+ on_change: Option,
+}
+
+impl ColorPicker {
+ pub fn new(id: impl Into, value: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ value: value.into(),
+ alpha: true,
+ on_change: None,
+ }
+ }
+
+ /// Hides the alpha rail; colors stay opaque.
+ pub fn opaque(mut self) -> Self {
+ self.alpha = false;
+ self
+ }
+
+ pub fn on_change(mut self, handler: impl Fn(Hsla, &mut Window, &mut App) + 'static) -> Self {
+ self.on_change = Some(Rc::new(handler));
+ self
+ }
+}
+
+/// An area that follows drags and jumps on press, recording where it sits.
+fn area(
+ id: ElementId,
+ which: Area,
+ state: &Entity,
+ owner: EntityId,
+) -> gpui::Stateful {
+ let (moved, pressed, measured) = (state.clone(), state.clone(), state.clone());
+ div()
+ .id(id)
+ .relative()
+ .w_full()
+ .cursor_crosshair()
+ .on_drag(Grip { owner, area: which }, |_, _, _, cx| {
+ cx.new(|_| EmptyView)
+ })
+ .on_drag_move(move |event: &DragMoveEvent, window, cx| {
+ let grip = event.drag(cx);
+ if grip.owner != owner || grip.area != which {
+ return;
+ }
+ let (bounds, at) = (event.bounds, event.event.position);
+ moved.update(cx, |picking, cx| {
+ let next = pointed(picking.color, which, bounds, at);
+ picking.commit(next, window, cx);
+ });
+ })
+ .on_mouse_down(MouseButton::Left, move |event, window, cx| {
+ let at = event.position;
+ pressed.update(cx, |picking, cx| {
+ let next = pointed(picking.color, which, picking.areas[which as usize], at);
+ picking.commit(next, window, cx);
+ });
+ })
+ .child(
+ canvas(
+ move |bounds, _, cx| {
+ if measured.read(cx).areas[which as usize] != bounds {
+ measured.update(cx, |picking, _| picking.areas[which as usize] = bounds);
+ }
+ },
+ |_, _, _, _| {},
+ )
+ .absolute()
+ .size_full(),
+ )
+}
+
+impl RenderOnce for ColorPicker {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let state = picking(&self.id, self.value, window, cx);
+ let value = if self.alpha {
+ self.value
+ } else {
+ self.value.alpha(1.0)
+ };
+ state.update(cx, |picking, _| {
+ picking.on_change = self.on_change.clone();
+ picking.opaque = !self.alpha;
+ if picking.committed != value {
+ picking.color = Hsva::from_rgba(value.to_rgb(), picking.color.h);
+ picking.committed = value;
+ }
+ });
+ let (color, field, format) = {
+ let picking = state.read(cx);
+ (picking.color, picking.field.clone(), picking.format)
+ };
+ let shown = hex(color.to_rgba());
+ if !field.read(cx).focus().is_focused(window) && field.read(cx).text() != shown {
+ field.update(cx, |field, cx| field.set_text(shown, cx));
+ }
+ let owner = window
+ .use_keyed_state((self.id.clone(), "grip"), cx, |_, _| ())
+ .entity_id();
+ let theme = cx.theme();
+ let (knob, rail, radius) = (
+ theme.slider_thumb(),
+ theme.slider_thumb() * 0.75,
+ theme.radius(Radius::Md),
+ );
+ let current: Hsla = color.to_rgba().into();
+ let solid = current.alpha(1.0);
+ let hue_at = |degrees: f32| -> Hsla {
+ Hsva {
+ h: degrees,
+ s: 1.0,
+ v: 1.0,
+ a: 1.0,
+ }
+ .to_rgba()
+ .into()
+ };
+ let thumb = |fill: Hsla, x: f32| {
+ div()
+ .absolute()
+ .size(knob)
+ .left(relative(x))
+ .ml(knob * -0.5)
+ .rounded_full()
+ .border_2()
+ .border_color(theme.colors.on_media)
+ .bg(fill)
+ .shadow(theme.elevation(Elevation::Raised))
+ };
+ let plane = area(
+ (self.id.clone(), "plane").into(),
+ Area::Plane,
+ &state,
+ owner,
+ )
+ .h(theme.color_plane())
+ .rounded(radius)
+ .bg(hue_at(color.h))
+ .child(
+ div()
+ .absolute()
+ .size_full()
+ .rounded(radius)
+ .bg(linear_gradient(
+ 90.0,
+ linear_color_stop(white(), 0.0),
+ linear_color_stop(white().alpha(0.0), 1.0),
+ )),
+ )
+ .child(
+ div()
+ .absolute()
+ .size_full()
+ .rounded(radius)
+ .bg(linear_gradient(
+ 180.0,
+ linear_color_stop(black().alpha(0.0), 0.0),
+ linear_color_stop(black(), 1.0),
+ )),
+ )
+ .child(
+ thumb(solid, color.s)
+ .top(relative(1.0 - color.v))
+ .mt(knob * -0.5),
+ );
+ let bands = (0..6).map(|band| {
+ div()
+ .flex_1()
+ .h_full()
+ .bg(linear_gradient(
+ 90.0,
+ linear_color_stop(hue_at(band as f32 * 60.0), 0.0),
+ linear_color_stop(hue_at((band + 1) as f32 * 60.0), 1.0),
+ ))
+ .when(band == 0, |first| first.rounded_l(theme.radius(Radius::Sm)))
+ .when(band == 5, |last| last.rounded_r(theme.radius(Radius::Sm)))
+ });
+ let hue = area((self.id.clone(), "hue").into(), Area::Hue, &state, owner)
+ .h(rail)
+ .flex()
+ .children(bands)
+ .child(
+ thumb(hue_at(color.h), color.h / 360.0)
+ .top(relative(0.5))
+ .mt(knob * -0.5),
+ );
+ let alpha = self.alpha.then(|| {
+ area(
+ (self.id.clone(), "alpha").into(),
+ Area::Alpha,
+ &state,
+ owner,
+ )
+ .h(rail)
+ .child(checker(theme.radius(Radius::Sm), cx))
+ .child(
+ div()
+ .absolute()
+ .size_full()
+ .rounded(theme.radius(Radius::Sm))
+ .bg(linear_gradient(
+ 90.0,
+ linear_color_stop(solid.alpha(0.0), 0.0),
+ linear_color_stop(solid, 1.0),
+ )),
+ )
+ .child(thumb(current, color.a).top(relative(0.5)).mt(knob * -0.5))
+ });
+ let dropped = state.clone();
+ let (typed, formatted) = (state.clone(), state.clone());
+ let commit: Commit = Rc::new(move |color, window, cx| {
+ typed.update(cx, |picking, cx| picking.commit(color, window, cx))
+ });
+ let set_format: SetFormat = Rc::new(move |format, _, cx| {
+ formatted.update(cx, |picking, cx| {
+ picking.format = format;
+ cx.notify();
+ })
+ });
+ let row = div()
+ .flex()
+ .items_center()
+ .gap_2()
+ .child(ColorSwatch::new((self.id.clone(), "preview"), current).size(ControlSize::Md))
+ .child(
+ div()
+ .flex_1()
+ .child(format_select(&self.id, format, set_format)),
+ )
+ .child(EyeDropper::new((self.id.clone(), "dropper")).on_pick(
+ move |picked, window, cx| {
+ dropped.update(cx, |picking, cx| {
+ let color = Hsva::from_rgba(picked.to_rgb(), picking.color.h);
+ picking.commit(color, window, cx);
+ })
+ },
+ ));
+ let fields = typed_fields(&self.id, format, color, &field, commit, cx);
+ let rails: Vec = [
+ Some(hue.into_any_element()),
+ alpha.map(IntoElement::into_any_element),
+ ]
+ .into_iter()
+ .flatten()
+ .collect();
+ div()
+ .id(self.id)
+ .flex()
+ .flex_col()
+ .gap_3()
+ .w_full()
+ .px(knob * 0.5)
+ .pt(knob * 0.5)
+ .child(plane)
+ .children(rails)
+ .child(row)
+ .child(fields)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use gpui::{Bounds, point, px, size};
+
+ use super::{Area, Hsva, pointed};
+
+ #[test]
+ fn pointing_sets_the_part_under_the_pointer() {
+ let bounds = Bounds::new(point(px(10.0), px(10.0)), size(px(100.0), px(50.0)));
+ let gray = Hsva {
+ h: 200.0,
+ s: 0.0,
+ v: 0.5,
+ a: 1.0,
+ };
+ let plane = pointed(gray, Area::Plane, bounds, point(px(60.0), px(20.0)));
+ assert_eq!((plane.h, plane.s, plane.v), (200.0, 0.5, 0.8));
+ let hue = pointed(gray, Area::Hue, bounds, point(px(200.0), px(0.0)));
+ assert_eq!(hue.h, 360.0);
+ let alpha = pointed(gray, Area::Alpha, bounds, point(px(35.0), px(30.0)));
+ assert_eq!(alpha.a, 0.25);
+ }
+}
diff --git a/src/forms/color/swatch.rs b/src/forms/color/swatch.rs
new file mode 100644
index 0000000..0ccbbdd
--- /dev/null
+++ b/src/forms/color/swatch.rs
@@ -0,0 +1,262 @@
+use std::rc::Rc;
+
+use gpui::{
+ App, Bounds, Corners, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton,
+ ParentElement, Pixels, Rems, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
+ Window, canvas, div, fill, point, prelude::*, size,
+};
+
+use super::super::options::Run;
+use crate::{
+ primitives::{FocusRing, Tooltip},
+ theme::{ActiveTheme, ControlSize, Radius},
+};
+
+/// Checker cells over `bounds`, at least `round` wide; odd counts keep the corners light.
+pub(crate) fn dark_cells(bounds: Bounds, round: Pixels) -> Vec> {
+ let (width, height) = (bounds.size.width, bounds.size.height);
+ if width <= Pixels::ZERO || height <= Pixels::ZERO {
+ return Vec::new();
+ }
+ let least = (width.min(height) / 4.0).max(round);
+ let odd = |extent: Pixels| {
+ let count = ((extent / least).floor() as usize).max(1);
+ if count.is_multiple_of(2) {
+ count - 1
+ } else {
+ count
+ }
+ };
+ let (columns, rows) = (odd(width), odd(height));
+ let cell = size(width / columns as f32, height / rows as f32);
+ (0..rows)
+ .flat_map(|row| {
+ (1 - row % 2..columns).step_by(2).map(move |column| {
+ let origin = point(
+ bounds.left() + cell.width * column as f32,
+ bounds.top() + cell.height * row as f32,
+ );
+ Bounds::new(origin, cell)
+ })
+ })
+ .collect()
+}
+
+/// A checkerboard filling its parent with `radius` corners, so see-through colors read as see-through.
+pub(crate) fn checker(radius: Rems, cx: &App) -> impl IntoElement + use<> {
+ let (light, dark) = (cx.theme().colors.surface, cx.theme().colors.active);
+ canvas(
+ |_, _, _| {},
+ move |bounds: Bounds, _, window, _| {
+ let round = radius
+ .to_pixels(window.rem_size())
+ .min(bounds.size.width.min(bounds.size.height) / 2.0);
+ window.paint_quad(fill(bounds, light).corner_radii(Corners::all(round)));
+ for cell in dark_cells(bounds, round) {
+ window.paint_quad(fill(cell, dark));
+ }
+ },
+ )
+ .absolute()
+ .size_full()
+}
+
+/// A patch of one color. Chosen swatches wear an ink ring with a gap.
+#[derive(IntoElement)]
+pub struct ColorSwatch {
+ id: ElementId,
+ color: Hsla,
+ size: ControlSize,
+ selected: bool,
+ tooltip: Option,
+ on_click: Option,
+}
+
+impl ColorSwatch {
+ pub fn new(id: impl Into, color: impl Into) -> Self {
+ Self {
+ id: id.into(),
+ color: color.into(),
+ size: ControlSize::Sm,
+ selected: false,
+ tooltip: None,
+ on_click: None,
+ }
+ }
+
+ pub fn size(mut self, size: ControlSize) -> Self {
+ self.size = size;
+ self
+ }
+
+ pub fn selected(mut self, selected: bool) -> Self {
+ self.selected = selected;
+ self
+ }
+
+ /// The color's name, shown on hover.
+ pub fn tooltip(mut self, text: impl Into) -> Self {
+ self.tooltip = Some(text.into());
+ self
+ }
+
+ pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
+ self.on_click = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for ColorSwatch {
+ fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+ let theme = cx.theme();
+ let colors = &theme.colors;
+ let (see_through, radius) = (self.color.a < 1.0, theme.radius(Radius::Sm));
+ let patch = div()
+ .relative()
+ .size_full()
+ .when(see_through, |patch| patch.child(checker(radius, cx)))
+ .child(div().absolute().size_full().rounded(radius).bg(self.color));
+ div()
+ .id(self.id)
+ .flex_none()
+ .size(theme.control_height(self.size))
+ .rounded(theme.radius(Radius::Md))
+ .border_1()
+ .border_color(if self.selected {
+ colors.accent
+ } else {
+ colors.border
+ })
+ .when(self.selected, |swatch| swatch.p_0p5())
+ .when_some(self.on_click, |swatch, click| {
+ swatch
+ .tab_index(0)
+ .focus_ring(cx)
+ .cursor_pointer()
+ .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
+ .on_click(move |_, window, cx| click(window, cx))
+ })
+ .when_some(self.tooltip, |swatch, text| {
+ swatch.tooltip(Tooltip::text(text))
+ })
+ .child(patch)
+ }
+}
+
+type OnColor = Rc;
+
+/// A grid of named swatches; one may be chosen.
+#[derive(IntoElement)]
+pub struct ColorPalette {
+ id: ElementId,
+ colors: Vec<(SharedString, Hsla)>,
+ selected: Option,
+ columns: u16,
+ on_change: Option,
+}
+
+impl ColorPalette {
+ pub fn new(
+ id: impl Into,
+ colors: impl IntoIterator
- , impl Into)>,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ colors: colors
+ .into_iter()
+ .map(|(name, color)| (name.into(), color.into()))
+ .collect(),
+ selected: None,
+ columns: 8,
+ on_change: None,
+ }
+ }
+
+ pub fn selected(mut self, color: impl Into) -> Self {
+ self.selected = Some(color.into());
+ self
+ }
+
+ pub fn columns(mut self, columns: u16) -> Self {
+ assert!(columns > 0, "a palette needs a column");
+ self.columns = columns;
+ self
+ }
+
+ pub fn on_change(mut self, handler: impl Fn(Hsla, &mut Window, &mut App) + 'static) -> Self {
+ self.on_change = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for ColorPalette {
+ fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
+ let swatches: Vec<_> = self
+ .colors
+ .into_iter()
+ .enumerate()
+ .map(|(ix, (name, color))| {
+ let (id, on_change) = (self.id.clone(), self.on_change.clone());
+ let label = name.clone();
+ ColorSwatch::new(("swatch", ix), color)
+ .tooltip(name)
+ .selected(self.selected == Some(color))
+ .on_click(move |window, cx| {
+ log::info!("color palette {id:?}: {label}");
+ if let Some(on_change) = &on_change {
+ on_change(color, window, cx);
+ }
+ })
+ })
+ .collect();
+ div()
+ .id(self.id)
+ .grid()
+ .grid_cols(self.columns)
+ .gap_1p5()
+ .children(swatches)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use gpui::{Bounds, point, px, size};
+
+ use super::dark_cells;
+
+ #[test]
+ fn empty_boxes_get_no_cells() {
+ let flat = Bounds::new(point(px(0.0), px(0.0)), size(px(0.0), px(40.0)));
+ assert!(dark_cells(flat, px(4.0)).is_empty());
+ }
+
+ #[test]
+ fn dark_cells_stay_clear_of_rounded_corners() {
+ for (width, height) in [(22.0, 22.0), (244.0, 12.0), (344.0, 28.0), (31.0, 9.0)] {
+ let bounds = Bounds::new(point(px(10.0), px(20.0)), size(px(width), px(height)));
+ let round = px(4.0).min(bounds.size.height / 2.0);
+ let cells = dark_cells(bounds, round);
+ assert!(!cells.is_empty(), "{width}x{height} has a pattern");
+ let corners = [
+ bounds.origin,
+ point(bounds.right() - round, bounds.top()),
+ point(bounds.left(), bounds.bottom() - round),
+ point(bounds.right() - round, bounds.bottom() - round),
+ ]
+ .map(|origin| Bounds::new(origin, size(round, round)));
+ for cell in &cells {
+ assert!(
+ bounds.contains(&cell.center()),
+ "{cell:?} inside {bounds:?}"
+ );
+ for corner in &corners {
+ let overlap = cell.intersect(corner);
+ assert!(
+ overlap.size.width <= px(0.001) || overlap.size.height <= px(0.001),
+ "{cell:?} reaches corner {corner:?} of {width}x{height}"
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/src/forms/mod.rs b/src/forms/mod.rs
index d5165da..9f49ba1 100644
--- a/src/forms/mod.rs
+++ b/src/forms/mod.rs
@@ -3,6 +3,7 @@ mod cascade;
mod check;
mod checked;
mod chips;
+mod color;
mod combobox;
mod date;
mod expression;
@@ -40,6 +41,7 @@ pub use cascade::{Cascade, Cascader};
pub use check::{CheckState, Checkbox, CheckboxGroup};
pub use checked::{EmailInput, UrlInput};
pub use chips::ChoiceChips;
+pub use color::{ColorPalette, ColorPicker, ColorSwatch, EyeDropper, GradientEditor, GradientStop};
pub use combobox::Combobox;
pub use date::{
Calendar, CronEditor, CronRule, DatePicker, DateRangePicker, DateTimePicker, DurationPicker,
diff --git a/src/forms/tests/colors.rs b/src/forms/tests/colors.rs
new file mode 100644
index 0000000..6e12ca8
--- /dev/null
+++ b/src/forms/tests/colors.rs
@@ -0,0 +1,81 @@
+use gpui::{
+ Context, Hsla, IntoElement, Modifiers, MouseButton, Render, TestAppContext, Window, point, px,
+ rgb, rgba,
+};
+
+use super::setup;
+use crate::forms::{ColorPicker, GradientEditor, GradientStop};
+
+struct Picked {
+ color: Hsla,
+}
+
+impl Render for Picked {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let view = cx.entity();
+ ColorPicker::new("picked", self.color)
+ .opaque()
+ .on_change(move |color, _, cx| {
+ view.update(cx, |view, cx| {
+ view.color = color;
+ cx.notify();
+ })
+ })
+ }
+}
+
+#[gpui::test]
+fn an_opaque_picker_commits_opaque_colors(cx: &mut TestAppContext) {
+ setup(cx);
+ let (view, cx) = cx.add_window_view(|_, _| Picked {
+ color: rgba(0x3772bb80).into(),
+ });
+ cx.simulate_click(point(px(60.0), px(60.0)), Modifiers::none());
+ assert_eq!(view.read_with(cx, |view, _| view.color.a), 1.0);
+}
+
+struct Gradient {
+ stops: Vec,
+}
+
+impl Render for Gradient {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let view = cx.entity();
+ GradientEditor::new("gradient", self.stops.clone()).on_change(move |next, _, cx| {
+ view.update(cx, |view, cx| {
+ view.stops = next.to_vec();
+ cx.notify();
+ })
+ })
+ }
+}
+
+#[gpui::test]
+fn a_dragged_stop_keeps_its_grip_after_passing_another(cx: &mut TestAppContext) {
+ setup(cx);
+ let stop = |at, hex| GradientStop {
+ at,
+ color: rgb(hex).into(),
+ };
+ let (view, cx) = cx.add_window_view(|_, _| Gradient {
+ stops: vec![
+ stop(0.2, 0xff0000),
+ stop(0.5, 0x00ff00),
+ stop(0.8, 0x0000ff),
+ ],
+ });
+ let width = cx.update(|window, _| window.viewport_size().width);
+ let at = |t: f32| point(px(8.0) + (width - px(16.0)) * t, px(44.0));
+ cx.simulate_mouse_down(at(0.2), MouseButton::Left, Modifiers::none());
+ for t in [0.22, 0.6, 0.65] {
+ cx.simulate_mouse_move(at(t), MouseButton::Left, Modifiers::none());
+ }
+ cx.simulate_mouse_up(at(0.65), MouseButton::Left, Modifiers::none());
+ let stops = view.read_with(cx, |view, _| view.stops.clone());
+ let place = |hex| {
+ let color: Hsla = rgb(hex).into();
+ stops.iter().find(|stop| stop.color == color).unwrap().at
+ };
+ assert!((place(0xff0000) - 0.65).abs() < 0.01, "{stops:?}");
+ assert_eq!(place(0x00ff00), 0.5, "{stops:?}");
+}
diff --git a/src/forms/tests/mod.rs b/src/forms/tests/mod.rs
index 00330e4..6e418b1 100644
--- a/src/forms/tests/mod.rs
+++ b/src/forms/tests/mod.rs
@@ -1,4 +1,5 @@
mod choices;
+mod colors;
mod dates;
use gpui::{
diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs
index 0b3883e..d8856ad 100644
--- a/src/primitives/icon.rs
+++ b/src/primitives/icon.rs
@@ -187,6 +187,7 @@ icons! {
PhoneOff => "phone-off",
Phone => "phone",
PictureInPicture2 => "picture-in-picture-2",
+ Pipette => "pipette",
PinOff => "pin-off",
Pin => "pin",
Play => "play",
diff --git a/src/theme/palette.rs b/src/theme/palette.rs
index 40458d6..ff2a2a5 100644
--- a/src/theme/palette.rs
+++ b/src/theme/palette.rs
@@ -83,6 +83,7 @@ mixable!(
accent: Hsla,
accent_hover: Hsla,
on_accent: Hsla,
+ on_media: Hsla,
focus: Hsla,
link: Hsla,
selection: Hsla,
@@ -130,6 +131,7 @@ impl Palette {
accent: c(0x181613),
accent_hover: c(0x302d2b),
on_accent: c(0xfcfaf7),
+ on_media: c(0xffffff),
focus: c(0x3772bb),
link: c(0x2863ab),
selection: rgba(0x3772bb33).into(),
@@ -194,6 +196,7 @@ impl Palette {
accent: c(0xf3f1f0),
accent_hover: c(0xd9d7d5),
on_accent: c(0x131110),
+ on_media: c(0xffffff),
focus: c(0x69a1e8),
link: c(0x7eb1f3),
selection: rgba(0x69a1e84d).into(),
diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs
index b0b2bda..60b1d6c 100644
--- a/src/theme/tokens.rs
+++ b/src/theme/tokens.rs
@@ -261,6 +261,11 @@ impl Theme {
(px_to_rems(48.0), px_to_rems(3.0))
}
+ /// Height of a color picker's saturation and value plane.
+ pub fn color_plane(&self) -> Rems {
+ px_to_rems(152.0)
+ }
+
/// Tallest a list grows before it scrolls.
pub fn list_max_height(&self) -> Rems {
px_to_rems(280.0)
diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md
index 78c61d0..095751b 100644
--- a/tasks/ch01-10.md
+++ b/tasks/ch01-10.md
@@ -237,11 +237,11 @@
## T06d Forms · Color
-- [ ] ColorPicker — HSV, RGB, HEX, alpha
-- [ ] ColorSwatch
-- [ ] ColorPalette
-- [ ] EyeDropper — prove: `NSColorSampler`
-- [ ] GradientEditor
+- [x] ColorPicker — HSV, RGB, HEX, alpha → `forms::ColorPicker`
+- [x] ColorSwatch → `forms::ColorSwatch`
+- [x] ColorPalette → `forms::ColorPalette`
+- [x] EyeDropper — proved: `NSColorSampler`; sRGB read tested (`read_srgb`), the sampler draws out of process → `forms::EyeDropper`
+- [x] GradientEditor → `forms::GradientEditor`
## T06e Forms · Files