Browse / github.com/storytold/photocraft / patch
feat(ui): add a Reset button to the Crop tool's options bar (#9730)
Crop tool does not display Reset, Cancel, and Commit buttons <img width="2560" height="1056" alt="Image" src="https://github.com/user-attachments/assets/6b9fad43-4e11-4f91-95ec-1c26903d5e7b" /> <img width="2560" height="1056" alt="Image" src="https://github.com/user-attachments/assets/e9870e08-d96f-4c15-9738-1b10e317d5e5" /> Steps to reproduce Open an image. Select the Crop tool using the toolbar or C. Drag any edge of the crop box. Expected behavior Three buttons should appear at the top: Reset - Reset the crop box. Cancel - Cancel the current crop. Commit - Apply the current crop. Actual behavior None of the three buttons appear after modi
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- x-labb (imported from pull request #9739, open)
- Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035- Size
- +113 −8 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
bde439e4aaa85f61a28aa4c0
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/panels/options_bar.rs crates/ui-egui/src/tools/crop_ui.rs crates/ui-egui/src/tools/crop_ui/tests.rs
@@ -534,6 +534,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if icons::button(ui, "ban", 26.0, false, tl!("Cancel current crop operation (Esc)")).clicked() { crate::crop_ui::cancel(app); }+ // Resets the box to its first frame; nothing to reset until it is changed.+ let changed = crate::crop_ui::modified(app);+ if ui.add_enabled_ui(changed, |ui| icons::button(ui, "undo-2", 26.0, false, tl!("Reset"))).inner.clicked() {+ crate::crop_ui::reset(app);+ } }); } // Every theme gets the font, style and size controls (#1387), as with Auto-Select (#1275).@@ -347,6 +347,15 @@ pub fn ensure_frame(app: &mut PhotocraftApp) { crate::crop_mode::sync(app, None); } +/// The frame a new crop starts from: the selection's bounds, or the whole canvas.+fn default_rect(app: &PhotocraftApp) -> Option<[f64; 4]> {+ let st = app.session.active()?;+ let canvas = st.doc.bounds();+ // Computed once per new frame, not per frame drawn.+ let r = st.doc.selection.as_ref().map(|s| s.content_bounds().intersect(&canvas)).filter(|b| !b.is_empty()).unwrap_or(canvas);+ (!r.is_empty()).then(|| [f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)])+}+ fn frame(app: &mut PhotocraftApp) { if app.ui.tool != Tool::Crop { app.crop.editing = false;@@ -377,14 +386,8 @@ fn frame(app: &mut PhotocraftApp) { if app.ui.crop_rect.is_some() || app.crop.drag.is_some() { return; }- let Some(st) = app.session.active() else { return };- let canvas = st.doc.bounds();- // Computed once per new frame, not per frame drawn.- let r = st.doc.selection.as_ref().map(|s| s.content_bounds().intersect(&canvas)).filter(|b| !b.is_empty()).unwrap_or(canvas);- if r.is_empty() {- return;- }- app.ui.crop_rect = Some([f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]);+ let Some(r) = default_rect(app) else { return };+ app.ui.crop_rect = Some(r); app.crop.default_frame = true; app.crop.frame_for = key; app.crop.editing = false;@@ -433,6 +436,37 @@ pub fn cancel(app: &mut PhotocraftApp) { crate::crop_straighten::reset(app); } +/// The frame was drawn, moved, resized or turned since it was last framed by default, so+/// [`reset`] has something to undo.+pub fn modified(app: &PhotocraftApp) -> bool {+ app.ui.tool == Tool::Crop && app.ui.crop_rect.is_some() && app.crop.drag.is_none() && (!app.crop.default_frame || app.ui.crop_angle != 0.0)+}++/// Reset the crop box (the options bar's Reset): the default frame again, upright, with the+/// straighten line gone. The tool stays up and the old frame is a box Undo step, unlike+/// [`cancel`], which drops the box's history.+pub fn reset(app: &mut PhotocraftApp) {+ if !modified(app) {+ return;+ }+ let Some(rect) = default_rect(app) else { return };+ let before = step_of(app);+ app.ui.crop_rect = Some(rect);+ app.ui.crop_angle = 0.0;+ app.crop.default_frame = true;+ app.crop.frame_for = app.session.active_index().zip(app.session.active()).map(|(i, st)| (i, st.doc.size.width, st.doc.size.height));+ app.crop.editing = false;+ app.crop.press_step = None;+ claim(app);+ crate::crop_straighten::reset(app);+ if step_of(app) != before {+ app.crop.undo.push(before);+ app.crop.redo.clear();+ }+ crate::crop_mode::sync_frame(app);+ crate::crop_mode::auto_center(app);+}+ /// Drop the box's own Undo/Redo (leaving the tool, cancelling or committing a crop). pub fn clear_history(app: &mut PhotocraftApp) { app.crop.undo.clear();@@ -109,6 +109,72 @@ fn picking_the_tool_frames_the_canvas_or_the_selection() { assert_eq!(app.ui.crop_rect, None); } +/// #9730: the options bar's Reset puts the box back to its first frame, as an Undo step.+#[test]+fn reset_frames_the_canvas_again_and_can_be_undone() {+ let mut app = app(SampleType::U8);+ ensure_frame(&mut app);+ assert!(!modified(&app), "the untouched default frame has nothing to reset");+ reset(&mut app);+ assert!(app.crop.undo.is_empty(), "so resetting it adds no step");++ drag(&mut app, &[[50.0, 20.0], [80.0, 40.0], [120.0, 70.0]], NONE);+ assert_eq!(app.ui.crop_rect, Some([50.0, 20.0, 120.0, 70.0]));+ assert!(modified(&app));+ reset(&mut app);+ assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0]));+ assert!(!modified(&app) && app.crop.default_frame);+ assert_eq!(app.session.active().unwrap().doc.size, Size::new(200, 100), "nothing is cropped");++ // The box's own Undo brings the drawn frame back, and Redo resets it again.+ assert!(intercept(&mut app, "edit.undo").is_some());+ assert_eq!(app.ui.crop_rect, Some([50.0, 20.0, 120.0, 70.0]));+ assert!(intercept(&mut app, "edit.redo").is_some());+ assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0]));++ // A turned default frame is a changed one too: Reset makes it upright.+ app.ui.crop_angle = 0.3;+ assert!(modified(&app));+ reset(&mut app);+ assert_eq!((app.ui.crop_angle, modified(&app)), (0.0, false));++ // With a selection, the first frame is its bounds.+ drag(&mut app, &[[50.0, 20.0], [80.0, 40.0], [120.0, 70.0]], NONE);+ app.run("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();+ reset(&mut app);+ assert_eq!(app.ui.crop_rect, Some([10.0, 10.0, 40.0, 30.0]));++ // Another tool has no box to reset.+ drag(&mut app, &[[60.0, 30.0], [100.0, 60.0]], NONE);+ app.ui.tool = Tool::Brush;+ assert!(!modified(&app));+}++/// #9730: the Crop tool's options bar has a Reset button beside Cancel and Commit, live once+/// the frame was changed.+#[test]+fn the_options_bar_reset_button_resets_a_changed_frame() {+ use egui_kittest::kittest::{NodeT, Queryable};+ let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());+ app.run("file.new", json!({"width": 200, "height": 100})).unwrap();+ let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {+ PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());+ app+ });+ h.state_mut().ui.tool = Tool::Crop;+ h.run_steps(8);+ let button = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.get_by_role_and_label(egui::accesskit::Role::Button, "Reset").accesskit_node().is_disabled();+ assert!(button(&h), "an untouched frame has nothing to reset");+ h.state_mut().ui.crop_rect = Some([50.0, 20.0, 120.0, 70.0]);+ h.state_mut().crop.default_frame = false;+ h.run_steps(4);+ assert!(!button(&h), "a changed frame can be reset");+ h.get_by_role_and_label(egui::accesskit::Role::Button, "Reset").click();+ h.run_steps(4);+ assert_eq!(h.state().ui.crop_rect, Some([0.0, 0.0, 200.0, 100.0]));+ assert!(button(&h));+}+ #[test] fn enter_on_the_selection_frame_crops_to_it_at_8_and_16_bit() { // #1789: selection, C, ↵ did nothing, as the untouched frame was always treated as a no-op.