Compare commits

...
Author SHA1 Message Date
Claude 8186dd8d67 Merge main into issue-970-blend-hover-preview (main's automation test fixed in #1074) 2026-10-08 18:20:45 +00:00
bflatasticandClaude Opus 5.5 40b23067e0 Layers panel: hovering a blend mode previews it on the canvas (#970)
While the Layers panel's blend-mode list is open, the mode under the pointer
shows live on the active layer, as in Photoshop. Hovering records nothing;
leaving the list or closing it without a choice shows the document again,
and choosing a mode commits it through layer.setProps (one history step).

The preview is one more step of canvas::display_doc (like the Move and Patch
previews): one cloned document per hovered mode, cached, and the canvas
redraws only the layer's change bounds between previews and the document.
widgets::dropdown_hovered reports the hovered option; dropdown wraps it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 12:59:43 +07:00
6 changed files with 231 additions and 4 deletions

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
//! Layers panel: hovering a mode in the open blend-mode list previews it on the active layer
//! (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or
//! closing it without a choice, shows the document again, and choosing a mode commits it through
//! `layer.setProps` (one history step). The canvas re-renders once per hovered mode, and only
//! where the layer's blending can change pixels.
use std::sync::Arc;
use photocraft_color::BlendMode;
use photocraft_doc::{DocId, Document, LayerId};
use photocraft_geom::Rect;
use crate::PhotocraftApp;
/// Preview keys (`canvas::display_doc`) are `BASE` plus a hash of the revision and the mode.
const BASE: u64 = 1 << 37;
/// A blend mode hovered for a layer of a document at a revision.
pub(crate) struct BlendPreview {
doc: DocId,
revision: u64,
layer: LayerId,
/// The mode under the pointer; `None` once it left the list (the rest stays for the
/// canvas's damage on its way back to the document).
mode: Option<BlendMode>,
/// The frame the list was last drawn in: a list no longer drawn previews nothing.
frame: u64,
/// Where the layer's blending can change pixels; `None` for anywhere.
area: Option<Rect>,
/// The document shown for `mode`, with its key.
shown: Option<(u64, Arc<Document>)>,
}
/// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the
/// pointer in its open list, if any.
pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option<BlendMode>) {
let frame = app.frame;
let Some(st) = app.session.active() else { return };
let (doc, revision) = (st.doc.id, st.revision);
if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layer == layer) {
p.frame = frame;
if mode.is_none() {
p.shown = None;
}
p.mode = mode;
return;
}
app.blend_preview = mode.and_then(|mode| {
let area = photocraft_compose::change_bounds(st.doc.layer(layer)?, st.doc.bounds());
Some(BlendPreview { doc, revision, layer, mode: Some(mode), frame, area, shown: None })
});
}
/// The document to show while a blend mode is hovered in document `idx` (`canvas::display_doc`).
pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
let frame = app.frame;
let st = app.session.documents().get(idx)?;
let p = app.blend_preview.as_mut().filter(|p| p.doc == st.doc.id && p.revision == st.revision && p.frame + 1 >= frame)?;
let mode = p.mode?;
let key = preview_key(p.revision, p.layer, mode);
if let Some((k, shown)) = &p.shown
&& *k == key
{
return Some((shown.clone(), key));
}
let mut doc = (*st.doc).clone();
let l = doc.layer_mut(p.layer).filter(|l| l.blend != mode)?;
l.blend = mode;
// Duotone documents display through their inks.
let doc = Arc::new(photocraft_engine::mode_cmds::display_document(&doc).unwrap_or(doc));
p.shown = Some((key, doc.clone()));
Some((doc, key))
}
/// What changed between preview (or document) key `seen` and `now` of document `doc` at
/// `revision`: the hovered layer's area. `None` when either key is another kind of preview, or
/// the layer can change anything (the canvas then recomposites everything).
pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, now: u64) -> Option<Rect> {
let p = app.blend_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision)?;
let ours = |k: u64| k == 0 || is_preview_key(k);
(seen != now && ours(seen) && ours(now) && (is_preview_key(seen) || is_preview_key(now))).then_some(p.area)?
}
fn is_preview_key(key: u64) -> bool {
(BASE..BASE << 1).contains(&key)
}
fn preview_key(revision: u64, layer: LayerId, mode: BlendMode) -> u64 {
let h = mode.label().bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
(h & (BASE - 1)) | BASE
}
#[cfg(test)]
mod tests;
+106
View File
@@ -0,0 +1,106 @@
//! #970: hovering a blend mode in the Layers panel previews it on the canvas; only a choice
//! records a history step.
use egui::accesskit::Role;
use egui::{PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_color::BlendMode;
use photocraft_doc::LayerId;
use serde_json::json;
use super::{damage, display_doc, hover};
use crate::PhotocraftApp;
/// Background and a filled layer `top` (active).
fn session() -> (photocraft_engine::Session, LayerId) {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let top = s.execute("layer.new.layer", json!({"name": "top"})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.rect", json!({"x": 8, "y": 8, "width": 16, "height": 16})).unwrap();
s.execute("edit.fill", json!({"color": "#cc3366"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
(s, LayerId(top))
}
fn blend(app: &PhotocraftApp, id: LayerId) -> BlendMode {
app.session.active().unwrap().doc.layer(id).unwrap().blend
}
fn steps(app: &PhotocraftApp) -> usize {
app.session.active().unwrap().history.past_len()
}
#[test]
fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() {
let (s, top) = session();
let mut app = PhotocraftApp::new(s, crate::Services::default());
let (undo, revision) = (steps(&app), app.session.active().unwrap().revision);
hover(&mut app, top, Some(BlendMode::Multiply));
let (shown, key) = display_doc(&mut app, 0).expect("previewed");
assert_eq!(shown.layer(top).unwrap().blend, BlendMode::Multiply);
let (again, same) = display_doc(&mut app, 0).unwrap();
assert!(std::sync::Arc::ptr_eq(&shown, &again) && key == same, "one document per hovered mode, not one per frame");
assert_eq!((blend(&app, top), steps(&app)), (BlendMode::Normal, undo), "hovering records nothing");
// From the document to the preview and between previews: only the layer's area redraws.
let doc = app.session.active().unwrap().doc.id;
assert_eq!(damage(&app, doc, revision, 0, key).map(|r| r.width()), Some(16));
hover(&mut app, top, Some(BlendMode::Screen));
let (_, screen) = display_doc(&mut app, 0).unwrap();
assert_ne!(screen, key);
assert!(damage(&app, doc, revision, key, screen).is_some());
// The current mode, or no mode, shows the document; and so does a list no longer drawn.
hover(&mut app, top, Some(BlendMode::Normal));
assert!(display_doc(&mut app, 0).is_none());
hover(&mut app, top, None);
assert!(display_doc(&mut app, 0).is_none());
assert!(damage(&app, doc, revision, screen, 0).is_some(), "back to the document: the layer's area");
hover(&mut app, top, Some(BlendMode::Multiply));
app.frame += 2;
assert!(display_doc(&mut app, 0).is_none(), "a stale hover previews nothing");
}
fn harness(session: photocraft_engine::Session) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(session, crate::Services::default())
});
h.run_steps(8);
h
}
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) {
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
}
fn previewed(h: &mut Harness<'_, PhotocraftApp>) -> Option<BlendMode> {
display_doc(h.state_mut(), 0).map(|(d, _)| d.layers.last().unwrap().blend)
}
#[test]
fn hovering_the_layers_panel_blend_list_previews_and_a_click_commits() {
let (s, top) = session();
let mut h = harness(s);
let undo = steps(h.state());
// The Layers panel's dropdown: the lowest one (the options bar and the title bar have some too).
let dropdown = h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).max_by(|a, b| a.top().total_cmp(&b.top())).unwrap();
click(&mut h, dropdown.center());
let multiply = h.get_by_label("Multiply").rect().center();
h.hover_at(multiply);
h.run_steps(3);
assert_eq!(previewed(&mut h), Some(BlendMode::Multiply), "the hovered mode shows");
assert_eq!((blend(h.state(), top), steps(h.state())), (BlendMode::Normal, undo), "no edit while hovering");
h.hover_at(Pos2::new(400.0, 400.0));
h.run_steps(3);
assert_eq!(previewed(&mut h), None, "off the list: the document again");
let screen = h.get_by_label("Screen").rect().center();
click(&mut h, screen);
assert_eq!(blend(h.state(), top), BlendMode::Screen);
assert_eq!(steps(h.state()), undo + 1, "choosing records one step");
assert_eq!(previewed(&mut h), None);
}
+11
View File
@@ -492,6 +492,10 @@ fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>
if let Some(shown) = crate::patch_preview::display_doc(app, idx) {
return shown;
}
// A blend mode hovered in the Layers panel.
if let Some(shown) = crate::blend_preview::display_doc(app, idx) {
return shown;
}
let st = &app.session.documents()[idx];
if let Some(l) = live_stroke(app, idx) {
return (l.stroke.doc.clone(), l.display_key());
@@ -694,6 +698,13 @@ fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u6
{
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
}
// Between hovered blend modes (and the document): where the layer's blending shows.
if seen.0 == now.0
&& let Some(st) = app.session.documents().get(idx)
&& let Some(r) = crate::blend_preview::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
{
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
}
let l = live_stroke(app, idx).filter(|l| seen.0 == now.0 && l.display_key() == now.1)?;
let r = l.since(seen.1 ^ display_key)?;
Some(if r.is_empty() { r } else { r.inflate(effect_reach(&l.stroke.doc.layers)) })
+4
View File
@@ -21,6 +21,7 @@ pub mod adjust_preview;
pub mod adjust_ui;
pub mod analysis_ui;
pub mod artboard_ui;
pub(crate) mod blend_preview;
mod brand;
pub mod brush_panel;
pub mod brush_picker;
@@ -311,6 +312,8 @@ pub struct PhotocraftApp {
trail: Option<stroke_trail::Trail>,
/// Move tool drag shown live (`move_ui`).
pub(crate) move_preview: Option<move_ui::MovePreview>,
/// A blend mode hovered in the Layers panel, shown live (`blend_preview`).
pub(crate) blend_preview: Option<blend_preview::BlendPreview>,
/// Patch Tool drag: the healed document at the pointer (`patch_preview`).
pub(crate) patch_preview: Option<patch_preview::PatchPreview>,
/// The pixels a Magnetic Lasso border follows (`magnetic_lasso_ui`).
@@ -469,6 +472,7 @@ impl PhotocraftApp {
live_stroke: None,
trail: None,
move_preview: None,
blend_preview: None,
patch_preview: None,
magnetic: Default::default(),
secondary_erase: false,
+4 -1
View File
@@ -1415,9 +1415,12 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") };
let right = (body_text_width(ui, opacity_label) + 66.0 + 2.0 * ui.spacing().item_spacing.x + 16.0).max(150.0);
let w = ui.available_width() - right;
if widgets::dropdown(ui, "blend", &mut m, &blend_options(l.is_group()), w.max(100.0)) {
let (chosen, hovered) = widgets::dropdown_hovered(ui, "blend", &mut m, &blend_options(l.is_group()), w.max(100.0));
if chosen {
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()})));
}
// Hovering a mode previews it on the canvas (#970).
crate::blend_preview::hover(app, l.id, hovered.filter(|_| !chosen));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut o = l.opacity * 100.0;
if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 66.0).changed() {
+12 -3
View File
@@ -510,17 +510,26 @@ pub fn hue_stops() -> Vec<Color32> {
/// A compact labelled dropdown in the studio style.
pub fn dropdown<T: PartialEq + Clone>(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> bool {
dropdown_hovered(ui, id, current, options, width).0
}
/// [`dropdown`], also returning the option under the pointer in its open list (live previews).
pub fn dropdown_hovered<T: PartialEq + Clone>(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str)], width: f32) -> (bool, Option<T>) {
let label = options.iter().find(|(v, _)| v == current).map(|(_, l)| tl!(l)).unwrap_or("—");
let mut changed = false;
let (mut changed, mut hovered) = (false, None);
egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).show_ui(ui, |ui| {
for (v, l) in options {
if ui.selectable_label(v == current, tl!(l)).clicked() {
let item = ui.selectable_label(v == current, tl!(l));
if item.hovered() {
hovered = Some(v.clone());
}
if item.clicked() {
*current = v.clone();
changed = true;
}
}
});
changed
(changed, hovered)
}
/// The body of a right-click menu: as tall as its items up to the part of the window that can be