From 879ff8da5bd6f56f0190d659ad32d2b2c3a36e84 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 27 Aug 2026 20:43:09 +0800 Subject: [PATCH] app: PS-style colour picker and an eyedropper that keeps the popup open - the popup now offers a large SV palette with a hue bar plus RGB/HSV mode tabs (shared slider model swap, hsv<->rgb pure functions) - while the eyedropper is armed, clicks outside the popup pick a colour instead of dismissing it, so the picked draft can still be confirmed - regression test pinning the OFX plugin colour set/read/undo chain - i18n: ofx.color.mode_rgb/mode_hsv in all eight packs --- assets/i18n/de.yaml | 2 + assets/i18n/en-US.yaml | 2 + assets/i18n/es.yaml | 2 + assets/i18n/fr.yaml | 2 + assets/i18n/ja.yaml | 2 + assets/i18n/pt.yaml | 2 + assets/i18n/ru.yaml | 2 + assets/i18n/zh-CN.yaml | 2 + crates/oak-app/src/i18n.rs | 2 + .../oak-app/src/oakui/component/controls.rs | 6 + crates/oak-app/src/oakui/effectchain.rs | 101 +++ crates/oak-app/src/panels/ofx_params.rs | 727 ++++++++++++++++-- 12 files changed, 800 insertions(+), 52 deletions(-) diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index 35a43540b..446460890 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -237,6 +237,8 @@ "ofx.color.cancel": "Abbrechen" "ofx.color.invalid": "Ungültige Hexadezimalfarbe" "ofx.color.pick_viewer": "Aus dem Viewer auswählen" +"ofx.color.mode_rgb": "RGB" +"ofx.color.mode_hsv": "HSV" "dialog.cancel": "Abbrechen" "dialog.close": "Schließen" "dialog.ok": "OK" diff --git a/assets/i18n/en-US.yaml b/assets/i18n/en-US.yaml index 9f385ed0a..e7137a96c 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -237,6 +237,8 @@ "ofx.color.cancel": "Cancel" "ofx.color.invalid": "Invalid hex color" "ofx.color.pick_viewer": "Pick from viewer" +"ofx.color.mode_rgb": "RGB" +"ofx.color.mode_hsv": "HSV" "dialog.cancel": "Cancel" "dialog.close": "Close" "dialog.ok": "OK" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index 15afe1002..61006e5e7 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -237,6 +237,8 @@ "ofx.color.cancel": "Cancelar" "ofx.color.invalid": "Color hexadecimal no válido" "ofx.color.pick_viewer": "Seleccionar del visor" +"ofx.color.mode_rgb": "RGB" +"ofx.color.mode_hsv": "HSV" "dialog.cancel": "Cancelar" "dialog.close": "Cerrar" "dialog.ok": "Aceptar" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index b2767a1f5..62ee3d657 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -237,6 +237,8 @@ "ofx.color.cancel": "Annuler" "ofx.color.invalid": "Couleur hexadécimale invalide" "ofx.color.pick_viewer": "Choisir dans le visualiseur" +"ofx.color.mode_rgb": "RGB" +"ofx.color.mode_hsv": "HSV" "dialog.cancel": "Annuler" "dialog.close": "Fermer" "dialog.ok": "OK" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index 07b1ee757..26fee3466 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -237,6 +237,8 @@ "ofx.color.cancel": "キャンセル" "ofx.color.invalid": "無効な16進カラーです" "ofx.color.pick_viewer": "ビューアから拾う" +"ofx.color.mode_rgb": "RGB" +"ofx.color.mode_hsv": "HSV" "dialog.cancel": "キャンセル" "dialog.close": "閉じる" "dialog.ok": "OK" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index 6a1f5d55e..346b9eef4 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -237,6 +237,8 @@ "ofx.color.cancel": "Cancelar" "ofx.color.invalid": "Cor hexadecimal inválida" "ofx.color.pick_viewer": "Escolher do visualizador" +"ofx.color.mode_rgb": "RGB" +"ofx.color.mode_hsv": "HSV" "dialog.cancel": "Cancelar" "dialog.close": "Fechar" "dialog.ok": "OK" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index 961453bc3..87c712e23 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -237,6 +237,8 @@ "ofx.color.cancel": "Отмена" "ofx.color.invalid": "Недопустимый шестнадцатеричный цвет" "ofx.color.pick_viewer": "Выбрать из просмотрщика" +"ofx.color.mode_rgb": "RGB" +"ofx.color.mode_hsv": "HSV" "dialog.cancel": "Отмена" "dialog.close": "Закрыть" "dialog.ok": "ОК" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index c0f9da481..3eb2daa22 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -237,6 +237,8 @@ "ofx.color.cancel": "取消" "ofx.color.invalid": "无效的十六进制颜色" "ofx.color.pick_viewer": "从画面取色" +"ofx.color.mode_rgb": "RGB" +"ofx.color.mode_hsv": "HSV" "dialog.cancel": "取消" "dialog.close": "关闭" "dialog.ok": "确定" diff --git a/crates/oak-app/src/i18n.rs b/crates/oak-app/src/i18n.rs index 8d6299abc..0245d6e38 100644 --- a/crates/oak-app/src/i18n.rs +++ b/crates/oak-app/src/i18n.rs @@ -331,6 +331,8 @@ mod tests { "Vulkan", "MP4", "OpenFX", + "RGB", + "HSV", "1/2", "1/4", "1/8", diff --git a/crates/oak-app/src/oakui/component/controls.rs b/crates/oak-app/src/oakui/component/controls.rs index 145403639..fb46fddc5 100644 --- a/crates/oak-app/src/oakui/component/controls.rs +++ b/crates/oak-app/src/oakui/component/controls.rs @@ -159,6 +159,12 @@ impl Slider { self.model.set_value(value); } + /// Replace the range model without rebuilding the entity — the colour + /// picker re-ranges its primary sliders when switching RGB ↔ HSV. + pub fn set_model(&mut self, model: SliderModel) { + self.model = model; + } + /// The current value. pub fn value(&self) -> SliderValue { self.model.value() diff --git a/crates/oak-app/src/oakui/effectchain.rs b/crates/oak-app/src/oakui/effectchain.rs index c43a03ff2..d617b57e0 100644 --- a/crates/oak-app/src/oakui/effectchain.rs +++ b/crates/oak-app/src/oakui/effectchain.rs @@ -923,6 +923,107 @@ mod tests { oak_undo::global::clear().unwrap(); } + /// The plugin-node colour loop, shaped exactly like the OFX translation + /// pass's `build_core` (default cached at element 0 AND on + /// `Input::default`; the panel reads/writes element -1; the renderer's + /// traverser fills unconnected inputs from `value_at_time(id, -1)`): + /// the inspector's commit must echo back through `effect_params` and + /// reach the render-side input row, or the plugin would keep rendering + /// the old colour while the swatch snapped back. + #[test] + fn plugin_color_param_set_read_round_trip() { + let _g = stack_lock(); + oak_undo::global::clear().unwrap(); + let (project, _host) = project_with_clip(); + + // A plugin-shaped node: colour param + texture clip input, defaults + // cached at element 0 and on the input (node_factory `build_core`). + let plugin = { + let mut g = lock(&project); + let mut core = oak_node::node::NodeCore::new(); + let default = oak_node::value::NodeValue::Color([1.0, 0.0, 0.0, 1.0]); + core.add_input(oak_node::input::Input::new( + "TintColor", + oak_node::value::ValueType::Color, + default.clone(), + )); + core.set_standard_value("TintColor", 0, default); + core.add_input(oak_node::input::Input::new( + "Source", + oak_node::value::ValueType::Texture, + oak_node::value::NodeValue::None, + )); + core.effect_input = "Source".to_string(); + let behavior = Box::new(oak_node::nodes::plugin::PluginNode::new( + oak_node::nodes::plugin::PluginInstanceHandle::null(), + "Tint".to_string(), + "org.example.tint".to_string(), + "A test tint plugin".to_string(), + "Filter".to_string(), + )); + g.graph.add_node(core, behavior) + }; + + // The snapshot lists the colour param under the OFX param name. + let params = effect_params(&lock(&project).graph, plugin).unwrap(); + let param = params + .iter() + .find(|p| p.input_id == "TintColor") + .expect("the plugin colour param appears in the snapshot"); + assert_eq!(param.value_type, oak_node::value::ValueType::Color); + assert_eq!( + param.value, + oak_node::value::NodeValue::Color([1.0, 0.0, 0.0, 1.0]), + "the panel must open with the plugin's default colour" + ); + + // Commit the way `OfxColorPicker::commit` does (f64 channels from + // the f32 draft). + let color = oak_node::value::NodeValue::Color([0.25, 0.5, 0.75, 0.4]); + set_input_value(&project, plugin, "TintColor", color.clone()).unwrap(); + let snapshot = effect_params(&lock(&project).graph, plugin).unwrap(); + let read = snapshot + .iter() + .find(|p| p.input_id == "TintColor") + .unwrap(); + assert_eq!(read.value, color, "the engine must echo the committed colour"); + + // The render-side read: the traverser fills unconnected inputs from + // `value_at_time(id, -1)`, which the plugin job payload then carries + // to `apply_param_overrides`. + let render_read = lock(&project) + .graph + .get(plugin) + .unwrap() + .core + .value_at_time("TintColor", -1, oak_core::Rational::new(0, 1)); + assert_eq!(render_read, color, "the renderer must see the committed colour"); + + // Undo restores the default; redo reapplies the commit. + oak_undo::global::undo().unwrap(); + assert_eq!( + lock(&project) + .graph + .get(plugin) + .unwrap() + .core + .standard_value("TintColor", -1), + oak_node::value::NodeValue::Color([1.0, 0.0, 0.0, 1.0]) + ); + oak_undo::global::redo().unwrap(); + assert_eq!( + lock(&project) + .graph + .get(plugin) + .unwrap() + .core + .standard_value("TintColor", -1), + color + ); + + oak_undo::global::clear().unwrap(); + } + /// The combo-option collector reads the repeated `("combo_option", _)` /// property keys (and the string-combo values from `("combo_value", _)`). #[test] diff --git a/crates/oak-app/src/panels/ofx_params.rs b/crates/oak-app/src/panels/ofx_params.rs index 081c9d0e3..293558fa6 100644 --- a/crates/oak-app/src/panels/ofx_params.rs +++ b/crates/oak-app/src/panels/ofx_params.rs @@ -57,9 +57,8 @@ use gpui::{ use gpui_elements::editable_text::{EditableTextState, StringStorage}; use crate::oakui::component::controls::{CheckBox, CheckBoxEvent, CheckState}; use crate::oakui::component::controls::{ComboBox, ComboBoxEvent, ComboBoxOption}; -use crate::oakui::component::controls::{Slider, SliderEvent, SliderModel}; +use crate::oakui::component::controls::{Slider, SliderEvent, SliderModel, SliderValue, ValueKind}; use crate::oakui::component::controls::{SpinBox, SpinBoxEvent}; -use crate::oakui::component::controls::{SliderValue, ValueKind}; use oak_node::value::{NodeValue, ValueType}; @@ -1038,9 +1037,10 @@ pub enum OfxColorEvent { /// /// - The swatch shows the **committed** colour (the engine value) over a /// two-tone checkerboard so alpha is visible. -/// - Clicking opens a deferred popup with four 0..1 channel sliders, a -/// live preview swatch, a hex field (`#RRGGBB` / `#RRGGBBAA`, validated) -/// and Cancel / OK buttons. +/// - Clicking opens a deferred popup with a Photoshop-style palette (an +/// S/V square + hue bar, switchable to RGB sliders), a live preview +/// swatch, a hex field (`#RRGGBB` / `#RRGGBBAA`, validated) and +/// Cancel / OK buttons. /// - Slider drags only mutate the **draft**; only OK emits /// [`OfxColorEvent::Committed`], which the params view routes through /// [`AppEngine::set_effect_param`] (undoable). A drag session is @@ -1066,10 +1066,13 @@ pub struct OfxColorPicker { /// Whether the popup was open when the swatch was pressed (a second /// click closes it). was_open_at_down: bool, - /// Channel sliders, R/G/B/A in 0..1. - r: Entity, - g: Entity, - b: Entity, + /// The editing mode of the primary sliders (RGB channels or HSV). + mode: ColorMode, + /// Primary channel sliders (R/G/B or H/S/V, re-ranged on mode switch). + c0: Entity, + c1: Entity, + c2: Entity, + /// The alpha slider (always 0..1). a: Entity, /// The hex editor (`#RRGGBB` / `#RRGGBBAA`). hex: Entity, @@ -1078,6 +1081,15 @@ pub struct OfxColorPicker { picking: bool, } +/// How the primary channel sliders present the colour. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorMode { + /// Red / Green / Blue, each 0..1. + Rgb, + /// Hue (0..360°) / Saturation / Value (0..1). + Hsv, +} + impl OfxColorPicker { /// Create a picker for `control` showing `color`. pub(crate) fn new( @@ -1094,10 +1106,11 @@ impl OfxColorPicker { draft: color, hex_error: false, was_open_at_down: false, - r: Self::channel_slider(cx, window, 0, color.r as f64), - g: Self::channel_slider(cx, window, 1, color.g as f64), - b: Self::channel_slider(cx, window, 2, color.b as f64), - a: Self::channel_slider(cx, window, 3, color.a as f64), + mode: ColorMode::Rgb, + c0: Self::channel_slider(cx, window, 0, 0.0, 1.0, 0.01, color.r as f64), + c1: Self::channel_slider(cx, window, 1, 0.0, 1.0, 0.01, color.g as f64), + c2: Self::channel_slider(cx, window, 2, 0.0, 1.0, 0.01, color.b as f64), + a: Self::channel_slider(cx, window, 3, 0.0, 1.0, 0.01, color.a as f64), hex: cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)), picking: false, }; @@ -1106,15 +1119,18 @@ impl OfxColorPicker { picker } - /// Build a 0..1 float slider for one channel and subscribe its edits to - /// [`Self::on_slider`]. + /// Build a float slider over `min..=max` with `step` for one channel and + /// subscribe its edits to [`Self::on_slider`]. fn channel_slider( cx: &mut Context, window: &mut Window, channel: usize, + min: f64, + max: f64, + step: f64, value: f64, ) -> Entity { - let model = SliderModel::new(ValueKind::Float, 0.0, 1.0, 0.01, value.clamp(0.0, 1.0)); + let model = SliderModel::new(ValueKind::Float, min, max, step, value.clamp(min, max)); let slider = cx.new(|cx| Slider::new(channel * 1000 + 100, model, window, cx)); cx.subscribe( &slider, @@ -1127,39 +1143,99 @@ impl OfxColorPicker { } /// A slider changed: update the draft channel and re-format the hex - /// field (no commit — the value only lands on OK). + /// field (no commit — the value only lands on OK). In HSV mode the + /// primary channels edit hue/saturation/value and write the derived RGB + /// back into the draft. fn on_slider(&mut self, channel: usize, event: &SliderEvent, cx: &mut Context) { if let SliderEvent::ValueChanged { value, .. } = event { - let v = value.to_f64().clamp(0.0, 1.0) as f32; - match channel { - 0 => self.draft.r = v, - 1 => self.draft.g = v, - 2 => self.draft.b = v, - 3 => self.draft.a = v, - _ => return, - } - self.hex_error = false; - let hex_entity = self.hex.clone(); - let hex_text = format_hex(self.draft); - hex_entity.update(cx, |hex, cx| { - if hex.as_str() != hex_text { - hex.emplace(&hex_text, cx); + match self.mode { + ColorMode::Rgb => { + let v = value.to_f64().clamp(0.0, 1.0) as f32; + match channel { + 0 => self.apply_draft_rgb(v, self.draft.g, self.draft.b, cx), + 1 => self.apply_draft_rgb(self.draft.r, v, self.draft.b, cx), + 2 => self.apply_draft_rgb(self.draft.r, self.draft.g, v, cx), + 3 => self.apply_draft_alpha(v, cx), + _ => return, + } } - }); - cx.notify(); + ColorMode::Hsv => { + let (mut h, mut s, mut v) = + rgb_to_hsv(self.draft.r, self.draft.g, self.draft.b); + match channel { + 0 => h = value.to_f64().clamp(0.0, 360.0) as f32, + 1 => s = value.to_f64().clamp(0.0, 1.0) as f32, + 2 => v = value.to_f64().clamp(0.0, 1.0) as f32, + 3 => { + self.apply_draft_alpha(value.to_f64().clamp(0.0, 1.0) as f32, cx); + return; + } + _ => return, + } + let rgb = hsv_to_rgb(h, s, v); + self.apply_draft_rgb(rgb.r, rgb.g, rgb.b, cx); + } + } } } + /// Set the draft RGB from a computed colour and refresh the hex field. + fn apply_draft_rgb(&mut self, r: f32, g: f32, b: f32, cx: &mut Context) { + self.draft.r = r; + self.draft.g = g; + self.draft.b = b; + self.refresh_hex(cx); + } + + /// Set the draft alpha and refresh the hex field. + fn apply_draft_alpha(&mut self, a: f32, cx: &mut Context) { + self.draft.a = a; + self.refresh_hex(cx); + } + + /// Clear the hex error, re-format the hex field from the draft and + /// repaint (shared by every draft mutation). + fn refresh_hex(&mut self, cx: &mut Context) { + self.hex_error = false; + let hex_entity = self.hex.clone(); + let hex_text = format_hex(self.draft); + hex_entity.update(cx, |hex, cx| { + if hex.as_str() != hex_text { + hex.emplace(&hex_text, cx); + } + }); + cx.notify(); + } + + /// S/V palette click / drag: keep the draft's hue, take the picked + /// saturation / value, write the result back into the draft. + fn on_palette(&mut self, s: f32, v: f32, cx: &mut Context) { + let (h, _, _) = rgb_to_hsv(self.draft.r, self.draft.g, self.draft.b); + let rgb = hsv_to_rgb(h, s, v); + self.apply_draft_rgb(rgb.r, rgb.g, rgb.b, cx); + } + + /// Hue bar click / drag: keep the draft's saturation / value, take the + /// picked hue, write the result back into the draft. + fn on_hue(&mut self, h: f32, cx: &mut Context) { + let (_, s, v) = rgb_to_hsv(self.draft.r, self.draft.g, self.draft.b); + let rgb = hsv_to_rgb(h, s, v); + self.apply_draft_rgb(rgb.r, rgb.g, rgb.b, cx); + } + /// Re-sync the sliders and the hex field from the current draft (no - /// events emitted; used when the draft is reset or committed). + /// events emitted; used when the draft is reset or committed). In HSV + /// mode the primary sliders show the hue/saturation/value of the draft. fn sync_from_draft(&self, cx: &mut Context) { - let values = [ - self.draft.r as f64, - self.draft.g as f64, - self.draft.b as f64, - self.draft.a as f64, - ]; - let sliders = [&self.r, &self.g, &self.b, &self.a]; + let (a, b, c) = match self.mode { + ColorMode::Rgb => (self.draft.r, self.draft.g, self.draft.b), + ColorMode::Hsv => { + let (h, s, v) = rgb_to_hsv(self.draft.r, self.draft.g, self.draft.b); + (h, s, v) + } + }; + let values = [a as f64, b as f64, c as f64, self.draft.a as f64]; + let sliders = [&self.c0, &self.c1, &self.c2, &self.a]; for (slider, value) in sliders.iter().zip(values.iter()) { let slider = slider.clone(); slider.update(cx, |slider, _| { @@ -1175,6 +1251,53 @@ impl OfxColorPicker { }); } + /// Switch the primary sliders between RGB and HSV editing. The draft is + /// preserved: the other mode's channels are derived from it. + fn set_mode(&mut self, mode: ColorMode, cx: &mut Context) { + if self.mode == mode { + return; + } + self.mode = mode; + self.rebuild_primary_sliders(cx); + self.sync_from_draft(cx); + cx.notify(); + } + + /// Re-range the three primary sliders for the current mode (RGB 0..1 / + /// HSV 0..360/0..1/0..1) without rebuilding the entities, so in-flight + /// subscriptions stay intact. + fn rebuild_primary_sliders(&mut self, cx: &mut Context) { + let (ranges, vals) = match self.mode { + ColorMode::Rgb => ( + [(0.0, 1.0, 0.01); 3], + [ + self.draft.r as f64, + self.draft.g as f64, + self.draft.b as f64, + ], + ), + ColorMode::Hsv => { + let (h, s, v) = rgb_to_hsv(self.draft.r, self.draft.g, self.draft.b); + ( + [(0.0, 360.0, 1.0), (0.0, 1.0, 0.01), (0.0, 1.0, 0.01)], + [h as f64, s as f64, v as f64], + ) + } + }; + let sliders = [&self.c0, &self.c1, &self.c2]; + for (slider, (range, value)) in sliders + .into_iter() + .zip(ranges.iter().zip(vals.iter())) + { + let (min, max, step) = *range; + let value = *value; + let slider = slider.clone(); + slider.update(cx, |slider, _| { + slider.set_model(SliderModel::new(ValueKind::Float, min, max, step, value)); + }); + } + } + /// Apply a committed colour from the engine (called every frame from /// the params view's value sync). While the popup is open the draft is /// left alone so an in-progress edit is not clobbered by re-syncs. @@ -1281,15 +1404,171 @@ impl OfxColorPicker { let draft = self.draft; let hex_weak = self.hex.downgrade(); let hex_error = self.hex_error; + let mode = self.mode; - // Four labelled 0..1 channel sliders. + // RGB / HSV mode tabs. + let tab = |this_mode: ColorMode| { + div() + .id(SharedString::from(format!( + "ofx-color-mode-{control}-{}", + if this_mode == ColorMode::Rgb { "rgb" } else { "hsv" } + ))) + .debug_selector(move || { + format!( + "ofx-color-mode-{control}-{}", + if this_mode == ColorMode::Rgb { "rgb" } else { "hsv" } + ) + .into() + }) + .cursor_pointer() + .flex_1() + .rounded_sm() + .border_1() + .border_color(colors.border) + .bg(if mode == this_mode { + colors.selected + } else { + colors.background + }) + .text_sm() + .text_color(colors.text) + .flex() + .items_center() + .justify_center() + .py_1() + .child(crate::i18n::tr(if this_mode == ColorMode::Rgb { + "ofx.color.mode_rgb" + } else { + "ofx.color.mode_hsv" + })) + .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { + this.set_mode(this_mode, cx); + })) + }; + let tab_row = div().flex().gap_1().child(tab(ColorMode::Rgb)).child(tab(ColorMode::Hsv)); + + // Photoshop-style S/V palette + hue bar. Both record their layout + // bounds each frame (an invisible canvas) so a click / drag can map + // the cursor to a value. + let sv_bounds = std::sync::Arc::new(std::sync::RwLock::new(None::>)); + let sv_drag = std::sync::Arc::new(std::sync::RwLock::new(SvPaletteDrag)); + let (hue, _, _) = rgb_to_hsv(draft.r, draft.g, draft.b); + let record_sv = sv_bounds.clone(); + let sv_canvas = canvas( + move |b, _window, _cx| { + *record_sv.write().unwrap() = Some(b); + b + }, + move |b, _content, window, cx| paint_sv_palette(b, hue, window, cx), + ); + let sv_panel = div() + .id(ElementId::named_usize("ofx-color-sv-palette", control)) + .debug_selector(|| "ofx-color-sv-palette".into()) + .w(px(180.0)) + .h(px(180.0)) + .relative() + .rounded_md() + .border_1() + .border_color(colors.border) + .overflow_hidden() + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + { + let sv_bounds = sv_bounds.clone(); + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + let Some(bounds) = sv_bounds.read().unwrap().as_ref().copied() else { + return; + }; + let (s, v) = sv_from_point(bounds, event.position); + this.on_palette(s, v, cx); + }) + }, + ) + .on_drag(sv_drag.clone(), |_payload, _offset, _window, cx| { + cx.new(|_| SvPaletteDragGhost) + }) + .on_drag_move( + { + let sv_bounds = sv_bounds.clone(); + cx.listener( + move |this, + event: &gpui::DragMoveEvent>>, + _window, + cx| { + let Some(bounds) = sv_bounds.read().unwrap().as_ref().copied() else { + return; + }; + let (s, v) = sv_from_point(bounds, event.event.position); + this.on_palette(s, v, cx); + }, + ) + }, + ) + .child(sv_canvas); + let hue_bounds = std::sync::Arc::new(std::sync::RwLock::new(None::>)); + let hue_drag = std::sync::Arc::new(std::sync::RwLock::new(HueBarDrag)); + let record_hue = hue_bounds.clone(); + let hue_canvas = canvas( + move |b, _window, _cx| { + *record_hue.write().unwrap() = Some(b); + b + }, + |b, _content, window, cx| paint_hue_bar(b, window, cx), + ); + let hue_bar = div() + .id(ElementId::named_usize("ofx-color-hue-bar", control)) + .debug_selector(|| "ofx-color-hue-bar".into()) + .w(px(18.0)) + .h(px(180.0)) + .relative() + .rounded_md() + .border_1() + .border_color(colors.border) + .overflow_hidden() + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + { + let hue_bounds = hue_bounds.clone(); + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + let Some(bounds) = hue_bounds.read().unwrap().as_ref().copied() else { + return; + }; + this.on_hue(hue_from_point(bounds, event.position), cx); + }) + }, + ) + .on_drag(hue_drag.clone(), |_payload, _offset, _window, cx| { + cx.new(|_| HueBarDragGhost) + }) + .on_drag_move( + { + let hue_bounds = hue_bounds.clone(); + cx.listener( + move |this, + event: &gpui::DragMoveEvent>>, + _window, + cx| { + let Some(bounds) = hue_bounds.read().unwrap().as_ref().copied() else { + return; + }; + this.on_hue(hue_from_point(bounds, event.event.position), cx); + }, + ) + }, + ) + .child(hue_canvas); + let palette_row = div().flex().gap_1().child(sv_panel).child(hue_bar); + + // Four labelled channel sliders — R/G/B/A or H/S/V/A depending on + // the mode. + let labels: [&str; 4] = match mode { + ColorMode::Rgb => ["R", "G", "B", "A"], + ColorMode::Hsv => ["H", "S", "V", "A"], + }; let mut slider_rows = div().flex().flex_col().gap_1(); - for (label, slider) in [ - ("R", &self.r), - ("G", &self.g), - ("B", &self.b), - ("A", &self.a), - ] { + for (label, slider) in labels.iter().zip([&self.c0, &self.c1, &self.c2, &self.a]) { slider_rows = slider_rows.child( div() .flex() @@ -1300,7 +1579,7 @@ impl OfxColorPicker { .w(px(12.0)) .text_sm() .text_color(colors.text) - .child(label), + .child(*label), ) .child(div().flex_1().child(slider.clone())), ); @@ -1413,7 +1692,7 @@ impl OfxColorPicker { .snap_to_window_with_margin(px(8.0)) .child( div() - .w(px(240.0)) + .w(px(300.0)) .p_2() .rounded_lg() .border_1() @@ -1426,7 +1705,14 @@ impl OfxColorPicker { .on_mouse_up_out( MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { - this.close_menu(cx); + // While the viewer eyedropper is armed, the click + // that follows is a *pick* on the program viewer, + // not an outside-click dismissal: the popup must + // survive it so the sampled colour lands in the + // draft (`apply_viewer_pick` disarms afterwards). + if !this.picking { + this.close_menu(cx); + } }), ) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { @@ -1438,6 +1724,8 @@ impl OfxColorPicker { } } })) + .child(tab_row) + .child(palette_row) .child(slider_rows) .child(hex_row) .child(error_hint) @@ -1572,6 +1860,155 @@ fn paint_checker_swatch( window.paint_quad(fill(bounds, Hsla::from(color))); } +/// Drag payload for the S/V palette. The palette reads its own layout +/// bounds from the recorded canvas, so the payload itself is empty. +struct SvPaletteDrag; + +/// The invisible ghost that accompanies an S/V palette drag (gpui requires +/// one for `on_drag`). +struct SvPaletteDragGhost; + +impl Render for SvPaletteDragGhost { + fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context) -> impl IntoElement { + div() + } +} + +/// Drag payload for the hue bar. +struct HueBarDrag; + +/// The invisible ghost that accompanies a hue-bar drag. +struct HueBarDragGhost; + +impl Render for HueBarDragGhost { + fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context) -> impl IntoElement { + div() + } +} + +/// Convert RGB (0..1 components) to HSV: hue in degrees (0..360, 0 when +/// the colour is achromatic), saturation 0..1, value 0..1. +fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let d = max - min; + let s = if max > 0.0 { d / max } else { 0.0 }; + let h = if d <= 0.0 { + 0.0 + } else if max == r { + ((g - b) / d).rem_euclid(6.0) * 60.0 + } else if max == g { + ((b - r) / d + 2.0) * 60.0 + } else { + ((r - g) / d + 4.0) * 60.0 + }; + (h, s, max) +} + +/// Convert HSV (hue in degrees) back to an opaque RGB [`Rgba`]. +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Rgba { + let h = h.rem_euclid(360.0); + let c = v * s; + let x = c * (1.0 - ((h / 60.0).rem_euclid(2.0) - 1.0).abs()); + let m = v - c; + let (r, g, b) = match (h / 60.0) as u32 { + 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), + }; + Rgba { + r: r + m, + g: g + m, + b: b + m, + a: 1.0, + } +} + +/// Map a cursor position to (saturation, value) in 0..1 — x rightwards, y +/// upwards (clamped to the palette bounds). +fn sv_from_point(bounds: Bounds, pos: Point) -> (f32, f32) { + let width = f32::from(bounds.size.width).max(1.0); + let height = f32::from(bounds.size.height).max(1.0); + let s = ((f32::from(pos.x) - f32::from(bounds.left())) / width).clamp(0.0, 1.0); + let v = 1.0 - ((f32::from(pos.y) - f32::from(bounds.top())) / height).clamp(0.0, 1.0); + (s, v) +} + +/// Inverse of [`sv_from_point`] — the palette position of a (s, v) pair. +fn point_from_sv(bounds: Bounds, s: f32, v: f32) -> Point { + let width = f32::from(bounds.size.width).max(1.0); + let height = f32::from(bounds.size.height).max(1.0); + point( + bounds.left() + px(s.clamp(0.0, 1.0) * width), + bounds.top() + px((1.0 - v.clamp(0.0, 1.0)) * height), + ) +} + +/// Map a cursor position on the hue bar to a hue in degrees (0..360, top +/// to bottom, clamped). +fn hue_from_point(bounds: Bounds, pos: Point) -> f32 { + let height = f32::from(bounds.size.height).max(1.0); + let t = ((f32::from(pos.y) - f32::from(bounds.top())) / height).clamp(0.0, 1.0); + t * 360.0 +} + +/// Paint the S/V palette: a 24×24 grid whose base colour is the hue at +/// full value, overlaid per row with a black fade (value 1.0 → 0.0). +fn paint_sv_palette(bounds: Bounds, hue: f32, window: &mut Window, _cx: &mut App) { + const GRID: u32 = 24; + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + let cw = width / GRID as f32; + let ch = height / GRID as f32; + for row in 0..GRID { + for col in 0..GRID { + let s = col as f32 / (GRID - 1) as f32; + let color = hsv_to_rgb(hue, s, 1.0); + let cell = Bounds::new( + point( + bounds.left() + px(col as f32 * cw), + bounds.top() + px(row as f32 * ch), + ), + size(px(cw + 1.0), px(ch + 1.0)), + ); + window.paint_quad(fill(cell, Hsla::from(color))); + } + // Black overlay fades the row's value from 1.0 down to 0.0. + let v = 1.0 - row as f32 / (GRID - 1) as f32; + let overlay = Rgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0 - v, + }; + let bar = Bounds::new( + point(bounds.left(), bounds.top() + px(row as f32 * ch)), + size(px(width), px(ch + 1.0)), + ); + window.paint_quad(fill(bar, Hsla::from(overlay))); + } +} + +/// Paint the hue bar: 36 vertical stripes spanning the full hue circle. +fn paint_hue_bar(bounds: Bounds, window: &mut Window, _cx: &mut App) { + const STRIPES: u32 = 36; + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + let ch = height / STRIPES as f32; + for i in 0..STRIPES { + let hue = i as f32 / STRIPES as f32 * 360.0; + let color = hsv_to_rgb(hue, 1.0, 1.0); + let cell = Bounds::new( + point(bounds.left(), bounds.top() + px(i as f32 * ch)), + size(px(width), px(ch + 1.0)), + ); + window.paint_quad(fill(cell, Hsla::from(color))); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1691,7 +2128,7 @@ mod tests { } cx.update(|cx| cx.init_colors()); - let window = cx.open_window(size(px(320.0), px(400.0)), |window, cx| { + let window = cx.open_window(size(px(320.0), px(560.0)), |window, cx| { let picker = cx.new(|cx| { OfxColorPicker::new( 1, @@ -1762,6 +2199,130 @@ mod tests { ); } + /// While the viewer eyedropper is armed, a click outside the popup is a + /// *pick* on the program viewer, not a dismissal: the popup must survive + /// it (no `Cancelled` emitted, the arm state kept), or the sampled colour + /// would be dropped by `apply_viewer_pick`'s `picking` guard. Once the + /// eyedropper is disarmed, an outside click dismisses as usual. + #[gpui::test] + async fn outside_click_while_picking_keeps_popup_open(cx: &mut TestAppContext) { + struct Host { + picker: Entity, + events: Arc>>, + _subscription: Subscription, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.picker.clone()) + } + } + + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(320.0), px(560.0)), |window, cx| { + let picker = cx.new(|cx| { + OfxColorPicker::new( + 1, + Rgba { + r: 0.4, + g: 0.2, + b: 0.8, + a: 0.5, + }, + window, + cx, + ) + }); + let events = Arc::new(Mutex::new(Vec::new())); + let events_sub = events.clone(); + let _subscription = cx.subscribe(&picker, move |_this, _emitter, event: &OfxColorEvent, _cx| { + events_sub.lock().unwrap().push(*event); + }); + Host { + picker, + events, + _subscription, + } + }); + cx.run_until_parked(); + + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx).into_mut(); + visual.update(|window, cx| { + window.draw(cx).clear(); + }); + + // Open the popup by clicking the swatch, then re-draw so the popup's + // deferred layer is laid out. + let swatch = visual.debug_bounds("ofx-color-swatch").expect("swatch painted"); + let swatch_center = Point::new( + swatch.origin.x + swatch.size.width * 0.5, + swatch.origin.y + swatch.size.height * 0.5, + ); + visual.simulate_click(swatch_center, Modifiers::default()); + visual.update(|window, cx| { + window.draw(cx).clear(); + }); + + // Arm the eyedropper. + let button = visual + .debug_bounds("ofx-color-pick-viewer-1") + .expect("pick button painted"); + let button_center = Point::new( + button.origin.x + button.size.width * 0.5, + button.origin.y + button.size.height * 0.5, + ); + visual.simulate_click(button_center, Modifiers::default()); + cx.run_until_parked(); + + // A click outside the popup (clear of its right/bottom edges, inside + // the window) while armed: the popup survives, the arm is kept, and no + // dismissal is emitted. + let popup = visual + .debug_bounds("ofx-color-popup") + .expect("popup painted"); + let outside = Point::new( + px((f32::from(popup.origin.x) + f32::from(popup.size.width) + 10.0).min(318.0)), + px((f32::from(popup.origin.y) + f32::from(popup.size.height) + 10.0).min(399.0)), + ); + visual.simulate_click(outside, Modifiers::default()); + cx.run_until_parked(); + let host = window.root(cx).expect("host root"); + let (open, picking, cancelled) = cx.read(|cx| { + let host = host.read(cx); + ( + host.picker.read(cx).open, + host.picker.read(cx).picking, + host.events + .lock() + .unwrap() + .iter() + .any(|e| matches!(e, OfxColorEvent::Cancelled)), + ) + }); + assert!(open, "the popup must survive an outside click while picking"); + assert!(picking, "the eyedropper stays armed through the pick"); + assert!(!cancelled, "no dismissal may be emitted while picking"); + + // Once disarmed (the pick landed / the button toggled again), an + // outside click dismisses the popup as usual. + visual.simulate_click(button_center, Modifiers::default()); + cx.run_until_parked(); + visual.simulate_click(outside, Modifiers::default()); + cx.run_until_parked(); + let (open, cancelled) = cx.read(|cx| { + let host = host.read(cx); + ( + host.picker.read(cx).open, + host.events + .lock() + .unwrap() + .iter() + .any(|e| matches!(e, OfxColorEvent::Cancelled)), + ) + }); + assert!(!open, "outside click dismisses the popup once disarmed"); + assert!(cancelled, "the dismissal emits Cancelled"); + } + /// Applying a viewer pick lands the sampled colour in the draft and /// disarms the eyedropper. #[gpui::test] @@ -1803,4 +2364,66 @@ mod tests { assert_eq!(draft, red, "viewer pick should land in the draft"); assert!(!picking, "a viewer pick disarms the eyedropper"); } + + /// Known-value checks + round-trips for the RGB↔HSV conversion. + #[test] + fn hsv_rgb_known_values() { + let hsv = |c: Rgba| rgb_to_hsv(c.r, c.g, c.b); + let close = |a: f32, b: f32| (a - b).abs() < 1e-4; + + // The six canonical corners of the RGB cube. + assert_eq!(hsv(Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }), (0.0, 1.0, 1.0)); + assert_eq!(hsv(Rgba { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }), (120.0, 1.0, 1.0)); + assert_eq!(hsv(Rgba { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }), (240.0, 1.0, 1.0)); + assert_eq!(hsv(Rgba { r: 1.0, g: 1.0, b: 0.0, a: 1.0 }), (60.0, 1.0, 1.0)); + assert_eq!(hsv(Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }), (0.0, 0.0, 1.0)); + assert_eq!(hsv(Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), (0.0, 0.0, 0.0)); + + // The same corners back to RGB. + let rgb = |h: f32, s: f32, v: f32| hsv_to_rgb(h, s, v); + assert_eq!(rgb(0.0, 1.0, 1.0), Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }); + assert_eq!(rgb(120.0, 1.0, 1.0), Rgba { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }); + assert_eq!(rgb(240.0, 1.0, 1.0), Rgba { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }); + + // Arbitrary RGB round-trips. + for (r, g, b) in [(0.5, 0.25, 0.75), (0.1, 0.9, 0.2), (0.33, 0.67, 0.44)] { + let (h, s, v) = rgb_to_hsv(r, g, b); + let back = hsv_to_rgb(h, s, v); + assert!( + close(back.r, r) && close(back.g, g) && close(back.b, b), + "RGB round-trip failed for ({r}, {g}, {b}) -> {back:?}" + ); + } + + // Arbitrary HSV round-trips (the hue may land on the other side of + // 0° / 360°, hence the looser tolerance). + for (h, s, v) in [(30.0, 0.5, 0.5), (200.0, 0.3, 0.8), (345.0, 1.0, 0.2)] { + let rgb = hsv_to_rgb(h, s, v); + let (h2, s2, v2) = rgb_to_hsv(rgb.r, rgb.g, rgb.b); + assert!( + (h2 - h).abs() < 1e-3 && (s2 - s).abs() < 1e-4 && (v2 - v).abs() < 1e-4, + "HSV round-trip failed for ({h}, {s}, {v}) -> ({h2}, {s2}, {v2})" + ); + } + } + + /// The palette ↔ cursor mapping: corners, centre and out-of-bounds + /// clamping. + #[test] + fn sv_point_mapping() { + let bounds = Bounds::new(point(px(10.0), px(20.0)), size(px(100.0), px(50.0))); + let at = |x: f32, y: f32| point(px(x), px(y)); + + assert_eq!(sv_from_point(bounds, at(10.0, 20.0)), (0.0, 1.0)); + assert_eq!(sv_from_point(bounds, at(110.0, 70.0)), (1.0, 0.0)); + assert_eq!(sv_from_point(bounds, at(60.0, 45.0)), (0.5, 0.5)); + // Out of bounds clamps to the edges. + assert_eq!(sv_from_point(bounds, at(0.0, 0.0)), (0.0, 1.0)); + assert_eq!(sv_from_point(bounds, at(500.0, 500.0)), (1.0, 0.0)); + + assert_eq!(point_from_sv(bounds, 0.0, 1.0), at(10.0, 20.0)); + assert_eq!(point_from_sv(bounds, 1.0, 0.0), at(110.0, 70.0)); + assert_eq!(point_from_sv(bounds, 0.5, 0.5), at(60.0, 45.0)); + assert_eq!(point_from_sv(bounds, -1.0, 2.0), at(10.0, 20.0)); + } }