mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 18:52:15 +01:00
Edit > Preferences > Enhanced Controls > Enable Gestures (enhancedControls.touchGestures) was saved but read by nothing, so it was hidden from the dialog. Now, with it off, two fingers on a touch screen no longer zoom the canvas or the Navigator, nor turn the view. The mouse and trackpad behave as before, including a trackpad pinch or Rotate event that arrives while fingers rest on the screen. - wheel_nav: pinch_factor() drops egui's multi-touch zoom when gestures are off and keeps the trackpad's own Event::Zoom (Windows' folded Ctrl + wheel pinch included); read_with() takes the preference. - rotate_view: trackpad_delta() only falls back to the multi-touch rotation while gestures are on; Rotate events always count. - canvas, Navigator: pass the preference through. - prefs: touchGestures leaves HIDDEN_UNTIL_IMPLEMENTED, so the dialog shows it. - Tests: synthetic two-finger pinch and twist, on and off, and the trackpad events with gestures off. - docs/scorecard.md regenerated: settings that do nothing 26 -> 25 of 168.
6696 lines
354 KiB
Rust
6696 lines
354 KiB
Rust
//! Chrome around the canvas: title bar, options bar, toolbar, status bar, dock cards, Properties.
|
||
|
||
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
|
||
use photocraft_color::BlendMode;
|
||
use photocraft_doc::{Layer, LayerContent, LayerId};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::mask_thumbs_ui::MaskKind;
|
||
use crate::state::Tool;
|
||
use crate::theme::{self, Tokens};
|
||
use crate::{PhotocraftApp, icons, widgets};
|
||
|
||
// ----------------------------------------------------------------------------- toolbar
|
||
|
||
/// Tool groups separated by hairlines, Photoshop order.
|
||
/// Photoshop toolbar: divider-separated sections of slots; each slot is a flyout group whose button
|
||
/// shows the group's last-used tool.
|
||
const TOOL_SECTIONS: &[&[&[Tool]]] = &[
|
||
&[&[Tool::Move, Tool::Artboard]],
|
||
&[
|
||
&[Tool::RectMarquee, Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee],
|
||
&[Tool::Lasso, Tool::PolygonLasso, Tool::MagneticLasso],
|
||
&[Tool::ObjectSelection, Tool::QuickSelection, Tool::MagicWand],
|
||
&[Tool::Crop, Tool::PerspectiveCrop, Tool::Slice, Tool::SliceSelect],
|
||
&[Tool::Frame],
|
||
&[Tool::Eyedropper, Tool::ColorSampler, Tool::Ruler, Tool::Note, Tool::Count],
|
||
],
|
||
&[
|
||
&[Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye],
|
||
&[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement],
|
||
&[Tool::CloneStamp, Tool::PatternStamp],
|
||
&[Tool::HistoryBrush],
|
||
&[Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser],
|
||
&[Tool::Gradient, Tool::PaintBucket],
|
||
&[Tool::Blur, Tool::Sharpen, Tool::Smudge],
|
||
&[Tool::Dodge, Tool::Burn, Tool::Sponge],
|
||
],
|
||
&[
|
||
&[Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::ConvertPoint],
|
||
&[Tool::Type, Tool::VerticalType, Tool::VerticalTypeMask, Tool::HorizontalTypeMask],
|
||
&[Tool::PathSelection, Tool::DirectSelection],
|
||
&[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape],
|
||
],
|
||
&[&[Tool::Hand, Tool::RotateView], &[Tool::Zoom]],
|
||
];
|
||
|
||
/// The tool a slot shows: the current tool if it belongs to the slot, else the last one used.
|
||
fn slot_tool(ui: &egui::Ui, current: Tool, slot: &[Tool], key: egui::Id) -> Tool {
|
||
if slot.contains(¤t) {
|
||
ui.data_mut(|d| d.insert_temp(key, current));
|
||
return current;
|
||
}
|
||
ui.data(|d| d.get_temp::<Tool>(key)).filter(|t| slot.contains(t)).unwrap_or(slot[0])
|
||
}
|
||
|
||
/// [`TOOL_SECTIONS`] without the tools hidden in Edit › Toolbar (`hidden` holds tool names: the
|
||
/// dialog writes `Tool` debug names, `edit.toolbar` takes any name `Tool::from_name` reads). A
|
||
/// slot keeps only its visible tools, so its flyout lists just those; a slot with none left is
|
||
/// dropped, and so is a section with no slots. Each slot carries its index in `TOOL_SECTIONS`,
|
||
/// so its remembered tool and flyout stay put when other slots are hidden.
|
||
fn visible_sections(hidden: &[String]) -> Vec<Vec<(usize, Vec<Tool>)>> {
|
||
let hidden: Vec<Tool> = hidden.iter().map(String::as_str).filter_map(Tool::from_name).collect();
|
||
let mut index = 0usize;
|
||
let mut sections = Vec::new();
|
||
for section in TOOL_SECTIONS {
|
||
let mut slots = Vec::new();
|
||
for slot in section.iter() {
|
||
let tools: Vec<Tool> = slot.iter().copied().filter(|tool| !hidden.contains(tool)).collect();
|
||
if !tools.is_empty() {
|
||
slots.push((index, tools));
|
||
}
|
||
index += 1;
|
||
}
|
||
if !slots.is_empty() {
|
||
sections.push(slots);
|
||
}
|
||
}
|
||
sections
|
||
}
|
||
|
||
pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (w1, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) };
|
||
let sections = visible_sections(&app.session.prefs().toolbar.hidden);
|
||
// Two columns when the header chevron asks for them; a column that doesn't fit scrolls.
|
||
let double = app.ui.panels.toolbar_double;
|
||
let w = if double { w1 + bx + 2.0 } else { w1 };
|
||
crate::direction::start_panel("toolbar")
|
||
.resizable(false)
|
||
.exact_size(w)
|
||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(m, 8)))
|
||
.show(ui, |ui| {
|
||
// Toolbar width is a user preference, not a Pro-only feature.
|
||
// Keep the same hit target in every theme (including RTL layouts).
|
||
let (cr, resp) = ui.allocate_exact_size(vec2(bx, 14.0), Sense::click());
|
||
let (shrink, grow) = if crate::direction::is_rtl() { ("chevrons-right", "chevrons-left") } else { ("chevrons-left", "chevrons-right") };
|
||
icons::paint(ui, cr, if double { shrink } else { grow }, 11.0, if resp.hovered() { t.text } else { t.text_faint });
|
||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Toolbar")));
|
||
if resp.clicked() {
|
||
app.ui.panels.toolbar_double = !double;
|
||
}
|
||
ui.add_space(4.0);
|
||
// Subtle violet wash at the bottom of the toolbar.
|
||
let full = ui.max_rect();
|
||
if !t.bevel && !t.pro && t.dark() {
|
||
let mut mesh = egui::Mesh::default();
|
||
let r = Rect::from_min_max(pos2(full.left() - 7.0, full.bottom() - 260.0), pos2(full.right() + 7.0, full.bottom() + 8.0));
|
||
let top = Color32::TRANSPARENT;
|
||
let bottom = Color32::from_rgba_unmultiplied(90, 70, 190, 34);
|
||
mesh.colored_vertex(r.left_top(), top);
|
||
mesh.colored_vertex(r.right_top(), top);
|
||
mesh.colored_vertex(r.right_bottom(), bottom);
|
||
mesh.colored_vertex(r.left_bottom(), bottom);
|
||
mesh.add_triangle(0, 1, 2);
|
||
mesh.add_triangle(0, 2, 3);
|
||
ui.painter().add(mesh);
|
||
}
|
||
ui.spacing_mut().item_spacing = vec2(2.0, 3.0);
|
||
let flyout_id = egui::Id::new("tool-flyout");
|
||
let held_id = flyout_id.with("held");
|
||
if ui.input(|i| i.pointer.any_pressed()) {
|
||
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
|
||
}
|
||
// No tool tooltips over an open flyout, or while a button is held to open one (#4450):
|
||
// Photoshop shows none there. (The flyout is our own Area, so egui's rule for popups
|
||
// doesn't hide them.)
|
||
let tool_tips = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_none() && !ui.input(|i| i.pointer.primary_down());
|
||
let ctx = ui.ctx().clone();
|
||
let footer = (if t.pro { 8.0 } else { 14.0 })
|
||
+ chips_height(t.pro, t.round_chips, t.round_chips && double)
|
||
+ if t.pro { 8.0 + if double { bx } else { bx * 2.0 + 3.0 } } else { 0.0 }
|
||
+ 6.0;
|
||
// As tall as the tools, so the colour chips sit right under them (Photoshop); a column
|
||
// taller than the window scrolls and keeps the chips at the bottom.
|
||
egui::ScrollArea::vertical()
|
||
.id_salt("toolbar-columns")
|
||
.auto_shrink([false, true])
|
||
.max_height((ui.available_height() - footer).max(0.0))
|
||
.min_scrolled_height(0.0)
|
||
.show(ui, |ui| {
|
||
// Pro has no group dividers, so its two columns fill every row across groups.
|
||
let sections: Vec<Vec<(usize, Vec<Tool>)>> = if t.pro { vec![sections.into_iter().flatten().collect()] } else { sections };
|
||
for (si, section) in sections.iter().enumerate() {
|
||
// Photoshop 2026 draws one uninterrupted column (no group dividers).
|
||
if si > 0 && !t.pro {
|
||
ui.add_space(4.0);
|
||
let (r, _) = ui.allocate_exact_size(vec2(if double { bx * 2.0 + 2.0 } else { bx }, 1.0), Sense::hover());
|
||
ui.painter().line_segment([r.left_center() + vec2(8.0, 0.0), r.right_center() - vec2(8.0, 0.0)], Stroke::new(1.0, t.separator));
|
||
ui.add_space(4.0);
|
||
}
|
||
let rows: Vec<&[(usize, Vec<Tool>)]> = if double { section.chunks(2).collect() } else { section.chunks(1).collect() };
|
||
for row in rows {
|
||
ui.horizontal(|ui| {
|
||
for (slot_index, slot) in row.iter() {
|
||
let key = egui::Id::new(("tool-slot", *slot_index));
|
||
let tool = slot_tool(ui, app.ui.tool, slot, key);
|
||
let sel = slot.contains(&app.ui.tool);
|
||
let tip = match crate::tool_shortcuts::shortcut(app, tool) {
|
||
Some(sc) => format!("{} ({})", tl!(tool.label()), crate::shortcuts::pretty(&sc)),
|
||
None => tl!(tool.label()).to_string(),
|
||
};
|
||
let resp =
|
||
icons::button_with_icon_size_tip(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip, tool_tips);
|
||
if slot.len() > 1 {
|
||
paint_toolbar_flyout_indicator(ui, &t, &resp);
|
||
}
|
||
if resp.clicked() && ui.data(|d| d.get_temp::<egui::Id>(held_id)) != Some(key) {
|
||
crate::tool_memory::select(app, tool);
|
||
}
|
||
// Double-clicking the Hand tool fits the image on screen (Photoshop).
|
||
if resp.double_clicked() && tool == Tool::Hand {
|
||
crate::tool_memory::select(app, tool);
|
||
// With no document open there is nothing to fit.
|
||
let _ = app.run("view.fitOnScreen", json!({}));
|
||
}
|
||
if resp.double_clicked() && tool == Tool::Zoom {
|
||
crate::tool_memory::select(app, tool);
|
||
let _ = app.run("view.actualPixels", json!({}));
|
||
}
|
||
// Right-click or long-press opens the flyout (Photoshop).
|
||
let held_for =
|
||
resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten();
|
||
if slot.len() > 1
|
||
&& let Some(seconds) = held_for
|
||
&& seconds < 0.35
|
||
{
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds));
|
||
}
|
||
let long_press = held_for.is_some_and(|seconds| seconds >= 0.35);
|
||
// Clicking the button of its open flyout again, with either button, closes
|
||
// it (#4932); the release that ends the long press that opened it doesn't.
|
||
let open_here = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_some_and(|(k, _)| k == key);
|
||
let reclicked = open_here
|
||
&& (resp.secondary_clicked() || (resp.clicked() && ui.data(|d| d.get_temp::<egui::Id>(held_id)) != Some(key)));
|
||
if reclicked {
|
||
ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id));
|
||
} else if slot.len() > 1 && (resp.secondary_clicked() || long_press) {
|
||
ui.data_mut(|d| {
|
||
d.insert_temp(flyout_id, (key, resp.rect));
|
||
if long_press {
|
||
d.insert_temp(held_id, key);
|
||
}
|
||
});
|
||
}
|
||
if slot.len() > 1 && long_press {
|
||
// Holding a tool button selects the tool it shows, as a click would (#4453).
|
||
crate::tool_memory::select(app, tool);
|
||
ui.ctx().request_repaint();
|
||
}
|
||
if let Some((open_key, anchor)) = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id))
|
||
&& open_key == key
|
||
{
|
||
// Beside the tool, towards the canvas.
|
||
let (pivot, pos) = if crate::direction::is_rtl() {
|
||
(egui::Align2::RIGHT_TOP, anchor.left_top() - vec2(6.0, 0.0))
|
||
} else {
|
||
(egui::Align2::LEFT_TOP, anchor.right_top() + vec2(6.0, 0.0))
|
||
};
|
||
let area = egui::Area::new(key.with("flyout")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(pos).show(
|
||
ui.ctx(),
|
||
|ui| {
|
||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||
let label_w = slot
|
||
.iter()
|
||
.map(|it| {
|
||
ui.painter()
|
||
.layout_no_wrap(tl!(it.label()).to_string(), egui::FontId::proportional(12.5), t.text)
|
||
.size()
|
||
.x
|
||
})
|
||
.fold(0.0f32, f32::max);
|
||
let fw = (label_w + 42.0 + 40.0).max(180.0);
|
||
for &item in slot.iter() {
|
||
let (r, ir) = ui.allocate_exact_size(vec2(fw, 26.0), Sense::click());
|
||
if ir.hovered() {
|
||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||
}
|
||
if item == tool {
|
||
ui.painter().rect_filled(
|
||
Rect::from_center_size(pos2(r.left() + 8.0, r.center().y), vec2(4.0, 4.0)),
|
||
0.0,
|
||
t.text,
|
||
);
|
||
}
|
||
icons::paint(
|
||
ui,
|
||
Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(18.0, 18.0)),
|
||
icons::tool_icon(item),
|
||
14.0,
|
||
t.icon,
|
||
);
|
||
ui.painter().text(
|
||
pos2(r.left() + 42.0, r.center().y),
|
||
Align2::LEFT_CENTER,
|
||
tl!(item.label()),
|
||
egui::FontId::proportional(12.5),
|
||
t.text,
|
||
);
|
||
if let Some(sc) = crate::tool_shortcuts::shortcut(app, item) {
|
||
ui.painter().text(
|
||
pos2(r.right() - 8.0, r.center().y),
|
||
Align2::RIGHT_CENTER,
|
||
crate::shortcuts::pretty(&sc),
|
||
egui::FontId::proportional(12.0),
|
||
t.text_dim,
|
||
);
|
||
}
|
||
if ir.clicked() {
|
||
crate::tool_memory::select(app, item);
|
||
ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id));
|
||
}
|
||
}
|
||
});
|
||
},
|
||
);
|
||
// Any press outside the flyout and its button closes it, as in Photoshop: a
|
||
// drag on the canvas (cropping, painting) isn't a click (#4460).
|
||
let pressed_outside = ui.input(|i| {
|
||
i.pointer.any_pressed()
|
||
&& i.pointer.press_origin().is_some_and(|p| !area.response.rect.contains(p) && !resp.rect.contains(p))
|
||
});
|
||
if pressed_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
if t.pro && icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() {
|
||
let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({}));
|
||
}
|
||
reveal_focused_control(ui);
|
||
});
|
||
ui.add_space(if t.pro { 8.0 } else { 14.0 });
|
||
color_chips(app, ui, double);
|
||
if t.pro {
|
||
ui.add_space(8.0);
|
||
|
||
let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) };
|
||
|
||
ui.with_layout(layout, |ui| {
|
||
let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some());
|
||
if icons::button_with_icon_size_tip(
|
||
ui,
|
||
"square-dashed",
|
||
bx,
|
||
(bx * 0.52).round(),
|
||
quick_mask,
|
||
if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") },
|
||
tool_tips,
|
||
)
|
||
.clicked()
|
||
{
|
||
let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({}));
|
||
}
|
||
let sm = icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips);
|
||
paint_toolbar_flyout_indicator(ui, &t, &sm);
|
||
|
||
// Right-click, or click and hold like the tool flyouts (#4444), lists the modes,
|
||
// like Photoshop's flyout. The release that ends a hold doesn't cycle the mode.
|
||
let held_id = sm.id.with("held");
|
||
let was_held = ui.data(|d| d.get_temp::<bool>(held_id)).unwrap_or(false);
|
||
let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten();
|
||
if let Some(seconds) = held_for
|
||
&& seconds < 0.35
|
||
{
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds));
|
||
}
|
||
let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35);
|
||
if long_press {
|
||
ui.data_mut(|d| d.insert_temp(held_id, true));
|
||
} else if was_held && !sm.is_pointer_button_down_on() {
|
||
ui.data_mut(|d| d.remove::<bool>(held_id));
|
||
}
|
||
let cycle = sm.clicked() && !was_held;
|
||
if cycle {
|
||
let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({}));
|
||
}
|
||
let open = if sm.secondary_clicked() || long_press {
|
||
Some(egui::SetOpenCommand::Bool(true))
|
||
} else if cycle {
|
||
Some(egui::SetOpenCommand::Bool(false))
|
||
} else {
|
||
None
|
||
};
|
||
// The release ending the hold counts as a click; it mustn't close the list it opened.
|
||
let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick };
|
||
egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| {
|
||
ui.set_min_width(220.0);
|
||
for (id, label) in [
|
||
("view.screenMode.standard", tl!("Standard Screen Mode")),
|
||
("view.screenMode.fullScreenWithMenuBar", tl!("Full Screen Mode With Menu Bar")),
|
||
("view.screenMode.fullScreen", tl!("Full Screen Mode")),
|
||
] {
|
||
let on = crate::view_cmds::checked(app, id).unwrap_or(false);
|
||
if ui.add(egui::Button::selectable(on, label)).clicked() {
|
||
let _ = crate::menus::invoke(app, &ctx, id, json!({}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Glyph size of a toolbar tool button. Pro fills two thirds of the button, close to Photoshop's
|
||
/// toolbar glyphs (#2665); the other themes keep the general icon-button proportion.
|
||
fn toolbar_icon_size(box_size: f32, pro: bool) -> f32 {
|
||
(box_size * if pro { 2.0 / 3.0 } else { 0.52 }).round()
|
||
}
|
||
|
||
/// Paints a triangle on the bottom corner of an icon button that faces the canvas (bottom right,
|
||
/// bottom left in a right-to-left UI) to indicate that a toolbar item has a submenu.
|
||
pub fn paint_toolbar_flyout_indicator(ui: &mut egui::Ui, tokens: &Tokens, resp: &egui::Response) {
|
||
let r = resp.rect;
|
||
let tri = if crate::direction::is_rtl() {
|
||
vec![r.left_bottom() + vec2(2.0, -2.0), r.left_bottom() + vec2(6.0, -2.0), r.left_bottom() + vec2(2.0, -6.0)]
|
||
} else {
|
||
vec![r.right_bottom() + vec2(-2.0, -2.0), r.right_bottom() + vec2(-6.0, -2.0), r.right_bottom() + vec2(-2.0, -6.0)]
|
||
};
|
||
ui.painter().add(egui::Shape::convex_polygon(tri, tokens.text_faint, Stroke::NONE));
|
||
}
|
||
|
||
fn c32(c: [f32; 4]) -> Color32 {
|
||
Color32::from_rgba_unmultiplied((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8, (c[3] * 255.0) as u8)
|
||
}
|
||
|
||
/// Colour chips: chip size and the background chip's offset (points).
|
||
fn chip_metrics(pro: bool) -> (f32, f32) {
|
||
if pro { (18.0, 10.0) } else { (21.0, 12.0) }
|
||
}
|
||
|
||
/// Height of the default-colours and swap icons over the chips.
|
||
const CHIP_ICON: f32 = 13.0;
|
||
|
||
/// Studio themes: round chip diameter and the background chip's vertical offset (points).
|
||
const STUDIO_CHIP: (f32, f32) = (30.0, 19.0);
|
||
/// Room under the round chips for the Default Colors and Switch Colors buttons.
|
||
const STUDIO_ROW: f32 = 15.0;
|
||
/// Room above the stacked round chips so Default Colors, at their top-right, clears them.
|
||
const STUDIO_TOP: f32 = 6.0;
|
||
|
||
/// Height of the colour chips block.
|
||
fn chips_height(pro: bool, studio: bool, wide: bool) -> f32 {
|
||
if studio && wide {
|
||
return STUDIO_CHIP.0 + STUDIO_ROW;
|
||
}
|
||
if studio {
|
||
return STUDIO_TOP + STUDIO_CHIP.0 + STUDIO_CHIP.1 + STUDIO_ROW;
|
||
}
|
||
let (chip, step) = chip_metrics(pro);
|
||
CHIP_ICON + 3.0 + chip + step
|
||
}
|
||
|
||
/// Photoshop's foreground / background colour chips: the Default Colors (D) and Switch Colors (X)
|
||
/// icons above them, the foreground chip over the background one, all centred in the toolbar
|
||
/// column and kept inside it.
|
||
fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) {
|
||
let t = Tokens::get(ui.ctx());
|
||
// Studio themes: round chips stacked vertically, the controls below them.
|
||
let studio = t.round_chips;
|
||
let (chip, step) = if studio { STUDIO_CHIP } else { chip_metrics(t.pro) };
|
||
// With two tool columns (a short window) the round chips lie side by side, which saves height.
|
||
let wide = studio && two_columns;
|
||
let group = if studio && !wide { chip } else { chip + step };
|
||
let w = ui.available_width().max(group);
|
||
let (rect, _) = ui.allocate_exact_size(vec2(w, chips_height(t.pro, studio, wide)), Sense::hover());
|
||
let left = (rect.left() + (w - group) / 2.0).round();
|
||
let (fg, bg) = if wide {
|
||
let fg = Rect::from_min_size(pos2(left, rect.top()), vec2(chip, chip));
|
||
(fg, fg.translate(vec2(step, 0.0)))
|
||
} else if studio {
|
||
let fg = Rect::from_min_size(pos2(left, rect.top() + STUDIO_TOP), vec2(chip, chip));
|
||
(fg, fg.translate(vec2(0.0, step)))
|
||
} else {
|
||
let fg = Rect::from_min_size(pos2(left, rect.top() + CHIP_ICON + 3.0), vec2(chip, chip));
|
||
(fg, fg.translate(vec2(step, step)))
|
||
};
|
||
let radius = t.radius_sm.min(3.0);
|
||
let p = ui.painter();
|
||
if studio {
|
||
let dot = |r: Rect, color: Color32| {
|
||
p.circle_filled(r.center(), r.width() / 2.0, color);
|
||
// A light line inside a dark one, so any colour reads against the toolbar.
|
||
p.circle_stroke(r.center(), r.width() / 2.0 - 0.5, Stroke::new(1.0, t.text.gamma_multiply(0.9)));
|
||
p.circle_stroke(r.center(), r.width() / 2.0 + 0.5, Stroke::new(1.0, t.field_border));
|
||
};
|
||
dot(bg, c32(app.session.tools.background));
|
||
// The foreground chip sits on the background one, a toolbar-coloured gap between them.
|
||
p.circle_filled(fg.center(), fg.width() / 2.0 + 2.0, t.chrome);
|
||
dot(fg, c32(app.session.tools.foreground));
|
||
} else {
|
||
let frame = |r: Rect| {
|
||
// A light line inside a dark one, so any colour reads against the toolbar.
|
||
p.rect_stroke(r, radius, Stroke::new(1.0, t.text.gamma_multiply(0.9)), StrokeKind::Inside);
|
||
p.rect_stroke(r, radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||
};
|
||
p.rect_filled(bg, radius, c32(app.session.tools.background));
|
||
frame(bg);
|
||
p.rect_filled(fg.expand(2.0), radius + 2.0, t.chrome);
|
||
p.rect_filled(fg, radius, c32(app.session.tools.foreground));
|
||
frame(fg);
|
||
}
|
||
// Photoshop: clicking a chip opens the Color Picker for that colour.
|
||
let bg_resp = ui.interact(bg, ui.id().with("bgchip"), Sense::click());
|
||
let fg_resp = ui.interact(fg, ui.id().with("fgchip"), Sense::click());
|
||
if fg_resp.on_hover_text(tl!("Set foreground color")).clicked() {
|
||
crate::color_picker_ui::open(app, "foreground");
|
||
} else if bg_resp.on_hover_text(tl!("Set background color")).clicked() {
|
||
crate::color_picker_ui::open(app, "background");
|
||
}
|
||
// Default Colors at the left, Switch Colors at the right, over the chips.
|
||
let (dr, sr) = if studio {
|
||
// Switch Colors is a curve right under the chips, centred on them so its heads touch
|
||
// them. Default Colors never moves it: at the left edge beside it when the chips lie side
|
||
// by side, at the block's top-right corner when they are stacked.
|
||
let (cx, row) = ((fg.center().x + bg.center().x) / 2.0, fg.bottom().max(bg.bottom()) + 3.5);
|
||
let default = if wide {
|
||
Rect::from_center_size(pos2(rect.left() + CHIP_ICON / 2.0, row), vec2(CHIP_ICON, CHIP_ICON))
|
||
} else {
|
||
// Just high enough that its bottom-left corner clears the foreground chip's outline.
|
||
let x = rect.right() - CHIP_ICON;
|
||
let (dx, r) = ((x - fg.center().x).max(0.0), fg.width() / 2.0 + 1.0);
|
||
let clear = if dx < r { (r * r - dx * dx).sqrt() } else { 0.0 };
|
||
Rect::from_min_size(pos2(x, fg.center().y - clear - CHIP_ICON), vec2(CHIP_ICON, CHIP_ICON))
|
||
};
|
||
(Some(default), Rect::from_center_size(pos2(cx, row), vec2(20.0, 14.0)))
|
||
} else {
|
||
let icon = |x: f32| Rect::from_min_size(pos2(x, rect.top()), vec2(CHIP_ICON, CHIP_ICON));
|
||
(Some(icon(left)), icon(left + group - CHIP_ICON))
|
||
};
|
||
let d = dr.map(|dr| ui.interact(dr, ui.id().with("default-colors"), Sense::click()));
|
||
let s = ui.interact(sr, ui.id().with("swap-colors"), Sense::click());
|
||
let p = ui.painter();
|
||
for (r, resp) in dr.into_iter().zip(d.iter()).chain([(sr, &s)]) {
|
||
if resp.hovered() && !studio {
|
||
p.rect_filled(r.expand(2.0), radius, t.hover);
|
||
}
|
||
}
|
||
let ink = |resp: &egui::Response| if resp.hovered() { t.text } else { t.icon };
|
||
if studio {
|
||
// Switch Colors: a shallow curve hugging the bottom chip, with a filled head at each end.
|
||
// It glows on hover instead of getting a button box.
|
||
let hot = s.hovered();
|
||
let stroke = Stroke::new(1.5, ink(&s));
|
||
let (c, rad) = (pos2(sr.center().x, sr.center().y - 5.0), 9.0);
|
||
let (from, to) = (0.45, std::f32::consts::PI - 0.45);
|
||
let arc: Vec<_> = (0..=12)
|
||
.map(|i| {
|
||
let a = from + (to - from) * i as f32 / 12.0;
|
||
c + vec2(rad * a.cos(), rad * a.sin())
|
||
})
|
||
.collect();
|
||
if hot {
|
||
p.add(egui::Shape::line(arc.clone(), Stroke::new(5.0, t.text.gamma_multiply(0.18))));
|
||
}
|
||
let ends = [(arc[0], from, -1.0_f32), (arc[arc.len() - 1], to, 1.0)];
|
||
p.add(egui::Shape::line(arc, stroke));
|
||
// Each head points along the curve, away from its middle.
|
||
for (end, a, sign) in ends {
|
||
let dir = vec2(-a.sin(), a.cos()) * sign;
|
||
let side = vec2(-dir.y, dir.x);
|
||
let tip = end + dir * 2.5;
|
||
let base = tip - dir * 5.0;
|
||
p.add(egui::Shape::convex_polygon(vec![tip, base + side * 3.0, base - side * 3.0], ink(&s), Stroke::NONE));
|
||
}
|
||
} else {
|
||
// Switch Colors: the compact clockwise bend has left- and down-pointing heads.
|
||
// swap_arrow.rs owns its geometry and the pixel-level render regression test.
|
||
crate::swap_arrow::paint(p, sr.min, ink(&s));
|
||
}
|
||
// Default Colors: a small black chip over a small white one.
|
||
if let (Some(dr), Some(d)) = (dr, d.as_ref()) {
|
||
let small = 7.0;
|
||
let (b, w) = (Rect::from_min_size(dr.min + vec2(1.0, 1.0), vec2(small, small)), Rect::from_min_size(dr.min + vec2(5.0, 5.0), vec2(small, small)));
|
||
p.rect_filled(w, 1.0, Color32::WHITE);
|
||
p.rect_stroke(w, 1.0, Stroke::new(1.0, ink(d)), StrokeKind::Inside);
|
||
p.rect_filled(b.expand(1.0), 1.5, t.chrome);
|
||
p.rect_filled(b, 1.0, Color32::BLACK);
|
||
p.rect_stroke(b, 1.0, Stroke::new(1.0, ink(d)), StrokeKind::Inside);
|
||
}
|
||
if let Some(d) = &d {
|
||
d.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Default colours (D)")));
|
||
}
|
||
s.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Swap colours (X)")));
|
||
if s.on_hover_text(tl!("Swap colours (X)")).clicked() {
|
||
chip_command(app, "tools.swapColors");
|
||
}
|
||
if d.is_some_and(|d| d.on_hover_text(tl!("Default colours (D)")).clicked()) {
|
||
chip_command(app, "tools.defaultColors");
|
||
}
|
||
}
|
||
|
||
/// Run Switch Colors or Default Colors from the chips. As in Photoshop, characters highlighted
|
||
/// in the type layer being edited take the new foreground colour, like picking one (#5690).
|
||
fn chip_command(app: &mut PhotocraftApp, id: &str) {
|
||
if app.run(id, json!({})).is_ok() {
|
||
crate::type_tool::foreground_changed(app);
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------- title bar
|
||
|
||
/// The app's top bar: brand mark, menus, the document title and the workspace controls. With
|
||
/// [`PhotocraftApp::custom_titlebar`] (Windows and Linux) it is also the window's title bar, as in
|
||
/// Photoshop on Windows: the caption buttons take its ends (`titlebar`). With the system
|
||
/// title bar (Windows and Linux, `system_title_bar`) the OS draws its own icon and title, so the
|
||
/// in-app bar shows menus and workspace controls only: no brand mark, no centred title.
|
||
pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let custom = app.custom_titlebar;
|
||
let system = system_title_bar(app);
|
||
// The caption buttons' room at each end (`titlebar`); a side with buttons has no margin.
|
||
let (caption_left, caption_right) = crate::titlebar::reserved(app, ui.ctx());
|
||
let left = if cfg!(target_os = "macos") && app.integrated_titlebar {
|
||
78
|
||
} else if caption_left > 0.0 {
|
||
0
|
||
} else {
|
||
10
|
||
};
|
||
let right = if caption_right > 0.0 { 0 } else { 10 };
|
||
let bar = egui::Panel::top("title_bar")
|
||
.exact_size(crate::titlebar::bar_height(app, ui.ctx(), if t.pro { 32.0 } else { 38.0 }))
|
||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right, top: 0, bottom: 0 }))
|
||
.show(ui, |ui| {
|
||
let full = ui.max_rect();
|
||
// Only the free gap between the menus and the far-end controls drags the window: a
|
||
// press on a menu title must open the menu, never move the window. The gap is last
|
||
// frame's, as the menus are laid out after this.
|
||
let span_id = ui.id().with("titledrag-span");
|
||
let (gap_l, gap_r) = ui.ctx().data(|d| d.get_temp::<(f32, f32)>(span_id)).unwrap_or((full.right(), full.right()));
|
||
let gap = egui::Rect::from_x_y_ranges(gap_l.max(full.left())..=gap_r.min(full.right()).max(gap_l), full.y_range());
|
||
ui.ctx().data_mut(|d| d.insert_temp(crate::titlebar::modal_drag_area_id(), gap));
|
||
let drag = ui.interact(gap, ui.id().with("titledrag"), Sense::click_and_drag());
|
||
if drag.drag_started() {
|
||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||
}
|
||
if drag.double_clicked() {
|
||
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
|
||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
|
||
}
|
||
let title = app.session.active().map(|d| format!("{}{}", d.doc.name, if d.is_dirty() { " •" } else { "" })).unwrap_or_else(|| "PhotoCraft".into());
|
||
// The menus and the far-end controls are laid out first; the title is centred in
|
||
// whatever room is left between them (`free`, left to right: menus then controls, or
|
||
// controls then menus in a right-to-left UI), shortened or dropped rather than drawn
|
||
// over them.
|
||
let rtl = crate::direction::is_rtl();
|
||
let mut free = (full.left(), full.right());
|
||
crate::direction::horizontal_centered(ui, |ui| {
|
||
// The menus start beside the caption buttons at their end, if it has any.
|
||
let lead = if rtl { caption_right } else { caption_left };
|
||
if lead > 0.0 {
|
||
ui.add_space(lead + 4.0);
|
||
}
|
||
if !system {
|
||
let side = if t.pro { 18.0 } else { 20.0 };
|
||
let (mark, _) = ui.allocate_exact_size(vec2(side, side), Sense::hover());
|
||
crate::brand::paint_mark(ui, mark);
|
||
ui.add_space(6.0);
|
||
}
|
||
// With the macOS menu bar the menus are at the top of the screen instead. Either way
|
||
// this is the menus' inner edge, facing the title.
|
||
let menus_end = if app.services.native_menu.is_some() {
|
||
if rtl { ui.cursor().right() } else { ui.cursor().left() }
|
||
} else {
|
||
crate::menus::menu_bar(app, ui)
|
||
};
|
||
// The menu bar takes the whole row, so the far-end group gets its own rect: from
|
||
// the menus to the bar's end, or to the caption buttons.
|
||
let group = if rtl {
|
||
let left_edge = if caption_left > 0.0 { full.left() + caption_left + 4.0 } else { full.left() };
|
||
egui::Rect::from_min_max(egui::pos2(left_edge, full.top()), egui::pos2((menus_end - TITLE_GAP).max(left_edge), full.bottom()))
|
||
} else {
|
||
let right_edge = if caption_right > 0.0 { full.right() - caption_right - 4.0 } else { full.right() };
|
||
egui::Rect::from_min_max(egui::pos2((menus_end + TITLE_GAP).min(right_edge), full.top()), egui::pos2(right_edge, full.bottom()))
|
||
};
|
||
let mut group_ui = ui.new_child(egui::UiBuilder::new().max_rect(group).layout(crate::direction::trailing()));
|
||
let controls_end = {
|
||
let ui = &mut group_ui;
|
||
ui.spacing_mut().item_spacing.x = 6.0;
|
||
let mut ws = app.ui.workspace.clone();
|
||
let opts = [
|
||
// Keys stay English (`apply_workspace` matches them); the dropdown
|
||
// shows the labels in the UI language.
|
||
("Essentials".to_string(), tl!("Essentials")),
|
||
("Photography".to_string(), tl!("Photography")),
|
||
("Painting".to_string(), tl!("Painting")),
|
||
("Graphic and Web".to_string(), tl!("Graphic and Web")),
|
||
("Pixel Art".to_string(), tl!("Pixel Art")),
|
||
("Motion".to_string(), tl!("Motion")),
|
||
];
|
||
// A narrow bar drops what is also in a menu, the theme toggle first
|
||
// (Preferences), then search (Edit › Search), and narrows the switcher
|
||
// (Window › Workspace) before anything runs over.
|
||
let room = ui.available_width();
|
||
let icons_shown: u8 = if room >= WORKSPACE_MIN + 2.0 * ICON_SLOT {
|
||
2
|
||
} else if room >= WORKSPACE_MIN + ICON_SLOT {
|
||
1
|
||
} else {
|
||
0
|
||
};
|
||
let ws_width = (room - f32::from(icons_shown) * ICON_SLOT - 6.0).clamp(WORKSPACE_MIN, 130.0);
|
||
if room >= WORKSPACE_MIN && widgets::dropdown(ui, "workspace", &mut ws, &opts, ws_width) {
|
||
app.ui.workspace = ws;
|
||
crate::menus::apply_workspace(app);
|
||
}
|
||
if icons_shown >= 1
|
||
&& icons::button(ui, "search", 28.0, app.ui.palette_open, &crate::shortcuts::tip_label(app, "Search commands", "edit.search")).clicked()
|
||
{
|
||
app.ui.palette_open = !app.ui.palette_open;
|
||
}
|
||
let (theme_icon, appearance_label) = match app.session.prefs().interface.appearance_mode {
|
||
photocraft_engine::prefs::AppearanceMode::Auto => ("monitor", tl!("Auto")),
|
||
photocraft_engine::prefs::AppearanceMode::Dark => ("moon", tl!("Dark")),
|
||
photocraft_engine::prefs::AppearanceMode::Light => ("sun", tl!("Light")),
|
||
};
|
||
let appearance_tip = format!("{}: {}", tl!("Appearance Mode"), appearance_label);
|
||
if icons_shown >= 2 {
|
||
let button = icons::button(ui, theme_icon, 28.0, false, &appearance_tip);
|
||
if button.clicked() {
|
||
crate::prefs_ui::cycle_appearance(app, ui.ctx());
|
||
}
|
||
button.context_menu(|ui| crate::prefs_ui::appearance_context_menu(app, ui));
|
||
}
|
||
if rtl { ui.min_rect().right() } else { ui.min_rect().left() }
|
||
};
|
||
free = if rtl { (controls_end, menus_end) } else { (menus_end, controls_end) };
|
||
});
|
||
ui.ctx().data_mut(|d| d.insert_temp(span_id, free));
|
||
if system {
|
||
return;
|
||
}
|
||
// A right-to-left document name in display order (the OS title keeps the logical text),
|
||
// in a left-to-right line: "— PhotoCraft" stays after the name, at the right.
|
||
let title = crate::bidi_text::visual_ltr(&title).into_owned();
|
||
let font = theme::medium(13.0);
|
||
let galley = ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text_dim);
|
||
if let Some(x) = title_x(full.center().x, free.0, free.1, galley.size().x) {
|
||
ui.painter().galley(egui::pos2(x, full.center().y - galley.size().y / 2.0), galley, t.text_dim);
|
||
} else if free.1 - free.0 > 80.0 {
|
||
// Too narrow for the whole name: show the start of it, elided, in the free gap
|
||
// next to the menus.
|
||
let avail = free.1 - free.0 - 2.0 * TITLE_GAP;
|
||
let mut job = egui::text::LayoutJob::simple_singleline(title, font, t.text_dim);
|
||
job.wrap = egui::text::TextWrapping::truncate_at_width(avail);
|
||
let g = ui.painter().layout_job(job);
|
||
let x = if rtl { free.1 - TITLE_GAP - g.size().x } else { free.0 + TITLE_GAP };
|
||
ui.painter().galley(egui::pos2(x, full.center().y - g.size().y / 2.0), g, t.text_dim);
|
||
}
|
||
});
|
||
if custom {
|
||
crate::titlebar::caption_buttons(app, ui, bar.response.rect);
|
||
}
|
||
}
|
||
|
||
/// Whether the window shows the system's title bar instead of the app drawing its own: Windows
|
||
/// and Linux with Preferences › Interface › System Title Bar. macOS always has system decorations,
|
||
/// but keeps the in-app brand mark and centred title, so it never counts as system mode here; nor
|
||
/// does the web build (the browser tab has no document title), nor an embedder or the snapshot
|
||
/// example that never turned the preference on.
|
||
pub fn system_title_bar(app: &PhotocraftApp) -> bool {
|
||
!app.custom_titlebar && app.session.prefs().interface.system_title_bar && !cfg!(any(target_os = "macos", target_arch = "wasm32"))
|
||
}
|
||
|
||
/// The OS window title: the active document's name suffixed with the app name (a `*` prefix
|
||
/// marks unsaved changes, like the `•` in the app-drawn title), or just the app name with no
|
||
/// document open.
|
||
pub fn window_title(app: &PhotocraftApp) -> String {
|
||
app.session.active().map(|d| format!("{}{} \u{2014} PhotoCraft", if d.is_dirty() { "*" } else { "" }, d.doc.name)).unwrap_or_else(|| "PhotoCraft".into())
|
||
}
|
||
|
||
/// Keep the OS window title (and the taskbar / Alt-Tab entry) on the active file. Sends
|
||
/// `ViewportCommand::Title` only when the title changed since the last frame.
|
||
pub fn sync_window_title(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||
let want = window_title(app);
|
||
if app.last_window_title != want {
|
||
app.last_window_title = want.clone();
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(want));
|
||
}
|
||
}
|
||
|
||
/// Minimum space kept between the window title and the menus or controls beside it.
|
||
const TITLE_GAP: f32 = 16.0;
|
||
/// The workspace switcher's narrowest width, and one 28 pt title-bar icon with its spacing.
|
||
const WORKSPACE_MIN: f32 = 90.0;
|
||
const ICON_SLOT: f32 = 34.0;
|
||
|
||
/// Left edge for a title `width` wide: centred on `center` when it fits in the free gap from
|
||
/// `left` to `right` (the menus' and the controls' facing edges, whichever side each is on),
|
||
/// else slid into that gap; None when the gap is too small for the whole title.
|
||
fn title_x(center: f32, left: f32, right: f32, width: f32) -> Option<f32> {
|
||
let (lo, hi) = (left + TITLE_GAP, right - TITLE_GAP - width);
|
||
if !(lo.is_finite() && hi.is_finite() && width.is_finite()) || hi < lo {
|
||
return None;
|
||
}
|
||
if !center.is_finite() {
|
||
return Some(lo);
|
||
}
|
||
Some((center - width / 2.0).clamp(lo, hi))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod title_tests {
|
||
use super::{system_title_bar, title_x, window_title};
|
||
|
||
#[test]
|
||
fn title_never_overlaps_menus_or_controls() {
|
||
// Wide window: centred.
|
||
assert_eq!(title_x(800.0, 420.0, 1300.0, 60.0), Some(770.0));
|
||
// Centre would hit the menus: slid right into the gap.
|
||
assert_eq!(title_x(400.0, 420.0, 1300.0, 60.0), Some(436.0));
|
||
// Centre would hit the controls: slid left.
|
||
assert_eq!(title_x(1280.0, 420.0, 1300.0, 60.0), Some(1224.0));
|
||
// No room for the whole title: the caller elides or drops it.
|
||
assert_eq!(title_x(400.0, 420.0, 480.0, 60.0), None);
|
||
assert_eq!(title_x(f32::NAN, 420.0, 1300.0, 60.0), Some(436.0));
|
||
assert_eq!(title_x(400.0, f32::INFINITY, 1300.0, 60.0), None);
|
||
}
|
||
|
||
fn app(custom_titlebar: bool) -> crate::PhotocraftApp {
|
||
let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.custom_titlebar = custom_titlebar;
|
||
// The preference that makes the shell launch without its own title bar.
|
||
app.session.edit_prefs(|p| p.interface.system_title_bar = !custom_titlebar);
|
||
app
|
||
}
|
||
|
||
#[test]
|
||
fn without_the_preference_the_mark_stays() {
|
||
// The default (e.g. the web build or the snapshot example): no custom bar, no preference.
|
||
let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
assert!(!system_title_bar(&app));
|
||
}
|
||
|
||
fn open_named(app: &mut crate::PhotocraftApp, name: &str) {
|
||
app.session.add_document(
|
||
photocraft_doc::Document::new(name, photocraft_doc::Size::new(4, 4), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8),
|
||
None,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn window_title_with_no_document_is_the_app_name() {
|
||
assert_eq!(window_title(&app(true)), "PhotoCraft");
|
||
}
|
||
|
||
#[test]
|
||
fn window_title_follows_the_active_file() {
|
||
let mut app = app(true);
|
||
open_named(&mut app, "foo.psd");
|
||
assert_eq!(window_title(&app), "foo.psd \u{2014} PhotoCraft");
|
||
// Unsaved changes gain a `*` prefix, like the `•` in the app-drawn title.
|
||
if let Some(st) = app.session.active_mut() {
|
||
st.revision += 1;
|
||
}
|
||
assert_eq!(window_title(&app), "*foo.psd \u{2014} PhotoCraft");
|
||
}
|
||
|
||
#[test]
|
||
fn system_mode_hides_the_mark_and_the_centred_title() {
|
||
for custom in [true, false] {
|
||
let mut app = app(custom);
|
||
open_named(&mut app, "foo.psd");
|
||
let ctx = egui::Context::default();
|
||
crate::PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
let out = ctx.run_ui(egui::RawInput::default(), |ui| super::title_bar(&mut app, ui));
|
||
let mut out = out;
|
||
out.textures_delta.clear();
|
||
let mut texts = Vec::new();
|
||
for shape in &out.shapes {
|
||
collect_text(&shape.shape, &mut texts);
|
||
}
|
||
let system = system_title_bar(&app);
|
||
assert_eq!(system, !custom && !cfg!(target_os = "macos"));
|
||
assert_eq!(crate::brand::mark_rect(&ctx).is_some(), !system, "mark in system mode (custom={custom})");
|
||
assert_eq!(texts.iter().any(|t| t.contains("foo.psd")), !system, "centred title in system mode (custom={custom}): {texts:?}");
|
||
}
|
||
}
|
||
|
||
fn collect_text(shape: &egui::Shape, out: &mut Vec<String>) {
|
||
match shape {
|
||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect_text(s, out)),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sync_window_title_sends_only_on_change() {
|
||
let mut app = app(true);
|
||
let ctx = egui::Context::default();
|
||
crate::PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
super::sync_window_title(&mut app, &ctx);
|
||
assert_eq!(app.last_window_title, "PhotoCraft");
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||
out.textures_delta.clear();
|
||
let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
|
||
assert!(cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(t) if t == "PhotoCraft")), "{cmds:?}");
|
||
// Same document state again: the cached title stops a repeat command.
|
||
super::sync_window_title(&mut app, &ctx);
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||
out.textures_delta.clear();
|
||
let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
|
||
assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(_))), "repeat Title: {cmds:?}");
|
||
open_named(&mut app, "foo.psd");
|
||
super::sync_window_title(&mut app, &ctx);
|
||
assert_eq!(app.last_window_title, "foo.psd \u{2014} PhotoCraft");
|
||
}
|
||
}
|
||
|
||
// Custom controls also need to bring keyboard focus into a scroll viewport.
|
||
fn reveal_focused_control(ui: &egui::Ui) {
|
||
// read_response reprocesses pointer events. Reading a popup's newly focused field
|
||
// during its opening click can undo the focus that the popup just requested.
|
||
if ui.input(|input| input.pointer.any_pressed() || input.pointer.any_released()) {
|
||
return;
|
||
}
|
||
let focused = ui.memory(|memory| memory.focused());
|
||
let key = ui.id().with("scroll-focused-control");
|
||
let changed = ui.data_mut(|data| {
|
||
let previous = data.get_temp::<Option<egui::Id>>(key).flatten();
|
||
data.insert_temp(key, focused);
|
||
previous != focused
|
||
});
|
||
if changed
|
||
&& let Some(response) = focused.and_then(|id| ui.ctx().read_response(id))
|
||
&& response.layer_id == ui.layer_id()
|
||
&& ui.min_rect().contains_rect(response.rect)
|
||
{
|
||
ui.scroll_to_rect(response.rect, Some(egui::Align::Center));
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------- options bar
|
||
|
||
/// Options bar height: Preferences ▸ Workspace ▸ Enable Narrow Options Bar trims it, as in
|
||
/// Photoshop, leaving the document more room.
|
||
fn options_bar_height(pro: bool, narrow: bool) -> f32 {
|
||
match (pro, narrow) {
|
||
(true, false) => 36.0,
|
||
(true, true) => 30.0,
|
||
(false, false) => 42.0,
|
||
(false, true) => 34.0,
|
||
}
|
||
}
|
||
|
||
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let narrow = app.session.prefs().workspace.narrow_options_bar();
|
||
egui::Panel::top("options_bar")
|
||
.exact_size(options_bar_height(t.pro, narrow))
|
||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0)))
|
||
.show(ui, |ui| {
|
||
ui.style_mut().spacing.scroll = egui::style::ScrollStyle { bar_width: 4.0, ..egui::style::ScrollStyle::floating() };
|
||
egui::ScrollArea::horizontal().id_salt("tool-options").auto_shrink([false, false]).show(ui, |ui| {
|
||
// Font ink can extend beyond its advance width at fractional positions.
|
||
// Include its gutter in the container layout, including right-aligned actions.
|
||
egui::Frame::NONE.inner_margin(egui::Margin { right: 2, ..Default::default() }).show(ui, |ui| {
|
||
crate::direction::horizontal_centered(ui, |ui| {
|
||
if t.pro {
|
||
crate::chrome_ui::home_button(app, ui);
|
||
widgets::vline(ui, 22.0);
|
||
}
|
||
let tool_icon = icons::button(ui, icons::tool_icon(app.ui.tool), if t.pro { 26.0 } else { 28.0 }, !t.pro, tl!(app.ui.tool.label()));
|
||
// Photoshop: right-click the tool icon for Reset Tool / Reset All Tools (#2825).
|
||
let mut reset = None;
|
||
tool_icon.context_menu(|ui| {
|
||
if ui.button(tl!("Reset Tool")).clicked() {
|
||
reset = Some("tool.reset");
|
||
ui.close();
|
||
}
|
||
if ui.button(tl!("Reset All Tools")).clicked() {
|
||
reset = Some("tool.resetAll");
|
||
ui.close();
|
||
}
|
||
});
|
||
if let Some(id) = reset
|
||
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), id, json!({}))
|
||
{
|
||
app.ui.status = e;
|
||
}
|
||
if t.pro {
|
||
let (r, _) = ui.allocate_exact_size(vec2(10.0, 20.0), Sense::hover());
|
||
icons::paint(ui, r, "chevron-down", 10.0, t.text_faint);
|
||
}
|
||
widgets::vline(ui, 22.0);
|
||
if app.ui.transform.is_some() {
|
||
crate::transform_tool::options_bar(app, ui);
|
||
return;
|
||
}
|
||
if crate::symmetry_ui::options_bar(app, ui) || crate::distort_ui::options_bar(app, ui) {
|
||
return;
|
||
}
|
||
let tool = app.ui.tool;
|
||
// The options bar shows the active tool's brush: switch it in before drawing the
|
||
// chip, so a tool change swaps the brush with it (Photoshop keeps one per tool).
|
||
crate::paint_mouse::sync_tool_brush(app);
|
||
// Brush edits here go through `tools.setBrush`, one journal entry per gesture (Rule 1).
|
||
if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::Eraser)) || tool == Tool::QuickSelection {
|
||
brush_preset_chip(ui, &app.session.tools.brush, &mut app.ui);
|
||
crate::brush_picker::settings_toggle(app, ui);
|
||
widgets::vline(ui, 22.0);
|
||
}
|
||
if tool == Tool::CloneStamp {
|
||
let before = app.session.tools.brush.clone();
|
||
let mut brush = before.clone();
|
||
let width = if t.pro { 62.0 } else { 66.0 };
|
||
percent_field(ui, tl!("Opacity"), &mut brush.opacity, 1.0..=100.0, width);
|
||
percent_field(ui, tl!("Flow"), &mut brush.flow, 1.0..=100.0, width);
|
||
crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &brush);
|
||
widgets::vline(ui, 22.0);
|
||
}
|
||
if crate::eraser_ui::options_bar(app, ui, tool)
|
||
|| crate::retouch_ui::options_bar(app, ui, tool)
|
||
|| crate::vector_ui::options_bar(app, ui, tool)
|
||
|| crate::analysis_ui::options_bar(app, ui, tool)
|
||
|| crate::slice_ui::options_bar(app, ui, tool)
|
||
|| crate::frame_tool::options_bar(app, ui, tool)
|
||
|| crate::artboard_tool::options_bar(app, ui, tool)
|
||
{
|
||
return;
|
||
}
|
||
let brush_before = app.session.tools.brush.clone();
|
||
let mut brush = brush_before.clone();
|
||
let b = &mut brush;
|
||
match app.ui.tool {
|
||
Tool::Brush | Tool::Eraser if t.pro => {
|
||
// A Block-mode Eraser has a fixed size, opacity and flow (Photoshop, #2770).
|
||
let block = crate::eraser_ui::block_mode(app, app.ui.tool);
|
||
ui.add_enabled_ui(!block, |ui| {
|
||
brush_preset_chip(ui, b, &mut app.ui);
|
||
crate::brush_picker::settings_toggle(app, ui);
|
||
});
|
||
widgets::vline(ui, 22.0);
|
||
// The Eraser has no blend mode: its Mode is Brush, Pencil or Block (#2662).
|
||
if app.ui.tool == Tool::Eraser {
|
||
crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode);
|
||
} else {
|
||
opt_label(ui, tl!("Mode"));
|
||
let held = app.held_erase;
|
||
paint_mode_dropdown(ui, "brush-mode", b, &mut app.ui.tool_options.brush_clear, held);
|
||
}
|
||
ui.add_enabled_ui(!block, |ui| {
|
||
percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 62.0);
|
||
if icons::button(ui, "circle-dot", 24.0, b.pressure_opacity, tl!("Always use pressure for opacity")).clicked() {
|
||
b.pressure_opacity = !b.pressure_opacity;
|
||
}
|
||
});
|
||
// A Pencil- or Block-mode Eraser is always full flow, without build-up (Photoshop).
|
||
ui.add_enabled_ui(!block && !crate::eraser_ui::pencil_mode(app, app.ui.tool), |ui| {
|
||
percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0);
|
||
let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects"));
|
||
if crate::brush_picker::named(airbrush, "Enable airbrush-style build-up effects").clicked() {
|
||
b.build_up = !b.build_up;
|
||
}
|
||
});
|
||
opt_label(ui, tl!("Smoothing"));
|
||
smoothing_field(ui, b, 58.0);
|
||
smoothing_options(ui, b);
|
||
widgets::vline(ui, 22.0);
|
||
if icons::button(ui, "circle-dot", 24.0, b.pressure_size, tl!("Always use pressure for size")).clicked() {
|
||
b.pressure_size = !b.pressure_size;
|
||
}
|
||
}
|
||
// Pencil: Photoshop's options (no hardness or flow: the pencil is always hard).
|
||
Tool::Pencil => {
|
||
brush_preset_chip(ui, b, &mut app.ui);
|
||
crate::brush_picker::settings_toggle(app, ui);
|
||
widgets::vline(ui, 22.0);
|
||
if !t.pro {
|
||
opt_label(ui, tl!("Size"));
|
||
widgets::value_field(ui, &mut b.size, 1.0..=photocraft_engine::paint::MAX_BRUSH_SIZE, "px", 76.0);
|
||
widgets::vline(ui, 22.0);
|
||
}
|
||
opt_label(ui, tl!("Mode"));
|
||
let held = app.held_erase;
|
||
paint_mode_dropdown(ui, "pencil-mode", b, &mut app.ui.tool_options.pencil_clear, held);
|
||
percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, if t.pro { 62.0 } else { 66.0 });
|
||
opt_label(ui, tl!("Smoothing"));
|
||
smoothing_field(ui, b, if t.pro { 58.0 } else { 66.0 });
|
||
widgets::vline(ui, 22.0);
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.pencil_auto_erase, tl!("Auto Erase"));
|
||
}
|
||
Tool::Brush | Tool::Eraser => {
|
||
brush_preset_chip(ui, b, &mut app.ui);
|
||
crate::brush_picker::settings_toggle(app, ui);
|
||
widgets::vline(ui, 22.0);
|
||
// A Block-mode Eraser has a fixed size, opacity and flow (Photoshop, #2770).
|
||
let block = crate::eraser_ui::block_mode(app, app.ui.tool);
|
||
ui.add_enabled_ui(!block, |ui| {
|
||
opt_label(ui, tl!("Size"));
|
||
widgets::value_field(ui, &mut b.size, 1.0..=photocraft_engine::paint::MAX_BRUSH_SIZE, "px", 76.0);
|
||
});
|
||
widgets::vline(ui, 22.0);
|
||
if app.ui.tool == Tool::Eraser {
|
||
crate::eraser_ui::mode_dropdown(ui, &mut app.ui.tool_options.eraser_mode);
|
||
} else {
|
||
opt_label(ui, tl!("Mode"));
|
||
let held = app.held_erase;
|
||
paint_mode_dropdown(ui, "brush-mode", b, &mut app.ui.tool_options.brush_clear, held);
|
||
}
|
||
// A Pencil-mode Eraser is always hard and at full flow (Photoshop).
|
||
let fixed = block || crate::eraser_ui::pencil_mode(app, app.ui.tool);
|
||
ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Hardness"), &mut b.hardness, 0.0..=100.0, 66.0));
|
||
ui.add_enabled_ui(!block, |ui| percent_field(ui, tl!("Opacity"), &mut b.opacity, 0.0..=100.0, 66.0));
|
||
ui.add_enabled_ui(!fixed, |ui| percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 66.0));
|
||
opt_label(ui, tl!("Smoothing"));
|
||
smoothing_field(ui, b, 66.0);
|
||
widgets::vline(ui, 22.0);
|
||
widgets::toggle(ui, &mut b.pressure_size, tl!("Pressure for Size"));
|
||
widgets::toggle(ui, &mut b.pressure_opacity, tl!("Pressure for Opacity"));
|
||
}
|
||
Tool::MixerBrush => {
|
||
brush_preset_chip(ui, b, &mut app.ui);
|
||
crate::brush_picker::settings_toggle(app, ui);
|
||
widgets::vline(ui, 22.0);
|
||
for (label, value) in [
|
||
(tl!("Wet"), &mut b.mixer.wet),
|
||
(tl!("Load"), &mut b.mixer.load),
|
||
(tl!("Mix"), &mut b.mixer.mix),
|
||
(tl!("Flow"), &mut b.mixer.flow),
|
||
] {
|
||
percent_field(ui, label, value, 0.0..=100.0, 62.0);
|
||
}
|
||
widgets::vline(ui, 22.0);
|
||
widgets::checkbox(ui, &mut b.mixer.sample_all_layers, tl!("Sample All Layers"));
|
||
}
|
||
Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee if t.pro => {
|
||
ui.spacing_mut().item_spacing.x = 2.0;
|
||
for (i, (icon, tip)) in [
|
||
("square", tl!("New selection").to_string()),
|
||
("plus", crate::i18n::fmt(tl!("Add to selection ({key})"), &[("key", &crate::shortcuts::pretty("Shift"))])),
|
||
("minus", crate::i18n::fmt(tl!("Subtract from selection ({key})"), &[("key", &crate::shortcuts::pretty("Alt"))])),
|
||
(
|
||
"squares-subtract",
|
||
crate::i18n::fmt(tl!("Intersect with selection ({key})"), &[("key", &crate::shortcuts::pretty("Shift+Alt"))]),
|
||
),
|
||
]
|
||
.iter()
|
||
.enumerate()
|
||
{
|
||
if icons::button(ui, icon, 24.0, app.ui.selection_mode == i as u8, tip).clicked() {
|
||
app.ui.selection_mode = i as u8;
|
||
}
|
||
}
|
||
ui.spacing_mut().item_spacing.x = 8.0;
|
||
widgets::vline(ui, 22.0);
|
||
let o = &mut app.ui.tool_options;
|
||
opt_label(ui, tl!("Feather"));
|
||
widgets::value_field(ui, &mut o.feather, 0.0..=1000.0, "px", 62.0);
|
||
// Photoshop greys Anti-alias for the Rectangular Marquee (its edges are always hard).
|
||
ui.add_enabled_ui(app.ui.tool == Tool::EllipseMarquee, |ui| widgets::checkbox(ui, &mut o.anti_alias, tl!("Anti-alias")));
|
||
widgets::vline(ui, 22.0);
|
||
// A single row or column has no style or size: Photoshop greys them.
|
||
let sized = matches!(app.ui.tool, Tool::RectMarquee | Tool::EllipseMarquee);
|
||
ui.add_enabled_ui(sized, |ui| opt_label(ui, tl!("Style")));
|
||
let styles = [
|
||
("normal".to_string(), tl!("Normal")),
|
||
("fixedRatio".to_string(), tl!("Fixed Ratio")),
|
||
("fixedSize".to_string(), tl!("Fixed Size")),
|
||
];
|
||
if ui.add_enabled_ui(sized, |ui| widgets::dropdown(ui, "marquee-style", &mut o.marquee_style, &styles, 96.0)).inner {
|
||
(o.marquee_width, o.marquee_height) = if o.marquee_style == "fixedSize" { (64.0, 64.0) } else { (1.0, 1.0) };
|
||
}
|
||
let fixed = sized && o.marquee_style != "normal";
|
||
let (unit, range) = if o.marquee_style == "fixedSize" { ("px", 1.0..=300_000.0) } else { ("", 0.001..=999.0) };
|
||
ui.add_enabled_ui(fixed, |ui| {
|
||
opt_label(ui, tl!("Width"));
|
||
widgets::value_field(ui, &mut o.marquee_width, range.clone(), unit, 62.0);
|
||
if icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() {
|
||
std::mem::swap(&mut o.marquee_width, &mut o.marquee_height);
|
||
}
|
||
opt_label(ui, tl!("Height"));
|
||
widgets::value_field(ui, &mut o.marquee_height, range, unit, 62.0);
|
||
});
|
||
widgets::vline(ui, 22.0);
|
||
if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() {
|
||
let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({}));
|
||
}
|
||
}
|
||
Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso if t.pro => {
|
||
selection_mode_buttons(app, ui);
|
||
widgets::vline(ui, 22.0);
|
||
opt_label(ui, tl!("Feather"));
|
||
widgets::value_field(ui, &mut app.ui.tool_options.feather, 0.0..=1000.0, "px", 62.0);
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, tl!("Anti-alias"));
|
||
widgets::vline(ui, 22.0);
|
||
if app.ui.tool == Tool::MagneticLasso {
|
||
let o = &mut app.ui.tool_options;
|
||
opt_label(ui, tl!("Width"));
|
||
widgets::value_field(ui, &mut o.magnetic_width, 1.0..=256.0, "px", 58.0)
|
||
.on_hover_text(tl!("Follows only edges this close to the pointer ([ and ] change it)"));
|
||
opt_label(ui, tl!("Contrast"));
|
||
widgets::value_field(ui, &mut o.magnetic_contrast, 1.0..=100.0, "%", 58.0)
|
||
.on_hover_text(tl!("Higher values follow only edges that contrast sharply with their surroundings"));
|
||
opt_label(ui, tl!("Frequency"));
|
||
widgets::value_field(ui, &mut o.magnetic_frequency, 0.0..=100.0, "", 50.0)
|
||
.on_hover_text(tl!("How often fastening points are placed by themselves"));
|
||
if icons::button(ui, "circle-dot", 24.0, o.magnetic_pressure, tl!("Use tablet pressure to change pen width")).clicked() {
|
||
o.magnetic_pressure = !o.magnetic_pressure;
|
||
}
|
||
widgets::vline(ui, 22.0);
|
||
}
|
||
if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() {
|
||
let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({}));
|
||
}
|
||
if crate::lasso_ui::active(app) || (app.ui.tool == Tool::PolygonLasso && !app.ui.polygon.is_empty()) {
|
||
hint(
|
||
ui,
|
||
&crate::i18n::fmt(
|
||
tl!("Click the first point or press {key} to close · Esc cancels"),
|
||
&[("key", &crate::shortcuts::pretty("Enter"))],
|
||
),
|
||
);
|
||
} else if app.ui.tool == Tool::Lasso {
|
||
hint(ui, tl!("Hold Alt while drawing for straight segments"));
|
||
}
|
||
if app.ui.tool == Tool::MagneticLasso && app.ui.magnetic.active() {
|
||
hint(
|
||
ui,
|
||
&crate::i18n::fmt(
|
||
tl!("Click the first point or press {key} to close · {del} removes a point · Esc cancels"),
|
||
&[("key", &crate::shortcuts::pretty("Enter")), ("del", &crate::shortcuts::pretty("Backspace"))],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
Tool::MagicWand => {
|
||
selection_mode_buttons(app, ui);
|
||
widgets::vline(ui, 22.0);
|
||
opt_label(ui, tl!("Tolerance"));
|
||
widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0);
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, tl!("Anti-alias"));
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, tl!("Contiguous"));
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, tl!("Sample All Layers"));
|
||
widgets::vline(ui, 22.0);
|
||
if widgets::secondary_button(ui, tl!("Select Subject"), 0.0).clicked() {
|
||
let r = app.run("select.subject", json!({}));
|
||
crate::retouch_ui::report_smart_result(app, r, tl!("No subject found — try Object Selection or the Lasso"));
|
||
}
|
||
if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() {
|
||
let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({}));
|
||
}
|
||
}
|
||
Tool::PaintBucket if t.pro => {
|
||
opt_label(ui, tl!("Fill"));
|
||
let mut src = u8::from(app.ui.tool_options.bucket_fill_pattern);
|
||
if widgets::dropdown(ui, "bucket-src", &mut src, &[(0u8, tl!("Foreground")), (1, tl!("Pattern"))], 100.0) {
|
||
app.ui.tool_options.bucket_fill_pattern = src == 1;
|
||
}
|
||
opt_label(ui, tl!("Opacity"));
|
||
widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0);
|
||
opt_label(ui, tl!("Tolerance"));
|
||
widgets::value_field(ui, &mut app.ui.tool_options.tolerance, 0.0..=255.0, "", 58.0);
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.anti_alias, tl!("Anti-alias"));
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, tl!("Contiguous"));
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, tl!("All Layers"));
|
||
}
|
||
Tool::Gradient => {
|
||
// Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels).
|
||
let mut classic = app.ui.tool_options.gradient_classic;
|
||
if widgets::dropdown(ui, "gradient-mode", &mut classic, &[(false, "Gradient"), (true, "Classic gradient")], 118.0) {
|
||
app.ui.tool_options.gradient_classic = classic;
|
||
}
|
||
// Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels):
|
||
// both paint with the same engine-owned preset (#1651), and either swatch
|
||
// click opens the Gradient Editor window, as in Photoshop.
|
||
if crate::gradient_ui::preset_swatch(app, ui) {
|
||
app.ui.panels.gradient_editor = true;
|
||
}
|
||
widgets::vline(ui, 22.0);
|
||
ui.spacing_mut().item_spacing.x = 2.0;
|
||
let before = app.ui.tool_options.clone();
|
||
for (style, icon, tip) in [
|
||
("linear", "gradient", tl!("Linear Gradient")),
|
||
("radial", "circle", tl!("Radial Gradient")),
|
||
("angle", "rotate-cw", tl!("Angle Gradient")),
|
||
("reflected", "arrow-left-right", tl!("Reflected Gradient")),
|
||
("diamond", "diamond", tl!("Diamond Gradient")),
|
||
] {
|
||
if icons::button(ui, icon, 24.0, app.ui.tool_options.gradient_style == style, tip).clicked() {
|
||
app.ui.tool_options.gradient_style = style.into();
|
||
}
|
||
}
|
||
ui.spacing_mut().item_spacing.x = 8.0;
|
||
widgets::vline(ui, 22.0);
|
||
opt_label(ui, tl!("Mode"));
|
||
let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect();
|
||
// The wheel over it steps the mode, as over the Layers panel's (#2061).
|
||
widgets::dropdown_wheel_hovered(ui, "gradient-blend-mode", &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0);
|
||
opt_label(ui, tl!("Opacity"));
|
||
let r = widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0);
|
||
// Live while scrubbed, one undo step per scrub (#5012).
|
||
let drag_key = r.id.with("gradient-opacity-drag");
|
||
if r.drag_started() {
|
||
let n = ui.ctx().cumulative_pass_nr();
|
||
ui.data_mut(|d| d.insert_temp(drag_key, n));
|
||
}
|
||
if r.changed() {
|
||
let drag = if r.dragged() { ui.data(|d| d.get_temp::<u64>(drag_key)) } else { None };
|
||
crate::gradient_ui::opacity_changed(app, drag);
|
||
}
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.gradient_reverse, tl!("Reverse"));
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.gradient_dither, tl!("Dither"));
|
||
// Live mode: the options also change a selected gradient fill layer.
|
||
crate::gradient_ui::options_changed(app, &before);
|
||
}
|
||
Tool::PerspectiveCrop if t.pro => crate::perspective_crop_ui::options_bar(app, ui),
|
||
Tool::Crop if t.pro => {
|
||
let before_ratio = crate::crop_ui::preset_ratio(app);
|
||
let doc_size = app.session.active().map_or((0.0, 0.0), |s| (f64::from(s.doc.size.width), f64::from(s.doc.size.height)));
|
||
// Ratios, sizes, saved presets, New / Delete Crop Preset… (#2443, #1919).
|
||
crate::crop_presets::dropdown(app, ui);
|
||
let o = &mut app.ui.tool_options;
|
||
if o.crop_ratio == crate::crop_size::WHR {
|
||
crate::crop_size::fields(o, ui);
|
||
} else {
|
||
// Keep partial edits in ToolOptions so the first side survives a frame;
|
||
// Original Ratio shows the document's numbers, as Photoshop does.
|
||
let (mut rw, mut rh) = crate::crop_size::custom_ratio_fields(o, doc_size);
|
||
let decimals = crate::crop_size::RATIO_DECIMALS;
|
||
let cw = widgets::value_field_prec(ui, &mut rw, 0.0..=99_999.0, "", 54.0, decimals).changed();
|
||
let swap = icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked();
|
||
let ch = widgets::value_field_prec(ui, &mut rh, 0.0..=99_999.0, "", 54.0, decimals).changed();
|
||
if swap {
|
||
std::mem::swap(&mut rw, &mut rh);
|
||
}
|
||
if cw || ch || swap {
|
||
crate::crop_size::set_custom_ratio_fields(o, rw, rh);
|
||
}
|
||
}
|
||
widgets::vline(ui, 22.0);
|
||
if widgets::secondary_button(ui, tl!("Clear"), 0.0).clicked() {
|
||
if o.crop_ratio == crate::crop_size::WHR {
|
||
crate::crop_size::clear(o);
|
||
} else {
|
||
o.crop_ratio.clear();
|
||
}
|
||
}
|
||
crate::crop_straighten::options_button(&mut app.crop.straighten, ui);
|
||
crate::crop_overlay::options_button(o, ui);
|
||
let pick_shield_color = crate::crop_shield::options_button(&mut o.crop_shield, ui);
|
||
widgets::checkbox(ui, &mut o.crop_delete, tl!("Delete Cropped Pixels"));
|
||
// Fill: what a turned or extending crop puts where the image doesn't reach
|
||
// (#3823). Generative Expand is not offered.
|
||
ui.label(tl!("Fill:"));
|
||
widgets::dropdown(
|
||
ui,
|
||
"crop-fill",
|
||
&mut o.crop_content_aware,
|
||
&[(false, tl!("Background (Default)")), (true, tl!("Content-Aware Fill"))],
|
||
150.0,
|
||
);
|
||
if crate::crop_ui::preset_ratio(app) != before_ratio {
|
||
crate::crop_ui::apply_ratio(app);
|
||
}
|
||
if pick_shield_color {
|
||
crate::crop_shield::pick_custom_color(app);
|
||
}
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if icons::button(
|
||
ui,
|
||
"check",
|
||
26.0,
|
||
false,
|
||
&crate::i18n::fmt(tl!("Commit current crop operation ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))]),
|
||
)
|
||
.clicked()
|
||
{
|
||
crate::canvas::commit_crop(app);
|
||
}
|
||
if icons::button(ui, "ban", 26.0, false, tl!("Cancel current crop operation (Esc)")).clicked() {
|
||
crate::crop_ui::cancel(app);
|
||
}
|
||
});
|
||
}
|
||
// Every theme gets the font, style and size controls (#1387), as with Auto-Select (#1275).
|
||
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => crate::type_tool::options_bar(app, ui),
|
||
Tool::Move if t.pro => {
|
||
let o = &mut app.ui.tool_options;
|
||
widgets::checkbox(ui, &mut o.move_auto_select, tl!("Auto-Select:"));
|
||
widgets::dropdown(
|
||
ui,
|
||
"move-target",
|
||
&mut o.move_target,
|
||
&[("layer".to_string(), tl!("Layer")), ("group".to_string(), tl!("Group"))],
|
||
76.0,
|
||
);
|
||
widgets::vline(ui, 22.0);
|
||
widgets::checkbox(ui, &mut o.move_show_transform, tl!("Show Transform Controls"));
|
||
widgets::vline(ui, 22.0);
|
||
ui.spacing_mut().item_spacing.x = 0.0;
|
||
for (icon, tip, cmd) in [
|
||
("panels-top-left", tl!("Align left edges"), "layer.align.leftEdges"),
|
||
("app-window", tl!("Align horizontal centers"), "layer.align.horizontalCenters"),
|
||
("panel-right", tl!("Align right edges"), "layer.align.rightEdges"),
|
||
] {
|
||
let on = app.session.is_enabled(cmd);
|
||
if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, tip)).inner.clicked() {
|
||
let _ = app.run(cmd, json!({}));
|
||
}
|
||
}
|
||
ui.add_space(6.0);
|
||
let more = icons::button(ui, "ellipsis", 24.0, false, tl!("Align and distribute"));
|
||
egui::Popup::menu(&more).show(|ui| {
|
||
ui.set_min_width(200.0);
|
||
let mut section = |ui: &mut egui::Ui, title: &str, prefix: &str| {
|
||
ui.label(egui::RichText::new(tl!(&title)).small().color(t.text_dim));
|
||
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with(prefix)) {
|
||
if ui.add_enabled(app.session.is_enabled(c.id), egui::Button::new(tl!(c.label))).clicked() {
|
||
let _ = app.run(c.id, json!({}));
|
||
ui.close();
|
||
}
|
||
}
|
||
};
|
||
section(ui, tl!("Align"), "layer.align.");
|
||
ui.separator();
|
||
section(ui, tl!("Distribute"), "layer.distribute.");
|
||
});
|
||
}
|
||
Tool::RectMarquee | Tool::EllipseMarquee => hint(
|
||
ui,
|
||
&format!(
|
||
"Drag to select · {} add · {} subtract · {} intersect · click to deselect",
|
||
crate::shortcuts::pretty("Shift"),
|
||
crate::shortcuts::pretty("Alt"),
|
||
crate::shortcuts::pretty("Shift+Alt")
|
||
),
|
||
),
|
||
Tool::SingleRowMarquee | Tool::SingleColumnMarquee => hint(
|
||
ui,
|
||
&format!(
|
||
"Click to select a one-pixel line · {} add · {} subtract · {} intersect",
|
||
crate::shortcuts::pretty("Shift"),
|
||
crate::shortcuts::pretty("Alt"),
|
||
crate::shortcuts::pretty("Shift+Alt")
|
||
),
|
||
),
|
||
Tool::Move => {
|
||
// The Studio themes keep Photoshop's Auto-Select toggle too (#1275).
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.move_auto_select, tl!("Auto-Select:"));
|
||
// And Show Transform Controls (#5740): without it the box could not be
|
||
// turned on outside the Pro themes.
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.move_show_transform, tl!("Show Transform Controls"));
|
||
hint(ui, tl!("Drag to move the active layer"));
|
||
}
|
||
Tool::Eyedropper => crate::eyedropper_ui::options(app, ui),
|
||
Tool::Zoom => {
|
||
widgets::checkbox(ui, &mut app.ui.tool_options.zoom_scrubby, tl!("Scrubby Zoom"));
|
||
hint(
|
||
ui,
|
||
&crate::i18n::fmt(
|
||
tl!("Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out"),
|
||
&[("key", &crate::shortcuts::pretty("Alt"))],
|
||
),
|
||
);
|
||
if widgets::secondary_button(ui, tl!("Fit Screen"), 0.0).clicked()
|
||
&& let Some(i) = app.session.active_index()
|
||
{
|
||
app.ui.views[i].fit_pending = true;
|
||
}
|
||
if widgets::secondary_button(ui, "100%", 0.0).clicked()
|
||
&& let Some(i) = app.session.active_index()
|
||
{
|
||
app.ui.views[i].zoom = 1.0;
|
||
}
|
||
}
|
||
Tool::RotateView => {
|
||
hint(ui, tl!("Drag around the centre to rotate the view · Shift constrains to 15°"));
|
||
if let Some(i) = app.session.active_index() {
|
||
ui.label(tl!("Angle"));
|
||
let mut angle = app.ui.views.get(i).map(|v| v.rotation).unwrap_or(0.0);
|
||
if widgets::value_field(ui, &mut angle, -180.0..=180.0, "°", 56.0).changed()
|
||
&& let Some(v) = app.ui.views.get_mut(i)
|
||
{
|
||
v.rotation = crate::rotate_view::wrap_deg(angle);
|
||
}
|
||
if widgets::secondary_button(ui, tl!("Reset View"), 0.0).clicked()
|
||
&& let Some(v) = app.ui.views.get_mut(i)
|
||
{
|
||
v.rotation = 0.0;
|
||
}
|
||
}
|
||
}
|
||
Tool::Hand => {
|
||
hint(ui, tl!("Drag to pan · hold Space with any tool"));
|
||
if widgets::secondary_button(ui, "100%", 0.0).clicked()
|
||
&& let Some(i) = app.session.active_index()
|
||
{
|
||
app.ui.views[i].zoom = 1.0;
|
||
app.ui.views[i].fit_pending = false;
|
||
app.ui.views[i].fill_pending = false;
|
||
}
|
||
if widgets::secondary_button(ui, tl!("Fit Screen"), 0.0).clicked()
|
||
&& let Some(i) = app.session.active_index()
|
||
{
|
||
app.ui.views[i].fit_pending = true;
|
||
app.ui.views[i].fill_pending = false;
|
||
}
|
||
if widgets::secondary_button(ui, tl!("Fill Screen"), 0.0).clicked()
|
||
&& let Some(i) = app.session.active_index()
|
||
{
|
||
app.ui.views[i].fill_pending = true;
|
||
app.ui.views[i].fit_pending = false;
|
||
}
|
||
}
|
||
Tool::Lasso | Tool::PolygonLasso => hint(
|
||
ui,
|
||
&crate::i18n::fmt(
|
||
tl!("Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts"),
|
||
&[("add", &crate::shortcuts::pretty("Shift")), ("sub", &crate::shortcuts::pretty("Alt"))],
|
||
),
|
||
),
|
||
Tool::MagneticLasso => hint(
|
||
ui,
|
||
&crate::i18n::fmt(
|
||
tl!("Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close"),
|
||
&[("alt", &crate::shortcuts::pretty("Alt"))],
|
||
),
|
||
),
|
||
Tool::Crop => hint(
|
||
ui,
|
||
&crate::i18n::fmt(
|
||
tl!("Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels"),
|
||
&[
|
||
("ratio", &crate::shortcuts::pretty("Shift")),
|
||
("centre", &crate::shortcuts::pretty("Alt")),
|
||
("commit", &crate::shortcuts::pretty("Enter")),
|
||
],
|
||
),
|
||
),
|
||
Tool::PerspectiveCrop => hint(
|
||
ui,
|
||
&crate::i18n::fmt(
|
||
tl!("Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels"),
|
||
&[("commit", &crate::shortcuts::pretty("Enter"))],
|
||
),
|
||
),
|
||
Tool::PaintBucket => hint(ui, tl!("Click to fill similar colours")),
|
||
// Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`.
|
||
_ => {}
|
||
}
|
||
// Painting symmetry is independent of visual theme. Keep the options-bar
|
||
// action available for Brush, Pencil and Eraser in every palette (#2659).
|
||
if matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser) {
|
||
crate::symmetry_ui::menu(app, ui);
|
||
}
|
||
crate::brush_panel::commit_gesture(app, ui.ctx(), &brush_before, &brush);
|
||
});
|
||
reveal_focused_control(ui);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Width of the Layers panel's Opacity and Fill fields, with room for their pop-up slider's ▾.
|
||
const LAYER_PCT_W: f32 = 66.0 + widgets::POPUP_ARROW_W;
|
||
|
||
/// The `layer.setProps` edit of a Layers panel Opacity or Fill (`key`) field at `pct` percent. A
|
||
/// drag of the pop-up slider (`drag`) coalesces into one history step, as in Photoshop.
|
||
fn pct_action(target: &Value, key: &str, pct: f32, drag: Option<u64>) -> (String, Value) {
|
||
let mut p = target.clone();
|
||
p[key] = json!(pct / 100.0);
|
||
if let Some(n) = drag {
|
||
p["coalesce"] = json!(format!("layer-{key}-slider:{n}"));
|
||
}
|
||
("layer.setProps".into(), p)
|
||
}
|
||
|
||
fn label(ui: &mut egui::Ui, s: &str) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.label(RichText::new(s).color(t.text_dim));
|
||
}
|
||
|
||
/// Photoshop's scrubby Opacity/Fill labels: drag horizontally to change the percentage without
|
||
/// hitting the small number field. The unique drag ID coalesces every frame into one undo step.
|
||
fn scrub_pct_label(ui: &mut egui::Ui, text: &str, percent: &mut f32, max_w: f32) -> widgets::PopupFieldResponse {
|
||
// Elide to the room left in the row (full text on hover): a longer translation shrinks here
|
||
// instead of running over the fixed controls beside it (#3580).
|
||
let resp = widgets::elided_label_sensed(ui, text, max_w, Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||
let key = resp.id.with("percent-label-drag");
|
||
if resp.drag_started() {
|
||
let stamp = ui.ctx().cumulative_pass_nr();
|
||
ui.data_mut(|d| d.insert_temp(key, (*percent, stamp)));
|
||
}
|
||
let mut out = widgets::PopupFieldResponse::default();
|
||
if resp.dragged()
|
||
&& let Some((start, stamp)) = ui.data(|d| d.get_temp::<(f32, u64)>(key))
|
||
&& let (Some(origin), Some(pos)) = (ui.input(|i| i.pointer.press_origin()), resp.interact_pointer_pos())
|
||
{
|
||
// Match DragValue's 0.5 percentage-point per screen point; do not jump on press.
|
||
let next = (start + (pos.x - origin.x) * 0.5).clamp(0.0, 100.0);
|
||
if next.is_finite() && next != *percent {
|
||
*percent = next;
|
||
out.changed = true;
|
||
out.drag = Some(stamp);
|
||
}
|
||
}
|
||
if resp.drag_stopped() {
|
||
ui.data_mut(|d| d.remove::<(f32, u64)>(key));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Width of `s` as [`label`] draws it (body text).
|
||
fn body_text_width(ui: &egui::Ui, s: &str) -> f32 {
|
||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||
ui.painter().layout_no_wrap(s.to_string(), font, Color32::WHITE).size().x
|
||
}
|
||
|
||
/// Options-bar label: Photoshop writes "Size:" with a colon.
|
||
/// An options-bar label; one right before a value field scrubs it when dragged (Photoshop).
|
||
fn opt_label(ui: &mut egui::Ui, s: &str) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let text = if t.pro && !s.ends_with(':') { format!("{s}:") } else { s.to_string() };
|
||
let label = ui.add(egui::Label::new(RichText::new(tl!(&text)).color(t.text_dim)).selectable(false));
|
||
widgets::scrub_option_label(ui, &label);
|
||
}
|
||
|
||
fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive<f32>, width: f32) {
|
||
opt_label(ui, label);
|
||
let mut percent = *value * 100.0;
|
||
if widgets::value_field(ui, &mut percent, range, "%", width).changed() {
|
||
*value = percent / 100.0;
|
||
}
|
||
}
|
||
|
||
fn hint(ui: &mut egui::Ui, s: &str) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.label(RichText::new(s).color(t.text_faint));
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------- status bar
|
||
|
||
/// The status bar's height and the height of the controls in it (zoom field, Fit), which leaves
|
||
/// them an even margin above and below inside the bar (#2823).
|
||
pub fn status_bar_heights(t: &Tokens) -> (f32, f32) {
|
||
if t.pro { (24.0, 18.0) } else { (30.0, 22.0) }
|
||
}
|
||
|
||
pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
egui::Panel::bottom("status_bar")
|
||
.exact_size(status_bar_heights(&t).0)
|
||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0)))
|
||
.show(ui, |ui| {
|
||
let control_h = status_bar_heights(&t).1;
|
||
// Read from the right in a right-to-left UI, with the trailing group at the left.
|
||
crate::direction::horizontal_centered(ui, |ui| {
|
||
if t.pro {
|
||
crate::chrome_ui::status_bar_pro(app, ui);
|
||
// Background job progress with Cancel, at the far end (#210).
|
||
ui.with_layout(crate::direction::trailing(), |ui| {
|
||
crate::jobs_ui::status_progress(app, ui);
|
||
crate::background_redraw::status(app, ui);
|
||
crate::chrome_ui::gpu_memory_status(app, ui);
|
||
});
|
||
return;
|
||
}
|
||
if let (Some(st), Some(i)) = (app.session.active(), app.session.active_index()) {
|
||
let (w, h, mode, bits, layers) =
|
||
(st.doc.size.width, st.doc.size.height, crate::canvas::mode_label(&st.doc), st.doc.depth.bits(), st.doc.layer_count());
|
||
let mut pct = app.ui.views[i].zoom * 100.0;
|
||
if widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(78.0, control_h))
|
||
.changed()
|
||
{
|
||
app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size);
|
||
app.ui.views[i].fit_pending = false;
|
||
}
|
||
widgets::vline(ui, 16.0);
|
||
label(ui, &crate::i18n::fmt(tl!("{mode} Color · {bits} bit"), &[("mode", mode), ("bits", &bits.to_string())]));
|
||
widgets::vline(ui, 16.0);
|
||
label(ui, &format!("{w} × {h} px"));
|
||
widgets::vline(ui, 16.0);
|
||
label(ui, &crate::i18n::trn(crate::i18n::current(), layers as u64, "{n} layer", "{n} layers"));
|
||
} else {
|
||
label(ui, tl!("No document"));
|
||
}
|
||
if !app.ui.status.is_empty() {
|
||
widgets::vline(ui, 16.0);
|
||
let is_err = app.ui.status_error || app.ui.status.starts_with("Couldn");
|
||
ui.label(RichText::new(&app.ui.status).color(if is_err { t.warning } else { t.text_faint }));
|
||
}
|
||
ui.with_layout(crate::direction::trailing(), |ui| {
|
||
if app.session.active().is_some()
|
||
&& !t.pro
|
||
&& widgets::compact_button(ui, tl!("Fit"), control_h).clicked()
|
||
&& let Some(i) = app.session.active_index()
|
||
{
|
||
app.ui.views[i].fit_pending = true;
|
||
}
|
||
if app.session.has_jobs() {
|
||
ui.add_space(6.0);
|
||
crate::jobs_ui::status_progress(app, ui);
|
||
}
|
||
crate::background_redraw::status(app, ui);
|
||
crate::chrome_ui::gpu_memory_status(app, ui);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------- dock
|
||
|
||
pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
// Even legacy destinations must discard gestures captured by an earlier workspace.
|
||
let customized = crate::panel_docking::show(app, ui);
|
||
let t = Tokens::get(ui.ctx());
|
||
let p = app.ui.panels.clone();
|
||
if t.pro && !customized {
|
||
dock_panels(app, ui, &p, &t);
|
||
}
|
||
// Narrow icon rail, unless an embedding app hides it: shows, expands or collapses panel groups.
|
||
if p.rail {
|
||
let (rw, rb) = if t.pro { (36.0, 28.0) } else { (44.0, 32.0) };
|
||
crate::direction::end_panel("rail")
|
||
.resizable(false)
|
||
.exact_size(rw)
|
||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(4, 8)))
|
||
.show(ui, |ui| {
|
||
// The separator is on the canvas side.
|
||
let r = ui.max_rect();
|
||
let x = if crate::direction::is_rtl() { r.right() + 6.0 } else { r.left() - 6.0 };
|
||
ui.painter().line_segment([pos2(x, r.top() - 8.0), pos2(x, r.bottom() + 8.0)], Stroke::new(1.0, t.separator));
|
||
ui.spacing_mut().item_spacing.y = 4.0;
|
||
use crate::dock::Group;
|
||
let entries: [(&str, &str, Group); 6] = [
|
||
("sliders-horizontal", tl!("Properties"), Group::Properties),
|
||
("navigation", tl!("Navigator"), Group::Navigator),
|
||
("palette", tl!("Color & Swatches"), Group::Color),
|
||
("layers", tl!("Layers"), Group::Layers),
|
||
("clock", tl!("History"), Group::History),
|
||
("brush", tl!("Brushes"), Group::Brushes),
|
||
];
|
||
for (icon, name, g) in entries {
|
||
// Studio floats Properties outside the dock.
|
||
let docked = t.pro || g != Group::Properties;
|
||
let on = if customized { crate::panel_docking::rail_on(app, g) } else { g.shown(&p) && !(docked && app.ui.dock.is_collapsed(g)) };
|
||
if icons::rail_button(ui, icon, rb, on, name).clicked() {
|
||
if customized {
|
||
crate::panel_docking::rail_click(app, g);
|
||
} else {
|
||
crate::dock::rail_click(app, g, docked);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
if !t.pro && !customized {
|
||
dock_panels(app, ui, &p, &t);
|
||
}
|
||
crate::dock::persist(app, ui.ctx());
|
||
}
|
||
|
||
/// The dock's width range (points). It is the right dock, on the left in a right-to-left UI.
|
||
pub(crate) const DOCK_WIDTH: std::ops::RangeInclusive<f32> = 250.0..=520.0;
|
||
|
||
pub(crate) fn dock_width_id() -> egui::Id {
|
||
egui::Id::new("dock-width-request")
|
||
}
|
||
|
||
/// Set the right dock's width on the next frame (`ui.set {dockWidth}`); clamped to its range.
|
||
pub fn request_dock_width(ctx: &egui::Context, w: f32) {
|
||
let w = if w.is_finite() { w.clamp(*DOCK_WIDTH.start(), *DOCK_WIDTH.end()) } else { *DOCK_WIDTH.start() };
|
||
ctx.data_mut(|d| d.insert_temp(dock_width_id(), w));
|
||
}
|
||
|
||
fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Panels, t: &Tokens) {
|
||
use crate::dock::Group;
|
||
// Floating in Studio, Properties docks only in Pro (Photoshop).
|
||
let shown: Vec<Group> = [
|
||
(Group::Color, p.color),
|
||
(Group::Properties, t.pro && p.properties),
|
||
(Group::Character, p.character),
|
||
(Group::Navigator, p.navigator),
|
||
(Group::History, p.history),
|
||
(Group::Layers, p.layers),
|
||
(Group::Brushes, p.brushes),
|
||
]
|
||
.into_iter()
|
||
.filter_map(|(g, on)| on.then_some(g))
|
||
.collect();
|
||
if shown.is_empty() {
|
||
return;
|
||
}
|
||
let margin = if t.pro { 2 } else { 8 };
|
||
let mut panel = crate::direction::end_panel("dock").resizable(true).default_size(if t.pro { 290.0 } else { 300.0 }).size_range(DOCK_WIDTH);
|
||
if let Some(w) = ui.ctx().data_mut(|d| d.remove_temp::<f32>(dock_width_id())) {
|
||
panel = panel.exact_size(w);
|
||
}
|
||
panel.frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::same(margin))).show(ui, |ui| {
|
||
// Groups keep their heights whatever they show (#88): see `dock`.
|
||
crate::dock::show(app, ui, &shown, dock_body);
|
||
});
|
||
}
|
||
|
||
/// One dock group's tab content; `dock` bounds it and scrolls it when it's taller.
|
||
pub(crate) fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate::dock::Group, tab: usize) {
|
||
use crate::dock::Group;
|
||
let pro = Tokens::get(ui.ctx()).pro;
|
||
match (group, tab) {
|
||
(Group::Color, 2) => crate::preset_panels::gradients_panel(app, ui),
|
||
(Group::Color, 3) => crate::preset_panels::patterns_panel(app, ui),
|
||
(Group::Color, 0) if pro => color_field(app, ui),
|
||
(Group::Color, _) if pro => crate::swatches_ui::panel(app, ui),
|
||
(Group::Color, 0) => crate::swatches_ui::panel(app, ui),
|
||
(Group::Color, _) => color_picker(app, ui),
|
||
(Group::Properties, 0) => properties_body(app, ui),
|
||
(Group::Properties, _) => adjustments_grid(app, ui),
|
||
(Group::Character, tab) => crate::type_tool::character_panel(app, ui, tab == 1),
|
||
(Group::Navigator, 0) => navigator(app, ui),
|
||
(Group::Navigator, 1) => crate::tone::histogram_panel(app, ui),
|
||
(Group::Navigator, _) => info_panel(app, ui),
|
||
(Group::History, 0) => history(app, ui),
|
||
(Group::History, 1) => crate::actions::panel(app, ui),
|
||
(Group::History, _) => crate::comps_ui::panel(app, ui),
|
||
(Group::Layers, 0) => layers(app, ui),
|
||
(Group::Layers, 1) => channels(app, ui),
|
||
(Group::Layers, _) => crate::vector_ui::paths_panel(app, ui),
|
||
(Group::Brushes, _) => crate::brushes_tab::show(app, ui),
|
||
}
|
||
}
|
||
|
||
/// Photoshop's Info panel: colour under the pointer (RGB and CMYK), position, selection size.
|
||
fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let Some(st) = app.session.active() else {
|
||
empty(ui, tl!("No document"));
|
||
return;
|
||
};
|
||
let (rev, size, origin) = (st.revision, st.doc.size, st.doc.ruler_origin);
|
||
// Positions and sizes in the ruler unit (Preferences › Units & Rulers).
|
||
let units = app.session.prefs().units_and_rulers.clone();
|
||
let dpi = st.doc.resolution_dpi as f64;
|
||
let fx = move |px: f64| units.format(px, dpi, size.width as f64);
|
||
let units_y = app.session.prefs().units_and_rulers.clone();
|
||
let fy = move |px: f64| units_y.format(px, dpi, size.height as f64);
|
||
let sel = st.doc.selection.as_ref().map(photocraft_compose::bounds::content_bounds);
|
||
let pos = app
|
||
.hover_doc
|
||
.map(|p| (p[0].floor() as i32, p[1].floor() as i32))
|
||
.filter(|(x, y)| *x >= 0 && *y >= 0 && *x < size.width as i32 && *y < size.height as i32);
|
||
// While a guide is dragged (#5728), the Info panel shows its position in one axis only, even
|
||
// when the pointer is outside the canvas (ruler drags do not update `hover_doc` there).
|
||
let (guide_x, guide_y) = match app.guide_drag {
|
||
Some(d) if d.vertical => (Some(d.pos), None),
|
||
Some(d) => (None, Some(d.pos)),
|
||
None => (None, None),
|
||
};
|
||
// The readout averages the Eyedropper's Sample Size, as in Photoshop (#1649).
|
||
let sample_size = app.ui.tool_options.eyedropper_size;
|
||
let rgba = pos.and_then(|(x, y)| {
|
||
if let Some(((cx, cy, cr, cs), v)) = app.info_sample
|
||
&& (cx, cy, cr, cs) == (x, y, rev, sample_size)
|
||
{
|
||
return Some(v);
|
||
}
|
||
let params = json!({"x": f64::from(x) + 0.5, "y": f64::from(y) + 0.5, "size": sample_size});
|
||
let v: Vec<f32> = serde_json::from_value(app.session.execute("document.sampleColor", params).ok()?).ok()?;
|
||
let v = [*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?];
|
||
app.info_sample = Some(((x, y, rev, sample_size), v));
|
||
Some(v)
|
||
});
|
||
let mono = theme::mono(11.5);
|
||
let row = |ui: &mut egui::Ui, k: &str, v: String| {
|
||
ui.horizontal(|ui| {
|
||
ui.add_sized([18.0, 16.0], egui::Label::new(RichText::new(k).color(t.text_dim).size(11.5)));
|
||
ui.label(RichText::new(v).font(mono.clone()).color(t.text));
|
||
});
|
||
};
|
||
ui.columns(2, |cols| {
|
||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as i32;
|
||
let dash = || "—".to_string();
|
||
row(&mut cols[0], "R:", rgba.map_or_else(dash, |c| q(c[0]).to_string()));
|
||
row(&mut cols[0], "G:", rgba.map_or_else(dash, |c| q(c[1]).to_string()));
|
||
row(&mut cols[0], "B:", rgba.map_or_else(dash, |c| q(c[2]).to_string()));
|
||
// Naive device CMYK (no ICC profile yet), in percent like Photoshop's readout. The Color
|
||
// Sampler readout uses the same conversion (`sampler_cmds::device_cmyk`).
|
||
let cmyk = rgba.map(|c| photocraft_engine::sampler_cmds::device_cmyk(c[0], c[1], c[2]));
|
||
for (i, k) in ["C:", "M:", "Y:", "K:"].iter().enumerate() {
|
||
row(&mut cols[1], k, cmyk.map_or_else(dash, |c| format!("{}%", c[i])));
|
||
}
|
||
});
|
||
widgets::hairline(ui);
|
||
ui.columns(2, |cols| {
|
||
let dash = || "—".to_string();
|
||
// Measured from the rulers' zero point (#2955).
|
||
row(&mut cols[0], "X:", guide_x.map(|p| fx(p - origin[0])).or_else(|| pos.map(|p| fx(p.0 as f64 - origin[0]))).unwrap_or_else(dash));
|
||
row(&mut cols[0], "Y:", guide_y.map(|p| fy(p - origin[1])).or_else(|| pos.map(|p| fy(p.1 as f64 - origin[1]))).unwrap_or_else(dash));
|
||
row(&mut cols[1], "W:", sel.map_or_else(dash, |r| fx(r.width() as f64)));
|
||
row(&mut cols[1], "H:", sel.map_or_else(dash, |r| fy(r.height() as f64)));
|
||
});
|
||
widgets::hairline(ui);
|
||
ui.label(
|
||
RichText::new(crate::i18n::fmt(tl!("Doc: {w} × {h} px"), &[("w", &size.width.to_string()), ("h", &size.height.to_string())]))
|
||
.color(t.text_dim)
|
||
.size(11.5),
|
||
);
|
||
// Color Sampler points (#1046): each under its number, with its position in the current ruler
|
||
// unit and the same RGB/CMYK readout as the pointer (one composite pixel each).
|
||
let samplers = sampler_readouts(app);
|
||
if !samplers.is_empty() {
|
||
widgets::hairline(ui);
|
||
let cell = |ui: &mut egui::Ui, s: String| {
|
||
ui.label(RichText::new(s).font(mono.clone()).color(t.text));
|
||
};
|
||
for s in samplers.iter() {
|
||
let n = s["number"].as_i64().unwrap_or(0);
|
||
let at = s["position"]
|
||
.as_array()
|
||
.map_or((0.0, 0.0), |a| (a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)));
|
||
let num = |v: &Value, i: usize| v.get(i).and_then(Value::as_i64).map_or_else(|| "—".to_string(), |n| n.to_string());
|
||
let (rgb, cmyk) = (&s["rgb"], &s["cmyk"]);
|
||
ui.horizontal(|ui| {
|
||
ui.add_sized([24.0, 16.0], egui::Label::new(RichText::new(format!("#{n}")).color(t.text_dim).size(11.5)));
|
||
cell(ui, format!("X: {} Y: {}", fx(at.0), fy(at.1)));
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.add_sized([24.0, 16.0], egui::Label::new(RichText::new("").size(11.5)));
|
||
cell(ui, format!("R:{} G:{} B:{}", num(rgb, 0), num(rgb, 1), num(rgb, 2)));
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.add_sized([24.0, 16.0], egui::Label::new(RichText::new("").size(11.5)));
|
||
cell(ui, format!("C:{}% M:{}% Y:{}% K:{}%", num(cmyk, 0), num(cmyk, 1), num(cmyk, 2), num(cmyk, 3)));
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The Info panel's Color Sampler readouts and the (document, revision) they were composited for.
|
||
pub(crate) type SamplerReadouts = ((photocraft_doc::DocId, u64), std::sync::Arc<Vec<Value>>);
|
||
|
||
/// The Color Sampler readouts of the active document, composited once per revision (#4528):
|
||
/// adding, moving or deleting a sampler and every pixel edit bump the revision, so between them
|
||
/// the Info panel reuses them instead of compositing each point through the stack every frame.
|
||
pub(crate) fn sampler_readouts(app: &mut PhotocraftApp) -> std::sync::Arc<Vec<Value>> {
|
||
let Some(key) = app.session.active().map(|s| (s.doc.id, s.revision)) else { return std::sync::Arc::default() };
|
||
if let Some((k, v)) = &app.info_samplers
|
||
&& *k == key
|
||
{
|
||
return v.clone();
|
||
}
|
||
let list = app.session.execute("view.colorSamplers.list", json!({})).ok();
|
||
let v = std::sync::Arc::new(list.as_ref().and_then(|v| v["samplers"].as_array()).cloned().unwrap_or_default());
|
||
app.info_samplers = Some((key, v.clone()));
|
||
v
|
||
}
|
||
|
||
fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let Some(idx) = app.session.active_index() else {
|
||
empty(ui, tl!("No document"));
|
||
return;
|
||
};
|
||
let ctx = ui.ctx().clone();
|
||
let size = app.session.documents()[idx].doc.size;
|
||
let avail = ui.available_width();
|
||
// The preview fills the group's height above the zoom controls (their height as measured
|
||
// last frame), so it grows when the group is made taller, as in Photoshop (#2726).
|
||
let controls_h = ui.data(|d| d.get_temp::<f32>(navigator_controls_id())).unwrap_or(48.0);
|
||
let left = ui.available_height() - controls_h;
|
||
let box_h = if left.is_finite() { left.max(40.0) } else { 150.0 };
|
||
let (frame, resp) = ui.allocate_exact_size(vec2(avail, box_h), Sense::click_and_drag());
|
||
ui.painter().rect_filled(frame, t.radius_sm, t.canvas);
|
||
let rect = navigator_fit(frame, size.width, size.height);
|
||
ui.data_mut(|d| d.insert_temp(navigator_preview_id(), rect));
|
||
// On the GPU canvas, its own texture at this size (#4518); otherwise the CPU thumbnail.
|
||
if let Some(params) = crate::canvas::navigator_gpu_view(app, idx, rect, &ctx) {
|
||
crate::gpu_canvas::GpuCanvas::paint(ui.painter(), rect, params);
|
||
} else if crate::canvas::navigator_waits_for_gpu(app, idx, &ctx) {
|
||
widgets::checker(ui.painter(), rect, 6.0);
|
||
} else {
|
||
let Some(tex) = crate::canvas::navigator_texture(app, &ctx, idx) else { return };
|
||
widgets::checker(ui.painter(), rect, 6.0);
|
||
ui.painter().image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
|
||
}
|
||
let s = rect.width() / size.width.max(1) as f32;
|
||
// ⌘/Ctrl-drag over the preview draws a rectangle the view then zooms to, as in Photoshop
|
||
// (#6872); a plain click or drag moves the view.
|
||
let command = crate::workspace_ui::sticky_mods(app, ctx.input(|i| i.modifiers)).command;
|
||
if resp.drag_started() && command {
|
||
let start = ctx.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos());
|
||
ui.data_mut(|d| match start {
|
||
Some(p) => {
|
||
d.insert_temp(navigator_zoom_drag_id(), p);
|
||
}
|
||
None => d.remove::<Pos2>(navigator_zoom_drag_id()),
|
||
});
|
||
}
|
||
let zoom_from = ui.data(|d| d.get_temp::<Pos2>(navigator_zoom_drag_id()));
|
||
if let Some(from) = zoom_from {
|
||
if let Some(to) = resp.interact_pointer_pos() {
|
||
let r = Rect::from_two_pos(from, to).intersect(rect);
|
||
crate::tool_feedback::draw_ants(ui.painter(), &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], true);
|
||
if resp.drag_stopped() {
|
||
let doc_rect = Rect::from_min_max(((r.min - rect.min) / s).to_pos2(), ((r.max - rect.min) / s).to_pos2());
|
||
let (canvas, ppp, aspect) = (app.last_canvas_rect, app.canvas_ppp(), app.ui.view.display_aspect());
|
||
navigator_zoom_to(&mut app.ui.views[idx], doc_rect, [size.width, size.height], canvas.size(), ppp, aspect);
|
||
}
|
||
}
|
||
if resp.drag_stopped() || !resp.dragged() {
|
||
ui.data_mut(|d| d.remove::<Pos2>(navigator_zoom_drag_id()));
|
||
}
|
||
} else if (resp.clicked() || resp.dragged())
|
||
&& let Some(pp) = resp.interact_pointer_pos()
|
||
{
|
||
let d = (pp - rect.min) / s;
|
||
app.ui.views[idx].center = [d.x.clamp(0.0, size.width as f32), d.y.clamp(0.0, size.height as f32)];
|
||
app.ui.views[idx].fit_pending = false;
|
||
}
|
||
if command && resp.hovered() {
|
||
ctx.set_cursor_icon(egui::CursorIcon::ZoomIn);
|
||
}
|
||
// The Navigator has its own zoom target: an unmodified wheel over the preview
|
||
// should zoom even when the document canvas is configured to scroll on wheel.
|
||
// Use the canvas wheel classifier to honour one ×1.1 step per notch without
|
||
// applying egui's smoothed wheel tail a second time.
|
||
let pinch_zooms = app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch;
|
||
let touch_gestures = app.session.prefs().enhanced_controls.touch_gestures;
|
||
if resp.hovered()
|
||
&& let Some(crate::wheel_nav::Wheel::Zoom(factor)) =
|
||
crate::wheel_nav::read_with(&ctx, true, pinch_zooms, egui::Id::new("pc-navigator-wheel"), true, true, touch_gestures)
|
||
{
|
||
let view = &mut app.ui.views[idx];
|
||
let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size);
|
||
if next != view.zoom {
|
||
view.zoom = next;
|
||
view.fit_pending = false;
|
||
}
|
||
}
|
||
// Visible-area rectangle.
|
||
let v = app.ui.views[idx].clone();
|
||
let canvas = app.last_canvas_rect;
|
||
let point_zoom = (v.zoom / app.canvas_ppp()).max(1e-6);
|
||
// Pixel aspect correction shows fewer document columns across the same canvas width.
|
||
let vw = canvas.width() / point_zoom / app.ui.view.display_aspect() * s;
|
||
let vh = canvas.height() / point_zoom * s;
|
||
let c = pos2(rect.min.x + v.center[0] * s, rect.min.y + v.center[1] * s);
|
||
let vr = Rect::from_center_size(c, vec2(vw, vh)).intersect(frame.shrink(1.0));
|
||
ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::from_rgb(255, 84, 84)), StrokeKind::Middle);
|
||
// Photoshop's one row under the preview (#4840): the zoom, typed in percent, then Zoom Out,
|
||
// the slider and Zoom In.
|
||
let controls = ui.vertical(|ui| {
|
||
ui.add_space(4.0);
|
||
ui.horizontal(|ui| {
|
||
ui.spacing_mut().item_spacing.x = 4.0;
|
||
let set = |app: &mut PhotocraftApp, zoom: f32| {
|
||
app.ui.views[idx].zoom = crate::zoom_levels::clamp(zoom, v.doc_size);
|
||
app.ui.views[idx].fit_pending = false;
|
||
};
|
||
let mut pct = v.zoom * 100.0;
|
||
let field = widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[idx]), "%", vec2(64.0, NAV_ROW_H));
|
||
if field.changed() {
|
||
set(app, pct / 100.0);
|
||
}
|
||
let out = crate::brush_picker::named(icons::button(ui, "minus", NAV_ROW_H, false, tl!("Zoom Out")), "Zoom Out");
|
||
if out.clicked() {
|
||
set(app, crate::zoom_levels::step(v.zoom, -1, v.doc_size));
|
||
}
|
||
// The whole zoom range, and always the current zoom: a narrower slider would pull it back.
|
||
let (lo, hi) = (crate::zoom_levels::min(v.doc_size).min(v.zoom).log2(), crate::zoom_levels::MAX.max(v.zoom).log2());
|
||
let mut lz = v.zoom.log2();
|
||
let slider_w = (ui.available_width() - NAV_ROW_H - 4.0).max(60.0);
|
||
let slid = ui.allocate_ui(vec2(slider_w, NAV_ROW_H), |ui| lz.is_finite() && widgets::slider(ui, &mut lz, lo..=hi, None).changed()).inner;
|
||
if slid {
|
||
set(app, 2f32.powf(lz));
|
||
}
|
||
let zin = crate::brush_picker::named(icons::button(ui, "plus", NAV_ROW_H, false, tl!("Zoom In")), "Zoom In");
|
||
if zin.clicked() {
|
||
set(app, crate::zoom_levels::step(v.zoom, 1, v.doc_size));
|
||
}
|
||
});
|
||
});
|
||
// From the preview's bottom, so the spacing above the controls counts too.
|
||
let controls = Rect::from_min_max(pos2(frame.left(), frame.bottom()), pos2(frame.right(), controls.response.rect.bottom()));
|
||
ui.data_mut(|d| {
|
||
d.insert_temp(navigator_controls_id(), controls.height());
|
||
d.insert_temp(navigator_controls_rect_id(), controls);
|
||
});
|
||
}
|
||
|
||
/// Height of the Navigator's zoom row: its field, buttons and slider.
|
||
const NAV_ROW_H: f32 = 22.0;
|
||
|
||
/// The document's `w` × `h` fitted inside the Navigator's `frame` with an 8 pt inset, centred and
|
||
/// keeping its aspect.
|
||
fn navigator_fit(frame: Rect, w: u32, h: u32) -> Rect {
|
||
let inner = (frame.size() - vec2(16.0, 16.0)).max(vec2(1.0, 1.0));
|
||
let (w, h) = (w.max(1) as f32, h.max(1) as f32);
|
||
let s = (inner.x / w).min(inner.y / h);
|
||
Rect::from_center_size(frame.center(), vec2(w * s, h * s))
|
||
}
|
||
|
||
/// Where a ⌘/Ctrl-drag over the Navigator's preview started, while it lasts.
|
||
fn navigator_zoom_drag_id() -> egui::Id {
|
||
egui::Id::new("navigator-zoom-drag")
|
||
}
|
||
|
||
/// Zoom `view` so the document rectangle `doc_rect` (pixels) fills a canvas of `canvas` points,
|
||
/// centred on it, as a Navigator ⌘/Ctrl-drag does (#6872). `doc` is the document's size in
|
||
/// pixels (the view's own copy is only set once the canvas has been drawn), `ppp` the canvas's
|
||
/// device pixels per point and `aspect` the pixel aspect ratio shown; the zoom stays within the
|
||
/// zoom range. A rectangle under 2 document pixels across zooms one step in on its centre.
|
||
fn navigator_zoom_to(view: &mut crate::state::View, doc_rect: Rect, doc: [u32; 2], canvas: egui::Vec2, ppp: f32, aspect: f32) {
|
||
// Checked before clipping: `min`/`max` would turn a NaN edge into a document edge.
|
||
if ![doc_rect.min.x, doc_rect.min.y, doc_rect.max.x, doc_rect.max.y].iter().all(|v| v.is_finite()) {
|
||
return;
|
||
}
|
||
let (w, h) = (doc[0] as f32, doc[1] as f32);
|
||
let r = doc_rect.intersect(Rect::from_min_size(Pos2::ZERO, vec2(w, h)));
|
||
if r.width() < 0.0 || r.height() < 0.0 {
|
||
return;
|
||
}
|
||
let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 };
|
||
let zoom = if r.width() < 2.0 || r.height() < 2.0 || canvas.x <= 0.0 || canvas.y <= 0.0 {
|
||
crate::zoom_levels::step(view.zoom, 1, view.doc_size)
|
||
} else {
|
||
// Document columns shown across the canvas are `canvas.x / (point_zoom * aspect)`.
|
||
let point_zoom = (canvas.x / (r.width() * aspect)).min(canvas.y / r.height());
|
||
point_zoom * ppp
|
||
};
|
||
if !zoom.is_finite() {
|
||
return;
|
||
}
|
||
view.zoom = crate::zoom_levels::clamp(zoom, view.doc_size);
|
||
view.center = [r.center().x, r.center().y];
|
||
view.fit_pending = false;
|
||
}
|
||
|
||
fn navigator_preview_id() -> egui::Id {
|
||
egui::Id::new("navigator-preview-rect")
|
||
}
|
||
|
||
fn navigator_controls_id() -> egui::Id {
|
||
egui::Id::new("navigator-controls-height")
|
||
}
|
||
|
||
fn navigator_controls_rect_id() -> egui::Id {
|
||
egui::Id::new("navigator-controls-rect")
|
||
}
|
||
|
||
/// Where the Navigator drew its document preview last frame.
|
||
#[cfg(test)]
|
||
pub(crate) fn last_navigator_preview(ctx: &egui::Context) -> Option<Rect> {
|
||
ctx.data(|d| d.get_temp(navigator_preview_id()))
|
||
}
|
||
|
||
/// The Navigator's zoom controls (below the preview) last frame.
|
||
#[cfg(test)]
|
||
pub(crate) fn last_navigator_controls(ctx: &egui::Context) -> Option<Rect> {
|
||
ctx.data(|d| d.get_temp(navigator_controls_rect_id()))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod navigator_wheel_tests {
|
||
use super::*;
|
||
use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase};
|
||
|
||
fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, pointer: Pos2, wheel: f32) {
|
||
let mut events = vec![Event::PointerMoved(pointer)];
|
||
if wheel != 0.0 {
|
||
events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, wheel), phase: TouchPhase::Move, modifiers: Modifiers::NONE });
|
||
}
|
||
let mut out = ctx
|
||
.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(430.0, 460.0))), events, ..Default::default() }, |ui| navigator(app, ui));
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
#[test]
|
||
fn navigator_wheel_changes_zoom_only_when_over_preview() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||
app.sync_views();
|
||
app.ui.views[0].zoom = 1.0;
|
||
app.ui.views[0].fit_pending = false;
|
||
let ctx = egui::Context::default();
|
||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
crate::wheel_nav::configure(&ctx);
|
||
frame(&mut app, &ctx, pos2(50.0, 55.0), 0.0);
|
||
let center = app.ui.views[0].center;
|
||
frame(&mut app, &ctx, pos2(50.0, 55.0), 1.0);
|
||
assert!((app.ui.views[0].zoom - 1.1).abs() < 1e-4, "one wheel notch zooms once");
|
||
assert_eq!(app.ui.views[0].center, center, "Navigator wheel must not pan");
|
||
frame(&mut app, &ctx, pos2(50.0, 55.0), -1.0);
|
||
assert!((app.ui.views[0].zoom - 1.0).abs() < 1e-4);
|
||
// Below the preview (it now fills the group, #2726): over the zoom controls.
|
||
let below = last_navigator_controls(&ctx).map_or(pos2(50.0, 455.0), |r| pos2(r.left() + 4.0, r.bottom() - 2.0));
|
||
frame(&mut app, &ctx, below, 1.0);
|
||
assert!((app.ui.views[0].zoom - 1.0).abs() < 1e-4, "wheel outside preview leaves zoom unchanged");
|
||
}
|
||
|
||
/// #6872: the zoom a Navigator ⌘/Ctrl-drag sets: the rectangle fills the canvas, centred.
|
||
#[test]
|
||
fn navigator_zoom_to_fits_the_rectangle_in_the_canvas() {
|
||
let fresh = || crate::state::View { doc_size: [200, 100], zoom: 1.0, center: [100.0, 50.0], ..Default::default() };
|
||
let canvas = vec2(800.0, 600.0);
|
||
let r = Rect::from_min_max(pos2(50.0, 25.0), pos2(100.0, 50.0));
|
||
// 50 × 25 px on 800 × 600 pt: the width limits it, 16 points per pixel.
|
||
let mut v = fresh();
|
||
navigator_zoom_to(&mut v, r, [200, 100], canvas, 1.0, 1.0);
|
||
assert_eq!((v.zoom, v.center, v.fit_pending), (crate::zoom_levels::clamp(16.0, [200, 100]), [75.0, 37.5], false));
|
||
// Device pixels per point and a 2:1 pixel aspect scale it as the canvas shows pixels.
|
||
let mut v = fresh();
|
||
navigator_zoom_to(&mut v, r, [200, 100], canvas, 2.0, 2.0);
|
||
assert_eq!(v.zoom, crate::zoom_levels::clamp(16.0, [200, 100]), "8 points per pixel across, at 2 device pixels per point");
|
||
// Off the document: only the part on it counts.
|
||
let mut v = fresh();
|
||
navigator_zoom_to(&mut v, Rect::from_min_max(pos2(150.0, 50.0), pos2(400.0, 300.0)), [200, 100], canvas, 1.0, 1.0);
|
||
assert_eq!(v.center, [175.0, 75.0]);
|
||
// Under 2 px across: one step in on its centre.
|
||
let mut v = fresh();
|
||
navigator_zoom_to(&mut v, Rect::from_min_max(pos2(10.0, 10.0), pos2(11.0, 40.0)), [200, 100], canvas, 1.0, 1.0);
|
||
assert_eq!((v.zoom, v.center), (crate::zoom_levels::step(1.0, 1, [200, 100]), [10.5, 25.0]));
|
||
// Not a number, or wholly off the document: nothing changes.
|
||
for bad in [Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(10.0, 10.0)), Rect::from_min_max(pos2(300.0, 300.0), pos2(400.0, 400.0))] {
|
||
let mut v = fresh();
|
||
navigator_zoom_to(&mut v, bad, [200, 100], canvas, 1.0, 1.0);
|
||
assert_eq!((v.zoom, v.center), (1.0, [100.0, 50.0]), "{bad:?}");
|
||
}
|
||
}
|
||
|
||
/// One frame of the Navigator with pointer `events` and `modifiers` held.
|
||
fn frame_with(app: &mut PhotocraftApp, ctx: &egui::Context, modifiers: Modifiers, mut events: Vec<Event>) {
|
||
events.insert(0, Event::ModifiersChanged(modifiers));
|
||
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(430.0, 460.0))), events, ..Default::default() };
|
||
ctx.run_ui(input, |ui| navigator(app, ui)).textures_delta.clear();
|
||
}
|
||
|
||
/// A drag over the Navigator's preview between document points `from` and `to`.
|
||
fn drag(app: &mut PhotocraftApp, ctx: &egui::Context, modifiers: Modifiers, from: [f32; 2], to: [f32; 2]) {
|
||
let preview = last_navigator_preview(ctx).expect("the preview");
|
||
let s = preview.width() / 200.0;
|
||
let at = |p: [f32; 2]| preview.min + vec2(p[0], p[1]) * s;
|
||
let (a, b) = (at(from), at(to));
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||
frame_with(app, ctx, modifiers, vec![Event::PointerMoved(a), button(a, true)]);
|
||
frame_with(app, ctx, modifiers, vec![Event::PointerMoved(a.lerp(b, 0.5))]);
|
||
frame_with(app, ctx, modifiers, vec![Event::PointerMoved(b)]);
|
||
frame_with(app, ctx, modifiers, vec![button(b, false)]);
|
||
frame_with(app, ctx, Modifiers::NONE, vec![]);
|
||
}
|
||
|
||
/// #6872: ⌘/Ctrl-dragging a rectangle over the preview zooms the view to it, as in
|
||
/// Photoshop; a plain drag still only moves the view.
|
||
#[test]
|
||
fn navigator_command_drag_zooms_to_the_rectangle() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||
app.sync_views();
|
||
app.ui.views[0].zoom = 1.0;
|
||
app.ui.views[0].fit_pending = false;
|
||
let ctx = egui::Context::default();
|
||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
// The preview settles once the zoom row under it has been measured.
|
||
for _ in 0..3 {
|
||
frame(&mut app, &ctx, pos2(5.0, 5.0), 0.0);
|
||
}
|
||
// A plain drag pans and keeps the zoom.
|
||
drag(&mut app, &ctx, Modifiers::NONE, [50.0, 25.0], [100.0, 50.0]);
|
||
assert_eq!(app.ui.views[0].zoom, 1.0);
|
||
let c = app.ui.views[0].center;
|
||
assert!((c[0] - 100.0).abs() < 1.0 && (c[1] - 50.0).abs() < 1.0, "the view follows the pointer: {c:?}");
|
||
// ⌘/Ctrl-drag: the rectangle fills the canvas (`last_canvas_rect`, 800 × 600 pt).
|
||
drag(&mut app, &ctx, Modifiers::COMMAND, [50.0, 25.0], [100.0, 50.0]);
|
||
let mut expected = app.ui.views[0].clone();
|
||
navigator_zoom_to(
|
||
&mut expected,
|
||
Rect::from_min_max(pos2(50.0, 25.0), pos2(100.0, 50.0)),
|
||
[200, 100],
|
||
vec2(800.0, 600.0),
|
||
app.canvas_ppp(),
|
||
app.ui.view.display_aspect(),
|
||
);
|
||
let v = &app.ui.views[0];
|
||
assert!((v.zoom - expected.zoom).abs() / expected.zoom < 0.05, "{} vs {}", v.zoom, expected.zoom);
|
||
assert!(
|
||
(v.center[0] - 75.0).abs() < 1.0 && (v.center[1] - 37.5).abs() < 1.0,
|
||
"centred on the rectangle: {:?} (zoom {}, expected {:?})",
|
||
v.center,
|
||
v.zoom,
|
||
(expected.zoom, expected.center)
|
||
);
|
||
assert!(v.zoom > 1.0 && !v.fit_pending);
|
||
assert!(ctx.data(|d| d.get_temp::<Pos2>(navigator_zoom_drag_id())).is_none(), "the drag is over");
|
||
}
|
||
|
||
/// #4840: under the preview, one row as in Photoshop: the zoom in percent, Zoom Out, the
|
||
/// slider and Zoom In. The buttons step the zoom like View › Zoom In / Out.
|
||
#[test]
|
||
fn navigator_zoom_row_is_one_line_and_its_buttons_step_the_zoom() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||
app.sync_views();
|
||
app.ui.views[0].zoom = 1.0;
|
||
app.ui.views[0].fit_pending = false;
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(360.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| navigator(app, ui), app);
|
||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||
h.run_steps(4);
|
||
let row = last_navigator_controls(&h.ctx).expect("the zoom row");
|
||
assert!(row.height() <= NAV_ROW_H + 14.0, "one row (with the gap above it): {row:?}");
|
||
let zoom = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().ui.views[0].zoom;
|
||
h.get_by_label("Zoom In").click();
|
||
h.run_steps(2);
|
||
assert!((zoom(&h) - crate::zoom_levels::step(1.0, 1, [200, 100])).abs() < 1e-4, "{}", zoom(&h));
|
||
h.get_by_label("Zoom Out").click();
|
||
h.run_steps(2);
|
||
h.get_by_label("Zoom Out").click();
|
||
h.run_steps(2);
|
||
assert!((zoom(&h) - crate::zoom_levels::step(1.0, -1, [200, 100])).abs() < 1e-4, "{}", zoom(&h));
|
||
assert!(!h.state().ui.views[0].fit_pending);
|
||
}
|
||
}
|
||
|
||
fn empty(ui: &mut egui::Ui, s: &str) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.add_space(6.0);
|
||
ui.label(RichText::new(s).color(t.text_faint));
|
||
ui.add_space(6.0);
|
||
}
|
||
|
||
/// The Color tab of the themes other than Photoshop's: the same spectrum field (chips, saturation
|
||
/// and brightness field, hue strip and readout) with HSB sliders under it (#4367).
|
||
fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
color_field(app, ui);
|
||
ui.add_space(8.0);
|
||
hsb_sliders(app, ui);
|
||
}
|
||
|
||
/// Hue, Saturation and Brightness sliders for the foreground colour.
|
||
fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let fg = app.session.tools.foreground;
|
||
// Keep the last edited HSB while it still gives the foreground: black and greys have no hue or
|
||
// saturation of their own, so recomputing them from RGB would reset what was just typed to 0.
|
||
let key = egui::Id::new("color-picker-hsva");
|
||
let stored: Option<egui::ecolor::Hsva> = ui.data(|d| d.get_temp(key));
|
||
let hsva0 = stored.filter(|h| srgb_bytes(hsva_srgb(*h)) == srgb_bytes(fg)).unwrap_or_else(|| srgb_hsva(fg));
|
||
let mut h = hsva0.h * 360.0;
|
||
let mut s = hsva0.s * 100.0;
|
||
let mut v = hsva0.v * 100.0;
|
||
let hue = widgets::hue_stops();
|
||
// Preferences ▸ Interface ▸ Dynamic Color Sliders: off, the tracks are the plain ones.
|
||
let dynamic = app.session.prefs().interface.dynamic_color_sliders;
|
||
let mut changed = widgets::slider_row(ui, tl!("Hue"), &mut h, 0.0..=360.0, "°", dynamic.then_some(&hue)).changed();
|
||
let sat_stops =
|
||
[egui::ecolor::Hsva::new(hsva0.h, 0.0, hsva0.v.max(0.2), 1.0), egui::ecolor::Hsva::new(hsva0.h, 1.0, hsva0.v.max(0.2), 1.0)].map(hsva_color32);
|
||
changed |= widgets::slider_row(ui, tl!("Saturation"), &mut s, 0.0..=100.0, "%", dynamic.then_some(&sat_stops)).changed();
|
||
let val_stops = [Color32::BLACK, hsva_color32(egui::ecolor::Hsva::new(hsva0.h, hsva0.s, 1.0, 1.0))];
|
||
changed |= widgets::slider_row(ui, tl!("Brightness"), &mut v, 0.0..=100.0, "%", dynamic.then_some(&val_stops)).changed();
|
||
let hsva = egui::ecolor::Hsva::new(h / 360.0, s / 100.0, v / 100.0, 1.0);
|
||
// Only an edit counts: the h/s/v round trip isn't exact, so comparing values would rewrite
|
||
// the foreground (and recolour selected type) every frame.
|
||
if changed {
|
||
app.session.tools.foreground = hsva_srgb(hsva);
|
||
ui.data_mut(|d| d.insert_temp(key, hsva));
|
||
crate::type_tool::foreground_changed(app);
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------- layers
|
||
|
||
/// The simple themes' single lock button. On the Background it converts the layer to a normal
|
||
/// one, as clicking the Background's lock does in Photoshop. Elsewhere it clears every lock when
|
||
/// any is set, and locks all otherwise. Toggling only "lock all" would leave the Background's
|
||
/// transparency lock (still showing as locked), and a second click would lock its pixels,
|
||
/// shutting out the Eraser and every other paint tool (#76).
|
||
/// The Basic layout's lock toggle: the active layer's state decides, and every target follows it
|
||
/// (`targets`: `{"layer": id}` or `{"layers": [ids]}`).
|
||
fn simple_lock_toggle(background: bool, l: &Layer, targets: &Value) -> (String, Value) {
|
||
if background {
|
||
return ("layer.new.layerFromBackground".into(), json!({}));
|
||
}
|
||
let locks =
|
||
if l.locks.any() { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) };
|
||
let mut p = targets.clone();
|
||
p["locks"] = locks;
|
||
("layer.setProps".into(), p)
|
||
}
|
||
|
||
/// The Layers panel's blend modes: Photoshop's, plus the layer's own Paint.NET mode when it has one
|
||
/// (an imported `.pdn`), so the menu matches Photoshop's for every other layer.
|
||
fn blend_options(groups: bool, current: BlendMode) -> Vec<(BlendMode, &'static str)> {
|
||
let own = Some(current).filter(|m| !m.has_psd_equivalent());
|
||
std::iter::once(BlendMode::PassThrough).filter(|_| groups).chain(BlendMode::LAYER_MODES).chain(own).map(|m| (m, m.label())).collect()
|
||
}
|
||
|
||
/// Scroll the Layers panel while holding a layer drag over its top/bottom edge or past them.
|
||
///
|
||
/// Returns the *content* displacement in points for this frame, so positive moves the
|
||
/// list downward (reveals rows above) and negative upward (reveals rows below).
|
||
/// The speed ramps with proximity to the edge and clamps to the maximum speed when
|
||
/// dragged outside, using elapsed time instead of assuming a particular refresh rate.
|
||
pub(crate) fn layer_drag_edge_scroll(pointer: Option<Pos2>, viewport: Rect, dragging: bool, dt: f32) -> f32 {
|
||
if !dragging || viewport.width() <= 0.0 || viewport.height() <= 0.0 || dt.is_nan() || dt <= 0.0 {
|
||
return 0.0;
|
||
}
|
||
let Some(pointer) = pointer.filter(|p| p.x.is_finite() && p.y.is_finite() && p.x >= viewport.left() && p.x <= viewport.right()) else {
|
||
return 0.0;
|
||
};
|
||
let edge = 32.0_f32.min(viewport.height() * 0.25);
|
||
let top = ((edge - (pointer.y - viewport.top())) / edge).clamp(0.0, 1.0);
|
||
let bottom = ((edge - (viewport.bottom() - pointer.y)) / edge).clamp(0.0, 1.0);
|
||
let direction = top - bottom;
|
||
if direction == 0.0 {
|
||
return 0.0;
|
||
}
|
||
let velocity = 80.0 + 520.0 * direction.abs();
|
||
direction.signum() * velocity * dt.clamp(0.0, 0.05)
|
||
}
|
||
|
||
/// Fit blend mode + Opacity into the available Layers-panel width (#2309).
|
||
/// Preserve the normal translated label when there is room; on a narrow dock,
|
||
/// keep the field interactive and give the dropdown the remaining width.
|
||
fn opacity_row_layout(available: f32, label_width: f32, gap: f32) -> (f32, bool) {
|
||
let full_right = label_width + LAYER_PCT_W + 2.0 * gap + 16.0;
|
||
// The label shows while the blend dropdown keeps a usable width (at the default dock width
|
||
// with an English label it does; a long translated label on a narrow dock gives way).
|
||
let show_label = available >= full_right + 80.0;
|
||
let reserved = LAYER_PCT_W + gap + if show_label { label_width + gap + 16.0 } else { 0.0 };
|
||
((available - reserved).max(40.0), show_label)
|
||
}
|
||
|
||
fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
// A new active layer opens its parent groups and is scrolled into view (#152).
|
||
let reveal = crate::layer_reveal::track(app, ui.ctx());
|
||
let Some(st) = app.session.active() else {
|
||
empty(ui, tl!("No document"));
|
||
return;
|
||
};
|
||
let doc = st.doc.clone();
|
||
let active = st.active_layer;
|
||
let active_layer = active.and_then(|id| doc.layer(id));
|
||
let selection = st.selected_layers();
|
||
let isolated = st.isolated_layers.clone();
|
||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||
|
||
let t = Tokens::get(ui.ctx());
|
||
if t.pro {
|
||
crate::layer_filter_ui::bar(app, ui);
|
||
ui.add_space(4.0);
|
||
}
|
||
if let Some(l) = active_layer {
|
||
// With several layers selected, blend mode, Opacity, Fill and the locks change every one
|
||
// of them, as in Photoshop; the controls show the active layer's values.
|
||
// The Background stays out of a several-layer change until Photoshop's behaviour is
|
||
// confirmed (#5856): Opacity, Fill or a blend mode would turn it into Layer 0.
|
||
let ids: Vec<LayerId> = if selection.len() > 1 && selection.contains(&l.id) {
|
||
selection.iter().copied().filter(|id| *id == l.id || !doc.layer(*id).is_some_and(|x| crate::doc_props_ui::is_background(&doc, x))).collect()
|
||
} else {
|
||
vec![l.id]
|
||
};
|
||
let targets = if ids.len() > 1 { json!({"layers": ids.iter().map(|id| id.0).collect::<Vec<_>>()}) } else { json!({"layer": l.id.0}) };
|
||
// Photoshop greys blend mode, Opacity and Fill for the Background layer. (With the
|
||
// Background among several selected, what Photoshop does awaits a check: #5856.)
|
||
let bg = crate::doc_props_ui::is_background(&doc, l);
|
||
// Pass Through is a group's mode: offered only when every target is a group.
|
||
let all_groups = ids.iter().all(|id| doc.layer(*id).is_some_and(|x| x.is_group()));
|
||
ui.horizontal(|ui| {
|
||
ui.add_enabled_ui(!bg, |ui| {
|
||
let mut m = l.blend;
|
||
// Reserve space for the numeric field first. On a narrow dock, hide the
|
||
// redundant visible Opacity label rather than force the dropdown beyond
|
||
// the panel's right edge (#2309). The numeric field keeps its name.
|
||
let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") };
|
||
let (blend_width, show_opacity_label) =
|
||
opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x);
|
||
let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(all_groups, l.blend), blend_width);
|
||
// One step per choice: a click, an arrow key or each wheel notch (#1747).
|
||
for m in &chosen {
|
||
let mut p = targets.clone();
|
||
p["blend"] = json!(m.label());
|
||
actions.push(("layer.setProps".into(), p));
|
||
}
|
||
// Hovering a mode previews it on the canvas (#970).
|
||
crate::blend_preview::hover(app, &ids, hovered.filter(|_| chosen.is_empty()));
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
let mut o = l.opacity * 100.0;
|
||
let field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W);
|
||
// On a narrow dock the (scrubby) label yields its room to the controls (#2309).
|
||
let scrub = if show_opacity_label {
|
||
scrub_pct_label(ui, opacity_label, &mut o, ui.available_width())
|
||
} else {
|
||
widgets::PopupFieldResponse::default()
|
||
};
|
||
if field.changed || scrub.changed {
|
||
actions.push(pct_action(&targets, "opacity", o, scrub.drag.or(field.drag)));
|
||
}
|
||
});
|
||
});
|
||
});
|
||
// Docks zero the item spacing: keep the Opacity and Fill fields apart (#155).
|
||
ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0));
|
||
ui.horizontal(|ui| {
|
||
let lock_label = if t.pro { tl!("Lock:") } else { tl!("Lock") };
|
||
// Layer fill opacity (≠ Edit › Fill / path fill → Tô).
|
||
let lang = crate::i18n::current();
|
||
let fill_label = if t.pro { crate::i18n::tr_ctx(lang, "layers", "Fill:") } else { crate::i18n::tr_ctx(lang, "layers", "Fill") };
|
||
// In a narrow panel with long translated labels, drop the "Lock:" text (the icons keep
|
||
// their tooltips) rather than let the Fill label run over the lock icons.
|
||
let gap = ui.spacing().item_spacing.x;
|
||
let icons_w = if t.pro { 5.0 * 20.0 + gap } else { 22.0 + gap };
|
||
let fits = body_text_width(ui, lock_label) + icons_w + body_text_width(ui, fill_label) + LAYER_PCT_W + 2.0 * gap <= ui.available_width();
|
||
if fits {
|
||
label(ui, lock_label);
|
||
}
|
||
if t.pro {
|
||
// The lock icons touch, but only they: the Fill label keeps the same gap before its
|
||
// field as Opacity's, so both stack in one right-aligned column (#2085).
|
||
ui.scope(|ui| {
|
||
ui.spacing_mut().item_spacing.x = 0.0;
|
||
let lk = l.locks;
|
||
for (key, icon, tip, on) in [
|
||
("transparency", "grid-3x3", tl!("Lock transparent pixels"), lk.transparency),
|
||
("pixels", "brush", tl!("Lock image pixels"), lk.pixels),
|
||
("position", "move", tl!("Lock position"), lk.position),
|
||
("artboard", "scan", tl!("Prevent auto-nesting in and out of Artboards and Frames"), lk.artboard),
|
||
("all", "lock", tl!("Lock all"), lk.all),
|
||
] {
|
||
// Lock Image Pixels includes Lock Transparent Pixels, which waits for it (#4892).
|
||
let enabled = key != "transparency" || !lk.pixels;
|
||
if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 20.0, on, tip)).inner.clicked() {
|
||
let mut p = targets.clone();
|
||
p["locks"] = json!({ key: !on });
|
||
actions.push(("layer.setProps".into(), p));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
if !t.pro {
|
||
let locked = l.locks.any();
|
||
if icons::button(ui, if locked { "lock" } else { "lock-open" }, 22.0, locked, tl!("Lock layer")).clicked() {
|
||
actions.push(simple_lock_toggle(crate::doc_props_ui::is_background(&doc, l), l, &targets));
|
||
}
|
||
}
|
||
ui.add_enabled_ui(!bg, |ui| {
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
let mut f = l.fill_opacity * 100.0;
|
||
let field = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W);
|
||
let scrub = scrub_pct_label(ui, fill_label, &mut f, ui.available_width());
|
||
if field.changed || scrub.changed {
|
||
actions.push(pct_action(&targets, "fill", f, scrub.drag.or(field.drag)));
|
||
}
|
||
})
|
||
});
|
||
});
|
||
ui.add_space(2.0);
|
||
}
|
||
|
||
// Top of the stack first, groups above their contents, closed groups' contents hidden (#126).
|
||
let rows = crate::layer_tree_ui::panel_rows(app, &doc);
|
||
let ctx = ui.ctx().clone();
|
||
let footer = widgets::footer_height(ui) + ui.spacing().item_spacing.y;
|
||
let fill = ui.available_height() > footer + 60.0;
|
||
let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY };
|
||
egui::ScrollArea::vertical()
|
||
// Keep each document's position independent, even when tab indices change (#5522).
|
||
.id_salt(("layer-rows", doc.id.0))
|
||
.max_height(rows_h)
|
||
.min_scrolled_height(if fill { rows_h } else { 0.0 })
|
||
.auto_shrink([false, !fill])
|
||
.show(ui, |ui| {
|
||
// The drag keys are set only by actual layer-row and fx drags, not clicks or
|
||
// ordinary scrolling. The ScrollArea applies this to its own content.
|
||
// An fx drag that never saw its release (the panel was hidden) must not outlive the button.
|
||
if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) {
|
||
ctx.data_mut(|d| d.remove::<FxDrag>(fx_drag_key()));
|
||
ctx.data_mut(|d| d.remove::<MaskDrag>(mask_drag_key()));
|
||
ctx.data_mut(|d| d.remove::<crate::smart_ui::FilterDrag>(crate::smart_ui::drag_key()));
|
||
}
|
||
let held = ctx.data(|d| {
|
||
d.get_temp::<u64>(egui::Id::new("layer-drag")).is_some()
|
||
|| d.get_temp::<FxDrag>(fx_drag_key()).is_some()
|
||
|| d.get_temp::<crate::smart_ui::FilterDrag>(crate::smart_ui::drag_key()).is_some()
|
||
});
|
||
let dragging = held && ctx.input(|i| i.pointer.primary_down());
|
||
let pointer = ctx.input(|i| i.pointer.interact_pos());
|
||
let delta = layer_drag_edge_scroll(pointer, ui.clip_rect(), dragging, ctx.input(|i| i.stable_dt));
|
||
if delta != 0.0 {
|
||
ui.scroll_with_delta(vec2(0.0, delta));
|
||
ctx.request_repaint();
|
||
}
|
||
let filter = crate::layer_filter_ui::Filter::of(&app.ui);
|
||
let fx_collapsed = app.session.active().map(|d| d.fx_collapsed.clone()).unwrap_or_default();
|
||
crate::layer_row_ui::begin(ui.ctx());
|
||
// The drawn rows, top to bottom (#1721): the space between them and below the last
|
||
// one is a drop zone too.
|
||
let mut drawn: Vec<(LayerId, Rect)> = Vec::new();
|
||
for &(depth, l) in &rows {
|
||
// Select › Isolate Layers.
|
||
if !photocraft_engine::select_extra_cmds::isolation_shows(&doc, &isolated, l.id) {
|
||
continue;
|
||
}
|
||
if !filter.shows(l) {
|
||
continue;
|
||
}
|
||
let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 };
|
||
let top = ui.cursor().top();
|
||
drawn.push((l.id, layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions)));
|
||
if reveal == Some(l.id) {
|
||
crate::layer_reveal::scroll_to_row(ui, top);
|
||
}
|
||
if !l.effects.items.is_empty() && fx_collapsed.iter().all(|id| *id != l.id) {
|
||
effect_rows(app, ui, l, depth, &mut actions);
|
||
}
|
||
crate::smart_ui::filter_rows(app, ui, l, depth, &mut actions);
|
||
}
|
||
// #1721: the list takes drops everywhere, not only on a row (Photoshop): the space
|
||
// between two rows lands above the lower one, the empty space below the bottom-most
|
||
// row below it. Both copy with ⌥ held, move without.
|
||
for w in drawn.windows(2) {
|
||
let gap = Rect::from_min_max(pos2(w[0].1.left(), w[0].1.bottom()), pos2(w[0].1.right(), w[1].1.top()));
|
||
layer_drop_zone(app, &ctx, ui, gap, (w[1].0, w[1].1), "above", &mut actions);
|
||
}
|
||
if let Some(&(last, last_rect)) = drawn.last() {
|
||
let below = Rect::from_min_max(pos2(last_rect.left(), last_rect.bottom()), pos2(last_rect.right(), ui.clip_rect().bottom()));
|
||
layer_drop_zone(app, &ctx, ui, below, (last, last_rect), "below", &mut actions);
|
||
}
|
||
// Tab / ⇧Tab in a rename: rename the layer below / above next, as in Photoshop. The
|
||
// Background isn't renamed in place (double-clicking it converts it), so it ends there.
|
||
if let Some((from, up)) = crate::layer_row_ui::take_next_rename(ui.ctx())
|
||
&& let Some(at) = drawn.iter().position(|(id, _)| id.0 == from)
|
||
&& let Some(&(next, _)) = if up { at.checked_sub(1).and_then(|i| drawn.get(i)) } else { drawn.get(at + 1) }
|
||
&& let Some(l) = doc.layer(next).filter(|l| !crate::doc_props_ui::is_background(&doc, l))
|
||
&& let Some(done) = crate::layer_row_ui::start_rename(ui.ctx(), next.0, &l.name)
|
||
{
|
||
actions.push(done);
|
||
}
|
||
// ⌥-click the line between two layers: clip / release the upper one (#967).
|
||
crate::clip_line_ui::show(ui, &doc, &mut actions);
|
||
// A rename whose row is gone (deleted, filtered out, inside a closed group) ends,
|
||
// committed: nothing else could commit or cancel it.
|
||
if let Some(layer) = crate::layer_row_ui::renaming(ui.ctx())
|
||
&& !crate::layer_row_ui::recorded(ui.ctx()).iter().any(|r| r.layer == layer)
|
||
&& let Some(done) = crate::layer_row_ui::end_rename(ui.ctx(), true)
|
||
{
|
||
actions.push(done);
|
||
}
|
||
});
|
||
// A layer being dragged can also be dropped on the footer's Delete, New Layer and Group
|
||
// buttons (#736); read the drag before it ends.
|
||
let footer_drag = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag"))).map(|id| {
|
||
let in_selection = selection.iter().any(|s| s.0 == id);
|
||
(id, in_selection)
|
||
});
|
||
// An effect row can be dropped on Delete too (#3102), and a mask's thumbnail (#3113, #3119).
|
||
let fx_footer_drag = ctx.data(|d| d.get_temp::<FxDrag>(fx_drag_key()));
|
||
fx_drag_feedback(&ctx, &doc);
|
||
// And a smart filter's row (#4271).
|
||
let filter_footer_drag = ctx.data(|d| d.get_temp::<crate::smart_ui::FilterDrag>(crate::smart_ui::drag_key()));
|
||
crate::smart_ui::drag_feedback(&ctx, &doc);
|
||
let mask_footer_drag = ctx.data(|d| d.get_temp::<MaskDrag>(mask_drag_key()));
|
||
if let Some((_, kind)) = mask_footer_drag
|
||
&& let Some(p) = ctx.input(|i| i.pointer.interact_pos())
|
||
{
|
||
let label = if kind == MaskKind::Vector { tl!("Vector Mask") } else { tl!("Layer Mask") };
|
||
crate::layer_transfer::ghost(&ctx, p, label);
|
||
}
|
||
// End any layer drag after every row has had a chance to accept the drop.
|
||
if ctx.input(|i| i.pointer.any_released()) {
|
||
ctx.data_mut(|d| d.remove::<u64>(egui::Id::new("layer-drag")));
|
||
ctx.data_mut(|d| d.remove::<FxDrag>(fx_drag_key()));
|
||
ctx.data_mut(|d| d.remove::<MaskDrag>(mask_drag_key()));
|
||
ctx.data_mut(|d| d.remove::<crate::smart_ui::FilterDrag>(crate::smart_ui::drag_key()));
|
||
}
|
||
widgets::panel_footer(ui, |ui| {
|
||
let active_layer = active.and_then(|id| doc.layer(id));
|
||
let (delete, label) = if app.ui.vector_mask_target && active_layer.is_some_and(|l| l.vector_mask.is_some()) {
|
||
("layer.vectorMask.delete", "Delete Vector Mask")
|
||
} else if app.ui.mask_target && active_layer.is_some_and(|l| l.mask.is_some()) {
|
||
("layer.layerMask.delete", "Delete Layer Mask")
|
||
} else {
|
||
("layer.delete", "Delete layer")
|
||
};
|
||
let trash = icons::button(ui, "trash", 26.0, false, tl!(label));
|
||
// A selected smart filter row is what Delete removes, as in Photoshop (#4751).
|
||
let picked_filter = crate::smart_ui::selected(&ctx, &doc, active);
|
||
if trash.clicked()
|
||
&& let Some(picked) = picked_filter
|
||
{
|
||
actions.push(crate::smart_ui::trash_action(picked));
|
||
crate::smart_ui::deselect(&ctx, doc.id);
|
||
} else if trash.clicked() {
|
||
// Like Photoshop, ⌥/Alt-click deletes without asking.
|
||
let confirm = delete == "layer.delete" && !ui.input(|i| i.modifiers.alt);
|
||
let params = if confirm { json!({"__trash": true}) } else { json!({}) };
|
||
actions.push((delete.into(), params));
|
||
}
|
||
actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete"));
|
||
actions.extend(fx_trash_drop(ui, &trash, fx_footer_drag));
|
||
if let Some(drag) = filter_footer_drag
|
||
&& dropped_on(ui, &trash)
|
||
{
|
||
actions.push(crate::smart_ui::trash_action(drag));
|
||
}
|
||
// Photoshop asks "Apply mask to layer before removing?" or "Delete the vector mask?"
|
||
// (`mask_trash_prompt`).
|
||
if let Some((layer, kind)) = mask_footer_drag
|
||
&& dropped_on(ui, &trash)
|
||
{
|
||
actions.push(crate::mask_trash_prompt::drop_action(layer, kind));
|
||
}
|
||
// A click follows the selected thumbnail; name that action for screen readers even
|
||
// though dropping a whole layer row onto the same button always deletes the layer.
|
||
trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(label)));
|
||
let new_layer = icons::button(ui, "square-plus", 26.0, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer"));
|
||
if new_layer.clicked() {
|
||
// A click makes the layer above the selection; ⌥-click asks first (the New Layer
|
||
// dialog) and ⌘/Ctrl-click adds it below, as in Photoshop.
|
||
let (alt, below) = ui.input(|i| (i.modifiers.alt, i.modifiers.command));
|
||
let params = if alt {
|
||
Value::Null
|
||
} else if below {
|
||
json!({"position": "below"})
|
||
} else {
|
||
json!({})
|
||
};
|
||
actions.push(("layer.new.layer".into(), params));
|
||
}
|
||
actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate"));
|
||
let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group"));
|
||
if group.clicked() {
|
||
// Like Photoshop a plain click adds an empty group; Shift-click groups the selected
|
||
// layers, the same as Layer › Group Layers (#2561).
|
||
let shift = ui.input(|i| i.modifiers.shift);
|
||
let cmd = if shift && active_layer.is_some() { "layer.groupLayers" } else { "layer.new.group" };
|
||
actions.push((cmd.into(), json!({})));
|
||
}
|
||
actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers"));
|
||
let adj = footer_menu_button(ui, "adjustment-layer", 26.0, tl!("Create new fill or adjustment layer"));
|
||
// Opens on the press like Photoshop's: drag onto an item and release to choose it (#2071).
|
||
crate::press_menu::show(&adj, |ui, menu| {
|
||
ui.set_min_width(190.0);
|
||
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("layer.newAdjustmentLayer.")) {
|
||
if menu.item(ui, tl!(c.label).trim_end_matches('…')) {
|
||
actions.push((c.id.into(), json!({})));
|
||
}
|
||
}
|
||
ui.separator();
|
||
if menu.item(ui, tl!("Solid Color…")) {
|
||
actions.push(("layer.newFillLayer.solidColor".into(), json!({})));
|
||
}
|
||
if menu.item(ui, tl!("Gradient…")) {
|
||
actions.push(("layer.newFillLayer.gradient".into(), json!({})));
|
||
}
|
||
});
|
||
// Like Photoshop the button never replaces a mask (#2075): with a layer mask it adds a
|
||
// vector mask, and with both it is greyed.
|
||
let alt = ui.input(|i| i.modifiers.alt);
|
||
let mask_cmd = crate::layer_menu_ui::mask_button_command(active_layer, doc.selection.is_some(), alt);
|
||
let mask_tip = if active_layer.is_some_and(|l| l.mask.is_some()) {
|
||
tl!("Add vector mask").to_string()
|
||
} else {
|
||
crate::i18n::fmt(tl!("Add a mask (from the selection; {key} inverts)"), &[("key", &crate::shortcuts::pretty("Alt"))])
|
||
};
|
||
let mask_btn = ui.add_enabled_ui(mask_cmd.is_some(), |ui| icons::button(ui, "layer-mask", 26.0, false, &mask_tip)).inner;
|
||
mask_btn.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &mask_tip));
|
||
if mask_btn.clicked()
|
||
&& let Some(cmd) = mask_cmd
|
||
{
|
||
// A path selected in the Paths panel becomes the vector mask, as in Photoshop (#4728);
|
||
// Alt still inverts it.
|
||
match crate::vector_ui::selected_document_path(app).filter(|_| cmd.starts_with("layer.vectorMask.")) {
|
||
Some(name) => actions.push(("layer.vectorMask.currentPath".into(), json!({"name": name, "hide": alt}))),
|
||
None => actions.push((cmd.into(), json!({}))),
|
||
}
|
||
}
|
||
let fx = fx_button(ui, 26.0, tl!("Add a layer style"));
|
||
crate::press_menu::show(&fx, |ui, menu| {
|
||
ui.set_min_width(180.0);
|
||
if menu.item(ui, tl!("Blending Options…")) {
|
||
crate::layer_style::open(app, None);
|
||
}
|
||
ui.separator();
|
||
for &(kind, label) in crate::layer_style::KINDS {
|
||
if menu.item(ui, format!("{}…", tl!(label))) {
|
||
crate::layer_style::open(app, Some(kind));
|
||
}
|
||
}
|
||
});
|
||
// Photoshop's footer starts with Link Layers (enabled with two or more layers selected).
|
||
let can_link = app.session.is_enabled("layer.linkLayers");
|
||
if ui.add_enabled_ui(can_link, |ui| icons::button(ui, "link-2", 26.0, false, tl!("Link layers"))).inner.clicked() {
|
||
actions.push(("layer.linkLayers".into(), json!({})));
|
||
}
|
||
});
|
||
for (id, p) in actions {
|
||
// A row click commits even when it selects the layer already being edited (#5584).
|
||
// Finish before selection changes so the existing type session keeps its own target.
|
||
if id == "layer.select" && app.ui.text_edit.is_some() {
|
||
crate::type_tool::commit(app);
|
||
}
|
||
if id == "ui.maskTarget" {
|
||
app.ui.mask_target = p.as_bool().unwrap_or(false);
|
||
app.ui.vector_mask_target = false;
|
||
app.sync_mask_targets();
|
||
continue;
|
||
}
|
||
if id == "ui.vectorMaskTarget" {
|
||
app.ui.vector_mask_target = p.as_bool().unwrap_or(false);
|
||
app.ui.mask_target = false;
|
||
app.sync_mask_targets();
|
||
continue;
|
||
}
|
||
// Double-clicking a mask thumbnail opens its settings: show Properties.
|
||
if id == "ui.properties" {
|
||
app.ui.panels.properties = true;
|
||
app.ui.dock_tabs.properties = 0;
|
||
app.ui.dock.collapsed.retain(|g| *g != crate::dock::Group::Properties);
|
||
continue;
|
||
}
|
||
if p.is_null() || id == "layer.newFillLayer.solidColor" {
|
||
// Context-menu items and the Layers footer's Solid Color… action behave like
|
||
// their menu-bar twins. In particular, a new Solid Color fill opens its picker.
|
||
let ctx = ui.ctx().clone();
|
||
let params = if p.is_null() { json!({}) } else { p };
|
||
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
|
||
app.ui.status = e;
|
||
app.ui.status_error = true;
|
||
}
|
||
continue;
|
||
}
|
||
let _ = app.run(&id, p);
|
||
}
|
||
}
|
||
|
||
/// A layer row dragged onto a Layers panel footer button, as in Photoshop: onto Delete deletes it,
|
||
/// onto New Layer duplicates it, onto New Group groups it. A row that is part of the selection
|
||
/// carries the whole selection; any other row goes alone. Highlights the button while over it and
|
||
/// returns the command on release.
|
||
fn footer_drop(ui: &egui::Ui, button: &egui::Response, drag: Option<(u64, bool)>, command: &str) -> Option<(String, Value)> {
|
||
// Named for screen readers (and tests) after the command a drop runs.
|
||
button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, footer_label(command)));
|
||
let (layer, in_selection) = drag?;
|
||
dropped_on(ui, button).then(|| (command.into(), if in_selection { json!({}) } else { json!({"layer": layer}) }))
|
||
}
|
||
|
||
/// While something is dragged over a footer `button`: highlights it, and is true on the release.
|
||
pub(crate) fn dropped_on(ui: &egui::Ui, button: &egui::Response) -> bool {
|
||
let Some(p) = ui.ctx().input(|i| i.pointer.interact_pos()) else { return false };
|
||
if !button.rect.contains(p) {
|
||
return false;
|
||
}
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.painter().rect_stroke(button.rect, t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||
ui.ctx().input(|i| i.pointer.any_released())
|
||
}
|
||
|
||
/// Effects dragged onto the footer's Delete button, as in Photoshop (#3102): the "Effects" row
|
||
/// clears the layer style, an effect's row deletes just that effect.
|
||
fn fx_trash_drop(ui: &egui::Ui, button: &egui::Response, drag: Option<FxDrag>) -> Option<(String, Value)> {
|
||
let (layer, effect) = drag?;
|
||
if !dropped_on(ui, button) {
|
||
return None;
|
||
}
|
||
let params = match effect {
|
||
Some(i) => json!({"layer": layer, "effect": i}),
|
||
None => json!({"layer": layer}),
|
||
};
|
||
Some(("layer.layerStyle.clear".into(), params))
|
||
}
|
||
|
||
fn footer_label(command: &str) -> &'static str {
|
||
match command {
|
||
"layer.delete" => tl!("Delete layer"),
|
||
"layer.duplicate" => tl!("Create a new layer"),
|
||
_ => tl!("Create a new group"),
|
||
}
|
||
}
|
||
|
||
/// The small down-arrow makes it clear that the footer icon expands into a menu.
|
||
fn footer_menu_arrow(ui: &egui::Ui, rect: Rect) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let arrow = Rect::from_min_size(rect.right_bottom() - vec2(11.0, 10.0), vec2(9.0, 9.0));
|
||
icons::paint(ui, arrow, "chevron-down", 8.0, t.text_dim);
|
||
}
|
||
|
||
fn footer_menu_button(ui: &mut egui::Ui, icon: &str, size: f32, tip: &str) -> egui::Response {
|
||
let response = icons::button(ui, icon, size, false, tip);
|
||
// Named for screen readers (and tests) after its tooltip.
|
||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
|
||
footer_menu_arrow(ui, response.rect);
|
||
response
|
||
}
|
||
|
||
/// Photoshop's italic "fx" footer button (no icon-font equivalent).
|
||
fn fx_button(ui: &mut egui::Ui, size: f32, tip: &str) -> egui::Response {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click());
|
||
if resp.hovered() {
|
||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||
}
|
||
let mut job = egui::text::LayoutJob::default();
|
||
job.append("fx", 0.0, egui::TextFormat { font_id: egui::FontId::proportional(15.0), color: t.icon, italics: true, ..Default::default() });
|
||
let g = ui.painter().layout_job(job);
|
||
ui.painter().galley(r.center() - g.size() / 2.0, g, t.icon);
|
||
footer_menu_arrow(ui, r);
|
||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
|
||
resp.on_hover_text(tip)
|
||
}
|
||
|
||
/// How a Layers panel row is selected: in the (multi-)selection, the primary/active layer, and
|
||
/// whether several layers are selected.
|
||
#[derive(Clone, Copy)]
|
||
struct RowSel {
|
||
selected: bool,
|
||
primary: bool,
|
||
multi: bool,
|
||
}
|
||
|
||
/// `layer.select` mode for a click with these modifiers (Photoshop: ⌘-click toggles, ⇧-click
|
||
/// selects a range, a plain click selects one layer).
|
||
fn select_mode(m: egui::Modifiers) -> &'static str {
|
||
if m.command {
|
||
"toggle"
|
||
} else if m.shift {
|
||
"range"
|
||
} else {
|
||
"replace"
|
||
}
|
||
}
|
||
|
||
/// A drag down the Layers panel's eye column (Photoshop): the first eye toggles and every row
|
||
/// swept over gets the same visibility, once per row, all in one history step.
|
||
#[derive(Clone)]
|
||
struct EyeSweep {
|
||
visible: bool,
|
||
/// The `coalesce` key shared by the drag's `layer.setProps` calls.
|
||
key: u64,
|
||
swept: Vec<u64>,
|
||
}
|
||
|
||
/// Starts an eye-column sweep from `eye`, or applies a running one to the row `row` of `l`.
|
||
fn eye_sweep(ctx: &egui::Context, l: &Layer, row: Rect, eye: &egui::Response, actions: &mut Vec<(String, Value)>) {
|
||
let id = egui::Id::new("layer-eye-sweep");
|
||
let set =
|
||
|visible: bool, key: u64| ("layer.setProps".to_string(), json!({"layer": l.id.0, "visible": visible, "coalesce": format!("layer-eye-sweep:{key}")}));
|
||
if eye.drag_started() {
|
||
let sweep = EyeSweep { visible: !l.visible, key: ctx.cumulative_pass_nr(), swept: vec![l.id.0] };
|
||
actions.push(set(sweep.visible, sweep.key));
|
||
ctx.data_mut(|d| d.insert_temp(id, sweep));
|
||
return;
|
||
}
|
||
let Some(mut sweep) = ctx.data(|d| d.get_temp::<EyeSweep>(id)) else { return };
|
||
let (down, pos, delta) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.interact_pos(), i.pointer.delta()));
|
||
if !down {
|
||
ctx.data_mut(|d| d.remove::<EyeSweep>(id));
|
||
return;
|
||
}
|
||
// Everything the pointer passed since the last frame, so a fast drag skips no row.
|
||
let Some(p) = pos else { return };
|
||
let (y0, y1) = ((p.y - delta.y).min(p.y), (p.y - delta.y).max(p.y));
|
||
if y1 < row.top() || y0 >= row.bottom() || sweep.swept.contains(&l.id.0) {
|
||
return;
|
||
}
|
||
if l.visible != sweep.visible {
|
||
actions.push(set(sweep.visible, sweep.key));
|
||
}
|
||
sweep.swept.push(l.id.0);
|
||
ctx.data_mut(|d| d.insert_temp(id, sweep));
|
||
}
|
||
|
||
/// Marker in the clipped row's existing 12-point slot, beside its thumbnail.
|
||
fn paint_clipping_indicator(painter: &egui::Painter, at: Pos2, color: Color32) {
|
||
let tip = at + vec2(5.0, 3.0);
|
||
let stroke = Stroke::new(1.0, color);
|
||
painter.add(egui::Shape::line(vec![at + vec2(9.0, -4.0), at + vec2(5.0, -4.0), tip], stroke));
|
||
painter.add(egui::Shape::line(vec![at + vec2(2.0, 0.0), tip, at + vec2(8.0, 0.0)], stroke));
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn layer_row(
|
||
app: &mut PhotocraftApp,
|
||
ctx: &egui::Context,
|
||
ui: &mut egui::Ui,
|
||
doc: &photocraft_doc::Document,
|
||
l: &Layer,
|
||
depth: usize,
|
||
row: RowSel,
|
||
actions: &mut Vec<(String, Value)>,
|
||
) -> Rect {
|
||
let selected = row.selected;
|
||
let t = Tokens::get(ctx);
|
||
// Photoshop's default (medium) thumbnails: 32 pt rows.
|
||
let row_h = if t.pro { 32.0 } else { 46.0 };
|
||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag());
|
||
// A drag from a mask's thumbnail drags that mask, not the layer (#3113, #3119).
|
||
let mask_drag = starts_mask_drag(ctx, l, &resp);
|
||
if let Some(kind) = mask_drag {
|
||
ctx.data_mut(|d| d.insert_temp::<MaskDrag>(mask_drag_key(), (l.id.0, kind)));
|
||
}
|
||
let mask_drag = mask_drag.is_some();
|
||
layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions);
|
||
fx_drop(ctx, ui, l, rect, actions);
|
||
crate::smart_ui::drop_on_row(ctx, ui, l, rect, actions);
|
||
if resp.drag_started() && !mask_drag {
|
||
crate::layer_transfer::begin_from_panel(app, ctx, l.id);
|
||
}
|
||
// Rows are painted: name them for screen readers and UI tests.
|
||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &l.name));
|
||
// A row scrolled out of view only keeps its place (#125): a layout's hundreds of rows would
|
||
// otherwise lay out names, icons and thumbnails every frame. Group rows stay whole: their
|
||
// disclosure triangle is a widget (accessibility, scroll-to).
|
||
if !ui.is_rect_visible(rect) && !l.is_group() && !resp.context_menu_opened() && crate::layer_row_ui::renaming(ctx) != Some(l.id.0) {
|
||
return rect;
|
||
}
|
||
let painter = ui.painter_at(rect.expand(1.0));
|
||
let is_hovered = resp.hovered() || app.canvas_hover_layer == Some(l.id);
|
||
if resp.hovered() {
|
||
app.hover_layer = Some(l.id);
|
||
}
|
||
if t.pro {
|
||
if selected {
|
||
painter.rect_filled(rect, 0.0, t.row_selected);
|
||
} else if is_hovered {
|
||
painter.rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5));
|
||
painter.rect_filled(rect, 0.0, t.accent.gamma_multiply(0.12));
|
||
}
|
||
} else if selected {
|
||
painter.rect_filled(rect, t.radius, t.hover);
|
||
painter.rect_stroke(rect, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||
} else if is_hovered {
|
||
painter.rect_filled(rect, t.radius, t.hover.gamma_multiply(0.5));
|
||
painter.rect_filled(rect, t.radius, t.accent.gamma_multiply(0.12));
|
||
}
|
||
let eye_cell = Rect::from_min_max(rect.left_top(), pos2((rect.left() + 30.0).min(rect.right()), rect.bottom()));
|
||
if let Some((_, background)) = t.layer_label_colors(l.label) {
|
||
painter.rect_filled(eye_cell, 0.0, background);
|
||
}
|
||
if t.pro {
|
||
// Paint dividers over the label so the eye column stays distinct.
|
||
painter.line_segment([pos2(rect.left() + 30.0, rect.top()), pos2(rect.left() + 30.0, rect.bottom())], Stroke::new(1.0, t.separator));
|
||
painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator));
|
||
}
|
||
let mut x = rect.left() + 6.0;
|
||
let eye = Rect::from_min_size(pos2(x, rect.center().y - 11.0), vec2(22.0, 22.0));
|
||
// The eye takes drags too (so a drag starting on it never reorders the row): dragging down
|
||
// the eyes gives every row swept over the visibility the first eye toggled to.
|
||
let eye_resp = ui.interact(eye, ui.id().with(("eye", l.id.0)), Sense::click_and_drag());
|
||
// A hidden layer's eye box is left empty (still clickable).
|
||
if l.visible {
|
||
// Centre the drawing in the column while preserving the visibility hit area.
|
||
icons::paint(ui, eye_cell, "eye", 15.0, t.layer_label_icon(l.label));
|
||
}
|
||
eye_sweep(ctx, l, rect, &eye_resp, actions);
|
||
if eye_resp.clicked() {
|
||
// ⌥-click shows only this layer; ⌥-click it again to restore the others.
|
||
if ui.input(|i| i.modifiers.alt) {
|
||
actions.push(("layer.showOnly".into(), json!({"layer": l.id.0})));
|
||
} else {
|
||
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "visible": !l.visible})));
|
||
}
|
||
}
|
||
eye_resp.context_menu(|ui| {
|
||
crate::widgets::style_spectrum_popup_menu(ui);
|
||
crate::layer_menu_ui::color_choices(app, ui, l, row.multi && selected, actions);
|
||
});
|
||
// Everything but the thumbnails, the indentation and the name, so a narrow panel squeezes
|
||
// the indentation first, then the thumbnails (a layer with two masks has three).
|
||
let others = 6.0
|
||
+ 28.0
|
||
+ if l.is_group() { crate::layer_tree_ui::TRIANGLE_W } else { 0.0 }
|
||
+ if l.clipped { 12.0 } else { 0.0 }
|
||
+ crate::layer_row_ui::reserved_width(l);
|
||
let ts = crate::mask_thumbs_ui::thumb_size(l, if t.pro { 24.0 } else { 34.0 }, rect.width() - others);
|
||
let fixed = others + ts + 6.0 + crate::mask_thumbs_ui::width(l, ts);
|
||
x += 28.0 + crate::layer_row_ui::indent(depth, rect.width(), fixed);
|
||
let toggled = crate::layer_tree_ui::disclosure(ui, rect, &mut x, l, actions);
|
||
if l.clipped {
|
||
paint_clipping_indicator(&painter, pos2(x, rect.center().y), t.text_faint);
|
||
x += 12.0;
|
||
}
|
||
let thumb = Rect::from_min_size(pos2(x, rect.center().y - ts / 2.0), vec2(ts, ts));
|
||
draw_layer_thumb(app, ctx, ui, doc, l, thumb, row.primary);
|
||
x += ts + 6.0;
|
||
// Link chains and pixel / vector mask thumbnails (#153).
|
||
let masks = crate::mask_thumbs_ui::paint(app, ctx, ui, &painter, doc, l, &mut x, rect.center().y, ts, actions);
|
||
let mask_rect = masks.thumb(crate::mask_thumbs_ui::MaskKind::Pixel);
|
||
let vector_rect = masks.thumb(crate::mask_thumbs_ui::MaskKind::Vector);
|
||
// Photoshop frames the targeted thumbnail (pixels, mask or vector mask) of the active layer
|
||
// with corner brackets.
|
||
if row.primary {
|
||
let target = if app.ui.vector_mask_target && vector_rect.is_some() {
|
||
vector_rect
|
||
} else if app.ui.mask_target {
|
||
mask_rect.or(Some(thumb))
|
||
} else {
|
||
Some(thumb)
|
||
};
|
||
if let Some(r) = target {
|
||
crate::mask_thumbs_ui::paint_brackets(&painter, r, t.text);
|
||
}
|
||
}
|
||
// Right-hand indicators first; the name gets what is left and ends in "…" (#144).
|
||
let fx_open = app.session.active().is_none_or(|d| !d.fx_collapsed.contains(&l.id));
|
||
let (name_right, indicators, fx_toggled) = crate::layer_row_ui::indicators(ui, &painter, rect, x, l, fx_open, actions);
|
||
// Dragging the fx badge drags all the layer's effects, not the layer (Photoshop). Like the eye,
|
||
// it takes the drag from the row; clicks still reach the row.
|
||
if let Some(&(_, badge)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Fx)
|
||
&& ui.interact(badge, ui.id().with(("fx-badge", l.id.0)), Sense::drag()).drag_started()
|
||
{
|
||
start_fx_drag(ctx, l.id, None);
|
||
}
|
||
// Clicking the lock icon unlocks the layer, and makes the Background a normal one (Photoshop,
|
||
// #4723). It takes the click from the row, so the row isn't selected.
|
||
if let Some(&(_, lock)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Lock)
|
||
&& ui.interact(lock, ui.id().with(("row-lock", l.id.0)), Sense::click()).clicked()
|
||
{
|
||
actions.push(simple_lock_toggle(doc.is_background(l.id), l, &json!({"layer": l.id.0})));
|
||
}
|
||
let name_color = if l.visible { t.text } else { t.text_faint };
|
||
let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) };
|
||
// Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright.
|
||
let italic = !t.pro && doc.is_background(l.id);
|
||
// Photoshop rows show only the name; the kind sub-label is a Studio-theme addition.
|
||
let is_pixel = t.pro || matches!(l.content, LayerContent::Raster(_));
|
||
let name_rect = crate::layer_row_ui::truncated(&painter, &l.name, font, name_color, italic, name_right - x).map(|galley| {
|
||
let text_pos = pos2(x, rect.center().y - galley.size().y / 2.0 - if is_pixel { 0.0 } else { 7.0 });
|
||
let r = Rect::from_min_size(text_pos, galley.size());
|
||
painter.galley(text_pos, galley, name_color);
|
||
r
|
||
});
|
||
if !is_pixel {
|
||
let sub = match &l.content {
|
||
LayerContent::Adjustment(a) => tl!(a.label()).to_string(),
|
||
LayerContent::Group(g) => crate::i18n::trn(crate::i18n::current(), g.children.len() as u64, "Group · {n} layer", "Group · {n} layers"),
|
||
other => tl!(other.kind_name()).to_string(),
|
||
};
|
||
crate::layer_row_ui::label(&painter, x, rect.center().y + 8.0, name_right, &sub, egui::FontId::proportional(11.0), t.text_faint);
|
||
}
|
||
crate::layer_row_ui::record(ctx, crate::layer_row_ui::RowRects { layer: l.id.0, row: rect, name: name_rect, indicators });
|
||
// ⌘-click a layer, mask or vector-mask thumbnail loads its transparency / mask / path as a
|
||
// selection (⇧ add, ⌥ subtract, ⇧⌥ intersect) instead of changing the layer selection.
|
||
let thumb_load = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| {
|
||
let p = pos.filter(|_| m.command)?;
|
||
if let Some(kind) = masks.hit(p) {
|
||
return Some(crate::mask_thumbs_ui::load_params(l, kind, m));
|
||
}
|
||
thumb.expand(2.0).contains(p).then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)}))
|
||
});
|
||
// ⇧-click a mask thumbnail: disable / enable that mask; ⌥-click a layer mask: view it.
|
||
let mask_toggle = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| {
|
||
let kind = masks.hit(pos?)?;
|
||
crate::mask_thumbs_ui::click_command(l, kind, m)
|
||
});
|
||
if let Some(p) = thumb_load {
|
||
actions.push(("select.loadSelection".into(), p));
|
||
} else if let Some(cmd) = mask_toggle {
|
||
actions.push(cmd);
|
||
} else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled && !masks.clicked {
|
||
let mode = select_mode(ui.input(|i| i.modifiers));
|
||
actions.push(("layer.select".into(), json!({"layer": l.id.0, "mode": mode})));
|
||
crate::smart_ui::deselect(ctx, doc.id);
|
||
// A thumbnail explicitly selects content or a mask. Selecting an adjustment/fill row
|
||
// defaults to its mask, but clicking its content thumbnail must leave the mask target.
|
||
let pos = resp.interact_pointer_pos();
|
||
let on_mask = mask_rect.zip(pos).is_some_and(|(r, p)| r.expand(2.0).contains(p));
|
||
let on_thumb = pos.is_some_and(|p| thumb.expand(2.0).contains(p));
|
||
let on_vector = pos.and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Vector);
|
||
// Clicking the layer thumbnail leaves mask view (#196).
|
||
let viewing = app.session.active().and_then(photocraft_engine::mask_view_cmds::current).is_some_and(|v| v.layer == l.id);
|
||
if on_thumb && viewing {
|
||
actions.push((photocraft_engine::mask_view_cmds::ID.into(), json!({"layer": l.id.0, "mode": "off"})));
|
||
}
|
||
let content_less = matches!(l.content, LayerContent::Adjustment(_) | LayerContent::Fill(_));
|
||
if on_vector {
|
||
// The vector mask thumbnail targets the vector mask: the path tools edit it and the
|
||
// Paths panel selects the layer's path.
|
||
app.ui.selected_path = Some("layer".into());
|
||
actions.push(("ui.vectorMaskTarget".into(), json!(true)));
|
||
} else if on_mask || (content_less && l.mask.is_some() && !on_thumb) {
|
||
if on_mask {
|
||
// The mask thumbnail also switches Properties to the mask (#3369).
|
||
app.ui.properties_layer = None;
|
||
}
|
||
actions.push(("ui.maskTarget".into(), json!(true)));
|
||
} else if on_thumb || !row.primary {
|
||
actions.push(("ui.maskTarget".into(), json!(false)));
|
||
}
|
||
// ⌥-click the layer thumbnail: fit the layer on screen, as Photoshop does.
|
||
let m = ui.input(|i| i.modifiers);
|
||
if on_thumb && m.alt && !m.shift && !m.command {
|
||
actions.push(("view.fitLayersOnScreen".into(), Value::Null));
|
||
}
|
||
}
|
||
// 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, 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();
|
||
let on = |r: Rect| pos.is_some_and(|p| r.expand(2.0).contains(p));
|
||
if crate::doc_props_ui::is_background(doc, l) {
|
||
actions.push(("layer.new.layerFromBackground".into(), json!({})));
|
||
} else if name_rect.is_some_and(|n| on(Rect::from_x_y_ranges(n.x_range(), rect.y_range()))) {
|
||
if let Some(done) = crate::layer_row_ui::start_rename(ctx, l.id.0, &l.name) {
|
||
// One rename at a time: starting this one commits any other (#314).
|
||
actions.push(done);
|
||
}
|
||
} else if let Some(kind) = pos.and_then(|p| masks.hit(p)) {
|
||
match kind {
|
||
crate::mask_thumbs_ui::MaskKind::Pixel => actions.push(("ui.maskTarget".into(), json!(true))),
|
||
crate::mask_thumbs_ui::MaskKind::Vector => actions.push(("ui.vectorMaskTarget".into(), json!(true))),
|
||
}
|
||
if let Some((cmd, val)) = crate::mask_thumbs_ui::double_click_command(app, kind) {
|
||
// The preference launches Select and Mask on a pixel mask.
|
||
actions.push((cmd.into(), val));
|
||
} else {
|
||
// Otherwise double-click opens the mask's settings (Density/Feather).
|
||
actions.push(("ui.properties".into(), json!(true)));
|
||
}
|
||
} else if pos.and_then(|p| masks.hit(p)).is_none() {
|
||
let id = match &l.content {
|
||
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",
|
||
};
|
||
if crate::menus::is_enabled(app, id) {
|
||
// Null params: run like the menu item, dialog included.
|
||
actions.push((id.into(), Value::Null));
|
||
}
|
||
}
|
||
}
|
||
let edit_rect = Rect::from_min_max(pos2(x - 3.0, rect.center().y - 11.0), pos2(name_right.max(x + 40.0), rect.center().y + 11.0));
|
||
if let Some(done) = crate::layer_row_ui::rename_field(ui, l.id.0, edit_rect) {
|
||
actions.push(done);
|
||
}
|
||
// The visibility icon has its own color menu, and the layer-mask thumbnail its mask menu
|
||
// (#4575); elsewhere use the full layer menu.
|
||
let on_mask_id = ui.id().with(("mask-menu", l.id.0));
|
||
if resp.secondary_clicked() {
|
||
let on_mask = resp.interact_pointer_pos().and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Pixel);
|
||
ctx.data_mut(|d| d.insert_temp(on_mask_id, on_mask));
|
||
}
|
||
if !eye_resp.secondary_clicked() && !eye_resp.context_menu_opened() {
|
||
resp.context_menu(|ui| {
|
||
crate::widgets::style_spectrum_popup_menu(ui);
|
||
if ui.ctx().data(|d| d.get_temp::<bool>(on_mask_id)).unwrap_or(false) {
|
||
crate::layer_menu_ui::show_mask(app, ui, l, actions);
|
||
return;
|
||
}
|
||
// Right-clicking inside a multi-selection keeps it and acts on every selected layer.
|
||
let on_set = row.multi && selected;
|
||
if crate::layer_menu_ui::show(app, ui, l, on_set, actions)
|
||
&& let Some(done) = crate::layer_row_ui::start_rename(ui.ctx(), l.id.0, &l.name)
|
||
{
|
||
actions.push(done);
|
||
}
|
||
});
|
||
}
|
||
rect
|
||
}
|
||
|
||
/// Screen rect and UVs for a layer thumbnail: the document-shaped part of the square cell and of
|
||
/// the letterboxed square texture, so tall and wide documents don't show empty bars.
|
||
fn layer_thumb_fit(cell: Rect, w: u32, h: u32) -> (Rect, Rect) {
|
||
crate::channels_panel::fit_thumb(cell, w, h)
|
||
}
|
||
|
||
fn draw_layer_thumb(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, doc: &photocraft_doc::Document, l: &Layer, rect: Rect, selected: bool) {
|
||
let t = Tokens::get(ctx);
|
||
let p = ui.painter();
|
||
match &l.content {
|
||
LayerContent::Adjustment(_) | LayerContent::Group(_) => {
|
||
p.rect_filled(rect, 6.0, t.field);
|
||
let icon = if l.is_group() { "folder" } else { "contrast" };
|
||
icons::paint(ui, rect, icon, 18.0, t.icon);
|
||
}
|
||
LayerContent::Text(_) => {
|
||
// Photoshop: type layers show a "T" tile rather than a pixel thumbnail.
|
||
p.rect_filled(rect, if t.pro { 0.0 } else { 6.0 }, Color32::from_gray(if t.pro { 222 } else { 236 }));
|
||
icons::paint(ui, rect, "type", 20.0, Color32::from_gray(40));
|
||
}
|
||
// Gradient fills show the gradient itself (Photoshop); solid ones their colour.
|
||
LayerContent::Fill(f) if crate::gradient_ui::paint_thumbnail(ui, l.id, f, rect) => {}
|
||
LayerContent::Fill(photocraft_doc::Fill::Solid(color)) => {
|
||
crate::solid_fill_ui::paint_thumbnail(ui, crate::solid_fill_ui::thumbnail_color(app, doc.id, l.id, *color), rect);
|
||
}
|
||
LayerContent::Fill(_) => {
|
||
let c = [128, 128, 128, 255];
|
||
p.rect_filled(rect, 6.0, Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]));
|
||
}
|
||
_ => {
|
||
// Keep row layout and outside thumbnail decorations when the image is clipped.
|
||
if ui.is_rect_visible(rect) {
|
||
// The texture is a letterboxed square: draw only the part its frame (the document,
|
||
// or the layer's bounds) fills.
|
||
let (tex, frame) = app.layer_thumb(ctx, doc, l);
|
||
let (fitted, uv) = layer_thumb_fit(rect, frame.width(), frame.height());
|
||
widgets::checker(p, fitted, 5.0);
|
||
p.image(tex, fitted, uv, Color32::WHITE);
|
||
}
|
||
}
|
||
}
|
||
let stroke = if t.pro {
|
||
Stroke::new(1.0, Color32::from_gray(20))
|
||
} else if selected {
|
||
Stroke::new(1.5, t.text)
|
||
} else {
|
||
Stroke::new(1.0, t.field_border)
|
||
};
|
||
p.rect_stroke(rect, CornerRadius::same(if t.pro { 0 } else { 5 }), stroke, StrokeKind::Outside);
|
||
crate::smart_ui::thumb_badge(ui, l, rect);
|
||
}
|
||
|
||
fn channels(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
crate::channels_panel::show(app, ui);
|
||
}
|
||
|
||
fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let Some(st) = app.session.active() else {
|
||
empty(ui, tl!("No document"));
|
||
return;
|
||
};
|
||
let doc = st.doc.clone();
|
||
let entries = st.history.entries();
|
||
let current = entries.len() - 1;
|
||
let redo: Vec<String> = st.history.redo_labels().map(str::to_string).collect();
|
||
let selected_snap = selected_snapshot(ui.ctx(), st);
|
||
let ctx = ui.ctx().clone();
|
||
// A row dragged onto the trash (#5324); a drag that never saw its release must not outlive it.
|
||
if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) {
|
||
ctx.data_mut(|d| d.remove::<HistoryDrag>(history_drag_key()));
|
||
}
|
||
let drag = ctx.data(|d| d.get_temp::<HistoryDrag>(history_drag_key()));
|
||
// Snapshot row (Photoshop shows the document's opening state with a thumbnail).
|
||
{
|
||
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::hover());
|
||
let thumb = Rect::from_min_size(pos2(rect.left() + 28.0, rect.center().y - 14.0), vec2(28.0, 28.0));
|
||
let ctx = ui.ctx().clone();
|
||
if let Some(layer) = doc.layers.first() {
|
||
widgets::checker(ui.painter(), thumb, 4.0);
|
||
let (tex, _) = app.layer_thumb(&ctx, &doc, layer);
|
||
ui.painter().image(tex, thumb, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
|
||
}
|
||
ui.painter().rect_stroke(thumb, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside);
|
||
ui.painter().text(pos2(thumb.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &doc.name, egui::FontId::proportional(12.0), t.text);
|
||
ui.painter().line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator));
|
||
}
|
||
let mut snap_cmd = None;
|
||
let mut target: Option<usize> = None;
|
||
let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true)));
|
||
// The dock gives History a fixed height: the rows scroll above the footer.
|
||
let footer = if t.pro { widgets::footer_height(ui) + ui.spacing().item_spacing.y } else { 0.0 };
|
||
let max_h = (ui.available_height() - footer).max(40.0);
|
||
egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| {
|
||
// Snapshots head the list and scroll with the states, so many of them never push the
|
||
// footer out of a short dock.
|
||
snap_cmd = snapshot_rows(app, ui, selected_snap);
|
||
for (i, (e, is_redo)) in all.enumerate() {
|
||
// The current state shows selected unless a snapshot is (Photoshop highlights one row).
|
||
let selected = i == current && selected_snap.is_none();
|
||
// Past states can be dragged onto the trash; the oldest one can't be deleted.
|
||
let sense = if is_redo || i == 0 { Sense::click() } else { Sense::click_and_drag() };
|
||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), sense);
|
||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &e));
|
||
if resp.drag_started() {
|
||
ui.ctx().data_mut(|d| d.insert_temp(history_drag_key(), HistoryDrag::State(i)));
|
||
}
|
||
if selected {
|
||
ui.painter().rect_filled(rect, if t.pro { 0.0 } else { t.radius_sm }, if t.pro { t.row_selected } else { t.accent_soft });
|
||
} else if resp.hovered() {
|
||
ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5));
|
||
}
|
||
let icon = match e.as_str() {
|
||
s if s.contains("Brush") || s.contains("Eraser") => "brush",
|
||
// The Layers panel's controls (layer.setProps).
|
||
"Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers",
|
||
s if s.contains("Layer") => "layers",
|
||
s if s.contains("Select") || s.contains("Marquee") || s.contains("Deselect") || s.contains("Lasso") => "square-dashed",
|
||
s if s.contains("Fill") => "paint-bucket",
|
||
"Open" => "image",
|
||
_ => "sliders-horizontal",
|
||
};
|
||
let color = if is_redo { t.text_faint } else { t.text };
|
||
icons::paint(
|
||
ui,
|
||
Rect::from_center_size(pos2(rect.left() + 42.0, rect.center().y), vec2(16.0, 16.0)),
|
||
icon,
|
||
12.0,
|
||
if is_redo { t.text_faint } else { t.icon },
|
||
);
|
||
ui.painter().text(pos2(rect.left() + 58.0, rect.center().y), Align2::LEFT_CENTER, &e, egui::FontId::proportional(12.0), color);
|
||
if resp.clicked() && i != current {
|
||
target = Some(i);
|
||
} else if resp.clicked() && selected_snap.is_some() {
|
||
// The current state is already the document: clicking it only selects it.
|
||
ctx.data_mut(|d| d.remove::<SelectedSnapshot>(selected_snapshot_key(doc.id)));
|
||
}
|
||
}
|
||
});
|
||
if let Some(drag) = drag
|
||
&& let Some(p) = ctx.input(|i| i.pointer.interact_pos())
|
||
{
|
||
let label = match drag {
|
||
HistoryDrag::Snapshot(id) => app.session.active().and_then(|st| st.snapshots.iter().find(|s| s.id == id)).map(|s| s.name.clone()),
|
||
HistoryDrag::State(i) => entries.get(i).cloned(),
|
||
};
|
||
crate::layer_transfer::ghost(&ctx, p, &label.unwrap_or_default());
|
||
}
|
||
let mut footer_cmd: Option<(&str, Value)> = None;
|
||
if t.pro {
|
||
// With a snapshot selected the trash deletes it, as in Photoshop (#5324).
|
||
let can_delete = selected_snap.is_some() || app.session.is_enabled("history.deleteState");
|
||
let can_new = app.session.is_enabled("history.newDocument");
|
||
widgets::panel_footer(ui, |ui| {
|
||
let trash = ui.add_enabled_ui(can_delete, |ui| icons::button(ui, "trash", 26.0, false, tl!("Delete current state"))).inner;
|
||
if trash.clicked() {
|
||
footer_cmd = Some(match selected_snap {
|
||
Some(id) => ("history.deleteSnapshot", json!({"snapshot": id})),
|
||
None => ("history.deleteState", json!({})),
|
||
});
|
||
}
|
||
// A snapshot or a past state dragged onto the trash is deleted (#5324).
|
||
if let Some(drag) = drag
|
||
&& dropped_on(ui, &trash)
|
||
{
|
||
footer_cmd = Some(match drag {
|
||
HistoryDrag::Snapshot(id) => ("history.deleteSnapshot", json!({"snapshot": id})),
|
||
HistoryDrag::State(i) => ("history.deleteState", json!({"state": i})),
|
||
});
|
||
}
|
||
let can_snap = app.session.is_enabled("history.newSnapshot");
|
||
if ui.add_enabled_ui(can_snap, |ui| icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot"))).inner.clicked() {
|
||
footer_cmd = Some(("history.newSnapshot", json!({})));
|
||
}
|
||
if ui.add_enabled_ui(can_new, |ui| icons::button(ui, "file-plus", 26.0, false, tl!("Create new document from current state"))).inner.clicked() {
|
||
footer_cmd = Some(("history.newDocument", json!({})));
|
||
}
|
||
});
|
||
}
|
||
if ctx.input(|i| i.pointer.any_released()) {
|
||
ctx.data_mut(|d| d.remove::<HistoryDrag>(history_drag_key()));
|
||
}
|
||
// An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under
|
||
// its box would leave it transforming pixels that changed.
|
||
// Selecting a snapshot changes the document, so it waits for an open transform like the rows.
|
||
let snap_cmd = snap_cmd.filter(|(cmd, _)| *cmd != "history.selectSnapshot" || app.ui.transform.is_none());
|
||
if let Some((cmd, p)) = footer_cmd.filter(|_| app.ui.transform.is_none()).or(snap_cmd) {
|
||
let picked = (cmd == "history.selectSnapshot").then(|| p.get("snapshot").and_then(Value::as_u64)).flatten();
|
||
match app.run(cmd, p) {
|
||
Err(e) => {
|
||
app.ui.status = e;
|
||
app.ui.status_error = true;
|
||
}
|
||
// The snapshot stays selected while the document is the state the click made.
|
||
Ok(_) => {
|
||
if let (Some(id), Some(st)) = (picked, app.session.active()) {
|
||
ctx.data_mut(|d| d.insert_temp::<SelectedSnapshot>(selected_snapshot_key(st.doc.id), (id, history_spot(st))));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// One command per click (#2966): stepping with Undo/Redo once per state made the canvas
|
||
// recomposite the whole document.
|
||
if let Some(state) = target.filter(|_| app.ui.transform.is_none())
|
||
&& let Err(e) = app.run("history.selectState", json!({ "state": state }))
|
||
{
|
||
app.ui.status = e;
|
||
app.ui.status_error = true;
|
||
}
|
||
}
|
||
|
||
/// A History panel row being dragged, for a drop on the trash (#5324).
|
||
#[derive(Clone, Copy, Debug)]
|
||
enum HistoryDrag {
|
||
Snapshot(u64),
|
||
State(usize),
|
||
}
|
||
|
||
fn history_drag_key() -> egui::Id {
|
||
egui::Id::new("history-drag")
|
||
}
|
||
|
||
/// The snapshot the History panel shows selected (#5324), and where the history stood after its
|
||
/// click: the number of past states, the current state's name and the number of redo states.
|
||
type SelectedSnapshot = (u64, HistorySpot);
|
||
type HistorySpot = (usize, Option<String>, usize);
|
||
|
||
fn history_spot(st: &photocraft_engine::DocState) -> HistorySpot {
|
||
(st.history.past_len(), st.history.entries().pop(), st.history.redo_labels().count())
|
||
}
|
||
|
||
fn selected_snapshot_key(doc: photocraft_doc::DocId) -> egui::Id {
|
||
egui::Id::new(("history-snapshot-selected", doc))
|
||
}
|
||
|
||
/// The selected snapshot: the one last clicked, while it still exists and the history is still at
|
||
/// the state that click made. Any other step (an edit, Undo, a state row) deselects it, as in
|
||
/// Photoshop.
|
||
fn selected_snapshot(ctx: &egui::Context, st: &photocraft_engine::DocState) -> Option<u64> {
|
||
let key = selected_snapshot_key(st.doc.id);
|
||
let (id, spot) = ctx.data(|d| d.get_temp::<SelectedSnapshot>(key))?;
|
||
let live = spot == history_spot(st) && st.snapshots.iter().any(|s| s.id == id);
|
||
if !live {
|
||
ctx.data_mut(|d| d.remove::<SelectedSnapshot>(key));
|
||
}
|
||
live.then_some(id)
|
||
}
|
||
|
||
/// The History panel's snapshot rows (#2966), under the opening row, each with its thumbnail and
|
||
/// name: a click returns to the snapshot (a new state) and selects it, a double-click or
|
||
/// right-click › Rename… edits the name in place (Enter or a click elsewhere keeps it, Escape
|
||
/// cancels), right-click › Delete removes it, and a drag onto the trash too (#5324). Returns the
|
||
/// command a row asks for.
|
||
fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option<u64>) -> Option<(&'static str, Value)> {
|
||
let t = Tokens::get(ui.ctx());
|
||
let st = app.session.active()?;
|
||
let (doc_id, snaps) = (st.doc.id, st.snapshots.clone());
|
||
let live: Vec<u64> = snaps.iter().map(|s| s.id).collect();
|
||
// Per document: snapshot ids restart in each, and a tab switch must not carry the edit over.
|
||
let rename_id = egui::Id::new(("history-snapshot-rename", doc_id));
|
||
let mut renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)).flatten();
|
||
let mut cmd = None;
|
||
let ctx = ui.ctx().clone();
|
||
for snap in &snaps {
|
||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click_and_drag());
|
||
let is_selected = selected == Some(snap.id);
|
||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, is_selected, &snap.name));
|
||
if resp.drag_started() {
|
||
ctx.data_mut(|d| d.insert_temp(history_drag_key(), HistoryDrag::Snapshot(snap.id)));
|
||
}
|
||
if is_selected {
|
||
ui.painter().rect_filled(rect, if t.pro { 0.0 } else { t.radius_sm }, if t.pro { t.row_selected } else { t.accent_soft });
|
||
} else if resp.hovered() {
|
||
ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5));
|
||
}
|
||
let thumb = Rect::from_min_size(pos2(rect.left() + 28.0, rect.center().y - 14.0), vec2(28.0, 28.0));
|
||
widgets::checker(ui.painter(), thumb, 4.0);
|
||
let tex = app.snapshot_thumb(&ctx, doc_id, snap, &live);
|
||
ui.painter().image(tex, thumb, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
|
||
ui.painter().rect_stroke(thumb, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside);
|
||
match renaming.as_mut().filter(|(id, _)| *id == snap.id) {
|
||
Some((_, text)) => {
|
||
let field = Rect::from_min_max(pos2(thumb.right() + 6.0, rect.center().y - 10.0), pos2(rect.right() - 6.0, rect.center().y + 10.0));
|
||
let r = ui.put(field, egui::TextEdit::singleline(text));
|
||
if r.lost_focus() {
|
||
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
cmd = Some(("history.renameSnapshot", json!({"snapshot": snap.id, "name": text.clone()})));
|
||
}
|
||
renaming = None;
|
||
} else if !r.has_focus() {
|
||
// Starting to rename: focus the field with the whole name selected, so typing
|
||
// replaces it (Photoshop).
|
||
r.request_focus();
|
||
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) {
|
||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()));
|
||
state.cursor.set_char_range(Some(all));
|
||
state.store(ui.ctx(), r.id);
|
||
}
|
||
}
|
||
}
|
||
None => {
|
||
ui.painter().text(pos2(thumb.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &snap.name, egui::FontId::proportional(12.0), t.text);
|
||
}
|
||
}
|
||
if resp.double_clicked() {
|
||
renaming = Some((snap.id, snap.name.clone()));
|
||
} else if resp.clicked() {
|
||
cmd = Some(("history.selectSnapshot", json!({"snapshot": snap.id})));
|
||
}
|
||
resp.context_menu(|ui| {
|
||
if ui.button(tl!("Rename…")).clicked() {
|
||
renaming = Some((snap.id, snap.name.clone()));
|
||
ui.close();
|
||
}
|
||
if ui.button(tl!("Delete")).clicked() {
|
||
cmd = Some(("history.deleteSnapshot", json!({"snapshot": snap.id})));
|
||
ui.close();
|
||
}
|
||
});
|
||
}
|
||
if !snaps.is_empty() {
|
||
let y = ui.cursor().top();
|
||
ui.painter().hline(ui.max_rect().x_range(), y, Stroke::new(1.0, t.separator));
|
||
}
|
||
ui.data_mut(|d| d.insert_temp(rename_id, renaming));
|
||
cmd
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------- properties
|
||
|
||
/// Floating Properties card anchored to the canvas' top-right corner. It shows the same content as
|
||
/// the docked panel (`properties_body`): document, layer, adjustment and mask properties.
|
||
pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||
if app.ui.docking.is_some() {
|
||
return;
|
||
}
|
||
// Pro (Photoshop) docks Properties; Studio floats it over the canvas.
|
||
if !app.ui.panels.properties || Tokens::get(ctx).pro {
|
||
return;
|
||
}
|
||
if app.session.active().is_none() {
|
||
return;
|
||
}
|
||
let t = Tokens::get(ctx);
|
||
let canvas = app.last_canvas_rect;
|
||
let width = 320.0;
|
||
let pos = pos2(canvas.right() - width - 12.0, canvas.top() + 44.0);
|
||
let frame = egui::Frame::NONE
|
||
.fill(t.card)
|
||
.stroke(Stroke::new(1.0, t.card_border))
|
||
.corner_radius(CornerRadius::same(t.radius_lg as u8))
|
||
.shadow(egui::Shadow { offset: [0, 12], blur: 36, spread: 0, color: t.shadow })
|
||
.inner_margin(egui::Margin::same(14));
|
||
// A floating panel (Order::Middle like other panels, so menus, popups and dialogs stay above
|
||
// it), anchored to the canvas corner and dragged by its title off the part being worked on.
|
||
let card_id = egui::Id::new("properties-card");
|
||
let offset: egui::Vec2 = ctx.data(|m| m.get_temp(card_id)).unwrap_or_default();
|
||
let mut drag = egui::Vec2::ZERO;
|
||
let shown = egui::Area::new(card_id).order(egui::Order::Middle).fixed_pos(pos + offset).show(ctx, |ui| {
|
||
frame.show(ui, |ui| {
|
||
ui.set_width(width - 28.0);
|
||
let title = ui.horizontal(|ui| {
|
||
ui.label(RichText::new(tl!("Properties")).font(theme::semibold(13.5)).color(t.text));
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if icons::button(ui, "minus", 22.0, false, tl!("Hide Properties")).clicked() {
|
||
app.ui.panels.properties = false;
|
||
}
|
||
});
|
||
});
|
||
let bar = title.response.rect.with_max_x(title.response.rect.right() - 28.0);
|
||
let grip = ui.interact(bar, card_id.with("title"), Sense::drag());
|
||
if grip.hovered() || grip.dragged() {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||
}
|
||
drag = grip.drag_delta();
|
||
ui.add_space(6.0);
|
||
widgets::hairline(ui);
|
||
ui.add_space(8.0);
|
||
properties_body(app, ui);
|
||
});
|
||
});
|
||
if drag != egui::Vec2::ZERO && canvas.is_positive() {
|
||
// Keep the title bar inside the canvas.
|
||
let r = shown.response.rect.translate(drag);
|
||
let dx = (canvas.left() - r.left()).max(0.0) - (r.right() - canvas.right()).max(0.0);
|
||
let dy = (canvas.top() - r.top()).max(0.0) - (r.top() + 40.0 - canvas.bottom()).max(0.0);
|
||
ctx.data_mut(|m| m.insert_temp(card_id, offset + drag + egui::vec2(dx, dy)));
|
||
}
|
||
}
|
||
|
||
fn adjustment_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &photocraft_doc::Adjustment) {
|
||
crate::adjust_editors::layer_editor(app, ui, id, adj);
|
||
ui.add_space(6.0);
|
||
let layer = app.session.active().and_then(|s| s.doc.layer(id).map(|l| (l.visible, l.clipped)));
|
||
if let Some((visible, clipped)) = layer {
|
||
ui.horizontal(|ui| {
|
||
let mut vis = visible;
|
||
if widgets::toggle(ui, &mut vis, tl!("Adjustment visible")).changed() {
|
||
let _ = app.run("layer.setProps", json!({"layer": id.0, "visible": vis}));
|
||
}
|
||
let mut clip = clipped;
|
||
if widgets::toggle(ui, &mut clip, tl!("Clip to layer below")).changed() {
|
||
let _ = app.run("layer.setProps", json!({"layer": id.0, "clipped": clip}));
|
||
}
|
||
});
|
||
}
|
||
ui.add_space(6.0);
|
||
if widgets::secondary_button(ui, tl!("Reset to defaults"), ui.available_width()).clicked() {
|
||
let doc = app.session.active().map_or(0, |s| s.doc.id.0);
|
||
// Photoshop's two-step Reset where measured (Color Balance), else the defaults.
|
||
let params = crate::adjust_editors::properties_reset(ui.ctx(), doc, id, adj).unwrap_or_else(|| json!({"layer": id.0}));
|
||
let _ = app.run("layer.setAdjustment", params);
|
||
app.live_adjust = None;
|
||
}
|
||
}
|
||
|
||
fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||
let t = Tokens::get(ui.ctx());
|
||
if let Some(s) = layer.surface() {
|
||
let b = app.cached_bounds(layer.id.0, s);
|
||
egui::Grid::new("props-grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
|
||
for (k, v) in [("X", b.x0.to_string()), ("Y", b.y0.to_string()), ("W", b.width().to_string()), ("H", b.height().to_string())] {
|
||
ui.label(RichText::new(k).color(t.text_dim));
|
||
ui.label(RichText::new(format!("{v} px")).font(theme::mono(12.0)).color(t.text));
|
||
ui.end_row();
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
}
|
||
let mut o = layer.opacity * 100.0;
|
||
let r = widgets::slider_row(ui, tl!("Opacity"), &mut o, 0.0..=100.0, "%", None);
|
||
if r.drag_stopped() || (r.changed() && !r.dragged()) {
|
||
let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "opacity": o / 100.0}));
|
||
}
|
||
let mut f = layer.fill_opacity * 100.0;
|
||
let r = widgets::slider_row(ui, crate::i18n::tr_ctx(crate::i18n::current(), "layers", "Fill"), &mut f, 0.0..=100.0, "%", None);
|
||
if r.drag_stopped() || (r.changed() && !r.dragged()) {
|
||
let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "fill": f / 100.0}));
|
||
}
|
||
}
|
||
|
||
/// Docked Properties body (Pro theme).
|
||
fn properties_body(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let identity = app.session.active().map(|st| (st.doc.id, st.active_layer, app.ui.mask_target, app.ui.vector_mask_target, app.ui.properties_layer));
|
||
ui.push_id(("properties-target", identity), |ui| properties_body_inner(app, ui));
|
||
}
|
||
|
||
fn properties_body_inner(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let Some(st) = app.session.active() else {
|
||
empty(ui, tl!("No properties"));
|
||
return;
|
||
};
|
||
let doc = st.doc.clone();
|
||
if let Some(layer) = st.active_layer.and_then(|id| doc.layer(id))
|
||
&& crate::mask_props_ui::targeted(app, layer)
|
||
{
|
||
crate::props_layout::header(ui, layer);
|
||
crate::mask_props_ui::properties(app, ui, layer);
|
||
return;
|
||
}
|
||
// The vector mask's own controls, when its thumbnail is targeted.
|
||
if let Some(layer) = st.active_layer.and_then(|id| doc.layer(id))
|
||
&& crate::mask_props_ui::vector_targeted(app, layer)
|
||
{
|
||
crate::props_layout::header(ui, layer);
|
||
crate::mask_props_ui::vector_properties(app, ui, layer);
|
||
return;
|
||
}
|
||
// Photoshop shows the Document properties when nothing or the Background layer is selected,
|
||
// with Align and Distribute above them when the Background is one of several (#4608).
|
||
if crate::doc_props_ui::shows_document(&st.doc, st.active_layer) {
|
||
if t.pro && st.selected_layers().len() > 1 {
|
||
crate::layer_props_ui::selection_align(app, ui);
|
||
}
|
||
crate::doc_props_ui::properties(app, ui);
|
||
return;
|
||
}
|
||
let Some(id) = st.active_layer else { return };
|
||
// Borrowed from the document snapshot: cloning the layer every frame copied whole groups.
|
||
let Some(layer) = doc.layer(id) else { return };
|
||
// Header: kind icon, layer name and kind (#155); sections below draw their own separators.
|
||
crate::props_layout::header(ui, layer);
|
||
let is_adjustment = matches!(layer.content, LayerContent::Adjustment(_));
|
||
if is_adjustment || layer.artboard().is_some() || !t.pro {
|
||
ui.add_space(4.0);
|
||
widgets::hairline(ui);
|
||
ui.add_space(6.0);
|
||
}
|
||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||
adjustment_controls(app, ui, id, adj);
|
||
} else if layer.artboard().is_some() {
|
||
crate::artboard_ui::properties(app, ui, layer);
|
||
} else if t.pro {
|
||
// Transform, Align, the kind's sections and Quick Actions.
|
||
crate::layer_props_ui::properties(app, ui, layer);
|
||
} else {
|
||
if matches!(layer.content, LayerContent::Fill(photocraft_doc::Fill::Gradient { .. })) {
|
||
crate::gradient_ui::properties(app, ui, layer);
|
||
ui.add_space(6.0);
|
||
}
|
||
layer_controls(app, ui, layer);
|
||
if matches!(layer.content, LayerContent::Text(_)) {
|
||
crate::type_tool::type_properties(app, ui);
|
||
}
|
||
if matches!(layer.content, LayerContent::Shape(_)) {
|
||
crate::vector_ui::shape_properties(app, ui, id);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Photoshop's Adjustments panel: a grid of one-click adjustment layers.
|
||
fn adjustments_grid(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.label(RichText::new(tl!("Add an adjustment")).color(t.text_dim));
|
||
ui.add_space(4.0);
|
||
let items: [(&str, &str, &str); 16] = [
|
||
("brightnessContrast", "sun", tl!("Brightness/Contrast")),
|
||
("levels", "gauge", tl!("Levels")),
|
||
("curves", "pen-tool", tl!("Curves")),
|
||
("exposure", "scan", tl!("Exposure")),
|
||
("vibrance", "sparkles", tl!("Vibrance")),
|
||
("hueSaturation", "droplet", tl!("Hue/Saturation")),
|
||
("colorBalance", "blend", tl!("Color Balance")),
|
||
("blackWhite", "contrast", tl!("Black & White")),
|
||
("photoFilter", "circle-dot", tl!("Photo Filter")),
|
||
("channelMixer", "sliders-horizontal", tl!("Channel Mixer")),
|
||
("invert", "squares-subtract", tl!("Invert")),
|
||
("posterize", "layers", tl!("Posterize")),
|
||
("threshold", "square", tl!("Threshold")),
|
||
("gradientMap", "palette", tl!("Gradient Map")),
|
||
("selectiveColor", "swatch-book", tl!("Selective Color")),
|
||
("colorLookup", "grid-3x3", tl!("Color Lookup")),
|
||
];
|
||
let mut run = None;
|
||
ui.horizontal_wrapped(|ui| {
|
||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||
for (kind, icon, tip) in items {
|
||
if icons::button(ui, icon, 30.0, false, tip).clicked() {
|
||
run = Some(format!("layer.newAdjustmentLayer.{kind}"));
|
||
}
|
||
}
|
||
});
|
||
if let Some(id) = run {
|
||
let _ = app.run(&id, json!({}));
|
||
app.ui.dock_tabs.properties = 0;
|
||
}
|
||
}
|
||
|
||
/// 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" });
|
||
}
|
||
}
|
||
|
||
/// Where the dock tells the Color field how tall its group body is.
|
||
fn color_field_fill_id() -> egui::Id {
|
||
egui::Id::new("color-field-fill-height")
|
||
}
|
||
|
||
/// The pro Color field stretches to its dock group's height instead of a fixed 120 pt: a dock
|
||
/// calls this with the group body's `ui` before drawing the tab.
|
||
pub(crate) fn offer_color_field_fill(ui: &egui::Ui, pro: bool, group: crate::dock::Group, tab: usize) {
|
||
if pro && group == crate::dock::Group::Color && tab == 0 {
|
||
let inner = ui.available_height().max(0.0);
|
||
ui.data_mut(|d| d.insert_temp(color_field_fill_id(), inner));
|
||
}
|
||
}
|
||
|
||
/// Height of the Color field: the group body's height (when docked) less the hex/RGB readout,
|
||
/// which folds onto a second line in a narrow dock. 120 pt when nothing sets a height.
|
||
fn color_field_height(avail: Option<f32>, width: f32) -> f32 {
|
||
let reserve = 32.0 + if width < 280.0 { 26.0 } else { 0.0 };
|
||
avail.filter(|a| a.is_finite()).map_or(120.0, |a| (a - reserve).clamp(120.0, 2000.0))
|
||
}
|
||
|
||
/// The colour the SV field draws for `hsva`, with what the field showed last (`hue`, `saturation`)
|
||
/// filling in what the colour itself cannot carry: a grey has no hue of its own, and black has
|
||
/// neither hue nor saturation — so a drag that bottoms out into black would otherwise round-trip
|
||
/// through RGB as `s = 0` and drop the knob from the bottom-right corner to the bottom-left one
|
||
/// (#3228).
|
||
fn field_hsva(mut hsva: egui::ecolor::Hsva, remembered: (f32, f32)) -> egui::ecolor::Hsva {
|
||
if hsva.v < 0.01 {
|
||
hsva.h = remembered.0;
|
||
hsva.s = remembered.1;
|
||
} else if hsva.s < 0.01 {
|
||
// A grey: the saturation really is zero (that is where the knob belongs), only the hue
|
||
// is undefined.
|
||
hsva.h = remembered.0;
|
||
}
|
||
hsva
|
||
}
|
||
|
||
/// 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 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 });
|
||
// What the field showed last: a grey carries no hue, black neither hue nor saturation (#3228).
|
||
let remembered: (f32, f32) = ui.data(|d| d.get_temp(key)).unwrap_or((hsva.h, hsva.s));
|
||
hsva = field_hsva(hsva, remembered);
|
||
let w = ui.available_width();
|
||
let strip_w = 14.0;
|
||
// Photoshop's Color panel field fills the panel: take the height the dock offers, less the
|
||
// hex/RGB readout (which folds onto a second line in a narrow dock). Without a dock, 120 pt.
|
||
// Only Photoshop's themes stretch it over the group: the other themes put sliders under it.
|
||
let fill = if t.pro { ui.data(|d| d.get_temp::<f32>(color_field_fill_id())) } else { None };
|
||
let h = color_field_height(fill, w);
|
||
ui.horizontal(|ui| {
|
||
ui.spacing_mut().item_spacing.x = 8.0;
|
||
let (chips, _) = ui.allocate_exact_size(vec2(38.0, h), Sense::hover());
|
||
field_chips(app, ui, chips);
|
||
// SV field.
|
||
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;
|
||
for j in 0..=n {
|
||
for i in 0..=n {
|
||
let (sx, vy) = (i as f32 / n as f32, j as f32 / n as f32);
|
||
let c = hsva_color32(egui::ecolor::Hsva::new(hsva.h, sx, 1.0 - vy, 1.0));
|
||
mesh.colored_vertex(pos2(field.left() + sx * field.width(), field.top() + vy * field.height()), c);
|
||
}
|
||
}
|
||
for j in 0..n {
|
||
for i in 0..n {
|
||
let a = (j * (n + 1) + i) as u32;
|
||
let b = a + 1;
|
||
let c = a + (n + 1) as u32;
|
||
let d = c + 1;
|
||
mesh.add_triangle(a, b, d);
|
||
mesh.add_triangle(a, d, c);
|
||
}
|
||
}
|
||
ui.painter().add(mesh);
|
||
ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside);
|
||
if (fresp.dragged() || fresp.clicked())
|
||
&& let Some(p) = fresp.interact_pointer_pos()
|
||
{
|
||
hsva.s = ((p.x - field.left()) / field.width()).clamp(0.0, 1.0);
|
||
hsva.v = 1.0 - ((p.y - field.top()) / field.height()).clamp(0.0, 1.0);
|
||
}
|
||
let knob = pos2(field.left() + hsva.s * field.width(), field.top() + (1.0 - hsva.v) * field.height());
|
||
ui.painter().circle_stroke(knob, 5.0, Stroke::new(1.5, Color32::WHITE));
|
||
ui.painter().circle_stroke(knob, 6.5, Stroke::new(1.0, Color32::from_black_alpha(160)));
|
||
// Hue strip.
|
||
let (strip, sresp) = ui.allocate_exact_size(vec2(strip_w, h), Sense::click_and_drag());
|
||
let mut m2 = egui::Mesh::default();
|
||
let steps = 24;
|
||
for k in 0..=steps {
|
||
let f = k as f32 / steps as f32;
|
||
let c = hsva_color32(egui::ecolor::Hsva::new(1.0 - f, 1.0, 1.0, 1.0));
|
||
m2.colored_vertex(pos2(strip.left(), strip.top() + f * strip.height()), c);
|
||
m2.colored_vertex(pos2(strip.right(), strip.top() + f * strip.height()), c);
|
||
}
|
||
for k in 0..steps {
|
||
let a = (k * 2) as u32;
|
||
m2.add_triangle(a, a + 1, a + 3);
|
||
m2.add_triangle(a, a + 3, a + 2);
|
||
}
|
||
ui.painter().add(m2);
|
||
if (sresp.dragged() || sresp.clicked())
|
||
&& let Some(p) = sresp.interact_pointer_pos()
|
||
{
|
||
hsva.h = 1.0 - ((p.y - strip.top()) / strip.height()).clamp(0.0, 0.9999);
|
||
}
|
||
let y = strip.top() + (1.0 - hsva.h) * strip.height();
|
||
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() {
|
||
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, hsva.s)));
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
ui.horizontal(|ui| {
|
||
let mut c = if bg_active { app.session.tools.background } else { app.session.tools.foreground };
|
||
if color_readout(ui, ui.id().with(("color-field", bg_active)), &mut c) {
|
||
if bg_active {
|
||
app.session.tools.background = c;
|
||
} else {
|
||
app.session.tools.foreground = c;
|
||
crate::type_tool::foreground_changed(app);
|
||
}
|
||
// Keep the hue for greys, so the field marker doesn't jump to red; the saturation with
|
||
// it, so a later black lands on the corner it was last dragged to (#3228).
|
||
let h = srgb_hsva(c);
|
||
if h.s >= 0.01 && h.v >= 0.01 {
|
||
ui.data_mut(|d| d.insert_temp(key, (h.h, h.s)));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Tool colours are sRGB-encoded floats; egui's Hsva works on sRGB bytes via these helpers
|
||
/// (its `from_rgba_unmultiplied` expects *linear* RGB, which gave wrong readouts).
|
||
fn srgb_bytes(c: [f32; 4]) -> [u8; 3] {
|
||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||
[q(c[0]), q(c[1]), q(c[2])]
|
||
}
|
||
|
||
/// The Color panel's HSB, as Photoshop's and the Color Picker's: on the sRGB-encoded values,
|
||
/// so a 50% grey has Brightness 50%. egui's `Hsva` conversions work in linear light, which put
|
||
/// that grey at 22% and drew the field's middle as 188 (#3323); `Hsva` only holds the numbers.
|
||
fn srgb_hsva(c: [f32; 4]) -> egui::ecolor::Hsva {
|
||
let [h, s, v] = crate::color_picker_ui::rgb_to_hsv([c[0], c[1], c[2]].map(|v| v.clamp(0.0, 1.0)));
|
||
egui::ecolor::Hsva::new(h / 360.0, s, v, 1.0)
|
||
}
|
||
|
||
fn hsva_srgb(h: egui::ecolor::Hsva) -> [f32; 4] {
|
||
let [r, g, b] = crate::color_picker_ui::hsv_to_rgb(h.h * 360.0, h.s.clamp(0.0, 1.0), h.v.clamp(0.0, 1.0));
|
||
[r, g, b, 1.0]
|
||
}
|
||
|
||
/// The colour to draw for Color panel HSB `h` (see [`srgb_hsva`]).
|
||
fn hsva_color32(h: egui::ecolor::Hsva) -> Color32 {
|
||
let [r, g, b] = srgb_bytes(hsva_srgb(h));
|
||
Color32::from_rgb(r, g, b)
|
||
}
|
||
|
||
/// The Color panel's editable colour readout: a hex field and R, G, B fields, all drawn like the
|
||
/// dock's [`widgets::value_field`] so they sit in the theme. Editing any of them sets `color`
|
||
/// (sRGB-encoded floats) and returns `true`.
|
||
fn color_readout(ui: &mut egui::Ui, id: egui::Id, color: &mut [f32; 4]) -> bool {
|
||
let t = Tokens::get(ui.ctx());
|
||
let mut changed = false;
|
||
// Wrapped so the fields fold onto a second line in a narrow dock instead of being clipped.
|
||
ui.horizontal_wrapped(|ui| {
|
||
// Docks zero the item spacing; keep the fields apart.
|
||
ui.spacing_mut().item_spacing.x = 4.0;
|
||
ui.label(RichText::new("#").font(theme::mono(12.0)).color(t.text_faint));
|
||
changed = hex_edit(ui, id.with("hex"), color);
|
||
let [mut r, mut g, mut b] = srgb_bytes(*color).map(f32::from);
|
||
let mut rgb = false;
|
||
for (label, v) in [("R", &mut r), ("G", &mut g), ("B", &mut b)] {
|
||
ui.label(RichText::new(label).font(theme::mono(11.0)).color(t.text_faint));
|
||
rgb |= widgets::value_field(ui, v, 0.0..=255.0, "", 38.0).changed();
|
||
}
|
||
if rgb {
|
||
*color = [r / 255.0, g / 255.0, b / 255.0, 1.0];
|
||
changed = true;
|
||
}
|
||
});
|
||
changed
|
||
}
|
||
|
||
/// The hex field of [`color_readout`]: type a colour with or without the leading `#`. Valid input
|
||
/// sets `color` and returns `true`. While it has focus it shows exactly what is typed, so partial
|
||
/// or invalid input isn't overwritten by the colour; an invalid entry snaps back when focus leaves.
|
||
fn hex_edit(ui: &mut egui::Ui, id: egui::Id, color: &mut [f32; 4]) -> bool {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (rect, _) = ui.allocate_exact_size(vec2(64.0, 24.0), Sense::hover());
|
||
widgets::surface(ui, rect, t.field, false);
|
||
if !t.bevel {
|
||
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||
}
|
||
let [r, g, b] = srgb_bytes(*color);
|
||
let typing = if ui.memory(|m| m.has_focus(id)) { ui.data(|d| d.get_temp::<String>(id)) } else { None };
|
||
let mut text = typing.unwrap_or_else(|| format!("{r:02X}{g:02X}{b:02X}"));
|
||
let field = rect.shrink2(vec2(5.0, 2.0));
|
||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||
// Room for a pasted "#rrggbb"; the parse trims it, and the field shows plain digits otherwise.
|
||
// `Frame::NONE`: the themed surface above is this field's frame.
|
||
let resp =
|
||
child.add(egui::TextEdit::singleline(&mut text).id(id).char_limit(7).desired_width(field.width()).frame(egui::Frame::NONE).font(theme::mono(12.0)));
|
||
if resp.has_focus() {
|
||
ui.data_mut(|d| d.insert_temp(id, text.clone()));
|
||
} else {
|
||
ui.data_mut(|d| d.remove::<String>(id));
|
||
}
|
||
if resp.changed()
|
||
&& let Some(c) = crate::color_picker_ui::parse_hex(&text)
|
||
{
|
||
*color = [c[0], c[1], c[2], 1.0];
|
||
return true;
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Photoshop's brush preset picker chip: a soft/hard round tip preview with the size underneath.
|
||
/// Draw a round brush tip preview (hard core fading to a soft edge).
|
||
fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: Color32) {
|
||
let inner = rad * hardness.clamp(0.05, 1.0);
|
||
let steps = 8;
|
||
for k in (0..=steps).rev() {
|
||
let f = k as f32 / steps as f32;
|
||
let rr = inner + (rad - inner) * f;
|
||
let a = if hardness >= 0.99 { 1.0 } else { (1.0 - f).powf(1.5) };
|
||
p.circle_filled(c, rr, color.gamma_multiply(a));
|
||
}
|
||
}
|
||
|
||
/// The Brush's or Pencil's Mode dropdown: the blend modes, with Photoshop's painting-only Clear
|
||
/// after Dissolve (#3439). Clear erases with the brush (`clear`); it shows while chosen, or while
|
||
/// `~` is held (`held`). The wheel over it steps the mode, as over the Layers panel's (#2061).
|
||
fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::BrushSettings, clear: &mut bool, held: bool) {
|
||
let mut opts: Vec<(Option<BlendMode>, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (Some(*m), m.label())).collect();
|
||
let at = opts.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(opts.len(), |i| i + 1);
|
||
opts.insert(at, (None, "Clear"));
|
||
let mut mode = if *clear || held { None } else { Some(b.mode) };
|
||
if !widgets::dropdown_wheel_hovered(ui, id, &mut mode, &opts, 96.0).0.is_empty() {
|
||
*clear = mode.is_none();
|
||
if let Some(m) = mode {
|
||
b.mode = m;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Options-bar Smoothing % (the brush's stroke smoothing; the live stroke and the commit use it).
|
||
fn smoothing_field(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings, width: f32) {
|
||
let mut sm = (b.smoothing.amount * 100.0).round();
|
||
if widgets::value_field(ui, &mut sm, 0.0..=100.0, "%", width).changed() {
|
||
b.smoothing.enabled = true;
|
||
b.smoothing.amount = (sm / 100.0).clamp(0.0, 1.0);
|
||
}
|
||
}
|
||
|
||
/// Options-bar gear beside Smoothing: Photoshop's smoothing options popup, edited on the brush
|
||
/// like the Smoothing % (so they stay per tool and reach the live stroke and the commit).
|
||
fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings) {
|
||
let resp = icons::button(ui, "settings", 24.0, false, tl!("Set additional smoothing options"));
|
||
let resp = crate::brush_picker::named(resp, "Set additional smoothing options");
|
||
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||
let s = &mut b.smoothing;
|
||
widgets::checkbox(ui, &mut s.pulled_string, tl!("Pulled String Mode"));
|
||
widgets::checkbox(ui, &mut s.catch_up, tl!("Stroke Catch-up"));
|
||
widgets::checkbox(ui, &mut s.catch_up_on_end, tl!("Catch-up on Stroke End"));
|
||
widgets::checkbox(ui, &mut s.adjust_for_zoom, tl!("Adjust for Zoom"));
|
||
});
|
||
}
|
||
|
||
/// Options-bar brush chip: a click shows or hides Photoshop's Brush Preset picker below it (size,
|
||
/// hardness, the preset library), the one a right-click on the canvas opens.
|
||
fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushSettings, state: &mut crate::state::UiState) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (r, _) = ui.allocate_exact_size(vec2(44.0, 30.0), Sense::hover());
|
||
let resp = ui.interact(r, crate::brush_picker::chip_id(), Sense::click());
|
||
if resp.hovered() || state.brush_picker.is_some() {
|
||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||
}
|
||
let c = pos2(r.left() + 14.0, r.top() + 11.0);
|
||
// Photoshop's white tip vanishes on light chrome; light themes use their icon colour.
|
||
let tip = if t.dark() { Color32::WHITE } else { t.icon };
|
||
brush_tip(ui.painter(), c, 7.0, b.hardness, tip);
|
||
ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", b.size.round() as i64), egui::FontId::proportional(9.5), t.text_dim);
|
||
icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint);
|
||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Brush Preset picker")));
|
||
if resp.on_hover_text(tl!("Brush Preset picker")).clicked() {
|
||
if state.brush_picker.is_some() {
|
||
crate::brush_picker::close(state);
|
||
} else {
|
||
crate::brush_picker::open(state, ui.ctx(), [r.left(), r.bottom() + 2.0]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A drag from within a multi-selection moves the whole selection, not only the grabbed
|
||
/// row. Dragging an unselected row keeps the existing single-layer behavior.
|
||
fn layer_drop_payload(dragged: u64, target: LayerId, position: &str, selected: &[LayerId]) -> Value {
|
||
if selected.len() > 1 && selected.contains(&LayerId(dragged)) {
|
||
json!({"layers": selected.iter().map(|id| id.0).collect::<Vec<_>>(), "target": target.0, "position": position})
|
||
} else {
|
||
json!({"layer": dragged, "target": target.0, "position": position})
|
||
}
|
||
}
|
||
|
||
/// Would this drop move the Background off the bottom, or put a layer below it? Photoshop
|
||
/// refuses both (no drop indicator); ⌥-dragging the Background still copies it above.
|
||
fn background_drop_refused(doc: &photocraft_doc::Document, payload: &Value, target: LayerId, position: &str, copy: bool) -> bool {
|
||
let Some(bg) = doc.layers.first().filter(|b| crate::doc_props_ui::is_background(doc, b)).map(|b| b.id) else { return false };
|
||
let moves_bg =
|
||
!copy && (payload["layer"].as_u64() == Some(bg.0) || payload["layers"].as_array().is_some_and(|a| a.iter().any(|v| v.as_u64() == Some(bg.0))));
|
||
moves_bg || (target == bg && position == "below")
|
||
}
|
||
|
||
/// Drag a layer row to reorder: drop above, below, or inside an existing group. With ⌥ held on
|
||
/// release the layers stay put and copies land there instead (Photoshop).
|
||
/// Multi-layer moves are atomic (one undo step), using the engine's stable document order.
|
||
fn layer_drag_and_drop(
|
||
app: &PhotocraftApp,
|
||
ctx: &egui::Context,
|
||
ui: &egui::Ui,
|
||
l: &Layer,
|
||
rect: Rect,
|
||
resp: &egui::Response,
|
||
actions: &mut Vec<(String, Value)>,
|
||
) {
|
||
let t = Tokens::get(ctx);
|
||
let key = egui::Id::new("layer-drag");
|
||
// A drag from the mask thumbnail (`starts_mask_drag`) moves the mask, not the layer.
|
||
let mask_drag = ctx.data(|d| d.get_temp::<MaskDrag>(mask_drag_key())).is_some_and(|(id, _)| id == l.id.0);
|
||
if resp.drag_started() && !mask_drag {
|
||
ctx.data_mut(|d| d.insert_temp(key, l.id.0));
|
||
}
|
||
let Some(dragged) = ctx.data(|d| d.get_temp::<u64>(key)) else { return };
|
||
let pointer = ctx.input(|i| i.pointer.interact_pos());
|
||
let released = ctx.input(|i| i.pointer.any_released());
|
||
let copy = ctx.input(|i| i.modifiers.alt);
|
||
if dragged == l.id.0 {
|
||
// Ghost label following the pointer, and the copy cursor while ⌥ is held.
|
||
if let Some(p) = pointer {
|
||
crate::layer_transfer::ghost(ctx, p, &l.name);
|
||
}
|
||
if copy {
|
||
ctx.set_cursor_icon(egui::CursorIcon::Copy);
|
||
}
|
||
// #1721: with ⌥ held the row also takes the drop onto itself — the only drop target a
|
||
// single-layer document has. The copy lands above or below it (never into a group's own
|
||
// centre). A multi-selection is left alone: the engine copies it beside each of its own
|
||
// layers, not onto one of them.
|
||
let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default();
|
||
let in_multi = selected.len() > 1 && selected.contains(&l.id);
|
||
if copy
|
||
&& !in_multi
|
||
&& let Some(p) = pointer.filter(|p| rect.contains(*p))
|
||
{
|
||
let position = if (p.y - rect.top()) / rect.height() < 0.5 { "above" } else { "below" };
|
||
let painter = ui.painter();
|
||
if position == "above" {
|
||
painter.line_segment([rect.left_top(), rect.right_top()], Stroke::new(2.0, t.accent));
|
||
} else {
|
||
painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent));
|
||
}
|
||
if released {
|
||
let mut payload = layer_drop_payload(dragged, l.id, position, &selected);
|
||
if let Some(o) = payload.as_object_mut() {
|
||
o.insert("copy".into(), json!(true));
|
||
}
|
||
actions.push(("layer.moveTo".into(), payload));
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
let Some(p) = pointer else { return };
|
||
if !rect.contains(p) {
|
||
return;
|
||
}
|
||
let f = (p.y - rect.top()) / rect.height();
|
||
let position = if l.is_group() && (0.3..0.7).contains(&f) {
|
||
"into"
|
||
} else if f < 0.5 {
|
||
"above"
|
||
} else {
|
||
"below"
|
||
};
|
||
let Some(st) = app.session.active() else { return };
|
||
let mut payload = layer_drop_payload(dragged, l.id, position, &st.selected_layers());
|
||
if background_drop_refused(&st.doc, &payload, l.id, position, copy) {
|
||
return;
|
||
}
|
||
let painter = ui.painter();
|
||
match position {
|
||
"into" => {
|
||
painter.rect_stroke(rect.shrink(1.0), t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||
}
|
||
"above" => {
|
||
painter.line_segment([rect.left_top(), rect.right_top()], Stroke::new(2.0, t.accent));
|
||
}
|
||
_ => {
|
||
painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent));
|
||
}
|
||
}
|
||
if released {
|
||
if copy && let Some(o) = payload.as_object_mut() {
|
||
o.insert("copy".into(), json!(true));
|
||
}
|
||
actions.push(("layer.moveTo".into(), payload));
|
||
}
|
||
}
|
||
|
||
/// #1721: the drop zones no row owns — the space between two rows and the empty space below the
|
||
/// bottom-most one. The drop lands on `target` at `position` (`above` or `below` its row): a copy
|
||
/// with ⌥ held on release (Photoshop), a move without. A multi-selection dropped on the row it
|
||
/// was grabbed from is left alone: the engine can only copy it beside each of its own layers.
|
||
fn layer_drop_zone(
|
||
app: &PhotocraftApp,
|
||
ctx: &egui::Context,
|
||
ui: &egui::Ui,
|
||
zone: Rect,
|
||
target: (LayerId, Rect),
|
||
position: &'static str,
|
||
actions: &mut Vec<(String, Value)>,
|
||
) {
|
||
let Some(dragged) = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag"))) else { return };
|
||
let selected = app.session.active().map(|st| st.selected_layers()).unwrap_or_default();
|
||
if target.0 == LayerId(dragged) && selected.len() > 1 && selected.contains(&target.0) {
|
||
return;
|
||
}
|
||
let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return };
|
||
if !zone.contains(p) {
|
||
return;
|
||
}
|
||
let t = Tokens::get(ctx);
|
||
let line = if position == "above" { [target.1.left_top(), target.1.right_top()] } else { [target.1.left_bottom(), target.1.right_bottom()] };
|
||
ui.painter().line_segment(line, Stroke::new(2.0, t.accent));
|
||
if ctx.input(|i| i.pointer.any_released()) {
|
||
let mut payload = layer_drop_payload(dragged, target.0, position, &selected);
|
||
if ctx.input(|i| i.modifiers.alt)
|
||
&& let Some(o) = payload.as_object_mut()
|
||
{
|
||
o.insert("copy".into(), json!(true));
|
||
}
|
||
actions.push(("layer.moveTo".into(), payload));
|
||
}
|
||
}
|
||
|
||
/// An fx row being dragged: the layer it belongs to and the effect index (`None` = the "Effects"
|
||
/// row, all of them).
|
||
type FxDrag = (u64, Option<usize>);
|
||
|
||
/// A mask thumbnail being dragged: the layer and which of its masks (#3113, #3119).
|
||
type MaskDrag = (u64, MaskKind);
|
||
|
||
fn mask_drag_key() -> egui::Id {
|
||
egui::Id::new("mask-drag")
|
||
}
|
||
|
||
/// Which mask's thumbnail (where it was drawn last frame) the row's drag starts on, if any.
|
||
fn starts_mask_drag(ctx: &egui::Context, l: &Layer, resp: &egui::Response) -> Option<MaskKind> {
|
||
if !resp.drag_started() {
|
||
return None;
|
||
}
|
||
let p = ctx.input(|i| i.pointer.press_origin())?;
|
||
let (thumbs, _) = crate::mask_thumbs_ui::recorded(ctx, l.id.0)?;
|
||
let kind = thumbs.iter().find(|(_, r)| r.contains(p))?.0;
|
||
let present = match kind {
|
||
MaskKind::Pixel => l.mask.is_some(),
|
||
MaskKind::Vector => l.vector_mask.is_some(),
|
||
};
|
||
present.then_some(kind)
|
||
}
|
||
|
||
fn fx_drag_key() -> egui::Id {
|
||
egui::Id::new("fx-drag")
|
||
}
|
||
|
||
/// Start dragging the effects of `layer`: one (`effect`) or all of them.
|
||
fn start_fx_drag(ctx: &egui::Context, layer: LayerId, effect: Option<usize>) {
|
||
ctx.data_mut(|d| d.insert_temp::<FxDrag>(fx_drag_key(), (layer.0, effect)));
|
||
}
|
||
|
||
/// While effects are dragged: their name by the pointer, and the copy cursor while ⌥ is held.
|
||
fn fx_drag_feedback(ctx: &egui::Context, doc: &photocraft_doc::Document) {
|
||
let Some((from, effect)) = ctx.data(|d| d.get_temp::<FxDrag>(fx_drag_key())) else { return };
|
||
let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return };
|
||
let label = match effect {
|
||
Some(i) => doc.layer(LayerId(from)).and_then(|l| l.effects.items.get(i)).map_or("", |e| e.label()),
|
||
None => "Effects",
|
||
};
|
||
crate::layer_transfer::ghost(ctx, p, label);
|
||
if ctx.input(|i| i.modifiers.alt) {
|
||
ctx.set_cursor_icon(egui::CursorIcon::Copy);
|
||
}
|
||
}
|
||
|
||
/// The command for dropping effects of layer `from` on `target`: they move there, or with ⌥ held
|
||
/// on release are copied (Photoshop). `effect` is one effect row, or all effects when `None`.
|
||
fn fx_drop_action(from: u64, effect: Option<usize>, target: LayerId, copy: bool) -> (String, Value) {
|
||
let mut payload = json!({"from": from, "to": target.0, "copy": copy});
|
||
if let Some(o) = payload.as_object_mut()
|
||
&& let Some(i) = effect
|
||
{
|
||
o.insert("effect".into(), json!(i));
|
||
}
|
||
("layer.layerStyle.transferEffects".into(), payload)
|
||
}
|
||
|
||
/// A layer row accepts the fx row being dragged: outlined while the pointer is over it, dropped
|
||
/// on release.
|
||
fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: &mut Vec<(String, Value)>) {
|
||
let Some((from, effect)) = ctx.data(|d| d.get_temp::<FxDrag>(fx_drag_key())) else { return };
|
||
let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return };
|
||
if from == l.id.0 || !rect.contains(p) {
|
||
return;
|
||
}
|
||
let t = Tokens::get(ctx);
|
||
ui.painter().rect_stroke(rect.shrink(1.0), t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||
if ctx.input(|i| i.pointer.any_released()) {
|
||
actions.push(fx_drop_action(from, effect, l.id, ctx.input(|i| i.modifiers.alt)));
|
||
}
|
||
}
|
||
|
||
/// Photoshop shows a layer's effects as indented sub-rows ("Effects", then each effect). The eye
|
||
/// on "Effects" shows or hides them all, the eye on an effect's row just that one (#1622).
|
||
fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let indent = 30.0 + depth as f32 * 14.0 + 34.0;
|
||
let mut rows: Vec<(String, bool, Option<&'static str>)> = vec![("Effects".into(), l.effects.enabled, None)];
|
||
for e in &l.effects.items {
|
||
let kind = crate::layer_style::KINDS.iter().find(|k| k.1 == e.label()).map(|k| k.0);
|
||
rows.push((e.label().to_string(), e.enabled(), kind));
|
||
}
|
||
for (i, (name, on, kind)) in rows.into_iter().enumerate() {
|
||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag());
|
||
// Drag the row onto another layer to move its effects there, ⌥-drag to copy them.
|
||
if resp.drag_started() {
|
||
start_fx_drag(ui.ctx(), l.id, i.checked_sub(1));
|
||
}
|
||
if !ui.is_rect_visible(rect) {
|
||
continue;
|
||
}
|
||
if resp.hovered() {
|
||
ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35));
|
||
}
|
||
if t.pro {
|
||
ui.painter().line_segment([pos2(rect.left() + 30.0, rect.top()), pos2(rect.left() + 30.0, rect.bottom())], Stroke::new(1.0, t.separator));
|
||
}
|
||
// Nested under the layer's eye column so an effect does not read as its own layer (#4572).
|
||
let eye = Rect::from_min_size(pos2(rect.left() + 36.0, rect.center().y - 9.0), vec2(18.0, 18.0));
|
||
// A hidden effect's eye box is left empty (still clickable), like a hidden layer's.
|
||
if on {
|
||
icons::paint(ui, eye, "eye", 12.0, t.icon);
|
||
}
|
||
// The whole eye column of the row takes the click, so it never opens the Layer Style dialog.
|
||
let eye_cell = Rect::from_min_max(pos2(rect.left() + 30.0, rect.top()), pos2((rect.left() + 58.0).min(rect.right()), rect.bottom()));
|
||
if ui.interact(eye_cell, ui.id().with(("fx-eye", l.id.0, i)), Sense::click()).clicked() {
|
||
let mut params = json!({"layer": l.id.0, "visible": !on});
|
||
if let (Some(index), Some(o)) = (i.checked_sub(1), params.as_object_mut()) {
|
||
o.insert("index".into(), json!(index));
|
||
}
|
||
actions.push(("layer.setEffectsVisible".into(), params));
|
||
}
|
||
let x = rect.left() + indent + if i == 0 { 8.0 } else { 24.0 };
|
||
if i == 0 {
|
||
icons::paint(ui, Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 14.0)), "sparkles", 11.0, t.text_dim);
|
||
}
|
||
let right = rect.right() - crate::layer_row_ui::RIGHT_PAD;
|
||
crate::layer_row_ui::label(
|
||
ui.painter(),
|
||
x,
|
||
rect.center().y,
|
||
right,
|
||
&name,
|
||
egui::FontId::proportional(11.5),
|
||
if on { t.text_dim } else { t.text_faint },
|
||
);
|
||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone()));
|
||
if resp.double_clicked() {
|
||
crate::layer_style::open_for_layer(app, l.id, kind);
|
||
}
|
||
// #2846: right-click opens the layer style menu (Create Layer, Copy Layer Style…), as in Photoshop.
|
||
resp.context_menu(|ui| {
|
||
crate::widgets::style_spectrum_popup_menu(ui);
|
||
crate::layer_menu_ui::show_effects(app, ui, l, actions);
|
||
});
|
||
}
|
||
}
|
||
|
||
/// New / add / subtract / intersect selection buttons (shared by selection tools).
|
||
fn selection_mode_buttons(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||
ui.spacing_mut().item_spacing.x = 2.0;
|
||
for (i, (icon, tip)) in [
|
||
("square", tl!("New selection").to_string()),
|
||
("plus", crate::i18n::fmt(tl!("Add to selection ({key})"), &[("key", &crate::shortcuts::pretty("Shift"))])),
|
||
("minus", crate::i18n::fmt(tl!("Subtract from selection ({key})"), &[("key", &crate::shortcuts::pretty("Alt"))])),
|
||
("squares-subtract", crate::i18n::fmt(tl!("Intersect with selection ({key})"), &[("key", &crate::shortcuts::pretty("Shift+Alt"))])),
|
||
]
|
||
.iter()
|
||
.enumerate()
|
||
{
|
||
if icons::button(ui, icon, 24.0, app.ui.selection_mode == i as u8, tip).clicked() {
|
||
app.ui.selection_mode = i as u8;
|
||
}
|
||
}
|
||
ui.spacing_mut().item_spacing.x = 8.0;
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod symmetry_theme_tests {
|
||
use super::*;
|
||
use egui_kittest::{Harness, kittest::Queryable};
|
||
|
||
#[test]
|
||
fn brush_pencil_and_eraser_expose_symmetry_menu_in_every_theme() {
|
||
for kind in crate::theme::ThemeKind::ALL {
|
||
for tool in [Tool::Brush, Tool::Pencil, Tool::Eraser] {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 80, "height": 80})).unwrap();
|
||
app.ui.tool = tool;
|
||
let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).build_ui_state(|ui, app| options_bar(app, ui), app);
|
||
PhotocraftApp::setup_context(&h.ctx, kind);
|
||
h.run_steps(4);
|
||
assert!(h.query_by_label("Set painting symmetry options").is_some(), "missing symmetry menu in {:?} for {:?}", kind, tool);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod color_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
/// #3323: the Color panel's HSB is Photoshop's (and the Color Picker's), on sRGB values, not
|
||
/// egui's linear-light HSV: a 50% grey has Brightness 50%, and the field's left edge halfway
|
||
/// down is that grey (128), not 188.
|
||
fn color_panel_hsb_is_srgb_like_photoshop() {
|
||
let grey = srgb_hsva([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]);
|
||
assert!((grey.v - 128.0 / 255.0).abs() < 1e-3 && grey.s < 1e-3, "{grey:?}");
|
||
let mid = hsva_srgb(egui::ecolor::Hsva::new(0.3, 0.0, 0.5, 1.0));
|
||
assert!(mid[..3].iter().all(|v| (v - 0.5).abs() <= 1.0 / 255.0), "{mid:?}");
|
||
// What the field paints there.
|
||
assert_eq!(hsva_color32(egui::ecolor::Hsva::new(0.3, 0.0, 0.5, 1.0)), Color32::from_gray(128));
|
||
// The same numbers as the Color Picker dialog for a saturated colour.
|
||
let c = [0.847, 0.271, 0.180];
|
||
let (ours, dialog) = (srgb_hsva([c[0], c[1], c[2], 1.0]), crate::color_picker_ui::rgb_to_hsv(c));
|
||
assert!((ours.h * 360.0 - dialog[0]).abs() < 0.5 && (ours.s - dialog[1]).abs() < 1e-3 && (ours.v - dialog[2]).abs() < 1e-3, "{ours:?} vs {dialog:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn srgb_hsva_roundtrip() {
|
||
for c in [[0.847, 0.271, 0.180, 1.0], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0], [0.2, 0.6, 0.4, 1.0]] {
|
||
let back = hsva_srgb(srgb_hsva(c));
|
||
for i in 0..3 {
|
||
assert!((back[i] - c[i]).abs() <= 1.0 / 255.0, "{c:?} -> {back:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Click the hex field, select its text and type `text` one key per frame.
|
||
fn type_hex(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str) {
|
||
use egui_kittest::kittest::Queryable;
|
||
h.get_by_role(egui::accesskit::Role::TextInput).click();
|
||
h.run_steps(1);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||
h.run_steps(1);
|
||
for ch in text.chars() {
|
||
h.event(egui::Event::Text(ch.to_string()));
|
||
h.run_steps(1);
|
||
}
|
||
}
|
||
|
||
/// The Color panel's hex readout is editable, with or without the leading `#`.
|
||
#[test]
|
||
fn color_panel_hex_field_takes_a_typed_hex() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.tools.foreground = [1.0, 1.0, 1.0, 1.0];
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(
|
||
|ui, app: &mut PhotocraftApp| {
|
||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||
color_picker(app, ui);
|
||
}
|
||
},
|
||
app,
|
||
);
|
||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||
h.run_steps(2);
|
||
type_hex(&mut h, "003300");
|
||
assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0x00, 0x33, 0x00]);
|
||
// A leading '#' is accepted too.
|
||
type_hex(&mut h, "#ff8000");
|
||
assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0xff, 0x80, 0x00]);
|
||
}
|
||
|
||
/// Themes with `round_chips` (Studio) draw round chips, stacked or side by side in two tool
|
||
/// columns, with Switch Colors right under them and Default Colors at the top-right corner
|
||
/// (stacked) or the left edge (side by side); both buttons work in either layout.
|
||
#[test]
|
||
fn studio_color_chips_keep_default_and_swap_buttons_below() {
|
||
use egui_kittest::kittest::Queryable;
|
||
for (kind, two_columns) in [
|
||
(crate::theme::ThemeKind::Studio, false),
|
||
(crate::theme::ThemeKind::StudioLight, false),
|
||
(crate::theme::ThemeKind::Studio, true),
|
||
(crate::theme::ThemeKind::StudioLight, true),
|
||
] {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.tools.foreground = [1.0, 0.0, 0.0, 1.0];
|
||
app.session.tools.background = [0.0, 0.0, 1.0, 1.0];
|
||
let mut h = egui_kittest::Harness::builder()
|
||
.with_size(vec2(80.0, 120.0))
|
||
.build_ui_state(|ui, app: &mut PhotocraftApp| color_chips(app, ui, two_columns), app);
|
||
PhotocraftApp::setup_context(&h.ctx, kind);
|
||
h.run_steps(2);
|
||
h.get_by_label("Swap colours (X)").click();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 1.0, 1.0], "{kind:?}");
|
||
assert_eq!(h.state().session.tools.background, [1.0, 0.0, 0.0, 1.0], "{kind:?}");
|
||
h.get_by_label("Default colours (D)").click();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 0.0, 1.0], "{kind:?}");
|
||
assert_eq!(h.state().session.tools.background, [1.0, 1.0, 1.0, 1.0], "{kind:?}");
|
||
}
|
||
}
|
||
|
||
/// While characters are highlighted in a type layer being edited, the Switch Colors and
|
||
/// Default Colors buttons recolour them with the new foreground (#5690); without a selection
|
||
/// they only change the tool colours, as before.
|
||
#[test]
|
||
fn swap_and_default_colors_recolour_selected_type() {
|
||
use egui_kittest::kittest::Queryable;
|
||
use photocraft_doc::LayerId;
|
||
let rgb = |app: &PhotocraftApp, id: LayerId, ci: usize| {
|
||
let doc = &app.session.active().unwrap().doc;
|
||
let photocraft_doc::LayerContent::Text(t) = &doc.layer(id).unwrap().content else { panic!("not a type layer") };
|
||
let b = t.text.char_indices().nth(ci).map_or(t.text.len(), |(b, _)| b);
|
||
let mut at = 0;
|
||
for r in t.char_runs() {
|
||
if b < at + r.len {
|
||
return r.style.color.to_rgba8();
|
||
}
|
||
at += r.len;
|
||
}
|
||
[0; 4]
|
||
};
|
||
for selected in [true, false] {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
let made = app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 50, "y": 150, "color": "#000000"})).unwrap();
|
||
let id = LayerId(made["layer"].as_u64().unwrap());
|
||
app.run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap();
|
||
let anchor = if selected { 6 } else { 11 };
|
||
app.ui.text_edit = Some(crate::state::TextEdit {
|
||
layer: id.0,
|
||
caret: 11,
|
||
anchor,
|
||
session: "s".into(),
|
||
created: false,
|
||
mask: false,
|
||
dragging: false,
|
||
resize: None,
|
||
preedit: None,
|
||
});
|
||
let mut h =
|
||
egui_kittest::Harness::builder().with_size(vec2(80.0, 120.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_chips(app, ui, false), app);
|
||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||
h.run_steps(2);
|
||
h.get_by_label("Swap colours (X)").click();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 1.0, 1.0]);
|
||
assert_eq!(h.state().session.tools.background, [1.0, 0.0, 0.0, 1.0]);
|
||
let blue = if selected { [0, 0, 255, 255] } else { [0, 0, 0, 255] };
|
||
assert_eq!(rgb(h.state(), id, 0), [0, 0, 0, 255], "unselected characters keep their colour");
|
||
assert_eq!((rgb(h.state(), id, 6), rgb(h.state(), id, 10)), (blue, blue), "selected: {selected}");
|
||
// Default Colors: the selection takes black, the new foreground.
|
||
h.state_mut().run("tools.setColors", json!({"foreground": "#00ff00"})).unwrap();
|
||
h.get_by_label("Default colours (D)").click();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 0.0, 1.0]);
|
||
assert_eq!(rgb(h.state(), id, 6), [0, 0, 0, 255], "selected: {selected}");
|
||
}
|
||
}
|
||
|
||
/// The Studio layout (chips stacked, icons below) fits the height the toolbar reserves for
|
||
/// the chips, so the two-column check does not change with the theme.
|
||
#[test]
|
||
fn studio_color_chips_fit_the_reserved_height() {
|
||
// Chips, then the row holding the Switch Colors curve.
|
||
assert!(STUDIO_CHIP.0 + STUDIO_CHIP.1 + 8.0 + 4.0 <= chips_height(false, true, false));
|
||
// Side by side: the chips plus the same row, and shorter than stacked.
|
||
assert!(STUDIO_CHIP.0 + 8.0 + 4.0 <= chips_height(false, true, true));
|
||
assert!(chips_height(false, true, true) < chips_height(false, true, false));
|
||
// The Studio block is taller than Photoshop's, and the two-column check knows it.
|
||
assert!(chips_height(false, true, false) > chips_height(false, false, false));
|
||
}
|
||
|
||
/// An incomplete entry never changes the colour and the field snaps back when focus leaves.
|
||
#[test]
|
||
fn color_panel_hex_field_ignores_partial_input() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0];
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(
|
||
|ui, app: &mut PhotocraftApp| {
|
||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||
color_picker(app, ui);
|
||
}
|
||
},
|
||
app,
|
||
);
|
||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||
h.run_steps(2);
|
||
let fg = h.state().session.tools.foreground;
|
||
h.get_by_role(egui::accesskit::Role::TextInput).click();
|
||
h.run_steps(1);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||
h.run_steps(1);
|
||
for ch in "12zz".chars() {
|
||
h.event(egui::Event::Text(ch.to_string()));
|
||
h.run_steps(1);
|
||
}
|
||
assert_eq!(h.state().session.tools.foreground, fg, "invalid input changes nothing");
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(2);
|
||
assert_eq!(h.get_by_role(egui::accesskit::Role::TextInput).value().as_deref(), Some("336699"), "snaps back to the colour");
|
||
}
|
||
|
||
/// The R, G and B fields next to the hex are editable, like the hex field.
|
||
#[test]
|
||
fn color_readout_rgb_fields_take_values() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.tools.foreground = [0.0, 0.0, 0.0, 1.0];
|
||
let mut h = field_harness(app);
|
||
// The only spin buttons are R, G and B, in that order.
|
||
h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(1).unwrap().click();
|
||
h.run_steps(1);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||
h.run_steps(1);
|
||
for ch in "128".chars() {
|
||
h.event(egui::Event::Text(ch.to_string()));
|
||
h.run_steps(1);
|
||
}
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(2);
|
||
assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0x00, 0x80, 0x00]);
|
||
}
|
||
|
||
/// The pro Color panel's hex readout edits the foreground too.
|
||
#[test]
|
||
fn color_field_hex_field_takes_a_typed_hex() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.tools.foreground = [1.0, 1.0, 1.0, 1.0];
|
||
let mut h = field_harness(app);
|
||
type_hex(&mut h, "003300");
|
||
assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0x00, 0x33, 0x00]);
|
||
}
|
||
|
||
#[test]
|
||
fn hue_and_saturation_fields_take_values_on_black() {
|
||
use egui_kittest::kittest::Queryable;
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.tools.foreground = [0.0, 0.0, 0.0, 1.0];
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(
|
||
|ui, app: &mut PhotocraftApp| {
|
||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||
hsb_sliders(app, ui);
|
||
}
|
||
},
|
||
app,
|
||
);
|
||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||
h.run_steps(2);
|
||
// Hue, Saturation, Brightness, typed one key per frame. Black has no hue or saturation of its
|
||
// own, so the first two used to reset to 0 and this ended on white.
|
||
for (field, typed) in [(0, "120"), (1, "100"), (2, "100")] {
|
||
h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(field).unwrap().click();
|
||
h.run_steps(1);
|
||
for ch in typed.chars() {
|
||
h.event(egui::Event::Text(ch.to_string()));
|
||
h.run_steps(1);
|
||
}
|
||
h.key_press(egui::Key::Tab);
|
||
h.run_steps(2);
|
||
}
|
||
assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0, 255, 0]);
|
||
}
|
||
|
||
/// The Color field fills the dock group, never shrinks below 120 pt, and ignores a bad height.
|
||
#[test]
|
||
fn color_field_height_fills_the_group() {
|
||
assert_eq!(color_field_height(None, 300.0), 120.0);
|
||
assert_eq!(color_field_height(Some(500.0), 300.0), 468.0);
|
||
assert_eq!(color_field_height(Some(500.0), 200.0), 442.0);
|
||
assert_eq!(color_field_height(Some(60.0), 300.0), 120.0);
|
||
assert_eq!(color_field_height(Some(f32::NAN), 300.0), 120.0);
|
||
assert_eq!(color_field_height(Some(1.0e9), 300.0), 2000.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));
|
||
}
|
||
}
|
||
|
||
/// Press at `from`, move to `to` in six steps and release, as egui sees a drag.
|
||
fn drag(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) {
|
||
h.hover_at(from);
|
||
h.run_steps(1);
|
||
h.drag_at(from);
|
||
h.run_steps(1);
|
||
for i in 1..=6 {
|
||
h.hover_at(from + (to - from) * (i as f32 / 6.0));
|
||
h.run_steps(1);
|
||
}
|
||
h.drop_at(to);
|
||
h.run_steps(3);
|
||
}
|
||
|
||
/// Black carries neither hue nor saturation, a grey only the saturation it really has, and any
|
||
/// other colour nothing at all: the knob keeps where it was dragged instead of round-tripping
|
||
/// through RGB to the left edge (#3228).
|
||
#[test]
|
||
fn field_hsva_keeps_black_where_it_was_and_leaves_a_real_colour_alone() {
|
||
// Read back from black, as every frame does: nothing of the drag survives the RGB trip.
|
||
let black = field_hsva(egui::ecolor::Hsva::new(0.0, 0.0, 0.0, 1.0), (0.6, 1.0));
|
||
assert_eq!((black.h, black.s), (0.6, 1.0), "the corner the drag ended in");
|
||
// A grey: the saturation is genuinely zero — that is where the knob belongs — only the
|
||
// hue was undefined.
|
||
let grey = field_hsva(egui::ecolor::Hsva::new(0.0, 0.0, 0.5, 1.0), (0.6, 1.0));
|
||
assert_eq!((grey.h, grey.s), (0.6, 0.0), "the grey stays on the left edge");
|
||
// An ordinary colour is left as it is.
|
||
let red = field_hsva(egui::ecolor::Hsva::new(0.1, 0.8, 0.9, 1.0), (0.6, 1.0));
|
||
assert_eq!((red.h, red.s), (0.1, 0.8));
|
||
}
|
||
|
||
/// Dragging the selector into the bottom-right corner picks black there and leaves the knob
|
||
/// in that corner: black has no saturation of its own, so it used to be read back as `s = 0`
|
||
/// and drawn at the bottom-left instead, one frame after the drag that put it on the right
|
||
/// (#3228).
|
||
#[test]
|
||
fn dragging_to_the_bottom_right_keeps_the_knob_there() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.session.tools.foreground = [1.0, 1.0, 1.0, 1.0];
|
||
let mut h = field_harness(app);
|
||
let min = h.ctx.input(|i| i.viewport_rect()).min;
|
||
// Into the far bottom-right corner of the SV field: full saturation, no value. The end
|
||
// point lies past the corner on purpose — a real drag goes there too, and the widget
|
||
// clamps it to the field.
|
||
drag(&mut h, min + vec2(200.0, 60.0), min + vec2(280.0, 170.0));
|
||
assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0, 0, 0], "both corners are black");
|
||
// What the knob is drawn from on the very next frame: the field kept the saturation the
|
||
// drag ended at (not the zero black reads back as), together with the hue.
|
||
let remembered: (f32, f32) = h.ctx.data(|d| d.get_temp(egui::Id::new(("color-field-hue", false)))).expect("the field remembers what it showed");
|
||
assert!(remembered.1 > 0.9, "the saturation the corner was dragged to: {remembered:?}");
|
||
let shown = field_hsva(egui::ecolor::Hsva::new(0.0, 0.0, 0.0, 1.0), remembered);
|
||
assert!(shown.s > 0.9, "the knob stays at the right edge, not the left: {remembered:?}");
|
||
assert!(shown.v < 0.01, "still black: {shown:?}");
|
||
}
|
||
|
||
/// 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)]
|
||
mod info_panel_sampler_tests {
|
||
use super::*;
|
||
use egui_kittest::{Harness, kittest::Queryable};
|
||
|
||
/// #1046: the Info panel lists each Color Sampler point under its number, with its position and
|
||
/// the composite RGB/CMYK readout, one composite pixel each.
|
||
#[test]
|
||
fn info_panel_lists_the_sample_points() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 80, "height": 60, "background": "white"})).unwrap();
|
||
app.run("view.colorSamplers.add", json!({"x": 5, "y": 6})).unwrap();
|
||
app.run("view.colorSamplers.add", json!({"x": 40, "y": 30})).unwrap();
|
||
let mut h = Harness::builder().with_size(vec2(340.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| info_panel(app, ui), app);
|
||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro);
|
||
h.run_steps(3);
|
||
// Both points are listed, numbered, and read as white (RGB 255 / CMYK 0%).
|
||
assert!(h.query_by_label("#1").is_some(), "point 1 is listed");
|
||
assert!(h.query_by_label("#2").is_some(), "point 2 is listed");
|
||
assert_eq!(h.query_all_by_label_contains("R:255 G:255 B:255").count(), 2, "each point shows its RGB");
|
||
assert_eq!(h.query_all_by_label_contains("C:0% M:0% Y:0% K:0%").count(), 2, "each point shows its CMYK");
|
||
// Clearing them takes the list away.
|
||
h.state_mut().run("view.colorSamplers.clear", json!({})).unwrap();
|
||
h.run_steps(2);
|
||
assert!(h.query_by_label("#1").is_none(), "no points listed once cleared");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod history_transform_tests {
|
||
use super::*;
|
||
use egui_kittest::Harness;
|
||
|
||
fn click(h: &mut Harness<'static, 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);
|
||
}
|
||
}
|
||
|
||
/// While Free Transform is open, clicking a History state leaves the document alone: the
|
||
/// transform owns Undo. Without a transform the same click steps back as usual.
|
||
#[test]
|
||
fn history_rows_wait_for_an_open_transform() {
|
||
for transforming in [true, false] {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||
app.run("layer.new.layer", json!({})).unwrap();
|
||
app.run("select.rect", json!({"x": 20, "y": 20, "width": 60, "height": 40})).unwrap();
|
||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||
app.run("select.deselect", json!({})).unwrap();
|
||
let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app);
|
||
h.run_steps(2);
|
||
if transforming {
|
||
let ctx = h.ctx.clone();
|
||
crate::transform_tool::begin(h.state_mut(), &ctx).unwrap();
|
||
}
|
||
let steps = h.state().session.active().unwrap().history.entries().len();
|
||
// The first state's row, below the 38 pt snapshot row.
|
||
let row = h.ctx.input(|i| i.viewport_rect()).min + vec2(60.0, 60.0);
|
||
click(&mut h, row);
|
||
let after = h.state().session.active().unwrap().history.entries().len();
|
||
if transforming {
|
||
assert_eq!(after, steps, "the document's history is untouched");
|
||
assert!(h.state().ui.transform.is_some());
|
||
} else {
|
||
assert!(after < steps, "the click steps back: {steps} -> {after}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The Photoshop-theme footer (#1117): trash deletes the current state, the camera takes a
|
||
/// snapshot (#2966), and file-plus makes a new document from the current state.
|
||
#[test]
|
||
fn history_footer_buttons_run_their_commands() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||
app.run("layer.new.layer", json!({})).unwrap();
|
||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||
let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app);
|
||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
|
||
h.state_mut().ui.theme = crate::theme::ThemeKind::ProMedium;
|
||
h.run_steps(3);
|
||
// Footer buttons are the only 26 pt squares; the bar lays them out right to left.
|
||
let buttons = |h: &Harness<'static, PhotocraftApp>| {
|
||
let mut b: Vec<(Rect, bool)> = h.ctx.viewport(|v| {
|
||
v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| (w.rect, w.enabled)).collect()
|
||
});
|
||
b.sort_by(|a, b| b.0.center().x.total_cmp(&a.0.center().x));
|
||
b
|
||
};
|
||
let b = buttons(&h);
|
||
assert_eq!(b.len(), 3, "trash, snapshot, new document: {b:?}");
|
||
assert!(b.iter().all(|b| b.1), "all enabled: {b:?}");
|
||
let steps = h.state().session.active().unwrap().history.entries().len();
|
||
click(&mut h, b[1].0.center());
|
||
let st = h.state().session.active().unwrap();
|
||
assert_eq!(st.snapshots.len(), 1, "the camera takes a snapshot");
|
||
assert_eq!(st.history.entries().len(), steps, "which is not a history state");
|
||
let docs = h.state().session.documents().len();
|
||
click(&mut h, b[2].0.center());
|
||
assert_eq!(h.state().session.documents().len(), docs + 1, "file-plus makes a new document");
|
||
h.state_mut().session.set_active(0);
|
||
h.run_steps(2);
|
||
let steps = h.state().session.active().unwrap().history.entries().len();
|
||
let b = buttons(&h);
|
||
click(&mut h, b[0].0.center());
|
||
let st = h.state().session.active().unwrap();
|
||
assert_eq!(st.history.entries().len(), steps - 1, "trash deletes the current state");
|
||
assert!(!st.history.can_redo(), "and it can't be redone");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod snapshot_row_tests {
|
||
use egui::vec2;
|
||
use egui_kittest::Harness;
|
||
use egui_kittest::kittest::{NodeT, Queryable};
|
||
use serde_json::json;
|
||
|
||
use super::*;
|
||
|
||
fn top_pixel(h: &Harness<'static, PhotocraftApp>) -> [f32; 4] {
|
||
h.state().session.active().unwrap().doc.layers.last().unwrap().surface().unwrap().rgba(1, 1)
|
||
}
|
||
|
||
fn snapshot_names(h: &Harness<'static, PhotocraftApp>) -> Vec<String> {
|
||
h.state().session.active().unwrap().snapshots.iter().map(|s| s.name.clone()).collect()
|
||
}
|
||
|
||
/// Snapshot rows (#2966) sit under the opening row: clicking one returns to it as a new
|
||
/// state, and its right-click menu renames or deletes it.
|
||
#[test]
|
||
fn snapshot_rows_select_rename_and_delete() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||
app.run("layer.new.layer", json!({})).unwrap();
|
||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||
app.run("history.newSnapshot", json!({})).unwrap();
|
||
app.run("edit.fill", json!({"color": "#0000ff"})).unwrap();
|
||
// 60 fps steps, so two clicks a frame apart count as a double-click.
|
||
let mut h =
|
||
Harness::builder().with_size(vec2(300.0, 400.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app);
|
||
h.run_steps(3);
|
||
let steps = h.state().session.active().unwrap().history.entries().len();
|
||
h.get_by_label("Snapshot 1").click();
|
||
h.run_steps(2);
|
||
let st = h.state().session.active().unwrap();
|
||
assert_eq!(st.history.entries().len(), steps + 1);
|
||
assert_eq!(st.history.entries().last().map(String::as_str), Some("Snapshot 1"));
|
||
assert_eq!(top_pixel(&h), [1.0, 0.0, 0.0, 1.0], "back to red");
|
||
// Right-click › Rename…: type a name, Enter. The new "Snapshot 1" state is listed under the
|
||
// snapshot rows, so the first match is the snapshot's row.
|
||
h.get_all_by_label("Snapshot 1").next().unwrap().click_secondary();
|
||
h.run_steps(2);
|
||
h.get_by_label("Rename…").click();
|
||
h.run_steps(2);
|
||
h.get_by_role(egui::accesskit::Role::TextInput).type_text("Lesson start");
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(2);
|
||
assert_eq!(snapshot_names(&h), ["Lesson start"]);
|
||
// A double-click renames too; Escape keeps the old name.
|
||
let steps_now = h.state().session.active().unwrap().history.entries().len();
|
||
// A real double-click (accessibility clicks are single clicks).
|
||
let p = h.get_by_label("Lesson start").rect().center();
|
||
let p = egui::pos2(p.x as f32, p.y as f32);
|
||
h.event(egui::Event::PointerMoved(p));
|
||
// Wait out the double-click window of the earlier clicks.
|
||
h.run_steps(30);
|
||
for pressed in [true, false, true, false] {
|
||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||
h.step();
|
||
}
|
||
h.run_steps(3);
|
||
h.get_by_role(egui::accesskit::Role::TextInput).type_text("Oops");
|
||
h.key_press(egui::Key::Escape);
|
||
h.run_steps(2);
|
||
assert_eq!(snapshot_names(&h), ["Lesson start"], "Escape cancels");
|
||
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps_now + 1, "the click selected it once");
|
||
// Right-click › Delete (the snapshot's row, above the state the click made).
|
||
h.get_all_by_label("Lesson start").next().unwrap().click_secondary();
|
||
h.run_steps(2);
|
||
h.get_by_label("Delete").click();
|
||
h.run_steps(2);
|
||
assert!(snapshot_names(&h).is_empty());
|
||
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 2, "deleting leaves the history alone");
|
||
}
|
||
|
||
/// A History panel in the Photoshop theme (the footer with the trash) on red, Snapshot 1, blue.
|
||
fn pro_history() -> Harness<'static, PhotocraftApp> {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||
app.run("layer.new.layer", json!({})).unwrap();
|
||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||
app.run("history.newSnapshot", json!({})).unwrap();
|
||
app.run("edit.fill", json!({"color": "#0000ff"})).unwrap();
|
||
let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app);
|
||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
|
||
h.state_mut().ui.theme = crate::theme::ThemeKind::ProMedium;
|
||
h.run_steps(3);
|
||
h
|
||
}
|
||
|
||
/// The footer's trash: the rightmost of its 26 pt buttons (the bar lays them out right to left).
|
||
fn trash(h: &Harness<'static, PhotocraftApp>) -> egui::Pos2 {
|
||
let buttons: Vec<Rect> = h
|
||
.ctx
|
||
.viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| w.rect).collect());
|
||
buttons.into_iter().max_by(|a, b| a.center().x.total_cmp(&b.center().x)).unwrap().center()
|
||
}
|
||
|
||
fn click(h: &mut Harness<'static, 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);
|
||
}
|
||
}
|
||
|
||
fn selected(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool {
|
||
h.get_all_by_label(label).next().unwrap().accesskit_node().toggled() == Some(egui::accesskit::Toggled::True)
|
||
}
|
||
|
||
/// Press on `from`, drag in small steps to `to` and release there.
|
||
fn drag(h: &mut Harness<'static, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) {
|
||
h.event(egui::Event::PointerMoved(from));
|
||
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
||
h.step();
|
||
for k in 1..=10 {
|
||
h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 10.0)));
|
||
h.step();
|
||
}
|
||
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
|
||
h.run_steps(3);
|
||
}
|
||
|
||
/// Clicking a snapshot selects it, and the trash then deletes the snapshot instead of a state
|
||
/// (#5324). Any other step deselects it, and the trash deletes the current state again.
|
||
#[test]
|
||
fn the_trash_deletes_the_selected_snapshot() {
|
||
let mut h = pro_history();
|
||
h.get_by_label("Snapshot 1").click();
|
||
h.run_steps(2);
|
||
let steps = h.state().session.active().unwrap().history.entries().len();
|
||
assert!(selected(&h, "Snapshot 1"), "the clicked snapshot shows selected");
|
||
let p = trash(&h);
|
||
click(&mut h, p);
|
||
let st = h.state().session.active().unwrap();
|
||
assert!(st.snapshots.is_empty(), "the trash deleted the snapshot");
|
||
assert_eq!(st.history.entries().len(), steps, "and left the states alone");
|
||
|
||
// An edit after the click deselects the snapshot: the trash deletes the state.
|
||
let mut h = pro_history();
|
||
h.get_by_label("Snapshot 1").click();
|
||
h.run_steps(2);
|
||
h.state_mut().run("edit.fill", json!({"color": "#00ff00"})).unwrap();
|
||
h.run_steps(2);
|
||
assert!(!selected(&h, "Snapshot 1"), "an edit deselects the snapshot");
|
||
let steps = h.state().session.active().unwrap().history.entries().len();
|
||
let p = trash(&h);
|
||
click(&mut h, p);
|
||
let st = h.state().session.active().unwrap();
|
||
assert_eq!(st.snapshots.len(), 1, "the snapshot is kept");
|
||
assert_eq!(st.history.entries().len(), steps - 1, "the current state is deleted");
|
||
}
|
||
|
||
/// A snapshot or a past state dragged onto the trash is deleted (#5324).
|
||
#[test]
|
||
fn rows_dragged_onto_the_trash_are_deleted() {
|
||
let mut h = pro_history();
|
||
let steps = h.state().session.active().unwrap().history.entries().len();
|
||
let row = h.get_by_label("Snapshot 1").rect().center();
|
||
let to = trash(&h);
|
||
drag(&mut h, egui::pos2(row.x, row.y), to);
|
||
let st = h.state().session.active().unwrap();
|
||
assert!(st.snapshots.is_empty(), "the dragged snapshot is deleted");
|
||
assert_eq!(st.history.entries().len(), steps, "no state is");
|
||
|
||
// The second-to-last state: it and the state after it go, as with Delete.
|
||
let mut h = pro_history();
|
||
// The states are Open (or New), New Layer, Fill, Fill: drag New Layer.
|
||
let entries = h.state().session.active().unwrap().history.entries();
|
||
assert_eq!(entries.len(), 4, "{entries:?}");
|
||
let row = h.get_by_label(&entries[1]).rect().center();
|
||
let to = trash(&h);
|
||
drag(&mut h, egui::pos2(row.x, row.y), to);
|
||
let st = h.state().session.active().unwrap();
|
||
assert_eq!(st.history.entries().len(), 1, "the dragged state and the ones after it are deleted");
|
||
assert_eq!(st.snapshots.len(), 1, "the snapshot is kept");
|
||
}
|
||
|
||
/// Snapshot rows scroll with the states: many snapshots in a short dock leave the footer and
|
||
/// its camera button in view.
|
||
#[test]
|
||
fn many_snapshots_keep_the_footer_in_a_short_panel() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||
for _ in 0..6 {
|
||
app.run("history.newSnapshot", json!({})).unwrap();
|
||
}
|
||
let mut h = Harness::builder().with_size(vec2(300.0, 250.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app);
|
||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
|
||
h.state_mut().ui.theme = crate::theme::ThemeKind::ProMedium;
|
||
h.run_steps(3);
|
||
let view = h.ctx.input(|i| i.viewport_rect());
|
||
let buttons: Vec<Rect> = h
|
||
.ctx
|
||
.viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| w.rect).collect());
|
||
assert_eq!(buttons.len(), 3, "{buttons:?}");
|
||
assert!(buttons.iter().all(|b| view.contains_rect(*b)), "footer in view: {buttons:?} in {view:?}");
|
||
}
|
||
|
||
/// Renaming belongs to one document: snapshot ids restart in each, so switching tabs must not
|
||
/// carry the edit over to the other document's snapshot with the same id.
|
||
#[test]
|
||
fn a_rename_in_progress_stays_with_its_document() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
for _ in 0..2 {
|
||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||
app.run("history.newSnapshot", json!({})).unwrap();
|
||
}
|
||
let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app);
|
||
h.run_steps(3);
|
||
h.get_by_label("Snapshot 1").click_secondary();
|
||
h.run_steps(2);
|
||
h.get_by_label("Rename…").click();
|
||
h.run_steps(2);
|
||
assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_some(), "renaming in document 2");
|
||
h.state_mut().session.set_active(0);
|
||
h.run_steps(2);
|
||
assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_none(), "not in document 1");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "layer_pct_slider_tests.rs"]
|
||
mod layer_pct_slider_tests;
|
||
|
||
#[cfg(test)]
|
||
#[path = "fx_drag_tests.rs"]
|
||
mod fx_drag_tests;
|
||
|
||
#[cfg(test)]
|
||
mod lock_tests {
|
||
use super::*;
|
||
use crate::canvas::{ToolEvent, tool_event};
|
||
use egui::Modifiers;
|
||
|
||
fn click_lock(app: &mut PhotocraftApp) {
|
||
let st = app.session.active().unwrap();
|
||
let l = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap();
|
||
let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l, &json!({"layer": l.id.0}));
|
||
app.run(&cmd, p).unwrap();
|
||
}
|
||
|
||
fn erase_line(app: &mut PhotocraftApp) {
|
||
app.ui.tool = Tool::Eraser;
|
||
let m = Modifiers::NONE;
|
||
tool_event(app, ToolEvent::Down { x: 10.0, y: 40.0, pressure: 1.0 }, m);
|
||
tool_event(app, ToolEvent::Move { x: 100.0, y: 40.0, pressure: 1.0 }, m);
|
||
tool_event(app, ToolEvent::Up { x: 100.0, y: 40.0 }, m);
|
||
}
|
||
|
||
#[test]
|
||
fn simple_lock_button_never_locks_the_eraser_out_of_the_background() {
|
||
// #76: the simple themes' lock button used to toggle "lock all" on the Background, which
|
||
// kept showing as locked and then refused every paint tool.
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 120, "height": 80})).unwrap();
|
||
app.run("tools.setColors", json!({"background": "#ff0000"})).unwrap();
|
||
app.run("tools.setBrush", json!({"brush": {"size": 12, "hardness": 1.0}})).unwrap();
|
||
// Clicking the Background's lock converts it to a normal layer (Photoshop).
|
||
click_lock(&mut app);
|
||
let l = &app.session.active().unwrap().doc.layers[0];
|
||
assert_eq!(l.name, "Layer 0");
|
||
assert!(!l.locks.any());
|
||
erase_line(&mut app);
|
||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||
assert_eq!(app.session.active().unwrap().doc.layers[0].surface().unwrap().rgba(50, 40)[3], 0.0, "erased to transparency");
|
||
// On a normal layer the button locks all, then unlocks everything.
|
||
click_lock(&mut app);
|
||
assert!(app.session.active().unwrap().doc.layers[0].locks.all);
|
||
click_lock(&mut app);
|
||
assert!(!app.session.active().unwrap().doc.layers[0].locks.any());
|
||
// A layer with only transparency locked unlocks in one click.
|
||
app.run("layer.setProps", json!({"locks": {"transparency": true}})).unwrap();
|
||
click_lock(&mut app);
|
||
assert!(!app.session.active().unwrap().doc.layers[0].locks.transparency);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod swatch_type_tests {
|
||
use super::*;
|
||
use egui_kittest::Harness;
|
||
|
||
/// The HSB sliders only act on an edit. Their h/s/v round trip isn't exact for every colour
|
||
/// (#D8452E isn't), and comparing values used to rewrite the foreground every frame, which
|
||
/// would recolour selected type the moment it was selected.
|
||
#[test]
|
||
fn idle_hsb_sliders_leave_the_foreground_and_selected_type_alone() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
|
||
let id = app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 20, "y": 100, "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||
app.run("tools.setColors", json!({"foreground": "#d8452e"})).unwrap();
|
||
let fg = app.session.tools.foreground;
|
||
app.ui.tool = Tool::Type;
|
||
app.ui.text_edit = Some(crate::state::TextEdit {
|
||
layer: id,
|
||
caret: 0,
|
||
anchor: 5,
|
||
session: "s".into(),
|
||
created: false,
|
||
mask: false,
|
||
dragging: false,
|
||
resize: None,
|
||
preedit: None,
|
||
});
|
||
let mut h = Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_picker(app, ui), app);
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().session.tools.foreground, fg);
|
||
let st = h.state().session.active().unwrap();
|
||
let Some(photocraft_doc::LayerContent::Text(t)) = st.doc.layer(photocraft_doc::LayerId(id)).map(|l| &l.content) else { panic!("type layer") };
|
||
assert_eq!(t.char_runs().len(), 1, "still one white run");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod type_flyout_tests {
|
||
use super::*;
|
||
|
||
fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, time: f64, events: Vec<egui::Event>) {
|
||
let mut out = ctx.run_ui(
|
||
egui::RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() },
|
||
|ui| toolbar(app, ui),
|
||
);
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
#[test]
|
||
fn long_press_type_button_selects_type_then_vertical_from_the_flyout() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
assert_ne!(app.ui.tool, Tool::Type);
|
||
let ctx = egui::Context::default();
|
||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
frame(&mut app, &ctx, 0.0, vec![]);
|
||
frame(&mut app, &ctx, 0.1, vec![]);
|
||
let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Type)).unwrap();
|
||
let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 };
|
||
let mut buttons: Vec<Rect> = ctx.viewport(|v| {
|
||
v.prev_pass
|
||
.widgets
|
||
.layers()
|
||
.flat_map(|(_, w)| w.iter())
|
||
.filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click())
|
||
.map(|w| w.rect)
|
||
.collect()
|
||
});
|
||
buttons.sort_by(|a, b| a.top().total_cmp(&b.top()));
|
||
let at = buttons[index].center();
|
||
let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]);
|
||
frame(&mut app, &ctx, 1.36, vec![]);
|
||
assert_eq!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).map(|(id, _)| id), Some(egui::Id::new(("tool-slot", index))));
|
||
frame(&mut app, &ctx, 1.4, vec![pointer(at, false)]);
|
||
frame(&mut app, &ctx, 1.45, vec![]);
|
||
// The held button's tool is selected, as Photoshop does (#4453).
|
||
assert_eq!(app.ui.tool, Tool::Type);
|
||
let key = egui::Id::new(("tool-slot", index));
|
||
let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).unwrap();
|
||
let row = egui::pos2(menu.left() + 65.0, menu.top() + 39.0 + 8.0);
|
||
frame(&mut app, &ctx, 2.0, vec![egui::Event::PointerMoved(row), pointer(row, true)]);
|
||
frame(&mut app, &ctx, 2.05, vec![pointer(row, false)]);
|
||
assert_eq!(app.ui.tool, Tool::VerticalType);
|
||
assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_none());
|
||
}
|
||
|
||
/// #4444: click and hold on Change Screen Mode opens its list like the tool flyouts, without
|
||
/// cycling the mode on release; a plain click still cycles, and right-click still opens it.
|
||
#[test]
|
||
fn long_press_screen_mode_opens_the_modes_without_cycling() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
let ctx = egui::Context::default();
|
||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
frame(&mut app, &ctx, 0.0, vec![]);
|
||
frame(&mut app, &ctx, 0.1, vec![]);
|
||
let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 };
|
||
// The toolbar's last button: Change Screen Mode, after Quick Mask.
|
||
let at = ctx
|
||
.viewport(|v| {
|
||
v.prev_pass
|
||
.widgets
|
||
.layers()
|
||
.flat_map(|(_, w)| w.iter())
|
||
.filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click())
|
||
.map(|w| w.rect)
|
||
.max_by(|a, b| a.top().total_cmp(&b.top()).then(a.left().total_cmp(&b.left())))
|
||
})
|
||
.unwrap()
|
||
.center();
|
||
let standard = |app: &PhotocraftApp| crate::view_cmds::checked(app, "view.screenMode.standard") == Some(true);
|
||
let menu_open = |ctx: &egui::Context| egui::Popup::is_any_open(ctx);
|
||
let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
assert!(standard(&app));
|
||
// Hold, then release: the list opens and stays open, and the mode is unchanged.
|
||
frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]);
|
||
frame(&mut app, &ctx, 1.2, vec![]);
|
||
assert!(!menu_open(&ctx), "not before the hold time");
|
||
frame(&mut app, &ctx, 1.4, vec![]);
|
||
assert!(menu_open(&ctx), "open while still held");
|
||
frame(&mut app, &ctx, 1.5, vec![pointer(at, false)]);
|
||
frame(&mut app, &ctx, 1.55, vec![]);
|
||
assert!(menu_open(&ctx), "the release keeps it open");
|
||
assert!(standard(&app), "the release doesn't cycle the mode");
|
||
// Escape closes it; a plain click cycles the mode and opens nothing.
|
||
frame(
|
||
&mut app,
|
||
&ctx,
|
||
2.0,
|
||
vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }],
|
||
);
|
||
frame(&mut app, &ctx, 2.05, vec![]);
|
||
assert!(!menu_open(&ctx));
|
||
frame(&mut app, &ctx, 3.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]);
|
||
frame(&mut app, &ctx, 3.1, vec![pointer(at, false)]);
|
||
frame(&mut app, &ctx, 3.15, vec![]);
|
||
assert!(!standard(&app), "a click cycles");
|
||
assert!(!menu_open(&ctx));
|
||
// Right-click opens it too.
|
||
let secondary = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, 4.0, vec![secondary(true)]);
|
||
frame(&mut app, &ctx, 4.05, vec![secondary(false)]);
|
||
frame(&mut app, &ctx, 4.1, vec![]);
|
||
assert!(menu_open(&ctx));
|
||
}
|
||
|
||
/// Every text drawn in one toolbar frame.
|
||
fn frame_texts(app: &mut PhotocraftApp, ctx: &egui::Context, time: f64, events: Vec<egui::Event>) -> Vec<String> {
|
||
fn collect(shape: &egui::Shape, out: &mut Vec<String>) {
|
||
match shape {
|
||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||
_ => {}
|
||
}
|
||
}
|
||
let mut out = ctx.run_ui(
|
||
egui::RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() },
|
||
|ui| toolbar(app, ui),
|
||
);
|
||
out.textures_delta.clear();
|
||
let mut texts = Vec::new();
|
||
out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts));
|
||
texts
|
||
}
|
||
|
||
/// #4450: no tool tooltip while a flyout is open or a tool button is held to open one, for the
|
||
/// held tool or any other; a plain hover still shows its tooltip.
|
||
#[test]
|
||
fn no_tooltips_over_an_open_tool_flyout() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
let ctx = egui::Context::default();
|
||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
frame(&mut app, &ctx, 0.0, vec![]);
|
||
frame(&mut app, &ctx, 0.1, vec![]);
|
||
let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 };
|
||
let mut buttons: Vec<Rect> = ctx.viewport(|v| {
|
||
v.prev_pass
|
||
.widgets
|
||
.layers()
|
||
.flat_map(|(_, w)| w.iter())
|
||
.filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click())
|
||
.map(|w| w.rect)
|
||
.collect()
|
||
});
|
||
buttons.sort_by(|a, b| a.top().total_cmp(&b.top()));
|
||
let slot_of = |tool: Tool| TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&tool)).unwrap();
|
||
let (eyedropper, brush) = (buttons[slot_of(Tool::Eyedropper)].center(), buttons[slot_of(Tool::Brush)].center());
|
||
let tip = |tool: Tool| format!("{} ({})", tl!(tool.label()), tool.key());
|
||
let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
// A plain hover shows the tooltip.
|
||
frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(brush)]);
|
||
let mut texts = Vec::new();
|
||
for k in 1..=10 {
|
||
texts = frame_texts(&mut app, &ctx, 1.0 + 0.1 * f64::from(k), vec![]);
|
||
}
|
||
assert!(texts.contains(&tip(Tool::Brush)), "hovered long enough: {texts:?}");
|
||
// Held on the Eyedropper: no tooltip before or after its flyout opens.
|
||
frame(&mut app, &ctx, 3.0, vec![egui::Event::PointerMoved(eyedropper), pointer(eyedropper, true)]);
|
||
for k in 1..=10 {
|
||
let texts = frame_texts(&mut app, &ctx, 3.0 + 0.1 * f64::from(k), vec![]);
|
||
assert!(!texts.contains(&tip(Tool::Eyedropper)), "held {k}: {texts:?}");
|
||
}
|
||
assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_some(), "the flyout is open");
|
||
// Released, with the flyout still open: none for the held tool or another one.
|
||
frame(&mut app, &ctx, 4.1, vec![pointer(eyedropper, false)]);
|
||
for (start, at, tool) in [(4.5, eyedropper, Tool::Eyedropper), (6.0, brush, Tool::Brush)] {
|
||
frame(&mut app, &ctx, start, vec![egui::Event::PointerMoved(at)]);
|
||
for k in 1..=10 {
|
||
let texts = frame_texts(&mut app, &ctx, start + 0.1 * f64::from(k), vec![]);
|
||
assert!(!texts.contains(&tip(tool)), "{tool:?} under the flyout: {texts:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #4460: a press outside an open flyout closes it, even when it becomes a drag (cropping or
|
||
/// painting on the canvas) rather than a click; a press on the flyout keeps it for its pick.
|
||
/// #4932: so does a click on its own tool button.
|
||
#[test]
|
||
fn a_drag_outside_closes_the_tool_flyout() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
let ctx = egui::Context::default();
|
||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
frame(&mut app, &ctx, 0.0, vec![]);
|
||
frame(&mut app, &ctx, 0.1, vec![]);
|
||
let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Brush)).unwrap();
|
||
let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 };
|
||
let mut buttons: Vec<Rect> = ctx.viewport(|v| {
|
||
v.prev_pass
|
||
.widgets
|
||
.layers()
|
||
.flat_map(|(_, w)| w.iter())
|
||
.filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click())
|
||
.map(|w| w.rect)
|
||
.collect()
|
||
});
|
||
buttons.sort_by(|a, b| a.top().total_cmp(&b.top()));
|
||
let at = buttons[index].center();
|
||
let flyout = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_some();
|
||
let secondary = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() };
|
||
let primary = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
// A right-click on the button of an open flyout closes it (#4932), so open only a closed one.
|
||
let open = |app: &mut PhotocraftApp, t: f64| {
|
||
if !flyout(&ctx) {
|
||
frame(app, &ctx, t, vec![egui::Event::PointerMoved(at), secondary(true)]);
|
||
frame(app, &ctx, t + 0.05, vec![secondary(false)]);
|
||
frame(app, &ctx, t + 0.1, vec![]);
|
||
}
|
||
assert!(flyout(&ctx), "right-click opens it");
|
||
};
|
||
// A press on the flyout itself keeps it open (the row picks on release).
|
||
open(&mut app, 1.0);
|
||
let menu = ctx.memory(|m| m.area_rect(egui::Id::new(("tool-slot", index)).with("flyout"))).unwrap();
|
||
frame(&mut app, &ctx, 1.5, vec![egui::Event::PointerMoved(menu.center()), primary(menu.center(), true)]);
|
||
assert!(flyout(&ctx), "a press on the flyout keeps it");
|
||
frame(&mut app, &ctx, 1.55, vec![primary(menu.center(), false)]);
|
||
// A drag far from the toolbar (the canvas) closes it as soon as it starts.
|
||
open(&mut app, 2.0);
|
||
let canvas = egui::pos2(900.0, 900.0);
|
||
frame(&mut app, &ctx, 2.5, vec![egui::Event::PointerMoved(canvas), primary(canvas, true)]);
|
||
frame(&mut app, &ctx, 2.55, vec![egui::Event::PointerMoved(canvas + vec2(80.0, 60.0))]);
|
||
assert!(!flyout(&ctx), "a press outside closes it, even one that becomes a drag");
|
||
frame(&mut app, &ctx, 2.6, vec![primary(canvas + vec2(80.0, 60.0), false)]);
|
||
assert!(!flyout(&ctx));
|
||
// #4932: clicking its own tool button again closes it, with either button.
|
||
open(&mut app, 3.0);
|
||
frame(&mut app, &ctx, 3.5, vec![egui::Event::PointerMoved(at), secondary(true)]);
|
||
frame(&mut app, &ctx, 3.55, vec![secondary(false)]);
|
||
frame(&mut app, &ctx, 3.6, vec![]);
|
||
assert!(!flyout(&ctx), "a second right-click on the button closes it");
|
||
open(&mut app, 4.0);
|
||
app.ui.tool = Tool::Move;
|
||
frame(&mut app, &ctx, 4.5, vec![egui::Event::PointerMoved(at), primary(at, true)]);
|
||
frame(&mut app, &ctx, 4.55, vec![primary(at, false)]);
|
||
frame(&mut app, &ctx, 4.6, vec![]);
|
||
assert!(!flyout(&ctx), "a click on the button closes it");
|
||
assert_eq!(app.ui.tool, Tool::Brush, "and picks the tool, as any click on it does");
|
||
// The release that ends a long press keeps the flyout it opened.
|
||
frame(&mut app, &ctx, 5.0, vec![egui::Event::PointerMoved(at), primary(at, true)]);
|
||
frame(&mut app, &ctx, 5.2, vec![]);
|
||
frame(&mut app, &ctx, 5.5, vec![]);
|
||
assert!(flyout(&ctx), "a long press opens it");
|
||
frame(&mut app, &ctx, 5.55, vec![primary(at, false)]);
|
||
frame(&mut app, &ctx, 5.6, vec![]);
|
||
assert!(flyout(&ctx), "releasing the long press keeps it open");
|
||
}
|
||
|
||
#[test]
|
||
fn red_eye_is_in_the_j_flyout() {
|
||
let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group");
|
||
assert!(j.contains(&Tool::RedEye), "{j:?}");
|
||
assert_eq!(j.last(), Some(&Tool::RedEye));
|
||
// Remove leads the flyout, so the slot shows it until another J tool is picked.
|
||
assert_eq!(j.first(), Some(&Tool::Remove));
|
||
}
|
||
|
||
#[test]
|
||
fn long_press_clone_stamp_selects_pattern_stamp() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
let ctx = egui::Context::default();
|
||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
frame(&mut app, &ctx, 0.0, vec![]);
|
||
frame(&mut app, &ctx, 0.1, vec![]);
|
||
let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::CloneStamp)).unwrap();
|
||
let slot = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).nth(index).copied().unwrap();
|
||
assert_eq!(slot, &[Tool::CloneStamp, Tool::PatternStamp]);
|
||
let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 };
|
||
let mut buttons: Vec<Rect> = ctx.viewport(|v| {
|
||
v.prev_pass
|
||
.widgets
|
||
.layers()
|
||
.flat_map(|(_, w)| w.iter())
|
||
.filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click())
|
||
.map(|w| w.rect)
|
||
.collect()
|
||
});
|
||
buttons.sort_by(|a, b| a.top().total_cmp(&b.top()));
|
||
let at = buttons[index].center();
|
||
let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]);
|
||
frame(&mut app, &ctx, 1.36, vec![]);
|
||
assert_eq!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).map(|(id, _)| id), Some(egui::Id::new(("tool-slot", index))));
|
||
frame(&mut app, &ctx, 1.4, vec![pointer(at, false)]);
|
||
frame(&mut app, &ctx, 1.45, vec![]);
|
||
let key = egui::Id::new(("tool-slot", index));
|
||
let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).unwrap();
|
||
let row = egui::pos2(menu.left() + 65.0, menu.top() + 39.0 + 8.0);
|
||
frame(&mut app, &ctx, 2.0, vec![egui::Event::PointerMoved(row), pointer(row, true)]);
|
||
frame(&mut app, &ctx, 2.05, vec![pointer(row, false)]);
|
||
assert_eq!(app.ui.tool, Tool::PatternStamp);
|
||
}
|
||
|
||
#[test]
|
||
fn long_press_brush_selects_color_replacement() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
let ctx = egui::Context::default();
|
||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
frame(&mut app, &ctx, 0.0, vec![]);
|
||
frame(&mut app, &ctx, 0.1, vec![]);
|
||
let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Brush)).unwrap();
|
||
let slot = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).nth(index).copied().unwrap();
|
||
// Photoshop's Brush group, Color Replacement after the Mixer Brush.
|
||
assert_eq!(slot, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement]);
|
||
let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 };
|
||
let mut buttons: Vec<Rect> = ctx.viewport(|v| {
|
||
v.prev_pass
|
||
.widgets
|
||
.layers()
|
||
.flat_map(|(_, w)| w.iter())
|
||
.filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click())
|
||
.map(|w| w.rect)
|
||
.collect()
|
||
});
|
||
buttons.sort_by(|a, b| a.top().total_cmp(&b.top()));
|
||
let at = buttons[index].center();
|
||
let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]);
|
||
frame(&mut app, &ctx, 1.36, vec![]);
|
||
frame(&mut app, &ctx, 1.4, vec![pointer(at, false)]);
|
||
frame(&mut app, &ctx, 1.45, vec![]);
|
||
let key = egui::Id::new(("tool-slot", index));
|
||
let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).unwrap();
|
||
// The last of the four 26-point rows.
|
||
let row = egui::pos2(menu.left() + 65.0, menu.bottom() - 20.0);
|
||
frame(&mut app, &ctx, 2.0, vec![egui::Event::PointerMoved(row), pointer(row, true)]);
|
||
frame(&mut app, &ctx, 2.05, vec![pointer(row, false)]);
|
||
assert_eq!(app.ui.tool, Tool::ColorReplacement);
|
||
assert_eq!(crate::icons::tool_icon(Tool::ColorReplacement), "brush-replace");
|
||
}
|
||
|
||
#[test]
|
||
fn rotate_view_is_in_the_hand_flyout() {
|
||
let hand = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::Hand)).expect("Hand group");
|
||
assert_eq!(*hand, [Tool::Hand, Tool::RotateView]);
|
||
assert_eq!(Tool::RotateView.key(), 'R');
|
||
}
|
||
|
||
#[test]
|
||
fn pro_toolbar_glyphs_fill_two_thirds_of_the_button() {
|
||
// #2665: Photoshop-style themes draw tool glyphs larger than other icon buttons.
|
||
assert_eq!(toolbar_icon_size(30.0, true), 20.0);
|
||
assert_eq!(toolbar_icon_size(36.0, false), (36.0f32 * 0.52).round(), "other themes keep the icon-button size");
|
||
// The glyph stays inside the button with room for the flyout triangle's corner.
|
||
assert!(toolbar_icon_size(30.0, true) <= 30.0 - 2.0 * 4.0);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod toolbar_hidden_tests {
|
||
use super::*;
|
||
|
||
/// Clickable tool-sized buttons the toolbar drew in its last pass.
|
||
fn tool_buttons(app: &mut PhotocraftApp) -> usize {
|
||
let ctx = egui::Context::default();
|
||
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
for time in [0.0, 0.1] {
|
||
let mut out = ctx.run_ui(
|
||
egui::RawInput { time: Some(time), screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() },
|
||
|ui| toolbar(app, ui),
|
||
);
|
||
out.textures_delta.clear();
|
||
}
|
||
let size = egui::Vec2::splat(if Tokens::get(&ctx).pro { 30.0 } else { 36.0 });
|
||
ctx.viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size && w.sense.senses_click()).count())
|
||
}
|
||
|
||
fn hide(app: &mut PhotocraftApp, tools: &[Tool]) {
|
||
let names: Vec<String> = tools.iter().map(|tool| format!("{tool:?}")).collect();
|
||
app.session.edit_prefs(|p| p.toolbar.hidden = names);
|
||
}
|
||
|
||
#[test]
|
||
fn hidden_tools_leave_the_toolbar() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
let all = tool_buttons(&mut app);
|
||
let slots: usize = TOOL_SECTIONS.iter().map(|section| section.len()).sum();
|
||
hide(&mut app, &Tool::ALL);
|
||
assert_eq!(tool_buttons(&mut app), all - slots, "every tool slot is gone when every tool is hidden");
|
||
hide(&mut app, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement]);
|
||
assert_eq!(tool_buttons(&mut app), all - 1, "a slot whose tools are all hidden is gone");
|
||
hide(&mut app, &[Tool::Sponge]);
|
||
assert_eq!(tool_buttons(&mut app), all, "a slot with a visible tool stays");
|
||
}
|
||
|
||
#[test]
|
||
fn a_slot_keeps_only_its_visible_tools_and_its_index() {
|
||
let sections = visible_sections(&["Sponge".to_string(), "Move".to_string(), "Artboard".to_string()]);
|
||
assert_eq!(sections.len(), TOOL_SECTIONS.len() - 1, "the Move section has no tools left");
|
||
let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Sponge)).unwrap();
|
||
let slot = sections.iter().flatten().find(|(i, _)| *i == index).map(|(_, tools)| tools.clone());
|
||
assert_eq!(slot, Some(vec![Tool::Dodge, Tool::Burn]));
|
||
assert!(sections.iter().flatten().all(|(_, tools)| !tools.contains(&Tool::Move) && !tools.contains(&Tool::Sponge)));
|
||
assert_eq!(visible_sections(&[]).iter().map(Vec::len).sum::<usize>(), TOOL_SECTIONS.iter().map(|section| section.len()).sum::<usize>());
|
||
let by_command = visible_sections(&["sponge".to_string(), "move".to_string(), "artboard".to_string()]);
|
||
assert_eq!(by_command, sections, "edit.toolbar names are read like `ui.set {{tool}}` names");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod properties_card_tests {
|
||
use super::*;
|
||
use egui_kittest::{Harness, kittest::Queryable};
|
||
|
||
/// Arithmetic left uncommitted in one fill layer's card never lands on the layer selected next.
|
||
#[test]
|
||
fn uncommitted_arithmetic_stays_with_its_layer() {
|
||
let mut s = photocraft_engine::Session::new();
|
||
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||
let mut ids = Vec::new();
|
||
for color in ["#00ff00", "#0000ff"] {
|
||
s.execute("layer.newFillLayer.solidColor", json!({"color": color})).unwrap();
|
||
ids.push(s.active().unwrap().active_layer.unwrap());
|
||
}
|
||
// The layer selected next already shows 25, the value the edited one has when `25*` stops it.
|
||
s.execute("layer.setProps", json!({"layer": ids[0].0, "opacity": 0.25})).unwrap();
|
||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||
app.ui.panels.properties = true;
|
||
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
|
||
|ui, app: &mut PhotocraftApp| {
|
||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) {
|
||
properties_window(app, ui.ctx());
|
||
}
|
||
},
|
||
app,
|
||
);
|
||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||
h.run_steps(2);
|
||
h.query_all_by_role(egui::accesskit::Role::SpinButton).next().unwrap().click();
|
||
h.run_steps(1);
|
||
for ch in "25*2".chars() {
|
||
h.event(egui::Event::Text(ch.to_string()));
|
||
h.run_steps(1);
|
||
}
|
||
// The Layers panel selects the other layer in the frame the click leaves the field.
|
||
h.state_mut().session.execute("layer.select", json!({"layer": ids[0].0})).unwrap();
|
||
h.ctx.memory_mut(|m| m.stop_text_input());
|
||
h.run_steps(3);
|
||
let doc = &h.state().session.active().unwrap().doc;
|
||
assert_eq!(ids.iter().map(|&id| doc.layer(id).unwrap().opacity).collect::<Vec<_>>(), [0.25, 0.25]);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod toolbar_tests {
|
||
use super::*;
|
||
use egui_kittest::kittest::Queryable;
|
||
|
||
/// #1197 / #3469: every theme can toggle the toolbar without changing its selection.
|
||
#[test]
|
||
fn header_chevron_toggles_two_columns_in_every_layout() {
|
||
for theme in [
|
||
crate::theme::ThemeKind::Pro,
|
||
crate::theme::ThemeKind::Studio,
|
||
crate::theme::ThemeKind::StudioLight,
|
||
crate::theme::ThemeKind::Classic,
|
||
crate::theme::ThemeKind::AdwaitaDark,
|
||
crate::theme::ThemeKind::SolarizedDark,
|
||
crate::theme::ThemeKind::BreezeDark,
|
||
] {
|
||
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.0)).build_ui_state(
|
||
|ui, app: &mut PhotocraftApp| {
|
||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||
toolbar(app, ui);
|
||
let left = ui.available_rect_before_wrap().left();
|
||
ui.data_mut(|d| d.insert_temp(egui::Id::new("toolbar-test-left"), left));
|
||
}
|
||
},
|
||
app,
|
||
);
|
||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||
h.run_steps(2);
|
||
let left = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.ctx.data(|d| d.get_temp::<f32>(egui::Id::new("toolbar-test-left"))).unwrap_or(0.0);
|
||
let single = left(&h);
|
||
assert!(!h.state().ui.panels.toolbar_double, "{theme:?}");
|
||
|
||
h.get_by_label("Toolbar").click();
|
||
h.run_steps(2);
|
||
assert!(h.state().ui.panels.toolbar_double, "{theme:?}");
|
||
assert!(left(&h) > single + 20.0, "{theme:?}: the toolbar did not widen: {single} -> {}", left(&h));
|
||
|
||
h.get_by_label("Toolbar").click();
|
||
h.run_steps(2);
|
||
assert!(!h.state().ui.panels.toolbar_double, "{theme:?}");
|
||
assert_eq!(left(&h), single, "{theme:?}");
|
||
}
|
||
}
|
||
|
||
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 tool's toolbar button: tool buttons have no label, so find it by slot order.
|
||
fn tool_button(h: &egui_kittest::Harness<'_, PhotocraftApp>, tool: Tool) -> egui::Pos2 {
|
||
let size = egui::Vec2::splat(if Tokens::get(&h.ctx).pro { 30.0 } else { 36.0 });
|
||
let buttons: Vec<Rect> = h.ctx.viewport(|v| {
|
||
v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size && w.sense.senses_click()).map(|w| w.rect).collect()
|
||
});
|
||
let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&tool)).unwrap();
|
||
buttons[index].center()
|
||
}
|
||
|
||
fn hand_button(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> egui::Pos2 {
|
||
tool_button(h, Tool::Hand)
|
||
}
|
||
|
||
fn toolbar_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||
// 60 fps steps, so two clicks a few frames apart are a double-click.
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 1400.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()))) {
|
||
toolbar(app, ui);
|
||
}
|
||
},
|
||
app,
|
||
);
|
||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||
h.run_steps(2);
|
||
h
|
||
}
|
||
|
||
/// Double-clicking the Hand tool fits the image on screen (Photoshop); a single click only
|
||
/// picks the tool.
|
||
#[test]
|
||
fn double_clicking_the_hand_tool_fits_on_screen() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||
app.ui.tool = Tool::Move;
|
||
app.ui.views[0].fit_pending = false;
|
||
let mut h = toolbar_harness(app);
|
||
let p = hand_button(&h);
|
||
click(&mut h, p);
|
||
assert_eq!(h.state().ui.tool, Tool::Hand);
|
||
assert!(!h.state().ui.views[0].fit_pending, "a single click doesn't fit");
|
||
// Past the double-click window, so the next two clicks are a fresh double-click.
|
||
h.run_steps(40);
|
||
click(&mut h, p);
|
||
click(&mut h, p);
|
||
assert_eq!(h.state().ui.tool, Tool::Hand);
|
||
assert!(h.state().ui.views[0].fit_pending, "a double-click fits on screen");
|
||
}
|
||
|
||
/// With no document open the double-click still picks the tool and doesn't panic.
|
||
#[test]
|
||
fn double_clicking_the_hand_tool_without_a_document_is_harmless() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.ui.tool = Tool::Move;
|
||
let mut h = toolbar_harness(app);
|
||
let p = hand_button(&h);
|
||
click(&mut h, p);
|
||
click(&mut h, p);
|
||
assert_eq!(h.state().ui.tool, Tool::Hand);
|
||
assert!(h.state().session.active().is_none());
|
||
}
|
||
|
||
/// Double-clicking the Zoom tool zooms to 100% (Photoshop); a single click only picks the tool.
|
||
#[test]
|
||
fn double_clicking_the_zoom_tool_zooms_to_actual_pixels() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||
app.ui.tool = Tool::Move;
|
||
app.ui.views[0].fit_pending = true;
|
||
app.ui.views[0].zoom = 0.25;
|
||
let mut h = toolbar_harness(app);
|
||
let p = tool_button(&h, Tool::Zoom);
|
||
click(&mut h, p);
|
||
assert_eq!(h.state().ui.tool, Tool::Zoom);
|
||
assert_eq!(h.state().ui.views[0].zoom, 0.25, "a single click doesn't zoom");
|
||
// Past the double-click window, so the next two clicks are a fresh double-click.
|
||
h.run_steps(40);
|
||
click(&mut h, p);
|
||
click(&mut h, p);
|
||
assert_eq!(h.state().ui.tool, Tool::Zoom);
|
||
assert_eq!(h.state().ui.views[0].zoom, 1.0, "a double-click zooms to 100%");
|
||
assert!(!h.state().ui.views[0].fit_pending, "100% replaces a pending fit");
|
||
}
|
||
|
||
/// With no document open the double-click still picks the tool and doesn't panic.
|
||
#[test]
|
||
fn double_clicking_the_zoom_tool_without_a_document_is_harmless() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.ui.tool = Tool::Move;
|
||
let mut h = toolbar_harness(app);
|
||
let p = tool_button(&h, Tool::Zoom);
|
||
click(&mut h, p);
|
||
click(&mut h, p);
|
||
assert_eq!(h.state().ui.tool, Tool::Zoom);
|
||
assert!(h.state().session.active().is_none());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod embedding_tests {
|
||
use super::*;
|
||
use egui_kittest::kittest::Queryable;
|
||
|
||
fn whole_app(rail: bool, menu_bar: bool) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(move |cc| {
|
||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.ui.panels.rail = rail;
|
||
app.ui.panels.menu_bar = menu_bar;
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
h
|
||
}
|
||
|
||
fn drawn(h: &egui_kittest::Harness<'_, PhotocraftApp>, panel: &str) -> bool {
|
||
egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new(panel)).is_some()
|
||
}
|
||
|
||
/// An app that embeds the UI can hide the icon rail and the menu bar; both are on by default.
|
||
#[test]
|
||
fn rail_and_menu_bar_follow_their_panel_flags() {
|
||
let panels = crate::state::Panels::default();
|
||
assert!(panels.rail && panels.menu_bar);
|
||
|
||
let h = whole_app(true, true);
|
||
assert!(drawn(&h, "rail") && drawn(&h, "title_bar"));
|
||
assert!(h.query_by_label("File").is_some());
|
||
|
||
let h = whole_app(false, false);
|
||
assert!(!drawn(&h, "rail"), "the icon rail is drawn");
|
||
assert!(!drawn(&h, "title_bar"), "the title bar is drawn");
|
||
assert!(h.query_by_label("File").is_none(), "the menus are drawn");
|
||
assert!(drawn(&h, "dock"), "the dock itself stays");
|
||
}
|
||
|
||
/// Settings saved before the flags existed load with both on.
|
||
#[test]
|
||
fn panels_without_the_flags_deserialize_with_both_on() {
|
||
let mut v = serde_json::to_value(crate::state::Panels::default()).unwrap();
|
||
let m = v.as_object_mut().unwrap();
|
||
m.remove("rail");
|
||
m.remove("menu_bar");
|
||
let p: crate::state::Panels = serde_json::from_value(v).unwrap();
|
||
assert!(p.rail && p.menu_bar);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod layer_drag_edge_scroll_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn inside_edge_scrolls_proportional_to_proximity() {
|
||
let viewport = Rect::from_min_max(pos2(10.0, 30.0), pos2(310.0, 330.0));
|
||
let point = |y| Some(pos2(100.0, y));
|
||
let near_top = layer_drag_edge_scroll(point(33.0), viewport, true, 1.0 / 60.0);
|
||
let far_top = layer_drag_edge_scroll(point(53.0), viewport, true, 1.0 / 60.0);
|
||
let near_bottom = layer_drag_edge_scroll(point(327.0), viewport, true, 1.0 / 60.0);
|
||
let far_bottom = layer_drag_edge_scroll(point(307.0), viewport, true, 1.0 / 60.0);
|
||
assert!(near_top > far_top && far_top > 0.0, "approaching the top reveals earlier rows");
|
||
assert!(near_bottom < far_bottom && far_bottom < 0.0, "approaching the bottom reveals later rows");
|
||
assert!((near_top + near_bottom).abs() < 1e-5, "symmetric edge behavior");
|
||
assert_eq!(layer_drag_edge_scroll(point(160.0), viewport, true, 1.0 / 60.0), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn outside_edges_scroll_in_drag_direction() {
|
||
let viewport = Rect::from_min_max(pos2(10.0, 30.0), pos2(310.0, 330.0));
|
||
let above = layer_drag_edge_scroll(Some(pos2(100.0, 20.0)), viewport, true, 1.0 / 60.0);
|
||
let below = layer_drag_edge_scroll(Some(pos2(100.0, 335.0)), viewport, true, 1.0 / 60.0);
|
||
assert!(above > 0.0, "dragging past the top continues scrolling upward");
|
||
assert!(below < 0.0, "dragging past the bottom continues scrolling downward");
|
||
}
|
||
|
||
#[test]
|
||
fn clamp_bounds_speed_outside_edges_and_across_frame_times() {
|
||
let viewport = Rect::from_min_max(pos2(10.0, 30.0), pos2(310.0, 330.0));
|
||
let at_top = layer_drag_edge_scroll(Some(pos2(100.0, 30.0)), viewport, true, 1.0 / 60.0);
|
||
let past_top = layer_drag_edge_scroll(Some(pos2(100.0, 15.0)), viewport, true, 1.0 / 60.0);
|
||
let far_past_top = layer_drag_edge_scroll(Some(pos2(100.0, -50.0)), viewport, true, 1.0 / 60.0);
|
||
assert_eq!(past_top, at_top, "speed past top edge is clamped to maximum edge speed");
|
||
assert_eq!(far_past_top, at_top, "speed far past top edge remains clamped");
|
||
|
||
let at_bottom = layer_drag_edge_scroll(Some(pos2(100.0, 330.0)), viewport, true, 1.0 / 60.0);
|
||
let past_bottom = layer_drag_edge_scroll(Some(pos2(100.0, 345.0)), viewport, true, 1.0 / 60.0);
|
||
let far_past_bottom = layer_drag_edge_scroll(Some(pos2(100.0, 500.0)), viewport, true, 1.0 / 60.0);
|
||
assert_eq!(past_bottom, at_bottom, "speed past bottom edge is clamped to maximum edge speed");
|
||
assert_eq!(far_past_bottom, at_bottom, "speed far past bottom edge remains clamped");
|
||
|
||
let active = Some(pos2(100.0, 325.0));
|
||
let step = layer_drag_edge_scroll(active, viewport, true, 1.0 / 60.0);
|
||
let twice = layer_drag_edge_scroll(active, viewport, true, 2.0 / 60.0);
|
||
assert!((twice - step * 2.0).abs() < 1e-4, "time-based scrolling scales across refresh rates");
|
||
assert!(layer_drag_edge_scroll(active, viewport, true, 0.5).abs() <= 30.0, "long frames have a bounded step");
|
||
}
|
||
|
||
#[test]
|
||
fn zero_when_no_drag_or_outside_horizontal_bounds() {
|
||
let viewport = Rect::from_min_max(pos2(10.0, 30.0), pos2(310.0, 330.0));
|
||
let active = Some(pos2(100.0, 325.0));
|
||
assert_eq!(layer_drag_edge_scroll(active, viewport, false, 1.0 / 60.0), 0.0);
|
||
assert_eq!(layer_drag_edge_scroll(None, viewport, true, 1.0 / 60.0), 0.0);
|
||
assert_eq!(layer_drag_edge_scroll(Some(pos2(9.0, 325.0)), viewport, true, 1.0 / 60.0), 0.0);
|
||
assert_eq!(layer_drag_edge_scroll(Some(pos2(311.0, 325.0)), viewport, true, 1.0 / 60.0), 0.0);
|
||
assert_eq!(layer_drag_edge_scroll(active, viewport, true, 0.0), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn short_viewports_keep_the_edge_zones_disjoint() {
|
||
let viewport = Rect::from_min_max(pos2(0.0, 0.0), pos2(150.0, 40.0));
|
||
assert!(layer_drag_edge_scroll(Some(pos2(10.0, 2.0)), viewport, true, 0.016) > 0.0);
|
||
assert!(layer_drag_edge_scroll(Some(pos2(10.0, 38.0)), viewport, true, 0.016) < 0.0);
|
||
assert_eq!(layer_drag_edge_scroll(Some(pos2(10.0, 20.0)), viewport, true, 0.016), 0.0);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod group_drag_selection_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn dragging_a_selected_layer_moves_the_complete_selection_into_a_group() {
|
||
let a = LayerId(10);
|
||
let b = LayerId(11);
|
||
let group = LayerId(20);
|
||
let payload = layer_drop_payload(a.0, group, "into", &[a, b]);
|
||
assert_eq!(payload, json!({"layers": [10, 11], "target": 20, "position": "into"}));
|
||
assert_eq!(payload.get("layer"), None, "batch drops must not also send a single layer");
|
||
|
||
// Above/below use the same batch route; engine preserves the document stack order.
|
||
assert_eq!(layer_drop_payload(b.0, group, "above", &[a, b])["position"], "above");
|
||
assert_eq!(layer_drop_payload(b.0, group, "below", &[a, b])["position"], "below");
|
||
}
|
||
|
||
#[test]
|
||
fn the_background_row_neither_moves_nor_takes_a_layer_below_it() {
|
||
let mut s = photocraft_engine::Session::new();
|
||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||
let bg = s.active().unwrap().doc.layers[0].id;
|
||
let a = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||
let doc = &s.active().unwrap().doc;
|
||
let refused = |dragged: LayerId, target: LayerId, pos: &str, sel: &[LayerId], copy: bool| {
|
||
background_drop_refused(doc, &layer_drop_payload(dragged.0, target, pos, sel), target, pos, copy)
|
||
};
|
||
assert!(refused(bg, a, "above", &[bg], false));
|
||
assert!(refused(a, bg, "below", &[a], false));
|
||
assert!(refused(a, bg, "below", &[a], true));
|
||
assert!(refused(a, a, "above", &[bg, a], false), "the Background is among the dragged layers");
|
||
assert!(!refused(a, bg, "above", &[a], false));
|
||
assert!(!refused(bg, a, "above", &[bg], true), "⌥-dragging copies the Background above");
|
||
// Without a Background every drop is fine.
|
||
let mut s = photocraft_engine::Session::new();
|
||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||
s.execute("layer.new.layerFromBackground", json!({})).unwrap();
|
||
let l0 = s.active().unwrap().doc.layers[0].id;
|
||
let doc = &s.active().unwrap().doc;
|
||
assert!(!background_drop_refused(doc, &layer_drop_payload(l0.0, a, "above", &[l0]), a, "above", false));
|
||
}
|
||
|
||
#[test]
|
||
fn dragging_unselected_or_singular_row_remains_a_single_layer_move() {
|
||
let a = LayerId(10);
|
||
let b = LayerId(11);
|
||
let group = LayerId(20);
|
||
assert_eq!(layer_drop_payload(9, group, "into", &[a, b]), json!({"layer": 9, "target": 20, "position": "into"}));
|
||
assert_eq!(layer_drop_payload(a.0, group, "above", &[a]), json!({"layer": 10, "target": 20, "position": "above"}));
|
||
assert_eq!(layer_drop_payload(a.0, group, "below", &[]), json!({"layer": 10, "target": 20, "position": "below"}));
|
||
}
|
||
|
||
#[test]
|
||
fn layer_thumb_of_a_tall_document_fills_the_height() {
|
||
let cell = Rect::from_min_size(pos2(10.0, 20.0), vec2(30.0, 30.0));
|
||
let (r, uv) = layer_thumb_fit(cell, 100, 400);
|
||
assert_eq!((r.top(), r.bottom()), (cell.top(), cell.bottom()));
|
||
assert!((r.width() - 7.5).abs() < 1e-3, "{r:?}");
|
||
assert!((uv.width() - 0.25).abs() < 1e-3 && (uv.height() - 1.0).abs() < 1e-3, "{uv:?}");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod opacity_row_layout_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn opacity_label_yields_to_controls_in_narrow_layers_panel() {
|
||
let gap = 2.0;
|
||
let label = 62.0;
|
||
let (wide, visible) = opacity_row_layout(360.0, label, gap);
|
||
assert!(visible);
|
||
assert!(wide >= 100.0);
|
||
// The default dock width keeps the English label (the Layers dock at its 250 px minimum).
|
||
assert!(opacity_row_layout(255.0, 46.0, 8.0).1);
|
||
let (medium, visible) = opacity_row_layout(180.0, label, gap);
|
||
assert!(!visible, "the long label must not overlap the blend field");
|
||
assert!(medium + LAYER_PCT_W + gap <= 180.0);
|
||
let (narrow, visible) = opacity_row_layout(135.0, label, gap);
|
||
assert!(!visible);
|
||
assert!(narrow >= 40.0);
|
||
assert!(narrow + LAYER_PCT_W + gap <= 135.0);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "clipping_indicator_tests.rs"]
|
||
mod clipping_indicator_tests;
|
||
|
||
#[cfg(test)]
|
||
#[path = "mask_props_ui_tests.rs"]
|
||
mod mask_properties_tests;
|
||
|
||
#[cfg(test)]
|
||
mod compact_visual_regressions {
|
||
use super::*;
|
||
use egui_kittest::kittest::Queryable;
|
||
|
||
fn input_at_scale(screen: Rect, scale: f32) -> egui::RawInput {
|
||
let mut input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
|
||
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
|
||
input
|
||
}
|
||
|
||
fn toolbar_harness(kind: theme::ThemeKind, scale: f32) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||
toolbar_harness_sized(kind, scale, 360.0)
|
||
}
|
||
|
||
fn toolbar_harness_sized(kind: theme::ThemeKind, scale: f32, height: f32) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||
let mut ready = false;
|
||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, height)).with_pixels_per_point(scale).build_ui_state(
|
||
move |ui, app: &mut PhotocraftApp| {
|
||
if !ready {
|
||
egui_extras::install_image_loaders(ui.ctx());
|
||
theme::install_fonts(ui.ctx());
|
||
theme::apply(ui.ctx(), kind);
|
||
app.session.prefs.edit(|prefs| prefs.shortcuts.insert("window.togglePanels".into(), String::new()));
|
||
ready = true;
|
||
return;
|
||
}
|
||
crate::shortcuts::handle(app, ui.ctx());
|
||
toolbar(app, ui);
|
||
},
|
||
PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()),
|
||
);
|
||
harness.run_steps(4);
|
||
harness
|
||
}
|
||
|
||
#[test]
|
||
fn compact_toolbar_scrolls_to_and_activates_the_last_tool() {
|
||
for kind in theme::ThemeKind::ALL {
|
||
for scale in [1.0, 1.5, 2.0] {
|
||
let mut harness = toolbar_harness(kind, scale);
|
||
harness.event(egui::Event::PointerMoved(pos2(25.0, 100.0)));
|
||
harness.event(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, -1500.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
});
|
||
harness.run_steps(30);
|
||
let node = harness.get_by_label("Zoom Tool (Z)");
|
||
let rect = node.rect();
|
||
assert!(rect.top() >= 0.0 && rect.bottom() <= 360.0, "{kind:?} at {scale}x: last tool remains outside the toolbar: {rect:?}");
|
||
node.click();
|
||
harness.run_steps(3);
|
||
assert_eq!(harness.state().ui.tool, Tool::Zoom, "the scrolled button must select its real tool");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn compact_toolbar_tab_reveals_and_activates_the_last_tool() {
|
||
for kind in theme::ThemeKind::ALL {
|
||
for scale in [1.0, 1.5, 2.0] {
|
||
let mut harness = toolbar_harness(kind, scale);
|
||
// The supported shortcut preference clears the canvas Tab binding. From no
|
||
// focus, every step below is a real Tab processed by the app's dispatcher.
|
||
let mut reached = false;
|
||
for _ in 0..80 {
|
||
harness.key_press(egui::Key::Tab);
|
||
harness.run_steps(3);
|
||
reached = harness.get_by_label("Zoom Tool (Z)").is_focused();
|
||
if reached {
|
||
break;
|
||
}
|
||
}
|
||
assert!(reached, "{kind:?} at {scale}x: Tab must reach the last tool");
|
||
harness.run_steps(20);
|
||
let rect = harness.get_by_label("Zoom Tool (Z)").rect();
|
||
assert!(rect.top() >= 0.0 && rect.bottom() <= 360.0, "{kind:?} at {scale}x: focused tool remains clipped: {rect:?}");
|
||
harness.key_press(egui::Key::Space);
|
||
harness.run_steps(3);
|
||
assert_eq!(harness.state().ui.tool, Tool::Zoom, "Space must select the revealed tool");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A window taller than the tools keeps the colour chips right under the last tool, as
|
||
/// Photoshop does, instead of at the bottom of the toolbar.
|
||
#[test]
|
||
fn tall_toolbar_keeps_the_colour_chips_under_the_tools() {
|
||
for kind in theme::ThemeKind::ALL {
|
||
let harness = toolbar_harness_sized(kind, 1.0, 1600.0);
|
||
let last = harness.get_by_label("Zoom Tool (Z)").rect();
|
||
let swap = harness.get_by_label("Swap colours (X)").rect();
|
||
assert!(swap.top() - last.bottom() < 80.0, "{kind:?}: the chips {swap:?} sit far below the last tool {last:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn capture_compact_visual_fixtures() {
|
||
let _gpu = photocraft_testkit::gpu_lock();
|
||
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
|
||
std::fs::create_dir_all(&directory).unwrap();
|
||
for kind in crate::theme::ThemeKind::ALL {
|
||
for (width, height) in [(800.0, 480.0), (1280.0, 720.0)] {
|
||
for scale in [1.0, 1.5, 2.0] {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||
app.session.tools.foreground = [1.0, 0.0, 1.0, 1.0];
|
||
app.session.tools.background = [0.0, 1.0, 0.0, 1.0];
|
||
let mut ready = false;
|
||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(width, height)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||
move |ui, app: &mut PhotocraftApp| {
|
||
if !ready {
|
||
egui_extras::install_image_loaders(ui.ctx());
|
||
crate::theme::install_fonts(ui.ctx());
|
||
crate::theme::apply(ui.ctx(), kind);
|
||
ready = true;
|
||
return;
|
||
}
|
||
app.ui.tool = if Tokens::get(ui.ctx()).pro { Tool::Pencil } else { Tool::Brush };
|
||
options_bar(app, ui);
|
||
toolbar(app, ui);
|
||
},
|
||
app,
|
||
);
|
||
harness.input_mut().max_texture_side = Some(8192);
|
||
harness.run_steps(4);
|
||
let stem = format!("photocraft-compact-{}-{width}-{height}-{scale}x", kind.id());
|
||
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
|
||
let mut regions = serde_json::Map::new();
|
||
for id in ["options_bar", "toolbar"] {
|
||
let r = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect;
|
||
regions.insert(id.into(), json!([r.left(), r.top(), r.width(), r.height()]));
|
||
}
|
||
let metadata = json!({"screen": [width, height], "scale": scale, "regions": regions});
|
||
std::fs::write(directory.join(format!("{stem}.json")), serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
|
||
for _ in 0..6 {
|
||
harness.input_mut().events.extend([
|
||
egui::Event::PointerMoved(pos2(width * 0.5, 20.0)),
|
||
egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(-1500.0, 0.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
},
|
||
]);
|
||
harness.run_steps(1);
|
||
}
|
||
harness.render().unwrap().save(directory.join(format!("{stem}-scrolled.png"))).unwrap();
|
||
std::fs::write(directory.join(format!("{stem}-scrolled.json")), serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn compact_toolbar_keeps_both_color_chips_visible() {
|
||
for kind in crate::theme::ThemeKind::ALL {
|
||
for height in [480.0, 720.0] {
|
||
for scale in [1.0, 1.5, 2.0] {
|
||
let ctx = egui::Context::default();
|
||
egui_extras::install_image_loaders(&ctx);
|
||
crate::theme::install_fonts(&ctx);
|
||
crate::theme::apply(&ctx, kind);
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||
app.session.tools.foreground = [1.0, 0.0, 1.0, 1.0];
|
||
app.session.tools.background = [0.0, 1.0, 0.0, 1.0];
|
||
let mut output = ctx.run_ui(input_at_scale(Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, height)), scale), |ui| toolbar(&mut app, ui));
|
||
output.textures_delta.clear();
|
||
for color in [Color32::from_rgb(255, 0, 255), Color32::GREEN] {
|
||
let (clip, chip) = output
|
||
.shapes
|
||
.iter()
|
||
.find_map(|shape| match &shape.shape {
|
||
egui::Shape::Rect(rect) if rect.fill == color => Some((shape.clip_rect, rect.rect)),
|
||
egui::Shape::Circle(circle) if circle.fill == color => {
|
||
Some((shape.clip_rect, Rect::from_center_size(circle.center, vec2(circle.radius * 2.0, circle.radius * 2.0))))
|
||
}
|
||
_ => None,
|
||
})
|
||
.unwrap_or_else(|| panic!("{kind:?} at {height}/{scale}: both color chips are painted"));
|
||
assert!(clip.contains_rect(chip) && chip.bottom() <= height, "{kind:?} at {height}: color chip {chip:?} clipped by {clip:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tab_focus_reveals_the_final_narrow_tool_option() {
|
||
for kind in theme::ThemeKind::ALL {
|
||
for scale in [1.0, 1.5, 2.0] {
|
||
let mut ready = false;
|
||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(360.0, 120.0)).with_pixels_per_point(scale).build_ui_state(
|
||
move |ui, app: &mut PhotocraftApp| {
|
||
if !ready {
|
||
egui_extras::install_image_loaders(ui.ctx());
|
||
theme::install_fonts(ui.ctx());
|
||
theme::apply(ui.ctx(), kind);
|
||
app.ui.tool = if Tokens::get(ui.ctx()).pro { Tool::Pencil } else { Tool::Brush };
|
||
app.session.prefs.edit(|prefs| prefs.shortcuts.insert("window.togglePanels".into(), String::new()));
|
||
ready = true;
|
||
return;
|
||
}
|
||
crate::shortcuts::handle(app, ui.ctx());
|
||
options_bar(app, ui);
|
||
},
|
||
PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()),
|
||
);
|
||
harness.run_steps(4);
|
||
let pro = Tokens::get(&harness.ctx).pro;
|
||
let last_label = if pro { "Auto Erase" } else { "Pressure for Opacity" };
|
||
// Only the canvas Tab shortcut is cleared, using its public preference;
|
||
// no response is assigned focus programmatically.
|
||
let mut reached = false;
|
||
for _ in 0..80 {
|
||
harness.key_press(egui::Key::Tab);
|
||
harness.run_steps(3);
|
||
let label = harness.output().shapes.iter().find_map(|shape| match &shape.shape {
|
||
egui::Shape::Text(text) if text.galley.job.text.contains(last_label) => Some(text.visual_bounding_rect()),
|
||
_ => None,
|
||
});
|
||
reached = label.is_some_and(|label| {
|
||
harness.ctx.memory(|memory| memory.focused()).and_then(|id| harness.ctx.read_response(id)).is_some_and(|response| {
|
||
response.rect.intersects(label)
|
||
|| ((label.left() - response.rect.right()).abs() <= 12.0
|
||
&& response.rect.top() < label.bottom()
|
||
&& response.rect.bottom() > label.top())
|
||
})
|
||
});
|
||
if reached {
|
||
break;
|
||
}
|
||
}
|
||
assert!(reached, "{kind:?} at {scale}x: Tab must reach the final tool option");
|
||
harness.run_steps(20);
|
||
let (clip, label) = harness
|
||
.output()
|
||
.shapes
|
||
.iter()
|
||
.find_map(|shape| match &shape.shape {
|
||
egui::Shape::Text(text) if text.galley.job.text.contains(last_label) => Some((shape.clip_rect, text.visual_bounding_rect())),
|
||
_ => None,
|
||
})
|
||
.expect("the final option remains painted");
|
||
assert!(clip.contains_rect(label), "{kind:?} at {scale}x: Tab leaves the focused final option outside {clip:?}: {label:?}");
|
||
let previous = if pro { harness.state().ui.tool_options.pencil_auto_erase } else { harness.state().session.tools.brush.pressure_opacity };
|
||
harness.key_press(egui::Key::Space);
|
||
harness.run_steps(3);
|
||
let current = if pro { harness.state().ui.tool_options.pencil_auto_erase } else { harness.state().session.tools.brush.pressure_opacity };
|
||
assert_ne!(current, previous, "{kind:?} at {scale}x: Space must activate the revealed option");
|
||
assert!(harness.state().ui.panels.options_bar, "widget traversal must preserve the options bar");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_wide_crop_options_bar_does_not_scroll() {
|
||
for kind in crate::theme::ThemeKind::ALL {
|
||
for scale in [1.0, 1.5, 2.0] {
|
||
for mode in ["", crate::crop_size::WHR] {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
crate::theme::apply(&ctx, kind);
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||
if !Tokens::get(&ctx).pro {
|
||
continue;
|
||
}
|
||
app.ui.tool = Tool::Crop;
|
||
app.ui.tool_options.crop_ratio = mode.into();
|
||
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 120.0));
|
||
let clear_x = |output: &egui::FullOutput| {
|
||
output
|
||
.shapes
|
||
.iter()
|
||
.find_map(|s| match &s.shape {
|
||
egui::Shape::Text(t) if t.galley.job.text == "Clear" => Some(t.pos.x),
|
||
_ => None,
|
||
})
|
||
.unwrap()
|
||
};
|
||
let mut before = ctx.run_ui(input_at_scale(screen, scale), |ui| options_bar(&mut app, ui));
|
||
before.textures_delta.clear();
|
||
for _ in 0..3 {
|
||
before = ctx.run_ui(input_at_scale(screen, scale), |ui| options_bar(&mut app, ui));
|
||
before.textures_delta.clear();
|
||
}
|
||
let mut after = before.clone();
|
||
for _ in 0..6 {
|
||
after = ctx.run_ui(
|
||
egui::RawInput {
|
||
events: vec![
|
||
egui::Event::PointerMoved(pos2(800.0, 20.0)),
|
||
egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(-100.0, 0.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
},
|
||
],
|
||
..input_at_scale(screen, scale)
|
||
},
|
||
|ui| options_bar(&mut app, ui),
|
||
);
|
||
after.textures_delta.clear();
|
||
}
|
||
assert_eq!(clear_x(&before), clear_x(&after), "a roomy bar must not have horizontal overflow");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_crop_ratio_fields_show_three_decimals() {
|
||
// #6487: a 1.414 : 1 ratio reads 1.414 in the bar, not 1.4.
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| {
|
||
crate::theme::apply(&ctx, *k);
|
||
Tokens::get(&ctx).pro
|
||
}) else {
|
||
return;
|
||
};
|
||
crate::theme::apply(&ctx, kind);
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||
app.ui.tool = Tool::Crop;
|
||
app.ui.tool_options.crop_ratio = "1.414:1".into();
|
||
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 120.0));
|
||
let mut out = ctx.run_ui(input_at_scale(screen, 1.0), |ui| options_bar(&mut app, ui));
|
||
for _ in 0..3 {
|
||
out.textures_delta.clear();
|
||
out = ctx.run_ui(input_at_scale(screen, 1.0), |ui| options_bar(&mut app, ui));
|
||
}
|
||
out.textures_delta.clear();
|
||
let texts: Vec<&str> = out
|
||
.shapes
|
||
.iter()
|
||
.filter_map(|s| match &s.shape {
|
||
egui::Shape::Text(t) => Some(t.galley.job.text.as_str()),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert!(texts.contains(&"1.414"), "{texts:?}");
|
||
assert_eq!(app.ui.tool_options.crop_ratio, "1.414:1", "showing the ratio leaves it alone");
|
||
}
|
||
|
||
#[test]
|
||
fn the_narrow_options_bar_preference_shortens_the_bar() {
|
||
assert_eq!(options_bar_height(true, false), 36.0);
|
||
assert_eq!(options_bar_height(true, true), 30.0);
|
||
assert_eq!(options_bar_height(false, false), 42.0);
|
||
assert_eq!(options_bar_height(false, true), 34.0);
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
crate::theme::apply(&ctx, crate::theme::ThemeKind::ALL[0]);
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 900.0));
|
||
let run = |app: &mut PhotocraftApp| {
|
||
let mut top = 0.0;
|
||
let mut out = ctx.run_ui(input_at_scale(screen, 1.0), |ui| {
|
||
options_bar(app, ui);
|
||
top = ui.available_rect_before_wrap().top();
|
||
});
|
||
out.textures_delta.clear();
|
||
top
|
||
};
|
||
let normal = run(&mut app);
|
||
assert_eq!(normal, options_bar_height(Tokens::get(&ctx).pro, false));
|
||
app.session.execute("prefs.set", json!({"path": "workspace.enableNarrowOptionsBar", "value": true})).unwrap();
|
||
let narrow = run(&mut app);
|
||
assert_eq!(narrow, options_bar_height(Tokens::get(&ctx).pro, true));
|
||
assert!(narrow < normal, "narrow {narrow} must trim the bar under {normal}");
|
||
}
|
||
|
||
#[test]
|
||
fn narrow_tool_options_can_scroll_to_the_last_control() {
|
||
for kind in crate::theme::ThemeKind::ALL {
|
||
for width in [360.0, 800.0, 1280.0] {
|
||
for scale in [1.0, 1.5, 2.0] {
|
||
let ctx = egui::Context::default();
|
||
egui_extras::install_image_loaders(&ctx);
|
||
crate::theme::install_fonts(&ctx);
|
||
crate::theme::apply(&ctx, kind);
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||
let (tool, last_label) = if Tokens::get(&ctx).pro { (Tool::Pencil, "Auto Erase") } else { (Tool::Brush, "Pressure for Opacity") };
|
||
app.ui.tool = tool;
|
||
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 120.0));
|
||
let mut output = ctx.run_ui(input_at_scale(screen, scale), |ui| options_bar(&mut app, ui));
|
||
output.textures_delta.clear();
|
||
for frame in 1..=6 {
|
||
output = ctx.run_ui(
|
||
egui::RawInput {
|
||
time: Some(f64::from(frame) * 0.1),
|
||
events: vec![
|
||
// Studio: above the controls, clear of the Brush's Mode dropdown, which takes the wheel
|
||
// to step through blend modes (#4512).
|
||
egui::Event::PointerMoved(if Tokens::get(&ctx).pro { pos2(width * 0.5, 20.0) } else { pos2(width * 0.5, 2.0) }),
|
||
egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(-1500.0, 0.0),
|
||
modifiers: egui::Modifiers::NONE,
|
||
phase: egui::TouchPhase::Move,
|
||
},
|
||
],
|
||
..input_at_scale(screen, scale)
|
||
},
|
||
|ui| options_bar(&mut app, ui),
|
||
);
|
||
output.textures_delta.clear();
|
||
}
|
||
let (clip, label) = output
|
||
.shapes
|
||
.iter()
|
||
.find_map(|shape| match &shape.shape {
|
||
egui::Shape::Text(text) if text.galley.job.text.contains(last_label) => Some((shape.clip_rect, text)),
|
||
_ => None,
|
||
})
|
||
.expect("scrolling reaches the final tool control");
|
||
assert!(
|
||
clip.contains_rect(label.visual_bounding_rect()),
|
||
"{kind:?} at {width}: final control is clipped: {:?} / {clip:?}",
|
||
label.visual_bounding_rect()
|
||
);
|
||
assert!(label.visual_bounding_rect().right() <= width, "the final option stays inside the window");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod properties_target_regressions {
|
||
use super::*;
|
||
use egui_kittest::{Harness, kittest::Queryable};
|
||
|
||
#[test]
|
||
fn pending_transform_arithmetic_does_not_apply_to_another_document_with_the_same_layer_id() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||
app.run("file.new", json!({"width":200,"height":200,"background":"transparent"})).unwrap();
|
||
app.run("select.rect", json!({"x":10,"y":20,"width":100,"height":50})).unwrap();
|
||
app.run("edit.fill", json!({"color":"#ff0000"})).unwrap();
|
||
app.run("select.deselect", json!({})).unwrap();
|
||
let layer = app.session.active().unwrap().active_layer.unwrap();
|
||
let duplicate = (*app.session.active().unwrap().doc).clone();
|
||
let second = app.session.add_document(duplicate, None);
|
||
app.session.active_mut().unwrap().active_layer = Some(layer);
|
||
assert!(app.session.set_active(0));
|
||
let mut ready = false;
|
||
let mut h = Harness::builder().with_size(vec2(360.0, 650.0)).build_ui_state(
|
||
move |ui, app: &mut PhotocraftApp| {
|
||
if !ready {
|
||
PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium);
|
||
ready = true;
|
||
return;
|
||
}
|
||
properties_body(app, ui);
|
||
},
|
||
app,
|
||
);
|
||
h.run_steps(3);
|
||
h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("transform width").click();
|
||
h.run_steps(1);
|
||
for c in "50/2".chars() {
|
||
h.event(egui::Event::Text(c.to_string()));
|
||
h.run_steps(1);
|
||
}
|
||
let before = h.state().session.documents()[second].doc.layer(layer).unwrap().surface().unwrap().content_bounds();
|
||
assert!(h.state_mut().session.set_active(second));
|
||
h.run_steps(2);
|
||
h.key_press(egui::Key::Enter);
|
||
h.run_steps(2);
|
||
let after = h.state().session.active().unwrap().doc.layer(layer).unwrap().surface().unwrap().content_bounds();
|
||
assert_eq!(after, before, "pending width belongs to the old document/layer");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "history_panel_select_tests.rs"]
|
||
mod history_panel_select_tests;
|
||
#[cfg(test)]
|
||
mod layer_click_type_commit_tests {
|
||
use super::*;
|
||
use egui_kittest::{Harness, kittest::Queryable};
|
||
|
||
fn harness(kind: theme::ThemeKind) -> (Harness<'static, PhotocraftApp>, LayerId, LayerId) {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||
let text = LayerId(app.run("type.create", json!({"text": "Before", "size": 24, "x": 20, "y": 80})).unwrap()["layer"].as_u64().unwrap());
|
||
app.run("layer.setProps", json!({"layer": text.0, "name": "Edited type"})).unwrap();
|
||
let other = LayerId(app.run("layer.new.layer", json!({"name": "Other pixels"})).unwrap()["layer"].as_u64().unwrap());
|
||
app.run("layer.select", json!({"layer": text.0})).unwrap();
|
||
crate::type_tool::edit_active(&mut app).unwrap();
|
||
let session = app.ui.text_edit.as_ref().unwrap().session.clone();
|
||
app.run("type.edit", json!({"layer": text.0, "text": "Preserved edit", "coalesce": session})).unwrap();
|
||
let mut ready = false;
|
||
let mut h = Harness::builder().with_size(vec2(360.0, 650.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||
move |ui, app: &mut PhotocraftApp| {
|
||
if !ready {
|
||
PhotocraftApp::setup_context(ui.ctx(), kind);
|
||
ready = true;
|
||
return;
|
||
}
|
||
layers(app, ui);
|
||
},
|
||
app,
|
||
);
|
||
h.run_steps(4);
|
||
(h, text, other)
|
||
}
|
||
|
||
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, modifiers: egui::Modifiers) {
|
||
h.event(egui::Event::ModifiersChanged(modifiers));
|
||
h.hover_at(at);
|
||
h.run_steps(1);
|
||
for pressed in [true, false] {
|
||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers });
|
||
h.run_steps(1);
|
||
}
|
||
}
|
||
|
||
fn preserved(app: &PhotocraftApp, id: LayerId) {
|
||
let layer = app.session.active().unwrap().doc.layer(id).unwrap();
|
||
let LayerContent::Text(text) = &layer.content else { panic!("type must remain editable") };
|
||
assert_eq!(text.text, "Preserved edit");
|
||
}
|
||
|
||
#[test]
|
||
fn layer_name_and_thumbnail_clicks_commit_same_or_other_type_selection() {
|
||
for kind in theme::ThemeKind::ALL {
|
||
for same in [true, false] {
|
||
for thumbnail in [true, false] {
|
||
let (mut h, text, other) = harness(kind);
|
||
let label = if same { "Edited type" } else { "Other pixels" };
|
||
let row = h.get_by_label(label).rect();
|
||
let at = pos2(row.left() + if thumbnail { 43.0 } else { 120.0 }, row.center().y);
|
||
click(&mut h, at, egui::Modifiers::NONE);
|
||
assert!(h.state().ui.text_edit.is_none(), "{kind:?}, same={same}, thumbnail={thumbnail}");
|
||
assert_eq!(h.state().session.active().unwrap().active_layer, Some(if same { text } else { other }));
|
||
preserved(h.state(), text);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn layer_click_commits_without_breaking_modifier_selection_or_double_click_editing() {
|
||
for kind in theme::ThemeKind::ALL {
|
||
let (mut h, text, other) = harness(kind);
|
||
let row = h.get_by_label("Other pixels").rect();
|
||
click(&mut h, pos2(row.left() + 120.0, row.center().y), egui::Modifiers { command: true, ..Default::default() });
|
||
assert!(h.state().ui.text_edit.is_none());
|
||
let selected = h.state().session.active().unwrap().selected_layers();
|
||
assert!(selected.contains(&text) && selected.contains(&other));
|
||
preserved(h.state(), text);
|
||
|
||
let (mut h, text, _) = harness(kind);
|
||
let row = h.get_by_label("Edited type").rect();
|
||
let thumb = pos2(row.left() + 43.0, row.center().y);
|
||
click(&mut h, thumb, egui::Modifiers::NONE);
|
||
assert!(h.state().ui.text_edit.is_none());
|
||
click(&mut h, thumb, egui::Modifiers::NONE);
|
||
assert_eq!(h.state().ui.text_edit.as_ref().map(|ed| ed.layer), Some(text.0));
|
||
preserved(h.state(), text);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "offscreen visual evidence for #5584"]
|
||
fn layer_click_synthetic_visual_evidence() {
|
||
let (h, text, _) = harness(theme::ThemeKind::ProMedium);
|
||
let mut app = h.into_state();
|
||
app.ui.tool = Tool::Type;
|
||
let mut h = Harness::builder().with_size(vec2(1280.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).wgpu().build_eframe(move |cc| {
|
||
PhotocraftApp::setup_context(&cc.egui_ctx, theme::ThemeKind::ProMedium);
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
assert!(h.state().ui.text_edit.is_some());
|
||
h.render().unwrap().save("5584-before.png").unwrap();
|
||
let row = h.get_by_label("Other pixels").rect();
|
||
click(&mut h, pos2(row.left() + 120.0, row.center().y), egui::Modifiers::NONE);
|
||
assert!(h.state().ui.text_edit.is_none());
|
||
preserved(h.state(), text);
|
||
h.run_steps(4);
|
||
h.render().unwrap().save("5584-after.png").unwrap();
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod sampler_readout_tests {
|
||
use super::*;
|
||
|
||
/// Composited once per revision: repeated frames share the readouts; adding a sampler or an
|
||
/// edit recomputes them (#4528).
|
||
#[test]
|
||
fn sampler_readouts_are_reused_until_the_document_changes() {
|
||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||
app.run("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
|
||
app.run("view.colorSamplers.add", json!({"x": 4, "y": 4})).unwrap();
|
||
let a = sampler_readouts(&mut app);
|
||
assert_eq!(a.len(), 1);
|
||
assert_eq!(a[0]["rgb"], json!([255, 255, 255]));
|
||
assert!(std::sync::Arc::ptr_eq(&a, &sampler_readouts(&mut app)), "the next frame reuses them");
|
||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||
let b = sampler_readouts(&mut app);
|
||
assert!(!std::sync::Arc::ptr_eq(&a, &b));
|
||
assert_eq!(b[0]["rgb"], json!([255, 0, 0]), "an edit shows");
|
||
app.run("view.colorSamplers.add", json!({"x": 8, "y": 8})).unwrap();
|
||
assert_eq!(sampler_readouts(&mut app).len(), 2, "so does a new sampler");
|
||
}
|
||
}
|