mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 13:44:11 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8186dd8d67 | ||
|
|
40b23067e0 |
No files matched your search
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)) })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user