mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 11:17:12 -04:00
Merge pull request #638 from FrederoxDev/feat/color-hex-input
This commit is contained in:
17 files changed
+678
-68
No files matched your search
@@ -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)]
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(" %")));
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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°");
|
||||
}
|
||||
Reference in new issue
Block a user