From d65b84a6ca960cb83464b25add51abc8434a74df Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 27 Aug 2026 17:34:28 +0800 Subject: [PATCH] app: live color swatch and a project-media eyedropper in the color picker - the swatch now follows the draft color while the popup is open (cancel still reverts) and set_effect_param failures are logged instead of silently reverting the swatch - the popup lists project footage thumbnails; clicking a pixel picks its color into the draft (contain-fit coordinate mapping, no new dependencies) - i18n: ofx.color.pick_project in all eight packs --- assets/i18n/de.yaml | 1 + assets/i18n/en-US.yaml | 1 + assets/i18n/es.yaml | 1 + assets/i18n/fr.yaml | 1 + assets/i18n/ja.yaml | 1 + assets/i18n/pt.yaml | 1 + assets/i18n/ru.yaml | 1 + assets/i18n/zh-CN.yaml | 1 + crates/oak-app/src/oakui/effectchain.rs | 66 ++++ crates/oak-app/src/panels/ofx_params.rs | 388 +++++++++++++++++++++++- 10 files changed, 455 insertions(+), 7 deletions(-) diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index d77a9a0f4..3fb6103cb 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -236,6 +236,7 @@ "ofx.color.ok": "Okay" "ofx.color.cancel": "Abbrechen" "ofx.color.invalid": "Ungültige Hexadezimalfarbe" +"ofx.color.pick_project": "Aus Projekt auswählen" "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 547d8ab2b..529cbe01a 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -236,6 +236,7 @@ "ofx.color.ok": "OK" "ofx.color.cancel": "Cancel" "ofx.color.invalid": "Invalid hex color" +"ofx.color.pick_project": "Pick from project" "dialog.cancel": "Cancel" "dialog.close": "Close" "dialog.ok": "OK" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index 399e1dbbc..e224cdd5b 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -236,6 +236,7 @@ "ofx.color.ok": "Aceptar" "ofx.color.cancel": "Cancelar" "ofx.color.invalid": "Color hexadecimal no válido" +"ofx.color.pick_project": "Seleccionar del proyecto" "dialog.cancel": "Cancelar" "dialog.close": "Cerrar" "dialog.ok": "Aceptar" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index 8543b5ac4..7db081fdb 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -236,6 +236,7 @@ "ofx.color.ok": "Valider" "ofx.color.cancel": "Annuler" "ofx.color.invalid": "Couleur hexadécimale invalide" +"ofx.color.pick_project": "Choisir dans le projet" "dialog.cancel": "Annuler" "dialog.close": "Fermer" "dialog.ok": "OK" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index a47c6241b..488a467f7 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -236,6 +236,7 @@ "ofx.color.ok": "決定" "ofx.color.cancel": "キャンセル" "ofx.color.invalid": "無効な16進カラーです" +"ofx.color.pick_project": "プロジェクトから拾う" "dialog.cancel": "キャンセル" "dialog.close": "閉じる" "dialog.ok": "OK" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index ff7aedc82..7387742c6 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -236,6 +236,7 @@ "ofx.color.ok": "Confirmar" "ofx.color.cancel": "Cancelar" "ofx.color.invalid": "Cor hexadecimal inválida" +"ofx.color.pick_project": "Escolher do projeto" "dialog.cancel": "Cancelar" "dialog.close": "Fechar" "dialog.ok": "OK" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index aa9562564..d8ae25629 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -236,6 +236,7 @@ "ofx.color.ok": "ОК" "ofx.color.cancel": "Отмена" "ofx.color.invalid": "Недопустимый шестнадцатеричный цвет" +"ofx.color.pick_project": "Выбрать из проекта" "dialog.cancel": "Отмена" "dialog.close": "Закрыть" "dialog.ok": "ОК" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index e129b331e..cf2ebb160 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -236,6 +236,7 @@ "ofx.color.ok": "确定" "ofx.color.cancel": "取消" "ofx.color.invalid": "无效的十六进制颜色" +"ofx.color.pick_project": "从项目取色" "dialog.cancel": "取消" "dialog.close": "关闭" "dialog.ok": "确定" diff --git a/crates/oak-app/src/oakui/effectchain.rs b/crates/oak-app/src/oakui/effectchain.rs index 03ca2668b..c43a03ff2 100644 --- a/crates/oak-app/src/oakui/effectchain.rs +++ b/crates/oak-app/src/oakui/effectchain.rs @@ -857,6 +857,72 @@ mod tests { oak_undo::global::clear().unwrap(); } + /// The inspector's colour-parameter loop: the value the UI commits + /// (`NodeValue::Color` built from `f32` channels) must read back + /// **exactly** through `effect_params` — the per-frame value sync + /// (`sync_values` → `set_committed`) pulls the swatch back to the engine + /// value whenever the readback differs, so any rounding here would make + /// a freshly committed colour snap back to the old one. + #[test] + fn color_param_set_read_round_trip() { + let _g = stack_lock(); + oak_undo::global::clear().unwrap(); + let (project, host) = project_with_clip(); + + // A built-in video effect whose scratch core exposes a colour input + // (the inspector's swatch picker handles `ValueType::Color`). + let ty = addable_effects() + .into_iter() + .find(|entry| { + let (core, _behavior) = oak_node::factory::Factory::global() + .create_any(&entry.type_id) + .expect("the entry resolves"); + core.inputs.iter().any(|i| { + i.value_type == oak_node::value::ValueType::Color + && i.flags & oak_node::input::flags::HIDDEN == 0 + }) + }) + .expect("at least one built-in effect exposes a colour input") + .type_id; + let eff = insert(&project, host, 0, &ty).unwrap(); + + let input_id = { + let g = lock(&project); + g.graph + .get(eff) + .and_then(|e| { + e.core.inputs.iter().find(|i| { + i.value_type == oak_node::value::ValueType::Color + && i.flags & oak_node::input::flags::HIDDEN == 0 + }) + }) + .map(|i| i.id.clone()) + .expect("the effect exposes a colour input") + }; + + // The snapshot lists the param under the same id the UI writes to. + let params = effect_params(&lock(&project).graph, eff).unwrap(); + let param = params + .iter() + .find(|p| p.input_id == input_id) + .expect("the colour param appears in the snapshot"); + assert_eq!(param.value_type, oak_node::value::ValueType::Color); + + // Commit the way `OfxColorPicker::commit` does: f32 channels widened + // to f64. The readback (f64 → f32) must round-trip exactly, or the + // swatch would snap back on the next `sync_values` pass. + let color = NodeValue::Color([0.25, 0.5, 0.75, 0.4]); + set_input_value(&project, eff, &input_id, color.clone()).unwrap(); + let snapshot = effect_params(&lock(&project).graph, eff).unwrap(); + let read = snapshot + .iter() + .find(|p| p.input_id == input_id) + .unwrap(); + assert_eq!(read.value, color, "the engine must echo the committed colour"); + + 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 50047b895..91ed6b7da 100644 --- a/crates/oak-app/src/panels/ofx_params.rs +++ b/crates/oak-app/src/panels/ofx_params.rs @@ -41,13 +41,14 @@ //! in-progress slider drags); the view observes the engine and re-syncs //! the widget values from the engine snapshot on every render. -use std::sync::Arc; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; use crate::oakui::component::text_input; use gpui::effect_stack::EffectId; use gpui::colors::DefaultColors; use gpui::{ - div, prelude::*, px, rgb, size, point, ClickEvent, Context, Entity, EventEmitter, Render, + div, img, prelude::*, px, rgb, size, point, ClickEvent, Context, Entity, EventEmitter, Render, SharedString, Window, }; use gpui::{ @@ -750,7 +751,13 @@ fn wire_controls(view: &OfxParamsView, cx: &mut Context Render for OfxParamsView { row.into_any_element() } ControlKind::Color(picker) => { + // Refresh the "pick from project" strip from the + // engine's bin every frame (equality inside the + // picker short-circuits unchanged media). + let media = collect_project_media(self.engine.read(cx)); + picker.update(cx, |picker, cx| picker.set_project_media(media, cx)); div().flex_1().child(picker.clone()).into_any_element() } ControlKind::Text(editor) => { @@ -1004,6 +1016,103 @@ pub enum OfxColorEvent { Committed(Rgba), } +/// A project media entry offered by the colour picker's "pick from +/// project" strip (a flattened view of the bin's entries). +#[derive(Debug, Clone, PartialEq)] +pub struct ProjectMedia { + /// The entry's stable id (host-side key). + id: u64, + /// The display name. + name: String, + /// An asset path for the thumbnail, if any. + thumbnail: Option, +} + +impl From for ProjectMedia { + fn from(entry: gpui_widgets::project_explorer::ProjectEntry) -> Self { + Self { + id: entry.id, + name: entry.name.to_string(), + thumbnail: entry.thumbnail.map(|p| PathBuf::from(p.to_string())), + } + } +} + +/// Flatten the engine's project entries into the picker's media strip: a +/// directory root contributes its direct file children; a file root is +/// itself an entry. Matches the project explorer's icon view. +fn collect_project_media(engine: &E) -> Vec { + let mut media = Vec::new(); + for root in engine.roots() { + if root.is_dir { + for child in engine.children(root.id) { + if !child.is_dir { + media.push(ProjectMedia::from(child)); + } + } + } else { + media.push(ProjectMedia::from(root)); + } + } + media +} + +/// Sample an [`image::RgbaImage`] at unit coordinates (0..1 each); +/// out-of-range coordinates clamp to the nearest edge pixel. +fn sample_rgba8(img: &image::RgbaImage, u: f32, v: f32) -> Option { + let w = img.width(); + let h = img.height(); + if w == 0 || h == 0 { + return None; + } + let x = (u.clamp(0.0, 1.0) * (w - 1) as f32).round() as u32; + let y = (v.clamp(0.0, 1.0) * (h - 1) as f32).round() as u32; + let p = img.get_pixel(x, y); + Some(Rgba { + r: p[0] as f32 / 255.0, + g: p[1] as f32 / 255.0, + b: p[2] as f32 / 255.0, + a: p[3] as f32 / 255.0, + }) +} + +/// Map a point inside a `box_w` x `box_h` widget to unit coordinates in an +/// `img_w` x `img_h` image laid out with `ObjectFit::Contain` (the `img` +/// default). Returns `None` when the point falls in the letterbox. +fn contain_uv( + img_w: u32, + img_h: u32, + box_w: f32, + box_h: f32, + u: f32, + v: f32, +) -> Option<(f32, f32)> { + let (w, h) = (img_w as f32, img_h as f32); + if w <= 0.0 || h <= 0.0 || box_w <= 0.0 || box_h <= 0.0 { + return None; + } + let scale = (box_w / w).min(box_h / h); + let cw = w * scale; + let ch = h * scale; + let ox = (box_w - cw) / 2.0; + let oy = (box_h - ch) / 2.0; + let px = u * box_w - ox; + let py = v * box_h - oy; + if px < 0.0 || py < 0.0 || px > cw || py > ch { + return None; + } + Some((px / cw, py / ch)) +} + +/// Load `path` and sample it at unit coordinates, assuming the image is +/// laid out with `ObjectFit::Contain` in a `box_w` x `box_h` box. `None` +/// when the file can't be decoded or the point lands in the letterbox. +fn sample_pixel(path: &Path, box_w: f32, box_h: f32, u: f32, v: f32) -> Option { + let img = image::open(path).ok()?.to_rgba8(); + let (uu, vv) = contain_uv(img.width(), img.height(), box_w, box_h, u, v)?; + sample_rgba8(&img, uu, vv) +} + /// A colour swatch with a deferred popup picker, used for OFX colour /// parameters (the replacement for the old per-channel spinboxes). /// @@ -1044,6 +1153,8 @@ pub struct OfxColorPicker { a: Entity, /// The hex editor (`#RRGGBB` / `#RRGGBBAA`). hex: Entity, + /// The "pick from project" media strip (thumbnails to sample from). + media: Vec, } impl OfxColorPicker { @@ -1067,6 +1178,7 @@ impl OfxColorPicker { b: Self::channel_slider(cx, window, 2, color.b as f64), a: Self::channel_slider(cx, window, 3, color.a as f64), hex: cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)), + media: Vec::new(), }; let hex_text = format_hex(color); picker.hex.update(cx, |hex, cx| hex.emplace(&hex_text, cx)); @@ -1157,6 +1269,17 @@ impl OfxColorPicker { cx.notify(); } + /// Refresh the "pick from project" media strip. No-op when unchanged + /// (the params view pushes this every frame; equality short-circuits + /// the notify so the strip does not re-render endlessly). + pub(crate) fn set_project_media(&mut self, media: Vec, cx: &mut Context) { + if self.media == media { + return; + } + self.media = media; + cx.notify(); + } + fn open_menu(&mut self, position: Point, cx: &mut Context) { if !self.open { self.open = true; @@ -1175,6 +1298,9 @@ impl OfxColorPicker { if self.open { self.open = false; self.hex_error = false; + // Discard any in-progress draft edits so the sliders / swatch + // settle back on the committed colour. + self.draft = self.committed; self.sync_from_draft(cx); cx.emit(OfxColorEvent::Cancelled); cx.notify(); @@ -1318,6 +1444,85 @@ impl OfxColorPicker { })); let buttons = div().flex().justify_between().gap_1().child(cancel).child(ok); + // "Pick from project": a strip of bin thumbnails; clicking one + // samples the pixel under the cursor into the draft. Thumbnails are + // `img` (ObjectFit::Contain), and the strip's canvas captures the + // rendered bounds so the click can be mapped back into the image. + let media_section = if self.media.is_empty() { + div().into_any_element() + } else { + let mut strip = div().flex().flex_wrap().gap_1(); + for media in &self.media { + let Some(thumb) = &media.thumbnail else { + continue; + }; + let id = media.id; + let thumb = thumb.clone(); + let bounds_cell = Arc::new(Mutex::new(None::>)); + let bounds_cell_prepaint = bounds_cell.clone(); + let thumb_click = thumb.clone(); + strip = strip.child( + div() + .id(ElementId::named_usize("ofx-color-project-thumb", id as usize)) + .debug_selector(move || format!("ofx-color-project-thumb-{id}").into()) + .relative() + .w(px(72.0)) + .h(px(48.0)) + .overflow_hidden() + .rounded_sm() + .border_1() + .border_color(colors.border) + .cursor_pointer() + .child(img(thumb).w(px(72.0)).h(px(48.0))) + .child( + canvas( + move |bounds, _window, _cx| { + *bounds_cell_prepaint.lock().unwrap() = Some(bounds); + }, + |_bounds, _content, _window, _cx| {}, + ) + .absolute() + .inset_0(), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + let Some(bounds) = bounds_cell.lock().unwrap().take() else { + return; + }; + let pos = event.position - bounds.origin; + let u = f32::from(pos.x) / f32::from(bounds.size.width); + let v = f32::from(pos.y) / f32::from(bounds.size.height); + let Some(color) = sample_pixel(&thumb_click, 72.0, 48.0, u, v) + else { + return; + }; + this.draft = color; + this.hex_error = false; + this.sync_from_draft(cx); + cx.notify(); + }), + ), + ); + } + div() + .flex() + .flex_col() + .gap_1() + .pt_1() + .mt_1() + .border_t_1() + .border_color(colors.border) + .child( + div() + .text_xs() + .text_color(colors.text) + .child(crate::i18n::tr("ofx.color.pick_project")), + ) + .child(strip) + .into_any_element() + }; + deferred( anchored() .position(self.position) @@ -1350,7 +1555,8 @@ impl OfxColorPicker { .child(slider_rows) .child(hex_row) .child(error_hint) - .child(buttons), + .child(buttons) + .child(media_section), ), ) .with_priority(1) @@ -1363,7 +1569,9 @@ impl Render for OfxColorPicker { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); let control = self.control; - let committed = self.committed; + // While the popup is open the swatch follows the draft live (the + // user's in-progress edit), otherwise it shows the committed value. + let swatch_color = if self.open { self.draft } else { self.committed }; let swatch = div() .id(ElementId::named_usize("ofx-color-swatch", control)) @@ -1396,7 +1604,7 @@ impl Render for OfxColorPicker { .child(canvas( |bounds, _window, _cx| bounds, move |bounds, _content, window, cx| { - paint_checker_swatch(bounds, committed, window, cx); + paint_checker_swatch(bounds, swatch_color, window, cx); }, )); @@ -1481,7 +1689,7 @@ fn paint_checker_swatch( #[cfg(test)] mod tests { use super::*; - use gpui::TestAppContext; + use gpui::{Modifiers, TestAppContext}; #[test] fn hex_parsing_and_formatting() { @@ -1579,4 +1787,170 @@ mod tests { "the colour swatch should be painted" ); } + + #[test] + fn sample_rgba8_reads_corners() { + let mut img = image::RgbaImage::new(4, 2); + img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + img.put_pixel(3, 0, image::Rgba([0, 255, 0, 255])); + img.put_pixel(0, 1, image::Rgba([0, 0, 255, 255])); + img.put_pixel(3, 1, image::Rgba([255, 255, 255, 128])); + + let close = |a: Rgba, b: Rgba| { + (a.r - b.r).abs() < 1e-6 + && (a.g - b.g).abs() < 1e-6 + && (a.b - b.b).abs() < 1e-6 + && (a.a - b.a).abs() < 1e-6 + }; + + let red = Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }; + let green = Rgba { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }; + let blue = Rgba { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }; + let white_half = Rgba { r: 1.0, g: 1.0, b: 1.0, a: 128.0 / 255.0 }; + assert!(close(sample_rgba8(&img, 0.0, 0.0).unwrap(), red), "top-left"); + assert!(close(sample_rgba8(&img, 1.0, 0.0).unwrap(), green), "top-right"); + assert!(close(sample_rgba8(&img, 0.0, 1.0).unwrap(), blue), "bottom-left"); + assert!( + close(sample_rgba8(&img, 1.0, 1.0).unwrap(), white_half), + "bottom-right translucent" + ); + + // Out-of-range coordinates clamp to the nearest edge pixel. + assert!(close(sample_rgba8(&img, 2.0, -0.5).unwrap(), green), "clamps u/v"); + assert!(close(sample_rgba8(&img, -1.0, 2.0).unwrap(), blue), "clamps negative"); + // (0.5, 0.5) rounds to pixel (2, 1), which was never set (transparent + // black) — exercises the rounding path. + let mid = sample_rgba8(&img, 0.5, 0.5).unwrap(); + assert!(mid.r.abs() < 1e-6 && mid.a.abs() < 1e-6, "middle pixel unset"); + } + + #[test] + fn contain_uv_maps_letterbox() { + // A 4x3 image inside a 72x48 box: scale = min(18, 16) = 16, so the + // content is 64x48, letterboxed by 4px on each side. + assert_eq!(contain_uv(4, 3, 72.0, 48.0, 0.5, 0.5), Some((0.5, 0.5))); + assert_eq!(contain_uv(4, 3, 72.0, 48.0, 0.0, 0.5), None, "left letterbox"); + assert_eq!(contain_uv(4, 3, 72.0, 48.0, 1.0, 0.5), None, "right letterbox"); + // The content's right edge sits at u = 4/72 + 64/72. + let right = contain_uv(4, 3, 72.0, 48.0, 68.0 / 72.0, 0.5).unwrap(); + assert!((right.0 - 1.0).abs() < 1e-6 && (right.1 - 0.5).abs() < 1e-6); + // Degenerate boxes / images sample nothing. + assert_eq!(contain_uv(0, 3, 72.0, 48.0, 0.5, 0.5), None); + assert_eq!(contain_uv(4, 3, 0.0, 48.0, 0.5, 0.5), None); + } + + #[test] + fn sample_pixel_reads_png_file() { + let dir = std::env::temp_dir().join(format!("oak-ofx-sample-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("flat.png"); + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([0x1A, 0x80, 0xE6, 0xFF])); + img.save(&path).unwrap(); + + // 2x2 in a 72x48 box: scale = 24 -> 48x48 content, 12px side bars. + let c = sample_pixel(&path, 72.0, 48.0, 0.5, 0.5).expect("centre samples"); + assert!((c.r - 0x1A as f32 / 255.0).abs() < 1e-6); + assert!((c.g - 0x80 as f32 / 255.0).abs() < 1e-6); + assert!((c.b - 0xE6 as f32 / 255.0).abs() < 1e-6); + assert!((c.a - 1.0).abs() < 1e-6); + // A click in the left letterbox has no image pixel under it. + assert!(sample_pixel(&path, 72.0, 48.0, 0.0, 0.5).is_none()); + // A missing file samples nothing. + assert!(sample_pixel(&dir.join("nope.png"), 72.0, 48.0, 0.5, 0.5).is_none()); + + std::fs::remove_file(&path).unwrap(); + std::fs::remove_dir(&dir).unwrap(); + } + + /// Opening the popup and clicking a project thumbnail samples the pixel + /// under the cursor into the draft (the "pick from project" strip). + #[gpui::test] + async fn project_eyedropper_samples_into_draft(cx: &mut TestAppContext) { + struct Host { + picker: Entity, + } + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.picker.clone()) + } + } + + // 4x4 source image: red centre pixel, black elsewhere. In a 72x48 + // thumbnail (scale 12 -> 48x48 content) the strip's centre maps to + // image pixel (2, 2). + let dir = std::env::temp_dir().join(format!("oak-ofx-eyedrop-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("clip.png"); + let mut img = image::RgbaImage::new(4, 4); + img.put_pixel(2, 2, image::Rgba([255, 0, 0, 255])); + img.save(&path).unwrap(); + + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(320.0), px(400.0)), |window, cx| { + let picker = cx.new(|cx| { + OfxColorPicker::new( + 1, + Rgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + window, + cx, + ) + }); + Host { picker } + }); + 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. + 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()); + + // The params view normally pushes the media strip every frame; here + // the Host injects it directly, then re-draws so the strip lays out. + let host = window.root(cx).expect("host root"); + visual.update(|window, cx| { + host.update(cx, |host, cx| { + host.picker.update(cx, |picker, cx| { + picker.set_project_media( + vec![ProjectMedia { + id: 7, + name: "clip.png".into(), + thumbnail: Some(path.clone()), + }], + cx, + ) + }); + }); + window.draw(cx).clear(); + }); + + // Click the thumbnail's centre: samples the red pixel into the draft. + let thumb = visual + .debug_bounds("ofx-color-project-thumb-7") + .expect("thumbnail painted"); + let thumb_center = Point::new( + thumb.origin.x + thumb.size.width * 0.5, + thumb.origin.y + thumb.size.height * 0.5, + ); + visual.simulate_click(thumb_center, Modifiers::default()); + + let draft = cx.read(|cx| host.read(cx).picker.read(cx).draft); + assert!((draft.r - 1.0).abs() < 1e-6, "draft should sample red, got {draft:?}"); + assert!(draft.g.abs() < 1e-6 && draft.b.abs() < 1e-6, "no green/blue leaked"); + + std::fs::remove_file(&path).unwrap(); + std::fs::remove_dir(&dir).unwrap(); + } }