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