Compare commits
2 Commits
v0.8.2
...
batch-foll
| Author | SHA1 | Date |
|---|---|---|
|
|
65bc1645b8 | |
|
|
1f2a74518a |
|
|
@ -2057,12 +2057,7 @@ mod transform_controls_tests {
|
|||
app.ui.tool = Tool::Move;
|
||||
app.ui.tool_options.move_show_transform = true;
|
||||
|
||||
let xf = ViewXform {
|
||||
rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)),
|
||||
zoom: 1.0,
|
||||
center: [100.0, 100.0],
|
||||
flip: false,
|
||||
};
|
||||
let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false };
|
||||
let r = transform_controls_rect(&app, &xf).expect("shape layers have transform bounds");
|
||||
assert!(transform_controls_hit(r, r.right_bottom()));
|
||||
|
||||
|
|
|
|||
|
|
@ -251,8 +251,9 @@ pub struct PhotocraftApp {
|
|||
/// The session clipboard currently holds an image imported from the OS clipboard: it has no
|
||||
/// original position, so Paste centres it (see `menus` "edit.paste").
|
||||
pub(crate) clip_external: bool,
|
||||
/// Last `ctx` time (s) the OS clipboard was polled; a read every frame is too costly.
|
||||
last_clip_poll: f64,
|
||||
/// The OS clipboard was already read for the paste in flight (Edit › Paste positions it first),
|
||||
/// so `run` doesn't read it a second time.
|
||||
pub(crate) clip_read_for_paste: bool,
|
||||
/// Pointer position over the canvas (document px), for the Info panel and status bar.
|
||||
pub(crate) hover_doc: Option<[f64; 2]>,
|
||||
/// Info panel sample cache: ((x, y, revision), composite RGBA).
|
||||
|
|
@ -324,7 +325,7 @@ impl PhotocraftApp {
|
|||
info_sample: None,
|
||||
os_clip_sig: None,
|
||||
clip_external: false,
|
||||
last_clip_poll: f64::NEG_INFINITY,
|
||||
clip_read_for_paste: false,
|
||||
transform_preview: None,
|
||||
style_preview: None,
|
||||
distort: Default::default(),
|
||||
|
|
@ -365,6 +366,7 @@ impl PhotocraftApp {
|
|||
|
||||
/// Run an engine command, reporting errors in the status bar.
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
let clip_read = std::mem::take(&mut self.clip_read_for_paste);
|
||||
if self.automation_input
|
||||
&& let Some(authorize) = self.services.automation_command.as_ref()
|
||||
{
|
||||
|
|
@ -375,8 +377,17 @@ impl PhotocraftApp {
|
|||
self.session.edit_prefs(|prefs| prefs.script_events.enabled = false);
|
||||
}
|
||||
let t0 = gpu_canvas::now_ms();
|
||||
// The OS clipboard is read only on an explicit paste, never in the background (privacy, CPU).
|
||||
if matches!(id, "edit.paste" | "edit.pasteSpecial.pasteInPlace") {
|
||||
self.import_os_clipboard();
|
||||
if !clip_read {
|
||||
self.import_os_clipboard();
|
||||
}
|
||||
if self.session.clipboard.is_none() && self.session.active().is_some() && self.services.clipboard_get_image.is_some() {
|
||||
// Enabled on the strength of the OS clipboard, which held no image: a quiet no-op.
|
||||
self.ui.status = "Nothing to paste: the clipboard holds no image".into();
|
||||
self.ui.status_error = false;
|
||||
return Ok(serde_json::json!({"pasted": false}));
|
||||
}
|
||||
}
|
||||
let r = self.session.execute(id, params).map_err(|e| e.to_string());
|
||||
if r.is_ok() && matches!(id, "edit.copy" | "edit.cut" | "edit.copyMerged") {
|
||||
|
|
@ -658,12 +669,6 @@ impl eframe::App for PhotocraftApp {
|
|||
self.issue_screenshots(ctx);
|
||||
prefs_ui::tick(self, ctx);
|
||||
discard_ui::guard_window_close(self, ctx);
|
||||
// Mirror the OS clipboard a few times a second so Edit > Paste greys correctly and Ctrl+V
|
||||
// sees images copied in other apps (a clipboard read every frame is too costly).
|
||||
if now - self.last_clip_poll >= 0.25 {
|
||||
self.last_clip_poll = now;
|
||||
self.import_os_clipboard();
|
||||
}
|
||||
shortcuts::handle(self, ctx);
|
||||
let arrived: Vec<(String, Vec<u8>)> =
|
||||
self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
|
||||
|
|
@ -1024,7 +1029,6 @@ mod clipboard_tests {
|
|||
|
||||
#[test]
|
||||
fn os_clipboard_bridge() {
|
||||
type OsClip = Arc<Mutex<Option<(u32, u32, Vec<u8>)>>>;
|
||||
let os: OsClip = Arc::default();
|
||||
let (a, b) = (os.clone(), os.clone());
|
||||
let services = Services {
|
||||
|
|
@ -1052,25 +1056,72 @@ mod clipboard_tests {
|
|||
assert_eq!(surf.content_bounds().width(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_clipboard_enables_and_centres_paste() {
|
||||
// The periodic clipboard poll in `logic` (or the ⌘V handler) imports the OS image into
|
||||
// the session clipboard before the enabled check and the menu's positioning run.
|
||||
type OsClip = Arc<Mutex<Option<(u32, u32, Vec<u8>)>>>;
|
||||
let os: OsClip = Arc::default();
|
||||
let b = os.clone();
|
||||
let services = Services { clipboard_get_image: Some(Box::new(move || b.lock().unwrap().clone())), ..Default::default() };
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
type OsClip = Arc<Mutex<Option<(u32, u32, Vec<u8>)>>>;
|
||||
|
||||
/// An app whose OS clipboard is `os`, counting every read in `reads`.
|
||||
fn app_with_os_clipboard(os: &OsClip, reads: &Arc<std::sync::atomic::AtomicUsize>) -> PhotocraftApp {
|
||||
let (b, n) = (os.clone(), reads.clone());
|
||||
let get = move || {
|
||||
n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
b.lock().unwrap().clone()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() });
|
||||
app.session.execute("file.new", serde_json::json!({"width": 64, "height": 64})).unwrap();
|
||||
app.sync_views();
|
||||
// An image copied in another app: Paste greys until the import brings it in.
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_clipboard_enables_and_centres_paste() {
|
||||
// Paste is enabled from the clipboard service alone (no read); the explicit paste imports
|
||||
// the OS image, once, and centres it like Photoshop does for foreign clipboard content.
|
||||
let (os, reads) = (OsClip::default(), Arc::default());
|
||||
let mut app = app_with_os_clipboard(&os, &reads);
|
||||
*os.lock().unwrap() = Some((4, 4, [255u8, 0, 0, 255].repeat(16)));
|
||||
assert!(!crate::menus::is_enabled(&app, "edit.paste"));
|
||||
assert!(app.import_os_clipboard());
|
||||
assert!(app.session.clipboard.is_none());
|
||||
assert!(crate::menus::is_enabled(&app, "edit.paste"));
|
||||
// Invoking Paste after the import still treats the image as external and centres it
|
||||
// instead of pasting at (0,0) like an in-app copy.
|
||||
assert!(crate::menus::is_enabled(&app, "edit.pasteSpecial.pasteInPlace"));
|
||||
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0, "enablement never reads the clipboard");
|
||||
let r = crate::menus::invoke(&mut app, &egui::Context::default(), "edit.paste", serde_json::json!({})).unwrap();
|
||||
assert_ne!(r["offset"], serde_json::json!([0, 0]), "external image is centred: {r}");
|
||||
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 1, "one read per explicit paste");
|
||||
// Without a clipboard service Paste greys until something is copied in the app.
|
||||
let mut plain = PhotocraftApp::new(Session::new(), Services::default());
|
||||
plain.session.execute("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap();
|
||||
assert!(!crate::menus::is_enabled(&plain, "edit.paste"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_an_empty_or_non_image_os_clipboard_is_a_quiet_no_op() {
|
||||
let (os, reads) = (OsClip::default(), Arc::default());
|
||||
let mut app = app_with_os_clipboard(&os, &reads);
|
||||
let layers = app.session.active().unwrap().doc.layers.len();
|
||||
let r = crate::menus::invoke(&mut app, &egui::Context::default(), "edit.paste", serde_json::json!({})).unwrap();
|
||||
assert_eq!(r["pasted"], serde_json::json!(false));
|
||||
assert_eq!(app.run("edit.pasteSpecial.pasteInPlace", serde_json::json!({})).unwrap()["pasted"], serde_json::json!(false));
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers);
|
||||
assert!(!app.ui.status_error && !app.ui.status.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_frames_never_read_the_os_clipboard() {
|
||||
let (os, reads) = (OsClip::default(), Arc::<std::sync::atomic::AtomicUsize>::default());
|
||||
*os.lock().unwrap() = Some((4, 4, [255u8, 0, 0, 255].repeat(16)));
|
||||
let (b, n) = (os.clone(), reads.clone());
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let get = move || {
|
||||
n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
b.lock().unwrap().clone()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() });
|
||||
app.session.execute("file.new", serde_json::json!({"width": 64, "height": 64})).unwrap();
|
||||
app
|
||||
});
|
||||
for _ in 0..20 {
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0, "the OS clipboard is read only on an explicit paste");
|
||||
assert!(h.state().session.clipboard.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -759,30 +759,23 @@ mod tests {
|
|||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_layer();
|
||||
open(&mut app, &ctx).unwrap();
|
||||
for ev in [
|
||||
ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 },
|
||||
ToolEvent::Move { x: 52.0, y: 40.0, pressure: 1.0 },
|
||||
ToolEvent::Up { x: 52.0, y: 40.0 },
|
||||
] {
|
||||
for ev in [ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, ToolEvent::Move { x: 52.0, y: 40.0, pressure: 1.0 }, ToolEvent::Up { x: 52.0, y: 40.0 }]
|
||||
{
|
||||
pointer(&mut app, ev, egui::Modifiers::NONE);
|
||||
}
|
||||
assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 1);
|
||||
|
||||
let raw = egui::RawInput {
|
||||
events: vec![egui::Event::Key {
|
||||
key: egui::Key::Z,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::COMMAND,
|
||||
}],
|
||||
events: vec![egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }],
|
||||
..Default::default()
|
||||
};
|
||||
let _ = ctx.run(raw, |ctx| {
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let ctx = ui.ctx();
|
||||
ctx.memory_mut(|m| m.request_focus(egui::Id::new("liquify-slider-focus")));
|
||||
assert!(ctx.egui_wants_keyboard_input());
|
||||
crate::shortcuts::handle(&mut app, ctx);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -314,6 +314,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
|||
// Photoshop: paste in place when the copied area is visible, else centred in the view;
|
||||
// images from other apps are always centred.
|
||||
app.import_os_clipboard();
|
||||
app.clip_read_for_paste = true;
|
||||
let external = app.clip_external;
|
||||
let visible = !external
|
||||
&& app.session.active_index().zip(app.session.clipboard.as_ref()).is_some_and(|(i, clip)| {
|
||||
|
|
@ -442,6 +443,11 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool {
|
|||
// "Custom…" is the full Proof Setup dialog.
|
||||
"view.proofSetup.custom" => app.session.active().is_some(),
|
||||
"view.rulers" | "view.show.grid" | "view.show.guides" | "view.snap" | "view.lockGuides" => true,
|
||||
// An image copied in another app can only be seen by reading the OS clipboard, which happens
|
||||
// on an explicit paste: with a clipboard service, Paste stays enabled whenever a document is open.
|
||||
"edit.paste" | "edit.pasteSpecial.pasteInPlace" => {
|
||||
app.session.is_enabled(id) || (app.services.clipboard_get_image.is_some() && app.session.active().is_some())
|
||||
}
|
||||
"select.selectAndMask" => app.session.is_enabled("select.refineEdge"),
|
||||
"select.transformSelection" => app.ui.transform.is_none() && app.session.is_enabled("select.transformSelection"),
|
||||
i if (i.starts_with("view.zoom") || i == "view.fitOnScreen" || i == "view.actualPixels") || i == "window.newWindowForDocument" => {
|
||||
|
|
@ -741,10 +747,7 @@ fn render_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &
|
|||
// Menu popups can be taller than the viewport. Keep each level on-screen and scroll it,
|
||||
// matching egui's own bounded-popup pattern used by ComboBox.
|
||||
let max_height = (ui.ctx().content_rect().height() - 32.0).max(120.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(("menu-level", depth))
|
||||
.max_height(max_height)
|
||||
.show(ui, |ui| render_level_rows(ui, items, depth, clicked));
|
||||
egui::ScrollArea::vertical().id_salt(("menu-level", depth)).max_height(max_height).show(ui, |ui| render_level_rows(ui, items, depth, clicked));
|
||||
}
|
||||
|
||||
fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>) {
|
||||
|
|
@ -860,26 +863,27 @@ mod tests {
|
|||
let ctx = egui::Context::default();
|
||||
let mut title: Option<egui::Response> = None;
|
||||
let p = egui::pos2(90.0, 12.0);
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = ctx.run(raw, |ctx| {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(80.0, 24.0), egui::Sense::hover());
|
||||
let shifted = rect.translate(egui::vec2(p.x - rect.center().x, p.y - rect.center().y));
|
||||
let response = ui.interact(shifted, egui::Id::new("menu-title-test"), egui::Sense::hover());
|
||||
title = Some(response);
|
||||
});
|
||||
egui::Area::new(egui::Id::new("overlapping-popup-test"))
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(p - egui::vec2(10.0, 10.0))
|
||||
.show(ctx, |ui| {
|
||||
let mut reaches = true;
|
||||
// The popup area becomes hit-testable once egui has laid it out, so draw a few frames.
|
||||
for _ in 0..3 {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let ctx = &ui.ctx().clone();
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(80.0, 24.0), egui::Sense::hover());
|
||||
let shifted = rect.translate(egui::vec2(p.x - rect.center().x, p.y - rect.center().y));
|
||||
let response = ui.interact(shifted, egui::Id::new("menu-title-test"), egui::Sense::hover());
|
||||
title = Some(response);
|
||||
});
|
||||
egui::Area::new(egui::Id::new("overlapping-popup-test")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(ctx, |ui| {
|
||||
ui.allocate_space(egui::vec2(20.0, 20.0));
|
||||
});
|
||||
let response = title.as_ref().unwrap();
|
||||
assert!(!pointer_reaches_title(ctx, response, p));
|
||||
});
|
||||
let response = title.as_ref().unwrap();
|
||||
reaches = pointer_reaches_title(ctx, response, p);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
assert!(!reaches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -222,11 +222,6 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
continue;
|
||||
}
|
||||
if consume(ctx, &sc) {
|
||||
// ⌘V can arrive with an external image still on the OS clipboard (the periodic import
|
||||
// hasn't run yet): pull it in before deciding whether Paste is enabled.
|
||||
if matches!(id.as_str(), "edit.paste" | "edit.pasteSpecial.pasteInPlace") {
|
||||
app.import_os_clipboard();
|
||||
}
|
||||
if crate::menus::is_enabled(app, &id) {
|
||||
let r = if crate::adjust_dialog::has_dialog(&id) {
|
||||
crate::adjust_dialog::open(app, &id);
|
||||
|
|
|
|||
|
|
@ -312,7 +312,12 @@ fn cjk_font_candidates() -> Vec<(std::path::PathBuf, u32)> {
|
|||
("/System/Library/Fonts/Hiragino Sans GB.ttc", 0),
|
||||
]
|
||||
} else if cfg!(target_os = "windows") {
|
||||
&[("C:\\Windows\\Fonts\\YuGothM.ttc", 0), ("C:\\Windows\\Fonts\\YuGothR.ttc", 0), ("C:\\Windows\\Fonts\\meiryo.ttc", 0), ("C:\\Windows\\Fonts\\msgothic.ttc", 0)]
|
||||
&[
|
||||
("C:\\Windows\\Fonts\\YuGothM.ttc", 0),
|
||||
("C:\\Windows\\Fonts\\YuGothR.ttc", 0),
|
||||
("C:\\Windows\\Fonts\\meiryo.ttc", 0),
|
||||
("C:\\Windows\\Fonts\\msgothic.ttc", 0),
|
||||
]
|
||||
} else {
|
||||
&[
|
||||
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 0),
|
||||
|
|
|
|||
Loading…
Reference in New Issue