mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Conflicts:
- crates/ui-egui/src/i18n/{cs,de,el,es,fr,hu,id,it,ja,ko,nl,pl,pt-br,ru,sr,tr,uk,vi,zh-hans,zh-hant}.tsv: kept every key of both sides (main's block, then the PR's Color panel block); no duplicate keys.
- crates/ui-egui/src/panels.rs: took the PR's removal of color_picker/hsb_sliders/rgb_sliders (moved to color_panel_ui.rs); kept main's color_field_height doc comment; main's #5842 slider recolour tests ported to color_panel_tests.rs for the new HSB/RGB slider modes; main's black-HSB test dropped as the PR's hsb_sliders_take_values_on_black covers it.
- crates/ui-egui/src/color_panel_ui.rs (no textual conflict, adapted): set_color now goes through apply_color::foreground_changed with the gesture (was type_tool::foreground_changed), so every mode (sliders, ramp, alerts, wheel) recolours selected type/shapes tool-gated in one undo step per gesture; panel() calls apply_color::track_focus.
- crates/ui-egui/src/color_panel_tests.rs (adapted): new every_slider_mode_recolours_the_selected_shape test.
- docs/gaps.md, docs/ui-parity.md: union of both sides' status lines, rows and revision history; the PR's done row replaces the old "missing" Color panel row.
- docs/scorecard.md: generated, took b7-base's version.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
//! The Color panel's modes, ramp and panel menu (#6000, #2569): every mode draws and edits the
|
||||
//! colour in every theme, and the choice is the panel menu's, never the theme's.
|
||||
|
||||
use egui::{Modifiers, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::color_panel_ui::{Model, hex_code, html_color, ramp_color};
|
||||
use crate::dock::Group;
|
||||
use crate::state::{ColorPanelMode, ColorRamp};
|
||||
use crate::theme::{ThemeKind, Tokens};
|
||||
|
||||
/// The Color tab of `kind` in `mode`, alone in a 300 x 400 pt panel that offers it 360 pt.
|
||||
fn color_tab(kind: ThemeKind, mode: ColorPanelMode, fg: [f32; 4]) -> Harness<'static, PhotocraftApp> {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.tools.foreground = fg;
|
||||
app.ui.color_panel.mode = mode;
|
||||
color_tab_of(kind, app)
|
||||
}
|
||||
|
||||
/// The Color tab of `kind` for `app`, as [`color_tab`].
|
||||
fn color_tab_of(kind: ThemeKind, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
|
||||
let tab = Group::Color.tabs(Tokens::for_kind(kind).pro).iter().position(|name| *name == "Color").unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
move |ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
ui.data_mut(|d| d.insert_temp(crate::panels::color_field_fill_id(), 360.0_f32));
|
||||
crate::panels::dock_body(app, ui, Group::Color, tab);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, kind);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// Click at `p` with `modifiers` held.
|
||||
fn press(h: &mut Harness<'_, PhotocraftApp>, p: Pos2, modifiers: Modifiers) {
|
||||
h.hover_at(p);
|
||||
h.event(egui::Event::ModifiersChanged(modifiers));
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn bytes(c: [f32; 4]) -> [u8; 3] {
|
||||
[c[0], c[1], c[2]].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
}
|
||||
|
||||
fn type_into(h: &mut Harness<'_, PhotocraftApp>, role: egui::accesskit::Role, nth: usize, text: &str) {
|
||||
h.query_all_by_role(role).nth(nth).unwrap().click();
|
||||
h.run_steps(1);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A);
|
||||
h.run_steps(1);
|
||||
for ch in text.chars() {
|
||||
h.event(egui::Event::Text(ch.to_string()));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.key_press(egui::Key::Tab);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_model_round_trips_its_colours() {
|
||||
let colours = [[0.2, 0.6, 0.8], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [0.5, 0.25, 0.75]];
|
||||
for model in [Model::Rgb, Model::Hsb, Model::Web, Model::Lab, Model::Cmyk] {
|
||||
for c in colours {
|
||||
// Pure red, for one, can't print: CMYK gives the nearest printable colour back.
|
||||
if model == Model::Cmyk && crate::color_picker_ui::out_of_gamut(c).is_some() {
|
||||
continue;
|
||||
}
|
||||
let back = model.to_rgb(model.values_of(c));
|
||||
let worst = (0..3).map(|i| (back[i] - c[i]).abs()).fold(0.0, f32::max);
|
||||
// Whole-number fields: RGB is exact, HSB within a step, CMYK and Lab within a few.
|
||||
let tolerance = match model {
|
||||
Model::Rgb | Model::Web => 0.6 / 255.0,
|
||||
Model::Hsb => 2.5 / 255.0,
|
||||
_ => 0.03,
|
||||
};
|
||||
assert!(worst <= tolerance, "{model:?} {c:?} -> {back:?}");
|
||||
}
|
||||
}
|
||||
for g in [0.0, 0.25, 0.5, 1.0] {
|
||||
let back = Model::Gray.to_rgb(Model::Gray.values_of([g; 3]));
|
||||
assert!((back[0] - g).abs() < 0.01, "gray {g} -> {back:?}");
|
||||
}
|
||||
// Hostile values give a valid colour.
|
||||
for model in [Model::Gray, Model::Rgb, Model::Hsb, Model::Cmyk, Model::Lab, Model::Web] {
|
||||
for v in [[f32::NAN; 4], [f32::INFINITY; 4], [-1e9; 4], [1e9; 4]] {
|
||||
assert!(model.to_rgb(v).iter().all(|x| (0.0..=1.0).contains(x)), "{model:?} {v:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
model.channels().len(),
|
||||
match model {
|
||||
Model::Gray => 1,
|
||||
Model::Cmyk => 4,
|
||||
_ => 3,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ramps_match_photoshop() {
|
||||
let (fg, bg) = ([0.2, 0.4, 0.6], [1.0, 1.0, 0.0]);
|
||||
let near = |a: [f32; 3], b: [f32; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 0.02);
|
||||
// RGB Spectrum: hue across, white at the top, the full colour in the middle, black at the bottom.
|
||||
assert!(near(ramp_color(ColorRamp::RgbSpectrum, false, fg, bg, 0.0, 0.0), [1.0; 3]));
|
||||
assert!(near(ramp_color(ColorRamp::RgbSpectrum, false, fg, bg, 0.0, 0.5), [1.0, 0.0, 0.0]));
|
||||
assert!(near(ramp_color(ColorRamp::RgbSpectrum, false, fg, bg, 1.0 / 3.0, 0.5), [0.0, 1.0, 0.0]));
|
||||
assert!(near(ramp_color(ColorRamp::RgbSpectrum, false, fg, bg, 0.5, 1.0), [0.0; 3]));
|
||||
assert!(near(ramp_color(ColorRamp::GrayscaleRamp, false, fg, bg, 0.0, 0.3), [1.0; 3]));
|
||||
assert!(near(ramp_color(ColorRamp::GrayscaleRamp, false, fg, bg, 1.0, 0.3), [0.0; 3]));
|
||||
assert!(near(ramp_color(ColorRamp::CurrentColors, false, fg, bg, 0.0, 0.9), fg));
|
||||
assert!(near(ramp_color(ColorRamp::CurrentColors, false, fg, bg, 1.0, 0.1), bg));
|
||||
// CMYK Spectrum stays printable.
|
||||
let c = ramp_color(ColorRamp::CmykSpectrum, false, fg, bg, 0.6, 0.5);
|
||||
assert!(crate::color_picker_ui::out_of_gamut(c).is_none(), "{c:?}");
|
||||
// Make Ramp Web Safe: every colour is a web colour.
|
||||
for ramp in ColorRamp::ALL {
|
||||
for (x, y) in [(0.13, 0.37), (0.71, 0.66), (0.5, 0.5)] {
|
||||
let c = ramp_color(ramp, true, fg, bg, x, y);
|
||||
assert!(crate::color_picker_ui::not_web_safe(c).is_none(), "{ramp:?} {c:?}");
|
||||
}
|
||||
let c = ramp_color(ramp, false, fg, bg, f32::NAN, f32::INFINITY);
|
||||
assert!(c.iter().all(|v| (0.0..=1.0).contains(v)), "{ramp:?} {c:?}");
|
||||
}
|
||||
// Shift-click cycles the ramps in menu order and wraps.
|
||||
let mut r = ColorRamp::RgbSpectrum;
|
||||
let mut seen = vec![r];
|
||||
for _ in 0..3 {
|
||||
r = r.next();
|
||||
seen.push(r);
|
||||
}
|
||||
assert_eq!(seen, ColorRamp::ALL);
|
||||
assert_eq!(r.next(), ColorRamp::RgbSpectrum);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_color_formats() {
|
||||
assert_eq!(html_color([0.2, 0.6, 0.8]), "color=\"#3399cc\"");
|
||||
assert_eq!(hex_code([0.2, 0.6, 0.8]), "3399cc");
|
||||
}
|
||||
|
||||
/// Every mode draws in every theme, and a click in it sets the colour.
|
||||
#[test]
|
||||
fn every_mode_works_in_every_theme() {
|
||||
for kind in ThemeKind::ALL {
|
||||
for mode in ColorPanelMode::ALL {
|
||||
let mut h = color_tab(kind, mode, [1.0, 0.0, 0.0, 1.0]);
|
||||
match mode {
|
||||
ColorPanelMode::HueCube | ColorPanelMode::BrightnessCube => {
|
||||
assert!(h.query_by_label("#").is_some(), "{kind:?} {mode:?}: the readout");
|
||||
}
|
||||
ColorPanelMode::ColorWheel => {
|
||||
for label in ["H", "S", "B", "Add to Swatches"] {
|
||||
assert!(h.query_by_label(label).is_some(), "{kind:?} {mode:?}: {label}");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for (label, ..) in Model::of(mode).channels() {
|
||||
assert!(h.query_by_label(label).is_some(), "{kind:?} {mode:?}: the {label} slider");
|
||||
}
|
||||
// The black box at the end of the ramp sets black.
|
||||
let black = h.get_by_label("Black").rect().center();
|
||||
press(&mut h, black, Modifiers::NONE);
|
||||
assert_eq!(bytes(h.state().session.tools.foreground), [0, 0, 0], "{kind:?} {mode:?}");
|
||||
}
|
||||
}
|
||||
assert!(h.query_by_label("Hue").is_none() && h.query_by_label("Saturation").is_none(), "{kind:?} {mode:?}: no old sliders");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_slider_track_sets_its_channel() {
|
||||
let mut h = color_tab(ThemeKind::AdwaitaDark, ColorPanelMode::RgbSliders, [0.0, 0.0, 0.0, 1.0]);
|
||||
let g = h.get_by_label("G").rect();
|
||||
press(&mut h, pos2(g.right() - 2.0, g.center().y), Modifiers::NONE);
|
||||
assert_eq!(bytes(h.state().session.tools.foreground), [0, 255, 0]);
|
||||
// The field takes a typed value too (the spin buttons are R, G, B).
|
||||
type_into(&mut h, egui::accesskit::Role::SpinButton, 2, "128");
|
||||
assert_eq!(bytes(h.state().session.tools.foreground), [0, 255, 128]);
|
||||
}
|
||||
|
||||
/// Black has no hue or saturation of its own: typed H and S must stick while B is still 0.
|
||||
#[test]
|
||||
fn hsb_sliders_take_values_on_black() {
|
||||
let mut h = color_tab(ThemeKind::Studio, ColorPanelMode::HsbSliders, [0.0, 0.0, 0.0, 1.0]);
|
||||
for (field, typed) in [(0, "120"), (1, "100"), (2, "100")] {
|
||||
type_into(&mut h, egui::accesskit::Role::SpinButton, field, typed);
|
||||
}
|
||||
assert_eq!(bytes(h.state().session.tools.foreground), [0, 255, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_sliders_keep_typed_values() {
|
||||
let mut h = color_tab(ThemeKind::Pro, ColorPanelMode::CmykSliders, [1.0, 1.0, 1.0, 1.0]);
|
||||
type_into(&mut h, egui::accesskit::Role::SpinButton, 0, "40");
|
||||
type_into(&mut h, egui::accesskit::Role::SpinButton, 3, "20");
|
||||
let shown: Vec<String> = h.query_all_by_role(egui::accesskit::Role::SpinButton).filter_map(|n| n.value()).collect();
|
||||
assert_eq!(shown.first().map(String::as_str), Some("40"), "C stays as typed: {shown:?}");
|
||||
assert_eq!(shown.get(3).map(String::as_str), Some("20"), "K stays as typed: {shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_sliders_snap_and_take_hex() {
|
||||
let mut h = color_tab(ThemeKind::Classic, ColorPanelMode::WebColorSliders, [0.0, 0.0, 0.0, 1.0]);
|
||||
let r = h.get_by_label("R").rect();
|
||||
// 45% along the track snaps to 0x66 (102), the nearest web step.
|
||||
let x = r.left() + 4.0 + (r.width() - 8.0) * 0.45;
|
||||
press(&mut h, pos2(x, r.center().y), Modifiers::NONE);
|
||||
assert_eq!(bytes(h.state().session.tools.foreground), [0x66, 0, 0]);
|
||||
// Not a web colour: the cube alert snaps it.
|
||||
type_into(&mut h, egui::accesskit::Role::TextInput, 1, "C8");
|
||||
assert_eq!(bytes(h.state().session.tools.foreground), [0x66, 0xC8, 0]);
|
||||
let alert = h.get_by_label("Not a web colour: click for the nearest web colour").rect().center();
|
||||
press(&mut h, alert, Modifiers::NONE);
|
||||
assert_eq!(bytes(h.state().session.tools.foreground), [0x66, 0xCC, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ramp_picks_and_shift_click_cycles_and_alt_picks_the_other_colour() {
|
||||
let mut h = color_tab(ThemeKind::BreezeDark, ColorPanelMode::RgbSliders, [0.0, 0.0, 0.0, 1.0]);
|
||||
let ramp: Rect = h.get_by_label("Color Ramp").rect();
|
||||
// The RGB Spectrum's top is white.
|
||||
press(&mut h, pos2(ramp.left() + 3.0, ramp.top() + 1.0), Modifiers::NONE);
|
||||
assert!(bytes(h.state().session.tools.foreground).iter().all(|v| *v > 240), "{:?}", h.state().session.tools.foreground);
|
||||
// Alt picks the background (the panel edits the foreground).
|
||||
press(&mut h, pos2(ramp.left() + 3.0, ramp.bottom() - 1.0), Modifiers::ALT);
|
||||
assert!(bytes(h.state().session.tools.background).iter().all(|v| *v < 16), "{:?}", h.state().session.tools.background);
|
||||
assert!(bytes(h.state().session.tools.foreground).iter().all(|v| *v > 240), "Alt leaves the foreground");
|
||||
// Shift-click cycles the ramp and picks nothing.
|
||||
let fg = h.state().session.tools.foreground;
|
||||
for expected in [ColorRamp::CmykSpectrum, ColorRamp::GrayscaleRamp, ColorRamp::CurrentColors, ColorRamp::RgbSpectrum] {
|
||||
press(&mut h, ramp.center(), Modifiers::SHIFT);
|
||||
assert_eq!(h.state().ui.color_panel.ramp, expected);
|
||||
}
|
||||
assert_eq!(h.state().session.tools.foreground, fg);
|
||||
// Grayscale Ramp: its left end is white.
|
||||
h.state_mut().ui.color_panel.ramp = ColorRamp::GrayscaleRamp;
|
||||
h.state_mut().session.tools.foreground = [0.0, 0.0, 0.0, 1.0];
|
||||
h.run_steps(2);
|
||||
press(&mut h, pos2(ramp.left() + 1.0, ramp.center().y), Modifiers::NONE);
|
||||
assert!(bytes(h.state().session.tools.foreground).iter().all(|v| *v > 245));
|
||||
}
|
||||
|
||||
/// A drag on the Current Colors ramp picks from the ends it began with: holding still keeps the
|
||||
/// colour under the pointer instead of drifting toward the background.
|
||||
#[test]
|
||||
fn current_colors_ramp_drag_keeps_its_ends() {
|
||||
let mut h = color_tab(ThemeKind::BreezeDark, ColorPanelMode::RgbSliders, [0.0, 0.0, 0.0, 1.0]);
|
||||
h.state_mut().session.tools.background = [1.0, 1.0, 1.0, 1.0];
|
||||
h.state_mut().ui.color_panel.ramp = ColorRamp::CurrentColors;
|
||||
h.run_steps(2);
|
||||
let ramp: Rect = h.get_by_label("Color Ramp").rect();
|
||||
let p = ramp.center();
|
||||
h.hover_at(p);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
// Move away and back to start the drag, then hold still for a while.
|
||||
for q in [p + vec2(12.0, 0.0), p] {
|
||||
h.event(egui::Event::PointerMoved(q));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(20);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
let expected = ramp_color(ColorRamp::CurrentColors, false, [0.0; 3], [1.0; 3], (p.x - ramp.left()) / ramp.width(), 0.5);
|
||||
let got = bytes(h.state().session.tools.foreground);
|
||||
for (g, e) in got.iter().zip(expected) {
|
||||
assert!(i32::from(*g).abs_diff((e * 255.0).round() as i32) <= 3, "{got:?} vs {expected:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-finite pointer (a huge synthetic `ui.move`) misses the triangle instead of panicking.
|
||||
#[test]
|
||||
fn triangle_ignores_non_finite_points() {
|
||||
let tri = [pos2(100.0, 0.0), pos2(0.0, 0.0), pos2(0.0, 100.0)];
|
||||
for p in [pos2(f32::INFINITY, f32::INFINITY), pos2(f32::NAN, 1.0), pos2(f32::NEG_INFINITY, f32::INFINITY)] {
|
||||
assert_eq!(crate::color_panel_ui::triangle_sv(tri, p), None, "{p:?}");
|
||||
}
|
||||
assert!(crate::color_panel_ui::triangle_sv(tri, pos2(10.0, 10.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_wheel_ring_sets_hue_and_triangle_sets_saturation_and_brightness() {
|
||||
let mut h = color_tab(ThemeKind::Pro, ColorPanelMode::ColorWheel, [0.0, 0.0, 1.0, 1.0]);
|
||||
let area = h.get_by_label("Color Wheel").rect();
|
||||
let (center, inner, outer) = crate::color_panel_ui::wheel_geometry(area);
|
||||
let mid = (inner + outer) / 2.0;
|
||||
// Up on the ring: hue 90° (yellow-green). Blue is fully saturated and bright, so it keeps both.
|
||||
press(&mut h, pos2(center.x, center.y - mid), Modifiers::NONE);
|
||||
let [r, g, bl] = bytes(h.state().session.tools.foreground);
|
||||
assert!(r > 100 && g > 240 && bl < 10, "hue 90: {r} {g} {bl}");
|
||||
// The triangle's centre: some saturation, some brightness, same hue.
|
||||
press(&mut h, center, Modifiers::NONE);
|
||||
let after = bytes(h.state().session.tools.foreground);
|
||||
let hsv = photocraft_color::convert::rgb_to_hsb(after.map(|v| f32::from(v) / 255.0));
|
||||
assert!((hsv[0] - 90.0).abs() < 4.0 && hsv[1] < 0.9 && hsv[2] < 0.95, "{after:?} {hsv:?}");
|
||||
}
|
||||
|
||||
/// Picking a mode from the panel menu changes the panel; the theme never does.
|
||||
#[test]
|
||||
fn panel_menu_picks_the_mode_and_ramp() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.tools.foreground = [0.2, 0.6, 0.8, 1.0];
|
||||
let mut h = Harness::builder().with_size(vec2(260.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
crate::color_panel_ui::panel_menu(app, ui);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, ThemeKind::AdwaitaDark);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("✔ Hue Cube").is_some(), "Hue Cube is the default");
|
||||
for mode in ColorPanelMode::ALL.into_iter().skip(1).chain([ColorPanelMode::HueCube, ColorPanelMode::WebColorSliders]) {
|
||||
h.get_by_label(&format!(" {}", mode.label())).click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.color_panel.mode, mode);
|
||||
assert!(h.query_by_label(&format!("✔ {}", mode.label())).is_some());
|
||||
}
|
||||
h.get_by_label(" Current Colors").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.color_panel.ramp, ColorRamp::CurrentColors);
|
||||
h.get_by_label(" Make Ramp Web Safe").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.color_panel.web_safe_ramp);
|
||||
h.get_by_label("Copy Color's Hex Code").click();
|
||||
h.run_steps(1);
|
||||
let copied: Vec<_> =
|
||||
h.output().platform_output.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(s) = c { Some(s.clone()) } else { None }).collect();
|
||||
assert_eq!(copied, ["3399cc"]);
|
||||
// Switching theme keeps the mode.
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_theme(&ctx, ThemeKind::Pro);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.color_panel.mode, ColorPanelMode::WebColorSliders);
|
||||
}
|
||||
|
||||
/// `ui.set colorPanel` merges, so setting the mode keeps the edited chip; bad values change nothing.
|
||||
#[test]
|
||||
fn control_sets_the_mode_ramp_and_web_safe_ramp() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let call = |app: &mut PhotocraftApp, params: serde_json::Value| {
|
||||
let (req, _rx) = crate::control::ControlRequest::new("ui.set", params);
|
||||
match crate::control::handle(app, &ctx, &req) {
|
||||
crate::control::Outcome::Done(v) => v["ok"] == true,
|
||||
_ => panic!("expected an immediate reply"),
|
||||
}
|
||||
};
|
||||
assert!(call(&mut app, json!({"colorPanel": {"background": true}})));
|
||||
assert!(call(&mut app, json!({"colorPanel": {"mode": "labSliders", "ramp": "grayscaleRamp", "webSafeRamp": true}})));
|
||||
let cp = app.ui.color_panel;
|
||||
assert!(cp.background, "the chip is kept");
|
||||
assert_eq!((cp.mode, cp.ramp, cp.web_safe_ramp), (ColorPanelMode::LabSliders, ColorRamp::GrayscaleRamp, true));
|
||||
for bad in [json!({"colorPanel": {"mode": "hueWheel"}}), json!({"colorPanel": {"ramp": 3}}), json!({"colorPanel": {"webSafeRamp": "yes"}})] {
|
||||
assert!(!call(&mut app, bad.clone()), "{bad}");
|
||||
assert_eq!(app.ui.color_panel, cp, "{bad}: nothing changes");
|
||||
}
|
||||
}
|
||||
|
||||
/// Only Web Colors is `webOnly` now that the state is camelCase; state saved before still loads.
|
||||
#[test]
|
||||
fn web_only_reads_its_old_snake_case_key() {
|
||||
let old: crate::state::ColorPanelState = serde_json::from_value(json!({"background": true, "web_only": true})).unwrap();
|
||||
assert!(old.background && old.web_only);
|
||||
let new = serde_json::to_value(old).unwrap();
|
||||
assert_eq!((new.get("webOnly"), new.get("web_only")), (Some(&json!(true)), None));
|
||||
}
|
||||
|
||||
/// Make Ramp Web Safe: a click picks the cell drawn under the pointer, even near its edge. On
|
||||
/// the Grayscale Ramp the fifth cell is drawn white, though its right edge rounds to CC.
|
||||
#[test]
|
||||
fn web_safe_ramp_picks_the_cell_it_draws() {
|
||||
let mut h = color_tab(ThemeKind::Pro, ColorPanelMode::RgbSliders, [0.0, 0.0, 0.0, 1.0]);
|
||||
h.state_mut().ui.color_panel.ramp = ColorRamp::GrayscaleRamp;
|
||||
h.state_mut().ui.color_panel.web_safe_ramp = true;
|
||||
h.run_steps(2);
|
||||
let ramp: Rect = h.get_by_label("Color Ramp").rect();
|
||||
press(&mut h, pos2(ramp.left() + ramp.width() * 4.95 / 48.0, ramp.center().y), Modifiers::NONE);
|
||||
assert_eq!(bytes(h.state().session.tools.foreground), [255, 255, 255]);
|
||||
}
|
||||
|
||||
/// A Web Color field takes two hex digits only: "+F" is not 0F.
|
||||
#[test]
|
||||
fn web_field_ignores_a_sign() {
|
||||
let mut h = color_tab(ThemeKind::Pro, ColorPanelMode::WebColorSliders, [0.0, 0.0, 0.0, 1.0]);
|
||||
type_into(&mut h, egui::accesskit::Role::TextInput, 0, "+F");
|
||||
assert_eq!(bytes(h.state().session.tools.foreground), [0, 0, 0]);
|
||||
}
|
||||
|
||||
/// A click beside the Color Wheel (its area spans the panel) leaves the colour alone.
|
||||
#[test]
|
||||
fn color_wheel_ignores_clicks_beside_it() {
|
||||
let mut h = color_tab(ThemeKind::Pro, ColorPanelMode::ColorWheel, [0.0, 0.0, 1.0, 1.0]);
|
||||
let area = h.get_by_label("Color Wheel").rect();
|
||||
let (center, _, outer) = crate::color_panel_ui::wheel_geometry(area);
|
||||
let corner = area.left_top() + vec2(3.0, 3.0);
|
||||
assert!(corner.distance(center) > outer + 20.0, "the corner is beside the wheel");
|
||||
press(&mut h, corner, Modifiers::NONE);
|
||||
assert_eq!(bytes(h.state().session.tools.foreground), [0, 0, 255]);
|
||||
}
|
||||
|
||||
/// The Color tab in `mode` with a document holding one selected black rectangle, the Rectangle tool
|
||||
/// in hand, foreground blue and background red, editing the background chip if `background`.
|
||||
fn color_tab_with_a_shape(mode: ColorPanelMode, background: bool) -> Harness<'static, PhotocraftApp> {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.sync_views();
|
||||
app.run("shape.create", json!({"kind": "rect", "rect": [20, 20, 50, 50], "fill": "#000000"})).unwrap();
|
||||
app.ui.tool = crate::state::Tool::Rectangle;
|
||||
app.session.tools.foreground = [0.0, 0.0, 1.0, 1.0];
|
||||
app.session.tools.background = [1.0, 0.0, 0.0, 1.0];
|
||||
app.ui.color_panel.mode = mode;
|
||||
app.ui.color_panel.background = background;
|
||||
color_tab_of(ThemeKind::Studio, app)
|
||||
}
|
||||
|
||||
/// Types `text` into the first slider field (Hue or Red) and commits it with Enter.
|
||||
fn type_first_slider(h: &mut Harness<'_, PhotocraftApp>, text: &str) {
|
||||
h.query_all_by_role(egui::accesskit::Role::SpinButton).next().unwrap().click();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::Text(text.into()));
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn shape_fill(app: &PhotocraftApp) -> [u8; 3] {
|
||||
let st = app.session.active().unwrap();
|
||||
let photocraft_doc::LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("shape layer") };
|
||||
let Some(photocraft_doc::Fill::Solid(c)) = &sh.fill else { panic!("solid fill") };
|
||||
bytes([c.c[0], c.c[1], c.c[2], 1.0])
|
||||
}
|
||||
|
||||
/// The HSB sliders edit the chip the panel is editing (#5842): with the background chip active
|
||||
/// they change the background, and the selected shape (which takes the foreground with a shape
|
||||
/// tool in hand) keeps its fill. On the foreground chip the shape follows.
|
||||
#[test]
|
||||
fn hsb_sliders_edit_the_active_chip_and_only_the_foreground_recolours_shapes() {
|
||||
let mut h = color_tab_with_a_shape(ColorPanelMode::HsbSliders, true);
|
||||
type_first_slider(&mut h, "120");
|
||||
let app = h.state();
|
||||
assert_eq!(bytes(app.session.tools.background), [0, 255, 0], "the background takes the hue");
|
||||
assert_eq!(bytes(app.session.tools.foreground), [0, 0, 255], "the foreground is left alone");
|
||||
assert_eq!(shape_fill(app), [0, 0, 0], "a background edit recolours nothing");
|
||||
|
||||
let mut h = color_tab_with_a_shape(ColorPanelMode::HsbSliders, false);
|
||||
type_first_slider(&mut h, "120");
|
||||
let app = h.state();
|
||||
assert_eq!(bytes(app.session.tools.foreground), [0, 255, 0]);
|
||||
assert_eq!(bytes(app.session.tools.background), [255, 0, 0]);
|
||||
assert_eq!(shape_fill(app), [0, 255, 0], "the selected shape takes the new foreground");
|
||||
}
|
||||
|
||||
/// The RGB sliders recolour the selected shape like the HSB sliders, the field and the swatches
|
||||
/// do (`apply_color`), in one history step, and only for a foreground edit.
|
||||
#[test]
|
||||
fn rgb_sliders_recolour_the_selected_shape_with_the_foreground() {
|
||||
let mut h = color_tab_with_a_shape(ColorPanelMode::RgbSliders, false);
|
||||
let steps = h.state().session.active().unwrap().history.entries().len();
|
||||
type_first_slider(&mut h, "255");
|
||||
let app = h.state();
|
||||
assert_eq!(bytes(app.session.tools.foreground), [255, 0, 255]);
|
||||
assert_eq!(shape_fill(app), [255, 0, 255]);
|
||||
assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 1, "one history step");
|
||||
|
||||
let mut h = color_tab_with_a_shape(ColorPanelMode::RgbSliders, true);
|
||||
type_first_slider(&mut h, "0");
|
||||
let app = h.state();
|
||||
assert_eq!(bytes(app.session.tools.background), [0, 0, 0]);
|
||||
assert_eq!(shape_fill(app), [0, 0, 0], "a background edit recolours nothing");
|
||||
}
|
||||
|
||||
/// Every slider mode's ramp (here its white box) recolours the selected shape with a new
|
||||
/// foreground, as the sliders and the field do.
|
||||
#[test]
|
||||
fn every_slider_mode_recolours_the_selected_shape() {
|
||||
for mode in ColorPanelMode::ALL {
|
||||
if matches!(mode, ColorPanelMode::HueCube | ColorPanelMode::BrightnessCube | ColorPanelMode::ColorWheel) {
|
||||
continue;
|
||||
}
|
||||
let mut h = color_tab_with_a_shape(mode, false);
|
||||
let white = h.get_by_label("White").rect().center();
|
||||
press(&mut h, white, Modifiers::NONE);
|
||||
assert_eq!(bytes(h.state().session.tools.foreground), [255, 255, 255], "{mode:?}");
|
||||
assert_eq!(shape_fill(h.state()), [255, 255, 255], "{mode:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
//! Photoshop's Color panel modes, picked from its panel menu and the same in every theme (#6000,
|
||||
//! #2569): Hue Cube and Brightness Cube (drawn by [`crate::panels::color_field`]), Color Wheel (HSB
|
||||
//! sliders over a hue ring around a saturation/brightness triangle), and the Grayscale, RGB, HSB,
|
||||
//! CMYK, Lab and Web Color sliders over a colour ramp. The panel menu also has Copy Color as HTML,
|
||||
//! Copy Color's Hex Code, the ramp choices (RGB Spectrum, CMYK Spectrum, Grayscale Ramp, Current
|
||||
//! Colors) and Make Ramp Web Safe. Shift-click on the ramp cycles the ramps, Alt-click picks the
|
||||
//! colour the panel isn't editing, and a right-click opens the ramp menu.
|
||||
|
||||
use std::f32::consts::TAU;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::color_picker_ui::{hex, not_web_safe, out_of_gamut};
|
||||
use crate::panels::{color_field_fill_id, field_chips, field_hsva, srgb_hsva};
|
||||
use crate::state::{ColorPanelMode, ColorRamp};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
use photocraft_color::convert;
|
||||
use photocraft_color::convert::{hsb_to_rgb, rgb_to_hsb};
|
||||
|
||||
/// Width of the chips column, as the cube field's.
|
||||
const CHIPS_W: f32 = 38.0;
|
||||
/// Height of one slider row.
|
||||
const ROW_H: f32 = 22.0;
|
||||
/// Width of a slider row's value field.
|
||||
const FIELD_W: f32 = 44.0;
|
||||
/// Width of the unit after a field ("°", "%").
|
||||
const UNIT_W: f32 = 14.0;
|
||||
/// Width of the white and black boxes at the right end of the ramp.
|
||||
const BOXES_W: f32 = 16.0;
|
||||
|
||||
/// The Color tab: whatever the panel menu picked.
|
||||
pub(crate) fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
match app.ui.color_panel.mode {
|
||||
ColorPanelMode::HueCube | ColorPanelMode::BrightnessCube => crate::panels::color_field(app, ui),
|
||||
ColorPanelMode::ColorWheel => wheel(app, ui),
|
||||
mode => sliders(app, ui, mode),
|
||||
}
|
||||
crate::apply_color::track_focus(ui.ctx());
|
||||
}
|
||||
|
||||
/// The colour model a slider mode edits.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Model {
|
||||
Gray,
|
||||
Rgb,
|
||||
Hsb,
|
||||
Cmyk,
|
||||
Lab,
|
||||
Web,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub(crate) fn of(mode: ColorPanelMode) -> Model {
|
||||
match mode {
|
||||
ColorPanelMode::GrayscaleSlider => Model::Gray,
|
||||
ColorPanelMode::HsbSliders | ColorPanelMode::ColorWheel => Model::Hsb,
|
||||
ColorPanelMode::CmykSliders => Model::Cmyk,
|
||||
ColorPanelMode::LabSliders => Model::Lab,
|
||||
ColorPanelMode::WebColorSliders => Model::Web,
|
||||
ColorPanelMode::RgbSliders | ColorPanelMode::HueCube | ColorPanelMode::BrightnessCube => Model::Rgb,
|
||||
}
|
||||
}
|
||||
|
||||
/// Each slider: label, range and unit, in Photoshop's order.
|
||||
pub(crate) fn channels(self) -> &'static [(&'static str, f32, f32, &'static str)] {
|
||||
match self {
|
||||
Model::Gray => &[("K", 0.0, 100.0, "%")],
|
||||
Model::Rgb | Model::Web => &[("R", 0.0, 255.0, ""), ("G", 0.0, 255.0, ""), ("B", 0.0, 255.0, "")],
|
||||
Model::Hsb => &[("H", 0.0, 360.0, "°"), ("S", 0.0, 100.0, "%"), ("B", 0.0, 100.0, "%")],
|
||||
Model::Cmyk => &[("C", 0.0, 100.0, "%"), ("M", 0.0, 100.0, "%"), ("Y", 0.0, 100.0, "%"), ("K", 0.0, 100.0, "%")],
|
||||
Model::Lab => &[("L", 0.0, 100.0, ""), ("a", -128.0, 127.0, ""), ("b", -128.0, 127.0, "")],
|
||||
}
|
||||
}
|
||||
|
||||
/// The sRGB colour of slider values `v` (in the units the fields show).
|
||||
pub(crate) fn to_rgb(self, v: [f32; 4]) -> [f32; 3] {
|
||||
let [a, b, c, d] = v.map(|x| if x.is_finite() { x } else { 0.0 });
|
||||
let rgb = match self {
|
||||
Model::Gray => [1.0 - a / 100.0; 3],
|
||||
Model::Rgb | Model::Web => [a / 255.0, b / 255.0, c / 255.0],
|
||||
Model::Hsb => hsb_to_rgb([a, b / 100.0, c / 100.0]),
|
||||
Model::Cmyk => convert::cmyk_to_rgb([a / 100.0, b / 100.0, c / 100.0, d / 100.0]),
|
||||
Model::Lab => convert::lab_to_srgb([a, b, c]),
|
||||
};
|
||||
rgb.map(|x| if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 })
|
||||
}
|
||||
|
||||
/// Slider values of `rgb`, rounded to whole numbers as Photoshop's fields show them.
|
||||
pub(crate) fn values_of(self, rgb: [f32; 3]) -> [f32; 4] {
|
||||
let v = match self {
|
||||
Model::Gray => [(1.0 - convert::rgb_to_gray(rgb)) * 100.0, 0.0, 0.0, 0.0],
|
||||
Model::Rgb | Model::Web => [rgb[0] * 255.0, rgb[1] * 255.0, rgb[2] * 255.0, 0.0],
|
||||
Model::Hsb => {
|
||||
let [h, s, b] = rgb_to_hsb(rgb);
|
||||
[h, s * 100.0, b * 100.0, 0.0]
|
||||
}
|
||||
Model::Cmyk => convert::rgb_to_cmyk(rgb).map(|x| x * 100.0),
|
||||
Model::Lab => {
|
||||
let [l, a, b] = convert::srgb_to_lab(rgb);
|
||||
[l, a, b, 0.0]
|
||||
}
|
||||
};
|
||||
v.map(|x| if x.is_finite() { x.round() } else { 0.0 })
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour the panel edits.
|
||||
fn active(app: &PhotocraftApp) -> [f32; 3] {
|
||||
let c = if app.ui.color_panel.background { app.session.tools.background } else { app.session.tools.foreground };
|
||||
[c[0], c[1], c[2]].map(|x| x.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Set the background colour, or the foreground and recolour what is selected as the tool in hand
|
||||
/// says (`apply_color`); the picks of one drag or typed value are one history step.
|
||||
pub(crate) fn set_color(app: &mut PhotocraftApp, ctx: &egui::Context, background: bool, rgb: [f32; 3]) {
|
||||
let c = [rgb[0], rgb[1], rgb[2], 1.0];
|
||||
if background {
|
||||
app.session.tools.background = c;
|
||||
} else {
|
||||
app.session.tools.foreground = c;
|
||||
crate::apply_color::foreground_changed(app, crate::apply_color::gesture(ctx));
|
||||
}
|
||||
}
|
||||
|
||||
/// The key of the slider values last shown for `model`, kept while they still give the colour:
|
||||
/// a grey has no hue, and CMYK or Lab values don't survive an sRGB round trip (#3228).
|
||||
fn values_key(model: Model, background: bool) -> egui::Id {
|
||||
egui::Id::new(("color-panel-values", model, background))
|
||||
}
|
||||
|
||||
/// The key of the Hue Cube's remembered hue and saturation (see [`field_hsva`]).
|
||||
fn field_key(background: bool) -> egui::Id {
|
||||
egui::Id::new(("color-field-hue", background))
|
||||
}
|
||||
|
||||
/// Slider values for the colour the panel edits: the ones last shown while they still give it.
|
||||
fn current_values(ui: &egui::Ui, model: Model, background: bool, rgb: [f32; 3]) -> [f32; 4] {
|
||||
let stored: Option<[f32; 4]> = ui.data(|d| d.get_temp(values_key(model, background)));
|
||||
if let Some(v) = stored.filter(|v| hex(model.to_rgb(*v)) == hex(rgb)) {
|
||||
return v;
|
||||
}
|
||||
if model == Model::Hsb {
|
||||
// Share the cube's hue and saturation, so a grey keeps its hue across modes.
|
||||
let c = [rgb[0], rgb[1], rgb[2], 1.0];
|
||||
let hsva = srgb_hsva(c);
|
||||
let remembered: (f32, f32) = ui.data(|d| d.get_temp(field_key(background))).unwrap_or((hsva.h, hsva.s));
|
||||
let h = field_hsva(hsva, remembered);
|
||||
return [h.h * 360.0, h.s * 100.0, h.v * 100.0, 0.0].map(f32::round);
|
||||
}
|
||||
model.values_of(rgb)
|
||||
}
|
||||
|
||||
/// Apply edited slider values: the colour, and the values (and the cube's hue) remembered.
|
||||
fn apply_values(app: &mut PhotocraftApp, ui: &egui::Ui, model: Model, v: [f32; 4]) {
|
||||
let background = app.ui.color_panel.background;
|
||||
let rgb = model.to_rgb(v);
|
||||
set_color(app, ui.ctx(), background, rgb);
|
||||
ui.data_mut(|d| d.insert_temp(values_key(model, background), v));
|
||||
if model == Model::Hsb && v[2] > 0.5 {
|
||||
ui.data_mut(|d| d.insert_temp(field_key(background), (v[0] / 360.0, v[1] / 100.0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Track colours of slider `i` at `steps` + 1 points, the other sliders at `v`.
|
||||
fn track_stops(model: Model, v: [f32; 4], i: usize, steps: usize) -> Vec<Color32> {
|
||||
let Some(&(_, lo, hi, _)) = model.channels().get(i) else { return Vec::new() };
|
||||
(0..=steps)
|
||||
.map(|k| {
|
||||
let mut w = v;
|
||||
if let Some(slot) = w.get_mut(i) {
|
||||
*slot = lo + (hi - lo) * k as f32 / steps as f32;
|
||||
}
|
||||
// HSB's hue track is the full spectrum, whatever the saturation and brightness.
|
||||
if model == Model::Hsb && i == 0 {
|
||||
w = [w[0], 100.0, 100.0, 0.0];
|
||||
}
|
||||
rgb32(model.to_rgb(w))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn rgb32(c: [f32; 3]) -> Color32 {
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
Color32::from_rgb(q(c[0]), q(c[1]), q(c[2]))
|
||||
}
|
||||
|
||||
/// The slider rows of `model`. Returns the edited values, if any.
|
||||
fn slider_rows(ui: &mut egui::Ui, model: Model, v: [f32; 4], background: bool, dynamic: bool) -> Option<[f32; 4]> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Dynamic Color Sliders off: the tracks keep the colours they had when a drag began.
|
||||
let stops_key = egui::Id::new(("color-panel-stops", model, background));
|
||||
let dragging = ui.input(|i| i.pointer.any_down());
|
||||
let stops_from = if dynamic || !dragging { v } else { ui.data(|d| d.get_temp::<[f32; 4]>(stops_key)).unwrap_or(v) };
|
||||
if !dragging {
|
||||
ui.data_mut(|d| d.insert_temp(stops_key, v));
|
||||
}
|
||||
let mut out = None;
|
||||
let mut values = v;
|
||||
for (i, &(label, lo, hi, unit)) in model.channels().iter().enumerate() {
|
||||
let Some(¤t) = values.get(i) else { continue };
|
||||
let mut x = current;
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
ui.set_height(ROW_H);
|
||||
let (lr, _) = ui.allocate_exact_size(vec2(12.0, ROW_H), Sense::hover());
|
||||
ui.painter().text(lr.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text_dim);
|
||||
let track_w = (ui.available_width() - FIELD_W - UNIT_W - 8.0).max(24.0);
|
||||
let (tr, resp) = ui.allocate_exact_size(vec2(track_w, ROW_H), Sense::click_and_drag());
|
||||
let stops = track_stops(model, stops_from, i, 24);
|
||||
changed |= track(ui, tr, &resp, &mut x, lo, hi, &stops, model == Model::Web);
|
||||
resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(x), label));
|
||||
if model == Model::Web {
|
||||
changed |= hex_pair(ui, ui.id().with(("web-field", i, background)), &mut x);
|
||||
} else if widgets::value_field(ui, &mut x, lo..=hi, "", FIELD_W).changed() {
|
||||
changed = true;
|
||||
}
|
||||
let (ur, _) = ui.allocate_exact_size(vec2(UNIT_W, ROW_H), Sense::hover());
|
||||
ui.painter().text(ur.left_center(), egui::Align2::LEFT_CENTER, unit, egui::FontId::proportional(12.0), t.text_faint);
|
||||
});
|
||||
if changed && let Some(slot) = values.get_mut(i) {
|
||||
*slot = x.clamp(lo, hi).round();
|
||||
out = Some(values);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A slider track with Photoshop's triangle thumb under it. Returns true when dragged or clicked
|
||||
/// to a new value; Web Color tracks snap to the web steps (00, 33, 66, 99, CC, FF).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn track(ui: &egui::Ui, r: Rect, resp: &egui::Response, value: &mut f32, lo: f32, hi: f32, stops: &[Color32], web: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let bar = Rect::from_min_max(pos2(r.left() + 4.0, r.center().y - 6.0), pos2(r.right() - 4.0, r.center().y + 1.0));
|
||||
let mut mesh = Mesh::default();
|
||||
let n = stops.len().saturating_sub(1).max(1);
|
||||
for (k, c) in stops.iter().enumerate() {
|
||||
let x = bar.left() + bar.width() * k as f32 / n as f32;
|
||||
mesh.colored_vertex(pos2(x, bar.top()), *c);
|
||||
mesh.colored_vertex(pos2(x, bar.bottom()), *c);
|
||||
}
|
||||
for k in 0..stops.len().saturating_sub(1) as u32 {
|
||||
let a = k * 2;
|
||||
mesh.add_triangle(a, a + 1, a + 3);
|
||||
mesh.add_triangle(a, a + 3, a + 2);
|
||||
}
|
||||
ui.painter().add(mesh);
|
||||
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
if web {
|
||||
// Ticks at the web steps.
|
||||
for k in 0..=5 {
|
||||
let x = bar.left() + bar.width() * k as f32 / 5.0;
|
||||
ui.painter().line_segment([pos2(x, bar.bottom() + 1.0), pos2(x, bar.bottom() + 3.0)], Stroke::new(1.0, t.text_faint));
|
||||
}
|
||||
}
|
||||
let mut changed = false;
|
||||
if (resp.dragged() || resp.clicked())
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& bar.width() > 0.0
|
||||
&& hi > lo
|
||||
{
|
||||
let f = ((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0);
|
||||
let mut v = lo + (hi - lo) * f;
|
||||
if web {
|
||||
v = (v / 51.0).round() * 51.0;
|
||||
}
|
||||
let v = v.round().clamp(lo, hi);
|
||||
if v != *value {
|
||||
*value = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let f = if hi > lo { ((*value - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let x = bar.left() + bar.width() * f;
|
||||
let y = bar.bottom() + 1.0;
|
||||
let thumb = vec![pos2(x, y), pos2(x + 5.0, y + 7.0), pos2(x - 5.0, y + 7.0)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(thumb, t.text, Stroke::new(1.0, t.field_border)));
|
||||
changed
|
||||
}
|
||||
|
||||
/// A Web Color slider's field: the channel as two hex digits. Valid input sets `value` (0..255)
|
||||
/// and returns true; while focused it shows what is typed, and an invalid entry snaps back.
|
||||
fn hex_pair(ui: &mut egui::Ui, id: egui::Id, value: &mut f32) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(FIELD_W, ROW_H), Sense::hover());
|
||||
widgets::surface(ui, rect, t.field, false);
|
||||
if !t.bevel {
|
||||
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
}
|
||||
let byte = value.clamp(0.0, 255.0).round() as u8;
|
||||
let typing = if ui.memory(|m| m.has_focus(id)) { ui.data(|d| d.get_temp::<String>(id)) } else { None };
|
||||
let mut text = typing.unwrap_or_else(|| format!("{byte:02X}"));
|
||||
let inner = rect.shrink2(vec2(5.0, 2.0));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
let resp = child
|
||||
.add(egui::TextEdit::singleline(&mut text).id(id).char_limit(2).desired_width(inner.width()).frame(egui::Frame::NONE).font(crate::theme::mono(12.0)));
|
||||
if resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(id, text.clone()));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
}
|
||||
// Two hex digits only: `from_str_radix` would also take a sign ("+F").
|
||||
if resp.changed()
|
||||
&& text.len() == 2
|
||||
&& text.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
&& let Ok(b) = u8::from_str_radix(&text, 16)
|
||||
{
|
||||
*value = f32::from(b);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The slider modes: chips (with the out-of-gamut and web-colour alerts under them) beside the
|
||||
/// sliders, over the colour ramp.
|
||||
fn sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui, mode: ColorPanelMode) {
|
||||
let model = Model::of(mode);
|
||||
let background = app.ui.color_panel.background;
|
||||
let rgb = active(app);
|
||||
let v = current_values(ui, model, background, rgb);
|
||||
let dynamic = app.session.prefs().interface.dynamic_color_sliders;
|
||||
let rows_h = model.channels().len() as f32 * (ROW_H + ui.spacing().item_spacing.y);
|
||||
let top_h = rows_h.max(CHIPS_W + 40.0);
|
||||
let mut edited = None;
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
let (col, _) = ui.allocate_exact_size(vec2(CHIPS_W, top_h), Sense::hover());
|
||||
field_chips(app, ui, col);
|
||||
alerts(app, ui, col, rgb, model == Model::Web);
|
||||
ui.vertical(|ui| {
|
||||
edited = slider_rows(ui, model, v, background, dynamic);
|
||||
});
|
||||
});
|
||||
if let Some(v) = edited {
|
||||
apply_values(app, ui, model, v);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let fill = ui.data(|d| d.get_temp::<f32>(color_field_fill_id()));
|
||||
let gaps = 2.0 * ui.spacing().item_spacing.y + 6.0;
|
||||
let ramp_h = fill.filter(|f| f.is_finite()).map_or(36.0, |f| (f - top_h - gaps).clamp(28.0, 2000.0));
|
||||
ramp(app, ui, ramp_h);
|
||||
}
|
||||
|
||||
/// Photoshop's alerts under the chips: ⚠ when the colour can't print (click: the nearest CMYK
|
||||
/// colour) and, for Web Color sliders, the cube when it isn't a web colour (click: the nearest).
|
||||
fn alerts(app: &mut PhotocraftApp, ui: &mut egui::Ui, col: Rect, rgb: [f32; 3], web: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let background = app.ui.color_panel.background;
|
||||
let mut y = col.top() + CHIPS_W + 2.0;
|
||||
let warnings = [
|
||||
(out_of_gamut(rgb), "Out of gamut for printing: click for the nearest printable colour", false),
|
||||
(if web { not_web_safe(rgb) } else { None }, "Not a web colour: click for the nearest web colour", true),
|
||||
];
|
||||
for (slot, (near, tip, cube)) in warnings.into_iter().enumerate() {
|
||||
let Some(near) = near else { continue };
|
||||
let icon = Rect::from_min_size(pos2(col.left() + 1.0, y), vec2(14.0, 13.0));
|
||||
let chip = Rect::from_min_size(pos2(col.left() + 18.0, y), vec2(13.0, 13.0));
|
||||
if cube {
|
||||
crate::color_picker_ui::web_cube(ui.painter(), icon.shrink(0.5), t.text_dim);
|
||||
} else {
|
||||
crate::color_picker_ui::warning_triangle(ui.painter(), icon, t.text_dim, t.card);
|
||||
}
|
||||
ui.painter().rect_filled(chip, 0.0, rgb32(near));
|
||||
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Outside);
|
||||
let resp = ui.interact(icon.union(chip), ui.id().with(("color-panel-alert", slot)), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tip));
|
||||
if resp.on_hover_text(tl!(tip)).clicked() {
|
||||
set_color(app, ui.ctx(), background, near);
|
||||
}
|
||||
y += 18.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// The ramp's colour at (x, y) in 0..1 (y down). RGB Spectrum: hue across, white at the top
|
||||
/// through the full colour to black at the bottom; CMYK Spectrum: the same in the CMYK gamut;
|
||||
/// Grayscale Ramp: white to black; Current Colors: the foreground to the background.
|
||||
pub(crate) fn ramp_color(ramp: ColorRamp, web_safe: bool, fg: [f32; 3], bg: [f32; 3], x: f32, y: f32) -> [f32; 3] {
|
||||
let (x, y) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
|
||||
let spectrum = || {
|
||||
let (s, v) = if y < 0.5 { (y * 2.0, 1.0) } else { (1.0, 1.0 - (y - 0.5) * 2.0) };
|
||||
hsb_to_rgb([x.min(0.9999) * 360.0, s, v])
|
||||
};
|
||||
let c = match ramp {
|
||||
ColorRamp::RgbSpectrum => spectrum(),
|
||||
ColorRamp::CmykSpectrum => convert::cmyk_to_rgb(convert::rgb_to_cmyk(spectrum())),
|
||||
ColorRamp::GrayscaleRamp => [1.0 - x; 3],
|
||||
ColorRamp::CurrentColors => [0, 1, 2].map(|i| fg[i] + (bg[i] - fg[i]) * x),
|
||||
};
|
||||
let c = c.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 });
|
||||
if web_safe { c.map(|v| (v * 5.0).round() / 5.0) } else { c }
|
||||
}
|
||||
|
||||
/// Cells across and down the ramp's mesh.
|
||||
const RAMP_NX: usize = 48;
|
||||
const RAMP_NY: usize = 8;
|
||||
|
||||
/// The ramp's colours, cached until the ramp, its inputs or the CMYK space change: the CMYK
|
||||
/// Spectrum takes a CMYK round trip per vertex, too slow to redo every frame.
|
||||
fn ramp_colors(ui: &egui::Ui, ramp: ColorRamp, web_safe: bool, fg: [f32; 3], bg: [f32; 3]) -> Arc<Vec<Color32>> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(ramp as u8, web_safe).hash(&mut h);
|
||||
if ramp == ColorRamp::CurrentColors {
|
||||
(hex(fg), hex(bg)).hash(&mut h);
|
||||
}
|
||||
if ramp == ColorRamp::CmykSpectrum {
|
||||
convert::active_cmyk_space().map(|s| Arc::as_ptr(&s) as usize).hash(&mut h);
|
||||
}
|
||||
let sig = h.finish();
|
||||
let key = egui::Id::new("color-panel-ramp-cache");
|
||||
if let Some((s, colors)) = ui.data(|d| d.get_temp::<(u64, Arc<Vec<Color32>>)>(key))
|
||||
&& s == sig
|
||||
{
|
||||
return colors;
|
||||
}
|
||||
// Web-safe ramps are flat cells (sampled at their centres), the others shaded vertices.
|
||||
let (nx, ny, half) = if web_safe { (RAMP_NX, RAMP_NY, 0.5) } else { (RAMP_NX + 1, RAMP_NY + 1, 0.0) };
|
||||
let mut colors = Vec::with_capacity(nx * ny);
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let (x, y) = ((i as f32 + half) / RAMP_NX as f32, (j as f32 + half) / RAMP_NY as f32);
|
||||
colors.push(rgb32(ramp_color(ramp, web_safe, fg, bg, x, y)));
|
||||
}
|
||||
}
|
||||
let colors = Arc::new(colors);
|
||||
ui.data_mut(|d| d.insert_temp(key, (sig, colors.clone())));
|
||||
colors
|
||||
}
|
||||
|
||||
/// The ramp mesh over `r` from [`ramp_colors`].
|
||||
fn ramp_mesh(r: Rect, colors: &[Color32], web_safe: bool) -> Mesh {
|
||||
let mut m = Mesh::default();
|
||||
let at = |i: usize, j: usize| pos2(r.left() + r.width() * i as f32 / RAMP_NX as f32, r.top() + r.height() * j as f32 / RAMP_NY as f32);
|
||||
if web_safe {
|
||||
for j in 0..RAMP_NY {
|
||||
for i in 0..RAMP_NX {
|
||||
let Some(&c) = colors.get(j * RAMP_NX + i) else { continue };
|
||||
let a = m.vertices.len() as u32;
|
||||
for p in [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)] {
|
||||
m.colored_vertex(p, c);
|
||||
}
|
||||
m.add_triangle(a, a + 1, a + 2);
|
||||
m.add_triangle(a, a + 2, a + 3);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
for j in 0..=RAMP_NY {
|
||||
for i in 0..=RAMP_NX {
|
||||
m.colored_vertex(at(i, j), colors.get(j * (RAMP_NX + 1) + i).copied().unwrap_or(Color32::BLACK));
|
||||
}
|
||||
}
|
||||
let row = (RAMP_NX + 1) as u32;
|
||||
for j in 0..RAMP_NY as u32 {
|
||||
for i in 0..RAMP_NX as u32 {
|
||||
let a = j * row + i;
|
||||
m.add_triangle(a, a + 1, a + row + 1);
|
||||
m.add_triangle(a, a + row + 1, a + row);
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The colour ramp with the white and black boxes at its right end. A click or drag picks; with
|
||||
/// Alt it picks the colour the panel isn't editing; Shift-click cycles the ramps; a right-click
|
||||
/// opens the ramp menu.
|
||||
fn ramp(app: &mut PhotocraftApp, ui: &mut egui::Ui, height: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (all, _) = ui.allocate_exact_size(vec2(w, height), Sense::hover());
|
||||
let bar = Rect::from_min_max(all.min, pos2(all.right() - BOXES_W - 2.0, all.bottom()));
|
||||
let boxes = Rect::from_min_max(pos2(all.right() - BOXES_W, all.top()), all.max);
|
||||
let cp = app.ui.color_panel;
|
||||
let ramp_id = ui.id().with("color-panel-ramp");
|
||||
// A pick changes the foreground or background, so a drag keeps the Current Colors ends it began
|
||||
// with; otherwise the ramp would slide under the pointer and a held pick would drift.
|
||||
let ends_id = ramp_id.with("ends");
|
||||
let live = (rgb3(app.session.tools.foreground), rgb3(app.session.tools.background));
|
||||
let (fg, bg) = if ui.ctx().is_being_dragged(ramp_id) {
|
||||
ui.data(|d| d.get_temp::<([f32; 3], [f32; 3])>(ends_id)).unwrap_or(live)
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(ends_id, live));
|
||||
live
|
||||
};
|
||||
let colors = ramp_colors(ui, cp.ramp, cp.web_safe_ramp, fg, bg);
|
||||
ui.painter().add(ramp_mesh(bar, &colors, cp.web_safe_ramp));
|
||||
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
let (white, black) =
|
||||
(Rect::from_min_max(boxes.min, pos2(boxes.right(), boxes.center().y)), Rect::from_min_max(pos2(boxes.left(), boxes.center().y), boxes.max));
|
||||
ui.painter().rect_filled(white, 0.0, Color32::WHITE);
|
||||
ui.painter().rect_filled(black, 0.0, Color32::BLACK);
|
||||
ui.painter().rect_stroke(boxes, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
let resp = ui.interact(bar, ramp_id, Sense::click_and_drag());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), "Color Ramp"));
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let target = cp.background != mods.alt;
|
||||
if resp.clicked() && mods.shift {
|
||||
app.ui.color_panel.ramp = cp.ramp.next();
|
||||
} else if (resp.dragged() || resp.clicked())
|
||||
&& !mods.shift
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& bar.width() > 0.0
|
||||
&& bar.height() > 0.0
|
||||
{
|
||||
let (mut x, mut y) = ((p.x - bar.left()) / bar.width(), (p.y - bar.top()) / bar.height());
|
||||
if cp.web_safe_ramp {
|
||||
// Pick the cell drawn under the pointer: web-safe cells show their centre's colour.
|
||||
let centre = |f: f32, n: usize| ((f * n as f32).floor().clamp(0.0, (n - 1) as f32) + 0.5) / n as f32;
|
||||
(x, y) = (centre(x, RAMP_NX), centre(y, RAMP_NY));
|
||||
}
|
||||
let c = ramp_color(cp.ramp, cp.web_safe_ramp, fg, bg, x, y);
|
||||
set_color(app, ui.ctx(), target, c);
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
widgets::style_spectrum_popup_menu(ui);
|
||||
ramp_menu(app, ui);
|
||||
});
|
||||
for (r, c, name) in [(white, [1.0; 3], "White"), (black, [0.0; 3], "Black")] {
|
||||
let b = ui.interact(r, ui.id().with(("color-panel-box", name)), Sense::click());
|
||||
b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), name));
|
||||
if b.clicked() {
|
||||
set_color(app, ui.ctx(), target, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb3(c: [f32; 4]) -> [f32; 3] {
|
||||
[c[0], c[1], c[2]].map(|v| v.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// The Color Wheel: HSB sliders over a hue ring (red at the right, running counter-clockwise
|
||||
/// through yellow and green) around a triangle whose tip is the hue, with white and black at its
|
||||
/// other corners; the add-to-swatches button sits under it.
|
||||
fn wheel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let background = app.ui.color_panel.background;
|
||||
let rgb = active(app);
|
||||
let v = current_values(ui, Model::Hsb, background, rgb);
|
||||
let dynamic = app.session.prefs().interface.dynamic_color_sliders;
|
||||
let rows_h = 3.0 * (ROW_H + ui.spacing().item_spacing.y);
|
||||
let top_h = rows_h.max(CHIPS_W);
|
||||
let mut edited = None;
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
let (col, _) = ui.allocate_exact_size(vec2(CHIPS_W, top_h), Sense::hover());
|
||||
field_chips(app, ui, col);
|
||||
ui.vertical(|ui| {
|
||||
edited = slider_rows(ui, Model::Hsb, v, background, dynamic);
|
||||
});
|
||||
});
|
||||
let mut v = edited.unwrap_or(v);
|
||||
let mut changed = edited.is_some();
|
||||
ui.add_space(6.0);
|
||||
let fill = ui.data(|d| d.get_temp::<f32>(color_field_fill_id()));
|
||||
let gaps = 2.0 * ui.spacing().item_spacing.y + 6.0;
|
||||
let w = ui.available_width();
|
||||
let side = fill.filter(|f| f.is_finite()).map_or(w.min(220.0), |f| (f - top_h - gaps).min(w)).max(80.0);
|
||||
let (area, resp) = ui.allocate_exact_size(vec2(w, side), Sense::click_and_drag());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), "Color Wheel"));
|
||||
let (center, inner, outer) = wheel_geometry(area);
|
||||
let ring_w = outer - inner;
|
||||
let (h, s, b) = (v[0] / 360.0, v[1] / 100.0, v[2] / 100.0);
|
||||
ui.painter().add(ring_mesh(center, inner, outer));
|
||||
let tri = triangle(center, inner - 1.0, h);
|
||||
ui.painter().add(triangle_mesh(tri, h));
|
||||
// Which part a drag began on: the ring sets the hue, the triangle saturation and brightness.
|
||||
// The ring or the triangle, from where the press began (kept for frames without a press
|
||||
// origin), so a drag that leaves the ring keeps turning the hue.
|
||||
let drag_key = ui.id().with("color-wheel-drag");
|
||||
if (resp.dragged() || resp.clicked())
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
// A click is where it landed; a drag goes by where its press began.
|
||||
let origin = if resp.clicked() { Some(p) } else { ui.input(|i| i.pointer.press_origin()) };
|
||||
// `None`: the press began beside the wheel (the area spans the panel), which picks nothing.
|
||||
let part = match origin {
|
||||
Some(origin) => {
|
||||
let d = origin.distance(center);
|
||||
// The hue knob reaches past the ring's edge on a small wheel.
|
||||
let part = (d <= outer + 7.0).then_some(d >= inner - 2.0);
|
||||
ui.data_mut(|data| data.insert_temp(drag_key, part));
|
||||
part
|
||||
}
|
||||
None => ui.data(|d| d.get_temp::<Option<bool>>(drag_key)).unwrap_or(Some(false)),
|
||||
};
|
||||
match part {
|
||||
Some(true) => {
|
||||
let a = (center.y - p.y).atan2(p.x - center.x).rem_euclid(TAU);
|
||||
v[0] = (a / TAU * 360.0).round().rem_euclid(360.0);
|
||||
changed = true;
|
||||
}
|
||||
Some(false) => {
|
||||
if let Some((s2, b2)) = triangle_sv(tri, p) {
|
||||
v[1] = (s2 * 100.0).round();
|
||||
v[2] = (b2 * 100.0).round();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Knobs: on the ring at the hue, in the triangle at the saturation and brightness.
|
||||
let a = h * TAU;
|
||||
let ring_knob = center + vec2(a.cos(), -a.sin()) * (inner + ring_w / 2.0);
|
||||
let tri_knob = triangle_point(tri, s, b);
|
||||
for k in [ring_knob, tri_knob] {
|
||||
ui.painter().circle_stroke(k, 5.0, Stroke::new(1.5, Color32::WHITE));
|
||||
ui.painter().circle_stroke(k, 6.5, Stroke::new(1.0, Color32::from_black_alpha(160)));
|
||||
}
|
||||
// Photoshop's add-to-swatches button under the wheel.
|
||||
let add = Rect::from_min_size(pos2(area.right() - 16.0, area.bottom() - 16.0), vec2(14.0, 14.0));
|
||||
ui.painter().rect_stroke(add, 1.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
|
||||
ui.painter().line_segment([pos2(add.center().x, add.top() + 3.5), pos2(add.center().x, add.bottom() - 3.5)], Stroke::new(1.0, t.text_dim));
|
||||
ui.painter().line_segment([pos2(add.left() + 3.5, add.center().y), pos2(add.right() - 3.5, add.center().y)], Stroke::new(1.0, t.text_dim));
|
||||
let add_resp = ui.interact(add, ui.id().with("color-wheel-add"), Sense::click());
|
||||
add_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Add to Swatches"));
|
||||
if add_resp.on_hover_text(tl!("Create new swatch")).clicked()
|
||||
&& let Err(e) = app.run("swatches.add", json!({"color": hex(rgb)}))
|
||||
{
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
if changed {
|
||||
apply_values(app, ui, Model::Hsb, v);
|
||||
}
|
||||
}
|
||||
|
||||
/// The wheel in `area`: its centre and the hue ring's inner and outer radii.
|
||||
pub(crate) fn wheel_geometry(area: Rect) -> (Pos2, f32, f32) {
|
||||
let outer = (area.width().min(area.height()) / 2.0 - 2.0).max(1.0);
|
||||
let ring_w = (outer * 0.09).clamp(6.0, 22.0).min(outer * 0.5);
|
||||
(area.center(), outer - ring_w, outer)
|
||||
}
|
||||
|
||||
/// The wheel's hue ring between radii `inner` and `outer`.
|
||||
fn ring_mesh(center: Pos2, inner: f32, outer: f32) -> Mesh {
|
||||
let mut m = Mesh::default();
|
||||
let steps = 96u32;
|
||||
for k in 0..=steps {
|
||||
let f = k as f32 / steps as f32;
|
||||
let a = f * TAU;
|
||||
let c = rgb32(hsb_to_rgb([f * 360.0, 1.0, 1.0]));
|
||||
let dir = vec2(a.cos(), -a.sin());
|
||||
m.colored_vertex(center + dir * inner, c);
|
||||
m.colored_vertex(center + dir * outer, c);
|
||||
}
|
||||
for k in 0..steps {
|
||||
let a = k * 2;
|
||||
m.add_triangle(a, a + 1, a + 3);
|
||||
m.add_triangle(a, a + 3, a + 2);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The triangle's corners for hue `h` (0..1): the pure hue at the hue's angle, white 120° on and
|
||||
/// black 240° on, inside radius `r`.
|
||||
fn triangle(center: Pos2, r: f32, h: f32) -> [Pos2; 3] {
|
||||
let at = |turns: f32| {
|
||||
let a = (h + turns) * TAU;
|
||||
center + vec2(a.cos(), -a.sin()) * r
|
||||
};
|
||||
[at(0.0), at(1.0 / 3.0), at(2.0 / 3.0)]
|
||||
}
|
||||
|
||||
/// The point of saturation `s` and brightness `b` in the triangle: black + b·(white − black) +
|
||||
/// b·s·(hue − white).
|
||||
fn triangle_point([hue, white, black]: [Pos2; 3], s: f32, b: f32) -> Pos2 {
|
||||
black + (white - black) * b + (hue - white) * (b * s)
|
||||
}
|
||||
|
||||
/// Saturation and brightness at `p`, clamped into the triangle; `None` for a degenerate one or a
|
||||
/// non-finite `p`.
|
||||
pub(crate) fn triangle_sv([hue, white, black]: [Pos2; 3], p: Pos2) -> Option<(f32, f32)> {
|
||||
let (a, c, d) = (white - black, hue - white, p - black);
|
||||
let det = a.x * c.y - a.y * c.x;
|
||||
if det.abs() < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let (b, bs) = ((d.x * c.y - d.y * c.x) / det, (a.x * d.y - a.y * d.x) / det);
|
||||
// A NaN `b` would panic as a clamp bound.
|
||||
if !b.is_finite() || !bs.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let b = b.clamp(0.0, 1.0);
|
||||
let bs = bs.clamp(0.0, b);
|
||||
Some((if b > 0.0 { bs / b } else { 0.0 }, b))
|
||||
}
|
||||
|
||||
/// The triangle shaded through saturation and brightness at hue `h`.
|
||||
fn triangle_mesh(tri: [Pos2; 3], h: f32) -> Mesh {
|
||||
let n = 16usize;
|
||||
let mut m = Mesh::default();
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
let (s, b) = (i as f32 / n as f32, j as f32 / n as f32);
|
||||
m.colored_vertex(triangle_point(tri, s, b), rgb32(hsb_to_rgb([h * 360.0, s, b])));
|
||||
}
|
||||
}
|
||||
let row = (n + 1) as u32;
|
||||
for j in 0..n as u32 {
|
||||
for i in 0..n as u32 {
|
||||
let a = j * row + i;
|
||||
m.add_triangle(a, a + 1, a + row + 1);
|
||||
m.add_triangle(a, a + row + 1, a + row);
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// `color="#rrggbb"`, what Copy Color as HTML puts on the clipboard.
|
||||
pub(crate) fn html_color(rgb: [f32; 3]) -> String {
|
||||
format!("color=\"{}\"", hex(rgb))
|
||||
}
|
||||
|
||||
/// `rrggbb`, what Copy Color's Hex Code puts on the clipboard.
|
||||
pub(crate) fn hex_code(rgb: [f32; 3]) -> String {
|
||||
hex(rgb).trim_start_matches('#').to_string()
|
||||
}
|
||||
|
||||
/// A panel menu row with Photoshop's check mark column.
|
||||
fn check_item(ui: &mut egui::Ui, on: bool, label: &str) -> bool {
|
||||
ui.button(format!("{}{}", if on { "✔ " } else { " " }, tl!(label))).clicked()
|
||||
}
|
||||
|
||||
/// The ramp choices and Make Ramp Web Safe (the panel menu's last section and the ramp's
|
||||
/// right-click menu).
|
||||
fn ramp_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
for r in ColorRamp::ALL {
|
||||
if check_item(ui, app.ui.color_panel.ramp == r, r.label()) {
|
||||
app.ui.color_panel.ramp = r;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if check_item(ui, app.ui.color_panel.web_safe_ramp, "Make Ramp Web Safe") {
|
||||
app.ui.color_panel.web_safe_ramp = !app.ui.color_panel.web_safe_ramp;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The Color panel's panel menu, Photoshop's: the modes, the copy commands, the ramps.
|
||||
pub(crate) fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// One section, as Photoshop's: no separator between Color Wheel and Grayscale Slider.
|
||||
for mode in ColorPanelMode::ALL {
|
||||
if check_item(ui, app.ui.color_panel.mode == mode, mode.label()) {
|
||||
app.ui.color_panel.mode = mode;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let rgb = active(app);
|
||||
if ui.button(tl!("Copy Color as HTML")).clicked() {
|
||||
ui.ctx().copy_text(html_color(rgb));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Copy Color's Hex Code")).clicked() {
|
||||
ui.ctx().copy_text(hex_code(rgb));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
ramp_menu(app, ui);
|
||||
}
|
||||
@@ -567,7 +567,7 @@ fn swatches(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
}
|
||||
|
||||
/// ⚠: a filled triangle with an exclamation mark cut out of it.
|
||||
fn warning_triangle(p: &egui::Painter, r: Rect, ink: Color32, cut: Color32) {
|
||||
pub(crate) fn warning_triangle(p: &egui::Painter, r: Rect, ink: Color32, cut: Color32) {
|
||||
let tri = vec![pos2(r.center().x, r.top()), pos2(r.right(), r.bottom()), pos2(r.left(), r.bottom())];
|
||||
p.add(egui::Shape::convex_polygon(tri, ink, Stroke::NONE));
|
||||
let x = r.center().x;
|
||||
@@ -576,7 +576,7 @@ fn warning_triangle(p: &egui::Painter, r: Rect, ink: Color32, cut: Color32) {
|
||||
}
|
||||
|
||||
/// A cube seen corner-on: a hexagon with three edges meeting in the middle.
|
||||
fn web_cube(p: &egui::Painter, r: Rect, ink: Color32) {
|
||||
pub(crate) fn web_cube(p: &egui::Painter, r: Rect, ink: Color32) {
|
||||
let (c, rad) = (r.center(), r.width() / 2.0);
|
||||
let corner = |k: f32| {
|
||||
let a = std::f32::consts::FRAC_PI_3 * k - std::f32::consts::FRAC_PI_2;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The Color panel tab draws Photoshop's spectrum field in every theme (#4367): the light themes
|
||||
//! used to show HSB sliders only. Those themes keep the sliders, under the field.
|
||||
//! The Color panel tab is Photoshop's spectrum field in every theme (#4367, #6000): the light
|
||||
//! themes used to show HSB sliders only, then a short field with the sliders under it.
|
||||
|
||||
use egui::{Rect, pos2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
@@ -12,8 +12,9 @@ fn left_top() -> egui::Id {
|
||||
egui::Id::new("color-tab-test-origin")
|
||||
}
|
||||
|
||||
/// The Color tab of `kind`, alone in a 300 pt wide panel, starting from a red foreground.
|
||||
fn color_tab(kind: ThemeKind) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||||
/// The Color tab of `kind`, alone in a 300 pt wide panel, starting from a red foreground. `fill`
|
||||
/// is the group body height the dock offers, if any.
|
||||
fn color_tab(kind: ThemeKind, fill: Option<f32>) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.tools.foreground = [1.0, 0.0, 0.0, 1.0];
|
||||
let tab = Group::Color.tabs(Tokens::for_kind(kind).pro).iter().position(|name| *name == "Color").unwrap();
|
||||
@@ -22,6 +23,9 @@ fn color_tab(kind: ThemeKind) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
let origin = ui.cursor().min;
|
||||
ui.data_mut(|d| d.insert_temp(left_top(), Rect::from_min_size(origin, egui::Vec2::ZERO)));
|
||||
if let Some(fill) = fill {
|
||||
ui.data_mut(|d| d.insert_temp(crate::panels::color_field_fill_id(), fill));
|
||||
}
|
||||
crate::panels::dock_body(app, ui, Group::Color, tab);
|
||||
}
|
||||
},
|
||||
@@ -32,24 +36,41 @@ fn color_tab(kind: ThemeKind) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||||
h
|
||||
}
|
||||
|
||||
/// Drag on the saturation/brightness field at `y` below its top, on its left edge (no saturation).
|
||||
fn pick_at(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, y: f32) -> [f32; 4] {
|
||||
let origin = h.ctx.data(|d| d.get_temp::<Rect>(left_top())).unwrap().min;
|
||||
// After the 38 pt chips and the 8 pt gap.
|
||||
let p = pos2(origin.x + 38.0 + 8.0 + 1.0, origin.y + y);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.drop_at(p);
|
||||
h.run_steps(2);
|
||||
h.state().session.tools.foreground
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_theme_shows_the_spectrum_field() {
|
||||
fn every_theme_shows_the_same_spectrum_field() {
|
||||
for kind in ThemeKind::ALL {
|
||||
let mut h = color_tab(kind);
|
||||
let origin = h.ctx.data(|d| d.get_temp::<Rect>(left_top())).unwrap().min;
|
||||
// Left edge of the saturation/brightness field (after the 38 pt chips and the 8 pt gap),
|
||||
// halfway down its 120 pt: no saturation, half brightness.
|
||||
let p = pos2(origin.x + 38.0 + 8.0 + 1.0, origin.y + 60.0);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.drop_at(p);
|
||||
h.run_steps(2);
|
||||
let [r, g, b, _] = h.state().session.tools.foreground;
|
||||
let mut h = color_tab(kind, None);
|
||||
// Halfway down its 120 pt: no saturation, half brightness.
|
||||
let [r, g, b, _] = pick_at(&mut h, 60.0);
|
||||
assert!((r - g).abs() < 0.02 && (g - b).abs() < 0.02 && (0.3..0.7).contains(&r), "{kind:?}: the field picks a mid grey, got {r} {g} {b}");
|
||||
if !Tokens::for_kind(kind).pro {
|
||||
assert!(h.query_by_label("Saturation").is_some(), "{kind:?}: the sliders stay under the field");
|
||||
// No theme swaps the field for sliders or adds them under it (#6000).
|
||||
for slider in ["Hue", "Saturation", "Brightness"] {
|
||||
assert!(h.query_by_label(slider).is_none(), "{kind:?}: no {slider} slider");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every theme stretches the field over the dock group, as Photoshop's themes always did (#6000).
|
||||
#[test]
|
||||
fn every_theme_stretches_the_field_over_the_group() {
|
||||
for kind in ThemeKind::ALL {
|
||||
let mut h = color_tab(kind, Some(380.0));
|
||||
// Far below a 120 pt field: only a stretched one picks here, and a dark grey.
|
||||
let [r, g, b, _] = pick_at(&mut h, 300.0);
|
||||
assert!((r - g).abs() < 0.02 && (g - b).abs() < 0.02 && r < 0.25, "{kind:?}: the stretched field picks a dark grey, got {r} {g} {b}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ fn merged_object<T: serde::Serialize + serde::de::DeserializeOwned>(current: &T,
|
||||
serde_json::from_value(cur).map(Some).map_err(|e| format!("{field}: {e}"))
|
||||
}
|
||||
|
||||
/// A `ui.set` object field applied by whole-value replacement (e.g. `dock`, `colorPanel`):
|
||||
/// A `ui.set` object field applied by whole-value replacement (e.g. `dock`):
|
||||
/// unknown nested keys are rejected like [`merged_object`] does, but the value replaces the
|
||||
/// state instead of merging into it.
|
||||
fn whole_object<T: serde::Serialize + serde::de::DeserializeOwned>(current: &T, v: Option<&Value>, field: &str) -> std::result::Result<Option<T>, String> {
|
||||
@@ -586,7 +586,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
let dock_tabs = merged_object(&app.ui.dock_tabs, p.get("dockTabs"), "dockTabs")?;
|
||||
let dock = whole_object(&app.ui.dock, p.get("dock"), "dock")?;
|
||||
// Which chip the Color panel edits.
|
||||
let color_panel = whole_object(&app.ui.color_panel, p.get("colorPanel"), "colorPanel")?;
|
||||
let color_panel = merged_object(&app.ui.color_panel, p.get("colorPanel"), "colorPanel")?;
|
||||
let dock_width = f32_field(p, "dockWidth")?;
|
||||
let zoom = f32_field(p, "zoom")?;
|
||||
let rotation = f32_field(p, "rotation")?;
|
||||
@@ -2338,17 +2338,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_color_panel_slider_mode() {
|
||||
use crate::state::ColorPanelSliderMode;
|
||||
fn ui_set_color_panel_mode() {
|
||||
use crate::state::ColorPanelMode;
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(app.ui.color_panel.slider_mode, ColorPanelSliderMode::Hsb);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"colorPanel": {"slider_mode": "rgb"}}))["ok"], true);
|
||||
assert_eq!(app.ui.color_panel.slider_mode, ColorPanelSliderMode::Rgb);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"colorPanel": {"slider_mode": "hsb"}}))["ok"], true);
|
||||
assert_eq!(app.ui.color_panel.slider_mode, ColorPanelSliderMode::Hsb);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"colorPanel": {"slider_mode": "cmyk"}}))["ok"], false);
|
||||
assert_eq!(app.ui.color_panel.slider_mode, ColorPanelSliderMode::Hsb);
|
||||
assert_eq!(app.ui.color_panel.mode, ColorPanelMode::HueCube);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"colorPanel": {"mode": "rgbSliders"}}))["ok"], true);
|
||||
assert_eq!(app.ui.color_panel.mode, ColorPanelMode::RgbSliders);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"colorPanel": {"mode": "hsbSliders"}}))["ok"], true);
|
||||
assert_eq!(app.ui.color_panel.mode, ColorPanelMode::HsbSliders);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"colorPanel": {"mode": "nope"}}))["ok"], false);
|
||||
assert_eq!(app.ui.color_panel.mode, ColorPanelMode::HsbSliders);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -626,6 +626,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
|
||||
crate::layer_row_ui::panel_menu(app, ui);
|
||||
ui.separator();
|
||||
}
|
||||
if tabs.get(sel) == Some(&"Color") {
|
||||
crate::color_panel_ui::panel_menu(app, ui);
|
||||
ui.separator();
|
||||
}
|
||||
if tabs.get(sel) == Some(&"Swatches") {
|
||||
crate::swatches_ui::panel_menu(app, ui);
|
||||
ui.separator();
|
||||
|
||||
@@ -736,8 +736,10 @@ fn theme_switch_keeps_the_selected_color_group_panel() {
|
||||
h.state_mut().set_theme(&ctx, theme);
|
||||
h.run_steps(4);
|
||||
assert_eq!(Group::Color.tabs(is_pro(theme)).get(h.state().ui.dock_tabs.color), Some(name), "{theme:?}");
|
||||
if *name == "Color" && !is_pro(theme) {
|
||||
assert!(h.query_by_label("Hue").is_some(), "{theme:?}: Color picker missing");
|
||||
if *name == "Color" {
|
||||
// The spectrum field's hex readout, the same in every theme (#6000).
|
||||
assert!(h.query_by_label("#").is_some(), "{theme:?}: Color field missing");
|
||||
assert!(h.query_by_label("Hue").is_none(), "{theme:?}: no HSB sliders");
|
||||
}
|
||||
h.state_mut().set_theme(&ctx, ThemeKind::Pro);
|
||||
h.run_steps(4);
|
||||
|
||||
@@ -4134,3 +4134,20 @@ cameraRaw Dehaze Odstranění oparu
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Krychle odstínu
|
||||
Brightness Cube Krychle jasu
|
||||
Color Wheel Barevné kolo
|
||||
Grayscale Slider Jezdec stupňů šedi
|
||||
RGB Sliders Jezdce RGB
|
||||
HSB Sliders Jezdce HSB
|
||||
CMYK Sliders Jezdce CMYK
|
||||
Lab Sliders Jezdce Lab
|
||||
Web Color Sliders Jezdce webových barev
|
||||
Copy Color as HTML Kopírovat barvu jako HTML
|
||||
Copy Color's Hex Code Kopírovat šestnáctkový kód barvy
|
||||
RGB Spectrum Spektrum RGB
|
||||
CMYK Spectrum Spektrum CMYK
|
||||
Grayscale Ramp Přechod stupňů šedi
|
||||
Current Colors Aktuální barvy
|
||||
Make Ramp Web Safe Omezit pruh na webové barvy
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3400,3 +3400,20 @@ curves Smooth Glätten
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Farbtonwürfel
|
||||
Brightness Cube Helligkeitswürfel
|
||||
Color Wheel Farbkreis
|
||||
Grayscale Slider Graustufen-Regler
|
||||
RGB Sliders RGB-Regler
|
||||
HSB Sliders HSB-Regler
|
||||
CMYK Sliders CMYK-Regler
|
||||
Lab Sliders Lab-Regler
|
||||
Web Color Sliders Webfarben-Regler
|
||||
Copy Color as HTML Farbe als HTML kopieren
|
||||
Copy Color's Hex Code Hex-Code der Farbe kopieren
|
||||
RGB Spectrum RGB-Spektrum
|
||||
CMYK Spectrum CMYK-Spektrum
|
||||
Grayscale Ramp Graustufenbalken
|
||||
Current Colors Aktuelle Farben
|
||||
Make Ramp Web Safe Farbbalken auf Webfarben beschränken
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4089,3 +4089,19 @@ curves Smooth Εξομάλυνση
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Κύβος απόχρωσης
|
||||
Brightness Cube Κύβος φωτεινότητας
|
||||
Color Wheel Χρωματικός τροχός
|
||||
RGB Sliders Ρυθμιστικά RGB
|
||||
HSB Sliders Ρυθμιστικά HSB
|
||||
CMYK Sliders Ρυθμιστικά CMYK
|
||||
Lab Sliders Ρυθμιστικά Lab
|
||||
Web Color Sliders Ρυθμιστικά χρωμάτων ιστού
|
||||
Copy Color as HTML Αντιγραφή χρώματος ως HTML
|
||||
Copy Color's Hex Code Αντιγραφή δεκαεξαδικού κωδικού χρώματος
|
||||
RGB Spectrum Φάσμα RGB
|
||||
CMYK Spectrum Φάσμα CMYK
|
||||
Grayscale Ramp Διαβάθμιση του γκρι
|
||||
Current Colors Τρέχοντα χρώματα
|
||||
Make Ramp Web Safe Μόνο χρώματα ιστού στη διαβάθμιση
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1038 and column 18.
|
@@ -4351,3 +4351,19 @@ navigator Zoom Zoom
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Cubo de tono
|
||||
Brightness Cube Cubo de brillo
|
||||
Color Wheel Rueda de color
|
||||
RGB Sliders Reguladores RGB
|
||||
HSB Sliders Reguladores HSB
|
||||
CMYK Sliders Reguladores CMYK
|
||||
Lab Sliders Reguladores Lab
|
||||
Web Color Sliders Reguladores de colores web
|
||||
Copy Color as HTML Copiar color como HTML
|
||||
Copy Color's Hex Code Copiar código hexadecimal del color
|
||||
RGB Spectrum Espectro RGB
|
||||
CMYK Spectrum Espectro CMYK
|
||||
Grayscale Ramp Rampa de escala de grises
|
||||
Current Colors Colores actuales
|
||||
Make Ramp Web Safe Hacer rampa segura para web
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4431,3 +4431,19 @@ shape Appearance Apparence
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Cube de teinte
|
||||
Brightness Cube Cube de luminosité
|
||||
Color Wheel Roue chromatique
|
||||
RGB Sliders Curseurs RVB
|
||||
HSB Sliders Curseurs TSL
|
||||
CMYK Sliders Curseurs CMJN
|
||||
Lab Sliders Curseurs Lab
|
||||
Web Color Sliders Curseurs de couleurs Web
|
||||
Copy Color as HTML Copier la couleur en HTML
|
||||
Copy Color's Hex Code Copier le code hexadécimal de la couleur
|
||||
RGB Spectrum Spectre RVB
|
||||
CMYK Spectrum Spectre CMJN
|
||||
Grayscale Ramp Barre de niveaux de gris
|
||||
Current Colors Couleurs actuelles
|
||||
Make Ramp Web Safe Limiter la barre aux couleurs Web
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4305,3 +4305,19 @@ cameraRaw Yellow Sárga
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Színárnyalat-kocka
|
||||
Brightness Cube Fényerő-kocka
|
||||
Color Wheel Színkör
|
||||
RGB Sliders RGB-csúszkák
|
||||
HSB Sliders HSB-csúszkák
|
||||
CMYK Sliders CMYK-csúszkák
|
||||
Lab Sliders Lab-csúszkák
|
||||
Web Color Sliders Webes színek csúszkái
|
||||
Copy Color as HTML Szín másolása HTML-ként
|
||||
Copy Color's Hex Code Szín hexadecimális kódjának másolása
|
||||
RGB Spectrum RGB-spektrum
|
||||
CMYK Spectrum CMYK-spektrum
|
||||
Grayscale Ramp Szürkeárnyalatos sáv
|
||||
Current Colors Aktuális színek
|
||||
Make Ramp Web Safe Sáv korlátozása webes színekre
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3403,3 +3403,19 @@ curves Smooth Haluskan
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Kubus Hue
|
||||
Brightness Cube Kubus Kecerahan
|
||||
Color Wheel Roda Warna
|
||||
RGB Sliders Slider RGB
|
||||
HSB Sliders Slider HSB
|
||||
CMYK Sliders Slider CMYK
|
||||
Lab Sliders Slider Lab
|
||||
Web Color Sliders Slider Warna Web
|
||||
Copy Color as HTML Salin Warna sebagai HTML
|
||||
Copy Color's Hex Code Salin Kode Hex Warna
|
||||
RGB Spectrum Spektrum RGB
|
||||
CMYK Spectrum Spektrum CMYK
|
||||
Grayscale Ramp Gradasi Grayscale
|
||||
Current Colors Warna Saat Ini
|
||||
Make Ramp Web Safe Jadikan Gradasi Aman untuk Web
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -4506,3 +4506,19 @@ shape Appearance Aspetto
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Cubo tonalità
|
||||
Brightness Cube Cubo luminosità
|
||||
Color Wheel Ruota dei colori
|
||||
RGB Sliders Cursori RGB
|
||||
HSB Sliders Cursori HSB
|
||||
CMYK Sliders Cursori CMYK
|
||||
Lab Sliders Cursori Lab
|
||||
Web Color Sliders Cursori colori web
|
||||
Copy Color as HTML Copia colore come HTML
|
||||
Copy Color's Hex Code Copia codice esadecimale del colore
|
||||
RGB Spectrum Spettro RGB
|
||||
CMYK Spectrum Spettro CMYK
|
||||
Grayscale Ramp Barra scala di grigio
|
||||
Current Colors Colori correnti
|
||||
Make Ramp Web Safe Limita la barra ai colori web
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2395 and column 12.
|
@@ -4067,3 +4067,19 @@ curves Smooth スムーズ
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube 色相キューブ
|
||||
Brightness Cube 明るさキューブ
|
||||
Color Wheel カラーホイール
|
||||
RGB Sliders RGB スライダー
|
||||
HSB Sliders HSB スライダー
|
||||
CMYK Sliders CMYK スライダー
|
||||
Lab Sliders Lab スライダー
|
||||
Web Color Sliders Web カラースライダー
|
||||
Copy Color as HTML カラーを HTML としてコピー
|
||||
Copy Color's Hex Code カラーの 16 進コードをコピー
|
||||
RGB Spectrum RGB スペクトル
|
||||
CMYK Spectrum CMYK スペクトル
|
||||
Grayscale Ramp グレースケールランプ
|
||||
Current Colors 現在のカラー
|
||||
Make Ramp Web Safe ランプを Web セーフにする
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3598,3 +3598,20 @@ curves Smooth 매끄럽게
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube 색조 큐브
|
||||
Brightness Cube 밝기 큐브
|
||||
Color Wheel 색상환
|
||||
Grayscale Slider 회색 음영 슬라이더
|
||||
RGB Sliders RGB 슬라이더
|
||||
HSB Sliders HSB 슬라이더
|
||||
CMYK Sliders CMYK 슬라이더
|
||||
Lab Sliders Lab 슬라이더
|
||||
Web Color Sliders 웹 색상 슬라이더
|
||||
Copy Color as HTML 색상을 HTML로 복사
|
||||
Copy Color's Hex Code 색상의 16진수 코드 복사
|
||||
RGB Spectrum RGB 스펙트럼
|
||||
CMYK Spectrum CMYK 스펙트럼
|
||||
Grayscale Ramp 회색 음영 램프
|
||||
Current Colors 현재 색상
|
||||
Make Ramp Web Safe 램프를 웹 안전 색상으로 만들기
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2940 and column 12.
|
@@ -4149,3 +4149,19 @@ curves Smooth Vloeiend maken
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Kleurtoonkubus
|
||||
Brightness Cube Helderheidskubus
|
||||
Color Wheel Kleurenwiel
|
||||
RGB Sliders RGB-schuifregelaars
|
||||
HSB Sliders HSB-schuifregelaars
|
||||
CMYK Sliders CMYK-schuifregelaars
|
||||
Lab Sliders Lab-schuifregelaars
|
||||
Web Color Sliders Webkleurschuifregelaars
|
||||
Copy Color as HTML Kleur kopiëren als HTML
|
||||
Copy Color's Hex Code Hexadecimale code van kleur kopiëren
|
||||
RGB Spectrum RGB-spectrum
|
||||
CMYK Spectrum CMYK-spectrum
|
||||
Grayscale Ramp Grijswaardenbalk
|
||||
Current Colors Huidige kleuren
|
||||
Make Ramp Web Safe Balk beperken tot webkleuren
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -4005,3 +4005,19 @@ curves Smooth Wygładź
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Sześcian odcienia
|
||||
Brightness Cube Sześcian jasności
|
||||
Color Wheel Koło kolorów
|
||||
RGB Sliders Suwaki RGB
|
||||
HSB Sliders Suwaki HSB
|
||||
CMYK Sliders Suwaki CMYK
|
||||
Lab Sliders Suwaki Lab
|
||||
Web Color Sliders Suwaki kolorów internetowych
|
||||
Copy Color as HTML Kopiuj kolor jako HTML
|
||||
Copy Color's Hex Code Kopiuj kod szesnastkowy koloru
|
||||
RGB Spectrum Widmo RGB
|
||||
CMYK Spectrum Widmo CMYK
|
||||
Grayscale Ramp Pasek skali szarości
|
||||
Current Colors Bieżące kolory
|
||||
Make Ramp Web Safe Ogranicz pasek do kolorów internetowych
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4328,3 +4328,19 @@ layerLabel No Color Sem cor
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Cubo de matiz
|
||||
Brightness Cube Cubo de brilho
|
||||
Color Wheel Roda de cores
|
||||
RGB Sliders Controles deslizantes RGB
|
||||
HSB Sliders Controles deslizantes HSB
|
||||
CMYK Sliders Controles deslizantes CMYK
|
||||
Lab Sliders Controles deslizantes Lab
|
||||
Web Color Sliders Controles deslizantes de cores da Web
|
||||
Copy Color as HTML Copiar cor como HTML
|
||||
Copy Color's Hex Code Copiar código hexadecimal da cor
|
||||
RGB Spectrum Espectro RGB
|
||||
CMYK Spectrum Espectro CMYK
|
||||
Grayscale Ramp Rampa de tons de cinza
|
||||
Current Colors Cores atuais
|
||||
Make Ramp Web Safe Tornar rampa segura para a Web
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4594,3 +4594,19 @@ shape Appearance Оформление
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Куб цветового тона
|
||||
Brightness Cube Куб яркости
|
||||
Color Wheel Цветовой круг
|
||||
RGB Sliders Ползунки RGB
|
||||
HSB Sliders Ползунки HSB
|
||||
CMYK Sliders Ползунки CMYK
|
||||
Lab Sliders Ползунки Lab
|
||||
Web Color Sliders Ползунки web-цветов
|
||||
Copy Color as HTML Копировать цвет как HTML
|
||||
Copy Color's Hex Code Копировать шестнадцатеричный код цвета
|
||||
RGB Spectrum Спектр RGB
|
||||
CMYK Spectrum Спектр CMYK
|
||||
Grayscale Ramp Шкала градаций серого
|
||||
Current Colors Текущие цвета
|
||||
Make Ramp Web Safe Сделать шкалу безопасной для web
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4461,3 +4461,19 @@ shape Appearance Izgled
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Kocka nijanse
|
||||
Brightness Cube Kocka osvetljenosti
|
||||
Color Wheel Točak boja
|
||||
RGB Sliders RGB klizači
|
||||
HSB Sliders HSB klizači
|
||||
CMYK Sliders CMYK klizači
|
||||
Lab Sliders Lab klizači
|
||||
Web Color Sliders Klizači veb boja
|
||||
Copy Color as HTML Kopiraj boju kao HTML
|
||||
Copy Color's Hex Code Kopiraj heksadecimalni kod boje
|
||||
RGB Spectrum RGB spektar
|
||||
CMYK Spectrum CMYK spektar
|
||||
Grayscale Ramp Skala nijansi sive
|
||||
Current Colors Trenutne boje
|
||||
Make Ramp Web Safe Učini skalu bezbednom za veb
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4119,3 +4119,19 @@ cameraData Lens Lens
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Renk Tonu Küpü
|
||||
Brightness Cube Parlaklık Küpü
|
||||
Color Wheel Renk Çarkı
|
||||
RGB Sliders RGB Kaydırıcıları
|
||||
HSB Sliders HSB Kaydırıcıları
|
||||
CMYK Sliders CMYK Kaydırıcıları
|
||||
Lab Sliders Lab Kaydırıcıları
|
||||
Web Color Sliders Web Rengi Kaydırıcıları
|
||||
Copy Color as HTML Rengi HTML Olarak Kopyala
|
||||
Copy Color's Hex Code Rengin Onaltılık Kodunu Kopyala
|
||||
RGB Spectrum RGB Spektrumu
|
||||
CMYK Spectrum CMYK Spektrumu
|
||||
Grayscale Ramp Gri Tonlama Şeridi
|
||||
Current Colors Geçerli Renkler
|
||||
Make Ramp Web Safe Şeridi Web Renkleriyle Sınırla
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -4098,3 +4098,19 @@ curves Smooth Згладити
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Куб тону
|
||||
Brightness Cube Куб яскравості
|
||||
Color Wheel Колірне коло
|
||||
RGB Sliders Повзунки RGB
|
||||
HSB Sliders Повзунки HSB
|
||||
CMYK Sliders Повзунки CMYK
|
||||
Lab Sliders Повзунки Lab
|
||||
Web Color Sliders Повзунки вебкольорів
|
||||
Copy Color as HTML Копіювати колір як HTML
|
||||
Copy Color's Hex Code Копіювати шістнадцятковий код кольору
|
||||
RGB Spectrum Спектр RGB
|
||||
CMYK Spectrum Спектр CMYK
|
||||
Grayscale Ramp Шкала градацій сірого
|
||||
Current Colors Поточні кольори
|
||||
Make Ramp Web Safe Зробити шкалу безпечною для вебу
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1404 and column 18.
|
@@ -3881,3 +3881,20 @@ curves Smooth Làm mượt
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube Khối sắc màu
|
||||
Brightness Cube Khối độ sáng
|
||||
Color Wheel Bánh xe màu
|
||||
Grayscale Slider Thanh trượt thang xám
|
||||
RGB Sliders Thanh trượt RGB
|
||||
HSB Sliders Thanh trượt HSB
|
||||
CMYK Sliders Thanh trượt CMYK
|
||||
Lab Sliders Thanh trượt Lab
|
||||
Web Color Sliders Thanh trượt màu web
|
||||
Copy Color as HTML Sao chép màu dạng HTML
|
||||
Copy Color's Hex Code Sao chép mã hex của màu
|
||||
RGB Spectrum Phổ RGB
|
||||
CMYK Spectrum Phổ CMYK
|
||||
Grayscale Ramp Dải thang xám
|
||||
Current Colors Màu hiện tại
|
||||
Make Ramp Web Safe Giới hạn dải màu ở màu web
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3048,3 +3048,20 @@ curves Smooth 平滑
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube 色相立方体
|
||||
Brightness Cube 亮度立方体
|
||||
Color Wheel 色轮
|
||||
Grayscale Slider 灰度滑块
|
||||
RGB Sliders RGB 滑块
|
||||
HSB Sliders HSB 滑块
|
||||
CMYK Sliders CMYK 滑块
|
||||
Lab Sliders Lab 滑块
|
||||
Web Color Sliders Web 颜色滑块
|
||||
Copy Color as HTML 将颜色复制为 HTML
|
||||
Copy Color's Hex Code 复制颜色的十六进制代码
|
||||
RGB Spectrum RGB 色谱
|
||||
CMYK Spectrum CMYK 色谱
|
||||
Grayscale Ramp 灰度色带
|
||||
Current Colors 当前颜色
|
||||
Make Ramp Web Safe 使色带成为 Web 安全色
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2368 and column 12.
|
@@ -3012,3 +3012,20 @@ curves Smooth 平滑
|
||||
|
||||
# Print preview image (#8236)
|
||||
Print preview image Print preview image
|
||||
# Color panel menu: modes, copy commands and colour ramps (#6000)
|
||||
Hue Cube 色相立方體
|
||||
Brightness Cube 亮度立方體
|
||||
Color Wheel 色輪
|
||||
Grayscale Slider 灰階滑桿
|
||||
RGB Sliders RGB 滑桿
|
||||
HSB Sliders HSB 滑桿
|
||||
CMYK Sliders CMYK 滑桿
|
||||
Lab Sliders Lab 滑桿
|
||||
Web Color Sliders 網頁色彩滑桿
|
||||
Copy Color as HTML 將色彩複製為 HTML
|
||||
Copy Color's Hex Code 複製色彩的十六進位碼
|
||||
RGB Spectrum RGB 色譜
|
||||
CMYK Spectrum CMYK 色譜
|
||||
Grayscale Ramp 灰階色帶
|
||||
Current Colors 目前色彩
|
||||
Make Ramp Web Safe 讓色帶成為網頁安全色
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -60,6 +60,7 @@ pub mod channels_panel;
|
||||
pub mod chrome_ui;
|
||||
pub mod cjk_fonts;
|
||||
pub mod clip_line_ui;
|
||||
pub mod color_panel_ui;
|
||||
pub mod color_picker_ui;
|
||||
pub mod color_range_ui;
|
||||
pub mod color_table_ui;
|
||||
@@ -3235,6 +3236,8 @@ mod move_auto_select_tests;
|
||||
#[cfg(test)]
|
||||
mod new_group_button_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_panel_tests;
|
||||
#[cfg(test)]
|
||||
mod color_tab_theme_tests;
|
||||
|
||||
|
||||
@@ -855,6 +855,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) {
|
||||
crate::swatches_ui::panel_menu(app, ui);
|
||||
ui.separator();
|
||||
}
|
||||
"color" => {
|
||||
crate::color_panel_ui::panel_menu(app, ui);
|
||||
ui.separator();
|
||||
}
|
||||
"history" => {
|
||||
crate::history_menu_ui::panel_menu(app, ui);
|
||||
ui.separator();
|
||||
|
||||
@@ -675,6 +675,26 @@ fn customized_panel_menus_and_rail_remain_reachable_after_float_and_restore() {
|
||||
assert!(menu.query_all_by_label("New Swatch…").count() > 0);
|
||||
}
|
||||
|
||||
/// A floated Color panel's menu still picks its mode (#6000): the shared docking renderer has
|
||||
/// its own panel menu, separate from the legacy dock's.
|
||||
#[test]
|
||||
fn floated_color_panel_menu_picks_the_mode() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"color","x":20,"y":40})).unwrap();
|
||||
let mut h = harness(app, vec2(1280.0, 800.0));
|
||||
let floating = h.ctx.memory(|memory| memory.area_rect(egui::Id::new("photocraft-panel-docking").with(("floating", &"color".to_string())))).unwrap();
|
||||
let button = h.query_all_by_label("Panel menu").find(|node| floating.contains_rect(node.rect())).unwrap().rect().center();
|
||||
h.hover_at(button);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: button, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label(" RGB Sliders").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.color_panel.mode, crate::state::ColorPanelMode::RgbSliders);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_panel_menu_includes_load_and_save_actions() {
|
||||
let mut ready = false;
|
||||
|
||||
+83
-307
@@ -7,7 +7,7 @@ use photocraft_engine::history_source_cmds::HistorySource;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::mask_thumbs_ui::MaskKind;
|
||||
use crate::state::{ColorPanelSliderMode, Tool};
|
||||
use crate::state::Tool;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PhotocraftApp, icons, widgets};
|
||||
|
||||
@@ -1902,17 +1902,10 @@ pub(crate) fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate
|
||||
match (group, tab) {
|
||||
(Group::Color, 2) => crate::preset_panels::gradients_panel(app, ui),
|
||||
(Group::Color, 3) => crate::preset_panels::patterns_panel(app, ui),
|
||||
(Group::Color, 0) if pro => {
|
||||
color_field(app, ui);
|
||||
if app.ui.color_panel.slider_mode == ColorPanelSliderMode::Rgb {
|
||||
ui.add_space(8.0);
|
||||
rgb_sliders(app, ui);
|
||||
}
|
||||
color_slider_mode_row(app, ui);
|
||||
}
|
||||
(Group::Color, _) if pro => crate::swatches_ui::panel(app, ui),
|
||||
(Group::Color, 0) => crate::swatches_ui::panel(app, ui),
|
||||
(Group::Color, _) => color_picker(app, ui),
|
||||
// The Color tab is the same in every theme, in the mode picked from its panel menu
|
||||
// (#6000); only the tab order differs between themes.
|
||||
(Group::Color, tab) if group.tabs(pro).get(tab) == Some(&"Color") => crate::color_panel_ui::panel(app, ui),
|
||||
(Group::Color, _) => crate::swatches_ui::panel(app, ui),
|
||||
(Group::Properties, 0) => properties_body(app, ui),
|
||||
(Group::Properties, _) => adjustments_grid(app, ui),
|
||||
(Group::Character, tab) => crate::type_tool::character_panel(app, ui, tab == 1),
|
||||
@@ -2496,100 +2489,6 @@ fn empty(ui: &mut egui::Ui, s: &str) {
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// The Color tab of the themes other than Photoshop's: the same spectrum field (chips, saturation
|
||||
/// and brightness field, hue strip and readout) with HSB sliders under it (#4367).
|
||||
fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
color_field(app, ui);
|
||||
ui.add_space(8.0);
|
||||
match app.ui.color_panel.slider_mode {
|
||||
ColorPanelSliderMode::Hsb => hsb_sliders(app, ui),
|
||||
ColorPanelSliderMode::Rgb => rgb_sliders(app, ui),
|
||||
}
|
||||
color_slider_mode_row(app, ui);
|
||||
}
|
||||
|
||||
/// HSB or RGB sliders under the colour field (#7172).
|
||||
fn color_slider_mode_row(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.add_space(6.0);
|
||||
let label = match app.ui.color_panel.slider_mode {
|
||||
ColorPanelSliderMode::Hsb => tl!("HSB/HSL"),
|
||||
ColorPanelSliderMode::Rgb => tl!("RGB Color"),
|
||||
};
|
||||
// A press menu, not a combo box: the default layout keeps the Color panel open beside
|
||||
// adjustment editors, and harness tests pick the combo nearest "3D LUT File" (#7317).
|
||||
let btn = ui.add_sized([72.0, ui.spacing().interact_size.y], egui::Button::new(label));
|
||||
crate::press_menu::show(&btn, |ui, menu| {
|
||||
if menu.item(ui, tl!("HSB/HSL")) {
|
||||
app.ui.color_panel.slider_mode = ColorPanelSliderMode::Hsb;
|
||||
}
|
||||
if menu.item(ui, tl!("RGB Color")) {
|
||||
app.ui.color_panel.slider_mode = ColorPanelSliderMode::Rgb;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Hue, Saturation and Brightness sliders for the colour the panel is editing.
|
||||
fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let bg = app.ui.color_panel.background;
|
||||
let c = if bg { app.session.tools.background } else { app.session.tools.foreground };
|
||||
// Keep the last edited HSB while it still gives the colour: black and greys have no hue or
|
||||
// saturation of their own, so recomputing them from RGB would reset what was just typed to 0.
|
||||
let key = egui::Id::new(("color-picker-hsva", bg));
|
||||
let stored: Option<egui::ecolor::Hsva> = ui.data(|d| d.get_temp(key));
|
||||
let hsva0 = stored.filter(|h| srgb_bytes(hsva_srgb(*h)) == srgb_bytes(c)).unwrap_or_else(|| srgb_hsva(c));
|
||||
let mut h = hsva0.h * 360.0;
|
||||
let mut s = hsva0.s * 100.0;
|
||||
let mut v = hsva0.v * 100.0;
|
||||
let hue = widgets::hue_stops();
|
||||
// Preferences ▸ Interface ▸ Dynamic Color Sliders: off, the tracks are the plain ones.
|
||||
let dynamic = app.session.prefs().interface.dynamic_color_sliders;
|
||||
let mut changed = widgets::slider_row(ui, tl!("Hue"), &mut h, 0.0..=360.0, "°", dynamic.then_some(&hue)).changed();
|
||||
let sat_stops =
|
||||
[egui::ecolor::Hsva::new(hsva0.h, 0.0, hsva0.v.max(0.2), 1.0), egui::ecolor::Hsva::new(hsva0.h, 1.0, hsva0.v.max(0.2), 1.0)].map(hsva_color32);
|
||||
changed |= widgets::slider_row(ui, tl!("Saturation"), &mut s, 0.0..=100.0, "%", dynamic.then_some(&sat_stops)).changed();
|
||||
let val_stops = [Color32::BLACK, hsva_color32(egui::ecolor::Hsva::new(hsva0.h, hsva0.s, 1.0, 1.0))];
|
||||
changed |= widgets::slider_row(ui, tl!("Brightness"), &mut v, 0.0..=100.0, "%", dynamic.then_some(&val_stops)).changed();
|
||||
let hsva = egui::ecolor::Hsva::new(h / 360.0, s / 100.0, v / 100.0, 1.0);
|
||||
// Only an edit counts: the h/s/v round trip isn't exact, so comparing values would rewrite
|
||||
// the colour (and recolour selected type) every frame.
|
||||
if changed {
|
||||
if bg {
|
||||
app.session.tools.background = hsva_srgb(hsva);
|
||||
} else {
|
||||
app.session.tools.foreground = hsva_srgb(hsva);
|
||||
crate::apply_color::foreground_changed(app, crate::apply_color::gesture(ui.ctx()));
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(key, hsva));
|
||||
}
|
||||
crate::apply_color::track_focus(ui.ctx());
|
||||
}
|
||||
|
||||
/// Red, Green and Blue sliders (0–255) for the colour the panel is editing (#7172).
|
||||
fn rgb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let bg = app.ui.color_panel.background;
|
||||
let mut c = if bg { app.session.tools.background } else { app.session.tools.foreground };
|
||||
let mut rgb = srgb_bytes(c).map(f32::from);
|
||||
let dynamic = app.session.prefs().interface.dynamic_color_sliders;
|
||||
let mut changed = false;
|
||||
let [r0, g0, b0] = rgb.map(|v| v.round() as u8);
|
||||
let r_stops = [Color32::from_rgb(0, g0, b0), Color32::from_rgb(255, g0, b0)];
|
||||
changed |= widgets::slider_row(ui, tl!("Red"), &mut rgb[0], 0.0..=255.0, "", dynamic.then_some(&r_stops)).changed();
|
||||
let g_stops = [Color32::from_rgb(r0, 0, b0), Color32::from_rgb(r0, 255, b0)];
|
||||
changed |= widgets::slider_row(ui, tl!("Green"), &mut rgb[1], 0.0..=255.0, "", dynamic.then_some(&g_stops)).changed();
|
||||
let b_stops = [Color32::from_rgb(r0, g0, 0), Color32::from_rgb(r0, g0, 255)];
|
||||
changed |= widgets::slider_row(ui, tl!("Blue"), &mut rgb[2], 0.0..=255.0, "", dynamic.then_some(&b_stops)).changed();
|
||||
if changed {
|
||||
let q = |v: f32| (v.clamp(0.0, 255.0).round() / 255.0).clamp(0.0, 1.0);
|
||||
c = [q(rgb[0]), q(rgb[1]), q(rgb[2]), 1.0];
|
||||
if bg {
|
||||
app.session.tools.background = c;
|
||||
} else {
|
||||
app.session.tools.foreground = c;
|
||||
crate::apply_color::foreground_changed(app, crate::apply_color::gesture(ui.ctx()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------- layers
|
||||
|
||||
/// The simple themes' single lock button. On the Background it converts the layer to a normal
|
||||
@@ -4176,7 +4075,7 @@ fn adjustments_grid(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
|
||||
/// The Color panel's foreground and background chips. A click picks the colour the field edits,
|
||||
/// framed; a double-click opens the Color Picker on it.
|
||||
fn field_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, chips: Rect) {
|
||||
pub(crate) fn field_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, chips: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let bgr = Rect::from_min_size(chips.min + vec2(13.0, 13.0), vec2(22.0, 22.0));
|
||||
let fgr = Rect::from_min_size(chips.min + vec2(3.0, 3.0), vec2(22.0, 22.0));
|
||||
@@ -4204,14 +4103,14 @@ fn field_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, chips: Rect) {
|
||||
}
|
||||
|
||||
/// Where the dock tells the Color field how tall its group body is.
|
||||
fn color_field_fill_id() -> egui::Id {
|
||||
pub(crate) fn color_field_fill_id() -> egui::Id {
|
||||
egui::Id::new("color-field-fill-height")
|
||||
}
|
||||
|
||||
/// The pro Color field stretches to its dock group's height instead of a fixed 120 pt: a dock
|
||||
/// calls this with the group body's `ui` before drawing the tab.
|
||||
/// The Color field stretches to its dock group's height instead of a fixed 120 pt, in every theme
|
||||
/// (#6000): a dock calls this with the group body's `ui` before drawing the tab.
|
||||
pub(crate) fn offer_color_field_fill(ui: &egui::Ui, pro: bool, group: crate::dock::Group, tab: usize) {
|
||||
if pro && group == crate::dock::Group::Color && tab == 0 {
|
||||
if group == crate::dock::Group::Color && group.tabs(pro).get(tab) == Some(&"Color") {
|
||||
let inner = ui.available_height().max(0.0);
|
||||
ui.data_mut(|d| d.insert_temp(color_field_fill_id(), inner));
|
||||
}
|
||||
@@ -4231,7 +4130,7 @@ fn color_field_height(avail: Option<f32>, width: f32) -> f32 {
|
||||
/// neither hue nor saturation — so a drag that bottoms out into black would otherwise round-trip
|
||||
/// through RGB as `s = 0` and drop the knob from the bottom-right corner to the bottom-left one
|
||||
/// (#3228).
|
||||
fn field_hsva(mut hsva: egui::ecolor::Hsva, remembered: (f32, f32)) -> egui::ecolor::Hsva {
|
||||
pub(crate) fn field_hsva(mut hsva: egui::ecolor::Hsva, remembered: (f32, f32)) -> egui::ecolor::Hsva {
|
||||
if hsva.v < 0.01 {
|
||||
hsva.h = remembered.0;
|
||||
hsva.s = remembered.1;
|
||||
@@ -4243,8 +4142,9 @@ fn field_hsva(mut hsva: egui::ecolor::Hsva, remembered: (f32, f32)) -> egui::eco
|
||||
hsva
|
||||
}
|
||||
|
||||
/// Photoshop Color panel: saturation/brightness field + hue strip, drawn as shaded meshes.
|
||||
fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
/// Photoshop's Color panel Hue Cube (saturation/brightness field + hue strip) or Brightness Cube
|
||||
/// (hue/saturation field + brightness strip), drawn as shaded meshes, over a hex/RGB readout.
|
||||
pub(crate) fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let bg_active = app.ui.color_panel.background;
|
||||
let key = egui::Id::new(("color-field-hue", bg_active));
|
||||
@@ -4254,70 +4154,60 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
hsva = field_hsva(hsva, remembered);
|
||||
let w = ui.available_width();
|
||||
let strip_w = 14.0;
|
||||
// Photoshop's Color panel field fills the panel: take the height the dock offers, less the
|
||||
// hex/RGB readout (which folds onto a second line in a narrow dock). Without a dock, 120 pt.
|
||||
// Only Photoshop's themes stretch it over the group: the other themes put sliders under it.
|
||||
let fill = if t.pro { ui.data(|d| d.get_temp::<f32>(color_field_fill_id())) } else { None };
|
||||
// Photoshop's Color panel field fills the panel in every theme (#6000): take the height the
|
||||
// dock offers, less the hex/RGB readout (which folds onto a second line in a narrow dock) and
|
||||
// the theme's spacing between the rows. Without a dock, 120 pt.
|
||||
let gaps = 2.0 * ui.spacing().item_spacing.y;
|
||||
let fill = ui.data(|d| d.get_temp::<f32>(color_field_fill_id())).map(|a| a - gaps);
|
||||
let h = color_field_height(fill, w);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
let (chips, _) = ui.allocate_exact_size(vec2(38.0, h), Sense::hover());
|
||||
field_chips(app, ui, chips);
|
||||
// SV field.
|
||||
// Hue Cube: saturation across and brightness up, hue on the strip. Brightness Cube (the
|
||||
// Color Picker's B mode): hue across and saturation up, brightness on the strip.
|
||||
let brightness = app.ui.color_panel.mode == crate::state::ColorPanelMode::BrightnessCube;
|
||||
let field_w = w - 38.0 - strip_w - 16.0;
|
||||
let (field, fresp) = ui.allocate_exact_size(vec2(field_w, h), Sense::click_and_drag());
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let n = 16;
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
let (sx, vy) = (i as f32 / n as f32, j as f32 / n as f32);
|
||||
let c = hsva_color32(egui::ecolor::Hsva::new(hsva.h, sx, 1.0 - vy, 1.0));
|
||||
mesh.colored_vertex(pos2(field.left() + sx * field.width(), field.top() + vy * field.height()), c);
|
||||
}
|
||||
}
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let a = (j * (n + 1) + i) as u32;
|
||||
let b = a + 1;
|
||||
let c = a + (n + 1) as u32;
|
||||
let d = c + 1;
|
||||
mesh.add_triangle(a, b, d);
|
||||
mesh.add_triangle(a, d, c);
|
||||
}
|
||||
}
|
||||
ui.painter().add(mesh);
|
||||
let at = |x: f32, y: f32| {
|
||||
if brightness { egui::ecolor::Hsva::new(x.min(0.9999), 1.0 - y, hsva.v, 1.0) } else { egui::ecolor::Hsva::new(hsva.h, x, 1.0 - y, 1.0) }
|
||||
};
|
||||
ui.painter().add(color_grid(field, 16, |x, y| hsva_color32(at(x, y))));
|
||||
ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside);
|
||||
if (fresp.dragged() || fresp.clicked())
|
||||
&& let Some(p) = fresp.interact_pointer_pos()
|
||||
{
|
||||
hsva.s = ((p.x - field.left()) / field.width()).clamp(0.0, 1.0);
|
||||
hsva.v = 1.0 - ((p.y - field.top()) / field.height()).clamp(0.0, 1.0);
|
||||
let x = ((p.x - field.left()) / field.width()).clamp(0.0, 1.0);
|
||||
let y = ((p.y - field.top()) / field.height()).clamp(0.0, 1.0);
|
||||
if brightness {
|
||||
hsva.h = x.min(0.9999);
|
||||
hsva.s = 1.0 - y;
|
||||
} else {
|
||||
hsva.s = x;
|
||||
hsva.v = 1.0 - y;
|
||||
}
|
||||
}
|
||||
let knob = pos2(field.left() + hsva.s * field.width(), field.top() + (1.0 - hsva.v) * field.height());
|
||||
let (kx, ky) = if brightness { (hsva.h, 1.0 - hsva.s) } else { (hsva.s, 1.0 - hsva.v) };
|
||||
let knob = pos2(field.left() + kx * field.width(), field.top() + ky * field.height());
|
||||
ui.painter().circle_stroke(knob, 5.0, Stroke::new(1.5, Color32::WHITE));
|
||||
ui.painter().circle_stroke(knob, 6.5, Stroke::new(1.0, Color32::from_black_alpha(160)));
|
||||
// Hue strip.
|
||||
// The strip: hue (red at both ends, as Photoshop's) or brightness (the colour down to black).
|
||||
let (strip, sresp) = ui.allocate_exact_size(vec2(strip_w, h), Sense::click_and_drag());
|
||||
let mut m2 = egui::Mesh::default();
|
||||
let steps = 24;
|
||||
for k in 0..=steps {
|
||||
let f = k as f32 / steps as f32;
|
||||
let c = hsva_color32(egui::ecolor::Hsva::new(1.0 - f, 1.0, 1.0, 1.0));
|
||||
m2.colored_vertex(pos2(strip.left(), strip.top() + f * strip.height()), c);
|
||||
m2.colored_vertex(pos2(strip.right(), strip.top() + f * strip.height()), c);
|
||||
}
|
||||
for k in 0..steps {
|
||||
let a = (k * 2) as u32;
|
||||
m2.add_triangle(a, a + 1, a + 3);
|
||||
m2.add_triangle(a, a + 3, a + 2);
|
||||
}
|
||||
ui.painter().add(m2);
|
||||
let strip_at = |f: f32| {
|
||||
if brightness { egui::ecolor::Hsva::new(hsva.h, hsva.s, 1.0 - f, 1.0) } else { egui::ecolor::Hsva::new(1.0 - f, 1.0, 1.0, 1.0) }
|
||||
};
|
||||
ui.painter().add(color_grid(strip, 24, |_, y| hsva_color32(strip_at(y))));
|
||||
if (sresp.dragged() || sresp.clicked())
|
||||
&& let Some(p) = sresp.interact_pointer_pos()
|
||||
{
|
||||
hsva.h = 1.0 - ((p.y - strip.top()) / strip.height()).clamp(0.0, 0.9999);
|
||||
let f = ((p.y - strip.top()) / strip.height()).clamp(0.0, 1.0);
|
||||
if brightness {
|
||||
hsva.v = 1.0 - f;
|
||||
} else {
|
||||
hsva.h = 1.0 - f.min(0.9999);
|
||||
}
|
||||
}
|
||||
let y = strip.top() + (1.0 - hsva.h) * strip.height();
|
||||
let y = strip.top() + (1.0 - if brightness { hsva.v } else { hsva.h }) * strip.height();
|
||||
let tri = vec![pos2(strip.right() + 1.0, y), pos2(strip.right() + 6.0, y - 4.0), pos2(strip.right() + 6.0, y + 4.0)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri, t.text, Stroke::NONE));
|
||||
if fresp.dragged() || fresp.clicked() || sresp.dragged() || sresp.clicked() {
|
||||
@@ -4351,9 +4241,31 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
crate::apply_color::track_focus(ui.ctx());
|
||||
}
|
||||
|
||||
/// A smooth-shaded `n` x `n` grid over `rect`, coloured by `color(x, y)` with x and y in 0..1
|
||||
/// (y down).
|
||||
pub(crate) fn color_grid(rect: Rect, n: usize, color: impl Fn(f32, f32) -> Color32) -> egui::Mesh {
|
||||
let n = n.max(1);
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
let (x, y) = (i as f32 / n as f32, j as f32 / n as f32);
|
||||
mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color(x, y));
|
||||
}
|
||||
}
|
||||
let row = (n + 1) as u32;
|
||||
for j in 0..n as u32 {
|
||||
for i in 0..n as u32 {
|
||||
let a = j * row + i;
|
||||
mesh.add_triangle(a, a + 1, a + row + 1);
|
||||
mesh.add_triangle(a, a + row + 1, a + row);
|
||||
}
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
/// Tool colours are sRGB-encoded floats; egui's Hsva works on sRGB bytes via these helpers
|
||||
/// (its `from_rgba_unmultiplied` expects *linear* RGB, which gave wrong readouts).
|
||||
fn srgb_bytes(c: [f32; 4]) -> [u8; 3] {
|
||||
pub(crate) fn srgb_bytes(c: [f32; 4]) -> [u8; 3] {
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
[q(c[0]), q(c[1]), q(c[2])]
|
||||
}
|
||||
@@ -4361,19 +4273,19 @@ fn srgb_bytes(c: [f32; 4]) -> [u8; 3] {
|
||||
/// The Color panel's HSB, as Photoshop's and the Color Picker's: on the sRGB-encoded values,
|
||||
/// so a 50% grey has Brightness 50%. egui's `Hsva` conversions work in linear light, which put
|
||||
/// that grey at 22% and drew the field's middle as 188 (#3323); `Hsva` only holds the numbers.
|
||||
fn srgb_hsva(c: [f32; 4]) -> egui::ecolor::Hsva {
|
||||
pub(crate) fn srgb_hsva(c: [f32; 4]) -> egui::ecolor::Hsva {
|
||||
let [h, s, v] = photocraft_color::convert::rgb_to_hsb([c[0], c[1], c[2]].map(|v| v.clamp(0.0, 1.0)));
|
||||
egui::ecolor::Hsva::new(h / 360.0, s, v, 1.0)
|
||||
}
|
||||
|
||||
/// The inverse of srgb_hsva: the Color panel's HSB back to sRGB-encoded values.
|
||||
fn hsva_srgb(h: egui::ecolor::Hsva) -> [f32; 4] {
|
||||
pub(crate) fn hsva_srgb(h: egui::ecolor::Hsva) -> [f32; 4] {
|
||||
let [r, g, b] = photocraft_color::convert::hsb_to_rgb([h.h * 360.0, h.s.clamp(0.0, 1.0), h.v.clamp(0.0, 1.0)]);
|
||||
[r, g, b, 1.0]
|
||||
}
|
||||
|
||||
/// The colour to draw for Color panel HSB `h` (see [`srgb_hsva`]).
|
||||
fn hsva_color32(h: egui::ecolor::Hsva) -> Color32 {
|
||||
pub(crate) fn hsva_color32(h: egui::ecolor::Hsva) -> Color32 {
|
||||
let [r, g, b] = srgb_bytes(hsva_srgb(h));
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
@@ -4381,7 +4293,7 @@ fn hsva_color32(h: egui::ecolor::Hsva) -> Color32 {
|
||||
/// The Color panel's editable colour readout: a hex field and R, G, B fields, all drawn like the
|
||||
/// dock's [`widgets::value_field`] so they sit in the theme. Editing any of them sets `color`
|
||||
/// (sRGB-encoded floats) and returns `true`.
|
||||
fn color_readout(ui: &mut egui::Ui, id: egui::Id, color: &mut [f32; 4]) -> bool {
|
||||
pub(crate) fn color_readout(ui: &mut egui::Ui, id: egui::Id, color: &mut [f32; 4]) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut changed = false;
|
||||
// Wrapped so the fields fold onto a second line in a narrow dock instead of being clipped.
|
||||
@@ -4951,112 +4863,6 @@ mod color_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_slider_mode_shows_red_green_blue_rows() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.color_panel.slider_mode = ColorPanelSliderMode::Rgb;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 360.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
color_picker(app, ui);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||||
h.run_steps(2);
|
||||
for label in ["Red", "Green", "Blue"] {
|
||||
h.get_by_label(label);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Color panel with a document holding one selected black rectangle, the Rectangle tool in
|
||||
/// hand, foreground blue and background red.
|
||||
fn color_panel_with_a_shape(mode: ColorPanelSliderMode, background: bool) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.sync_views();
|
||||
app.run("shape.create", json!({"kind": "rect", "rect": [20, 20, 50, 50], "fill": "#000000"})).unwrap();
|
||||
app.ui.tool = crate::state::Tool::Rectangle;
|
||||
app.session.tools.foreground = [0.0, 0.0, 1.0, 1.0];
|
||||
app.session.tools.background = [1.0, 0.0, 0.0, 1.0];
|
||||
app.ui.color_panel.slider_mode = mode;
|
||||
app.ui.color_panel.background = background;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 420.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
color_picker(app, ui);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||||
h.run_steps(2);
|
||||
h
|
||||
}
|
||||
|
||||
/// Types `text` into the first of the three slider fields under the colour field (Hue or Red).
|
||||
fn type_first_slider(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str) {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let n = h.query_all_by_role(egui::accesskit::Role::SpinButton).count();
|
||||
h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(n - 3).unwrap().click();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::Text(text.into()));
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn shape_fill(app: &PhotocraftApp) -> [u8; 3] {
|
||||
let st = app.session.active().unwrap();
|
||||
let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("shape layer") };
|
||||
let Some(photocraft_doc::Fill::Solid(c)) = &sh.fill else { panic!("solid fill") };
|
||||
srgb_bytes([c.c[0], c.c[1], c.c[2], 1.0])
|
||||
}
|
||||
|
||||
/// The HSB sliders edit the chip the panel is editing, as the RGB sliders and the field do:
|
||||
/// with the background chip active they change the background, and the selected shape (which
|
||||
/// takes the foreground with a shape tool in hand) keeps its fill. On the foreground chip
|
||||
/// the shape follows.
|
||||
#[test]
|
||||
fn hsb_sliders_edit_the_active_chip_and_only_the_foreground_recolours_shapes() {
|
||||
let mut h = color_panel_with_a_shape(ColorPanelSliderMode::Hsb, true);
|
||||
type_first_slider(&mut h, "120");
|
||||
let app = h.state();
|
||||
assert_eq!(srgb_bytes(app.session.tools.background), [0, 255, 0], "the background takes the hue");
|
||||
assert_eq!(srgb_bytes(app.session.tools.foreground), [0, 0, 255], "the foreground is left alone");
|
||||
assert_eq!(shape_fill(app), [0, 0, 0], "a background edit recolours nothing");
|
||||
|
||||
let mut h = color_panel_with_a_shape(ColorPanelSliderMode::Hsb, false);
|
||||
type_first_slider(&mut h, "120");
|
||||
let app = h.state();
|
||||
assert_eq!(srgb_bytes(app.session.tools.foreground), [0, 255, 0]);
|
||||
assert_eq!(srgb_bytes(app.session.tools.background), [255, 0, 0]);
|
||||
assert_eq!(shape_fill(app), [0, 255, 0], "the selected shape takes the new foreground");
|
||||
}
|
||||
|
||||
/// The RGB sliders recolour the selected shape like the HSB sliders, the field and the
|
||||
/// swatches do (`apply_color`), and only for a foreground edit.
|
||||
#[test]
|
||||
fn rgb_sliders_recolour_the_selected_shape_with_the_foreground() {
|
||||
let mut h = color_panel_with_a_shape(ColorPanelSliderMode::Rgb, false);
|
||||
let steps = h.state().session.active().unwrap().history.entries().len();
|
||||
type_first_slider(&mut h, "255");
|
||||
let app = h.state();
|
||||
assert_eq!(srgb_bytes(app.session.tools.foreground), [255, 0, 255]);
|
||||
assert_eq!(shape_fill(app), [255, 0, 255]);
|
||||
assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 1, "one history step");
|
||||
|
||||
let mut h = color_panel_with_a_shape(ColorPanelSliderMode::Rgb, true);
|
||||
type_first_slider(&mut h, "0");
|
||||
let app = h.state();
|
||||
assert_eq!(srgb_bytes(app.session.tools.background), [0, 0, 0]);
|
||||
assert_eq!(shape_fill(app), [0, 0, 0], "a background edit recolours nothing");
|
||||
}
|
||||
|
||||
/// The Color panel's hex readout is editable, with or without the leading `#`.
|
||||
#[test]
|
||||
fn color_panel_hex_field_takes_a_typed_hex() {
|
||||
@@ -5065,7 +4871,7 @@ mod color_tests {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
color_picker(app, ui);
|
||||
color_field(app, ui);
|
||||
}
|
||||
},
|
||||
app,
|
||||
@@ -5200,7 +5006,7 @@ mod color_tests {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
color_picker(app, ui);
|
||||
color_field(app, ui);
|
||||
}
|
||||
},
|
||||
app,
|
||||
@@ -5318,36 +5124,6 @@ mod color_tests {
|
||||
assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0x00, 0x33, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_and_saturation_fields_take_values_on_black() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.tools.foreground = [0.0, 0.0, 0.0, 1.0];
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
hsb_sliders(app, ui);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||||
h.run_steps(2);
|
||||
// Hue, Saturation, Brightness, typed one key per frame. Black has no hue or saturation of its
|
||||
// own, so the first two used to reset to 0 and this ended on white.
|
||||
for (field, typed) in [(0, "120"), (1, "100"), (2, "100")] {
|
||||
h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(field).unwrap().click();
|
||||
h.run_steps(1);
|
||||
for ch in typed.chars() {
|
||||
h.event(egui::Event::Text(ch.to_string()));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.key_press(egui::Key::Tab);
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0, 255, 0]);
|
||||
}
|
||||
|
||||
/// The Color field fills the dock group, shrinks to leave room for the readout in a short group, and ignores a bad height.
|
||||
#[test]
|
||||
fn color_field_height_fills_the_group() {
|
||||
@@ -6035,11 +5811,11 @@ mod swatch_type_tests {
|
||||
use super::*;
|
||||
use egui_kittest::Harness;
|
||||
|
||||
/// The HSB sliders only act on an edit. Their h/s/v round trip isn't exact for every colour
|
||||
/// (#D8452E isn't), and comparing values used to rewrite the foreground every frame, which
|
||||
/// would recolour selected type the moment it was selected.
|
||||
/// The Color field only acts on an edit. Its h/s/v round trip isn't exact for every colour
|
||||
/// (#D8452E isn't), and writing it back every frame would recolour selected type the moment it
|
||||
/// was selected.
|
||||
#[test]
|
||||
fn idle_hsb_sliders_leave_the_foreground_and_selected_type_alone() {
|
||||
fn idle_color_field_leaves_the_foreground_and_selected_type_alone() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
|
||||
let id = app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 20, "y": 100, "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
@@ -6058,7 +5834,7 @@ mod swatch_type_tests {
|
||||
resize: None,
|
||||
preedit: None,
|
||||
});
|
||||
let mut h = Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_picker(app, ui), app);
|
||||
let mut h = Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_field(app, ui), app);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.tools.foreground, fg);
|
||||
let st = h.state().session.active().unwrap();
|
||||
|
||||
+95
-16
@@ -1246,27 +1246,106 @@ pub struct DockTabs {
|
||||
pub brushes: usize,
|
||||
}
|
||||
|
||||
/// Sliders under the Color panel's field: hue/saturation/brightness or red/green/blue (#7172).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ColorPanelSliderMode {
|
||||
#[default]
|
||||
Hsb,
|
||||
Rgb,
|
||||
}
|
||||
|
||||
/// Color panel state.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct ColorPanelState {
|
||||
/// The panel edits the background colour (its chip was clicked), not the foreground.
|
||||
pub background: bool,
|
||||
/// Color Picker › Only Web Colors, kept across opens like Photoshop (#4995).
|
||||
#[serde(default)]
|
||||
/// Color Picker › Only Web Colors, kept across opens like Photoshop (#4995). `web_only` is
|
||||
/// its name before the camelCase rename (#6000).
|
||||
#[serde(default, alias = "web_only")]
|
||||
pub web_only: bool,
|
||||
/// HSB or RGB sliders under the colour field (#7172).
|
||||
#[serde(default)]
|
||||
pub slider_mode: ColorPanelSliderMode,
|
||||
/// What the panel shows, picked from its panel menu as in Photoshop (#6000).
|
||||
pub mode: ColorPanelMode,
|
||||
/// The colour ramp under the sliders.
|
||||
pub ramp: ColorRamp,
|
||||
/// Make Ramp Web Safe: the ramp shows (and picks) web colours only.
|
||||
pub web_safe_ramp: bool,
|
||||
}
|
||||
|
||||
/// The Color panel's display, Photoshop's panel menu choices in its order. Hue Cube is
|
||||
/// Photoshop's default; the theme never changes it (#6000).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ColorPanelMode {
|
||||
#[default]
|
||||
HueCube,
|
||||
BrightnessCube,
|
||||
ColorWheel,
|
||||
GrayscaleSlider,
|
||||
RgbSliders,
|
||||
HsbSliders,
|
||||
CmykSliders,
|
||||
LabSliders,
|
||||
WebColorSliders,
|
||||
}
|
||||
|
||||
impl ColorPanelMode {
|
||||
/// Every mode, in Photoshop's panel menu order.
|
||||
pub const ALL: [ColorPanelMode; 9] = [
|
||||
ColorPanelMode::HueCube,
|
||||
ColorPanelMode::BrightnessCube,
|
||||
ColorPanelMode::ColorWheel,
|
||||
ColorPanelMode::GrayscaleSlider,
|
||||
ColorPanelMode::RgbSliders,
|
||||
ColorPanelMode::HsbSliders,
|
||||
ColorPanelMode::CmykSliders,
|
||||
ColorPanelMode::LabSliders,
|
||||
ColorPanelMode::WebColorSliders,
|
||||
];
|
||||
|
||||
/// The panel menu item.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ColorPanelMode::HueCube => "Hue Cube",
|
||||
ColorPanelMode::BrightnessCube => "Brightness Cube",
|
||||
ColorPanelMode::ColorWheel => "Color Wheel",
|
||||
ColorPanelMode::GrayscaleSlider => "Grayscale Slider",
|
||||
ColorPanelMode::RgbSliders => "RGB Sliders",
|
||||
ColorPanelMode::HsbSliders => "HSB Sliders",
|
||||
ColorPanelMode::CmykSliders => "CMYK Sliders",
|
||||
ColorPanelMode::LabSliders => "Lab Sliders",
|
||||
ColorPanelMode::WebColorSliders => "Web Color Sliders",
|
||||
}
|
||||
}
|
||||
|
||||
/// The slider modes, which show the colour ramp under their sliders.
|
||||
pub fn is_sliders(self) -> bool {
|
||||
!matches!(self, ColorPanelMode::HueCube | ColorPanelMode::BrightnessCube | ColorPanelMode::ColorWheel)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Color panel's colour ramp, from its panel menu (or Shift-click on the ramp to cycle).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ColorRamp {
|
||||
#[default]
|
||||
RgbSpectrum,
|
||||
CmykSpectrum,
|
||||
GrayscaleRamp,
|
||||
CurrentColors,
|
||||
}
|
||||
|
||||
impl ColorRamp {
|
||||
/// Every ramp, in Photoshop's panel menu order (also the Shift-click cycle).
|
||||
pub const ALL: [ColorRamp; 4] = [ColorRamp::RgbSpectrum, ColorRamp::CmykSpectrum, ColorRamp::GrayscaleRamp, ColorRamp::CurrentColors];
|
||||
|
||||
/// The panel menu item.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ColorRamp::RgbSpectrum => "RGB Spectrum",
|
||||
ColorRamp::CmykSpectrum => "CMYK Spectrum",
|
||||
ColorRamp::GrayscaleRamp => "Grayscale Ramp",
|
||||
ColorRamp::CurrentColors => "Current Colors",
|
||||
}
|
||||
}
|
||||
|
||||
/// The next ramp in the Shift-click cycle.
|
||||
pub fn next(self) -> ColorRamp {
|
||||
let i = ColorRamp::ALL.iter().position(|r| *r == self).unwrap_or(0);
|
||||
ColorRamp::ALL.get(i + 1).copied().unwrap_or(ColorRamp::RgbSpectrum)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -1461,7 +1540,7 @@ pub struct UiState {
|
||||
/// One-frame focus/raise request from an explicit panel reveal.
|
||||
#[serde(skip)]
|
||||
pub docking_reveal: Option<String>,
|
||||
/// Which chip the Color panel edits.
|
||||
/// Which chip the Color panel edits, and how it shows the colour.
|
||||
#[serde(default)]
|
||||
pub color_panel: ColorPanelState,
|
||||
/// Brush Settings: selected section (0 = Brush Tip Shape) and tab (0 settings, 1 Brushes).
|
||||
|
||||
@@ -47,7 +47,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI)
|
||||
- `engine.commands`: list commands with enablement; the `result` is a JSON array of command objects (see [Command list replies](#command-list-replies))
|
||||
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], size: [w, h]?, list: {collapsed, filter, renaming, showName, showStroke, showTip, scale}}` while the Brush Preset picker is open (coordinates in screen points; `size` is the picker's content size once its corner grip was dragged, `null` before that, and the drag remembers both dimensions; the three `show*` flags pick the parts of each preset's card — at least one is always on; `scale` is the footer slider, 0.15–2 with 1 the standard, multiplying the cards' width and height — the heights stop at half size and the name and tip-size text hold their 1.0 size — and dropping the tips' size numbers at 0.40 and below; the parts and the scale are remembered across restarts, while the search, collapsed groups and a rename in progress are not). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background?, mode?, ramp?, webSafeRamp?}`, merged into the Color panel's state: `background` is whether it edits the background colour, `mode` its panel-menu display (`hueCube`, `brightnessCube`, `colorWheel`, `grayscaleSlider`, `rgbSliders`, `hsbSliders`, `cmykSliders`, `labSliders` or `webColorSliders`), `ramp` the colour ramp under the sliders (`rgbSpectrum`, `cmykSpectrum`, `grayscaleRamp` or `currentColors`) and `webSafeRamp` Make Ramp Web Safe; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again)
|
||||
- `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state.
|
||||
- `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once. Window ends with the open documents as `window.document.<index>` (tab order, the active one checked, #2991); invoking one brings that document to the front and replies `{document: index}`
|
||||
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
# Where PhotoCraft falls short of Photoshop
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
|
||||
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
|
||||
@@ -259,10 +259,12 @@ what an agent can't finish alone.
|
||||
selected text keeps its current command and undo scope. Other panel gaps remain open.
|
||||
- **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086),
|
||||
with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`).
|
||||
- **Progress (2026-10-15):** the Color panel has Photoshop's panel-menu modes (Hue Cube, Brightness
|
||||
Cube, Color Wheel, Grayscale/RGB/HSB/CMYK/Lab/Web Color sliders), its colour ramp and copy
|
||||
commands, the same in every theme (UI-217-17, #2569, #6000; `ui-egui/src/color_panel_ui.rs`).
|
||||
- **Missing:** 24 of 171 preferences do nothing (scorecard prefs audit, 2026-10-15: Save in
|
||||
Background now takes effect, #6207); panel groups can't
|
||||
float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and
|
||||
slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); a single pixel layer
|
||||
float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); more themes (UI-217-11, #2661); a single pixel layer
|
||||
copies without its mask and styles (UI-217-23; fill and adjustment layers copy whole, #2344); tool-specific context menus beyond brushes, selections and Pen
|
||||
([context-menu-parity.md](context-menu-parity.md)); Libraries, Comments, Contextual Task Bar
|
||||
panels.
|
||||
@@ -360,6 +362,7 @@ what an agent can't finish alone.
|
||||
| 2026-10-15 | minor | G10: Cineon write landed (10-bit log gray/RGB, Save As `.cin`) |
|
||||
| 2026-10-14 | minor | G1: 32-bit Hue/Saturation unclipped (slider on whole levels) and Colorize's saturation scaled by 1 − \|Lightness\|; photoshop rgb32 hue-saturation and hue-saturation-colorize pass |
|
||||
| 2026-10-14 | minor | G1: Lab adjustment layers' results are whole samples (native Lab path); photoshop lab8 curves passes |
|
||||
| 2026-10-19 | minor | G9: Color panel modes, ramp and copy commands from its panel menu, the same in every theme (UI-217-17 done, #6000) |
|
||||
| 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open |
|
||||
| 2026-10-20 | minor | G10: `.asl` layer style library import and export landed (#1051) |
|
||||
| 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity: tools, handles, modifiers and feel
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
|
||||
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
|
||||
@@ -220,7 +220,7 @@ Paragraph; the rest open as in-app floating windows.
|
||||
| Panel groups float and tear off into OS windows | missing | UI-217-5, #1828, #1213 |
|
||||
| Drag panels to reorder and regroup | partial | #2272, #2305 |
|
||||
| Docked resize pushes the neighbour | partial | #2573 |
|
||||
| Color panel wheel and RGB/HSB/CMYK/Lab sliders | missing | UI-217-17, #2569 |
|
||||
| Color panel modes from its panel menu (Hue Cube, Brightness Cube, Color Wheel, Grayscale/RGB/HSB/CMYK/Lab/Web Color sliders), colour ramp (RGB/CMYK Spectrum, Grayscale Ramp, Current Colors, Make Ramp Web Safe; Shift-click cycles, Alt-click picks the other colour), Copy Color as HTML / Hex Code; the same in every theme | done | `color_panel_ui.rs`, `color_panel_tests.rs`; UI-217-17, #2569, #6000 |
|
||||
| A colour picked in the Color or Swatches panel or the Color Picker recolours the selection: selected characters while editing; every selected type layer with a Type tool; every selected shape's fill with a shape, Pen or path selection tool; only the foreground with other tools, the Move tool too; one history step (a Color panel drag too) | done: `apply_color.rs`, `apply_color_tests.rs` | |
|
||||
| Window size and dock width remembered | done | UI-217-2 |
|
||||
| Command palette with recents | done | UI-217-7 |
|
||||
@@ -291,3 +291,4 @@ Paragraph; the rest open as in-app floating windows.
|
||||
| 2026-10-15 | minor | ⌫/Delete with a smart filter row selected removes that filter (the Smart Filters row: all of them), as the Delete button does, instead of the smart object (#4751) |
|
||||
| 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) |
|
||||
| 2026-10-14 | minor | A colour picked in the Color or Swatches panel or the Color Picker recolours the selected type layers (Type tool) or shape fills (shape, Pen and path selection tools) |
|
||||
| 2026-10-15 | minor | Color panel: Photoshop's panel-menu modes, colour ramp and copy commands; the theme no longer picks the panel's layout (UI-217-17 done, #2569, #6000) |
|
||||
|
||||
+2
-2
@@ -120,9 +120,9 @@ note = "saturation_track/lightness_track (adjust_editors.rs): grey -> the hue, r
|
||||
[[item]]
|
||||
id = "UI-217-17"
|
||||
target = "Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group"
|
||||
status = "missing"
|
||||
status = "done"
|
||||
issue = 217
|
||||
note = "panels.rs:1837 single field + hex/RGB readout"
|
||||
note = "color_panel_ui.rs: Hue/Brightness Cube, Color Wheel, Grayscale/RGB/HSB/CMYK/Lab/Web sliders, ramp; the same in every theme (#6000); color_panel_tests.rs"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-18"
|
||||
|
||||
Reference in New Issue
Block a user