T06d forms color: color picker with hex, RGB and HSV, swatches, palette, eye dropper, gradient editor

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 08:24:08 -04:00
1 parent 8b418b8e80
commit eac5793c7d
21 files changed
+1736 -7

No files matched your search

+4
View File
@@ -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.
Generated
+1
View File
@@ -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",
+1
View File
@@ -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"
+3 -1
View File
@@ -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 | |
+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-pipette"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12 9-8.414 8.414A2 2 0 0 0 3 18.828v1.344a2 2 0 0 1-.586 1.414A2 2 0 0 1 3.828 21h1.344a2 2 0 0 0 1.414-.586L15 12" />
<path d="m18 9 .4.4a1 1 0 1 1-3 3l-3.8-3.8a1 1 0 1 1 3-3l.4.4 3.4-3.4a1 1 0 1 1 3 3z" />
<path d="m2 22 .414-.414" />
</svg>

After

Width:  |  Height:  |  Size: 539 B

+140
View File
@@ -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::<Hsla>, 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))
}
+24
View File
@@ -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()
}
+1 -1
View File
@@ -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
+134
View File
@@ -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<dyn Fn(Hsla, &mut Window, &mut App)>;
/// A button that picks a color from anywhere on screen. macOS only.
#[derive(IntoElement)]
pub struct EyeDropper {
id: ElementId,
on_pick: Option<OnColor>,
}
impl EyeDropper {
pub fn new(id: impl Into<ElementId>) -> 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<gpui::Rgba> {
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<OnColor>) {
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<OnColor>) {
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);
}
}
+150
View File
@@ -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<dyn Fn(Hsva, &mut Window, &mut App)>;
pub(crate) type SetFormat = Rc<dyn Fn(Format, &mut Window, &mut App)>;
/// 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<TextInput>,
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,
)),
}
}
+334
View File
@@ -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<GradientStop>, 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<GradientStop>, 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<dyn Fn(&[GradientStop], &mut Window, &mut App)>;
type SetStops = Rc<dyn Fn(Vec<GradientStop>, usize, &mut Window, &mut App)>;
type Live = Entity<Seeded<Vec<GradientStop>>>;
/// The stops and the chosen index as they stand when a handler runs.
fn now(live: &Live, chosen: &Entity<usize>, cx: &App) -> (Vec<GradientStop>, 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<GradientStop>,
on_change: Option<OnStops>,
}
impl GradientEditor {
pub fn new(id: impl Into<ElementId>, stops: impl IntoIterator<Item = GradientStop>) -> Self {
let stops: Vec<GradientStop> = 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::<Pixels>::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<Handle>, 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<GradientStop> {
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::<Vec<_>>(),
[0.25, 0.5, 1.0]
);
}
}
+159
View File
@@ -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<Rgba, String> {
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());
}
}
+408
View File
@@ -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<dyn Fn(Hsla, &mut Window, &mut App)>;
/// 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<Pixels>, at: Point<Pixels>) -> 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<TextInput>,
format: Format,
opaque: bool,
on_change: Option<OnColor>,
areas: [Bounds<Pixels>; 3],
_events: Subscription,
}
impl Picking {
fn commit(&mut self, color: Hsva, window: &mut Window, cx: &mut Context<Self>) {
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<Picking> {
window.use_keyed_state(
(id.clone(), "picking"),
cx,
|window, cx: &mut Context<Picking>| {
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<OnColor>,
}
impl ColorPicker {
pub fn new(id: impl Into<ElementId>, value: impl Into<Hsla>) -> 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<Picking>,
owner: EntityId,
) -> gpui::Stateful<gpui::Div> {
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<Grip>, 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<AnyElement> = [
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);
}
}
+262
View File
@@ -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<Pixels>, round: Pixels) -> Vec<Bounds<Pixels>> {
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<Pixels>, _, 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<SharedString>,
on_click: Option<Run>,
}
impl ColorSwatch {
pub fn new(id: impl Into<ElementId>, color: impl Into<Hsla>) -> 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<SharedString>) -> 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<dyn Fn(Hsla, &mut Window, &mut App)>;
/// A grid of named swatches; one may be chosen.
#[derive(IntoElement)]
pub struct ColorPalette {
id: ElementId,
colors: Vec<(SharedString, Hsla)>,
selected: Option<Hsla>,
columns: u16,
on_change: Option<OnColor>,
}
impl ColorPalette {
pub fn new(
id: impl Into<ElementId>,
colors: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<Hsla>)>,
) -> 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<Hsla>) -> 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}"
);
}
}
}
}
}
+2
View File
@@ -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,
+81
View File
@@ -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<Self>) -> 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<GradientStop>,
}
impl Render for Gradient {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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:?}");
}
+1
View File
@@ -1,4 +1,5 @@
mod choices;
mod colors;
mod dates;
use gpui::{
+1
View File
@@ -187,6 +187,7 @@ icons! {
PhoneOff => "phone-off",
Phone => "phone",
PictureInPicture2 => "picture-in-picture-2",
Pipette => "pipette",
PinOff => "pin-off",
Pin => "pin",
Play => "play",
+3
View File
@@ -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(),
+5
View File
@@ -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)
+5 -5
View File
@@ -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