mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-26 10:57:20 -06:00
* feat(engine): view.editGuide changes a guide in one step Photoshop's Edit Selected Guides (the guide context menu) reopens New Guide on the selected guide. view.editGuide takes the guide by orientation and index and changes whatever is given: "newOrientation", "position" (pixels or a length with a unit, % of the new orientation's extent) and "color" (a name or "#rrggbb"), as one undoable "Edit Guide" step. A guide that keeps its orientation keeps its index; one that turns goes to the end of the other list. Without "color" the guide keeps its own colour, or none. Bad params are refused without a history step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ui): select guides, the guide context menu and Edit Selected Guides As in Photoshop, a click on a guide with the Move tool (or ⌘ with a selection tool) selects it, drawn in Photoshop's selected-guide blue (#0000ff); a press anywhere else deselects it, and Delete or Backspace deletes it, before Edit › Clear can reach the pixels. A guide that is dragged stays selected, and a click no longer nudges a guide to the pointer. One guide at a time. Right-click on a guide selects it and opens Photoshop's guide menu: Edit Selected Guides, Lock Guides, Clear Guides, Clear Canvas Guides, Clear Selected Guides and Clear Selected Artboard Guides. Edit Selected Guides opens the New Guide dialog on the guide (its orientation, its position in the ruler unit and its colour, Cyan for a guide dragged from a ruler) and OK runs view.editGuide, sending the colour only when it was changed. The selection is view state (UiState.selected_guide, readable over the control channel), keyed by document, orientation and position like guide colours, so it lapses by itself when its guide goes. The menu's two new labels are in every catalogue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ui): Alt-click turns a guide; double-click opens the guide preferences As in Photoshop, with the Move tool: - Alt-clicking a guide turns it: a vertical guide becomes horizontal through the point clicked, and the other way round. It keeps its colour and stays selected; one undoable Edit Guide step (view.editGuide). Locked or hidden guides can't be turned. - Double-clicking a guide opens Preferences › Guides, Grid & Slices. docs/ui-parity.md lists the guide interactions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(i18n): Hungarian and Turkish strings for the guide menu Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): the spelling rows call engine_call with the app main's spelling suggestions (choose_spelling) came in calling engine_call(menu, command); on this branch engine_call takes the app first (the guide menu needs it), so the merge didn't compile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * i18n(sr): translate the guide context menu items Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): give main's move-hover test guide drag a press point Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
220 lines
10 KiB
Rust
220 lines
10 KiB
Rust
//! Move tool › Show Highlight on Rollover (Preferences › Tools, on by default): with Auto-Select
|
||
//! on, or ⌘ held while it is off, hovering the canvas outlines the layer (or group, per the
|
||
//! Auto-Select target) that a click there would pick. The hit test is the click's own
|
||
//! (`pick_cmds::auto_select_target`), so a type layer is outlined anywhere inside its text, as it
|
||
//! is picked there (#2591). The answer is cached per document revision and pointer pixel: a
|
||
//! pointer resting on the canvas costs nothing per frame.
|
||
|
||
use egui::{Pos2, Stroke};
|
||
use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId};
|
||
use photocraft_geom::Rect;
|
||
|
||
use crate::PhotocraftApp;
|
||
use crate::canvas::ViewXform;
|
||
use crate::state::Tool;
|
||
|
||
/// The last answer: what a click at `at` on that revision of the document would outline.
|
||
pub(crate) struct Hover {
|
||
doc: DocId,
|
||
revision: u64,
|
||
at: [i32; 2],
|
||
group: bool,
|
||
target_layer: Option<LayerId>,
|
||
bounds: Option<Rect>,
|
||
}
|
||
|
||
/// The document-space bounds to outline with the pointer at `at` (document px), or `None` when
|
||
/// nothing is highlighted: the preference is off, another tool is current, a click wouldn't
|
||
/// Auto-Select (it's off without ⌘, or the selected pixels move instead), a button is down, or a
|
||
/// drag or transform is under way.
|
||
pub(crate) fn target(app: &mut PhotocraftApp, at: Option<[f64; 2]>, command: bool, pressed: bool) -> Option<Rect> {
|
||
if !app.session.prefs().tools.show_highlight_on_rollover
|
||
|| app.ui.tool != Tool::Move
|
||
|| pressed
|
||
|| app.drag.is_some()
|
||
|| app.guide_drag.is_some()
|
||
|| app.ui.transform.is_some()
|
||
|| app.ui.tool_options.move_auto_select == command
|
||
|| crate::move_ui::moves_selected_pixels_with(app, Tool::Move)
|
||
{
|
||
app.canvas_hover_layer = None;
|
||
return None;
|
||
}
|
||
let Some(at) = at.filter(|p| p[0].is_finite() && p[1].is_finite()) else {
|
||
app.canvas_hover_layer = None;
|
||
return None;
|
||
};
|
||
let at = [at[0].floor() as i32, at[1].floor() as i32];
|
||
let group = app.ui.tool_options.move_target == "group";
|
||
let st = app.session.active()?;
|
||
let (doc, revision) = (st.doc.id, st.revision);
|
||
if let Some(h) = app.move_hover.as_ref().filter(|h| h.doc == doc && h.revision == revision && h.at == at && h.group == group) {
|
||
app.canvas_hover_layer = h.target_layer;
|
||
return h.bounds;
|
||
}
|
||
let target_layer = photocraft_engine::pick_cmds::auto_select_target(&st.doc, at[0], at[1], group);
|
||
let bounds = target_layer.and_then(|id| outline(&st.doc, id));
|
||
let target_layer = if bounds.is_some() { target_layer } else { None };
|
||
app.move_hover = Some(Hover { doc, revision, at, group, target_layer, bounds });
|
||
app.canvas_hover_layer = target_layer;
|
||
bounds
|
||
}
|
||
|
||
/// The outline of a picked layer: its visible content (a group's, its visible layers' together).
|
||
/// Not the Background: it fills the canvas, and the Move tool doesn't move it.
|
||
fn outline(doc: &Document, id: LayerId) -> Option<Rect> {
|
||
let rows = doc.walk();
|
||
let (prefix, _, top) = rows.iter().find(|(_, _, l)| l.id == id)?;
|
||
if crate::doc_props_ui::is_background(doc, top) {
|
||
return None;
|
||
}
|
||
let b = rows
|
||
.iter()
|
||
.filter(|(p, _, _)| p.starts_with(prefix) && (prefix.len()..=p.len()).all(|n| p.get(..n).and_then(|q| doc.layer_at(q)).is_some_and(|l| l.visible)))
|
||
.fold(Rect::EMPTY, |acc, (_, _, l)| acc.union(&bounds(doc, l)));
|
||
(!b.is_empty()).then_some(b)
|
||
}
|
||
|
||
fn bounds(doc: &Document, l: &Layer) -> Rect {
|
||
if let Some(a) = l.artboard() {
|
||
return a.rect;
|
||
}
|
||
let content = |s| photocraft_compose::bounds::content_bounds(s);
|
||
match &l.content {
|
||
LayerContent::Group(_) | LayerContent::Adjustment(_) => Rect::EMPTY,
|
||
// A fill layer covers the canvas; its mask limits it.
|
||
LayerContent::Fill(_) => l.mask.as_ref().filter(|m| m.enabled).map_or(doc.bounds(), |m| content(&m.surface)),
|
||
_ => l.surface().map_or(Rect::EMPTY, content),
|
||
}
|
||
}
|
||
|
||
/// The document-space bounds to outline: either a layer hovered in the Layers panel, or what a
|
||
/// click at `at` would pick with the Move tool.
|
||
pub(crate) fn active_outline(app: &mut PhotocraftApp, at: Option<[f64; 2]>, command: bool, pressed: bool) -> Option<Rect> {
|
||
if !app.session.prefs().tools.show_highlight_on_rollover {
|
||
app.canvas_hover_layer = None;
|
||
return None;
|
||
}
|
||
// A live rectangle's corner widget under the pointer (or being dragged) isn't a layer to pick.
|
||
if at.is_some_and(|p| crate::corner_radius::hovers(app, p)) {
|
||
app.canvas_hover_layer = None;
|
||
return None;
|
||
}
|
||
// A layer hovered in the Layers panel takes precedence over canvas rollover.
|
||
if let Some(id) = app.hover_layer
|
||
&& app.drag.is_none()
|
||
&& app.guide_drag.is_none()
|
||
&& app.ui.transform.is_none()
|
||
&& let Some(st) = app.session.active()
|
||
{
|
||
return outline(&st.doc, id);
|
||
}
|
||
target(app, at, command, pressed)
|
||
}
|
||
|
||
/// Draws the highlight on the active document's canvas, in the theme's accent colour.
|
||
pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, hover: Option<Pos2>) {
|
||
if app.session.active_index() != Some(idx) {
|
||
return;
|
||
}
|
||
let prev_canvas_hover = app.canvas_hover_layer;
|
||
let (mods, pressed) = painter.ctx().input(|i| (i.modifiers, i.pointer.any_down()));
|
||
let command = crate::workspace_ui::sticky_mods(app, mods).command;
|
||
let at = hover.map(|p| xf.to_doc(p));
|
||
let outline_rect = active_outline(app, at, command, pressed);
|
||
if app.canvas_hover_layer != prev_canvas_hover {
|
||
painter.ctx().request_repaint();
|
||
}
|
||
let Some(b) = outline_rect else { return };
|
||
let accent = crate::theme::Tokens::get(painter.ctx()).accent;
|
||
painter.rect_stroke(xf.doc_rect(b), 0.0, Stroke::new(1.5, accent), egui::StrokeKind::Outside);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use serde_json::json;
|
||
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn rollover_outlines_the_layer_a_click_would_pick() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||
app.session
|
||
.edit("paint", |doc, a| {
|
||
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(8, 8, 24, 24), &[1.0, 0.0, 0.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||
app.ui.tool = Tool::Move;
|
||
let a = Rect::new(8, 8, 24, 24);
|
||
// The empty top layer is clicked through to the painted one; the Background isn't outlined.
|
||
assert_eq!(target(&mut app, Some([10.5, 12.0]), false, false), Some(a));
|
||
assert_eq!(target(&mut app, Some([40.0, 40.0]), false, false), None);
|
||
// Nothing while a button is down or a drag runs.
|
||
assert_eq!(target(&mut app, Some([10.5, 12.0]), false, true), None);
|
||
// Auto-Select off: only with ⌘ held.
|
||
app.ui.tool_options.move_auto_select = false;
|
||
assert_eq!(target(&mut app, Some([10.5, 12.0]), false, false), None);
|
||
assert_eq!(target(&mut app, Some([10.5, 12.0]), true, false), Some(a));
|
||
// Preferences › Tools › Show Highlight on Rollover off.
|
||
app.session.edit_prefs(|p| p.tools.show_highlight_on_rollover = false);
|
||
assert_eq!(target(&mut app, Some([10.5, 12.0]), true, false), None);
|
||
}
|
||
|
||
#[test]
|
||
fn layer_panel_hover_outlines_layer_on_canvas() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||
app.session
|
||
.edit("paint", |doc, a| {
|
||
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(8, 8, 24, 24), &[1.0, 0.0, 0.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let target_layer = app.session.active().unwrap().active_layer.unwrap();
|
||
let a = Rect::new(8, 8, 24, 24);
|
||
|
||
// Even with a different tool (e.g. Brush) and no canvas hover, hovering the layer in the panel outlines it:
|
||
app.ui.tool = Tool::Brush;
|
||
assert_eq!(active_outline(&mut app, None, false, false), None);
|
||
app.hover_layer = Some(target_layer);
|
||
assert_eq!(active_outline(&mut app, None, false, false), Some(a));
|
||
|
||
// When a drag or transform is in progress, no outline:
|
||
app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0 });
|
||
assert_eq!(active_outline(&mut app, None, false, false), None);
|
||
app.guide_drag = None;
|
||
|
||
// When rollover preference is disabled, no outline:
|
||
app.session.edit_prefs(|p| p.tools.show_highlight_on_rollover = false);
|
||
assert_eq!(active_outline(&mut app, None, false, false), None);
|
||
}
|
||
|
||
#[test]
|
||
fn canvas_hover_sets_canvas_hover_layer() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||
app.session.execute("layer.new.layer", json!({})).unwrap();
|
||
app.session
|
||
.edit("paint", |doc, a| {
|
||
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(8, 8, 24, 24), &[1.0, 0.0, 0.0, 1.0]);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
let target_layer = app.session.active().unwrap().active_layer.unwrap();
|
||
app.ui.tool = Tool::Move;
|
||
|
||
// Hovering over painted layer sets canvas_hover_layer:
|
||
assert!(target(&mut app, Some([10.5, 12.0]), false, false).is_some());
|
||
assert_eq!(app.canvas_hover_layer, Some(target_layer));
|
||
|
||
// Hovering empty area clears canvas_hover_layer:
|
||
assert!(target(&mut app, Some([40.0, 40.0]), false, false).is_none());
|
||
assert_eq!(app.canvas_hover_layer, None);
|
||
}
|
||
}
|