T06d forms color: color picker with hex, RGB and HSV, swatches, palette, eye dropper, gradient editor
This commit is contained in:
21 files changed
+1736
-7
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 | |
|
||||
@@ -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 |
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
mod choices;
|
||||
mod colors;
|
||||
mod dates;
|
||||
|
||||
use gpui::{
|
||||
|
||||
@@ -187,6 +187,7 @@ icons! {
|
||||
PhoneOff => "phone-off",
|
||||
Phone => "phone",
|
||||
PictureInPicture2 => "picture-in-picture-2",
|
||||
Pipette => "pipette",
|
||||
PinOff => "pin-off",
|
||||
Pin => "pin",
|
||||
Play => "play",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user