Layers: double-click a shape layer's thumbnail to pick its fill colour (#2812) (#2820)

Like Photoshop, double-clicking a solid-colour shape layer's thumbnail (or
Layer > Layer Content Options...) opens the Color Picker on the shape's fill.
The canvas previews the pick, Cancel changes nothing and OK commits one
undoable shape.edit with the new fill (keeping the fill's alpha). Gradient,
pattern or no-fill shapes keep opening their Properties controls.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-11 06:12:57 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 1608b7b309
commit 273ff014e3
7 changed files with 213 additions and 5 deletions
+3
View File
@@ -955,6 +955,9 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar
if let Some(shown) = crate::solid_fill_ui::display_doc(app, idx) {
return shown;
}
if let Some(shown) = crate::shape_fill_ui::display_doc(app, idx) {
return shown;
}
if let Some(shown) = crate::shape_stroke_ui::display_doc(app, idx) {
return shown;
}
+3
View File
@@ -694,6 +694,9 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
if crate::solid_fill_ui::owns(f) {
return crate::solid_fill_ui::confirm(app, f);
}
if crate::shape_fill_ui::owns(f) {
return crate::shape_fill_ui::confirm(app, f);
}
let target = f.get("__colorPicker").and_then(Value::as_str).unwrap_or("foreground");
let color = f.get("color").and_then(Value::as_str).unwrap_or("#000000");
if let Some(cmd) = f.get("__command").and_then(Value::as_str) {
+4
View File
@@ -141,6 +141,7 @@ pub mod screen_picker;
pub mod scrollbars;
pub mod served_fonts;
pub mod shape_dialog;
pub(crate) mod shape_fill_ui;
pub mod shape_stroke_ui;
pub mod shortcut_dispatch;
pub mod shortcuts;
@@ -562,6 +563,8 @@ pub struct PhotocraftApp {
/// Live Layer Style dialog preview: (key over revision + style fields, preview or validation error).
pub(crate) style_preview: Option<(u64, Result<std::sync::Arc<Document>, String>)>,
pub(crate) solid_fill_preview: Option<solid_fill_ui::Preview>,
/// Shape fill Color Picker preview (shape_fill_ui).
pub(crate) shape_fill_preview: Option<shape_fill_ui::Preview>,
/// Liquify dialog, Puppet Warp and Perspective Warp sessions (distort_ui).
pub(crate) distort: distort_ui::Distort,
/// Gradient tool live-mode drags and previews (gradient_ui).
@@ -719,6 +722,7 @@ impl PhotocraftApp {
style_preview: None,
text_style_preview: None,
solid_fill_preview: None,
shape_fill_preview: None,
distort: Default::default(),
gradient: Default::default(),
camera_raw: None,
+3 -2
View File
@@ -411,9 +411,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
}
Ok(Value::Null)
}
// Solid Color uses the Color Picker; other adjustment / fill controls use Properties.
// Solid Color fills and solid-colour shapes use the Color Picker; other adjustment /
// fill / shape controls use Properties.
"layer.layerContentOptions" => {
if let Some(dialog) = crate::solid_fill_ui::open(app) {
if let Some(dialog) = crate::solid_fill_ui::open(app).or_else(|| crate::shape_fill_ui::open(app)) {
return Ok(json!({"dialog": dialog}));
}
let r = app.run(id, params)?;
+4 -3
View File
@@ -2599,8 +2599,9 @@ fn layer_row(
}
// Double-click: the name renames in place (over the row's full height, not just the glyphs:
// #651); the Background, which can't be renamed while it's locked, becomes a normal layer; an
// adjustment or fill thumbnail opens its settings and a Smart Object thumbnail its contents,
// and a type thumbnail edits its text; anywhere else on the row opens Layer Style (#350, #537).
// adjustment, fill or shape thumbnail opens its settings (a solid colour: the Color Picker,
// #2812) and a Smart Object thumbnail its contents, and a type thumbnail edits its text;
// anywhere else on the row opens Layer Style (#350, #537).
// The first click already made this the active layer.
if resp.double_clicked() {
let pos = resp.interact_pointer_pos();
@@ -2626,7 +2627,7 @@ fn layer_row(
}
} else if pos.and_then(|p| masks.hit(p)).is_none() {
let id = match &l.content {
LayerContent::Adjustment(_) | LayerContent::Fill(_) if on(thumb) => "layer.layerContentOptions",
LayerContent::Adjustment(_) | LayerContent::Fill(_) | LayerContent::Shape(_) if on(thumb) => "layer.layerContentOptions",
LayerContent::Smart(_) if on(thumb) => "layer.smartObjects.editContents",
LayerContent::Text(_) if on(thumb) => "type.editText",
_ => "layer.layerStyle.blendingOptions",
+124
View File
@@ -0,0 +1,124 @@
//! A solid-colour shape layer's thumbnail (or Layer › Layer Content Options…) opens the shared
//! Color Picker on the shape's fill, like Photoshop (#2812). The canvas previews a shallow
//! document snapshot; Cancel changes nothing and OK commits exactly one `shape.edit`.
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use photocraft_color::Color;
use photocraft_doc::{DocId, Document, Fill, LayerContent, LayerId};
use serde_json::{Map, Value, json};
use crate::{PhotocraftApp, color_picker_ui};
const COMMAND: &str = "shape.edit";
const MARKER: &str = "__shapeFill";
pub(crate) struct Preview {
document: DocId,
revision: u64,
dialog: u64,
layer: LayerId,
color: Color,
shown: Arc<Document>,
key: u64,
}
/// The solid fill colour of shape layer `layer`, if it has one (not a gradient, pattern or none).
fn solid_fill(doc: &Document, layer: LayerId) -> Option<Color> {
match &doc.layer(layer)?.content {
LayerContent::Shape(sh) => match sh.fill {
Some(Fill::Solid(c)) => Some(c),
_ => None,
},
_ => None,
}
}
/// Opens the picker for the active layer when it is a shape filled with a solid colour.
pub(crate) fn open(app: &mut PhotocraftApp) -> Option<u64> {
let st = app.session.active()?;
let layer = st.active_layer?;
let color = solid_fill(&st.doc, layer)?;
let params = json!({"layer": layer.0, "document": st.doc.id.0});
let id = color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), COMMAND, params);
if let Some(d) = app.ui.dialog_mut(id) {
d.fields.insert(MARKER.into(), json!(true));
}
Some(id)
}
pub(crate) fn owns(f: &Map<String, Value>) -> bool {
f.get(MARKER).and_then(Value::as_bool) == Some(true)
}
/// The picker's colour as `shape.edit` stores it: RGB with the original fill's alpha.
fn picked(f: &Map<String, Value>, original: Color) -> Option<Color> {
let [r, g, b] = color_picker_ui::parse_hex(f.get("color")?.as_str()?)?;
Some(Color::rgba(r, g, b, original.alpha))
}
/// The target layer and its current fill, unless the dialog is stale (another document is
/// active, or the layer is gone or no longer a solid-colour shape).
fn target(app: &PhotocraftApp, f: &Map<String, Value>, idx: usize) -> Result<(LayerId, Color), String> {
let params = f.get("__params").ok_or("missing shape fill target")?;
let st = app.session.documents().get(idx).ok_or("no document open")?;
if params.get("document").and_then(Value::as_u64) != Some(st.doc.id.0) {
return Err("the target document is no longer active".into());
}
let layer = LayerId(params.get("layer").and_then(Value::as_u64).ok_or("missing shape layer")?);
let original = solid_fill(&st.doc, layer).ok_or("the target shape layer no longer has a solid colour fill")?;
Ok((layer, original))
}
/// OK: one `shape.edit` with the new fill; an unchanged colour keeps the stored one as it is.
pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let idx = app.session.active_index().ok_or("no document open")?;
let (layer, original) = target(app, f, idx)?;
if f.get("color") == f.get("__orig") {
return Ok(json!({"layer": layer.0, "unchanged": true}));
}
let color = picked(f, original).ok_or("not a colour")?;
app.run(COMMAND, json!({"layer": layer.0, "fill": [color.c[0], color.c[1], color.c[2], color.alpha]}))
}
/// The canvas preview while the picker is open: the document with the shape re-rendered in the
/// picked colour, cached per dialog, revision and colour.
pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
let Some(d) = app.ui.dialogs.iter().rev().find(|d| owns(&d.fields)) else {
app.shape_fill_preview = None;
return None;
};
if d.fields.get("color") == d.fields.get("__orig") {
return None;
}
let (layer, original) = target(app, &d.fields, idx).ok()?;
let color = picked(&d.fields, original)?;
let st = app.session.documents().get(idx)?;
if let Some(p) = &app.shape_fill_preview
&& p.document == st.doc.id
&& p.revision == st.revision
&& p.dialog == d.id
&& p.layer == layer
&& p.color == color
{
return Some((p.shown.clone(), p.key));
}
let mut doc = (*st.doc).clone();
if let LayerContent::Shape(sh) = &mut doc.layer_mut(layer)?.content {
sh.fill = Some(Fill::Solid(color));
photocraft_engine::vector_cmds::refresh_shape(&st.doc, sh);
}
let mut hash = std::collections::hash_map::DefaultHasher::new();
(st.doc.id.0, st.revision, d.id, layer.0).hash(&mut hash);
for component in color.c.into_iter().chain([color.alpha]) {
component.to_bits().hash(&mut hash);
}
let key = (1 << 46) | (hash.finish() & ((1 << 40) - 1));
let shown = Arc::new(photocraft_engine::mode_cmds::display_document(&doc).unwrap_or(doc));
app.shape_fill_preview = Some(Preview { document: st.doc.id, revision: st.revision, dialog: d.id, layer, color, shown: shown.clone(), key });
Some((shown, key))
}
#[cfg(test)]
mod tests;
+72
View File
@@ -0,0 +1,72 @@
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
use egui_kittest::Harness;
use photocraft_doc::{Fill, LayerContent, LayerId};
use serde_json::json;
use super::*;
fn double_click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) {
h.run_steps(40);
for _ in 0..2 {
h.hover_at(at);
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
}
}
fn fill(app: &PhotocraftApp, id: LayerId) -> Option<Fill> {
match &app.session.active().unwrap().doc.layer(id).unwrap().content {
LayerContent::Shape(sh) => sh.fill.clone(),
_ => None,
}
}
/// #2812: double-clicking a shape layer's thumbnail opens the Color Picker on its fill; the
/// canvas previews the pick, Cancel changes nothing and OK is one undoable `shape.edit`.
#[test]
fn shape_thumbnail_double_click_picks_the_fill_colour() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let id = LayerId(s.execute("shape.create", json!({"rect": [8, 8, 40, 30], "fill": "#336699"})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.new.layer", json!({"name": "Other"})).unwrap();
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(s, crate::Services::default())
});
let ctx = h.ctx.clone();
let (r, _) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}}));
crate::control::handle(h.state_mut(), &ctx, &r);
h.run_steps(8);
let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == id.0).unwrap().row;
double_click(&mut h, pos2(row.left() + 46.0, row.center().y));
let app = h.state_mut();
assert_eq!(app.session.active().unwrap().active_layer, Some(id));
let before = app.session.active().unwrap().doc.clone();
let steps = app.session.active().unwrap().history.entries().len();
let dialog = app.ui.dialogs.last().filter(|d| owns(&d.fields)).expect("Color Picker opened on the shape fill");
assert_eq!(dialog.fields["color"], "#336699");
let dialog = dialog.id;
// Picking previews on the canvas without touching the document; Cancel drops it.
app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800"));
let (shown, _) = display_doc(app, 0).unwrap();
let LayerContent::Shape(sh) = &shown.layer(id).unwrap().content else { panic!() };
assert_eq!(sh.fill, Some(Fill::Solid(Color::rgb(1.0, 136.0 / 255.0, 0.0))));
app.ui.close_dialog(dialog);
assert!(display_doc(app, 0).is_none());
assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc), "Cancel leaves the shape alone");
// OK commits the colour as one undo step.
let dialog = open(app).unwrap();
app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800"));
crate::dialogs::confirm(app, dialog).unwrap();
assert_eq!(fill(app, id), Some(Fill::Solid(Color::rgb(1.0, 136.0 / 255.0, 0.0))));
assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(*app.session.active().unwrap().doc, *before);
}