Commit c7c0317c authored by Jim Nelson's avatar Jim Nelson

#1417: Fixes problem of dragging hundreds of photos by using XDS instead of...

#1417: Fixes problem of dragging hundreds of photos by using XDS instead of standard GTK drag-and-drop when 
dealing with Nautilus.  Because of this change, photo export is remarkably simpler.  Also, refactored the DnD 
export code out to a separate class, and the batch export code to a separate function.  Also, a case where 
dropping 5000 photos onto a tag being excessively slow is fixed.
parent 9201324a
......@@ -442,6 +442,21 @@ public abstract class AppWindow : PageWindow {
return yes;
}
public static Gtk.ResponseType yes_no_cancel_question(string message, string? title = null,
Gtk.Window? parent = null) {
Gtk.MessageDialog dialog = new Gtk.MessageDialog((parent != null) ? parent : get_instance(),
Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, "%s", message);
dialog.title = (title != null) ? title : Resources.APP_TITLE;
dialog.add_buttons(_("_No"), Gtk.ResponseType.NO, _("_Yes"), Gtk.ResponseType.YES,
_("_Cancel"), Gtk.ResponseType.CANCEL);
int response = dialog.run();
dialog.destroy();
return (Gtk.ResponseType) response;
}
public static void database_error(DatabaseError err) {
string msg = _("A fatal error occurred when accessing Shotwell's library. Shotwell cannot continue.\n\n%s").printf(
err.message);
......
......@@ -659,9 +659,7 @@ public class CheckerboardLayout : Gtk.DrawingArea {
}
private void on_geometries_altered() {
// don't schedule as this indicates all have resized and are ready for reflow
if (reflow("on_geometries_altered"))
queue_draw();
schedule_background_reflow("on_geometries_altered");
}
private void schedule_background_reflow(string caller) {
......
......@@ -37,15 +37,12 @@ public abstract class CollectionPage : CheckerboardPage {
private Gtk.ToolButton rotate_button = null;
private Gtk.ToolButton enhance_button = null;
private Gtk.ToolButton slideshow_button = null;
private PhotoDragAndDropHandler dnd_handler = null;
#if !NO_PUBLISHING
private Gtk.ToolButton publish_button = null;
#endif
private int scale = Thumbnail.DEFAULT_SCALE;
private Gee.ArrayList<File> drag_files = new Gee.ArrayList<File>();
private Gee.ArrayList<LibraryPhoto> drag_photos = new Gee.ArrayList<LibraryPhoto>();
private int drag_failed_item_count = 0;
public CollectionPage(string page_name, string? ui_filename = null,
Gtk.ActionEntry[]? child_actions = null) {
......@@ -175,8 +172,9 @@ public abstract class CollectionPage : CheckerboardPage {
set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
show_all();
enable_drag_source(Gdk.DragAction.COPY);
// enable photo drag-and-drop on our ViewCollection
dnd_handler = new PhotoDragAndDropHandler(this);
}
private Gtk.ActionEntry[] create_actions() {
......@@ -557,104 +555,6 @@ public abstract class CollectionPage : CheckerboardPage {
return null;
}
private override void drag_begin(Gdk.DragContext context) {
if (get_view().get_selected_count() == 0)
return;
drag_files.clear();
drag_photos.clear();
// because drag_data_get may be called multiple times in a single drag, prepare all the exported
// files first
Gdk.Pixbuf icon = null;
drag_failed_item_count = 0;
foreach (DataView view in get_view().get_selected()) {
LibraryPhoto photo = ((Thumbnail) view).get_photo();
drag_photos.add(photo);
File file = null;
try {
file = photo.generate_exportable();
drag_files.add(file);
} catch (Error err) {
drag_failed_item_count++;
warning("%s", err.message);
}
try {
// set up icon using the first photo
if (icon == null) {
icon = photo.get_preview_pixbuf(Scaling.for_best_fit(
AppWindow.DND_ICON_SCALE, true));
}
} catch (Error err) {
warning("%s", err.message);
}
if (file != null)
debug("Prepared %s for export", file.get_path());
}
if (icon != null)
Gtk.drag_source_set_icon_pixbuf(get_event_source(), icon);
}
private override void drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data,
uint target_type, uint time) {
if (target_type == TargetType.URI_LIST) {
if (drag_files.size == 0)
return;
// prepare list of uris
string[] uris = new string[drag_files.size];
int ctr = 0;
foreach (File file in drag_files)
uris[ctr++] = file.get_uri();
selection_data.set_uris(uris);
} else {
assert(target_type == TargetType.PHOTO_LIST);
if (drag_photos.size == 0)
return;
selection_data.set(Gdk.Atom.intern_static_string("PhotoID"), (int) sizeof(int64),
serialize_photo_ids(drag_photos));
}
}
private override void drag_end(Gdk.DragContext context) {
drag_files.clear();
drag_photos.clear();
if (drag_failed_item_count > 0) {
Idle.add(report_drag_failed);
}
}
private bool report_drag_failed() {
string error_string = (ngettext("A photo source file is missing.",
"%d photo source files missing.", drag_failed_item_count)).printf(
drag_failed_item_count);
AppWindow.error_message(error_string);
drag_failed_item_count = 0;
return false;
}
private override bool source_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) {
debug("Drag failed: %d", (int) drag_result);
drag_files.clear();
drag_photos.clear();
foreach (DataView view in get_view().get_selected())
((Thumbnail) view).get_photo().export_failed();
return false;
}
protected override bool on_app_key_pressed(Gdk.EventKey event) {
bool handled = true;
......@@ -714,28 +614,29 @@ public abstract class CollectionPage : CheckerboardPage {
}
private void on_export() {
Gee.ArrayList<LibraryPhoto> export_list = new Gee.ArrayList<LibraryPhoto>();
foreach (DataView view in get_view().get_selected())
export_list.add(((Thumbnail) view).get_photo());
Gee.Collection<LibraryPhoto> export_list =
(Gee.Collection<LibraryPhoto>) get_view().get_selected_sources();
if (export_list.size == 0)
return;
ExportDialog export_dialog = null;
if (export_list.size == 1)
export_dialog = new ExportDialog(_("Export Photo"));
else
export_dialog = new ExportDialog(_("Export Photos"));
string title = ngettext("Export Photo", "Export Photos", export_list.size);
ExportDialog export_dialog = new ExportDialog(title);
int scale;
ScaleConstraint constraint;
Jpeg.Quality quality;
if (!export_dialog.execute(out scale, out constraint, out quality))
return;
// handle the single-photo case
Scaling scaling = Scaling.for_constraint(constraint, scale, false);
// handle the single-photo case, which is treated like a Save As file operation
if (export_list.size == 1) {
LibraryPhoto photo = export_list.get(0);
LibraryPhoto photo = null;
foreach (LibraryPhoto p in export_list) {
photo = p;
break;
}
File save_as = ExportUI.choose_file(photo.get_file());
if (save_as == null)
......@@ -744,7 +645,7 @@ public abstract class CollectionPage : CheckerboardPage {
spin_event_loop();
try {
photo.export(save_as, scale, constraint, quality);
photo.export(save_as, scaling, quality);
} catch (Error err) {
AppWindow.error_message(_("Unable to export photo %s: %s").printf(
photo.get_file().get_path(), err.message));
......@@ -758,45 +659,7 @@ public abstract class CollectionPage : CheckerboardPage {
if (export_dir == null)
return;
AppWindow.get_instance().set_busy_cursor();
int count = 0;
int total = export_list.size;
Cancellable cancellable = null;
ProgressDialog progress = null;
if (total >= MIN_OPS_FOR_PROGRESS_WINDOW) {
cancellable = new Cancellable();
progress = new ProgressDialog(AppWindow.get_instance(), _("Exporting..."), cancellable);
}
foreach (LibraryPhoto photo in export_list) {
File save_as = export_dir.get_child(photo.get_file().get_basename());
if (save_as.query_exists(null)) {
if (!ExportUI.query_overwrite(save_as))
continue;
}
try {
photo.export(save_as, scale, constraint, quality);
} catch (Error err) {
AppWindow.error_message(_("Unable to export photo %s: %s").printf(save_as.get_path(),
err.message));
}
if (progress != null) {
progress.set_fraction(++count, total);
spin_event_loop();
if (cancellable.is_cancelled())
break;
}
}
if (progress != null)
progress.close();
AppWindow.get_instance().set_normal_cursor();
ExportUI.export_photos(export_dir, export_list);
}
private void on_edit_menu() {
......@@ -983,12 +846,14 @@ public abstract class CollectionPage : CheckerboardPage {
LibraryPhoto photo = (LibraryPhoto) get_view().get_selected_at(0).get_source();
// get all tags for this photo to display in the dialog
Gee.SortedSet<Tag> tags = Tag.get_sorted_tags(photo);
Gee.SortedSet<Tag>? tags = Tag.global.fetch_sorted_for_photo(photo);
// make a list of their names for the dialog
string[] tag_names = new string[0];
foreach (Tag tag in tags)
tag_names += tag.get_name();
if (tags != null) {
foreach (Tag tag in tags)
tag_names += tag.get_name();
}
ModifyTagsDialog dialog = new ModifyTagsDialog(tag_names);
tag_names = dialog.execute();
......
......@@ -1056,14 +1056,16 @@ public class ModifyTagsCommand : SingleDataSourceCommand {
this.photo = photo;
// Remove any tag that's in the original list but not the new one
Gee.List<Tag> original_tags = Tag.get_tags(photo);
foreach (Tag tag in original_tags) {
if (!new_tag_list.contains(tag)) {
SourceProxy proxy = tag.get_proxy();
to_remove.add(proxy);
proxy.broken += on_proxy_broken;
}
Gee.List<Tag>? original_tags = Tag.global.fetch_for_photo(photo);
if (original_tags != null) {
foreach (Tag tag in original_tags) {
if (!new_tag_list.contains(tag)) {
SourceProxy proxy = tag.get_proxy();
to_remove.add(proxy);
proxy.broken += on_proxy_broken;
}
}
}
// Add any tag that's in the new list but not the original
......
......@@ -1701,10 +1701,7 @@ public class ViewCollection : DataCollection {
}
public Gee.Collection<DataSource> get_sources() {
Gee.Collection<DataSource> sources = new Gee.ArrayList<DataSource>();
sources.add_all(source_map.keys);
return sources;
return source_map.keys.read_only_view;
}
public Gee.Collection<DataSource> get_selected_sources() {
......
......@@ -53,8 +53,81 @@ public bool query_overwrite(File file) {
return AppWindow.yes_no_question(_("%s already exists. Overwrite?").printf(file.get_path()),
_("Export Photos"));
}
public void export_photos(File folder, Gee.Collection<TransformablePhoto> photos) {
ProgressDialog dialog = null;
Cancellable cancellable = null;
if (photos.size > 2) {
cancellable = new Cancellable();
dialog = new ProgressDialog(AppWindow.get_instance(), _("Exporting..."), cancellable);
}
AppWindow.get_instance().set_busy_cursor();
int count = 0;
int failed = 0;
foreach (TransformablePhoto photo in photos) {
string basename = photo.get_file().get_basename();
File dest = folder.get_child(basename);
if (dest.query_exists(null)) {
string question = _("File %s already exists. Overwrite?").printf(basename);
Gtk.ResponseType response = AppWindow.yes_no_cancel_question(question, _("Export Photos"));
bool skip = false;
switch (response) {
case Gtk.ResponseType.YES:
// fall through
break;
case Gtk.ResponseType.CANCEL:
cancellable.cancel();
break;
case Gtk.ResponseType.NO:
default:
if (dialog != null) {
dialog.set_fraction(++count, photos.size);
spin_event_loop();
}
skip = true;
break;
}
if (skip)
continue;
}
if (cancellable != null && cancellable.is_cancelled())
break;
try {
photo.export(dest, Scaling.for_original(), Jpeg.Quality.HIGH);
} catch (Error err) {
failed++;
}
if (dialog != null) {
dialog.set_fraction(++count, photos.size);
spin_event_loop();
}
}
if (dialog != null)
dialog.close();
AppWindow.get_instance().set_normal_cursor();
if (failed > 0) {
string msg = ngettext("Unable to export the photo due to a file error.",
"Unable to export %d photos due to file errors.", failed).printf(failed);
AppWindow.error_message(msg);
}
}
}
public class ExportDialog : Gtk.Dialog {
public const int DEFAULT_SCALE = 1200;
public const ScaleConstraint DEFAULT_CONSTRAINT = ScaleConstraint.DIMENSIONS;
......
......@@ -287,6 +287,10 @@ public struct Scaling {
return Scaling(ScaleConstraint.FILL_VIEWPORT, NO_SCALE, viewport, true);
}
public static Scaling for_constraint(ScaleConstraint constraint, int scale, bool scale_up) {
return Scaling(constraint, scale, Dimensions(), scale_up);
}
private static Dimensions get_screen_dimensions(Gtk.Window window) {
Gdk.Screen screen = window.get_screen();
......@@ -302,37 +306,60 @@ public struct Scaling {
}
public bool is_best_fit(Dimensions original, out int pixels) {
if (constraint == ScaleConstraint.ORIGINAL || scale == NO_SCALE)
if (scale == NO_SCALE)
return false;
pixels = scale_to_pixels();
assert(pixels > 0);
return true;
switch (constraint) {
case ScaleConstraint.ORIGINAL:
case ScaleConstraint.FILL_VIEWPORT:
return false;
default:
pixels = scale_to_pixels();
assert(pixels > 0);
return true;
}
}
public bool is_best_fit_dimensions(Dimensions original, out Dimensions scaled) {
int pixels;
if (!is_best_fit(original, out pixels))
if (scale == NO_SCALE)
return false;
scaled = original.get_scaled(pixels, scale_up);
return true;
switch (constraint) {
case ScaleConstraint.ORIGINAL:
case ScaleConstraint.FILL_VIEWPORT:
return false;
default:
int pixels = scale_to_pixels();
assert(pixels > 0);
scaled = original.get_scaled_by_constraint(pixels, constraint);
return true;
}
}
public bool is_for_viewport(Dimensions original, out Dimensions scaled) {
if (constraint == ScaleConstraint.ORIGINAL || scale != NO_SCALE)
if (scale != NO_SCALE)
return false;
assert(viewport.has_area());
if (!scale_up && original.width < viewport.width && original.height < viewport.height)
scaled = original;
else
scaled = original.get_scaled_proportional(viewport);
return true;
switch (constraint) {
case ScaleConstraint.ORIGINAL:
case ScaleConstraint.FILL_VIEWPORT:
return false;
default:
assert(viewport.has_area());
if (!scale_up && original.width < viewport.width && original.height < viewport.height)
scaled = original;
else
scaled = original.get_scaled_proportional(viewport);
return true;
}
}
public bool is_fill_viewport(Dimensions original, out Dimensions scaled) {
......@@ -388,8 +415,8 @@ public struct Scaling {
else if (constraint == ScaleConstraint.FILL_VIEWPORT)
return "scaling: fill viewport %s".printf(viewport.to_string());
else if (scale != NO_SCALE)
return "scaling: best-fit (%d pixels %s)".printf(scale_to_pixels(),
scale_up ? "scaled up" : "not scaled up");
return "scaling: best-fit (%s %d pixels %s)".printf(constraint.to_string(),
scale_to_pixels(), scale_up ? "scaled up" : "not scaled up");
else
return "scaling: viewport %s (%s)".printf(viewport.to_string(),
scale_up ? "scaled up" : "not scaled up");
......
......@@ -741,9 +741,11 @@ class FacebookUploadActionPane : UploadActionPane {
}
protected override void prepare_file(UploadActionPane.TemporaryFileDescriptor file) {
Scaling scaling = Scaling.for_constraint(ScaleConstraint.DIMENSIONS, MAX_PHOTO_DIMENSION,
false);
try {
file.source_photo.export(file.temp_file, MAX_PHOTO_DIMENSION,
ScaleConstraint.DIMENSIONS, Jpeg.Quality.MAXIMUM);
file.source_photo.export(file.temp_file, scaling, Jpeg.Quality.MAXIMUM);
} catch (Error e) {
error("FacebookUploadPane: can't create temporary files");
}
......
......@@ -303,14 +303,11 @@ class FlickrUploadActionPane : UploadActionPane {
}
protected override void prepare_file(UploadActionPane.TemporaryFileDescriptor file) {
Scaling scaling = (major_axis_size == ORIGINAL_SIZE)
? Scaling.for_original() : Scaling.for_best_fit(major_axis_size, false);
try {
if (major_axis_size == ORIGINAL_SIZE) {
file.source_photo.export(file.temp_file, major_axis_size, ScaleConstraint.ORIGINAL,
Jpeg.Quality.MAXIMUM);
} else {
file.source_photo.export(file.temp_file, major_axis_size,
ScaleConstraint.DIMENSIONS, Jpeg.Quality.MAXIMUM);
}
file.source_photo.export(file.temp_file, scaling, Jpeg.Quality.MAXIMUM);
} catch (Error e) {
error("FlickrUploadPane: can't create temporary files");
}
......
......@@ -743,7 +743,7 @@ public class LibraryWindow : AppWindow {
Gee.ArrayList<PhotoView> views = new Gee.ArrayList<PhotoView>();
foreach (LibraryPhoto photo in photos) {
// don't move a photo into the event it already exists in
if (!photo.get_event().equals(event))
if (photo.get_event() == null || !photo.get_event().equals(event))
views.add(new PhotoView(photo));
}
......
......@@ -6,17 +6,6 @@
public abstract class Page : Gtk.ScrolledWindow, SidebarPage {
private const int CONSIDER_CONFIGURE_HALTED_MSEC = 400;
protected enum TargetType {
URI_LIST,
PHOTO_LIST
}
// For now, assuming all drag-and-drop source functions are providing the same set of targets
protected const Gtk.TargetEntry[] SOURCE_TARGET_ENTRIES = {
{ "text/uri-list", Gtk.TargetFlags.OTHER_APP, TargetType.URI_LIST },
{ "shotwell/photo-id", Gtk.TargetFlags.SAME_APP, TargetType.PHOTO_LIST }
};
public Gtk.UIManager ui = new Gtk.UIManager();
public Gtk.ActionGroup action_group = null;
......@@ -347,13 +336,16 @@ public abstract class Page : Gtk.ScrolledWindow, SidebarPage {
// This method enables drag-and-drop on the event source and routes its events through this
// object
public void enable_drag_source(Gdk.DragAction actions) {
public void enable_drag_source(Gdk.DragAction actions, Gtk.TargetEntry[] source_target_entries) {
if (dnd_enabled)
return;
assert(event_source != null);
Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, SOURCE_TARGET_ENTRIES, actions);
Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, source_target_entries, actions);
// hook up handlers which route the event_source's DnD signals to the Page's (necessary
// because Page is a NO_WINDOW widget and cannot support DnD on its own).
event_source.drag_begin += on_drag_begin;
event_source.drag_data_get += on_drag_data_get;
event_source.drag_data_delete += on_drag_data_delete;
......@@ -403,6 +395,7 @@ public abstract class Page : Gtk.ScrolledWindow, SidebarPage {
// wierdly, Gtk 2.16.1 doesn't supply a drag_failed virtual method in the GtkWidget impl ...
// Vala binds to it, but it's not available in gtkwidget.h, and so gcc complains. Have to
// makeshift one for now.
// https://bugzilla.gnome.org/show_bug.cgi?id=584247
public virtual bool source_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) {
return false;
}
......@@ -1599,3 +1592,172 @@ public abstract class SinglePhotoPage : Page {
}
}
// This can be removed and the call changed to Gdk.property_get when this bug is fixed:
// https://bugzilla.gnome.org/show_bug.cgi?id=611250
extern bool gdk_property_get(Gdk.Window window, Gdk.Atom property, Gdk.Atom type, ulong offset,
ulong data_length, int pdelete, out Gdk.Atom actual_type, out int actual_format,
out int actual_length,
[CCode (array_length=false)] out uchar[] data);
//
// DragPhotoHandler attaches signals to a Page so properly handle drag-and-drop requests for the
// Page as a DnD Source. (DnD Destination handling is handled by the appropriate AppWindow, i.e.
// LibraryWindow and DirectWindow).
//
// The Page's ViewCollection *must* be holding TransformablePhotos or the show is off.
//
public class PhotoDragAndDropHandler {
private enum TargetType {
XDS,
PHOTO_LIST
}
private const Gtk.TargetEntry[] SOURCE_TARGET_ENTRIES = {
{ "XdndDirectSave0", Gtk.TargetFlags.OTHER_APP, TargetType.XDS },
{ "shotwell/photo-id", Gtk.TargetFlags.SAME_APP, TargetType.PHOTO_LIST }
};
private static Gdk.Atom? XDS_ATOM = null;
private static Gdk.Atom? TEXT_ATOM = null;
private static uchar[]? XDS_FAKE_TARGET = null;
private weak Page page;
private Gtk.Widget event_source;
private File? drag_destination = null;
public PhotoDragAndDropHandler(Page page) {
this.page = page;
this.event_source = page.get_event_source();
assert(event_source != null);
// Need to do this because static member variables are not properly handled
if (XDS_ATOM == null)
XDS_ATOM = Gdk.Atom.intern_static_string("XdndDirectSave0");
if (TEXT_ATOM == null)
TEXT_ATOM = Gdk.Atom.intern_static_string("text/plain");
if (XDS_FAKE_TARGET == null)
XDS_FAKE_TARGET = string_to_uchar_array("shotwell.txt");
// register what's available on this DnD Source
Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, SOURCE_TARGET_ENTRIES,
Gdk.DragAction.COPY);
// attach to the event source's DnD signals, not the Page's, which is a NO_WINDOW widget
// and does not emit them
event_source.drag_begin += on_drag_begin;
event_source.drag_data_get += on_drag_data_get;
event_source.drag_end += on_drag_end;
event_source.drag_failed += on_drag_failed;
}
~PhotoDragAndDropHandler() {
if (event_source != null) {
event_source.drag_begin -= on_drag_begin;
event_source.drag_data_get -= on_drag_data_get;
event_source.drag_end -= on_drag_end;
event_source.drag_failed -= on_drag_failed;
}
page = null;
event_source = null;
}
private void on_drag_begin(Gdk.DragContext context) {
if (page == null || page.get_view().get_selected_count() == 0)
return;
debug("on_drag_begin (%s)", page.get_page_name());
drag_destination = null;
// use the first photo as the icon
TransformablePhoto photo = (TransformablePhoto) page.get_view().get_selected_at(0).get_source();
try {
Gdk.Pixbuf icon = photo.get_thumbnail(AppWindow.DND_ICON_SCALE);
Gtk.drag_source_set_icon_pixbuf(event_source, icon);
} catch (Error err) {
warning("Unable to fetch icon for drag-and-drop from %s: %s", photo.to_string(),
err.message);
}
// set the XDS property to indicate an XDS save is available
Gdk.property_change(context.source_window, XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE,
XDS_FAKE_TARGET, XDS_FAKE_TARGET.length);
}
private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data,
uint target_type, uint time) {
if (page == null || page.get_view().get_selected_count() == 0)
return;
switch (target_type) {
case TargetType.XDS:
// Fetch the XDS property that has been set with the destination path
uchar[] data = new uchar[4096];
Gdk.Atom actual_type;
int actual_format = 0;
int actual_length = 0;
bool fetched = gdk_property_get(context.source_window, XDS_ATOM, TEXT_ATOM,
0, data.length, 0, out actual_type, out actual_format, out actual_length, out data);
// the destination path is actually for our XDS_FAKE_TARGET, use its parent
// to determine where the file(s) should go
if (fetched && actual_length > 0)
drag_destination = File.new_for_uri(uchar_array_to_string(data, actual_length)).get_parent();
debug("on_drag_data_get (%s): %s", page.get_page_name(),
(drag_destination != null) ? drag_destination.get_path() : "(no path)");
// Set the property to "S" for Success or "E" for Error
selection_data.set(XDS_ATOM, 8,
string_to_uchar_array((drag_destination != null) ? "S" : "E"));
break;