diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index 88a7d5953..0255d563a 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -236,7 +236,7 @@ "ofx.color.ok": "Okay" "ofx.color.cancel": "Abbrechen" "ofx.color.invalid": "Ungültige Hexadezimalfarbe" -"ofx.color.pick_project": "Aus Projekt auswählen" +"ofx.color.pick_viewer": "Aus dem Viewer 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 620081a84..067ef25fc 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -236,7 +236,7 @@ "ofx.color.ok": "OK" "ofx.color.cancel": "Cancel" "ofx.color.invalid": "Invalid hex color" -"ofx.color.pick_project": "Pick from project" +"ofx.color.pick_viewer": "Pick from viewer" "dialog.cancel": "Cancel" "dialog.close": "Close" "dialog.ok": "OK" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index db4e5b6f1..96d7bfaba 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -236,7 +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" +"ofx.color.pick_viewer": "Seleccionar del visor" "dialog.cancel": "Cancelar" "dialog.close": "Cerrar" "dialog.ok": "Aceptar" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index 56c37e458..3fd596df4 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -236,7 +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" +"ofx.color.pick_viewer": "Choisir dans le visualiseur" "dialog.cancel": "Annuler" "dialog.close": "Fermer" "dialog.ok": "OK" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index d152f073d..c63850a38 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -236,7 +236,7 @@ "ofx.color.ok": "決定" "ofx.color.cancel": "キャンセル" "ofx.color.invalid": "無効な16進カラーです" -"ofx.color.pick_project": "プロジェクトから拾う" +"ofx.color.pick_viewer": "ビューアから拾う" "dialog.cancel": "キャンセル" "dialog.close": "閉じる" "dialog.ok": "OK" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index a1f31cead..5dc076231 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -236,7 +236,7 @@ "ofx.color.ok": "Confirmar" "ofx.color.cancel": "Cancelar" "ofx.color.invalid": "Cor hexadecimal inválida" -"ofx.color.pick_project": "Escolher do projeto" +"ofx.color.pick_viewer": "Escolher do visualizador" "dialog.cancel": "Cancelar" "dialog.close": "Fechar" "dialog.ok": "OK" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index 9d60db7c5..1835fc7f0 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -236,7 +236,7 @@ "ofx.color.ok": "ОК" "ofx.color.cancel": "Отмена" "ofx.color.invalid": "Недопустимый шестнадцатеричный цвет" -"ofx.color.pick_project": "Выбрать из проекта" +"ofx.color.pick_viewer": "Выбрать из просмотрщика" "dialog.cancel": "Отмена" "dialog.close": "Закрыть" "dialog.ok": "ОК" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index 5fb49bf34..acea16a5b 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -236,7 +236,7 @@ "ofx.color.ok": "确定" "ofx.color.cancel": "取消" "ofx.color.invalid": "无效的十六进制颜色" -"ofx.color.pick_project": "从项目取色" +"ofx.color.pick_viewer": "从画面取色" "dialog.cancel": "取消" "dialog.close": "关闭" "dialog.ok": "确定" diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index 7b75f8be4..c065dd4be 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -40,7 +40,7 @@ use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent}; use gpui::timeline::{ ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackData, TrackKind, }; -use gpui::{App, Context, Entity, Pixels, Point, RenderImage}; +use gpui::{App, Context, Entity, Pixels, Point, Rgba, RenderImage}; use gpui_widgets::audio_meter::AudioMeterDataSource; use gpui_widgets::project_explorer::ProjectDataSource; use gpui_widgets::viewer::PlaybackClock; @@ -274,6 +274,21 @@ pub trait EngineClock: PlaybackClock + 'static {} impl EngineClock for T {} +/// The cross-panel mailbox for the viewer eyedropper. The OFX color picker +/// arms the program viewer's eyedropper, the viewer samples a pixel on click, +/// and the picker polls for the result — without `AppEngine` (or either +/// panel) having to know about the other. Stored as a gpui global so the +/// default-method implementation needs no state on the engine types. +#[derive(Default)] +struct EyedropperMailbox { + /// Whether the program viewer's eyedropper is armed. + armed: bool, + /// The color picked by the viewer, consumed by the picker. + result: Option, +} + +impl gpui::Global for EyedropperMailbox {} + /// The full app-facing engine surface: the gateway plus every widget /// data-source trait and the app-only operations (clocks, viewer frames, /// edits, undo/redo, file operations). @@ -1158,6 +1173,34 @@ pub trait AppEngine: /// The display name of the engine backend ("mock" / "real"), shown in /// the status bar. fn backend_name(&self) -> &'static str; + + /// Arms or disarms the program viewer's eyedropper. While armed the + /// viewer samples the pixel under the cursor on click and reports it via + /// [`Self::eyedropper_picked`]; the OFX color picker drives both. + fn set_eyedropper_armed(&mut self, armed: bool, cx: &mut Context) { + cx.default_global::().armed = armed; + cx.notify(); + } + + /// Whether the program viewer's eyedropper is currently armed. + fn eyedropper_armed(&self, cx: &App) -> bool { + cx.try_global::().is_some_and(|mailbox| mailbox.armed) + } + + /// Reports a color picked by the program viewer's eyedropper and + /// disarms it in the same move. + fn eyedropper_picked(&mut self, color: Rgba, cx: &mut Context) { + let mailbox = cx.default_global::(); + mailbox.result = Some(color); + mailbox.armed = false; + cx.notify(); + } + + /// Takes (and clears) the last picked color, if any. Polled by the OFX + /// color picker every frame while its picker is armed. + fn take_eyedropper_result(&mut self, cx: &mut Context) -> Option { + cx.default_global::().result.take() + } } /// The detected multicam state the Multicam panel displays (the C++ diff --git a/crates/oak-app/src/panels/ofx_params.rs b/crates/oak-app/src/panels/ofx_params.rs index 91ed6b7da..081c9d0e3 100644 --- a/crates/oak-app/src/panels/ofx_params.rs +++ b/crates/oak-app/src/panels/ofx_params.rs @@ -41,14 +41,13 @@ //! in-progress slider drags); the view observes the engine and re-syncs //! the widget values from the engine snapshot on every render. -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use crate::oakui::component::text_input; use gpui::effect_stack::EffectId; use gpui::colors::DefaultColors; use gpui::{ - div, img, prelude::*, px, rgb, size, point, ClickEvent, Context, Entity, EventEmitter, Render, + div, prelude::*, px, rgb, size, point, ClickEvent, Context, Entity, EventEmitter, Render, SharedString, Window, }; use gpui::{ @@ -192,6 +191,16 @@ impl OfxParamsView { let picker = picker.clone(); picker.update(cx, |picker, cx| picker.set_committed(color, cx)); } + // Drain a viewer eyedropper pick into the draft. The result + // is taken (not peeked) so an armed-but-unpicked picker + // keeps the previous value once the picker disarms. + if let Some(color) = self + .engine + .update(cx, |engine, cx| engine.take_eyedropper_result(cx)) + { + let picker = picker.clone(); + picker.update(cx, |picker, cx| picker.apply_viewer_pick(color, cx)); + } } ControlKind::Text(editor) => { let text = match ¶m.value { @@ -740,10 +749,10 @@ fn wire_controls(view: &OfxParamsView, cx: &mut Context { let picker = picker.clone(); - cx.subscribe(&picker, move |_, _, event: &OfxColorEvent, cx| { + cx.subscribe(&picker, move |_, _, event: &OfxColorEvent, cx| match event { // Only OK commits; slider drags update the draft inside // the picker, so a drag session is one undo row. - if let OfxColorEvent::Committed(color) = event { + OfxColorEvent::Committed(color) => { let nv = NodeValue::Color([ color.r as f64, color.g as f64, @@ -760,6 +769,15 @@ fn wire_controls(view: &OfxParamsView, cx: &mut Context { + engine.update(cx, |engine, cx| { + engine.set_eyedropper_armed(*armed, cx); + }); + } + // Open/close are purely local to the popup. + OfxColorEvent::Opened | OfxColorEvent::Cancelled => {} }) .detach(); } @@ -899,11 +917,6 @@ impl 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) => { @@ -1014,103 +1027,10 @@ pub enum OfxColorEvent { Cancelled, /// OK pressed: the caller should commit this colour (undoable). 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) + /// The viewer eyedropper was armed (`true`) or disarmed (`false`); the + /// params view routes it to the engine, which mirrors it into the + /// program viewer. + PickViewerToggle { armed: bool }, } /// A colour swatch with a deferred popup picker, used for OFX colour @@ -1153,8 +1073,9 @@ pub struct OfxColorPicker { a: Entity, /// The hex editor (`#RRGGBB` / `#RRGGBBAA`). hex: Entity, - /// The "pick from project" media strip (thumbnails to sample from). - media: Vec, + /// Whether the viewer eyedropper is armed (a click on the program + /// viewer samples a pixel back into the draft). + picking: bool, } impl OfxColorPicker { @@ -1165,7 +1086,7 @@ impl OfxColorPicker { window: &mut Window, cx: &mut Context, ) -> Self { - let mut picker = Self { + let picker = Self { control, open: false, position: Point::default(), @@ -1178,7 +1099,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(), + picking: false, }; let hex_text = format_hex(color); picker.hex.update(cx, |hex, cx| hex.emplace(&hex_text, cx)); @@ -1269,17 +1190,28 @@ 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; + /// Toggle the viewer eyedropper. While armed the program viewer samples + /// the pixel under the cursor on click; the params view routes the + /// toggle to the engine and polls [`AppEngine::take_eyedropper_result`] + /// every frame, so this picker stays in sync with the armed state. + fn toggle_viewer_pick(&mut self, cx: &mut Context) { + self.picking = !self.picking; + cx.emit(OfxColorEvent::PickViewerToggle { armed: self.picking }); cx.notify(); } + /// Apply a colour sampled from the program viewer into the draft and + /// disarm the eyedropper (the engine already cleared its armed flag). + pub(crate) fn apply_viewer_pick(&mut self, color: Rgba, cx: &mut Context) { + if self.picking { + self.draft = color; + self.picking = false; + self.hex_error = false; + self.sync_from_draft(cx); + cx.notify(); + } + } + fn open_menu(&mut self, position: Point, cx: &mut Context) { if !self.open { self.open = true; @@ -1293,8 +1225,13 @@ impl OfxColorPicker { } } - /// Cancel: discard the draft, keep the committed colour. + /// Cancel: discard the draft, keep the committed colour. Also disarms a + /// viewer eyedropper that was left armed. fn close_menu(&mut self, cx: &mut Context) { + if self.picking { + self.picking = false; + cx.emit(OfxColorEvent::PickViewerToggle { armed: false }); + } if self.open { self.open = false; self.hex_error = false; @@ -1307,8 +1244,13 @@ impl OfxColorPicker { } } - /// OK: validate a hand-typed hex edit, then commit the draft. + /// OK: validate a hand-typed hex edit, then commit the draft. Also + /// disarms a viewer eyedropper that was left armed. fn commit(&mut self, cx: &mut Context) { + if self.picking { + self.picking = false; + cx.emit(OfxColorEvent::PickViewerToggle { armed: false }); + } let text = self.hex.read(cx).as_str().trim().to_string(); if !text.is_empty() { match parse_hex(&text) { @@ -1444,84 +1386,24 @@ 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() - }; + // "Pick from viewer": arms the eyedropper in the program viewer; the + // next click on the frame samples the pixel under the cursor. + let pick_viewer = div() + .id(SharedString::from(format!("ofx-color-pick-viewer-{control}"))) + .debug_selector(move || format!("ofx-color-pick-viewer-{control}").into()) + .cursor_pointer() + .rounded_sm() + .border_1() + .border_color(colors.border) + .bg(if self.picking { colors.selected } else { colors.background }) + .text_sm() + .text_color(colors.text) + .px_2() + .py_1() + .child(crate::i18n::tr("ofx.color.pick_viewer")) + .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { + this.toggle_viewer_pick(cx); + })); deferred( anchored() @@ -1549,14 +1431,18 @@ impl OfxColorPicker { ) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { if event.keystroke.key == "escape" { - this.close_menu(cx); + if this.picking { + this.toggle_viewer_pick(cx); + } else { + this.close_menu(cx); + } } })) .child(slider_rows) .child(hex_row) .child(error_hint) .child(buttons) - .child(media_section), + .child(pick_viewer), ), ) .with_priority(1) @@ -1689,7 +1575,8 @@ fn paint_checker_swatch( #[cfg(test)] mod tests { use super::*; - use gpui::{Modifiers, TestAppContext}; + use gpui::{Modifiers, Subscription, TestAppContext}; + use std::sync::Mutex; #[test] fn hex_parsing_and_formatting() { @@ -1788,86 +1675,14 @@ mod tests { ); } - #[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). + /// The "pick from viewer" button toggles the eyedropper and emits + /// `PickViewerToggle` events that the params view routes to the engine. #[gpui::test] - async fn project_eyedropper_samples_into_draft(cx: &mut TestAppContext) { + async fn pick_viewer_button_toggles_armed(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 { @@ -1875,32 +1690,31 @@ mod tests { } } - // 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, + r: 0.4, + g: 0.2, + b: 0.8, + a: 0.5, }, window, cx, ) }); - Host { picker } + 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(); @@ -1909,48 +1723,84 @@ mod tests { window.draw(cx).clear(); }); - // Open the popup by clicking the swatch. + // 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()); - - // 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, + // Clicking "pick from viewer" arms 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(); + let host = window.root(cx).expect("host root"); + let events = cx.read(|cx| host.read(cx).events.lock().unwrap().clone()); + assert!( + matches!(events.last(), Some(OfxColorEvent::PickViewerToggle { armed: true })), + "clicking pick should arm the eyedropper, got {events:?}" ); - 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"); + // ...and a second click disarms it again. + visual.simulate_click(button_center, Modifiers::default()); + cx.run_until_parked(); + let events = cx.read(|cx| host.read(cx).events.lock().unwrap().clone()); + assert!( + matches!(events.last(), Some(OfxColorEvent::PickViewerToggle { armed: false })), + "second click should disarm the eyedropper, got {events:?}" + ); + } - std::fs::remove_file(&path).unwrap(); - std::fs::remove_dir(&dir).unwrap(); + /// Applying a viewer pick lands the sampled colour in the draft and + /// disarms the eyedropper. + #[gpui::test] + async fn apply_viewer_pick_updates_draft(cx: &mut TestAppContext) { + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(320.0), px(200.0)), |window, cx| { + OfxColorPicker::new( + 1, + Rgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + window, + cx, + ) + }); + cx.run_until_parked(); + + let picker = window.root(cx).expect("picker root"); + let red = Rgba { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }; + cx.update(|cx| { + picker.update(cx, |picker, cx| { + picker.toggle_viewer_pick(cx); + picker.apply_viewer_pick(red, cx); + }); + }); + + let (draft, picking) = cx.read(|cx| { + let picker = picker.read(cx); + (picker.draft, picker.picking) + }); + assert_eq!(draft, red, "viewer pick should land in the draft"); + assert!(!picking, "a viewer pick disarms the eyedropper"); } } diff --git a/crates/oak-app/src/panels/program_viewer.rs b/crates/oak-app/src/panels/program_viewer.rs index 688d027bd..9d1aacd10 100644 --- a/crates/oak-app/src/panels/program_viewer.rs +++ b/crates/oak-app/src/panels/program_viewer.rs @@ -156,6 +156,12 @@ impl ProgramViewerPanel { ViewerEvent::InPointRequested { .. } => cx.emit(menu::ViewerPanelEvent::SetInPoint), ViewerEvent::OutPointRequested { .. } => cx.emit(menu::ViewerPanelEvent::SetOutPoint), ViewerEvent::ClearRangeRequested { .. } => cx.emit(menu::ViewerPanelEvent::ClearRange), + // The OFX color picker armed the eyedropper and the user clicked + // the frame: hand the sampled colour back to the engine's mailbox. + ViewerEvent::EyedropperPick { color } => { + this.engine + .update(cx, |engine, cx| engine.eyedropper_picked(*color, cx)) + } event => { let monitor = Monitor::Program; this.engine.update(cx, |engine, cx| match event { @@ -168,6 +174,10 @@ impl ProgramViewerPanel { }) .detach(); + // Re-render whenever the engine's global eyedropper arm state flips, + // so `sync_frame` mirrors it into the viewer widget. + cx.observe(&engine, |_this, _engine, cx| cx.notify()).detach(); + // A throttled idle pump for the OFX interact: the plugin's UI work // loop is served on the viewer's own timer (the app tick is // shell-owned), so an active interact's `idle` action runs without @@ -320,6 +330,13 @@ impl ProgramViewerPanel { /// viewport changed or the plugin requested a repaint (so a paused /// viewer stays inert). fn sync_frame(&mut self, cx: &mut Context) { + // Mirror the OFX color picker's eyedropper arm state into the viewer + // widget; while armed, clicking the frame samples the pixel under the + // cursor (the setter no-ops when the state is unchanged, so a paused + // viewer stays inert). + let armed = self.engine.read(cx).eyedropper_armed(cx); + self.viewer.update(cx, |viewer, cx| viewer.set_eyedropper_armed(armed, cx)); + // Keep the main-process interact in sync with the inspector's // current selection (creates/destroys the interact as the target // moves; a no-op while it is unchanged). diff --git a/gpui b/gpui index 31f3838c5..59d39ac2a 160000 --- a/gpui +++ b/gpui @@ -1 +1 @@ -Subproject commit 31f3838c52992884835b470353370fa5c5887812 +Subproject commit 59d39ac2a1c4cf0fbf36a2597cd9b35f4385b35e