Merge pull request #638 from FrederoxDev/feat/color-hex-input

This commit is contained in:
Claude committed 2026-10-10 18:51:16 +00:00
commit 954e89c661
17 files changed
+678 -68

No files matched your search

+411
View File
@@ -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<bool>,
}
/// 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::<usize>(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::<String>(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::<String>(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)]
}
+2 -30
View File
@@ -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::<bool>(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");
+2
View File
@@ -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")),
+1
View File
@@ -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;
+1 -1
View File
@@ -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(" %")));
+1 -1
View File
@@ -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]));
}
}
+3 -4
View File
@@ -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
}
+1 -1
View File
@@ -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);
@@ -111,9 +111,9 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
(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());
+2 -2
View File
@@ -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));
+1 -1
View File
@@ -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);
+12 -4
View File
@@ -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))));
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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]));
}
}
+1 -8
View File
@@ -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
+2 -11
View File
@@ -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 {
+233
View File
@@ -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<S>(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<S>(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°");
}