diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs new file mode 100644 index 00000000..cd24dbc0 --- /dev/null +++ b/crates/ui-egui/src/color_picker.rs @@ -0,0 +1,411 @@ +//! The colour picker every colour input opens, laid out like After Effects' Color Picker: a +//! square and a slider for the channel chosen with the H / S / B / R / G / B buttons, the new +//! colour over the original, the HSB and RGB values, the hex code, and OK / Cancel. +//! +//! Colours are sRGB components (0–1), as the project stores them. Changes apply as they are made +//! (After Effects' Preview); Cancel or Escape goes back to the colour the picker opened with. + +use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; + +use crate::i18n::tr; +use crate::widgets::{open_popup, popup_is_open, pressed_outside, select_all}; + +/// A colour button for egui-laid-out rows and dialogs: a swatch that opens [`color_popup`]. Don't +/// use egui's `color_edit_button_rgb(a)` for project colours: they read linear values, so the +/// swatch shows lighter and a picked colour is stored darker. The value is only rewritten when the +/// user picks a colour. +pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response { + let (rect, mut resp) = ui.allocate_exact_size(ui.spacing().interact_size, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ColorButton, ui.is_enabled(), srgb_hex(*c))); + let stroke = if resp.hovered() { ui.visuals().widgets.hovered.fg_stroke } else { ui.visuals().widgets.inactive.bg_stroke }; + ui.painter().rect_filled(rect, 2.0, srgb32(*c)); + ui.painter().rect_stroke(rect, 2.0, stroke, StrokeKind::Inside); + if resp.clicked() { + open_popup(ui, resp.id); + } + if color_popup(ui, resp.id, rect.left_bottom(), c) { + resp.mark_changed(); + } + resp +} + +/// The colour picker as a popup at `pos`, bound to `c`, opened with [`open_popup`]. Returns true +/// when the colour changed (Cancel, Escape and the original swatch change it back). +pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool { + if !popup_is_open(ui, id) { + return false; + } + let orig_id = id.with("original"); + let original = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(orig_id, || *c)); + let mut out = Outcome::default(); + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| out = color_picker(ui, id, c, original)); + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + out.close = Some(false); + } + if pressed_outside(ui.ctx(), &area.response) { + out.close.get_or_insert(true); + } + if let Some(keep) = out.close { + if !keep && *c != original { + *c = original; + out.changed = true; + } + ui.data_mut(|d| { + d.insert_temp(id.with("open"), false); + d.remove::<[f32; 3]>(orig_id); + }); + } + out.changed +} + +/// What the picker did this frame. +#[derive(Default)] +struct Outcome { + changed: bool, + /// Close the picker, keeping the colour (OK) or not (Cancel). + close: Option, +} + +/// The channel the slider sets; the square sets the other two of its model. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Chan { + H, + S, + V, + R, + G, + B, +} + +impl Chan { + /// The square's (across, up) channels while `self` is on the slider (After Effects' layout). + fn square(self) -> (Chan, Chan) { + match self { + Chan::H => (Chan::S, Chan::V), + Chan::S => (Chan::H, Chan::V), + Chan::V => (Chan::H, Chan::S), + Chan::R => (Chan::B, Chan::G), + Chan::G => (Chan::B, Chan::R), + Chan::B => (Chan::R, Chan::G), + } + } + + fn index(self) -> usize { + match self { + Chan::H | Chan::R => 0, + Chan::S | Chan::G => 1, + Chan::V | Chan::B => 2, + } + } + + fn is_hsv(self) -> bool { + matches!(self, Chan::H | Chan::S | Chan::V) + } +} + +/// The colour as both HSB and RGB: HSB keeps the hue (and saturation) of greys and black, where +/// RGB has none, so moving off black returns to the hue that was chosen. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Pick { + hsv: [f32; 3], + rgb: [f32; 3], +} + +impl Pick { + fn get(&self, ch: Chan) -> f32 { + let v = if ch.is_hsv() { self.hsv } else { self.rgb }; + v.get(ch.index()).copied().unwrap_or(0.0) + } + + fn set(mut self, ch: Chan, v: f32) -> Pick { + let v = v.clamp(0.0, 1.0); + if ch.is_hsv() { + if let Some(x) = self.hsv.get_mut(ch.index()) { + *x = v; + } + self.rgb = hsv_to_rgb(self.hsv); + } else { + if let Some(x) = self.rgb.get_mut(ch.index()) { + *x = v; + } + self.hsv = keep_hue(rgb_to_hsv(self.rgb), self.hsv); + } + self + } + + /// The colour at (`u` across, `v` up) on the square, or `t` up the slider. + fn at(&self, ch: Chan, u: f32, v: f32) -> Pick { + let (x, y) = ch.square(); + self.set(x, u).set(y, v) + } +} + +const SQUARE: f32 = 200.0; +const SLIDER_W: f32 = 16.0; + +fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3]) -> Outcome { + let state_id = id.with("pick"); + let mut pick = match ui.data(|d| d.get_temp::<([f32; 3], [f32; 3])>(state_id)) { + Some((hsv, rgb)) if rgb == *c => Pick { hsv, rgb }, + Some((hsv, _)) => Pick { hsv: keep_hue(rgb_to_hsv(*c), hsv), rgb: *c }, + None => Pick { hsv: rgb_to_hsv(*c), rgb: *c }, + }; + // The slider's channel is remembered between pickers, as After Effects does. + let chan_id = egui::Id::new("color-picker-channel"); + let mut chan = ui.data(|d| d.get_temp::(chan_id)).and_then(|i| CHANS.get(i).map(|c| c.0)).unwrap_or(Chan::H); + let before = pick; + let mut out = Outcome::default(); + + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + // The square and slider, with OK and Cancel under the square's left edge. + ui.vertical(|ui| { + ui.horizontal_top(|ui| { + square(ui, chan, &mut pick); + slider(ui, chan, &mut pick); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + if ui.add_sized(vec2(72.0, 20.0), egui::Button::new(tr("OK"))).clicked() { + out.close = Some(true); + } + if ui.add_sized(vec2(72.0, 20.0), egui::Button::new(tr("Cancel"))).clicked() { + out.close = Some(false); + } + }); + }); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 4.0; + if swatches(ui, pick.rgb, original) { + pick = Pick { hsv: keep_hue(rgb_to_hsv(original), pick.hsv), rgb: original }; + } + ui.add_space(4.0); + let mut field = |ui: &mut Ui, ch: Chan, name: &str, scale: f32, suffix: &str| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let radio = ui.radio(chan == ch, format!("{name}:")); + // Line the fields up whatever each label's width. + ui.add_space((40.0 - radio.rect.width()).max(0.0)); + if radio.clicked() { + chan = ch; + } + // Rounded as the hex code rounds, and 360° shows as 0°. + let mut v = (pick.get(ch) * scale).round(); + if ch == Chan::H && v >= 360.0 { + v = 0.0; + } + let drag = egui::DragValue::new(&mut v).range(0.0..=scale).fixed_decimals(0).suffix(suffix).speed(scale / 255.0); + if ui.add_sized(vec2(56.0, 18.0), drag).labelled_by(radio.id).changed() { + pick = pick.set(ch, v.round() / scale); + } + }); + }; + field(ui, Chan::H, "H", 360.0, "°"); + field(ui, Chan::S, "S", 100.0, "%"); + field(ui, Chan::V, "B", 100.0, "%"); + ui.add_space(4.0); + field(ui, Chan::R, "R", 255.0, ""); + field(ui, Chan::G, "G", 255.0, ""); + field(ui, Chan::B, "B", 255.0, ""); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let mut rgb = pick.rgb; + if hex_field(ui, id.with("hex"), &mut rgb) { + pick = Pick { hsv: keep_hue(rgb_to_hsv(rgb), pick.hsv), rgb }; + } + }); + }); + }); + + if let Some(i) = CHANS.iter().position(|(c, _)| *c == chan) { + ui.data_mut(|d| d.insert_temp(chan_id, i)); + } + if pick != before && pick.rgb != *c { + *c = pick.rgb; + out.changed = true; + } + ui.data_mut(|d| d.insert_temp(state_id, (pick.hsv, pick.rgb))); + out +} + +/// The channels in the order of their buttons. +const CHANS: [(Chan, &str); 6] = [(Chan::H, "H"), (Chan::S, "S"), (Chan::V, "B"), (Chan::R, "R"), (Chan::G, "G"), (Chan::B, "B")]; + +/// Drag or click on the square to set its two channels. +fn square(ui: &mut Ui, chan: Chan, pick: &mut Pick) { + let (rect, resp) = ui.allocate_exact_size(vec2(SQUARE, SQUARE), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color field")); + let (x, y) = chan.square(); + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) { + *pick = pick.at(chan, (p.x - rect.min.x) / rect.width(), 1.0 - (p.y - rect.min.y) / rect.height()); + } + // Hue changes unevenly across a row, so the HSB squares get a finer grid. + let n: u32 = if chan.is_hsv() { 24 } else { 8 }; + let row = n + 1; + let mut mesh = egui::Mesh::default(); + for yi in 0..=n { + for xi in 0..=n { + let (u, v) = (xi as f32 / n as f32, yi as f32 / n as f32); + mesh.colored_vertex(rect.lerp_inside(vec2(u, v)), srgb32(pick.at(chan, u, 1.0 - v).rgb)); + if xi < n && yi < n { + let k = yi * row + xi; + mesh.add_triangle(k, k + 1, k + row); + mesh.add_triangle(k + 1, k + row + 1, k + row); + } + } + } + ui.painter().add(mesh); + let at = rect.lerp_inside(vec2(pick.get(x), 1.0 - pick.get(y))); + let light = pick.rgb[0] * 0.3 + pick.rgb[1] * 0.59 + pick.rgb[2] * 0.11 > 0.6; + ui.painter().circle_stroke(at, 5.0, Stroke::new(1.5, if light { Color32::BLACK } else { Color32::WHITE })); +} + +/// Drag or click on the slider to set `chan`; arrows on both sides mark its value. +fn slider(ui: &mut Ui, chan: Chan, pick: &mut Pick) { + let (outer, resp) = ui.allocate_exact_size(vec2(SLIDER_W + 12.0, SQUARE), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color slider")); + let bar = Rect::from_center_size(outer.center(), vec2(SLIDER_W, SQUARE)); + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) { + *pick = pick.set(chan, 1.0 - (p.y - bar.min.y) / bar.height()); + } + let n: u32 = if chan == Chan::H { 36 } else { 16 }; + let mut mesh = egui::Mesh::default(); + for i in 0..=n { + let t = i as f32 / n as f32; + // The hue slider shows pure hues; the others vary their channel in the current colour. + let col = if chan == Chan::H { hsv_to_rgb([1.0 - t, 1.0, 1.0]) } else { pick.set(chan, 1.0 - t).rgb }; + let y = bar.min.y + t * bar.height(); + mesh.colored_vertex(pos2(bar.min.x, y), srgb32(col)); + mesh.colored_vertex(pos2(bar.max.x, y), srgb32(col)); + if i < n { + let k = 2 * i; + mesh.add_triangle(k, k + 1, k + 2); + mesh.add_triangle(k + 1, k + 3, k + 2); + } + } + ui.painter().add(mesh); + let y = bar.min.y + (1.0 - pick.get(chan)) * bar.height(); + let fg = ui.visuals().strong_text_color(); + let stroke = Stroke::new(1.2, fg); + for (edge, dir) in [(outer.min.x + 1.0, 1.0), (outer.max.x - 1.0, -1.0)] { + let tip = pos2(edge + dir * 4.0, y); + ui.painter().line_segment([pos2(edge, y - 4.0), tip], stroke); + ui.painter().line_segment([pos2(edge, y + 4.0), tip], stroke); + } +} + +/// The new colour over the original; returns true when the original is clicked (to go back to it). +fn swatches(ui: &mut Ui, new: [f32; 3], original: [f32; 3]) -> bool { + let (rect, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover()); + let (top, bottom) = rect.split_top_bottom_at_fraction(0.5); + let new_resp = ui.interact(top, ui.id().with("new-swatch"), Sense::hover()); + new_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "New color")); + let old = ui.interact(bottom, ui.id().with("original-swatch"), Sense::click()).on_hover_text(tr("Original color: click to go back to it")); + old.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Original color")); + ui.painter().rect_filled(top, 0.0, srgb32(new)); + ui.painter().rect_filled(bottom, 0.0, srgb32(original)); + ui.painter().rect_stroke(rect, 0.0, ui.visuals().widgets.inactive.bg_stroke, StrokeKind::Outside); + old.clicked() +} + +/// `hsv` with `old`'s hue where `hsv` has none (a grey or black), and `old`'s saturation where it +/// has neither (black). +fn keep_hue(mut hsv: [f32; 3], old: [f32; 3]) -> [f32; 3] { + if hsv[1] == 0.0 || hsv[2] == 0.0 { + hsv[0] = old[0]; + } + if hsv[2] == 0.0 { + hsv[1] = old[1]; + } + hsv +} + +/// The hex field. While it has focus it keeps what was typed (`id` holds the text); otherwise it +/// shows the colour. Focusing it selects the code, so typing replaces it; a valid code applies as +/// it is typed. +fn hex_field(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3]) -> bool { + let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| srgb_hex(*c).trim_start_matches('#').to_string()); + let mut changed = false; + let label = ui.label("#"); + let resp = ui + .add(egui::TextEdit::singleline(&mut text).id(id.with("edit")).desired_width(62.0).char_limit(7).font(egui::TextStyle::Monospace)) + .labelled_by(label.id); + if resp.gained_focus() { + select_all(ui.ctx(), resp.id, &text); + } + if resp.changed() + && let Some(rgb) = hex_rgb(&text) + { + let v = rgb.map(|b| b as f32 / 255.0); + if v != *c { + *c = v; + changed = true; + } + } + if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(id, text.clone())); + } else { + ui.data_mut(|d| d.remove::(id)); + } + changed +} + +/// An sRGB colour (components 0–1) as an egui colour. +pub fn srgb32(c: [f32; 3]) -> Color32 { + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) +} + +/// An sRGB colour (components 0–1) as `#RRGGBB`. +pub fn srgb_hex(c: [f32; 3]) -> String { + let col = srgb32(c); + format!("#{:02X}{:02X}{:02X}", col.r(), col.g(), col.b()) +} + +/// Parse a hex colour: `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB` (any case, surrounding space ignored). +pub fn hex_rgb(s: &str) -> Option<[u8; 3]> { + let h = s.trim().trim_start_matches('#'); + if !h.is_ascii() { + return None; + } + let digit = |i: usize| h.get(i..i + 1).and_then(|d| u8::from_str_radix(d, 16).ok()); + let pair = |i: usize| h.get(i..i + 2).and_then(|d| u8::from_str_radix(d, 16).ok()); + match h.len() { + 3 => Some([digit(0)? * 17, digit(1)? * 17, digit(2)? * 17]), + 6 => Some([pair(0)?, pair(2)?, pair(4)?]), + _ => None, + } +} + +/// sRGB (components 0–1) to hue, saturation and value (0–1). +pub fn rgb_to_hsv([r, g, b]: [f32; 3]) -> [f32; 3] { + let (r, g, b) = (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)); + let max = r.max(g).max(b); + let d = max - r.min(g).min(b); + if d <= 0.0 { + return [0.0, 0.0, max]; + } + let h = if max == r { + ((g - b) / d).rem_euclid(6.0) + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + [h / 6.0, d / max, max] +} + +/// Hue, saturation and value (0–1) to sRGB (components 0–1). +pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let h = h.clamp(0.0, 1.0) * 6.0; + let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); + let f = |n: f32| { + let k = (n + h) % 6.0; + v - v * s * k.min(4.0 - k).clamp(0.0, 1.0) + }; + [f(5.0), f(3.0), f(1.0)] +} diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1b93658f..779459fb 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -364,7 +364,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa } } let mut c = tp.color; - if color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) { + if widgets::color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) { tool_options(app, json!({key: c})); paint(app, key, json!(c)); } @@ -445,12 +445,6 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t } } -/// sRGB components (0–1) as a colour. -fn srgb32(c: [f32; 3]) -> Color32 { - let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) -} - /// A Fill or Stroke swatch: its colour, a gradient (white to black), or None's red slash. fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: &Tokens) { let clip = p.with_clip_rect(r); @@ -460,7 +454,7 @@ fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: clip.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.5, Color32::from_rgb(0xe0, 0x30, 0x30))); } PaintKind::Solid => { - clip.rect_filled(r, 2.0, srgb32(paint.color)); + clip.rect_filled(r, 2.0, widgets::srgb32(paint.color)); } PaintKind::Linear => { let mut m = egui::Mesh::default(); @@ -555,28 +549,6 @@ fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } -/// A small colour picker popup bound to an RGB value. -pub fn color_popup(ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool { - let open_id = id.with("open"); - if !ui.data(|d| d.get_temp::(open_id).unwrap_or(false)) { - return false; - } - let mut changed = false; - let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - let mut col = srgb32(*c); - if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) { - *c = [col.r() as f32 / 255.0, col.g() as f32 / 255.0, col.b() as f32 / 255.0]; - changed = true; - } - }); - }); - if widgets::pressed_outside(ui.ctx(), &area.response) || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(open_id, false)); - } - changed -} - /// The ArtCraft mark as supplied in `docs/brand` (the README's and getartcraft.com's logo; used /// unmodified, see `docs/brand/LICENSE-brand.txt`). static ARTCRAFT_MARK: &[u8] = include_bytes!("../../../docs/brand/artcraft-mark.png"); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 9cb9d736..c0385aa4 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1927,9 +1927,11 @@ mod tests { /// The interface text that has moved into this catalog. Add a panel here when you convert it; /// the tests below then keep it translated. const CONVERTED: &[(&str, &str)] = &[ + ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), + ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), ("panels/camera_tracker_ui.rs", include_str!("../panels/camera_tracker_ui.rs")), ("panels/comp_settings.rs", include_str!("../panels/comp_settings.rs")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7cd7a2a5..6c4053c0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -10,6 +10,7 @@ pub mod audio; pub mod automation; pub mod bench; +pub mod color_picker; pub mod control; pub mod credits; pub mod dock; diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 6113c866..afc0fc8a 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -268,7 +268,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.end_row(); ui.label(tr("Color")); - ui.color_edit_button_rgb(&mut d.color); + crate::widgets::srgb_color_button(ui, &mut d.color); ui.end_row(); ui.label(tr("Intensity")); ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %"))); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 94d8e5a4..adab0247 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -353,7 +353,7 @@ fn prop_row( } app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; - if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); } } diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index c264e74e..ce6121a7 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -344,11 +344,10 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Color => { let c = v.as_color(); - let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]); - let r = egui::widgets::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::widgets::color_picker::Alpha::Opaque); + let mut rgb = [c[0], c[1], c[2]]; + let r = crate::widgets::srgb_color_button(ui, &mut rgb); if r.changed() { - let u = rgba.to_rgba_unmultiplied(); - out = Some(json!([u[0], u[1], u[2], u[3]])); + out = Some(json!([rgb[0], rgb[1], rgb[2], c[3]])); } r.rect } diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 51b146af..a426f5a1 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -581,7 +581,7 @@ fn colorama_wheel( t.text_dim, ); let mut rgb = [col[0], col[1], col[2]]; - if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { let mut st = pal.stops.clone(); st[sel].1 = [rgb[0], rgb[1], rgb[2], col[3]]; new_stops = Some(st); diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index c6b6abbe..1366c2ae 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -111,9 +111,9 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { (r.changed() && y != *x).then(|| json!(y)) } (KV::Color(c), _) => { - let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32); - let changed = egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::Opaque).changed(); - changed.then(|| json!([rgba.r(), rgba.g(), rgba.b()])) + let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; + let changed = crate::widgets::srgb_color_button(ui, &mut rgb).changed(); + changed.then(|| json!(rgb)) } (KV::Gradient(_), _) => { ui.label(egui::RichText::new(tr("Edit in the Timeline")).weak()); diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs index 62f27055..675bc1e1 100644 --- a/crates/ui-egui/src/panels/paint_panels.rs +++ b/crates/ui-egui/src/panels/paint_panels.rs @@ -102,11 +102,11 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("paint.color", fg, "Foreground color"); app.auto.add("paint.background", bg, "Background color"); let mut fc = rgb(o.color); - if crate::header::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) { + if crate::widgets::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) { set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]})); } let mut bc = rgb(o.background); - if crate::header::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) { + if crate::widgets::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) { set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]})); } let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0)); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index e5d5d165..dbeeb1a7 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -395,7 +395,7 @@ fn text_section( } app.auto.add(&format!("properties.text.{key}"), sw, label); let mut rgb = [c[0], c[1], c[2]]; - if crate::header::color_popup(ui, pop, sw.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sw.left_bottom(), &mut rgb) { set(actions, json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("props-text-{key}")})); } p.text(pos2(sw.max.x + 8.0, sw.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index c7945178..385febef 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -38,6 +38,12 @@ fn hex(c: [u8; 3]) -> String { format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) } +/// sRGB components (0–1) as bytes. +fn srgb_bytes(c: [f32; 3]) -> [u8; 3] { + let col = crate::widgets::srgb32(c); + [col.r(), col.g(), col.b()] +} + fn reg(app: &mut EffectcraftApp, id: &str, r: &egui::Response, label: &str) { app.auto.add(id, r.rect, label); } @@ -178,8 +184,9 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } Kind::Color => { let c = crate::panels::viewer::hex_rgb(v.as_str().unwrap_or("")).unwrap_or([128, 128, 128]); - let mut rgb = c; - let r = ui.color_edit_button_srgb(&mut rgb); + let mut f = c.map(|v| v as f32 / 255.0); + let r = crate::widgets::srgb_color_button(ui, &mut f); + let rgb = if r.changed() { srgb_bytes(f) } else { c }; ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim)); if rgb != c { acts.push(Act::Set(key.into(), json!(hex(rgb)))); @@ -559,8 +566,9 @@ fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mu egui::Grid::new("settings-labels").num_columns(4).spacing([10.0, 4.0]).show(ui, |ui| { for (i, l) in labels.iter().enumerate() { let c = crate::panels::viewer::hex_rgb(l["color"].as_str().unwrap_or("")).unwrap_or([128, 128, 128]); - let mut rgb = c; - let r = ui.color_edit_button_srgb(&mut rgb); + let mut f = c.map(|v| v as f32 / 255.0); + let r = crate::widgets::srgb_color_button(ui, &mut f); + let rgb = if r.changed() { srgb_bytes(f) } else { c }; app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour"); if rgb != c { acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb)))); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index fa64860a..1ed3f21e 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -198,7 +198,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("character.{key}"), fs, label); let mut rgb = [c[0], c[1], c[2]]; - if crate::header::color_popup(ui, pop, fs.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, fs.left_bottom(), &mut rgb) { actions.push(json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("char-{key}")})); } p.text(pos2(fs.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 878a577f..32c52b47 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3563,7 +3563,7 @@ fn value_editor( widgets::open_popup(ui, pop); } let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; - if crate::header::color_popup(ui, pop, r.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) { set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); } } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 1a0a8087..081e136d 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2151,14 +2151,7 @@ pub(crate) fn minify_factor(smooth: bool, pixels: f64, screen: f64) -> usize { if smooth && k >= 1.0 { k.min(256.0) as usize } else { 1 } } -pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { - let h = s.trim().trim_start_matches('#'); - if h.len() != 6 || !h.is_ascii() { - return None; - } - let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok(); - Some([p(0)?, p(2)?, p(4)?]) -} +pub(crate) use crate::widgets::hex_rgb; /// The Extended Viewer's render region for this frame (see [`ViewerState::extended`]): /// Settings ▸ 3D ▸ Extended Viewer on, the comp has 3D layers and the view is a custom 3D view diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 81b976d2..0b811ffb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -192,17 +192,8 @@ pub fn swatch(ui: &mut Ui, rect: Rect, c: [f32; 4], id: egui::Id, t: &Tokens) -> resp } -/// A colour button with egui's picker for a colour stored as sRGB components (0–1), as the -/// project stores colours (`color_edit_button_rgb` reads linear values and shows them lighter). -/// The value is only rewritten when the user picks a colour. -pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response { - let mut u = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); - let r = ui.color_edit_button_srgb(&mut u); - if r.changed() { - *c = u.map(|v| v as f32 / 255.0); - } - r -} +// The colour picker and its helpers (every colour input uses them). +pub use crate::color_picker::{color_popup, hex_rgb, hsv_to_rgb, rgb_to_hsv, srgb_color_button, srgb_hex, srgb32}; /// A twirl-down triangle; returns clicked. pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> Response { diff --git a/crates/ui-egui/tests/ui_color_hex.rs b/crates/ui-egui/tests/ui_color_hex.rs new file mode 100644 index 00000000..90eb19b8 --- /dev/null +++ b/crates/ui-egui/tests/ui_color_hex.rs @@ -0,0 +1,233 @@ +//! The colour picker every colour input opens: its hex field sets an effect's colour (CC Toner), +//! the dialogs' colour button stores sRGB (egui's own `color_edit_button_rgba` read the stored +//! sRGB values as linear, so mid-grey `#808080` was stored as `#373737`), the square, slider and +//! HSB / RGB fields set the colour, and Cancel / Escape / the original swatch restore it. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::widgets; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +const GREY: f32 = 128.0 / 255.0; + +fn click(h: &mut Harness<'_, S>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Click the open popup's Hex field and type `text` over the code it shows. +fn type_hex(h: &mut Harness<'_, S>, text: &str) { + let at = h.get_by_role_and_label(Role::TextInput, "#").rect().center(); + click(h, at); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND }); + } + h.step(); + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(3); +} + +fn close(c: [f32; 3], want: [f32; 3]) -> bool { + c.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4) +} + +#[test] +fn hex_code_sets_an_effect_colour() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Hex", "width": 320, "height": 180, "duration": 4})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#40b080"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "CC Toner"})).unwrap()["effects"][0].as_u64().unwrap(); + let uid = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().get("midtones").unwrap().uid; + s.state.selected_layers = vec![LayerId(layer)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.toggle_maximize(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + let midtones = |h: &Harness<'_, EffectcraftApp>| { + let c = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(uid).unwrap().value.as_color(); + [c[0], c[1], c[2]] + }; + + let [x, y, w, ht] = h.state().auto.find(&format!("effectControls.prop.{uid}.value")).expect("the Midtones swatch").rect; + click(&mut h, pos2(x + w / 2.0, y + ht / 2.0)); + type_hex(&mut h, "#FF8000"); + assert!(close(midtones(&h), [1.0, GREY, 0.0]), "{:?}", midtones(&h)); + // A partial code doesn't apply; the short form does. + type_hex(&mut h, "#12"); + assert!(close(midtones(&h), [1.0, GREY, 0.0]), "{:?}", midtones(&h)); + type_hex(&mut h, "0f8"); + assert!(close(midtones(&h), [0.0, 1.0, 136.0 / 255.0]), "{:?}", midtones(&h)); +} + +/// A colour button in a harness of its own, its picker open. +fn picker(c: [f32; 3]) -> Harness<'static, [f32; 3]> { + let mut h = Harness::builder().with_size(vec2(560.0, 400.0)).build_ui_state( + |ui, c: &mut [f32; 3]| { + widgets::srgb_color_button(ui, c); + }, + c, + ); + h.run_steps(2); + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + h +} + +fn press(h: &mut Harness<'_, [f32; 3]>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + } + h.run_steps(2); +} + +fn click_label(h: &mut Harness<'_, [f32; 3]>, label: &str) { + let at = h.get_by_label(label).rect().center(); + click(h, at); +} + +/// Type `text` into the first number field labelled `label` ("R:", "H:" …; "B:" is Brightness +/// before Blue, as in After Effects) and press Enter. +fn type_field(h: &mut Harness<'_, [f32; 3]>, label: &str, text: &str) { + let at = h.get_all_by_role_and_label(Role::SpinButton, label).next().expect("the field").rect().center(); + click(h, at); + press(h, Key::A, Modifiers::COMMAND); + h.input_mut().events.push(Event::Text(text.into())); + h.step(); + press(h, Key::Enter, Modifiers::NONE); +} + +fn near(c: [f32; 3], want: [f32; 3]) -> bool { + c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02) +} + +fn is_open(h: &Harness<'_, [f32; 3]>) -> bool { + h.query_by_role_and_label(Role::TextInput, "#").is_some() +} + +#[test] +fn the_dialog_colour_button_stores_srgb() { + let mut h = picker([1.0, 1.0, 1.0]); + type_hex(&mut h, "808080"); + assert!(close(*h.state(), [GREY; 3]), "{:?}", h.state()); + click_label(&mut h, "OK"); + assert!(!is_open(&h)); + assert!(close(*h.state(), [GREY; 3])); +} + +/// Changes apply as they are made; Cancel, Escape and the original swatch go back to the colour +/// the picker opened with, OK keeps the new one. +#[test] +fn cancel_escape_and_the_original_swatch_restore_the_colour() { + let red = [1.0, 0.0, 0.0]; + let mut h = picker(red); + type_hex(&mut h, "00FF00"); + assert!(close(*h.state(), [0.0, 1.0, 0.0])); + click_label(&mut h, "Cancel"); + assert!(!is_open(&h)); + assert!(close(*h.state(), red), "{:?}", h.state()); + + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + type_hex(&mut h, "0000FF"); + press(&mut h, Key::Escape, Modifiers::NONE); + assert!(!is_open(&h)); + assert!(close(*h.state(), red), "{:?}", h.state()); + + click(&mut h, button); + type_hex(&mut h, "0000FF"); + click_label(&mut h, "Original color"); + assert!(is_open(&h), "going back to the original keeps the picker open"); + assert!(close(*h.state(), red), "{:?}", h.state()); + type_hex(&mut h, "FFFF00"); + click_label(&mut h, "OK"); + assert!(close(*h.state(), [1.0, 1.0, 0.0]), "{:?}", h.state()); +} + +/// With H on the slider the square is saturation × brightness; the hue is kept while the colour is +/// black, where RGB has none. HSB and RGB values can be typed. +#[test] +fn the_hsb_square_slider_and_fields_set_the_colour() { + let mut h = picker([1.0, 0.0, 0.0]); + let square = h.get_by_label("Color field").rect(); + let slider = h.get_by_label("Color slider").rect(); + + click(&mut h, pos2(square.center().x, square.max.y - 1.0)); + assert!(near(*h.state(), [0.0; 3]), "the bottom edge is black: {:?}", h.state()); + click(&mut h, slider.center()); + assert!(near(*h.state(), [0.0; 3]), "a new hue leaves black black: {:?}", h.state()); + click(&mut h, pos2(square.max.x - 1.0, square.min.y + 1.0)); + assert!(near(*h.state(), [0.0, 1.0, 1.0]), "the hue chosen while black is kept: {:?}", h.state()); + + type_field(&mut h, "R:", "64"); + assert!((h.state()[0] - 64.0 / 255.0).abs() < 1e-4 && near(*h.state(), [64.0 / 255.0, 1.0, 1.0]), "{:?}", h.state()); + type_field(&mut h, "H:", "0"); + type_field(&mut h, "S:", "100"); + type_field(&mut h, "B:", "50"); + assert!(near(*h.state(), [0.5, 0.0, 0.0]), "{:?}", h.state()); +} + +/// With R chosen the slider sets red and the square blue (across) and green (up), as in After +/// Effects; the choice is remembered by the next picker. +#[test] +fn choosing_r_puts_red_on_the_slider_and_blue_green_on_the_square() { + let mut h = picker([0.0, 0.0, 0.0]); + let radio = h.get_by_role_and_label(Role::RadioButton, "R:").rect().center(); + click(&mut h, radio); + let square = h.get_by_label("Color field").rect(); + let slider = h.get_by_label("Color slider").rect(); + click(&mut h, pos2(slider.center().x, slider.min.y + 1.0)); + assert!(near(*h.state(), [1.0, 0.0, 0.0]), "the top of the slider is full red: {:?}", h.state()); + click(&mut h, pos2(square.max.x - 1.0, square.max.y - 1.0)); + assert!(near(*h.state(), [1.0, 0.0, 1.0]), "bottom right adds full blue: {:?}", h.state()); + click(&mut h, pos2(square.min.x + 1.0, square.min.y + 1.0)); + assert!(near(*h.state(), [1.0, 1.0, 0.0]), "top left is full green, no blue: {:?}", h.state()); + click_label(&mut h, "OK"); + + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + let slider = h.get_by_label("Color slider").rect(); + click(&mut h, pos2(slider.center().x, slider.max.y - 1.0)); + assert!(near(*h.state(), [0.0, 1.0, 0.0]), "the slider still sets red: {:?}", h.state()); +} + +#[test] +fn hex_codes_parse() { + assert_eq!(widgets::hex_rgb("#ff8000"), Some([255, 128, 0])); + assert_eq!(widgets::hex_rgb(" FF8000 "), Some([255, 128, 0])); + assert_eq!(widgets::hex_rgb("#0f8"), Some([0, 255, 136])); + for bad in ["", "#", "#12", "#12345", "#1234567", "#gg0000", "#ffé000"] { + assert_eq!(widgets::hex_rgb(bad), None, "{bad}"); + } + assert_eq!(widgets::srgb_hex([1.0, GREY, 0.0]), "#FF8000"); +} + +#[test] +fn hsv_round_trips() { + for c in [[1.0, 0.0, 0.0], [0.0, 1.0, 1.0], [GREY, 0.25, 0.75], [0.2, 0.2, 0.2], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] { + let back = widgets::hsv_to_rgb(widgets::rgb_to_hsv(c)); + assert!(close(back, c), "{c:?} -> {back:?}"); + } + assert!(close(widgets::hsv_to_rgb([0.5, 1.0, 1.0]), [0.0, 1.0, 1.0])); +} + +/// The fields round as the hex code does (0.7 × 255 = 178.5 shows as 179, `B3`), and a hue just +/// under 360° shows as 0°. +#[test] +fn the_fields_agree_with_the_hex_code() { + let h = picker([1.0, 0.699, 0.7]); + let value = |label: &str, nth: usize| h.get_all_by_role_and_label(Role::SpinButton, label).nth(nth).and_then(|n| n.value()).unwrap_or_default(); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "#").value().as_deref(), Some("FFB2B3")); + assert_eq!(value("G:", 0), "178"); + assert_eq!(value("B:", 1), "179"); + assert_eq!(value("H:", 0), "0°"); +}