* fix: Color panel chips pick the colour to edit and open the Color Picker on double-click A click on the foreground or background chip makes the Color panel's field and hue strip edit that colour; a double-click opens the Color Picker on it, with its RGB, HSB, Lab, CMYK and hex fields. Each chip remembers its own hue for greys. Fixes #655 * refactor(ui): keep the Color panel's edited chip in UiState The chip the Color panel edits is UiState::color_panel.background, so the control channel and MCP can read it and set it with ui.set {colorPanel: {background}}. The double-click test now clicks where the chips overlap, where the foreground chip takes the click.
This commit is contained in:
parent
9aef02cac3
commit
6c4010cfc4
|
|
@ -8,7 +8,7 @@
|
|||
//! - `engine.commands`: list commands with enablement
|
||||
//! - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size); the menu
|
||||
//! tree is `ui.menu.list`
|
||||
//! - `ui.set {tool?, panels?, dock?, dockTabs?, dockWidth?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSection?, brushTab?, brushesView?, brushSize?}`:
|
||||
//! - `ui.set {tool?, panels?, dock?, dockTabs?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSection?, brushTab?, brushesView?, brushSize?}`:
|
||||
//! change UI state; any other field is an error ([`UI_SET_FIELDS`])
|
||||
//! - `ui.menu.invoke {id, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree
|
||||
//! - `ui.dialog.open {kind, fields?}` (kinds: newDocument, about, layerStyle {effect?}, colorPicker {target: foreground|background}, command {command}) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`
|
||||
|
|
@ -70,12 +70,13 @@ pub enum Outcome {
|
|||
|
||||
/// The fields `ui.set` reads. Anything else is rejected before a field is applied, so a typo or
|
||||
/// a field the method doesn't have can't reply with success while nothing changes (#412).
|
||||
pub const UI_SET_FIELDS: [&str; 18] = [
|
||||
pub const UI_SET_FIELDS: [&str; 19] = [
|
||||
"tool",
|
||||
"panels",
|
||||
"dock",
|
||||
"dockTabs",
|
||||
"dockWidth",
|
||||
"colorPanel",
|
||||
"maskTarget",
|
||||
"vectorMaskTarget",
|
||||
"selectionMode",
|
||||
|
|
@ -274,6 +275,13 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
|
|||
Err(e) => return err(e),
|
||||
}
|
||||
}
|
||||
// Which chip the Color panel edits.
|
||||
if let Some(c) = p.get("colorPanel") {
|
||||
match serde_json::from_value(c.clone()) {
|
||||
Ok(v) => app.ui.color_panel = v,
|
||||
Err(e) => return err(e),
|
||||
}
|
||||
}
|
||||
// Right dock width in points (clamped to the dock's 250..=520 range), applied next frame.
|
||||
if let Some(w) = p.get("dockWidth").and_then(Value::as_f64) {
|
||||
crate::panels::request_dock_width(ctx, w as f32);
|
||||
|
|
@ -771,6 +779,16 @@ mod tests {
|
|||
assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_color_panel_picks_the_edited_chip() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"colorPanel": {"background": true}}))["ok"], true);
|
||||
assert!(app.ui.color_panel.background);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"colorPanel": {"background": "yes"}}))["ok"], false);
|
||||
assert!(app.ui.color_panel.background, "a bad value changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_unknown_theme_error_names_every_theme_and_each_name_works() {
|
||||
use crate::theme::ThemeKind;
|
||||
|
|
|
|||
|
|
@ -2144,12 +2144,41 @@ 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) {
|
||||
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));
|
||||
let frame = Stroke::new(1.0, t.text_dim);
|
||||
let bg_active = app.ui.color_panel.background;
|
||||
let p = ui.painter();
|
||||
p.rect_filled(bgr, 2.0, c32(app.session.tools.background));
|
||||
p.rect_stroke(bgr, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
if bg_active {
|
||||
p.rect_stroke(bgr.expand(2.0), 2.0, frame, StrokeKind::Outside);
|
||||
}
|
||||
p.rect_filled(fgr, 2.0, c32(app.session.tools.foreground));
|
||||
p.rect_stroke(fgr, 2.0, Stroke::new(1.0, Color32::from_gray(210)), StrokeKind::Outside);
|
||||
if !bg_active {
|
||||
p.rect_stroke(fgr.expand(2.0), 2.0, frame, StrokeKind::Outside);
|
||||
}
|
||||
// The foreground is on top, so it takes the clicks where the two overlap.
|
||||
let bg_resp = ui.interact(bgr, ui.id().with("field-bg"), Sense::click());
|
||||
let fg_resp = ui.interact(fgr, ui.id().with("field-fg"), Sense::click());
|
||||
let picked = if fg_resp.clicked() { false } else { bg_resp.clicked() || bg_active };
|
||||
app.ui.color_panel.background = picked;
|
||||
if fg_resp.double_clicked() || bg_resp.double_clicked() {
|
||||
crate::color_picker_ui::open(app, if picked { "background" } else { "foreground" });
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop Color panel: saturation/brightness field + hue strip, drawn as shaded meshes.
|
||||
fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let fg = app.session.tools.foreground;
|
||||
let key = egui::Id::new("color-field-hue");
|
||||
let mut hsva = srgb_hsva(fg);
|
||||
let bg_active = app.ui.color_panel.background;
|
||||
let key = egui::Id::new(("color-field-hue", bg_active));
|
||||
let mut hsva = srgb_hsva(if bg_active { app.session.tools.background } else { app.session.tools.foreground });
|
||||
// Keep hue stable for greys (where RGB->HSV hue is undefined).
|
||||
let remembered: f32 = ui.data(|d| d.get_temp(key)).unwrap_or(hsva.h);
|
||||
if hsva.s < 0.01 || hsva.v < 0.01 {
|
||||
|
|
@ -2160,16 +2189,10 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||
let h = 120.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
// Colour chips (fg/bg) at left, Photoshop style.
|
||||
let (chips, _) = ui.allocate_exact_size(vec2(34.0, h), Sense::hover());
|
||||
let bgr = Rect::from_min_size(chips.min + vec2(10.0, 10.0), vec2(22.0, 22.0));
|
||||
let fgr = Rect::from_min_size(chips.min, vec2(22.0, 22.0));
|
||||
ui.painter().rect_filled(bgr, 2.0, c32(app.session.tools.background));
|
||||
ui.painter().rect_stroke(bgr, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
ui.painter().rect_filled(fgr, 2.0, c32(fg));
|
||||
ui.painter().rect_stroke(fgr, 2.0, Stroke::new(1.0, Color32::from_gray(210)), StrokeKind::Outside);
|
||||
let (chips, _) = ui.allocate_exact_size(vec2(38.0, h), Sense::hover());
|
||||
field_chips(app, ui, chips);
|
||||
// SV field.
|
||||
let field_w = w - 34.0 - strip_w - 16.0;
|
||||
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;
|
||||
|
|
@ -2226,8 +2249,12 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
|||
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() {
|
||||
app.session.tools.foreground = hsva_srgb(hsva);
|
||||
crate::type_tool::foreground_changed(app);
|
||||
if bg_active {
|
||||
app.session.tools.background = hsva_srgb(hsva);
|
||||
} else {
|
||||
app.session.tools.foreground = hsva_srgb(hsva);
|
||||
crate::type_tool::foreground_changed(app);
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(key, hsva.h));
|
||||
}
|
||||
});
|
||||
|
|
@ -2474,6 +2501,86 @@ mod color_tests {
|
|||
}
|
||||
assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0, 255, 0]);
|
||||
}
|
||||
|
||||
fn field_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||||
// 60 fps steps, so two clicks a frame apart are a double-click.
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
color_field(app, ui);
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||||
h.run_steps(2);
|
||||
h
|
||||
}
|
||||
|
||||
fn click(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, p: egui::Pos2) {
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on the background chip makes the field edit the background, not the foreground.
|
||||
#[test]
|
||||
fn background_chip_retargets_the_color_field() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0];
|
||||
app.session.tools.background = [0.0, 0.0, 0.0, 1.0];
|
||||
let mut h = field_harness(app);
|
||||
let min = h.ctx.input(|i| i.viewport_rect()).min;
|
||||
// The background chip's corner that the foreground chip doesn't cover.
|
||||
click(&mut h, min + vec2(40.0, 40.0));
|
||||
// The field's top-left: no saturation, full brightness.
|
||||
click(&mut h, min + vec2(60.0, 10.0));
|
||||
assert!(h.state().ui.color_panel.background);
|
||||
let tools = &h.state().session.tools;
|
||||
assert_eq!(tools.foreground, [0.2, 0.4, 0.6, 1.0]);
|
||||
assert!(tools.background[..3].iter().all(|&v| v > 0.9), "{:?}", tools.background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_chip_opens_its_color_picker() {
|
||||
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let mut h = field_harness(app);
|
||||
let min = h.ctx.input(|i| i.viewport_rect()).min;
|
||||
// The foreground point is where the chips overlap: the foreground is on top there.
|
||||
for (p, target) in [(vec2(40.0, 40.0), "background"), (vec2(30.0, 30.0), "foreground")] {
|
||||
h.state_mut().ui.dialogs.clear();
|
||||
// Past the last double-click, so this one isn't counted as a triple-click.
|
||||
h.run_steps(40);
|
||||
click(&mut h, min + p);
|
||||
assert!(h.state().ui.dialogs.is_empty(), "a single click only picks the chip");
|
||||
click(&mut h, min + p);
|
||||
let [d] = h.state().ui.dialogs.as_slice() else { panic!("one Color Picker") };
|
||||
assert_eq!(d.fields.get("__colorPicker").and_then(Value::as_str), Some(target));
|
||||
}
|
||||
}
|
||||
|
||||
/// Black has no hue of its own, so each chip remembers the hue last picked for it.
|
||||
#[test]
|
||||
fn each_chip_keeps_its_own_hue_on_black() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.tools.foreground = [0.0, 0.0, 0.0, 1.0];
|
||||
app.session.tools.background = [0.0, 0.0, 0.0, 1.0];
|
||||
let mut h = field_harness(app);
|
||||
let min = h.ctx.input(|i| i.viewport_rect()).min;
|
||||
// The hue strip runs from red at the top through blue (a third down) and green (two thirds).
|
||||
let (strip_x, green, blue) = (285.0, 88.0, 48.0);
|
||||
click(&mut h, min + vec2(strip_x, green));
|
||||
click(&mut h, min + vec2(40.0, 40.0));
|
||||
click(&mut h, min + vec2(strip_x, blue));
|
||||
click(&mut h, min + vec2(16.0, 16.0));
|
||||
// Near the field's top-right: high saturation and brightness.
|
||||
click(&mut h, min + vec2(265.0, 10.0));
|
||||
let [r, g, b] = srgb_bytes(h.state().session.tools.foreground);
|
||||
assert!(g > 200 && r < 64 && b < 64, "the foreground's green, not the background's blue: {:?}", [r, g, b]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -601,6 +601,14 @@ pub struct DockTabs {
|
|||
pub character: usize,
|
||||
}
|
||||
|
||||
/// Color panel state.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ColorPanelState {
|
||||
/// The panel edits the background colour (its chip was clicked), not the foreground.
|
||||
pub background: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UiState {
|
||||
pub tool: Tool,
|
||||
|
|
@ -690,6 +698,9 @@ pub struct UiState {
|
|||
/// Right-dock group order, heights and collapsed groups (see `dock`).
|
||||
#[serde(default)]
|
||||
pub dock: crate::dock::DockLayout,
|
||||
/// Which chip the Color panel edits.
|
||||
#[serde(default)]
|
||||
pub color_panel: ColorPanelState,
|
||||
/// Brush Settings: selected section (0 = Brush Tip Shape) and tab (0 settings, 1 Brushes).
|
||||
#[serde(default)]
|
||||
pub brush_section: usize,
|
||||
|
|
@ -770,6 +781,7 @@ impl Default for UiState {
|
|||
palette_open: false,
|
||||
dock_tabs: DockTabs::default(),
|
||||
dock: Default::default(),
|
||||
color_panel: Default::default(),
|
||||
brush_section: 0,
|
||||
brush_tab: 0,
|
||||
brushes_panel: Default::default(),
|
||||
|
|
|
|||
|
|
@ -35,7 +35,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
|
||||
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `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?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?}`: change UI state; any other field is an error, checked before anything changes (`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; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?}`: change UI state; any other field is an error, checked before anything changes (`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; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour)
|
||||
- `ui.menu.invoke {id, 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
|
||||
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
|
||||
- `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once
|
||||
|
|
|
|||
Loading…
Reference in New Issue