From d443a3c2509889533ca812c163056bace396b586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Frings-F=C3=BCrst?= Date: Wed, 14 Jun 2023 20:35:58 +0200 Subject: New upstream version 0.32.1 --- src/AppDirs.vala | 58 +- src/AppWindow.vala | 56 +- src/Application.vala | 12 +- src/BatchImport.vala | 11 +- src/CheckerboardItem.vala | 734 ++++++++++ src/CheckerboardItemText.vala | 98 ++ src/CheckerboardLayout.vala | 922 +----------- src/CheckerboardPage.vala | 758 ++++++++++ src/Commands.vala | 89 +- src/Debug.vala | 5 +- src/DesktopIntegration.vala | 2 +- src/Dialogs.vala | 63 +- src/Dimensions.vala | 3 +- src/DirectoryMonitor.vala | 8 +- src/DragAndDropHandler.vala | 182 +++ src/Event.vala | 77 +- src/Exporter.vala | 75 +- src/LibraryFiles.vala | 10 +- src/LibraryMonitor.vala | 8 +- src/MapWidget.vala | 788 +++++++++++ src/MediaDataRepresentation.vala | 8 +- src/MediaInterfaces.vala | 4 +- src/MediaMetadata.vala | 125 -- src/MediaPage.vala | 7 +- src/MetadataWriter.vala | 18 +- src/Page.vala | 1557 +-------------------- src/PageMessagePane.vala | 19 + src/Photo.vala | 161 ++- src/PhotoPage.vala | 19 +- src/Printing.vala | 28 +- src/ProfileBrowser.vala | 294 ++++ src/Profiles.vala | 303 ++++ src/Properties.vala | 101 +- src/Resources.vala | 45 +- src/SearchFilter.vala | 14 +- src/SinglePhotoPage.vala | 537 +++++++ src/SlideshowPage.vala | 14 +- src/SortedList.vala | 4 +- src/Tag.vala | 6 +- src/Thumbnail.vala | 9 +- src/ThumbnailCache.vala | 28 +- src/TimedQueue.vala | 2 +- src/Upgrades.vala | 2 +- src/VideoMetadata.vala | 655 --------- src/VideoSupport.vala | 1193 ---------------- src/camera/CameraBranch.vala | 2 +- src/camera/CameraTable.vala | 83 +- src/camera/DiscoveredCamera.vala | 119 ++ src/camera/GPhoto.vala | 5 +- src/camera/ImportPage.vala | 162 +-- src/config/Config.vala | 2 +- src/config/ConfigurationInterfaces.vala | 30 +- src/config/GSettingsEngine.vala | 88 +- src/core/DataCollection.vala | 2 +- src/core/DataSourceTypes.vala | 4 +- src/core/SourceInterfaces.vala | 15 + src/core/util.vala | 11 +- src/data_imports/DataImportJob.vala | 6 +- src/data_imports/DataImportSource.vala | 6 +- src/data_imports/DataImports.vala | 2 +- src/data_imports/DataImportsPluginHost.vala | 5 +- src/data_imports/DataImportsUI.vala | 10 +- src/db/DatabaseTable.vala | 22 +- src/db/Db.vala | 65 +- src/db/EventTable.vala | 14 +- src/db/FaceLocationTable.vala | 75 +- src/db/FaceTable.vala | 56 +- src/db/PhotoTable.vala | 206 ++- src/db/TagTable.vala | 8 +- src/db/TombstoneTable.vala | 6 +- src/db/VideoTable.vala | 64 +- src/dialogs/AdjustDateTimeDialog.vala | 71 +- src/dialogs/MultiTextEntryDialog.vala | 2 +- src/dialogs/Preferences.vala | 46 +- src/dialogs/SetBackground.vala | 4 +- src/dialogs/SetBackgroundSlideshow.vala | 8 +- src/dialogs/TextEntry.vala | 4 +- src/dialogs/WelcomeDialog.vala | 4 +- src/direct/DirectPhotoPage.vala | 5 +- src/editing_tools/EditingTools.vala | 81 +- src/editing_tools/StraightenTool.vala | 14 +- src/events/EventDirectoryItem.vala | 2 +- src/events/EventsBranch.vala | 40 +- src/events/EventsDirectoryPage.vala | 40 +- src/faces/Face.vala | 30 +- src/faces/FaceDetect.vala | 146 ++ src/faces/FaceLocation.vala | 45 +- src/faces/FacePage.vala | 14 + src/faces/FaceShape.vala | 120 +- src/faces/FacesTool.vala | 230 +-- src/import-roll/ImportRollBranch.vala | 3 +- src/library/BackgroundProgressBar.vala | 109 ++ src/library/LibraryWindow.vala | 175 +-- src/library/TrashPage.vala | 4 + src/libshotwell.deps | 20 - src/main.vala | 171 ++- src/meson.build | 486 ++++--- src/metadata/MediaMetadata.vala | 15 + src/metadata/MetadataDateTime.vala | 78 ++ src/metadata/MetadataRational.vala | 26 + src/metadata/meson.build | 16 + src/photos/AvifSupport.vala | 140 ++ src/photos/BmpSupport.vala | 27 - src/photos/GdkSupport.vala | 28 +- src/photos/GifSupport.vala | 27 - src/photos/HeifSupport.vala | 150 ++ src/photos/JfifSupport.vala | 108 +- src/photos/JpegXLSupport.vala | 149 ++ src/photos/PhotoFileFormat.vala | 72 +- src/photos/PhotoFileSniffer.vala | 28 + src/photos/PhotoMetadata.vala | 258 +++- src/photos/PngSupport.vala | 27 - src/photos/RawSupport.vala | 19 +- src/photos/TiffSupport.vala | 6 +- src/photos/WebPSupport.vala | 240 ++++ src/plugins/DataImportsInterfaces.vala | 4 +- src/plugins/ManifestWidget.vala | 412 +++--- src/plugins/Plugins.vala | 45 +- src/plugins/PublishingInterfaces.vala | 44 +- src/plugins/SpitInterfaces.vala | 33 +- src/plugins/StandardHostInterface.vala | 9 +- src/publishing/APIGlue.vala | 6 +- src/publishing/LoginWelcomePaneWidget.vala | 45 + src/publishing/ProgressPaneWidget.vala | 44 + src/publishing/Publishing.vala | 7 +- src/publishing/PublishingPluginHost.vala | 8 +- src/publishing/PublishingUI.vala | 202 +-- src/publishing/StaticMessagePaneWidget.vala | 62 + src/publishing/SuccessPaneWidget.vala | 39 + src/publishing/meson.build | 27 + src/searches/SavedSearchDialog.vala | 37 +- src/searches/SearchBoolean.vala | 6 +- src/sidebar/Tree.vala | 12 - src/slideshow/Slideshow.vala | 20 +- src/slideshow/TransitionEffects.vala | 12 +- src/unit/rc/Unit.m4 | 29 - src/unit/rc/UnitInternals.m4 | 32 - src/unit/rc/template.vala | 7 - src/unit/rc/unitize_entry.m4 | 19 - src/util/Util.vala | 2 + src/util/file.vala | 26 +- src/util/image.vala | 8 +- src/util/misc.vala | 30 +- src/util/string.vala | 14 +- src/util/system.vala | 5 +- src/util/ui.vala | 11 +- src/video-support/AVIChunk.vala | 121 ++ src/video-support/AVIMetadataLoader.vala | 227 +++ src/video-support/QuickTimeAtom.vala | 118 ++ src/video-support/QuicktimeMetdataLoader.vala | 127 ++ src/video-support/Video.vala | 703 ++++++++++ src/video-support/VideoImportParams.vala | 28 + src/video-support/VideoMetadata.vala | 51 + src/video-support/VideoMetadataReaderProcess.vala | 66 + src/video-support/VideoReader.vala | 317 +++++ src/video-support/VideoSourceCollection.vala | 175 +++ src/video-support/meson.build | 36 + src/video-support/util.vala | 13 + 158 files changed, 10081 insertions(+), 6626 deletions(-) create mode 100644 src/CheckerboardItem.vala create mode 100644 src/CheckerboardItemText.vala create mode 100644 src/CheckerboardPage.vala create mode 100644 src/DragAndDropHandler.vala create mode 100644 src/MapWidget.vala delete mode 100644 src/MediaMetadata.vala create mode 100644 src/PageMessagePane.vala create mode 100644 src/ProfileBrowser.vala create mode 100644 src/Profiles.vala create mode 100644 src/SinglePhotoPage.vala delete mode 100644 src/VideoMetadata.vala delete mode 100644 src/VideoSupport.vala create mode 100644 src/camera/DiscoveredCamera.vala create mode 100644 src/faces/FaceDetect.vala create mode 100644 src/library/BackgroundProgressBar.vala delete mode 100644 src/libshotwell.deps create mode 100644 src/metadata/MediaMetadata.vala create mode 100644 src/metadata/MetadataDateTime.vala create mode 100644 src/metadata/MetadataRational.vala create mode 100644 src/metadata/meson.build create mode 100644 src/photos/AvifSupport.vala create mode 100644 src/photos/HeifSupport.vala create mode 100644 src/photos/JpegXLSupport.vala create mode 100644 src/photos/WebPSupport.vala create mode 100644 src/publishing/LoginWelcomePaneWidget.vala create mode 100644 src/publishing/ProgressPaneWidget.vala create mode 100644 src/publishing/StaticMessagePaneWidget.vala create mode 100644 src/publishing/SuccessPaneWidget.vala create mode 100644 src/publishing/meson.build delete mode 100644 src/unit/rc/Unit.m4 delete mode 100644 src/unit/rc/UnitInternals.m4 delete mode 100644 src/unit/rc/template.vala delete mode 100644 src/unit/rc/unitize_entry.m4 create mode 100644 src/video-support/AVIChunk.vala create mode 100644 src/video-support/AVIMetadataLoader.vala create mode 100644 src/video-support/QuickTimeAtom.vala create mode 100644 src/video-support/QuicktimeMetdataLoader.vala create mode 100644 src/video-support/Video.vala create mode 100644 src/video-support/VideoImportParams.vala create mode 100644 src/video-support/VideoMetadata.vala create mode 100644 src/video-support/VideoMetadataReaderProcess.vala create mode 100644 src/video-support/VideoReader.vala create mode 100644 src/video-support/VideoSourceCollection.vala create mode 100644 src/video-support/meson.build create mode 100644 src/video-support/util.vala (limited to 'src') diff --git a/src/AppDirs.vala b/src/AppDirs.vala index 6c4541c..20df920 100644 --- a/src/AppDirs.vala +++ b/src/AppDirs.vala @@ -169,15 +169,14 @@ class AppDirs { } // Library folder + photo folder, based on user's preferred directory pattern. - public static File get_baked_import_dir(time_t tm) { + public static File get_baked_import_dir(DateTime tm) { string? pattern = Config.Facade.get_instance().get_directory_pattern(); if (is_string_empty(pattern)) pattern = Config.Facade.get_instance().get_directory_pattern_custom(); if (is_string_empty(pattern)) pattern = "%Y" + Path.DIR_SEPARATOR_S + "%m" + Path.DIR_SEPARATOR_S + "%d"; // default - DateTime date = new DateTime.from_unix_local(tm); - return File.new_for_path(get_import_dir().get_path() + Path.DIR_SEPARATOR_S + date.format(pattern)); + return File.new_for_path(get_import_dir().get_path() + Path.DIR_SEPARATOR_S + tm.to_local().format(pattern)); } // Returns true if the File is in or is equal to the library/import directory. @@ -210,7 +209,7 @@ class AppDirs { return tmp_dir; } - + public static File get_data_subdir(string name, string? subname = null) { File subdir = get_data_dir().get_child(name); if (subname != null) @@ -262,7 +261,7 @@ class AppDirs { File? install_dir = get_install_dir(); return (install_dir != null) ? install_dir.get_child("share").get_child("shotwell") - : get_exec_dir(); + : get_lib_dir(); } public static File get_lib_dir() { @@ -317,32 +316,67 @@ class AppDirs { return f; } + public static File get_metadata_helper() { + const string filename = "shotwell-video-metadata-handler"; + File f = AppDirs.get_libexec_dir().get_child("video-support").get_child (filename); + if (!f.query_exists()) { + // If we're running installed. + f = AppDirs.get_libexec_dir () .get_child ("shotwell").get_child (filename); + } + return f; + } + public static File get_settings_migrator_bin() { const string filename = "shotwell-settings-migrator"; - File f = AppDirs.get_libexec_dir().get_child ("settings-migrator").get_child (filename); + File f = AppDirs.get_libexec_dir().get_child("settings-migrator").get_child (filename); if (!f.query_exists()) { // If we're running installed. f = AppDirs.get_libexec_dir () .get_child ("shotwell").get_child (filename); } + + if (!f.query_exists()) { + f = AppDirs.get_libexec_dir().get_parent().get_child("settings-migrator").get_child(filename); + } + return f; } + public static File get_haarcascade_file() { + const string filename = "facedetect-haarcascade.xml"; + var f = AppDirs.get_resources_dir().get_parent().get_child("subprojects").get_child("shotwell-facedetect").get_child (filename); + if (f.query_exists()) {//testing meson builddir + return f; + } + return get_resources_dir().get_child("facedetect-haarcascade.xml"); + } + + +#if ENABLE_FACE_DETECTION public static File get_facedetect_bin() { const string filename = "shotwell-facedetect"; - File f = AppDirs.get_libexec_dir().get_parent().get_child("facedetect").get_child (filename); + File f = AppDirs.get_libexec_dir().get_parent().get_child("subprojects").get_child(filename).get_child (filename); if (!f.query_exists()) { f = AppDirs.get_libexec_dir().get_child("shotwell").get_child(filename); } return f; } - - public static File get_haarcascade_file() { - File f = File.new_for_path(AppDirs.get_exec_dir().get_parent().get_parent().get_child("facedetect").get_child("facedetect-haarcascade.xml").get_path()); + + public static File get_openface_dnn_dir() { + return File.new_for_path(Environment.get_user_data_dir()).get_child(DEFAULT_DATA_DIR).get_child("facedetect"); + } + + public static File get_openface_dnn_system_dir() { + var f = File.new_for_path("/app/extra"); + if (f.query_exists()) + return f; + + f = AppDirs.get_resources_dir().get_parent().get_child("subprojects").get_child("shotwell-facedetect"); if (f.query_exists()) {//testing meson builddir return f; } - return get_resources_dir().get_child("facedetect-haarcascade.xml"); + + return AppDirs.get_resources_dir().get_child("facedetect"); } +#endif } - diff --git a/src/AppWindow.vala b/src/AppWindow.vala index a5b27a4..438806c 100644 --- a/src/AppWindow.vala +++ b/src/AppWindow.vala @@ -6,7 +6,7 @@ public class FullscreenWindow : PageWindow { public const int TOOLBAR_INVOCATION_MSEC = 250; - public const int TOOLBAR_DISMISSAL_SEC = 2; + public const int TOOLBAR_DISMISSAL_SEC = 2 * 1000000; public const int TOOLBAR_CHECK_DISMISSAL_MSEC = 500; private Gtk.Overlay overlay = new Gtk.Overlay(); @@ -15,7 +15,7 @@ public class FullscreenWindow : PageWindow { private Gtk.ToggleToolButton pin_button = new Gtk.ToggleToolButton(); private bool is_toolbar_shown = false; private bool waiting_for_invoke = false; - private time_t left_toolbar_time = 0; + private int64 left_toolbar_time = 0; private bool switched_to = false; private bool is_toolbar_dismissal_enabled; @@ -246,13 +246,13 @@ public class FullscreenWindow : PageWindow { // if this is the first time noticed, start the timer and keep checking if (left_toolbar_time == 0) { - left_toolbar_time = time_t(); + left_toolbar_time = GLib.get_monotonic_time(); return true; } // see if enough time has elapsed - time_t now = time_t(); + int64 now = GLib.get_monotonic_time(); assert(now >= left_toolbar_time); if (now - left_toolbar_time < TOOLBAR_DISMISSAL_SEC) @@ -367,7 +367,6 @@ public abstract class PageWindow : Gtk.ApplicationWindow { var display = get_window ().get_display (); var cursor = new Gdk.Cursor.for_display (display, Gdk.CursorType.WATCH); get_window().set_cursor (cursor); - spin_event_loop(); } public void set_normal_cursor() { @@ -381,7 +380,6 @@ public abstract class PageWindow : Gtk.ApplicationWindow { var display = get_window ().get_display (); var cursor = new Gdk.Cursor.for_display (display, Gdk.CursorType.LEFT_PTR); get_window().set_cursor (cursor); - spin_event_loop(); } } @@ -415,7 +413,7 @@ public abstract class AppWindow : PageWindow { instance = this; title = Resources.APP_TITLE; - set_default_icon_name("shotwell"); + set_default_icon_name("org.gnome.Shotwell"); // restore previous size and maximization state if (this is LibraryWindow) { @@ -441,10 +439,6 @@ public abstract class AppWindow : PageWindow { // with each ActionGroup while we're adding the groups to the UIManager. add_actions (); - - Gtk.CssProvider provider = new Gtk.CssProvider(); - provider.load_from_resource("/org/gnome/Shotwell/misc/org.gnome.Shotwell.css"); - Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); } private const GLib.ActionEntry[] common_actions = { @@ -475,19 +469,6 @@ public abstract class AppWindow : PageWindow { return fullscreen_window; } - public static Gtk.Builder create_builder(string glade_filename = "shotwell.ui", void *user = null) { - Gtk.Builder builder = new Gtk.Builder(); - try { - builder.add_from_resource(Resources.get_ui(glade_filename)); - } catch(GLib.Error error) { - warning("Unable to create Gtk.Builder: %s\n", error.message); - } - - builder.connect_signals(user); - - return builder; - } - public static void error_message(string message, Gtk.Window? parent = null) { error_message_with_title(Resources.APP_TITLE, message, parent); } @@ -560,27 +541,26 @@ public abstract class AppWindow : PageWindow { return (Gtk.ResponseType) response; } - public static Gtk.ResponseType negate_affirm_all_cancel_question(string message, - string negative, string affirmative, string affirmative_all, string? title = null, - Gtk.Window? parent = null) { + public static int export_overwrite_or_replace_question(string message, + string alt1, string alt2, string alt3, string alt4, string alt5, string alt6, + 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(negative, Gtk.ResponseType.NO, affirmative, Gtk.ResponseType.YES, - affirmative_all, Gtk.ResponseType.APPLY, _("_Cancel"), Gtk.ResponseType.CANCEL); + dialog.add_buttons(alt1, 1, alt2, 2, alt3, 3, alt4, 4, alt5, 5, alt6, 6); int response = dialog.run(); dialog.destroy(); - return (Gtk.ResponseType) response; + return response; } - - public static void database_error(DatabaseError err) { + + public static void database_error(Error err) { panic(_("A fatal error occurred when accessing Shotwell’s library. Shotwell cannot continue.\n\n%s").printf( err.message)); } - + public static void panic(string msg) { critical(msg); error_message(msg); @@ -591,9 +571,13 @@ public abstract class AppWindow : PageWindow { public abstract string get_app_role(); protected void on_about() { - const string[] artists = { "Celler Schloss created by Hajotthu, CC BY-SA 3.0, https://commons.wikimedia.org/wiki/File:Celler_Schloss_April_2010.jpg#file", null }; + var hash = ""; + if (Resources.GIT_VERSION != null && Resources.GIT_VERSION != "" && Resources.GIT_VERSION != Resources.APP_VERSION) { + hash = " (%s)".printf(Resources.GIT_VERSION.substring(0,7)); + } + string[] artists = {"Image of the Delmenhorst Town Hall by Charlie1965nrw, source: https://commons.wikimedia.org/wiki/File:Delmenhorst_Rathaus.jpg", null}; Gtk.show_about_dialog(this, - "version", Resources.APP_VERSION + " \u2013 “Celle”", + "version", Resources.APP_VERSION + hash + " — Delmenhorst", "comments", get_app_role(), "copyright", Resources.COPYRIGHT, "website", Resources.HOME_URL, @@ -601,8 +585,8 @@ public abstract class AppWindow : PageWindow { "website-label", _("Visit the Shotwell web site"), "authors", Resources.AUTHORS, "logo", Resources.get_icon(Resources.ICON_ABOUT_LOGO, -1), - "artists", artists, "translator-credits", _("translator-credits"), + "artists", artists, null ); } diff --git a/src/Application.vala b/src/Application.vala index 36acc41..59bae36 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -69,6 +69,16 @@ public class Application { system_app.startup.connect(on_activated); } + public static double get_scale() { + var instance = get_instance().system_app; + unowned GLib.List windows = instance.get_windows(); + + if (windows == null) + return 1.0; + + return windows.data.get_scale_factor(); + } + /** * @brief This is a helper for library mode that should only be * called if we've gotten a camera mount and are _not_ the primary @@ -104,7 +114,7 @@ public class Application { } /** - * @brief Signal handler for GApplication's 'command-line' signal. + * @brief Signal handler for GApplication's 'activate' signal. * * The most likely scenario for this to be fired is if the user * either tried to run us twice in library mode, or we've just gotten diff --git a/src/BatchImport.vala b/src/BatchImport.vala index 0e31441..90ccba8 100644 --- a/src/BatchImport.vala +++ b/src/BatchImport.vala @@ -201,10 +201,10 @@ public abstract class BatchImportJob { return false; } - // returns a non-zero time_t value if this has a valid exposure time override, returns zero + // returns a non-null DateTime value if this has a valid exposure time override, returns zero // otherwise - public virtual time_t get_exposure_time_override() { - return 0; + public virtual DateTime? get_exposure_time_override() { + return null; } public virtual bool recurse() { @@ -1597,6 +1597,11 @@ private class WorkSniffer : BackgroundImportJob { } public void search_dir(BatchImportJob job, File dir, bool copy_to_library, bool recurse) throws Error { + if (dir.get_child(".nomedia").query_exists()) { + debug("Folder %s contains \".nomedia\" file, ignoring.", dir.get_path()); + return; + } + FileEnumerator enumerator = dir.enumerate_children("standard::*", FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); diff --git a/src/CheckerboardItem.vala b/src/CheckerboardItem.vala new file mode 100644 index 0000000..a8a5e63 --- /dev/null +++ b/src/CheckerboardItem.vala @@ -0,0 +1,734 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +public abstract class CheckerboardItem : ThumbnailView { + // Collection properties CheckerboardItem understands + // SHOW_TITLES (bool) + public const string PROP_SHOW_TITLES = "show-titles"; + // SHOW_COMMENTS (bool) + public const string PROP_SHOW_COMMENTS = "show-comments"; + // SHOW_SUBTITLES (bool) + public const string PROP_SHOW_SUBTITLES = "show-subtitles"; + + public const int FRAME_WIDTH = 8; + public const int LABEL_PADDING = 4; + public const int BORDER_WIDTH = 1; + + public const int SHADOW_RADIUS = 4; + public const float SHADOW_INITIAL_ALPHA = 0.5f; + + public const int TRINKET_SCALE = 12; + public const int TRINKET_PADDING = 1; + + public const int BRIGHTEN_SHIFT = 0x18; + + public Dimensions requisition = Dimensions(); + public Gdk.Rectangle allocation = Gdk.Rectangle(); + + private bool exposure = false; + private CheckerboardItemText? title = null; + private bool title_visible = true; + private CheckerboardItemText? comment = null; + private bool comment_visible = true; + private CheckerboardItemText? subtitle = null; + private bool subtitle_visible = false; + private bool is_cursor = false; + private Pango.Alignment tag_alignment = Pango.Alignment.LEFT; + private Gee.List? user_visible_tag_list = null; + private Gee.Collection tags; + private Gdk.Pixbuf pixbuf = null; + private Gdk.Pixbuf display_pixbuf = null; + private Gdk.Pixbuf brightened = null; + private Dimensions pixbuf_dim = Dimensions(); + private int col = -1; + private int row = -1; + private int horizontal_trinket_offset = 0; + + protected CheckerboardItem(ThumbnailSource source, Dimensions initial_pixbuf_dim, string title, string? comment, + bool marked_up = false, Pango.Alignment alignment = Pango.Alignment.LEFT) { + base(source); + + pixbuf_dim = initial_pixbuf_dim; + this.title = new CheckerboardItemText(title, alignment, marked_up); + // on the checkboard page we display the comment in + // one line, i.e., replacing all newlines with spaces. + // that means that the display will contain "..." if the comment + // is too long. + // warning: changes here have to be done in set_comment, too! + if (comment != null) + this.comment = new CheckerboardItemText(comment.replace("\n", " "), alignment, + marked_up); + + // Don't calculate size here, wait for the item to be assigned to a ViewCollection + // (notify_membership_changed) and calculate when the collection's property settings + // are known + } + + public bool has_tags { get; private set; } + + public override string get_name() { + return (title != null) ? title.get_text() : base.get_name(); + } + + public string get_title() { + return (title != null) ? title.get_text() : ""; + } + + public string get_comment() { + return (comment != null) ? comment.get_text() : ""; + } + + public void set_title(string text, bool marked_up = false, + Pango.Alignment alignment = Pango.Alignment.LEFT) { + if (title != null && title.is_set_to(text, marked_up, alignment)) + return; + + title = new CheckerboardItemText(text, alignment, marked_up); + + if (title_visible) { + recalc_size("set_title"); + notify_view_altered(); + } + } + + public void translate_coordinates(ref int x, ref int y) { + x -= allocation.x + FRAME_WIDTH; + y -= allocation.y + FRAME_WIDTH; + } + + public void clear_title() { + if (title == null) + return; + + title = null; + + if (title_visible) { + recalc_size("clear_title"); + notify_view_altered(); + } + } + + private void set_title_visible(bool visible) { + if (title_visible == visible) + return; + + title_visible = visible; + + recalc_size("set_title_visible"); + notify_view_altered(); + } + + public void set_comment(string text, bool marked_up = false, + Pango.Alignment alignment = Pango.Alignment.LEFT) { + if (comment != null && comment.is_set_to(text, marked_up, alignment)) + return; + + comment = new CheckerboardItemText(text.replace("\n", " "), alignment, marked_up); + + if (comment_visible) { + recalc_size("set_comment"); + notify_view_altered(); + } + } + + public void clear_comment() { + if (comment == null) + return; + + comment = null; + + if (comment_visible) { + recalc_size("clear_comment"); + notify_view_altered(); + } + } + + private void set_comment_visible(bool visible) { + if (comment_visible == visible) + return; + + comment_visible = visible; + + recalc_size("set_comment_visible"); + notify_view_altered(); + } + + public void set_tags(Gee.Collection? tags, + Pango.Alignment alignment = Pango.Alignment.LEFT) { + has_tags = (tags != null && tags.size > 0); + tag_alignment = alignment; + string text; + if (has_tags) { + this.tags = tags; + user_visible_tag_list = Tag.make_user_visible_tag_list(tags); + text = Tag.make_tag_markup_string(user_visible_tag_list); + } else { + text = "."; + } + + if (subtitle != null && subtitle.is_set_to(text, true, alignment)) + return; + subtitle = new CheckerboardItemText(text, alignment, true); + + if (subtitle_visible) { + recalc_size("set_subtitle"); + notify_view_altered(); + } + } + + public void clear_tags() { + clear_subtitle(); + has_tags = false; + user_visible_tag_list = null; + } + + public void highlight_user_visible_tag(int index) + requires (user_visible_tag_list != null) { + string text = Tag.make_tag_markup_string(user_visible_tag_list, index); + subtitle = new CheckerboardItemText(text, tag_alignment, true); + + if (subtitle_visible) + notify_view_altered(); + } + + public Tag get_user_visible_tag(int index) + requires (index >= 0 && index < user_visible_tag_list.size) { + return user_visible_tag_list.get(index); + } + + public Pango.Layout? get_tag_list_layout() { + return has_tags ? subtitle.get_pango_layout() : null; + } + + public Gdk.Rectangle get_subtitle_allocation() { + return subtitle.allocation; + } + + public string get_subtitle() { + return (subtitle != null) ? subtitle.get_text() : ""; + } + + public void set_subtitle(string text, bool marked_up = false, + Pango.Alignment alignment = Pango.Alignment.LEFT) { + if (subtitle != null && subtitle.is_set_to(text, marked_up, alignment)) + return; + + subtitle = new CheckerboardItemText(text, alignment, marked_up); + + if (subtitle_visible) { + recalc_size("set_subtitle"); + notify_view_altered(); + } + } + + public void clear_subtitle() { + if (subtitle == null) + return; + + subtitle = null; + + if (subtitle_visible) { + recalc_size("clear_subtitle"); + notify_view_altered(); + } + } + + private void set_subtitle_visible(bool visible) { + if (subtitle_visible == visible) + return; + + subtitle_visible = visible; + + recalc_size("set_subtitle_visible"); + notify_view_altered(); + } + + public void set_is_cursor(bool is_cursor) { + this.is_cursor = is_cursor; + } + + public bool get_is_cursor() { + return is_cursor; + } + + public virtual void handle_mouse_motion(int x, int y, int height, int width) { + + } + + public virtual void handle_mouse_leave() { + unbrighten(); + } + + public virtual void handle_mouse_enter() { + brighten(); + } + + protected override void notify_membership_changed(DataCollection? collection) { + bool title_visible = (bool) get_collection_property(PROP_SHOW_TITLES, true); + bool comment_visible = (bool) get_collection_property(PROP_SHOW_COMMENTS, true); + bool subtitle_visible = (bool) get_collection_property(PROP_SHOW_SUBTITLES, false); + + bool altered = false; + if (this.title_visible != title_visible) { + this.title_visible = title_visible; + altered = true; + } + + if (this.comment_visible != comment_visible) { + this.comment_visible = comment_visible; + altered = true; + } + + if (this.subtitle_visible != subtitle_visible) { + this.subtitle_visible = subtitle_visible; + altered = true; + } + + if (altered || !requisition.has_area()) { + recalc_size("notify_membership_changed"); + notify_view_altered(); + } + + base.notify_membership_changed(collection); + } + + protected override void notify_collection_property_set(string name, Value? old, Value val) { + switch (name) { + case PROP_SHOW_TITLES: + set_title_visible((bool) val); + break; + + case PROP_SHOW_COMMENTS: + set_comment_visible((bool) val); + break; + + case PROP_SHOW_SUBTITLES: + set_subtitle_visible((bool) val); + break; + } + + base.notify_collection_property_set(name, old, val); + } + + // The alignment point is the coordinate on the y-axis (relative to the top of the + // CheckerboardItem) which this item should be aligned to. This allows for + // bottom-alignment along the bottom edge of the thumbnail. + public int get_alignment_point() { + return FRAME_WIDTH + BORDER_WIDTH + pixbuf_dim.height; + } + + public virtual void exposed() { + exposure = true; + } + + public virtual void unexposed() { + exposure = false; + + if (title != null) + title.clear_pango_layout(); + + if (comment != null) + comment.clear_pango_layout(); + + if (subtitle != null) + subtitle.clear_pango_layout(); + } + + public virtual bool is_exposed() { + return exposure; + } + + public bool has_image() { + return pixbuf != null; + } + + public Gdk.Pixbuf? get_image() { + return pixbuf; + } + + public void set_image(Gdk.Pixbuf pixbuf) { + this.pixbuf = pixbuf; + display_pixbuf = pixbuf; + pixbuf_dim = Dimensions.for_pixbuf(pixbuf); + + recalc_size("set_image"); + notify_view_altered(); + } + + public void clear_image(Dimensions dim) { + bool had_image = pixbuf != null; + + pixbuf = null; + display_pixbuf = null; + pixbuf_dim = dim; + + recalc_size("clear_image"); + + if (had_image) + notify_view_altered(); + } + + public static int get_max_width(int scale) { + // width is frame width (two sides) + frame padding (two sides) + width of pixbuf (text + // never wider) + return (FRAME_WIDTH * 2) + scale; + } + + private void recalc_size(string reason) { + Dimensions old_requisition = requisition; + + // only add in the text heights if they're displayed + int title_height = (title != null && title_visible) + ? title.get_height() + LABEL_PADDING : 0; + int comment_height = (comment != null && comment_visible) + ? comment.get_height() + LABEL_PADDING : 0; + int subtitle_height = (subtitle != null && subtitle_visible) + ? subtitle.get_height() + LABEL_PADDING : 0; + + // width is frame width (two sides) + frame padding (two sides) + width of pixbuf + // (text never wider) + requisition.width = (FRAME_WIDTH * 2) + (BORDER_WIDTH * 2) + pixbuf_dim.width; + + // height is frame width (two sides) + frame padding (two sides) + height of pixbuf + // + height of text + label padding (between pixbuf and text) + requisition.height = (FRAME_WIDTH * 2) + (BORDER_WIDTH * 2) + + pixbuf_dim.height + title_height + comment_height + subtitle_height; + +#if TRACE_REFLOW_ITEMS + debug("recalc_size %s: %s title_height=%d comment_height=%d subtitle_height=%d requisition=%s", + get_source().get_name(), reason, title_height, comment_height, subtitle_height, + requisition.to_string()); +#endif + + if (!requisition.approx_equals(old_requisition)) { +#if TRACE_REFLOW_ITEMS + debug("recalc_size %s: %s notifying geometry altered", get_source().get_name(), reason); +#endif + notify_geometry_altered(); + } + } + + protected static Dimensions get_border_dimensions(Dimensions object_dim, int border_width) { + Dimensions dimensions = Dimensions(); + dimensions.width = object_dim.width + (border_width * 2); + dimensions.height = object_dim.height + (border_width * 2); + return dimensions; + } + + protected static Gdk.Point get_border_origin(Gdk.Point object_origin, int border_width) { + Gdk.Point origin = Gdk.Point(); + origin.x = object_origin.x - border_width; + origin.y = object_origin.y - border_width; + return origin; + } + + protected virtual void paint_shadow(Cairo.Context ctx, Dimensions dimensions, Gdk.Point origin, + int radius, float initial_alpha) { + double rgb_all = 0.0; + + // top right corner + paint_shadow_in_corner(ctx, origin.x + dimensions.width, origin.y + radius, rgb_all, radius, + initial_alpha, -0.5 * Math.PI, 0); + // bottom right corner + paint_shadow_in_corner(ctx, origin.x + dimensions.width, origin.y + dimensions.height, rgb_all, + radius, initial_alpha, 0, 0.5 * Math.PI); + // bottom left corner + paint_shadow_in_corner(ctx, origin.x + radius, origin.y + dimensions.height, rgb_all, radius, + initial_alpha, 0.5 * Math.PI, Math.PI); + + // left right + Cairo.Pattern lr = new Cairo.Pattern.linear(0, origin.y + dimensions.height, + 0, origin.y + dimensions.height + radius); + lr.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); + lr.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0.0); + ctx.set_source(lr); + ctx.rectangle(origin.x + radius, origin.y + dimensions.height, dimensions.width - radius, radius); + ctx.fill(); + + // top down + Cairo.Pattern td = new Cairo.Pattern.linear(origin.x + dimensions.width, + 0, origin.x + dimensions.width + radius, 0); + td.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); + td.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0.0); + ctx.set_source(td); + ctx.rectangle(origin.x + dimensions.width, origin.y + radius, + radius, dimensions.height - radius); + ctx.fill(); + } + + protected void paint_shadow_in_corner(Cairo.Context ctx, int x, int y, + double rgb_all, float radius, float initial_alpha, double arc1, double arc2) { + Cairo.Pattern p = new Cairo.Pattern.radial(x, y, 0, x, y, radius); + p.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); + p.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0); + ctx.set_source(p); + ctx.move_to(x, y); + ctx.arc(x, y, radius, arc1, arc2); + ctx.close_path(); + ctx.fill(); + } + + protected virtual void paint_border(Cairo.Context ctx, Dimensions object_dimensions, + Gdk.Point object_origin, int border_width) { + if (border_width == 1) { + ctx.rectangle(object_origin.x - border_width, object_origin.y - border_width, + object_dimensions.width + (border_width * 2), + object_dimensions.height + (border_width * 2)); + ctx.fill(); + } else { + Dimensions dimensions = get_border_dimensions(object_dimensions, border_width); + Gdk.Point origin = get_border_origin(object_origin, border_width); + + // amount of rounding needed on corners varies by size of object + double scale = int.max(object_dimensions.width, object_dimensions.height); + draw_rounded_corners_filled(ctx, dimensions, origin, 0.25 * scale); + } + } + + protected virtual void paint_image(Cairo.Context ctx, Gdk.Pixbuf pixbuf, Gdk.Point origin) { + paint_pixmap_with_background(ctx, pixbuf, origin.x, origin.y); + } + + private int get_selection_border_width(int scale) { + return ((scale <= ((Thumbnail.MIN_SCALE + Thumbnail.MAX_SCALE) / 3)) ? 5 : 4) + + BORDER_WIDTH; + } + + protected virtual Gdk.Pixbuf? get_top_left_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_top_right_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_bottom_left_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_bottom_right_trinket(int scale) { + return null; + } + + public void paint(Gtk.StyleContext style_context, Cairo.Context ctx, Gdk.RGBA bg_color, Gdk.RGBA selected_color, + Gdk.RGBA? border_color, Gdk.RGBA? focus_color) { + ctx.save(); + ctx.translate(allocation.x + FRAME_WIDTH, + allocation.y + FRAME_WIDTH); + // calc the top-left point of the pixbuf + Gdk.Point pixbuf_origin = Gdk.Point(); + pixbuf_origin.x = BORDER_WIDTH; + pixbuf_origin.y = BORDER_WIDTH; + + ctx.set_line_width(FRAME_WIDTH); + ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue, + selected_color.alpha); + + // draw shadow + if (border_color != null) { + ctx.save(); + Dimensions shadow_dim = Dimensions(); + shadow_dim.width = pixbuf_dim.width + BORDER_WIDTH; + shadow_dim.height = pixbuf_dim.height + BORDER_WIDTH; + paint_shadow(ctx, shadow_dim, pixbuf_origin, SHADOW_RADIUS, SHADOW_INITIAL_ALPHA); + ctx.restore(); + } + + // draw a border for the cursor with the selection width and normal border color + if (is_cursor) { + ctx.save(); + ctx.set_source_rgba(focus_color.red, focus_color.green, focus_color.blue, + focus_color.alpha); + paint_border(ctx, pixbuf_dim, pixbuf_origin, + get_selection_border_width(int.max(pixbuf_dim.width, pixbuf_dim.height))); + ctx.restore(); + } + + // draw selection border + if (is_selected()) { + // border thickness depends on the size of the thumbnail + ctx.save(); + paint_border(ctx, pixbuf_dim, pixbuf_origin, + get_selection_border_width(int.max(pixbuf_dim.width, pixbuf_dim.height))); + ctx.restore(); + } + + if (display_pixbuf != null) { + ctx.save(); + ctx.set_source_rgba(bg_color.red, bg_color.green, bg_color.blue, bg_color.alpha); + paint_image(ctx, display_pixbuf, pixbuf_origin); + ctx.restore(); + } + + // title and subtitles are LABEL_PADDING below bottom of pixbuf + int text_y = pixbuf_dim.height + FRAME_WIDTH + LABEL_PADDING; + if (title != null && title_visible) { + // get the layout sized so its width is no more than the pixbuf's + // resize the text width to be no more than the pixbuf's + title.allocation.x = 0; + title.allocation.y = text_y; + title.allocation.width = pixbuf_dim.width; + title.allocation.height = title.get_height(); + style_context.render_layout(ctx, title.allocation.x, title.allocation.y, + title.get_pango_layout(pixbuf_dim.width)); + + text_y += title.get_height() + LABEL_PADDING; + } + + if (comment != null && comment_visible) { + comment.allocation.x = 0; + comment.allocation.y = text_y; + comment.allocation.width = pixbuf_dim.width; + comment.allocation.height = comment.get_height(); + style_context.render_layout(ctx, comment.allocation.x, comment.allocation.y, + comment.get_pango_layout(pixbuf_dim.width)); + + text_y += comment.get_height() + LABEL_PADDING; + } + + if (subtitle != null && subtitle_visible) { + subtitle.allocation.x = 0; + subtitle.allocation.y = text_y; + subtitle.allocation.width = pixbuf_dim.width; + subtitle.allocation.height = subtitle.get_height(); + + style_context.render_layout(ctx, subtitle.allocation.x, subtitle.allocation.y, + subtitle.get_pango_layout(pixbuf_dim.width)); + + // increment text_y if more text lines follow + } + + ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue, + selected_color.alpha); + + // draw trinkets last + Gdk.Pixbuf? trinket = get_bottom_left_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + TRINKET_PADDING + get_horizontal_trinket_offset(); + int y = pixbuf_origin.y + pixbuf_dim.height - trinket.get_height() - + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + + trinket = get_top_left_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + TRINKET_PADDING + get_horizontal_trinket_offset(); + int y = pixbuf_origin.y + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + + trinket = get_top_right_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + pixbuf_dim.width - trinket.width - + get_horizontal_trinket_offset() - TRINKET_PADDING; + int y = pixbuf_origin.y + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + + trinket = get_bottom_right_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + pixbuf_dim.width - trinket.width - + get_horizontal_trinket_offset() - TRINKET_PADDING; + int y = pixbuf_origin.y + pixbuf_dim.height - trinket.height - + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + ctx.restore(); + } + + protected void set_horizontal_trinket_offset(int horizontal_trinket_offset) { + assert(horizontal_trinket_offset >= 0); + this.horizontal_trinket_offset = horizontal_trinket_offset; + } + + protected int get_horizontal_trinket_offset() { + return horizontal_trinket_offset; + } + + public void set_grid_coordinates(int col, int row) { + this.col = col; + this.row = row; + } + + public int get_column() { + return col; + } + + public int get_row() { + return row; + } + + public void brighten() { + // "should" implies "can" and "didn't already" + if (brightened != null || pixbuf == null) + return; + + // create a new lightened pixbuf to display + brightened = pixbuf.copy(); + shift_colors(brightened, BRIGHTEN_SHIFT, BRIGHTEN_SHIFT, BRIGHTEN_SHIFT, 0); + + display_pixbuf = brightened; + + notify_view_altered(); + } + + + public void unbrighten() { + // "should", "can", "didn't already" + if (brightened == null || pixbuf == null) + return; + + brightened = null; + + // return to the normal image + display_pixbuf = pixbuf; + + notify_view_altered(); + } + + public override void visibility_changed(bool visible) { + // if going from visible to hidden, unbrighten + if (!visible) + unbrighten(); + + base.visibility_changed(visible); + } + + private bool query_tooltip_on_text(CheckerboardItemText text, Gtk.Tooltip tooltip) { + if (!text.get_pango_layout().is_ellipsized()) + return false; + + if (text.is_marked_up()) + tooltip.set_markup(text.get_text()); + else + tooltip.set_text(text.get_text()); + + return true; + } + + public bool query_tooltip(int x, int y, Gtk.Tooltip tooltip) { + if (title != null && title_visible && coord_in_rectangle(x, y, title.allocation)) + return query_tooltip_on_text(title, tooltip); + + if (comment != null && comment_visible && coord_in_rectangle(x, y, comment.allocation)) + return query_tooltip_on_text(comment, tooltip); + + if (subtitle != null && subtitle_visible && coord_in_rectangle(x, y, subtitle.allocation)) + return query_tooltip_on_text(subtitle, tooltip); + + return false; + } +} + + diff --git a/src/CheckerboardItemText.vala b/src/CheckerboardItemText.vala new file mode 100644 index 0000000..8924938 --- /dev/null +++ b/src/CheckerboardItemText.vala @@ -0,0 +1,98 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +private class CheckerboardItemText { + private static int one_line_height = 0; + + private string text; + private bool marked_up; + private Pango.Alignment alignment; + private Pango.Layout layout = null; + private bool single_line = true; + private int height = 0; + + public Gdk.Rectangle allocation = Gdk.Rectangle(); + + public CheckerboardItemText(string text, Pango.Alignment alignment = Pango.Alignment.LEFT, + bool marked_up = false) { + this.text = text; + this.marked_up = marked_up; + this.alignment = alignment; + + single_line = is_single_line(); + } + + private bool is_single_line() { + return !String.contains_char(text, '\n'); + } + + public bool is_marked_up() { + return marked_up; + } + + public bool is_set_to(string text, bool marked_up, Pango.Alignment alignment) { + return (this.marked_up == marked_up && this.alignment == alignment && this.text == text); + } + + public string get_text() { + return text; + } + + public int get_height() { + if (height == 0) + update_height(); + + return height; + } + + public Pango.Layout get_pango_layout(int max_width = 0) { + if (layout == null) + create_pango(); + + if (max_width > 0) + layout.set_width(max_width * Pango.SCALE); + + return layout; + } + + public void clear_pango_layout() { + layout = null; + } + + private void update_height() { + if (one_line_height != 0 && single_line) + height = one_line_height; + else + create_pango(); + } + + private void create_pango() { + // create layout for this string and ellipsize so it never extends past its laid-down width + layout = AppWindow.get_instance().create_pango_layout(null); + if (!marked_up) + layout.set_text(text, -1); + else + layout.set_markup(text, -1); + + layout.set_ellipsize(Pango.EllipsizeMode.END); + layout.set_alignment(alignment); + + // getting pixel size is expensive, and we only need the height, so use cached values + // whenever possible + if (one_line_height != 0 && single_line) { + height = one_line_height; + } else { + int width; + layout.get_pixel_size(out width, out height); + + // cache first one-line height discovered + if (one_line_height == 0 && single_line) + one_line_height = height; + } + } +} + + diff --git a/src/CheckerboardLayout.vala b/src/CheckerboardLayout.vala index 70e3b5c..85232f3 100644 --- a/src/CheckerboardLayout.vala +++ b/src/CheckerboardLayout.vala @@ -4,824 +4,6 @@ * See the COPYING file in this distribution. */ -private class CheckerboardItemText { - private static int one_line_height = 0; - - private string text; - private bool marked_up; - private Pango.Alignment alignment; - private Pango.Layout layout = null; - private bool single_line = true; - private int height = 0; - - public Gdk.Rectangle allocation = Gdk.Rectangle(); - - public CheckerboardItemText(string text, Pango.Alignment alignment = Pango.Alignment.LEFT, - bool marked_up = false) { - this.text = text; - this.marked_up = marked_up; - this.alignment = alignment; - - single_line = is_single_line(); - } - - private bool is_single_line() { - return !String.contains_char(text, '\n'); - } - - public bool is_marked_up() { - return marked_up; - } - - public bool is_set_to(string text, bool marked_up, Pango.Alignment alignment) { - return (this.marked_up == marked_up && this.alignment == alignment && this.text == text); - } - - public string get_text() { - return text; - } - - public int get_height() { - if (height == 0) - update_height(); - - return height; - } - - public Pango.Layout get_pango_layout(int max_width = 0) { - if (layout == null) - create_pango(); - - if (max_width > 0) - layout.set_width(max_width * Pango.SCALE); - - return layout; - } - - public void clear_pango_layout() { - layout = null; - } - - private void update_height() { - if (one_line_height != 0 && single_line) - height = one_line_height; - else - create_pango(); - } - - private void create_pango() { - // create layout for this string and ellipsize so it never extends past its laid-down width - layout = AppWindow.get_instance().create_pango_layout(null); - if (!marked_up) - layout.set_text(text, -1); - else - layout.set_markup(text, -1); - - layout.set_ellipsize(Pango.EllipsizeMode.END); - layout.set_alignment(alignment); - - // getting pixel size is expensive, and we only need the height, so use cached values - // whenever possible - if (one_line_height != 0 && single_line) { - height = one_line_height; - } else { - int width; - layout.get_pixel_size(out width, out height); - - // cache first one-line height discovered - if (one_line_height == 0 && single_line) - one_line_height = height; - } - } -} - -public abstract class CheckerboardItem : ThumbnailView { - // Collection properties CheckerboardItem understands - // SHOW_TITLES (bool) - public const string PROP_SHOW_TITLES = "show-titles"; - // SHOW_COMMENTS (bool) - public const string PROP_SHOW_COMMENTS = "show-comments"; - // SHOW_SUBTITLES (bool) - public const string PROP_SHOW_SUBTITLES = "show-subtitles"; - - public const int FRAME_WIDTH = 8; - public const int LABEL_PADDING = 4; - public const int BORDER_WIDTH = 1; - - public const int SHADOW_RADIUS = 4; - public const float SHADOW_INITIAL_ALPHA = 0.5f; - - public const int TRINKET_SCALE = 12; - public const int TRINKET_PADDING = 1; - - public const int BRIGHTEN_SHIFT = 0x18; - - public Dimensions requisition = Dimensions(); - public Gdk.Rectangle allocation = Gdk.Rectangle(); - - private bool exposure = false; - private CheckerboardItemText? title = null; - private bool title_visible = true; - private CheckerboardItemText? comment = null; - private bool comment_visible = true; - private CheckerboardItemText? subtitle = null; - private bool subtitle_visible = false; - private bool is_cursor = false; - private Pango.Alignment tag_alignment = Pango.Alignment.LEFT; - private Gee.List? user_visible_tag_list = null; - private Gee.Collection tags; - private Gdk.Pixbuf pixbuf = null; - private Gdk.Pixbuf display_pixbuf = null; - private Gdk.Pixbuf brightened = null; - private Dimensions pixbuf_dim = Dimensions(); - private int col = -1; - private int row = -1; - private int horizontal_trinket_offset = 0; - - protected CheckerboardItem(ThumbnailSource source, Dimensions initial_pixbuf_dim, string title, string? comment, - bool marked_up = false, Pango.Alignment alignment = Pango.Alignment.LEFT) { - base(source); - - pixbuf_dim = initial_pixbuf_dim; - this.title = new CheckerboardItemText(title, alignment, marked_up); - // on the checkboard page we display the comment in - // one line, i.e., replacing all newlines with spaces. - // that means that the display will contain "..." if the comment - // is too long. - // warning: changes here have to be done in set_comment, too! - if (comment != null) - this.comment = new CheckerboardItemText(comment.replace("\n", " "), alignment, - marked_up); - - // Don't calculate size here, wait for the item to be assigned to a ViewCollection - // (notify_membership_changed) and calculate when the collection's property settings - // are known - } - - public bool has_tags { get; private set; } - - public override string get_name() { - return (title != null) ? title.get_text() : base.get_name(); - } - - public string get_title() { - return (title != null) ? title.get_text() : ""; - } - - public string get_comment() { - return (comment != null) ? comment.get_text() : ""; - } - - public void set_title(string text, bool marked_up = false, - Pango.Alignment alignment = Pango.Alignment.LEFT) { - if (title != null && title.is_set_to(text, marked_up, alignment)) - return; - - title = new CheckerboardItemText(text, alignment, marked_up); - - if (title_visible) { - recalc_size("set_title"); - notify_view_altered(); - } - } - - public void translate_coordinates(ref int x, ref int y) { - x -= allocation.x + FRAME_WIDTH; - y -= allocation.y + FRAME_WIDTH; - } - - public void clear_title() { - if (title == null) - return; - - title = null; - - if (title_visible) { - recalc_size("clear_title"); - notify_view_altered(); - } - } - - private void set_title_visible(bool visible) { - if (title_visible == visible) - return; - - title_visible = visible; - - recalc_size("set_title_visible"); - notify_view_altered(); - } - - public void set_comment(string text, bool marked_up = false, - Pango.Alignment alignment = Pango.Alignment.LEFT) { - if (comment != null && comment.is_set_to(text, marked_up, alignment)) - return; - - comment = new CheckerboardItemText(text.replace("\n", " "), alignment, marked_up); - - if (comment_visible) { - recalc_size("set_comment"); - notify_view_altered(); - } - } - - public void clear_comment() { - if (comment == null) - return; - - comment = null; - - if (comment_visible) { - recalc_size("clear_comment"); - notify_view_altered(); - } - } - - private void set_comment_visible(bool visible) { - if (comment_visible == visible) - return; - - comment_visible = visible; - - recalc_size("set_comment_visible"); - notify_view_altered(); - } - - public void set_tags(Gee.Collection? tags, - Pango.Alignment alignment = Pango.Alignment.LEFT) { - has_tags = (tags != null && tags.size > 0); - tag_alignment = alignment; - string text; - if (has_tags) { - this.tags = tags; - user_visible_tag_list = Tag.make_user_visible_tag_list(tags); - text = Tag.make_tag_markup_string(user_visible_tag_list); - } else { - text = "."; - } - - if (subtitle != null && subtitle.is_set_to(text, true, alignment)) - return; - subtitle = new CheckerboardItemText(text, alignment, true); - - if (subtitle_visible) { - recalc_size("set_subtitle"); - notify_view_altered(); - } - } - - public void clear_tags() { - clear_subtitle(); - has_tags = false; - user_visible_tag_list = null; - } - - public void highlight_user_visible_tag(int index) - requires (user_visible_tag_list != null) { - string text = Tag.make_tag_markup_string(user_visible_tag_list, index); - subtitle = new CheckerboardItemText(text, tag_alignment, true); - - if (subtitle_visible) - notify_view_altered(); - } - - public Tag get_user_visible_tag(int index) - requires (index >= 0 && index < user_visible_tag_list.size) { - return user_visible_tag_list.get(index); - } - - public Pango.Layout? get_tag_list_layout() { - return has_tags ? subtitle.get_pango_layout() : null; - } - - public Gdk.Rectangle get_subtitle_allocation() { - return subtitle.allocation; - } - - public string get_subtitle() { - return (subtitle != null) ? subtitle.get_text() : ""; - } - - public void set_subtitle(string text, bool marked_up = false, - Pango.Alignment alignment = Pango.Alignment.LEFT) { - if (subtitle != null && subtitle.is_set_to(text, marked_up, alignment)) - return; - - subtitle = new CheckerboardItemText(text, alignment, marked_up); - - if (subtitle_visible) { - recalc_size("set_subtitle"); - notify_view_altered(); - } - } - - public void clear_subtitle() { - if (subtitle == null) - return; - - subtitle = null; - - if (subtitle_visible) { - recalc_size("clear_subtitle"); - notify_view_altered(); - } - } - - private void set_subtitle_visible(bool visible) { - if (subtitle_visible == visible) - return; - - subtitle_visible = visible; - - recalc_size("set_subtitle_visible"); - notify_view_altered(); - } - - public void set_is_cursor(bool is_cursor) { - this.is_cursor = is_cursor; - } - - public bool get_is_cursor() { - return is_cursor; - } - - public virtual void handle_mouse_motion(int x, int y, int height, int width) { - - } - - public virtual void handle_mouse_leave() { - unbrighten(); - } - - public virtual void handle_mouse_enter() { - brighten(); - } - - protected override void notify_membership_changed(DataCollection? collection) { - bool title_visible = (bool) get_collection_property(PROP_SHOW_TITLES, true); - bool comment_visible = (bool) get_collection_property(PROP_SHOW_COMMENTS, true); - bool subtitle_visible = (bool) get_collection_property(PROP_SHOW_SUBTITLES, false); - - bool altered = false; - if (this.title_visible != title_visible) { - this.title_visible = title_visible; - altered = true; - } - - if (this.comment_visible != comment_visible) { - this.comment_visible = comment_visible; - altered = true; - } - - if (this.subtitle_visible != subtitle_visible) { - this.subtitle_visible = subtitle_visible; - altered = true; - } - - if (altered || !requisition.has_area()) { - recalc_size("notify_membership_changed"); - notify_view_altered(); - } - - base.notify_membership_changed(collection); - } - - protected override void notify_collection_property_set(string name, Value? old, Value val) { - switch (name) { - case PROP_SHOW_TITLES: - set_title_visible((bool) val); - break; - - case PROP_SHOW_COMMENTS: - set_comment_visible((bool) val); - break; - - case PROP_SHOW_SUBTITLES: - set_subtitle_visible((bool) val); - break; - } - - base.notify_collection_property_set(name, old, val); - } - - // The alignment point is the coordinate on the y-axis (relative to the top of the - // CheckerboardItem) which this item should be aligned to. This allows for - // bottom-alignment along the bottom edge of the thumbnail. - public int get_alignment_point() { - return FRAME_WIDTH + BORDER_WIDTH + pixbuf_dim.height; - } - - public virtual void exposed() { - exposure = true; - } - - public virtual void unexposed() { - exposure = false; - - if (title != null) - title.clear_pango_layout(); - - if (comment != null) - comment.clear_pango_layout(); - - if (subtitle != null) - subtitle.clear_pango_layout(); - } - - public virtual bool is_exposed() { - return exposure; - } - - public bool has_image() { - return pixbuf != null; - } - - public Gdk.Pixbuf? get_image() { - return pixbuf; - } - - public void set_image(Gdk.Pixbuf pixbuf) { - this.pixbuf = pixbuf; - display_pixbuf = pixbuf; - pixbuf_dim = Dimensions.for_pixbuf(pixbuf); - - recalc_size("set_image"); - notify_view_altered(); - } - - public void clear_image(Dimensions dim) { - bool had_image = pixbuf != null; - - pixbuf = null; - display_pixbuf = null; - pixbuf_dim = dim; - - recalc_size("clear_image"); - - if (had_image) - notify_view_altered(); - } - - public static int get_max_width(int scale) { - // width is frame width (two sides) + frame padding (two sides) + width of pixbuf (text - // never wider) - return (FRAME_WIDTH * 2) + scale; - } - - private void recalc_size(string reason) { - Dimensions old_requisition = requisition; - - // only add in the text heights if they're displayed - int title_height = (title != null && title_visible) - ? title.get_height() + LABEL_PADDING : 0; - int comment_height = (comment != null && comment_visible) - ? comment.get_height() + LABEL_PADDING : 0; - int subtitle_height = (subtitle != null && subtitle_visible) - ? subtitle.get_height() + LABEL_PADDING : 0; - - // width is frame width (two sides) + frame padding (two sides) + width of pixbuf - // (text never wider) - requisition.width = (FRAME_WIDTH * 2) + (BORDER_WIDTH * 2) + pixbuf_dim.width; - - // height is frame width (two sides) + frame padding (two sides) + height of pixbuf - // + height of text + label padding (between pixbuf and text) - requisition.height = (FRAME_WIDTH * 2) + (BORDER_WIDTH * 2) - + pixbuf_dim.height + title_height + comment_height + subtitle_height; - -#if TRACE_REFLOW_ITEMS - debug("recalc_size %s: %s title_height=%d comment_height=%d subtitle_height=%d requisition=%s", - get_source().get_name(), reason, title_height, comment_height, subtitle_height, - requisition.to_string()); -#endif - - if (!requisition.approx_equals(old_requisition)) { -#if TRACE_REFLOW_ITEMS - debug("recalc_size %s: %s notifying geometry altered", get_source().get_name(), reason); -#endif - notify_geometry_altered(); - } - } - - protected static Dimensions get_border_dimensions(Dimensions object_dim, int border_width) { - Dimensions dimensions = Dimensions(); - dimensions.width = object_dim.width + (border_width * 2); - dimensions.height = object_dim.height + (border_width * 2); - return dimensions; - } - - protected static Gdk.Point get_border_origin(Gdk.Point object_origin, int border_width) { - Gdk.Point origin = Gdk.Point(); - origin.x = object_origin.x - border_width; - origin.y = object_origin.y - border_width; - return origin; - } - - protected virtual void paint_shadow(Cairo.Context ctx, Dimensions dimensions, Gdk.Point origin, - int radius, float initial_alpha) { - double rgb_all = 0.0; - - // top right corner - paint_shadow_in_corner(ctx, origin.x + dimensions.width, origin.y + radius, rgb_all, radius, - initial_alpha, -0.5 * Math.PI, 0); - // bottom right corner - paint_shadow_in_corner(ctx, origin.x + dimensions.width, origin.y + dimensions.height, rgb_all, - radius, initial_alpha, 0, 0.5 * Math.PI); - // bottom left corner - paint_shadow_in_corner(ctx, origin.x + radius, origin.y + dimensions.height, rgb_all, radius, - initial_alpha, 0.5 * Math.PI, Math.PI); - - // left right - Cairo.Pattern lr = new Cairo.Pattern.linear(0, origin.y + dimensions.height, - 0, origin.y + dimensions.height + radius); - lr.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); - lr.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0.0); - ctx.set_source(lr); - ctx.rectangle(origin.x + radius, origin.y + dimensions.height, dimensions.width - radius, radius); - ctx.fill(); - - // top down - Cairo.Pattern td = new Cairo.Pattern.linear(origin.x + dimensions.width, - 0, origin.x + dimensions.width + radius, 0); - td.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); - td.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0.0); - ctx.set_source(td); - ctx.rectangle(origin.x + dimensions.width, origin.y + radius, - radius, dimensions.height - radius); - ctx.fill(); - } - - protected void paint_shadow_in_corner(Cairo.Context ctx, int x, int y, - double rgb_all, float radius, float initial_alpha, double arc1, double arc2) { - Cairo.Pattern p = new Cairo.Pattern.radial(x, y, 0, x, y, radius); - p.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); - p.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0); - ctx.set_source(p); - ctx.move_to(x, y); - ctx.arc(x, y, radius, arc1, arc2); - ctx.close_path(); - ctx.fill(); - } - - protected virtual void paint_border(Cairo.Context ctx, Dimensions object_dimensions, - Gdk.Point object_origin, int border_width) { - if (border_width == 1) { - ctx.rectangle(object_origin.x - border_width, object_origin.y - border_width, - object_dimensions.width + (border_width * 2), - object_dimensions.height + (border_width * 2)); - ctx.fill(); - } else { - Dimensions dimensions = get_border_dimensions(object_dimensions, border_width); - Gdk.Point origin = get_border_origin(object_origin, border_width); - - // amount of rounding needed on corners varies by size of object - double scale = int.max(object_dimensions.width, object_dimensions.height); - draw_rounded_corners_filled(ctx, dimensions, origin, 0.25 * scale); - } - } - - protected virtual void paint_image(Cairo.Context ctx, Gdk.Pixbuf pixbuf, Gdk.Point origin) { - paint_pixmap_with_background(ctx, pixbuf, origin.x, origin.y); - } - - private int get_selection_border_width(int scale) { - return ((scale <= ((Thumbnail.MIN_SCALE + Thumbnail.MAX_SCALE) / 3)) ? 5 : 4) - + BORDER_WIDTH; - } - - protected virtual Gdk.Pixbuf? get_top_left_trinket(int scale) { - return null; - } - - protected virtual Gdk.Pixbuf? get_top_right_trinket(int scale) { - return null; - } - - protected virtual Gdk.Pixbuf? get_bottom_left_trinket(int scale) { - return null; - } - - protected virtual Gdk.Pixbuf? get_bottom_right_trinket(int scale) { - return null; - } - - public void paint(Gtk.StyleContext style_context, Cairo.Context ctx, Gdk.RGBA bg_color, Gdk.RGBA selected_color, - Gdk.RGBA? border_color, Gdk.RGBA? focus_color) { - ctx.save(); - ctx.translate(allocation.x + FRAME_WIDTH, - allocation.y + FRAME_WIDTH); - // calc the top-left point of the pixbuf - Gdk.Point pixbuf_origin = Gdk.Point(); - pixbuf_origin.x = BORDER_WIDTH; - pixbuf_origin.y = BORDER_WIDTH; - - ctx.set_line_width(FRAME_WIDTH); - ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue, - selected_color.alpha); - - // draw shadow - if (border_color != null) { - ctx.save(); - Dimensions shadow_dim = Dimensions(); - shadow_dim.width = pixbuf_dim.width + BORDER_WIDTH; - shadow_dim.height = pixbuf_dim.height + BORDER_WIDTH; - paint_shadow(ctx, shadow_dim, pixbuf_origin, SHADOW_RADIUS, SHADOW_INITIAL_ALPHA); - ctx.restore(); - } - - // draw a border for the cursor with the selection width and normal border color - if (is_cursor) { - ctx.save(); - ctx.set_source_rgba(focus_color.red, focus_color.green, focus_color.blue, - focus_color.alpha); - paint_border(ctx, pixbuf_dim, pixbuf_origin, - get_selection_border_width(int.max(pixbuf_dim.width, pixbuf_dim.height))); - ctx.restore(); - } - - // draw selection border - if (is_selected()) { - // border thickness depends on the size of the thumbnail - ctx.save(); - paint_border(ctx, pixbuf_dim, pixbuf_origin, - get_selection_border_width(int.max(pixbuf_dim.width, pixbuf_dim.height))); - ctx.restore(); - } - - if (display_pixbuf != null) { - ctx.save(); - ctx.set_source_rgba(bg_color.red, bg_color.green, bg_color.blue, bg_color.alpha); - paint_image(ctx, display_pixbuf, pixbuf_origin); - ctx.restore(); - } - - // title and subtitles are LABEL_PADDING below bottom of pixbuf - int text_y = pixbuf_dim.height + FRAME_WIDTH + LABEL_PADDING; - if (title != null && title_visible) { - // get the layout sized so its width is no more than the pixbuf's - // resize the text width to be no more than the pixbuf's - title.allocation.x = 0; - title.allocation.y = text_y; - title.allocation.width = pixbuf_dim.width; - title.allocation.height = title.get_height(); - style_context.render_layout(ctx, title.allocation.x, title.allocation.y, - title.get_pango_layout(pixbuf_dim.width)); - - text_y += title.get_height() + LABEL_PADDING; - } - - if (comment != null && comment_visible) { - comment.allocation.x = 0; - comment.allocation.y = text_y; - comment.allocation.width = pixbuf_dim.width; - comment.allocation.height = comment.get_height(); - style_context.render_layout(ctx, comment.allocation.x, comment.allocation.y, - comment.get_pango_layout(pixbuf_dim.width)); - - text_y += comment.get_height() + LABEL_PADDING; - } - - if (subtitle != null && subtitle_visible) { - subtitle.allocation.x = 0; - subtitle.allocation.y = text_y; - subtitle.allocation.width = pixbuf_dim.width; - subtitle.allocation.height = subtitle.get_height(); - - style_context.render_layout(ctx, subtitle.allocation.x, subtitle.allocation.y, - subtitle.get_pango_layout(pixbuf_dim.width)); - - // increment text_y if more text lines follow - } - - ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue, - selected_color.alpha); - - // draw trinkets last - Gdk.Pixbuf? trinket = get_bottom_left_trinket(TRINKET_SCALE); - if (trinket != null) { - int x = pixbuf_origin.x + TRINKET_PADDING + get_horizontal_trinket_offset(); - int y = pixbuf_origin.y + pixbuf_dim.height - trinket.get_height() - - TRINKET_PADDING; - Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); - ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); - ctx.fill(); - } - - trinket = get_top_left_trinket(TRINKET_SCALE); - if (trinket != null) { - int x = pixbuf_origin.x + TRINKET_PADDING + get_horizontal_trinket_offset(); - int y = pixbuf_origin.y + TRINKET_PADDING; - Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); - ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); - ctx.fill(); - } - - trinket = get_top_right_trinket(TRINKET_SCALE); - if (trinket != null) { - int x = pixbuf_origin.x + pixbuf_dim.width - trinket.width - - get_horizontal_trinket_offset() - TRINKET_PADDING; - int y = pixbuf_origin.y + TRINKET_PADDING; - Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); - ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); - ctx.fill(); - } - - trinket = get_bottom_right_trinket(TRINKET_SCALE); - if (trinket != null) { - int x = pixbuf_origin.x + pixbuf_dim.width - trinket.width - - get_horizontal_trinket_offset() - TRINKET_PADDING; - int y = pixbuf_origin.y + pixbuf_dim.height - trinket.height - - TRINKET_PADDING; - Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); - ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); - ctx.fill(); - } - ctx.restore(); - } - - protected void set_horizontal_trinket_offset(int horizontal_trinket_offset) { - assert(horizontal_trinket_offset >= 0); - this.horizontal_trinket_offset = horizontal_trinket_offset; - } - - protected int get_horizontal_trinket_offset() { - return horizontal_trinket_offset; - } - - public void set_grid_coordinates(int col, int row) { - this.col = col; - this.row = row; - } - - public int get_column() { - return col; - } - - public int get_row() { - return row; - } - - public void brighten() { - // "should" implies "can" and "didn't already" - if (brightened != null || pixbuf == null) - return; - - // create a new lightened pixbuf to display - brightened = pixbuf.copy(); - shift_colors(brightened, BRIGHTEN_SHIFT, BRIGHTEN_SHIFT, BRIGHTEN_SHIFT, 0); - - display_pixbuf = brightened; - - notify_view_altered(); - } - - - public void unbrighten() { - // "should", "can", "didn't already" - if (brightened == null || pixbuf == null) - return; - - brightened = null; - - // return to the normal image - display_pixbuf = pixbuf; - - notify_view_altered(); - } - - public override void visibility_changed(bool visible) { - // if going from visible to hidden, unbrighten - if (!visible) - unbrighten(); - - base.visibility_changed(visible); - } - - private bool query_tooltip_on_text(CheckerboardItemText text, Gtk.Tooltip tooltip) { - if (!text.get_pango_layout().is_ellipsized()) - return false; - - if (text.is_marked_up()) - tooltip.set_markup(text.get_text()); - else - tooltip.set_text(text.get_text()); - - return true; - } - - public bool query_tooltip(int x, int y, Gtk.Tooltip tooltip) { - if (title != null && title_visible && coord_in_rectangle(x, y, title.allocation)) - return query_tooltip_on_text(title, tooltip); - - if (comment != null && comment_visible && coord_in_rectangle(x, y, comment.allocation)) - return query_tooltip_on_text(comment, tooltip); - - if (subtitle != null && subtitle_visible && coord_in_rectangle(x, y, subtitle.allocation)) - return query_tooltip_on_text(subtitle, tooltip); - - return false; - } -} - public class CheckerboardLayout : Gtk.DrawingArea { public const int TOP_PADDING = 16; public const int BOTTOM_PADDING = 16; @@ -836,7 +18,7 @@ public class CheckerboardLayout : Gtk.DrawingArea { // The number of pixels that the scrollbars of Gtk.ScrolledWindows allocate for themselves // before their final size is computed. This must be taken into account when computing // the width of this widget. This value was 0 in Gtk+ 2.x but is 1 in Gtk+ 3.x. See - // ticket #3870 (http://redmine.yorba.org/issues/3870) for more information + // ticket #3870 (https://bugzilla.gnome.org/show_bug.cgi?id=717754) for more information private const int SCROLLBAR_PLACEHOLDER_WIDTH = 1; private class LayoutRow { @@ -857,7 +39,6 @@ public class CheckerboardLayout : Gtk.DrawingArea { private Gee.HashSet exposed_items = new Gee.HashSet(); private Gtk.Adjustment hadjustment = null; private Gtk.Adjustment vadjustment = null; - private string message = null; private Gdk.RGBA selected_color; private Gdk.RGBA unselected_color; private Gdk.RGBA focus_color; @@ -963,23 +144,18 @@ public class CheckerboardLayout : Gtk.DrawingArea { Gtk.Allocation parent_allocation; parent.get_allocation(out parent_allocation); - if (message == null) { - // set the layout's new size to be the same as the parent's width but maintain - // it's own height + // set the layout's new size to be the same as the parent's width but maintain + // it's own height #if TRACE_REFLOW - debug("on_viewport_resized: due_to_reflow=%s set_size_request %dx%d", - size_allocate_due_to_reflow.to_string(), parent_allocation.width, req.height); + debug("on_viewport_resized: due_to_reflow=%s set_size_request %dx%d", + size_allocate_due_to_reflow.to_string(), parent_allocation.width, req.height); #endif - // But if the current height is 0, don't request a size yet. Delay - // it to do_reflow (bgo#766864) - if (req.height != 0) { - set_size_request(parent_allocation.width - SCROLLBAR_PLACEHOLDER_WIDTH, req.height); - } - } else { - // set the layout's width and height to always match the parent's - set_size_request(parent_allocation.width, parent_allocation.height); + // But if the current height is 0, don't request a size yet. Delay + // it to do_reflow (bgo#766864) + if (req.height != 0) { + set_size_request(parent_allocation.width - SCROLLBAR_PLACEHOLDER_WIDTH, req.height); } - + // possible for this widget's size_allocate not to be called, so need to update the page // rect here viewport_resized(); @@ -1070,8 +246,6 @@ public class CheckerboardLayout : Gtk.DrawingArea { private void on_contents_altered(Gee.Iterable? added, Gee.Iterable? removed) { - if (added != null) - message = null; if (removed != null) { foreach (DataObject object in removed) @@ -1142,31 +316,6 @@ public class CheckerboardLayout : Gtk.DrawingArea { queue_draw(); } - public void set_message(string? text) { - if (text == message) - return; - - message = text; - - if (text != null) { - // message is being set, change size to match parent's; if no parent, then the size - // will be set later when added to the parent - if (parent != null) { - Gtk.Allocation parent_allocation; - parent.get_allocation(out parent_allocation); - - set_size_request(parent_allocation.width, parent_allocation.height); - } - } else { - // message is being cleared, layout all the items again - need_reflow("set_message"); - } - } - - public void unset_message() { - set_message(null); - } - private void update_visible_page() { if (hadjustment != null && vadjustment != null) visible_page = get_adjustment_page(hadjustment, vadjustment); @@ -1185,7 +334,7 @@ public class CheckerboardLayout : Gtk.DrawingArea { } public CheckerboardItem? get_item_at_pixel(double xd, double yd) { - if (message != null || item_rows == null) + if (item_rows == null) return null; int x = (int) xd; @@ -1560,10 +709,6 @@ public class CheckerboardLayout : Gtk.DrawingArea { private void reflow(string caller) { reflow_needed = false; - // if set in message mode, nothing to do here - if (message != null) - return; - Gtk.Allocation allocation; get_allocation(out allocation); @@ -1957,35 +1102,17 @@ public class CheckerboardLayout : Gtk.DrawingArea { get_allocation(out allocation); get_style_context().render_background (ctx, 0, 0, allocation.width, allocation.height); - // watch for message mode - if (message == null) { #if TRACE_REFLOW - debug("draw %s: %s", page_name, rectangle_to_string(visible_page)); + debug("draw %s: %s", page_name, rectangle_to_string(visible_page)); #endif - - if (exposure_dirty) - expose_items("draw"); - - // have all items in the exposed area paint themselves - foreach (CheckerboardItem item in intersection(visible_page)) { - item.paint(get_style_context(), ctx, bg_color, item.is_selected() ? selected_color : unselected_color, - border_color, focus_color); - } - } else { - // draw the message in the center of the window - Pango.Layout pango_layout = create_pango_layout(message); - int text_width, text_height; - pango_layout.get_pixel_size(out text_width, out text_height); - - get_allocation(out allocation); - - int x = allocation.width - text_width; - x = (x > 0) ? x / 2 : 0; - - int y = allocation.height - text_height; - y = (y > 0) ? y / 2 : 0; - - get_style_context().render_layout(ctx, x, y, pango_layout); + + if (exposure_dirty) + expose_items("draw"); + + // have all items in the exposed area paint themselves + foreach (CheckerboardItem item in intersection(visible_page)) { + item.paint(get_style_context(), ctx, bg_color, item.is_selected() ? selected_color : unselected_color, + border_color, focus_color); } bool result = (base.draw != null) ? base.draw(ctx) : true; @@ -2025,7 +1152,14 @@ public class CheckerboardLayout : Gtk.DrawingArea { public override bool query_tooltip(int x, int y, bool keyboard_mode, Gtk.Tooltip tooltip) { CheckerboardItem? item = get_item_at_pixel(x, y); - return (item != null) ? item.query_tooltip(x, y, tooltip) : false; + // Note: X & Y allocations are relative to parents, so we need to query the item's tooltip + // relative to its INTERNAL coordinates, otherwise tooltips don't work + if (item != null) { + item.translate_coordinates(ref x, ref y); + return item.query_tooltip(x, y, tooltip); + } else { + return false; + } } private void on_colors_changed() { diff --git a/src/CheckerboardPage.vala b/src/CheckerboardPage.vala new file mode 100644 index 0000000..24a252a --- /dev/null +++ b/src/CheckerboardPage.vala @@ -0,0 +1,758 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +public abstract class CheckerboardPage : Page { + private const int AUTOSCROLL_PIXELS = 50; + private const int AUTOSCROLL_TICKS_MSEC = 50; + + private CheckerboardLayout layout; + private Gtk.Stack stack; + private PageMessagePane message_pane; + private string item_context_menu_path = null; + private string page_context_menu_path = null; + private Gtk.Viewport viewport = new Gtk.Viewport(null, null); + protected CheckerboardItem anchor = null; + protected CheckerboardItem cursor = null; + private CheckerboardItem current_hovered_item = null; + private bool autoscroll_scheduled = false; + private CheckerboardItem activated_item = null; + private Gee.ArrayList previously_selected = null; + + public enum Activator { + KEYBOARD, + MOUSE + } + + public struct KeyboardModifiers { + public KeyboardModifiers(Page page) { + ctrl_pressed = page.get_ctrl_pressed(); + alt_pressed = page.get_alt_pressed(); + shift_pressed = page.get_shift_pressed(); + super_pressed = page.get_super_pressed(); + } + + public bool ctrl_pressed; + public bool alt_pressed; + public bool shift_pressed; + public bool super_pressed; + } + + protected CheckerboardPage(string page_name) { + base (page_name); + + stack = new Gtk.Stack(); + message_pane = new PageMessagePane(); + + layout = new CheckerboardLayout(get_view()); + layout.set_name(page_name); + stack.add_named (layout, "layout"); + stack.add_named (message_pane, "message"); + stack.set_visible_child(layout); + + set_event_source(layout); + + set_border_width(0); + set_shadow_type(Gtk.ShadowType.NONE); + + viewport.set_border_width(0); + viewport.set_shadow_type(Gtk.ShadowType.NONE); + + viewport.add(stack); + + // want to set_adjustments before adding to ScrolledWindow to let our signal handlers + // run first ... otherwise, the thumbnails draw late + layout.set_adjustments(get_hadjustment(), get_vadjustment()); + + add(viewport); + + // need to monitor items going hidden when dealing with anchor/cursor/highlighted items + get_view().items_hidden.connect(on_items_hidden); + get_view().contents_altered.connect(on_contents_altered); + get_view().items_state_changed.connect(on_items_state_changed); + get_view().items_visibility_changed.connect(on_items_visibility_changed); + + // scrollbar policy + set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); + } + + public void init_item_context_menu(string path) { + item_context_menu_path = path; + } + + public void init_page_context_menu(string path) { + page_context_menu_path = path; + } + + public Gtk.Menu? get_context_menu() { + // show page context menu if nothing is selected + return (get_view().get_selected_count() != 0) ? get_item_context_menu() : + get_page_context_menu(); + } + + private Gtk.Menu item_context_menu; + public virtual Gtk.Menu? get_item_context_menu() { + if (item_context_menu == null) { + var model = this.builder.get_object (item_context_menu_path) + as GLib.MenuModel; + item_context_menu = new Gtk.Menu.from_model (model); + item_context_menu.attach_to_widget (this, null); + } + + return item_context_menu; + } + + private Gtk.Menu page_context_menu; + public override Gtk.Menu? get_page_context_menu() { + if (page_context_menu_path == null) + return null; + + if (page_context_menu == null) { + var model = this.builder.get_object (page_context_menu_path) + as GLib.MenuModel; + page_context_menu = new Gtk.Menu.from_model (model); + page_context_menu.attach_to_widget (this, null); + } + + return page_context_menu; + } + + protected override bool on_context_keypress() { + return popup_context_menu(get_context_menu()); + } + + protected virtual string get_view_empty_icon() { + return "image-x-generic-symbolic"; + } + + protected virtual string get_view_empty_message() { + return _("No photos/videos"); + } + + protected virtual string get_filter_no_match_message() { + return _("No photos/videos found which match the current filter"); + } + + protected virtual void on_item_activated(CheckerboardItem item, Activator activator, + KeyboardModifiers modifiers) { + } + + public CheckerboardLayout get_checkerboard_layout() { + return layout; + } + + // Gets the search view filter for this page. + public abstract SearchViewFilter get_search_view_filter(); + + public virtual Core.ViewTracker? get_view_tracker() { + return null; + } + + public override void switching_from() { + layout.set_in_view(false); + get_search_view_filter().refresh.disconnect(on_view_filter_refresh); + + // unselect everything so selection won't persist after page loses focus + get_view().unselect_all(); + + base.switching_from(); + } + + public void scroll_to_item(CheckerboardItem item) { + Gtk.Adjustment vadj = get_vadjustment(); + if (!(get_adjustment_relation(vadj, item.allocation.y) == AdjustmentRelation.IN_RANGE + && (get_adjustment_relation(vadj, item.allocation.y + item.allocation.height) == AdjustmentRelation.IN_RANGE))) { + + // scroll to see the new item + int top = 0; + if (item.allocation.y < vadj.get_value()) { + top = item.allocation.y; + top -= CheckerboardLayout.ROW_GUTTER_PADDING / 2; + } else { + top = item.allocation.y + item.allocation.height - (int) vadj.get_page_size(); + top += CheckerboardLayout.ROW_GUTTER_PADDING / 2; + } + + vadj.set_value(top); + + } + } + + public override void switched_to() { + layout.set_in_view(true); + get_search_view_filter().refresh.connect(on_view_filter_refresh); + on_view_filter_refresh(); + + if (get_view().get_selected_count() > 0) { + CheckerboardItem? item = (CheckerboardItem?) get_view().get_selected_at(0); + + // if item is in any way out of view, scroll to it + scroll_to_item(item); + } + + base.switched_to(); + } + + private void on_view_filter_refresh() { + update_view_filter_message(); + } + + private void on_contents_altered(Gee.Iterable? added, + Gee.Iterable? removed) { + update_view_filter_message(); + } + + private void on_items_state_changed(Gee.Iterable changed) { + update_view_filter_message(); + } + + private void on_items_visibility_changed(Gee.Collection changed) { + update_view_filter_message(); + } + + private void update_view_filter_message() { + if (get_view().are_items_filtered_out() && get_view().get_count() == 0) { + set_page_message(get_filter_no_match_message()); + } else if (get_view().get_count() == 0) { + set_page_message(get_view_empty_message()); + } else { + unset_page_message(); + } + } + + public void set_page_message(string message) { + message_pane.label.label = message; + try { + message_pane.icon_image.icon_name = null; + message_pane.icon_image.gicon = Icon.new_for_string (get_view_empty_icon()); + } catch (Error error) { + message_pane.icon_image.gicon = null; + message_pane.icon_image.icon_name = "image-x-generic-symbolic"; + } + stack.set_visible_child_name ("message"); + } + + public void unset_page_message() { + stack.set_visible_child (layout); + } + + public override void set_page_name(string name) { + base.set_page_name(name); + + layout.set_name(name); + } + + public CheckerboardItem? get_item_at_pixel(double x, double y) { + return layout.get_item_at_pixel(x, y); + } + + private void on_items_hidden(Gee.Iterable hidden) { + foreach (DataView view in hidden) { + CheckerboardItem item = (CheckerboardItem) view; + + if (anchor == item) + anchor = null; + + if (cursor == item) + cursor = null; + + if (current_hovered_item == item) + current_hovered_item = null; + } + } + + protected override bool key_press_event(Gdk.EventKey event) { + bool handled = true; + + // mask out the modifiers we're interested in + uint state = event.state & Gdk.ModifierType.SHIFT_MASK; + + switch (Gdk.keyval_name(event.keyval)) { + case "Up": + case "KP_Up": + move_cursor(CompassPoint.NORTH); + select_anchor_to_cursor(state); + break; + + case "Down": + case "KP_Down": + move_cursor(CompassPoint.SOUTH); + select_anchor_to_cursor(state); + break; + + case "Left": + case "KP_Left": + move_cursor(CompassPoint.WEST); + select_anchor_to_cursor(state); + break; + + case "Right": + case "KP_Right": + move_cursor(CompassPoint.EAST); + select_anchor_to_cursor(state); + break; + + case "Home": + case "KP_Home": + CheckerboardItem? first = (CheckerboardItem?) get_view().get_first(); + if (first != null) + cursor_to_item(first); + select_anchor_to_cursor(state); + break; + + case "End": + case "KP_End": + CheckerboardItem? last = (CheckerboardItem?) get_view().get_last(); + if (last != null) + cursor_to_item(last); + select_anchor_to_cursor(state); + break; + + case "Return": + case "KP_Enter": + if (get_view().get_selected_count() == 1) + on_item_activated((CheckerboardItem) get_view().get_selected_at(0), + Activator.KEYBOARD, KeyboardModifiers(this)); + else + handled = false; + break; + + case "space": + Marker marker = get_view().mark(layout.get_cursor()); + get_view().toggle_marked(marker); + break; + + default: + handled = false; + break; + } + + if (handled) + return true; + + return (base.key_press_event != null) ? base.key_press_event(event) : true; + } + + protected override bool on_left_click(Gdk.EventButton event) { + // only interested in single-click and double-clicks for now + if ((event.type != Gdk.EventType.BUTTON_PRESS) && (event.type != Gdk.EventType.2BUTTON_PRESS)) + return false; + + // mask out the modifiers we're interested in + uint state = event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK); + + // use clicks for multiple selection and activation only; single selects are handled by + // button release, to allow for multiple items to be selected then dragged ... + CheckerboardItem item = get_item_at_pixel(event.x, event.y); + if (item != null) { + // ... however, there is no dragging if the user clicks on an interactive part of the + // CheckerboardItem (e.g. a tag) + if (layout.handle_left_click(item, event.x, event.y, event.state)) + return true; + + switch (state) { + case Gdk.ModifierType.CONTROL_MASK: + // with only Ctrl pressed, multiple selections are possible ... chosen item + // is toggled + Marker marker = get_view().mark(item); + get_view().toggle_marked(marker); + + if (item.is_selected()) { + anchor = item; + cursor = item; + } + break; + + case Gdk.ModifierType.SHIFT_MASK: + get_view().unselect_all(); + + if (anchor == null) + anchor = item; + + select_between_items(anchor, item); + + cursor = item; + break; + + case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK: + // Ticket #853 - Make Ctrl + Shift + Mouse Button 1 able to start a new run + // of contiguous selected items without unselecting previously-selected items + // a la Nautilus. + // Same as the case for SHIFT_MASK, but don't unselect anything first. + if (anchor == null) + anchor = item; + + select_between_items(anchor, item); + + cursor = item; + break; + + default: + if (event.type == Gdk.EventType.2BUTTON_PRESS) { + activated_item = item; + } else { + // if the user has selected one or more items and is preparing for a drag, + // don't want to blindly unselect: if they've clicked on an unselected item + // unselect all and select that one; if they've clicked on a previously + // selected item, do nothing + if (!item.is_selected()) { + Marker all = get_view().start_marking(); + all.mark_many(get_view().get_selected()); + + get_view().unselect_and_select_marked(all, get_view().mark(item)); + } + } + + anchor = item; + cursor = item; + break; + } + layout.set_cursor(item); + } else { + // user clicked on "dead" area; only unselect if control is not pressed + // do we want similar behavior for shift as well? + if (state != Gdk.ModifierType.CONTROL_MASK) + get_view().unselect_all(); + + // grab previously marked items + previously_selected = new Gee.ArrayList(); + foreach (DataView view in get_view().get_selected()) + previously_selected.add((CheckerboardItem) view); + + layout.set_drag_select_origin((int) event.x, (int) event.y); + + return true; + } + + // need to determine if the signal should be passed to the DnD handlers + // Return true to block the DnD handler, false otherwise + + return get_view().get_selected_count() == 0; + } + + protected override bool on_left_released(Gdk.EventButton event) { + previously_selected = null; + + // if drag-selecting, stop here and do nothing else + if (layout.is_drag_select_active()) { + layout.clear_drag_select(); + anchor = cursor; + + return true; + } + + // only interested in non-modified button releases + if ((event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) != 0) + return false; + + // if the item was activated in the double-click, report it now + if (activated_item != null) { + on_item_activated(activated_item, Activator.MOUSE, KeyboardModifiers(this)); + activated_item = null; + + return true; + } + + CheckerboardItem item = get_item_at_pixel(event.x, event.y); + if (item == null) { + // released button on "dead" area + return true; + } + + if (cursor != item) { + // user released mouse button after moving it off the initial item, or moved from dead + // space onto one. either way, unselect everything + get_view().unselect_all(); + } else { + // the idea is, if a user single-clicks on an item with no modifiers, then all other items + // should be deselected, however, if they single-click in order to drag one or more items, + // they should remain selected, hence performing this here rather than on_left_click + // (item may not be selected if an unimplemented modifier key was used) + if (item.is_selected()) + get_view().unselect_all_but(item); + } + + return true; + } + + protected override bool on_right_click(Gdk.EventButton event) { + // only interested in single-clicks for now + if (event.type != Gdk.EventType.BUTTON_PRESS) + return false; + + // get what's right-clicked upon + CheckerboardItem item = get_item_at_pixel(event.x, event.y); + if (item != null) { + // mask out the modifiers we're interested in + switch (event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) { + case Gdk.ModifierType.CONTROL_MASK: + // chosen item is toggled + Marker marker = get_view().mark(item); + get_view().toggle_marked(marker); + break; + + case Gdk.ModifierType.SHIFT_MASK: + // TODO + break; + + case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK: + // TODO + break; + + default: + // if the item is already selected, proceed; if item is not selected, a bare right + // click unselects everything else but it + if (!item.is_selected()) { + Marker all = get_view().start_marking(); + all.mark_many(get_view().get_selected()); + + get_view().unselect_and_select_marked(all, get_view().mark(item)); + } + break; + } + } else { + // clicked in "dead" space, unselect everything + get_view().unselect_all(); + } + + Gtk.Menu context_menu = get_context_menu(); + return popup_context_menu(context_menu, event); + } + + protected virtual bool on_mouse_over(CheckerboardItem? item, int x, int y, Gdk.ModifierType mask) { + if (item != null) + layout.handle_mouse_motion(item, x, y, mask); + + // if hovering over the last hovered item, or both are null (nothing highlighted and + // hovering over empty space), do nothing + if (item == current_hovered_item) + return true; + + // either something new is highlighted or now hovering over empty space, so dim old item + if (current_hovered_item != null) { + current_hovered_item.handle_mouse_leave(); + current_hovered_item = null; + } + + // if over empty space, done + if (item == null) + return true; + + // brighten the new item + current_hovered_item = item; + current_hovered_item.handle_mouse_enter(); + + return true; + } + + protected override bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) { + // report what item the mouse is hovering over + if (!on_mouse_over(get_item_at_pixel(x, y), x, y, mask)) + return false; + + // go no further if not drag-selecting + if (!layout.is_drag_select_active()) + return false; + + // set the new endpoint of the drag selection + layout.set_drag_select_endpoint(x, y); + + updated_selection_band(); + + // if out of bounds, schedule a check to auto-scroll the viewport + if (!autoscroll_scheduled + && get_adjustment_relation(get_vadjustment(), y) != AdjustmentRelation.IN_RANGE) { + Timeout.add(AUTOSCROLL_TICKS_MSEC, selection_autoscroll); + autoscroll_scheduled = true; + } + + // return true to stop a potential drag-and-drop operation + return true; + } + + private void updated_selection_band() { + assert(layout.is_drag_select_active()); + + // get all items inside the selection + Gee.List? intersection = layout.items_in_selection_band(); + if (intersection == null) + return; + + Marker to_unselect = get_view().start_marking(); + Marker to_select = get_view().start_marking(); + + // mark all selected items to be unselected + to_unselect.mark_many(get_view().get_selected()); + + // except for the items that were selected before the drag began + assert(previously_selected != null); + to_unselect.unmark_many(previously_selected); + to_select.mark_many(previously_selected); + + // toggle selection on everything in the intersection and update the cursor + cursor = null; + + foreach (CheckerboardItem item in intersection) { + if (to_select.toggle(item)) + to_unselect.unmark(item); + else + to_unselect.mark(item); + + if (cursor == null) + cursor = item; + } + + get_view().select_marked(to_select); + get_view().unselect_marked(to_unselect); + } + + private bool selection_autoscroll() { + if (!layout.is_drag_select_active()) { + autoscroll_scheduled = false; + + return false; + } + + // as the viewport never scrolls horizontally, only interested in vertical + Gtk.Adjustment vadj = get_vadjustment(); + + int x, y; + Gdk.ModifierType mask; + get_event_source_pointer(out x, out y, out mask); + + int new_value = (int) vadj.get_value(); + switch (get_adjustment_relation(vadj, y)) { + case AdjustmentRelation.BELOW: + // pointer above window, scroll up + new_value -= AUTOSCROLL_PIXELS; + layout.set_drag_select_endpoint(x, new_value); + break; + + case AdjustmentRelation.ABOVE: + // pointer below window, scroll down, extend selection to bottom of page + new_value += AUTOSCROLL_PIXELS; + layout.set_drag_select_endpoint(x, new_value + (int) vadj.get_page_size()); + break; + + case AdjustmentRelation.IN_RANGE: + autoscroll_scheduled = false; + + return false; + + default: + warn_if_reached(); + break; + } + + // It appears that in GTK+ 2.18, the adjustment is not clamped the way it was in 2.16. + // This may have to do with how adjustments are different w/ scrollbars, that they're upper + // clamp is upper - page_size ... either way, enforce these limits here + vadj.set_value(new_value.clamp((int) vadj.get_lower(), + (int) vadj.get_upper() - (int) vadj.get_page_size())); + + updated_selection_band(); + + return true; + } + + public void cursor_to_item(CheckerboardItem item) { + assert(get_view().contains(item)); + + cursor = item; + + if (!get_ctrl_pressed()) { + get_view().unselect_all(); + Marker marker = get_view().mark(item); + get_view().select_marked(marker); + } + layout.set_cursor(item); + scroll_to_item(item); + } + + public void move_cursor(CompassPoint point) { + // if no items, nothing to do + if (get_view().get_count() == 0) + return; + + // if there is no better starting point, simply select the first and exit + // The right half of the or is related to Bug #732334, the cursor might be non-null and still not contained in + // the view, if the user dragged a full screen Photo off screen + if (cursor == null && layout.get_cursor() == null || cursor != null && !get_view().contains(cursor)) { + CheckerboardItem item = layout.get_item_at_coordinate(0, 0); + cursor_to_item(item); + anchor = item; + + return; + } + + if (cursor == null) { + cursor = layout.get_cursor() as CheckerboardItem; + } + + // move the cursor relative to the "first" item + CheckerboardItem? item = layout.get_item_relative_to(cursor, point); + if (item != null) + cursor_to_item(item); + } + + public void set_cursor(CheckerboardItem item) { + Marker marker = get_view().mark(item); + get_view().select_marked(marker); + + cursor = item; + anchor = item; + } + + public void select_between_items(CheckerboardItem item_start, CheckerboardItem item_end) { + Marker marker = get_view().start_marking(); + + bool passed_start = false; + bool passed_end = false; + + foreach (DataObject object in get_view().get_all()) { + CheckerboardItem item = (CheckerboardItem) object; + + if (item_start == item) + passed_start = true; + + if (item_end == item) + passed_end = true; + + if (passed_start || passed_end) + marker.mark((DataView) object); + + if (passed_start && passed_end) + break; + } + + get_view().select_marked(marker); + } + + public void select_anchor_to_cursor(uint state) { + if (cursor == null || anchor == null) + return; + + if (state == Gdk.ModifierType.SHIFT_MASK) { + get_view().unselect_all(); + select_between_items(anchor, cursor); + } else { + anchor = cursor; + } + } + + protected virtual void set_display_titles(bool display) { + get_view().freeze_notifications(); + get_view().set_property(CheckerboardItem.PROP_SHOW_TITLES, display); + get_view().thaw_notifications(); + } + + protected virtual void set_display_comments(bool display) { + get_view().freeze_notifications(); + get_view().set_property(CheckerboardItem.PROP_SHOW_COMMENTS, display); + get_view().thaw_notifications(); + } +} + + diff --git a/src/Commands.vala b/src/Commands.vala index 589ae38..76aecb4 100644 --- a/src/Commands.vala +++ b/src/Commands.vala @@ -1316,8 +1316,17 @@ public class AdjustDateTimePhotoCommand : SingleDataSourceCommand { this.modify_original = modify_original; } + private DateTime get_base_time() { + var exposure_time = dateable.get_exposure_time(); + if (exposure_time == null) { + exposure_time = new DateTime.from_unix_utc(0); + } + + return exposure_time; + } + public override void execute() { - set_time(dateable, dateable.get_exposure_time() + (time_t) time_shift); + set_time(dateable, get_base_time().add_seconds(time_shift)); prev_event = dateable.get_event(); @@ -1333,12 +1342,12 @@ public class AdjustDateTimePhotoCommand : SingleDataSourceCommand { } public override void undo() { - set_time(dateable, dateable.get_exposure_time() - (time_t) time_shift); + set_time(dateable, get_base_time().add_seconds(-1 * time_shift)); dateable.set_event(prev_event); } - private void set_time(Dateable dateable, time_t exposure_time) { + private void set_time(Dateable dateable, DateTime exposure_time) { if (modify_original && dateable is Photo) { try { ((Photo)dateable).set_exposure_time_persistent(exposure_time); @@ -1358,8 +1367,8 @@ public class AdjustDateTimePhotosCommand : MultipleDataSourceCommand { private Gee.Map prev_events; // used when photos are batch changed instead of shifted uniformly - private time_t? new_time = null; - private Gee.HashMap old_times; + private DateTime? new_time = null; + private Gee.HashMap old_times; private Gee.ArrayList error_list; public AdjustDateTimePhotosCommand(Gee.Iterable iter, int64 time_shift, @@ -1377,16 +1386,24 @@ public class AdjustDateTimePhotosCommand : MultipleDataSourceCommand { // this should be replaced by a first function when we migrate to Gee's List foreach (DataView view in iter) { - prev_events.set(view.get_source() as Dateable, (view.get_source() as MediaSource).get_event()); + prev_events.set(view.get_source() as Dateable, ((MediaSource) view.get_source()).get_event()); if (new_time == null) { - new_time = ((Dateable) view.get_source()).get_exposure_time() + - (time_t) time_shift; + new_time = get_base_time((Dateable)view.get_source()).add_seconds(time_shift); break; } } - old_times = new Gee.HashMap(); + old_times = new Gee.HashMap(); + } + + private DateTime get_base_time(Dateable dateable) { + var exposure_time = dateable.get_exposure_time(); + if (exposure_time == null) { + exposure_time = new DateTime.from_unix_utc(0); + } + + return exposure_time; } public override void execute() { @@ -1425,7 +1442,7 @@ public class AdjustDateTimePhotosCommand : MultipleDataSourceCommand { } } - private void set_time(Dateable dateable, time_t exposure_time) { + private void set_time(Dateable dateable, DateTime exposure_time) { // set_exposure_time_persistent wouldn't work on videos, // since we can't actually write them from inside shotwell, // so check whether we're working on a Photo or a Video @@ -1445,8 +1462,8 @@ public class AdjustDateTimePhotosCommand : MultipleDataSourceCommand { public override void execute_on_source(DataSource source) { Dateable dateable = ((Dateable) source); - if (keep_relativity && dateable.get_exposure_time() != 0) { - set_time(dateable, dateable.get_exposure_time() + (time_t) time_shift); + if (keep_relativity && dateable.get_exposure_time() != null) { + set_time(dateable, dateable.get_exposure_time().add_seconds(time_shift)); } else { old_times.set(dateable, dateable.get_exposure_time()); set_time(dateable, new_time); @@ -1470,10 +1487,10 @@ public class AdjustDateTimePhotosCommand : MultipleDataSourceCommand { set_time(photo, old_times.get(photo)); old_times.unset(photo); } else { - set_time(photo, photo.get_exposure_time() - (time_t) time_shift); + set_time(photo, photo.get_exposure_time().add_seconds(-1 * time_shift)); } - (source as MediaSource).set_event(prev_events.get(source as Dateable)); + ((MediaSource) source).set_event(prev_events.get(source as Dateable)); } } @@ -2165,8 +2182,6 @@ public class ModifyTagsCommand : SingleDataSourceCommand { } foreach (string path in new_paths) { - assert(Tag.global.exists(path)); - SourceProxy proxy = Tag.for_path(path).get_proxy(); to_add.add(proxy); proxy.broken.connect(on_proxy_broken); @@ -2541,7 +2556,8 @@ public class RemoveFacesFromPhotosCommand : SimpleProxyableCommand { face.attach_many(map_source_geometry.keys); foreach (Gee.Map.Entry entry in map_source_geometry.entries) - FaceLocation.create(face.get_face_id(), ((Photo) entry.key).get_photo_id(), entry.value); + FaceLocation.create(face.get_face_id(), ((Photo) entry.key).get_photo_id(), + { entry.value, null }); } private void on_source_destroyed(DataSource source) { @@ -2572,6 +2588,26 @@ public class RenameFaceCommand : SimpleProxyableCommand { } } +public class SetFaceRefCommand : SimpleProxyableCommand { + private FaceLocation face_loc; + + public SetFaceRefCommand(Face face, MediaSource source) { + base (face, Resources.set_face_from_photo_label(face.get_name()), face.get_name()); + Gee.Map? face_loc_map = FaceLocation.get_locations_by_photo((Photo)source); + face_loc = face_loc_map.get(face.get_face_id()); + } + + protected override void execute_on_source(DataSource source) { + if (!((Face) source).set_reference(face_loc)) + AppWindow.error_message(Resources.set_face_from_photo_error()); + } + + protected override void undo_on_source(DataSource source) { + //if (!((Face) source).rename(old_name)) + // AppWindow.error_message(Resources.rename_face_exists_message(old_name)); + } +} + public class DeleteFaceCommand : SimpleProxyableCommand { private Gee.Map photo_geometry_map = new Gee.HashMap ((Gee.HashDataFunc)FaceLocation.photo_id_hash, (Gee.EqualDataFunc)FaceLocation.photo_ids_equal); @@ -2607,7 +2643,8 @@ public class DeleteFaceCommand : SimpleProxyableCommand { Face face = (Face) source; face.attach(photo); - FaceLocation.create(face.get_face_id(), entry.key, entry.value); + FaceLocation.create(face.get_face_id(), entry.key, + { entry.value, null }); } } } @@ -2617,10 +2654,10 @@ public class ModifyFacesCommand : SingleDataSourceCommand { private MediaSource media; private Gee.ArrayList to_add = new Gee.ArrayList(); private Gee.ArrayList to_remove = new Gee.ArrayList(); - private Gee.Map to_update = new Gee.HashMap(); - private Gee.Map geometries = new Gee.HashMap(); + private Gee.Map to_update = new Gee.HashMap(); + private Gee.Map geometries = new Gee.HashMap(); - public ModifyFacesCommand(MediaSource media, Gee.Map new_face_list) { + public ModifyFacesCommand(MediaSource media, Gee.Map new_face_list) { base (media, Resources.MODIFY_FACES_LABEL, ""); this.media = media; @@ -2639,13 +2676,13 @@ public class ModifyFacesCommand : SingleDataSourceCommand { FaceLocation.get_face_location(face.get_face_id(), ((Photo) media).get_photo_id()); assert(face_location != null); - geometries.set(proxy, face_location.get_serialized_geometry()); + geometries.set(proxy, face_location.get_face_data()); } } } // Add any face that's in the new list but not the original - foreach (Gee.Map.Entry entry in new_face_list.entries) { + foreach (Gee.Map.Entry entry in new_face_list.entries) { if (original_faces == null || !original_faces.contains(entry.key)) { SourceProxy proxy = entry.key.get_proxy(); @@ -2661,13 +2698,13 @@ public class ModifyFacesCommand : SingleDataSourceCommand { assert(face_location != null); string old_geometry = face_location.get_serialized_geometry(); - if (old_geometry != entry.value) { + if (old_geometry != entry.value.geometry) { SourceProxy proxy = entry.key.get_proxy(); to_update.set(proxy, entry.value); proxy.broken.connect(on_proxy_broken); - geometries.set(proxy, old_geometry); + geometries.set(proxy, face_location.get_face_data()); } } } @@ -2694,7 +2731,7 @@ public class ModifyFacesCommand : SingleDataSourceCommand { foreach (SourceProxy proxy in to_remove) ((Face) proxy.get_source()).detach(media); - foreach (Gee.Map.Entry entry in to_update.entries) { + foreach (Gee.Map.Entry entry in to_update.entries) { Face face = (Face) entry.key.get_source(); FaceLocation.create(face.get_face_id(), ((Photo) media).get_photo_id(), entry.value); } diff --git a/src/Debug.vala b/src/Debug.vala index f159b0d..799a94f 100644 --- a/src/Debug.vala +++ b/src/Debug.vala @@ -33,7 +33,7 @@ namespace Debug { string log_file_error_msg = null; - // logging to disk is currently off for viewer more; see http://trac.yorba.org/ticket/2078 + // logging to disk is currently off for viewer more; see https://bugzilla.gnome.org/show_bug.cgi?id=716474 File? log_file = (log_app_version_prefix == LIBRARY_PREFIX) ? AppDirs.get_log_file() : null; if(log_file != null) { File log_dir = log_file.get_parent(); @@ -104,11 +104,10 @@ namespace Debug { } private void log(FileStream stream, string prefix, string message) { - time_t now = time_t(); stream.printf("%s %d %s [%s] %s\n", log_app_version_prefix, Posix.getpid(), - Time.local(now).to_string(), + new DateTime.now_local().format("%F %T"), prefix, message ); diff --git a/src/DesktopIntegration.vala b/src/DesktopIntegration.vala index 68d1ec6..754d9a1 100644 --- a/src/DesktopIntegration.vala +++ b/src/DesktopIntegration.vala @@ -9,7 +9,6 @@ namespace DesktopIntegration { private const string DESKTOP_SLIDESHOW_XML_FILENAME = "wallpaper.xml"; private int init_count = 0; -private bool send_to_installed = false; private ExporterUI send_to_exporter = null; private ExporterUI desktop_slideshow_exporter = null; private double desktop_slideshow_transition = 0.0; @@ -113,6 +112,7 @@ public async void files_send_to(File[] files) { yield portal.compose_email(parent, {null}, null, null, _("Send files per Mail: ") + file_names.str, null, file_paths, Xdp.EmailFlags.NONE, null); } catch (Error e){ + // Translators: The first %s is the name of the file, the second %s is the reason why it could not be sent AppWindow.error_message(_("Unable to send file %s, %s").printf( file_names.str, e.message)); } diff --git a/src/Dialogs.vala b/src/Dialogs.vala index b1f6e08..70dc76d 100644 --- a/src/Dialogs.vala +++ b/src/Dialogs.vala @@ -5,7 +5,7 @@ */ // namespace for future migration of AppWindow alert and other question dialogs into single -// place: http://trac.yorba.org/ticket/3452 +// place: https://bugzilla.gnome.org/show_bug.cgi?id=717659 namespace Dialogs { public bool confirm_delete_tag(Tag tag) { @@ -67,7 +67,7 @@ public File? choose_file(string current_file_basename) { current_export_dir = File.new_for_path(Environment.get_home_dir()); string file_chooser_title = VideoReader.is_supported_video_filename(current_file_basename) ? - _("Export Video") : _("Export Photo"); + _("Export Video") : GLib.dpgettext2 (null, "Dialog Title", "Export Photo"); var chooser = new Gtk.FileChooserNative(file_chooser_title, AppWindow.get_instance(), Gtk.FileChooserAction.SAVE, Resources.SAVE_LABEL, Resources.CANCEL_LABEL); @@ -214,7 +214,7 @@ public string create_result_report_from_manifest(ImportManifest manifest) { StringBuilder builder = new StringBuilder(); string header = _("Import Results Report") + " (Shotwell " + Resources.APP_VERSION + " @ " + - TimeVal().to_iso8601() + ")\n\n"; + new DateTime.now_utc().format_iso8601() + ")\n\n"; builder.append(header); string subhead = (ngettext("Attempted to import %d file.", "Attempted to import %d files.", @@ -817,8 +817,14 @@ public void multiple_object_error_dialog(Gee.ArrayList objects, stri public abstract class TagsDialog : TextEntryDialogMediator { protected TagsDialog(string title, string label, string? initial_text = null) { - base (title, label, initial_text, HierarchicalTagIndex.get_global_index().get_all_tags(), - ","); + var all = new Gee.ArrayList(); + all.add_all(HierarchicalTagIndex.get_global_index().get_all_tags()); + var paths = HierarchicalTagIndex.get_global_index().get_all_paths(); + foreach (var p in paths) { + if (p.has_prefix("/")) all.add(p); + } + + base (title, label, initial_text, all, ","); } } @@ -840,14 +846,24 @@ public class AddTagsDialog : TagsDialog { } protected override bool on_modify_validate(string text) { - if (text.contains(Tag.PATH_SEPARATOR_STRING)) - return false; - - // Can't simply call Tag.prep_tag_names().length because of this bug: - // https://bugzilla.gnome.org/show_bug.cgi?id=602208 string[] names = Tag.prep_tag_names(text.split(",")); - - return names.length > 0; + if (names.length == 0) + return false; + + // If allowing hierarchies, they have to start with a "/" + for (int i = 0; i < names.length; i++) { + if (names[i].contains(Tag.PATH_SEPARATOR_STRING) && !names[i].strip().has_prefix(Tag.PATH_SEPARATOR_STRING)) + return false; + + if (names[i].strip().has_prefix(Tag.PATH_SEPARATOR_STRING) && names[i].strip().length == 1) + return false; + + if (names[i].strip().contains(Tag.PATH_SEPARATOR_STRING + Tag.PATH_SEPARATOR_STRING)) { + return false; + } + } + + return true; } } @@ -904,7 +920,26 @@ public class ModifyTagsDialog : TagsDialog { } protected override bool on_modify_validate(string text) { - return (!text.contains(Tag.PATH_SEPARATOR_STRING)); + string[] names = Tag.prep_tag_names(text.split(",")); + if (names.length == 0) + return false; + + // If allowing hierarchies, they have to start with a "/" + for (int i = 0; i < names.length; i++) { + if (names[i].contains(Tag.PATH_SEPARATOR_STRING) && !names[i].strip().has_prefix(Tag.PATH_SEPARATOR_STRING)) { + return false; + } + + if (names[i].strip().has_prefix(Tag.PATH_SEPARATOR_STRING) && names[i].strip().length == 1) + return false; + + if (names[i].strip().contains(Tag.PATH_SEPARATOR_STRING + Tag.PATH_SEPARATOR_STRING)) { + return false; + } + } + + return true; + } } @@ -930,7 +965,7 @@ public Gtk.ResponseType copy_files_dialog() { public void remove_photos_from_library(Gee.Collection photos) { remove_from_app(photos, _("Remove From Library"), - (photos.size == 1) ? _("Removing Photo From Library") : _("Removing Photos From Library")); + ngettext("Removing Photo From Library", "Removing Photos From Library", photos.size)); } public void remove_from_app(Gee.Collection sources, string dialog_title, diff --git a/src/Dimensions.vala b/src/Dimensions.vala index 3b4163c..32bf32c 100644 --- a/src/Dimensions.vala +++ b/src/Dimensions.vala @@ -59,8 +59,9 @@ public struct Dimensions { public static Dimensions for_widget_allocation(Gtk.Widget widget) { Gtk.Allocation allocation; widget.get_allocation(out allocation); + var scale = widget.get_scale_factor(); - return Dimensions(allocation.width, allocation.height); + return Dimensions(allocation.width * scale, allocation.height * scale); } public static Dimensions for_rectangle(Gdk.Rectangle rect) { diff --git a/src/DirectoryMonitor.vala b/src/DirectoryMonitor.vala index a37b124..19992dd 100644 --- a/src/DirectoryMonitor.vala +++ b/src/DirectoryMonitor.vala @@ -60,7 +60,7 @@ public class DirectoryMonitor : Object { public const int DEFAULT_PRIORITY = Priority.LOW; public const FileQueryInfoFlags DIR_INFO_FLAGS = FileQueryInfoFlags.NONE; - public const FileQueryInfoFlags FILE_INFO_FLAGS = FileQueryInfoFlags.NOFOLLOW_SYMLINKS; + public const FileQueryInfoFlags FILE_INFO_FLAGS = FileQueryInfoFlags.NONE; // when using UNKNOWN_FILE_FLAGS, check if the resulting FileInfo's symlink status matches // symlink support for files and directories by calling is_file_symlink_supported(). @@ -290,7 +290,7 @@ public class DirectoryMonitor : Object { // get all the interesting matchable items from the supplied FileInfo int64 match_size = match.get_size(); - TimeVal match_time = match.get_modification_time(); + var match_time = match.get_modification_date_time(); foreach (File file in map.keys) { FileInfo info = map.get(file); @@ -303,9 +303,9 @@ public class DirectoryMonitor : Object { if (match_size != info.get_size()) continue; - TimeVal time = info.get_modification_time(); + var time = info.get_modification_date_time(); - if (time.tv_sec != match_time.tv_sec) + if (!time.equal(match_time)) continue; return file; diff --git a/src/DragAndDropHandler.vala b/src/DragAndDropHandler.vala new file mode 100644 index 0000000..ece6d9d --- /dev/null +++ b/src/DragAndDropHandler.vala @@ -0,0 +1,182 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +// +// DragAndDropHandler attaches signals to a Page to 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). Assumes the Page's ViewCollection holds MediaSources. +// +public class DragAndDropHandler { + private enum TargetType { + XDS, + MEDIA_LIST + } + + private const Gtk.TargetEntry[] SOURCE_TARGET_ENTRIES = { + { "XdndDirectSave0", Gtk.TargetFlags.OTHER_APP, TargetType.XDS }, + { "shotwell/media-id-atom", Gtk.TargetFlags.SAME_APP, TargetType.MEDIA_LIST } + }; + + private static Gdk.Atom? XDS_ATOM = null; + private static Gdk.Atom? TEXT_ATOM = null; + private static uint8[]? XDS_FAKE_TARGET = null; + + private weak Page page; + private Gtk.Widget event_source; + private File? drag_destination = null; + private ExporterUI exporter = null; + + public DragAndDropHandler(Page page) { + this.page = page; + this.event_source = page.get_event_source(); + assert(event_source != null); + assert(event_source.get_has_window()); + + // 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.connect(on_drag_begin); + event_source.drag_data_get.connect(on_drag_data_get); + event_source.drag_end.connect(on_drag_end); + event_source.drag_failed.connect(on_drag_failed); + } + + ~DragAndDropHandler() { + if (event_source != null) { + event_source.drag_begin.disconnect(on_drag_begin); + event_source.drag_data_get.disconnect(on_drag_data_get); + event_source.drag_end.disconnect(on_drag_end); + event_source.drag_failed.disconnect(on_drag_failed); + } + + page = null; + event_source = null; + } + + private void on_drag_begin(Gdk.DragContext context) { + debug("on_drag_begin (%s)", page.get_page_name()); + + if (page == null || page.get_view().get_selected_count() == 0 || exporter != null) + return; + + drag_destination = null; + + // use the first media item as the icon + ThumbnailSource thumb = (ThumbnailSource) page.get_view().get_selected_at(0).get_source(); + + try { + Gdk.Pixbuf icon = thumb.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", thumb.to_string(), + err.message); + } + + // set the XDS property to indicate an XDS save is available + Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE, + XDS_FAKE_TARGET, 1); + } + + private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data, + uint target_type, uint time) { + debug("on_drag_data_get (%s)", page.get_page_name()); + + 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; + bool fetched = Gdk.property_get(context.get_source_window(), XDS_ATOM, TEXT_ATOM, + 0, data.length, 0, out actual_type, out actual_format, 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 && data != null && data.length > 0) + drag_destination = File.new_for_uri(uchar_array_to_string(data)).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; + + case TargetType.MEDIA_LIST: + Gee.Collection sources = + (Gee.Collection) page.get_view().get_selected_sources(); + + // convert the selected media sources to Gdk.Atom-encoded sourceID strings for + // internal drag-and-drop + selection_data.set(Gdk.Atom.intern_static_string("SourceIDAtom"), (int) sizeof(Gdk.Atom), + serialize_media_sources(sources)); + break; + + default: + warning("on_drag_data_get (%s): unknown target type %u", page.get_page_name(), + target_type); + break; + } + } + + private void on_drag_end() { + debug("on_drag_end (%s)", page.get_page_name()); + + if (page == null || page.get_view().get_selected_count() == 0 || drag_destination == null + || exporter != null) { + return; + } + + debug("Exporting to %s", drag_destination.get_path()); + + // drag-and-drop export doesn't pop up an export dialog, so use what are likely the + // most common export settings (the current -- or "working" -- file format, with + // all transformations applied, at the image's original size). + if (drag_destination.get_path() != null) { + exporter = new ExporterUI(new Exporter( + (Gee.Collection) page.get_view().get_selected_sources(), + drag_destination, Scaling.for_original(), ExportFormatParameters.current())); + exporter.export(on_export_completed); + } else { + AppWindow.error_message(_("Photos cannot be exported to this directory.")); + } + + drag_destination = null; + } + + private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) { + debug("on_drag_failed (%s): %d", page.get_page_name(), (int) drag_result); + + if (page == null) + return false; + + drag_destination = null; + + return false; + } + + private void on_export_completed() { + exporter = null; + } + +} diff --git a/src/Event.vala b/src/Event.vala index 084df97..69d27d0 100644 --- a/src/Event.vala +++ b/src/Event.vala @@ -100,8 +100,6 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { // In 24-hour time. public const int EVENT_BOUNDARY_HOUR = 4; - private const time_t TIME_T_DAY = 24 * 60 * 60; - private class EventSnapshot : SourceSnapshot { private EventRow row; private MediaSource primary_source; @@ -303,8 +301,10 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { } private static int64 view_comparator(void *a, void *b) { - return ((MediaSource) ((ThumbnailView *) a)->get_source()).get_exposure_time() - - ((MediaSource) ((ThumbnailView *) b)->get_source()).get_exposure_time() ; + var time_a = ((MediaSource) ((ThumbnailView *) a)->get_source()).get_exposure_time(); + var time_b = ((MediaSource) ((ThumbnailView *) b)->get_source()).get_exposure_time(); + + return nullsafe_date_time_comperator(time_a, time_b); } private static bool view_comparator_predicate(DataObject object, Alteration alteration) { @@ -591,7 +591,7 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { return indexable_keywords; } - public bool is_in_starting_day(time_t time) { + public bool is_in_starting_day(DateTime time) { // it's possible the Event ref is held although it's been emptied // (such as the user removing items during an import, when events // are being generate on-the-fly) ... return false here and let @@ -601,30 +601,25 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { // media sources are stored in ViewCollection from earliest to latest MediaSource earliest_media = (MediaSource) ((DataView) view.get_at(0)).get_source(); - Time earliest_tm = Time.local(earliest_media.get_exposure_time()); + var earliest_tm = earliest_media.get_exposure_time().to_local(); // use earliest to generate the boundary hour for that day - Time start_boundary_tm = Time(); - start_boundary_tm.second = 0; - start_boundary_tm.minute = 0; - start_boundary_tm.hour = EVENT_BOUNDARY_HOUR; - start_boundary_tm.day = earliest_tm.day; - start_boundary_tm.month = earliest_tm.month; - start_boundary_tm.year = earliest_tm.year; - start_boundary_tm.isdst = -1; - - time_t start_boundary = start_boundary_tm.mktime(); - + var start_boundary = new DateTime.local(earliest_tm.get_year(), + earliest_tm.get_month(), + earliest_tm.get_day_of_month(), + EVENT_BOUNDARY_HOUR, + 0, + 0); // if the earliest's exposure time was on the day but *before* the boundary hour, // step it back a day to the prior day's boundary - if (earliest_tm.hour < EVENT_BOUNDARY_HOUR) { + if (earliest_tm.get_hour() < EVENT_BOUNDARY_HOUR) { debug("Hour before boundary, shifting back one day"); - start_boundary -= TIME_T_DAY; + start_boundary = start_boundary.add_days(-1); } - time_t end_boundary = (start_boundary + TIME_T_DAY - 1); - - return time >= start_boundary && time <= end_boundary; + var end_boundary = start_boundary.add_days(1).add_seconds(-1); + + return time.compare(start_boundary) >= 0 && time.compare(end_boundary) <= 0; } // This method attempts to add a media source to an event in the supplied list that it would @@ -632,9 +627,9 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { // photo). Otherwise, a new Event is generated and the source is added to it and the list. private static Event? generate_event(MediaSource media, ViewCollection events_so_far, string? event_name, out bool new_event) { - time_t exposure_time = media.get_exposure_time(); + DateTime? exposure_time = media.get_exposure_time(); - if (exposure_time == 0 && event_name == null) { + if (exposure_time == null && event_name == null) { debug("Skipping event assignment to %s: no exposure time and no event name", media.to_string()); new_event = false; @@ -754,22 +749,20 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { } public string? get_formatted_daterange() { - time_t start_time = get_start_time(); - time_t end_time = get_end_time(); + DateTime? start_time = get_start_time(); + DateTime? end_time = get_end_time(); - if (end_time == 0 && start_time == 0) + if (end_time == null && start_time == null) return null; - if (end_time == 0 && start_time != 0) - return format_local_date(Time.local(start_time)); - - Time start = Time.local(start_time); - Time end = Time.local(end_time); + if (end_time == null && start_time != null) + return format_local_date(start_time.to_local()); - if (start.day == end.day && start.month == end.month && start.day == end.day) - return format_local_date(Time.local(start_time)); + if (start_time.get_year() == end_time.get_year() && + start_time.get_day_of_year() == end_time.get_day_of_year()) + return format_local_date(start_time.to_local()); - return format_local_datespan(start, end); + return format_local_datespan(start_time.to_local(), end_time.to_local()); } public string? get_raw_name() { @@ -811,30 +804,30 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { return committed; } - public time_t get_creation_time() { + public DateTime? get_creation_time() { return event_table.get_time_created(event_id); } - public override time_t get_start_time() { + public override DateTime? get_start_time() { // Because the ViewCollection is sorted by a DateComparator, the start time is the // first item. However, we keep looking if it has no start time. int count = view.get_count(); for (int i = 0; i < count; i++) { - time_t time = ((MediaSource) (((DataView) view.get_at(i)).get_source())).get_exposure_time(); - if (time != 0) + var time = ((MediaSource) (((DataView) view.get_at(i)).get_source())).get_exposure_time(); + if (time != null) return time; } - return 0; + return null; } - public override time_t get_end_time() { + public override DateTime? get_end_time() { int count = view.get_count(); // Because the ViewCollection is sorted by a DateComparator, the end time is the // last item--no matter what. if (count == 0) - return 0; + return null; return ((MediaSource) (((DataView) view.get_at(count - 1)).get_source())).get_exposure_time(); } diff --git a/src/Exporter.vala b/src/Exporter.vala index b9596f5..a7f7b6b 100644 --- a/src/Exporter.vala +++ b/src/Exporter.vala @@ -55,7 +55,9 @@ public class Exporter : Object { YES, NO, CANCEL, - REPLACE_ALL + REPLACE_ALL, + RENAME, + RENAME_ALL, } public delegate void CompletionCallback(Exporter exporter, bool is_cancelled); @@ -116,8 +118,10 @@ public class Exporter : Object { private unowned ProgressMonitor? monitor = null; private Cancellable cancellable; private bool replace_all = false; + private bool rename_all = false; private bool aborted = false; private ExportFormatParameters export_params; + private static File? USE_TEMPORARY_EXPORT_FOLDER = null; public Exporter(Gee.Collection to_export, File? dir, Scaling scaling, ExportFormatParameters export_params, bool auto_replace_all = false) { @@ -131,7 +135,7 @@ public class Exporter : Object { public Exporter.for_temp_file(Gee.Collection to_export, Scaling scaling, ExportFormatParameters export_params) { this.to_export.add_all(to_export); - this.dir = null; + this.dir = USE_TEMPORARY_EXPORT_FOLDER; this.scaling = scaling; this.export_params = export_params; } @@ -193,6 +197,7 @@ public class Exporter : Object { private bool process_queue() { int submitted = 0; + Gee.HashSet used = new Gee.HashSet(); foreach (MediaSource source in to_export) { File? use_source_file = null; PhotoFileFormat real_export_format = PhotoFileFormat.get_system_default_format(); @@ -227,7 +232,7 @@ public class Exporter : Object { if (export_dir == null) { try { bool collision; - dest = generate_unique_file(AppDirs.get_temp_dir(), basename, out collision); + dest = generate_unique_file(AppDirs.get_temp_dir(), basename, out collision, used); } catch (Error err) { AppWindow.error_message(_("Unable to generate a temporary file for %s: %s").printf( source.get_file().get_basename(), err.message)); @@ -236,17 +241,30 @@ public class Exporter : Object { } } else { dest = dir.get_child(basename); + bool rename = false; - if (!replace_all && dest.query_exists(null)) { - switch (overwrite_callback(this, dest)) { + if (!replace_all && (dest.query_exists(null) || used.contains(basename))) { + if (rename_all) { + rename = true; + } else { + switch (overwrite_callback(this, dest)) { case Overwrite.YES: // continue - break; + break; case Overwrite.REPLACE_ALL: replace_all = true; - break; - + break; + + case Overwrite.RENAME: + rename = true; + break; + + case Overwrite.RENAME_ALL: + rename = true; + rename_all = true; + break; + case Overwrite.CANCEL: cancellable.cancel(); @@ -264,10 +282,22 @@ public class Exporter : Object { } continue; + } + } + if (rename) { + try { + bool collision; + dest = generate_unique_file(dir, basename, out collision, used); + } catch (Error err) { + AppWindow.error_message(_("Unable to generate a temporary file for %s: %s").printf( + source.get_file().get_basename(), err.message)); + break; + } } } } + used.add(dest.get_basename()); workers.enqueue(new ExportJob(this, source, dest, scaling, export_params.quality, real_export_format, cancellable, export_params.mode == ExportFormatMode.UNMODIFIED, export_params.export_metadata)); submitted++; @@ -315,24 +345,30 @@ public class ExporterUI { private Exporter.Overwrite on_export_overwrite(Exporter exporter, File file) { progress_dialog.set_modal(false); string question = _("File %s already exists. Replace?").printf(file.get_basename()); - Gtk.ResponseType response = AppWindow.negate_affirm_all_cancel_question(question, - _("_Skip"), _("_Replace"), _("Replace _All"), _("Export")); + int response = AppWindow.export_overwrite_or_replace_question(question, + _("_Skip"), _("Rename"), _("Rename All"),_("_Replace"), _("Replace _All"), _("_Cancel"), _("Export")); progress_dialog.set_modal(true); switch (response) { - case Gtk.ResponseType.APPLY: - return Exporter.Overwrite.REPLACE_ALL; + case 2: + return Exporter.Overwrite.RENAME; + + case 3: + return Exporter.Overwrite.RENAME_ALL; - case Gtk.ResponseType.YES: - return Exporter.Overwrite.YES; + case 4: + return Exporter.Overwrite.YES; - case Gtk.ResponseType.CANCEL: - return Exporter.Overwrite.CANCEL; + case 5: + return Exporter.Overwrite.REPLACE_ALL; - case Gtk.ResponseType.NO: - default: - return Exporter.Overwrite.NO; + case 6: + return Exporter.Overwrite.CANCEL; + + case 1: + default: + return Exporter.Overwrite.NO; } } @@ -340,4 +376,3 @@ public class ExporterUI { return export_error_dialog(file, remaining > 0) != Gtk.ResponseType.CANCEL; } } - diff --git a/src/LibraryFiles.vala b/src/LibraryFiles.vala index bbacb6c..4941742 100644 --- a/src/LibraryFiles.vala +++ b/src/LibraryFiles.vala @@ -26,18 +26,18 @@ public void select_copy_function() { // Thus, when the method returns success a file may exist already, and should be overwritten. // // This function is thread safe. -public File? generate_unique_file(string basename, MediaMetadata? metadata, time_t ts, out bool collision) +public File? generate_unique_file(string basename, MediaMetadata? metadata, DateTime ts, out bool collision) throws Error { // use exposure timestamp over the supplied one (which probably comes from the file's // modified time, or is simply time()), unless it's zero, in which case use current time - time_t timestamp = ts; + DateTime timestamp = ts; if (metadata != null) { MetadataDateTime? date_time = metadata.get_creation_date_time(); if (date_time != null) timestamp = date_time.get_timestamp(); - else if (timestamp == 0) - timestamp = time_t(); + else if (timestamp == null) + timestamp = new DateTime.now_utc(); } // build a directory tree inside the library @@ -71,7 +71,7 @@ public string convert_basename(string basename) { // This function is thread-safe. private File duplicate(File src, FileProgressCallback? progress_callback, bool blacklist) throws Error { - time_t timestamp = 0; + DateTime? timestamp = null; try { timestamp = query_file_modified(src); } catch (Error err) { diff --git a/src/LibraryMonitor.vala b/src/LibraryMonitor.vala index f9291d7..a291f15 100644 --- a/src/LibraryMonitor.vala +++ b/src/LibraryMonitor.vala @@ -96,7 +96,7 @@ public class LibraryMonitorPool { public class LibraryMonitor : DirectoryMonitor { private const int FLUSH_IMPORT_QUEUE_SEC = 3; - private const int IMPORT_ROLL_QUIET_SEC = 5 * 60; + private const int IMPORT_ROLL_QUIET_SEC = 5 * 60 * 1000 * 1000; private const int MIN_BLACKLIST_DURATION_MSEC = 5 * 1000; private const int MAX_VERIFY_EXISTING_MEDIA_JOBS = 5; @@ -217,7 +217,7 @@ public class LibraryMonitor : DirectoryMonitor { private Gee.HashSet pending_imports = new Gee.HashSet(file_hash, file_equal); private Gee.ArrayList batch_import_queue = new Gee.ArrayList(); private BatchImportRoll current_import_roll = null; - private time_t last_import_roll_use = 0; + private int64 last_import_roll_use = 0; private BatchImport current_batch_import = null; private int checksums_completed = 0; private int checksums_total = 0; @@ -583,7 +583,7 @@ public class LibraryMonitor : DirectoryMonitor { // If no import roll, or it's been over IMPORT_ROLL_QUIET_SEC since using the last one, // create a new one. This allows for multiple files to come in back-to-back and be // imported on the same roll. - time_t now = (time_t) now_sec(); + var now = GLib.get_monotonic_time(); if (current_import_roll == null || (now - last_import_roll_use) >= IMPORT_ROLL_QUIET_SEC) current_import_roll = new BatchImportRoll(); last_import_roll_use = now; @@ -996,7 +996,7 @@ public class LibraryMonitor : DirectoryMonitor { } if (!known) { - // ressurrect tombstone if deleted + // resurrect tombstone if deleted Tombstone? tombstone = Tombstone.global.locate(file); if (tombstone != null) { debug("Resurrecting tombstoned file %s", file.get_path()); diff --git a/src/MapWidget.vala b/src/MapWidget.vala new file mode 100644 index 0000000..ddfae38 --- /dev/null +++ b/src/MapWidget.vala @@ -0,0 +1,788 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +private class MarkerImageSet { + public float marker_image_width; + public float marker_image_height; + public Clutter.Image? marker_image; + public Clutter.Image? marker_selected_image; + public Clutter.Image? marker_highlighted_image; +} + +private enum SelectionAction { + SET, + ADD, + REMOVE +} + +private abstract class PositionMarker : Object { + protected bool _highlighted = false; + protected bool _selected = false; + protected MarkerImageSet image_set; + + protected PositionMarker(Champlain.Marker champlain_marker, MarkerImageSet image_set) { + this.champlain_marker = champlain_marker; + this.image_set = image_set; + champlain_marker.selectable = true; + champlain_marker.set_content(image_set.marker_image); + float w = image_set.marker_image_width; + float h = image_set.marker_image_height; + champlain_marker.set_size(w, h); + champlain_marker.set_translation(-w * MapWidget.MARKER_IMAGE_HORIZONTAL_PIN_RATIO, + -h * MapWidget.MARKER_IMAGE_VERTICAL_PIN_RATIO, 0); + } + + public Champlain.Marker champlain_marker { get; protected set; } + + public bool highlighted { + get { + return _highlighted; + } + set { + if (_highlighted == value) + return; + _highlighted = value; + var base_image = _selected ? image_set.marker_selected_image : image_set.marker_image; + champlain_marker.set_content(value ? image_set.marker_highlighted_image : base_image); + } + } + public bool selected { + get { + return _selected; + } + set { + if (_selected == value) + return; + _selected = value; + if (!_highlighted) { + var base_image = value ? image_set.marker_selected_image : image_set.marker_image; + champlain_marker.set_content(base_image); + } + champlain_marker.set_selected(value); + } + } +} + +private class DataViewPositionMarker : PositionMarker { + private Gee.LinkedList _data_view_position_markers = + new Gee.LinkedList(); + + public weak DataView view { get; protected set; } + + public DataViewPositionMarker(DataView view, Champlain.Marker champlain_marker, + MarkerImageSet image_set) { + base(champlain_marker, image_set); + this.view = view; + + this._data_view_position_markers.add(this); + } + + public void bind_mouse_events(MapWidget map_widget) { + champlain_marker.button_release_event.connect ((event) => { + if (event.button > 1) + return true; + bool mod = (bool)(event.modifier_state & + (Clutter.ModifierType.CONTROL_MASK | Clutter.ModifierType.SHIFT_MASK)); + SelectionAction action = SelectionAction.SET; + if (mod) + action = _selected ? SelectionAction.REMOVE : SelectionAction.ADD; + selected = (action != SelectionAction.REMOVE); + map_widget.select_data_views(_data_view_position_markers, action); + return true; + }); + champlain_marker.enter_event.connect ((event) => { + highlighted = true; + map_widget.highlight_data_views(_data_view_position_markers); + return true; + }); + champlain_marker.leave_event.connect ((event) => { + highlighted = false; + map_widget.unhighlight_data_views(_data_view_position_markers); + return true; + }); + } +} + +private class MarkerGroup : PositionMarker { + private Gee.Collection _data_view_position_markers = + new Gee.LinkedList(); + private Gee.Collection _position_markers = new Gee.LinkedList(); + private Champlain.BoundingBox bbox = new Champlain.BoundingBox(); + + public void bind_mouse_events(MapWidget map_widget) { + champlain_marker.button_release_event.connect ((event) => { + if (event.button > 1) + return true; + bool mod = (bool)(event.modifier_state & + (Clutter.ModifierType.CONTROL_MASK | Clutter.ModifierType.SHIFT_MASK)); + SelectionAction action = SelectionAction.SET; + if (mod) + action = _selected ? SelectionAction.REMOVE : SelectionAction.ADD; + selected = (action != SelectionAction.REMOVE); + foreach (var m in _data_view_position_markers) { + m.selected = _selected; + } + map_widget.select_data_views(_data_view_position_markers.read_only_view, action); + return true; + }); + champlain_marker.enter_event.connect ((event) => { + highlighted = true; + map_widget.highlight_data_views(_data_view_position_markers.read_only_view); + return true; + }); + champlain_marker.leave_event.connect ((event) => { + highlighted = false; + map_widget.unhighlight_data_views(_data_view_position_markers.read_only_view); + return true; + }); + } + + public Gee.Collection position_markers { + owned get { return _position_markers.read_only_view; } + } + + public MarkerGroup(Champlain.Marker champlain_marker, MarkerImageSet image_set) { + base(champlain_marker, image_set); + } + + public void add_position_marker(PositionMarker marker) { + var data_view_position_marker = marker as DataViewPositionMarker; + if (data_view_position_marker != null) + _data_view_position_markers.add(data_view_position_marker); + var new_champlain_marker = marker.champlain_marker; + bbox.extend(new_champlain_marker.latitude, new_champlain_marker.longitude); + double lat, lon; + bbox.get_center(out lat, out lon); + champlain_marker.set_location(lat, lon); + _position_markers.add(marker); + } +} + +private class MarkerGroupRaster : Object { + private const long MARKER_GROUP_RASTER_WIDTH_PX = 30l; + private const long MARKER_GROUP_RASTER_HEIGHT_PX = 30l; + + private weak MapWidget map_widget; + private weak Champlain.View map_view; + private weak Champlain.MarkerLayer marker_layer; + + public bool is_empty { + get { + return position_markers.is_empty; + } + } + + // position_markers_tree is a two-dimensional tree for grouping position + // markers indexed by x (outer tree) and y (inner tree) raster coordinates. + // It maps coordinates to the PositionMarker (DataViewMarker or MarkerGroup) + // corresponding to them. + // If either raster index keys are empty, there is no marker within the + // raster cell. If both exist there are two possibilities: + // (1) the value is a MarkerGroup which means that multiple markers are + // grouped together, or (2) the value is a PositionMarker (but not a + // MarkerGroup) which means that there is exactly one marker in the raster + // cell. The tree is recreated every time the zoom level changes. + private Gee.TreeMap?> position_markers_tree = + new Gee.TreeMap?>(); + // The marker group's collection keeps track of and owns all PositionMarkers including the marker groups + private Gee.Map data_view_map = new Gee.HashMap(); + private Gee.Set position_markers = new Gee.HashSet(); + + public MarkerGroupRaster(MapWidget map_widget, Champlain.View map_view, Champlain.MarkerLayer marker_layer) { + this.map_widget = map_widget; + this.map_view = map_view; + this.marker_layer = marker_layer; + map_widget.zoom_changed.connect(regroup); + } + + public void clear() { + lock (position_markers) { + data_view_map.clear(); + position_markers_tree.clear(); + position_markers.clear(); + } + } + + public void clear_selection() { + lock (position_markers) { + foreach (PositionMarker m in position_markers) { + m.selected = false; + } + } + } + + public unowned PositionMarker? find_position_marker(DataView data_view) { + if (!data_view_map.has_key(data_view)) + return null; + unowned PositionMarker? m; + lock (position_markers) { + m = data_view_map.get(data_view); + } + return m; + } + + public void rasterize_marker(PositionMarker position_marker, bool already_on_map=false) { + var data_view_position_marker = position_marker as DataViewPositionMarker; + var champlain_marker = position_marker.champlain_marker; + long x, y; + + lock (position_markers) { + rasterize_coords(champlain_marker.longitude, champlain_marker.latitude, out x, out y); + var yg = position_markers_tree.get(x); + if (yg == null) { + yg = new Gee.TreeMap(); + position_markers_tree.set(x, yg); + } + var cell = yg.get(y); + if (cell == null) { + // first marker in this raster cell + yg.set(y, position_marker); + position_markers.add(position_marker); + if (!already_on_map) + marker_layer.add_marker(position_marker.champlain_marker); + if (data_view_position_marker != null) + data_view_map.set(data_view_position_marker.view, position_marker); + + } else { + var marker_group = cell as MarkerGroup; + if (marker_group == null) { + // single marker already occupies raster cell: create new group + GpsCoords rasterized_gps_coords = GpsCoords() { + has_gps = 1, + longitude = map_view.x_to_longitude(x), + latitude = map_view.y_to_latitude(y) + }; + marker_group = map_widget.create_marker_group(rasterized_gps_coords); + marker_group.add_position_marker(cell); + if (cell.selected) // group becomes selected if any contained marker is + marker_group.selected = true; + if (cell is DataViewPositionMarker) + data_view_map.set(((DataViewPositionMarker) cell).view, marker_group); + yg.set(y, marker_group); + position_markers.add(marker_group); + position_markers.remove(cell); + marker_layer.add_marker(marker_group.champlain_marker); + marker_layer.remove_marker(cell.champlain_marker); + } + // group already exists, add new marker to it + marker_group.add_position_marker(position_marker); + if (already_on_map) + marker_layer.remove_marker(position_marker.champlain_marker); + if (data_view_position_marker != null) + data_view_map.set(data_view_position_marker.view, marker_group); + } + } + } + + private void rasterize_coords(double longitude, double latitude, out long x, out long y) { + x = (Math.lround(map_view.longitude_to_x(longitude) / MARKER_GROUP_RASTER_WIDTH_PX)) * + MARKER_GROUP_RASTER_WIDTH_PX + (MARKER_GROUP_RASTER_WIDTH_PX / 2); + y = (Math.lround(map_view.latitude_to_y(latitude) / MARKER_GROUP_RASTER_HEIGHT_PX)) * + MARKER_GROUP_RASTER_HEIGHT_PX + (MARKER_GROUP_RASTER_HEIGHT_PX / 2); + } + + internal void regroup() { + lock (position_markers) { + var position_markers_current = (owned) position_markers; + position_markers = new Gee.HashSet(); + position_markers_tree.clear(); + + foreach (var pm in position_markers_current) { + var marker_group = pm as MarkerGroup; + if (marker_group != null) { + marker_layer.remove_marker(marker_group.champlain_marker); + foreach (var position_marker in marker_group.position_markers) { + rasterize_marker(position_marker, false); + } + } else { + rasterize_marker(pm, true); + } + } + position_markers_current = null; + } + } +} + +private class MapWidget : Gtk.Bin { + private const string MAPBOX_API_TOKEN = "pk.eyJ1IjoiamVuc2dlb3JnIiwiYSI6ImNqZ3FtYmhrMTBkOW8yeHBlNG8xN3hlNTAifQ.ek7i8UHeNIlkKi10fhgFgg"; + private const uint DEFAULT_ZOOM_LEVEL = 8; + + private static MapWidget instance = null; + private bool hide_map = false; + + private GtkChamplain.Embed gtk_champlain_widget = new GtkChamplain.Embed(); + private Champlain.View map_view = null; + private Champlain.Scale map_scale = new Champlain.Scale(); + private Champlain.MarkerLayer marker_layer = new Champlain.MarkerLayer(); + public bool map_edit_lock { get; set; } + private MarkerGroupRaster marker_group_raster = null; + private Gee.Map data_view_marker_cache = + new Gee.HashMap(); + private weak Page? page = null; + private Clutter.Image? map_edit_locked_image; + private Clutter.Image? map_edit_unlocked_image; + private Clutter.Actor map_edit_lock_button = new Clutter.Actor(); + private uint position_markers_timeout = 0; + + public const float MARKER_IMAGE_HORIZONTAL_PIN_RATIO = 0.5f; + public const float MARKER_IMAGE_VERTICAL_PIN_RATIO = 0.825f; + public float map_edit_lock_image_width { get; private set; } + public float map_edit_lock_image_height { get; private set; } + public MarkerImageSet marker_image_set { get; private set; } + public MarkerImageSet marker_group_image_set { get; private set; } + public const Clutter.Color marker_point_color = { 10, 10, 255, 192 }; + + public signal void zoom_changed(); + + private MapWidget() { + setup_map(); + add(gtk_champlain_widget); + } + + public static MapWidget get_instance() { + if (instance == null) + instance = new MapWidget(); + return instance; + } + + public override bool drag_motion(Gdk.DragContext context, int x, int y, uint time) { + if (!map_edit_lock) + map_view.stop_go_to(); + else + Gdk.drag_status(context, 0, time); + return true; + } + + public override void drag_data_received(Gdk.DragContext context, int x, int y, + Gtk.SelectionData selection_data, uint info, uint time) { + bool success = false; + Gee.List? media = unserialize_media_sources(selection_data.get_data(), + selection_data.get_length()); + if (media != null && media.size > 0) { + double lat = map_view.y_to_latitude(y); + double lon = map_view.x_to_longitude(x); + success = internal_drop_received(media, lat, lon); + } + + Gtk.drag_finish(context, success, false, time); + } + + public new void set_visible(bool visible) { + /* hides Gtk.Widget.set_visible */ + hide_map = !visible; + base.set_visible(visible); + } + + public override void show_all() { + if (!hide_map) + base.show_all(); + } + + public void set_page(Page page) { + bool page_changed = false; + if (this.page != page) { + this.page = page; + page_changed = true; + clear(); + } + ViewCollection view_collection = page.get_view(); + if (view_collection == null) + return; + + if (page_changed) { + data_view_marker_cache.clear(); + foreach (DataObject view in view_collection.get_all()) { + if (view is DataView) + add_data_view((DataView) view); + } + show_position_markers(); + } + // In any case, the selection did change.. + var selected = view_collection.get_selected(); + if (selected != null) { + marker_group_raster.clear_selection(); + foreach (DataView v in view_collection.get_selected()) { + + var position_marker = marker_group_raster.find_position_marker(v); + if (position_marker != null) + position_marker.selected = true; + if (position_marker is MarkerGroup) { + DataViewPositionMarker? m = data_view_marker_cache.get(v); + if (m != null) + m.selected = true; + } + } + } + } + + public void clear() { + data_view_marker_cache.clear(); + marker_layer.remove_all(); + marker_group_raster.clear(); + } + + public void add_data_view(DataView view) { + DataSource view_source = view.get_source(); + if (!(view_source is Positionable)) + return; + Positionable p = (Positionable) view_source; + GpsCoords gps_coords = p.get_gps_coords(); + if (gps_coords.has_gps <= 0) + return; + PositionMarker position_marker = create_position_marker(view); + marker_group_raster.rasterize_marker(position_marker); + } + + public void show_position_markers() { + if (marker_group_raster.is_empty) + return; + + map_view.stop_go_to(); + double lat, lon; + var bbox = marker_layer.get_bounding_box(); + var zoom_level = map_view.get_zoom_level(); + var zoom_level_test = zoom_level < 2 ? 0 : zoom_level - 2; + bbox.get_center(out lat, out lon); + + if (map_view.get_bounding_box_for_zoom_level(zoom_level_test).covers(lat, lon)) { + // Don't zoom in/out if target is in proximity + map_view.ensure_visible(bbox, true); + } else if (zoom_level >= DEFAULT_ZOOM_LEVEL) { + // zoom out to DEFAULT_ZOOM_LEVEL first, then move + map_view.set_zoom_level(DEFAULT_ZOOM_LEVEL); + map_view.ensure_visible(bbox, true); + } else { + // move first, then zoom in to DEFAULT_ZOOM_LEVEL + map_view.go_to(lat, lon); + // There seems to be a runtime issue with the animation_completed signal + // sig = map_view.animation_completed["go-to"].connect((v) => { ... } + // so we're using a timeout-based approach instead. It should be kept in sync with + // the animation time (500ms by default.) + if (position_markers_timeout > 0) + Source.remove(position_markers_timeout); + position_markers_timeout = Timeout.add(500, () => { + map_view.center_on(lat, lon); // ensure the timeout wasn't too fast + if (map_view.get_zoom_level() < DEFAULT_ZOOM_LEVEL) + map_view.set_zoom_level(DEFAULT_ZOOM_LEVEL); + map_view.ensure_visible(bbox, true); + position_markers_timeout = 0; + return Source.REMOVE; + }); + } + } + + public void select_data_views(Gee.Collection ms, + SelectionAction action = SelectionAction.SET) { + if (page == null) + return; + + ViewCollection page_view = page.get_view(); + if (page_view != null) { + Marker marked = page_view.start_marking(); + foreach (var m in ms) { + if (m.view is CheckerboardItem) { + marked.mark(m.view); + } + } + if (action == SelectionAction.REMOVE) { + page_view.unselect_marked(marked); + } else { + if (action == SelectionAction.SET) + page_view.unselect_all(); + page_view.select_marked(marked); + } + } + } + + public void highlight_data_views(Gee.Collection ms) { + if (page == null) + return; + + bool did_adjust_view = false; + foreach (var m in ms) { + if (!(m.view is CheckerboardItem)) { + continue; + } + + CheckerboardItem item = m.view as CheckerboardItem; + + if (!did_adjust_view && page is CheckerboardPage) { + ((CheckerboardPage) page).scroll_to_item(item); + did_adjust_view = true; + } + item.brighten(); + } + } + + public void unhighlight_data_views(Gee.Collection ms) { + if (page == null) + return; + + foreach (var m in ms) { + if (m.view is CheckerboardItem) { + CheckerboardItem item = (CheckerboardItem) m.view; + item.unbrighten(); + } + } + } + + public void highlight_position_marker(DataView v) { + var position_marker = marker_group_raster.find_position_marker(v); + if (position_marker != null) { + position_marker.highlighted = true; + } + } + + public void unhighlight_position_marker(DataView v) { + var position_marker = marker_group_raster.find_position_marker(v); + if (position_marker != null) { + position_marker.highlighted = false; + } + } + + public void media_source_position_changed(Gee.List media, GpsCoords gps_coords) { + if (page == null) + return; + var view_collection = page.get_view(); + foreach (var source in media) { + var view = view_collection.get_view_for_source(source); + if (view == null) + continue; + var marker = data_view_marker_cache.get(view); + if (marker != null) { + if (gps_coords.has_gps > 0) { + // update individual marker cache + marker.champlain_marker.set_location(gps_coords.latitude, gps_coords.longitude); + } else { + // TODO: position removal not supported by GUI + // remove marker from cache, map_layer + // remove from marker_group_raster (needs a removal method which also removes the + // item from the group if (marker_group_raster.find_position_marker(view) is MarkerGroup) + } + } + } + marker_group_raster.regroup(); + } + + private Champlain.MapSource create_map_source() { + var map_source = new Champlain.MapSourceChain(); + var file_cache = new Champlain.FileCache.full(10 * 1024 * 1024, + AppDirs.get_cache_dir().get_child("tiles").get_child("mapbox-outdoors").get_path(), + new Champlain.ImageRenderer()); + var memory_cache = new Champlain.MemoryCache.full(10 * 1024 * 1024, new Champlain.ImageRenderer()); + var error_source = new Champlain.NullTileSource.full(new Champlain.ImageRenderer()); + + var tile_source = new Champlain.NetworkTileSource.full("mapbox-outdoors", + "Mapbox outdoors tiles", + "", + "", + 0, + 19, + 512, + Champlain.MapProjection.MERCATOR, + "https://api.mapbox.com/styles/v1/mapbox/outdoors-v11/tiles/#Z#/#X#/#Y#?access_token=" + + MAPBOX_API_TOKEN, + new Champlain.ImageRenderer()); + + var user_agent = "Shotwell/%s libchamplain/%s".printf(_VERSION, Champlain.VERSION_S); + tile_source.set_user_agent(user_agent); + tile_source.max_conns = 2; + + map_source.push(error_source); + map_source.push(tile_source); + map_source.push(file_cache); + map_source.push(memory_cache); + + return map_source; + } + + private Clutter.Actor create_attribution_actor() { + const string IMPROVE_TEXT = N_("Improve this map"); + var label = new Gtk.Label(null); + label.set_markup("© Mapbox © OpenStreetMap %s".printf(IMPROVE_TEXT)); + label.get_style_context().add_class("map-attribution"); + + return new GtkClutter.Actor.with_contents(label); + } + + private void setup_map() { + map_view = gtk_champlain_widget.get_view(); + map_view.add_layer(marker_layer); + map_view.set_map_source(create_map_source()); + + var map_attribution_text = create_attribution_actor(); + map_attribution_text.content_gravity = Clutter.ContentGravity.BOTTOM_RIGHT; + map_attribution_text.set_x_align(Clutter.ActorAlign.END); + map_attribution_text.set_x_expand(true); + map_attribution_text.set_y_align(Clutter.ActorAlign.END); + map_attribution_text.set_y_expand(true); + + // add lock/unlock button to top left corner of map + map_edit_lock_button.content_gravity = Clutter.ContentGravity.TOP_RIGHT; + map_edit_lock_button.reactive = true; + map_edit_lock_button.set_x_align(Clutter.ActorAlign.END); + map_edit_lock_button.set_x_expand(true); + map_edit_lock_button.set_y_align(Clutter.ActorAlign.START); + map_edit_lock_button.set_y_expand(true); + + map_edit_lock_button.button_release_event.connect((a, e) => { + if (e.button != 1 /* CLUTTER_BUTTON_PRIMARY */) + return false; + map_edit_lock = !map_edit_lock; + map_edit_lock_button.set_content(map_edit_lock ? + map_edit_locked_image : map_edit_unlocked_image); + return true; + }); + map_view.add_child(map_edit_lock_button); + map_view.add_child(map_attribution_text); + + gtk_champlain_widget.has_tooltip = true; + gtk_champlain_widget.query_tooltip.connect((x, y, keyboard_tooltip, tooltip) => { + Gdk.Rectangle lock_rect = { + (int) map_edit_lock_button.x, + (int) map_edit_lock_button.y, + (int) map_edit_lock_button.width, + (int) map_edit_lock_button.height, + }; + Gdk.Rectangle mouse_pos = { x, y, 1, 1 }; + if (!lock_rect.intersect(mouse_pos, null)) + return false; + tooltip.set_text(_("Lock or unlock map for geotagging by dragging pictures onto the map")); + return true; + }); + + // add scale to bottom left corner of the map + map_scale.content_gravity = Clutter.ContentGravity.BOTTOM_LEFT; + map_scale.connect_view(map_view); + map_scale.set_x_align(Clutter.ActorAlign.START); + map_scale.set_x_expand(true); + map_scale.set_y_align(Clutter.ActorAlign.END); + map_scale.set_y_expand(true); + map_view.add_child(map_scale); + + map_view.set_zoom_on_double_click(false); + map_view.notify.connect((o, p) => { + if (p.name == "zoom-level") + zoom_changed(); + }); + + Gtk.TargetEntry[] dnd_targets = { + LibraryWindow.DND_TARGET_ENTRIES[LibraryWindow.TargetType.URI_LIST], + LibraryWindow.DND_TARGET_ENTRIES[LibraryWindow.TargetType.MEDIA_LIST] + }; + Gtk.drag_dest_set(this, Gtk.DestDefaults.ALL, dnd_targets, + Gdk.DragAction.COPY | Gdk.DragAction.LINK | Gdk.DragAction.ASK); + button_press_event.connect(map_zoom_handler); + set_size_request(200, 200); + + marker_group_raster = new MarkerGroupRaster(this, map_view, marker_layer); + + // Load icons + float w, h; + marker_image_set = new MarkerImageSet(); + marker_group_image_set = new MarkerImageSet(); + marker_image_set.marker_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_MARKER, out w, out h); + marker_image_set.marker_image_width = w; + marker_image_set.marker_image_height = h; + marker_image_set.marker_selected_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_MARKER_SELECTED, out w, out h); + marker_image_set.marker_highlighted_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_MARKER_HIGHLIGHTED, out w, out h); + + marker_group_image_set.marker_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_GROUP_MARKER, out w, out h); + marker_group_image_set.marker_image_width = w; + marker_group_image_set.marker_image_height = h; + marker_group_image_set.marker_selected_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_GROUP_MARKER_SELECTED, out w, out h); + marker_group_image_set.marker_highlighted_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_GROUP_MARKER_HIGHLIGHTED, out w, out h); + + map_edit_locked_image = Resources.get_icon_as_clutter_image( + Resources.ICON_MAP_EDIT_LOCKED, out w, out h); + map_edit_unlocked_image = Resources.get_icon_as_clutter_image( + Resources.ICON_MAP_EDIT_UNLOCKED, out w, out h); + map_edit_lock_image_width = w; + map_edit_lock_image_height = h; + if (map_edit_locked_image == null) { + warning("Couldn't load map edit lock image"); + } else { + map_edit_lock_button.set_content(map_edit_locked_image); + map_edit_lock_button.set_size(map_edit_lock_image_width, map_edit_lock_image_height); + map_edit_lock = true; + } + } + + private Champlain.Marker create_champlain_marker(GpsCoords gps_coords) { + assert(gps_coords.has_gps > 0); + + Champlain.Marker champlain_marker; + champlain_marker = new Champlain.Marker(); + champlain_marker.set_pivot_point(0.5f, 0.5f); // set center of marker + champlain_marker.set_location(gps_coords.latitude, gps_coords.longitude); + return champlain_marker; + } + + private DataViewPositionMarker create_position_marker(DataView view) { + var position_marker = data_view_marker_cache.get(view); + if (position_marker != null) + return position_marker; + DataSource data_source = view.get_source(); + Positionable p = (Positionable) data_source; + GpsCoords gps_coords = p.get_gps_coords(); + Champlain.Marker champlain_marker = create_champlain_marker(gps_coords); + position_marker = new DataViewPositionMarker(view, champlain_marker, marker_image_set); + position_marker.bind_mouse_events(this); + data_view_marker_cache.set(view, position_marker); + return (owned) position_marker; + } + + internal MarkerGroup create_marker_group(GpsCoords gps_coords) { + Champlain.Marker champlain_marker = create_champlain_marker(gps_coords); + var g = new MarkerGroup(champlain_marker, marker_group_image_set); + g.bind_mouse_events(this); + return (owned) g; + } + + private bool map_zoom_handler(Gdk.EventButton event) { + if (event.type == Gdk.EventType.2BUTTON_PRESS) { + if (event.button == 1 || event.button == 3) { + double lat = map_view.y_to_latitude(event.y); + double lon = map_view.x_to_longitude(event.x); + if (event.button == 1) { + map_view.zoom_in(); + } else { + map_view.zoom_out(); + } + map_view.center_on(lat, lon); + return true; + } + } + return false; + } + + private bool internal_drop_received(Gee.List media, double lat, double lon) { + if (map_edit_lock) + return false; + + bool success = false; + GpsCoords gps_coords = GpsCoords() { + has_gps = 1, + latitude = lat, + longitude = lon + }; + foreach (var m in media) { + Positionable p = m as Positionable; + if (p != null) { + p.set_gps_coords(gps_coords); + success = true; + } + } + media_source_position_changed(media, gps_coords); + return success; + } +} diff --git a/src/MediaDataRepresentation.vala b/src/MediaDataRepresentation.vala index 6e6af00..3400577 100644 --- a/src/MediaDataRepresentation.vala +++ b/src/MediaDataRepresentation.vala @@ -7,10 +7,10 @@ public class BackingFileState { public string filepath; public int64 filesize; - public time_t modification_time; + public DateTime modification_time; public string? md5; - public BackingFileState(string filepath, int64 filesize, time_t modification_time, string? md5) { + public BackingFileState(string filepath, int64 filesize, DateTime modification_time, string? md5) { this.filepath = filepath; this.filesize = filesize; this.modification_time = modification_time; @@ -154,7 +154,7 @@ public abstract class MediaSource : ThumbnailSource, Indexable { public abstract File get_master_file(); public abstract uint64 get_master_filesize(); public abstract uint64 get_filesize(); - public abstract time_t get_timestamp(); + public abstract DateTime? get_timestamp(); // Must return at least one, for the master file. public abstract BackingFileState[] get_backing_files_state(); @@ -262,7 +262,7 @@ public abstract class MediaSource : ThumbnailSource, Indexable { controller.commit(); } - public abstract time_t get_exposure_time(); + public abstract DateTime? get_exposure_time(); public abstract ImportID get_import_id(); } diff --git a/src/MediaInterfaces.vala b/src/MediaInterfaces.vala index 1a352a2..f2f570c 100644 --- a/src/MediaInterfaces.vala +++ b/src/MediaInterfaces.vala @@ -206,7 +206,7 @@ public interface Monitorable : MediaSource { // from Photo to here in order to add this capability to videos. It should // fire a "metadata:exposure-time" alteration when called. public interface Dateable : MediaSource { - public abstract void set_exposure_time(time_t target_time); + public abstract void set_exposure_time(DateTime target_time); - public abstract time_t get_exposure_time(); + public abstract DateTime? get_exposure_time(); } diff --git a/src/MediaMetadata.vala b/src/MediaMetadata.vala deleted file mode 100644 index b2ba1b7..0000000 --- a/src/MediaMetadata.vala +++ /dev/null @@ -1,125 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. - */ - -public abstract class MediaMetadata { - public abstract void read_from_file(File file) throws Error; - - public abstract MetadataDateTime? get_creation_date_time(); - - public abstract string? get_title(); - - public abstract string? get_comment(); -} - -public struct MetadataRational { - public int numerator; - public int denominator; - - public MetadataRational(int numerator, int denominator) { - this.numerator = numerator; - this.denominator = denominator; - } - - private bool is_component_valid(int component) { - return (component >= 0) && (component <= 1000000); - } - - public bool is_valid() { - return (is_component_valid(numerator) && is_component_valid(denominator)); - } - - public string to_string() { - return (is_valid()) ? ("%d/%d".printf(numerator, denominator)) : ""; - } -} - -public errordomain MetadataDateTimeError { - INVALID_FORMAT, - UNSUPPORTED_FORMAT -} - -public class MetadataDateTime { - - private time_t timestamp; - - public MetadataDateTime(time_t timestamp) { - this.timestamp = timestamp; - } - - public MetadataDateTime.from_exif(string label) throws MetadataDateTimeError { - if (!from_exif_date_time(label, out timestamp)) - throw new MetadataDateTimeError.INVALID_FORMAT("%s is not EXIF format date/time", label); - } - - public MetadataDateTime.from_iptc(string date, string time) throws MetadataDateTimeError { - // TODO: Support IPTC date/time format - throw new MetadataDateTimeError.UNSUPPORTED_FORMAT("IPTC date/time format not currently supported"); - } - - public MetadataDateTime.from_xmp(string label) throws MetadataDateTimeError { - TimeVal time_val = TimeVal(); - if (!time_val.from_iso8601(label)) - throw new MetadataDateTimeError.INVALID_FORMAT("%s is not XMP format date/time", label); - - timestamp = time_val.tv_sec; - } - - public time_t get_timestamp() { - return timestamp; - } - - public string get_exif_label() { - return to_exif_date_time(timestamp); - } - - // TODO: get_iptc_date() and get_iptc_time() - - public string get_xmp_label() { - TimeVal time_val = TimeVal(); - time_val.tv_sec = timestamp; - time_val.tv_usec = 0; - - return time_val.to_iso8601(); - } - - public static bool from_exif_date_time(string date_time, out time_t timestamp) { - timestamp = 0; - - Time tm = Time(); - - // Check standard EXIF format - if (date_time.scanf("%d:%d:%d %d:%d:%d", - &tm.year, &tm.month, &tm.day, &tm.hour, &tm.minute, &tm.second) != 6) { - // Fallback in a more generic format - string tmp = date_time.dup(); - tmp.canon("0123456789", ' '); - if (tmp.scanf("%4d%2d%2d%2d%2d%2d", - &tm.year, &tm.month, &tm.day, &tm.hour, &tm.minute,&tm.second) != 6) - return false; - } - - // watch for bogosity - if (tm.year <= 1900 || tm.month <= 0 || tm.day < 0 || tm.hour < 0 || tm.minute < 0 || tm.second < 0) - return false; - - tm.year -= 1900; - tm.month--; - tm.isdst = -1; - - timestamp = tm.mktime(); - - return true; - } - - public static string to_exif_date_time(time_t timestamp) { - return Time.local(timestamp).format("%Y:%m:%d %H:%M:%S"); - } - - public string to_string() { - return to_exif_date_time(timestamp); - } -} - diff --git a/src/MediaPage.vala b/src/MediaPage.vala index f849ac3..5fa3fca 100644 --- a/src/MediaPage.vala +++ b/src/MediaPage.vala @@ -90,10 +90,7 @@ public abstract class MediaPage : CheckerboardPage { } public static double scale_to_slider(int value) { - assert(value >= Thumbnail.MIN_SCALE); - assert(value <= Thumbnail.MAX_SCALE); - - return (double) ((value - Thumbnail.MIN_SCALE) / SLIDER_STEPPING); + return (double) ((value.clamp(Thumbnail.MIN_SCALE, Thumbnail.MAX_SCALE) - Thumbnail.MIN_SCALE) / SLIDER_STEPPING); } public static int slider_to_scale(double value) { @@ -887,7 +884,7 @@ public abstract class MediaPage : CheckerboardPage { case SortBy.EXPOSURE_DATE: if (ascending) comparator = Thumbnail.exposure_time_ascending_comparator; - else comparator = Thumbnail.exposure_time_desending_comparator; + else comparator = Thumbnail.exposure_time_descending_comparator; predicate = Thumbnail.exposure_time_comparator_predicate; break; diff --git a/src/MetadataWriter.vala b/src/MetadataWriter.vala index 0c23260..5fc26d1 100644 --- a/src/MetadataWriter.vala +++ b/src/MetadataWriter.vala @@ -15,7 +15,7 @@ public class MetadataWriter : Object { public const uint COMMIT_DELAY_MSEC = 3000; public const uint COMMIT_SPACING_MSEC = 50; - private const string[] INTERESTED_PHOTO_METADATA_DETAILS = { "name", "comment", "rating", "exposure-time" }; + private const string[] INTERESTED_PHOTO_METADATA_DETAILS = { "name", "comment", "rating", "exposure-time", "gps" }; private class CommitJob : BackgroundJob { public LibraryPhoto photo; @@ -108,18 +108,26 @@ public class MetadataWriter : Object { } // exposure date/time - time_t current_exposure_time = photo.get_exposure_time(); - time_t metadata_exposure_time = 0; + DateTime? current_exposure_time = photo.get_exposure_time(); + DateTime? metadata_exposure_time = null; MetadataDateTime? metadata_exposure_date_time = metadata.get_exposure_date_time(); if (metadata_exposure_date_time != null) metadata_exposure_time = metadata_exposure_date_time.get_timestamp(); if (current_exposure_time != metadata_exposure_time) { - metadata.set_exposure_date_time(current_exposure_time != 0 + metadata.set_exposure_date_time(current_exposure_time != null ? new MetadataDateTime(current_exposure_time) : null); changed = true; } + // gps location + GpsCoords current_gps_coords = photo.get_gps_coords(); + GpsCoords metadata_gps_coords = metadata.get_gps_coords(); + if (!current_gps_coords.equals(ref metadata_gps_coords)) { + metadata.set_gps_coords(current_gps_coords); + changed = true; + } + // tags (keywords) ... replace (or clear) entirely rather than union or intersection Gee.Set safe_keywords = new Gee.HashSet(); @@ -681,7 +689,7 @@ public class MetadataWriter : Object { try { job.photo.set_master_metadata_dirty(false); - } catch (DatabaseError err) { + } catch (Error err) { AppWindow.database_error(err); } diff --git a/src/Page.vala b/src/Page.vala index 65b263b..6b07568 100644 --- a/src/Page.vala +++ b/src/Page.vala @@ -13,13 +13,13 @@ public class InjectionGroup { } public string name; public string action; - public string? accellerator; + public string? accelerator; public ItemType kind; - public Element(string name, string? action, string? accellerator, ItemType kind) { + public Element(string name, string? action, string? accelerator, ItemType kind) { this.name = name; this.action = action != null ? action : name; - this.accellerator = accellerator; + this.accelerator = accelerator; this.kind = kind; } } @@ -40,8 +40,8 @@ public class InjectionGroup { return elements; } - public void add_menu_item(string name, string? action = null, string? accellerator = null) { - elements.add(new Element(name, action, accellerator, Element.ItemType.MENUITEM)); + public void add_menu_item(string name, string? action = null, string? accelerator = null) { + elements.add(new Element(name, action, accelerator, Element.ItemType.MENUITEM)); } public void add_menu(string name, string? action = null) { @@ -68,7 +68,6 @@ public abstract class Page : Gtk.ScrolledWindow { private string toolbar_path; private Gdk.Rectangle last_position = Gdk.Rectangle(); private Gtk.Widget event_source = null; - private bool dnd_enabled = false; private ulong last_configure_ms = 0; private bool report_move_finished = false; private bool report_resize_finished = false; @@ -85,6 +84,9 @@ public abstract class Page : Gtk.ScrolledWindow { private int cursor_hide_time_cached = 0; private bool are_actions_attached = false; private OneShotScheduler? update_actions_scheduler = null; + + protected double wheel_factor = 0.0; + protected double modified_wheel_factor = 1.0; protected Page(string page_name) { this.page_name = page_name; @@ -193,8 +195,6 @@ public abstract class Page : Gtk.ScrolledWindow { event_source.leave_notify_event.disconnect(on_leave_notify_event); event_source.scroll_event.disconnect(on_mousewheel_internal); - disable_drag_source(); - event_source = null; } @@ -230,10 +230,10 @@ public abstract class Page : Gtk.ScrolledWindow { case InjectionGroup.Element.ItemType.MENUITEM: var item = new GLib.MenuItem (element.name, "win." + element.action); - if (element.accellerator != null) { + if (element.accelerator != null) { item.set_attribute ("accel", "s", - element.accellerator); + element.accelerator); } menu.append_item (item); @@ -592,76 +592,6 @@ public abstract class Page : Gtk.ScrolledWindow { protected virtual void update_actions(int selected_count, int count) { } - // 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, 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); - - // 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.connect(on_drag_begin); - event_source.drag_data_get.connect(on_drag_data_get); - event_source.drag_data_delete.connect(on_drag_data_delete); - event_source.drag_end.connect(on_drag_end); - event_source.drag_failed.connect(on_drag_failed); - - dnd_enabled = true; - } - - public void disable_drag_source() { - if (!dnd_enabled) - return; - - assert(event_source != null); - - event_source.drag_begin.disconnect(on_drag_begin); - event_source.drag_data_get.disconnect(on_drag_data_get); - event_source.drag_data_delete.disconnect(on_drag_data_delete); - event_source.drag_end.disconnect(on_drag_end); - event_source.drag_failed.disconnect(on_drag_failed); - Gtk.drag_source_unset(event_source); - - dnd_enabled = false; - } - - public bool is_dnd_enabled() { - return dnd_enabled; - } - - private void on_drag_begin(Gdk.DragContext context) { - drag_begin(context); - } - - private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data, - uint info, uint time) { - drag_data_get(context, selection_data, info, time); - } - - private void on_drag_data_delete(Gdk.DragContext context) { - drag_data_delete(context); - } - - private void on_drag_end(Gdk.DragContext context) { - drag_end(context); - } - - // 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; - } - - private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) { - return source_drag_failed(context, drag_result); - } - // Use this function rather than GDK or GTK's get_pointer, especially if called during a // button-down mouse drag (i.e. a window grab). // @@ -1032,13 +962,13 @@ public abstract class Page : Gtk.ScrolledWindow { double dx, dy; event.get_scroll_deltas(out dx, out dy); - if (dy < 0) + if (dy < -1.0 * this.wheel_factor) return on_mousewheel_up(event); - else if (dy > 0) + else if (dy > this.wheel_factor) return on_mousewheel_down(event); - else if (dx < 0) + else if (dx < -1.0 * this.wheel_factor) return on_mousewheel_left(event); - else if (dx > 0) + else if (dx > this.wheel_factor) return on_mousewheel_right(event); else return false; @@ -1115,15 +1045,19 @@ public abstract class Page : Gtk.ScrolledWindow { } public void stop_cursor_hiding() { - if (last_timeout_id != 0) + if (last_timeout_id != 0) { Source.remove(last_timeout_id); + last_timeout_id = 0; + } } public void suspend_cursor_hiding() { cursor_hide_time_cached = cursor_hide_msec; - if (last_timeout_id != 0) + if (last_timeout_id != 0) { Source.remove(last_timeout_id); + last_timeout_id = 0; + } cursor_hide_msec = 0; } @@ -1209,1454 +1143,3 @@ public abstract class Page : Gtk.ScrolledWindow { } -public abstract class CheckerboardPage : Page { - private const int AUTOSCROLL_PIXELS = 50; - private const int AUTOSCROLL_TICKS_MSEC = 50; - - private CheckerboardLayout layout; - private string item_context_menu_path = null; - private string page_context_menu_path = null; - private Gtk.Viewport viewport = new Gtk.Viewport(null, null); - protected CheckerboardItem anchor = null; - protected CheckerboardItem cursor = null; - private CheckerboardItem current_hovered_item = null; - private bool autoscroll_scheduled = false; - private CheckerboardItem activated_item = null; - private Gee.ArrayList previously_selected = null; - - public enum Activator { - KEYBOARD, - MOUSE - } - - public struct KeyboardModifiers { - public KeyboardModifiers(Page page) { - ctrl_pressed = page.get_ctrl_pressed(); - alt_pressed = page.get_alt_pressed(); - shift_pressed = page.get_shift_pressed(); - super_pressed = page.get_super_pressed(); - } - - public bool ctrl_pressed; - public bool alt_pressed; - public bool shift_pressed; - public bool super_pressed; - } - - protected CheckerboardPage(string page_name) { - base (page_name); - - layout = new CheckerboardLayout(get_view()); - layout.set_name(page_name); - - set_event_source(layout); - - set_border_width(0); - set_shadow_type(Gtk.ShadowType.NONE); - - viewport.set_border_width(0); - viewport.set_shadow_type(Gtk.ShadowType.NONE); - - viewport.add(layout); - - // want to set_adjustments before adding to ScrolledWindow to let our signal handlers - // run first ... otherwise, the thumbnails draw late - layout.set_adjustments(get_hadjustment(), get_vadjustment()); - - add(viewport); - - // need to monitor items going hidden when dealing with anchor/cursor/highlighted items - get_view().items_hidden.connect(on_items_hidden); - get_view().contents_altered.connect(on_contents_altered); - get_view().items_state_changed.connect(on_items_state_changed); - get_view().items_visibility_changed.connect(on_items_visibility_changed); - - // scrollbar policy - set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); - } - - public void init_item_context_menu(string path) { - item_context_menu_path = path; - } - - public void init_page_context_menu(string path) { - page_context_menu_path = path; - } - - public Gtk.Menu? get_context_menu() { - // show page context menu if nothing is selected - return (get_view().get_selected_count() != 0) ? get_item_context_menu() : - get_page_context_menu(); - } - - private Gtk.Menu item_context_menu; - public virtual Gtk.Menu? get_item_context_menu() { - if (item_context_menu == null) { - var model = this.builder.get_object (item_context_menu_path) - as GLib.MenuModel; - item_context_menu = new Gtk.Menu.from_model (model); - item_context_menu.attach_to_widget (this, null); - } - - return item_context_menu; - } - - private Gtk.Menu page_context_menu; - public override Gtk.Menu? get_page_context_menu() { - if (page_context_menu_path == null) - return null; - - if (page_context_menu == null) { - var model = this.builder.get_object (page_context_menu_path) - as GLib.MenuModel; - page_context_menu = new Gtk.Menu.from_model (model); - page_context_menu.attach_to_widget (this, null); - } - - return page_context_menu; - } - - protected override bool on_context_keypress() { - return popup_context_menu(get_context_menu()); - } - - protected virtual string get_view_empty_message() { - return _("No photos/videos"); - } - - protected virtual string get_filter_no_match_message() { - return _("No photos/videos found which match the current filter"); - } - - protected virtual void on_item_activated(CheckerboardItem item, Activator activator, - KeyboardModifiers modifiers) { - } - - public CheckerboardLayout get_checkerboard_layout() { - return layout; - } - - // Gets the search view filter for this page. - public abstract SearchViewFilter get_search_view_filter(); - - public virtual Core.ViewTracker? get_view_tracker() { - return null; - } - - public override void switching_from() { - layout.set_in_view(false); - get_search_view_filter().refresh.disconnect(on_view_filter_refresh); - - // unselect everything so selection won't persist after page loses focus - get_view().unselect_all(); - - base.switching_from(); - } - - public override void switched_to() { - layout.set_in_view(true); - get_search_view_filter().refresh.connect(on_view_filter_refresh); - on_view_filter_refresh(); - - if (get_view().get_selected_count() > 0) { - CheckerboardItem? item = (CheckerboardItem?) get_view().get_selected_at(0); - - // if item is in any way out of view, scroll to it - Gtk.Adjustment vadj = get_vadjustment(); - if (!(get_adjustment_relation(vadj, item.allocation.y) == AdjustmentRelation.IN_RANGE - && (get_adjustment_relation(vadj, item.allocation.y + item.allocation.height) == AdjustmentRelation.IN_RANGE))) { - - // scroll to see the new item - int top = 0; - if (item.allocation.y < vadj.get_value()) { - top = item.allocation.y; - top -= CheckerboardLayout.ROW_GUTTER_PADDING / 2; - } else { - top = item.allocation.y + item.allocation.height - (int) vadj.get_page_size(); - top += CheckerboardLayout.ROW_GUTTER_PADDING / 2; - } - - vadj.set_value(top); - - } - } - - base.switched_to(); - } - - private void on_view_filter_refresh() { - update_view_filter_message(); - } - - private void on_contents_altered(Gee.Iterable? added, - Gee.Iterable? removed) { - update_view_filter_message(); - } - - private void on_items_state_changed(Gee.Iterable changed) { - update_view_filter_message(); - } - - private void on_items_visibility_changed(Gee.Collection changed) { - update_view_filter_message(); - } - - private void update_view_filter_message() { - if (get_view().are_items_filtered_out() && get_view().get_count() == 0) { - set_page_message(get_filter_no_match_message()); - } else if (get_view().get_count() == 0) { - set_page_message(get_view_empty_message()); - } else { - unset_page_message(); - } - } - - public void set_page_message(string message) { - layout.set_message(message); - if (is_in_view()) - layout.queue_draw(); - } - - public void unset_page_message() { - layout.unset_message(); - if (is_in_view()) - layout.queue_draw(); - } - - public override void set_page_name(string name) { - base.set_page_name(name); - - layout.set_name(name); - } - - public CheckerboardItem? get_item_at_pixel(double x, double y) { - return layout.get_item_at_pixel(x, y); - } - - private void on_items_hidden(Gee.Iterable hidden) { - foreach (DataView view in hidden) { - CheckerboardItem item = (CheckerboardItem) view; - - if (anchor == item) - anchor = null; - - if (cursor == item) - cursor = null; - - if (current_hovered_item == item) - current_hovered_item = null; - } - } - - protected override bool key_press_event(Gdk.EventKey event) { - bool handled = true; - - // mask out the modifiers we're interested in - uint state = event.state & Gdk.ModifierType.SHIFT_MASK; - - switch (Gdk.keyval_name(event.keyval)) { - case "Up": - case "KP_Up": - move_cursor(CompassPoint.NORTH); - select_anchor_to_cursor(state); - break; - - case "Down": - case "KP_Down": - move_cursor(CompassPoint.SOUTH); - select_anchor_to_cursor(state); - break; - - case "Left": - case "KP_Left": - move_cursor(CompassPoint.WEST); - select_anchor_to_cursor(state); - break; - - case "Right": - case "KP_Right": - move_cursor(CompassPoint.EAST); - select_anchor_to_cursor(state); - break; - - case "Home": - case "KP_Home": - CheckerboardItem? first = (CheckerboardItem?) get_view().get_first(); - if (first != null) - cursor_to_item(first); - select_anchor_to_cursor(state); - break; - - case "End": - case "KP_End": - CheckerboardItem? last = (CheckerboardItem?) get_view().get_last(); - if (last != null) - cursor_to_item(last); - select_anchor_to_cursor(state); - break; - - case "Return": - case "KP_Enter": - if (get_view().get_selected_count() == 1) - on_item_activated((CheckerboardItem) get_view().get_selected_at(0), - Activator.KEYBOARD, KeyboardModifiers(this)); - else - handled = false; - break; - - case "space": - Marker marker = get_view().mark(layout.get_cursor()); - get_view().toggle_marked(marker); - break; - - default: - handled = false; - break; - } - - if (handled) - return true; - - return (base.key_press_event != null) ? base.key_press_event(event) : true; - } - - protected override bool on_left_click(Gdk.EventButton event) { - // only interested in single-click and double-clicks for now - if ((event.type != Gdk.EventType.BUTTON_PRESS) && (event.type != Gdk.EventType.2BUTTON_PRESS)) - return false; - - // mask out the modifiers we're interested in - uint state = event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK); - - // use clicks for multiple selection and activation only; single selects are handled by - // button release, to allow for multiple items to be selected then dragged ... - CheckerboardItem item = get_item_at_pixel(event.x, event.y); - if (item != null) { - // ... however, there is no dragging if the user clicks on an interactive part of the - // CheckerboardItem (e.g. a tag) - if (layout.handle_left_click(item, event.x, event.y, event.state)) - return true; - - switch (state) { - case Gdk.ModifierType.CONTROL_MASK: - // with only Ctrl pressed, multiple selections are possible ... chosen item - // is toggled - Marker marker = get_view().mark(item); - get_view().toggle_marked(marker); - - if (item.is_selected()) { - anchor = item; - cursor = item; - } - break; - - case Gdk.ModifierType.SHIFT_MASK: - get_view().unselect_all(); - - if (anchor == null) - anchor = item; - - select_between_items(anchor, item); - - cursor = item; - break; - - case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK: - // Ticket #853 - Make Ctrl + Shift + Mouse Button 1 able to start a new run - // of contiguous selected items without unselecting previously-selected items - // a la Nautilus. - // Same as the case for SHIFT_MASK, but don't unselect anything first. - if (anchor == null) - anchor = item; - - select_between_items(anchor, item); - - cursor = item; - break; - - default: - if (event.type == Gdk.EventType.2BUTTON_PRESS) { - activated_item = item; - } else { - // if the user has selected one or more items and is preparing for a drag, - // don't want to blindly unselect: if they've clicked on an unselected item - // unselect all and select that one; if they've clicked on a previously - // selected item, do nothing - if (!item.is_selected()) { - Marker all = get_view().start_marking(); - all.mark_many(get_view().get_selected()); - - get_view().unselect_and_select_marked(all, get_view().mark(item)); - } - } - - anchor = item; - cursor = item; - break; - } - layout.set_cursor(item); - } else { - // user clicked on "dead" area; only unselect if control is not pressed - // do we want similar behavior for shift as well? - if (state != Gdk.ModifierType.CONTROL_MASK) - get_view().unselect_all(); - - // grab previously marked items - previously_selected = new Gee.ArrayList(); - foreach (DataView view in get_view().get_selected()) - previously_selected.add((CheckerboardItem) view); - - layout.set_drag_select_origin((int) event.x, (int) event.y); - - return true; - } - - // need to determine if the signal should be passed to the DnD handlers - // Return true to block the DnD handler, false otherwise - - return get_view().get_selected_count() == 0; - } - - protected override bool on_left_released(Gdk.EventButton event) { - previously_selected = null; - - // if drag-selecting, stop here and do nothing else - if (layout.is_drag_select_active()) { - layout.clear_drag_select(); - anchor = cursor; - - return true; - } - - // only interested in non-modified button releases - if ((event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) != 0) - return false; - - // if the item was activated in the double-click, report it now - if (activated_item != null) { - on_item_activated(activated_item, Activator.MOUSE, KeyboardModifiers(this)); - activated_item = null; - - return true; - } - - CheckerboardItem item = get_item_at_pixel(event.x, event.y); - if (item == null) { - // released button on "dead" area - return true; - } - - if (cursor != item) { - // user released mouse button after moving it off the initial item, or moved from dead - // space onto one. either way, unselect everything - get_view().unselect_all(); - } else { - // the idea is, if a user single-clicks on an item with no modifiers, then all other items - // should be deselected, however, if they single-click in order to drag one or more items, - // they should remain selected, hence performing this here rather than on_left_click - // (item may not be selected if an unimplemented modifier key was used) - if (item.is_selected()) - get_view().unselect_all_but(item); - } - - return true; - } - - protected override bool on_right_click(Gdk.EventButton event) { - // only interested in single-clicks for now - if (event.type != Gdk.EventType.BUTTON_PRESS) - return false; - - // get what's right-clicked upon - CheckerboardItem item = get_item_at_pixel(event.x, event.y); - if (item != null) { - // mask out the modifiers we're interested in - switch (event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) { - case Gdk.ModifierType.CONTROL_MASK: - // chosen item is toggled - Marker marker = get_view().mark(item); - get_view().toggle_marked(marker); - break; - - case Gdk.ModifierType.SHIFT_MASK: - // TODO - break; - - case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK: - // TODO - break; - - default: - // if the item is already selected, proceed; if item is not selected, a bare right - // click unselects everything else but it - if (!item.is_selected()) { - Marker all = get_view().start_marking(); - all.mark_many(get_view().get_selected()); - - get_view().unselect_and_select_marked(all, get_view().mark(item)); - } - break; - } - } else { - // clicked in "dead" space, unselect everything - get_view().unselect_all(); - } - - Gtk.Menu context_menu = get_context_menu(); - return popup_context_menu(context_menu, event); - } - - protected virtual bool on_mouse_over(CheckerboardItem? item, int x, int y, Gdk.ModifierType mask) { - if (item != null) - layout.handle_mouse_motion(item, x, y, mask); - - // if hovering over the last hovered item, or both are null (nothing highlighted and - // hovering over empty space), do nothing - if (item == current_hovered_item) - return true; - - // either something new is highlighted or now hovering over empty space, so dim old item - if (current_hovered_item != null) { - current_hovered_item.handle_mouse_leave(); - current_hovered_item = null; - } - - // if over empty space, done - if (item == null) - return true; - - // brighten the new item - current_hovered_item = item; - current_hovered_item.handle_mouse_enter(); - - return true; - } - - protected override bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) { - // report what item the mouse is hovering over - if (!on_mouse_over(get_item_at_pixel(x, y), x, y, mask)) - return false; - - // go no further if not drag-selecting - if (!layout.is_drag_select_active()) - return false; - - // set the new endpoint of the drag selection - layout.set_drag_select_endpoint(x, y); - - updated_selection_band(); - - // if out of bounds, schedule a check to auto-scroll the viewport - if (!autoscroll_scheduled - && get_adjustment_relation(get_vadjustment(), y) != AdjustmentRelation.IN_RANGE) { - Timeout.add(AUTOSCROLL_TICKS_MSEC, selection_autoscroll); - autoscroll_scheduled = true; - } - - // return true to stop a potential drag-and-drop operation - return true; - } - - private void updated_selection_band() { - assert(layout.is_drag_select_active()); - - // get all items inside the selection - Gee.List? intersection = layout.items_in_selection_band(); - if (intersection == null) - return; - - Marker to_unselect = get_view().start_marking(); - Marker to_select = get_view().start_marking(); - - // mark all selected items to be unselected - to_unselect.mark_many(get_view().get_selected()); - - // except for the items that were selected before the drag began - assert(previously_selected != null); - to_unselect.unmark_many(previously_selected); - to_select.mark_many(previously_selected); - - // toggle selection on everything in the intersection and update the cursor - cursor = null; - - foreach (CheckerboardItem item in intersection) { - if (to_select.toggle(item)) - to_unselect.unmark(item); - else - to_unselect.mark(item); - - if (cursor == null) - cursor = item; - } - - get_view().select_marked(to_select); - get_view().unselect_marked(to_unselect); - } - - private bool selection_autoscroll() { - if (!layout.is_drag_select_active()) { - autoscroll_scheduled = false; - - return false; - } - - // as the viewport never scrolls horizontally, only interested in vertical - Gtk.Adjustment vadj = get_vadjustment(); - - int x, y; - Gdk.ModifierType mask; - get_event_source_pointer(out x, out y, out mask); - - int new_value = (int) vadj.get_value(); - switch (get_adjustment_relation(vadj, y)) { - case AdjustmentRelation.BELOW: - // pointer above window, scroll up - new_value -= AUTOSCROLL_PIXELS; - layout.set_drag_select_endpoint(x, new_value); - break; - - case AdjustmentRelation.ABOVE: - // pointer below window, scroll down, extend selection to bottom of page - new_value += AUTOSCROLL_PIXELS; - layout.set_drag_select_endpoint(x, new_value + (int) vadj.get_page_size()); - break; - - case AdjustmentRelation.IN_RANGE: - autoscroll_scheduled = false; - - return false; - - default: - warn_if_reached(); - break; - } - - // It appears that in GTK+ 2.18, the adjustment is not clamped the way it was in 2.16. - // This may have to do with how adjustments are different w/ scrollbars, that they're upper - // clamp is upper - page_size ... either way, enforce these limits here - vadj.set_value(new_value.clamp((int) vadj.get_lower(), - (int) vadj.get_upper() - (int) vadj.get_page_size())); - - updated_selection_band(); - - return true; - } - - public void cursor_to_item(CheckerboardItem item) { - assert(get_view().contains(item)); - - cursor = item; - - if (!get_ctrl_pressed()) { - get_view().unselect_all(); - Marker marker = get_view().mark(item); - get_view().select_marked(marker); - } - layout.set_cursor(item); - - // if item is in any way out of view, scroll to it - Gtk.Adjustment vadj = get_vadjustment(); - if (get_adjustment_relation(vadj, item.allocation.y) == AdjustmentRelation.IN_RANGE - && (get_adjustment_relation(vadj, item.allocation.y + item.allocation.height) == AdjustmentRelation.IN_RANGE)) - return; - - // scroll to see the new item - int top = 0; - if (item.allocation.y < vadj.get_value()) { - top = item.allocation.y; - top -= CheckerboardLayout.ROW_GUTTER_PADDING / 2; - } else { - top = item.allocation.y + item.allocation.height - (int) vadj.get_page_size(); - top += CheckerboardLayout.ROW_GUTTER_PADDING / 2; - } - - vadj.set_value(top); - } - - public void move_cursor(CompassPoint point) { - // if no items, nothing to do - if (get_view().get_count() == 0) - return; - - // if there is no better starting point, simply select the first and exit - // The right half of the or is related to Bug #732334, the cursor might be non-null and still not contained in - // the view, if the user dragged a full screen Photo off screen - if (cursor == null && layout.get_cursor() == null || cursor != null && !get_view().contains(cursor)) { - CheckerboardItem item = layout.get_item_at_coordinate(0, 0); - cursor_to_item(item); - anchor = item; - - return; - } - - if (cursor == null) { - cursor = layout.get_cursor() as CheckerboardItem; - } - - // move the cursor relative to the "first" item - CheckerboardItem? item = layout.get_item_relative_to(cursor, point); - if (item != null) - cursor_to_item(item); - } - - public void set_cursor(CheckerboardItem item) { - Marker marker = get_view().mark(item); - get_view().select_marked(marker); - - cursor = item; - anchor = item; - } - - public void select_between_items(CheckerboardItem item_start, CheckerboardItem item_end) { - Marker marker = get_view().start_marking(); - - bool passed_start = false; - bool passed_end = false; - - foreach (DataObject object in get_view().get_all()) { - CheckerboardItem item = (CheckerboardItem) object; - - if (item_start == item) - passed_start = true; - - if (item_end == item) - passed_end = true; - - if (passed_start || passed_end) - marker.mark((DataView) object); - - if (passed_start && passed_end) - break; - } - - get_view().select_marked(marker); - } - - public void select_anchor_to_cursor(uint state) { - if (cursor == null || anchor == null) - return; - - if (state == Gdk.ModifierType.SHIFT_MASK) { - get_view().unselect_all(); - select_between_items(anchor, cursor); - } else { - anchor = cursor; - } - } - - protected virtual void set_display_titles(bool display) { - get_view().freeze_notifications(); - get_view().set_property(CheckerboardItem.PROP_SHOW_TITLES, display); - get_view().thaw_notifications(); - } - - protected virtual void set_display_comments(bool display) { - get_view().freeze_notifications(); - get_view().set_property(CheckerboardItem.PROP_SHOW_COMMENTS, display); - get_view().thaw_notifications(); - } -} - -public abstract class SinglePhotoPage : Page { - public const Gdk.InterpType FAST_INTERP = Gdk.InterpType.NEAREST; - public const Gdk.InterpType QUALITY_INTERP = Gdk.InterpType.BILINEAR; - public const int KEY_REPEAT_INTERVAL_MSEC = 200; - - public enum UpdateReason { - NEW_PIXBUF, - QUALITY_IMPROVEMENT, - RESIZED_CANVAS - } - - protected Gtk.DrawingArea canvas = new Gtk.DrawingArea(); - protected Gtk.Viewport viewport = new Gtk.Viewport(null, null); - - private bool scale_up_to_viewport; - private TransitionClock transition_clock; - private int transition_duration_msec = 0; - private Cairo.Surface pixmap = null; - private Cairo.Context pixmap_ctx = null; - private Cairo.Context text_ctx = null; - private Dimensions pixmap_dim = Dimensions(); - private Gdk.Pixbuf unscaled = null; - private Dimensions max_dim = Dimensions(); - private Gdk.Pixbuf scaled = null; - private Gdk.Pixbuf old_scaled = null; // previous scaled image - private Gdk.Rectangle scaled_pos = Gdk.Rectangle(); - private ZoomState static_zoom_state; - private bool zoom_high_quality = true; - private ZoomState saved_zoom_state; - private bool has_saved_zoom_state = false; - private uint32 last_nav_key = 0; - - protected SinglePhotoPage(string page_name, bool scale_up_to_viewport) { - base(page_name); - - this.scale_up_to_viewport = scale_up_to_viewport; - - transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock(); - - // With the current code automatically resizing the image to the viewport, scrollbars - // should never be shown, but this may change if/when zooming is supported - set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); - - set_border_width(0); - set_shadow_type(Gtk.ShadowType.NONE); - - viewport.set_shadow_type(Gtk.ShadowType.NONE); - viewport.set_border_width(0); - viewport.add(canvas); - - add(viewport); - - canvas.add_events(Gdk.EventMask.EXPOSURE_MASK | Gdk.EventMask.STRUCTURE_MASK - | Gdk.EventMask.SUBSTRUCTURE_MASK); - - viewport.size_allocate.connect(on_viewport_resize); - canvas.draw.connect(on_canvas_exposed); - - set_event_source(canvas); - Config.Facade.get_instance().colors_changed.connect(on_colors_changed); - } - - ~SinglePhotoPage() { - Config.Facade.get_instance().colors_changed.disconnect(on_colors_changed); - } - - public bool is_transition_in_progress() { - return transition_clock.is_in_progress(); - } - - public void cancel_transition() { - if (transition_clock.is_in_progress()) - transition_clock.cancel(); - } - - public void set_transition(string effect_id, int duration_msec) { - cancel_transition(); - - transition_clock = TransitionEffectsManager.get_instance().create_transition_clock(effect_id); - if (transition_clock == null) - transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock(); - - transition_duration_msec = duration_msec; - } - - // This method includes a call to pixmap_ctx.paint(). - private void render_zoomed_to_pixmap(ZoomState zoom_state) { - assert(is_zoom_supported()); - - Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content(); - - Gdk.Pixbuf zoomed; - if (get_zoom_buffer() != null) { - zoomed = (zoom_high_quality) ? get_zoom_buffer().get_zoomed_image(zoom_state) : - get_zoom_buffer().get_zoom_preview_image(zoom_state); - } else { - Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(unscaled); - - Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(unscaled, view_rect_proj.x, - view_rect_proj.y, view_rect_proj.width, view_rect_proj.height); - - zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height, - Gdk.InterpType.BILINEAR); - } - - if (zoomed == null) { - return; - } - - int draw_x = (pixmap_dim.width - view_rect.width) / 2; - draw_x = draw_x.clamp(0, int.MAX); - - int draw_y = (pixmap_dim.height - view_rect.height) / 2; - draw_y = draw_y.clamp(0, int.MAX); - paint_pixmap_with_background(pixmap_ctx, zoomed, draw_x, draw_y); - } - - protected void on_interactive_zoom(ZoomState interactive_zoom_state) { - assert(is_zoom_supported()); - - set_source_color_from_string(pixmap_ctx, "#000"); - pixmap_ctx.paint(); - - bool old_quality_setting = zoom_high_quality; - zoom_high_quality = false; - render_zoomed_to_pixmap(interactive_zoom_state); - zoom_high_quality = old_quality_setting; - - canvas.queue_draw(); - } - - protected void on_interactive_pan(ZoomState interactive_zoom_state) { - assert(is_zoom_supported()); - - set_source_color_from_string(pixmap_ctx, "#000"); - pixmap_ctx.paint(); - - bool old_quality_setting = zoom_high_quality; - zoom_high_quality = true; - render_zoomed_to_pixmap(interactive_zoom_state); - zoom_high_quality = old_quality_setting; - - canvas.queue_draw(); - } - - protected virtual bool is_zoom_supported() { - return false; - } - - protected virtual void cancel_zoom() { - if (pixmap != null) { - set_source_color_from_string(pixmap_ctx, "#000"); - pixmap_ctx.paint(); - } - } - - protected virtual void save_zoom_state() { - saved_zoom_state = static_zoom_state; - has_saved_zoom_state = true; - } - - protected virtual void restore_zoom_state() { - if (!has_saved_zoom_state) - return; - - static_zoom_state = saved_zoom_state; - repaint(); - has_saved_zoom_state = false; - } - - protected virtual ZoomBuffer? get_zoom_buffer() { - return null; - } - - protected ZoomState get_saved_zoom_state() { - return saved_zoom_state; - } - - protected void set_zoom_state(ZoomState zoom_state) { - assert(is_zoom_supported()); - - static_zoom_state = zoom_state; - } - - protected ZoomState get_zoom_state() { - assert(is_zoom_supported()); - - return static_zoom_state; - } - - public override void switched_to() { - base.switched_to(); - - if (unscaled != null) - repaint(); - } - - public override void set_container(Gtk.Window container) { - base.set_container(container); - - // scrollbar policy in fullscreen mode needs to be auto/auto, else the pixbuf will shift - // off the screen - if (container is FullscreenWindow) - set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); - } - - // max_dim represents the maximum size of the original pixbuf (i.e. pixbuf may be scaled and - // the caller capable of producing larger ones depending on the viewport size). max_dim - // is used when scale_up_to_viewport is set to true. Pass a Dimensions with no area if - // max_dim should be ignored (i.e. scale_up_to_viewport is false). - public void set_pixbuf(Gdk.Pixbuf unscaled, Dimensions max_dim, Direction? direction = null) { - static_zoom_state = ZoomState(max_dim, pixmap_dim, - static_zoom_state.get_interpolation_factor(), - static_zoom_state.get_viewport_center()); - - cancel_transition(); - - this.unscaled = unscaled; - this.max_dim = max_dim; - this.old_scaled = scaled; - scaled = null; - - // need to make sure this has happened - canvas.realize(); - - repaint(direction); - } - - public void blank_display() { - unscaled = null; - max_dim = Dimensions(); - scaled = null; - pixmap = null; - - // this has to have happened - canvas.realize(); - - // force a redraw - invalidate_all(); - } - - public Cairo.Surface? get_surface() { - return pixmap; - } - - public Dimensions get_surface_dim() { - return pixmap_dim; - } - - public Cairo.Context get_cairo_context() { - return pixmap_ctx; - } - - public void paint_text(Pango.Layout pango_layout, int x, int y) { - text_ctx.move_to(x, y); - Pango.cairo_show_layout(text_ctx, pango_layout); - } - - public Scaling get_canvas_scaling() { - return (get_container() is FullscreenWindow) ? Scaling.for_screen(AppWindow.get_instance(), scale_up_to_viewport) - : Scaling.for_widget(viewport, scale_up_to_viewport); - } - - public Gdk.Pixbuf? get_unscaled_pixbuf() { - return unscaled; - } - - public Gdk.Pixbuf? get_scaled_pixbuf() { - return scaled; - } - - // Returns a rectangle describing the pixbuf in relation to the canvas - public Gdk.Rectangle get_scaled_pixbuf_position() { - return scaled_pos; - } - - public bool is_inside_pixbuf(int x, int y) { - return coord_in_rectangle(x, y, scaled_pos); - } - - public void invalidate(Gdk.Rectangle rect) { - if (canvas.get_window() != null) - canvas.get_window().invalidate_rect(rect, false); - } - - public void invalidate_all() { - if (canvas.get_window() != null) - canvas.get_window().invalidate_rect(null, false); - } - - private void on_viewport_resize() { - // do fast repaints while resizing - internal_repaint(true, null); - } - - protected override void on_resize_finished(Gdk.Rectangle rect) { - base.on_resize_finished(rect); - - // when the resize is completed, do a high-quality repaint - repaint(); - } - - private bool on_canvas_exposed(Cairo.Context exposed_ctx) { - // draw pixmap onto canvas unless it's not been instantiated, in which case draw black - // (so either old image or contents of another page is not left on screen) - if (pixmap != null) - exposed_ctx.set_source_surface(pixmap, 0, 0); - else - set_source_color_from_string(exposed_ctx, "#000"); - - exposed_ctx.rectangle(0, 0, get_allocated_width(), get_allocated_height()); - exposed_ctx.paint(); - - return true; - } - - protected virtual void new_surface(Cairo.Context ctx, Dimensions ctx_dim) { - } - - protected virtual void updated_pixbuf(Gdk.Pixbuf pixbuf, UpdateReason reason, Dimensions old_dim) { - } - - protected virtual void paint(Cairo.Context ctx, Dimensions ctx_dim) { - if (is_zoom_supported() && (!static_zoom_state.is_default())) { - set_source_color_from_string(ctx, "#000"); - ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height); - ctx.fill(); - - render_zoomed_to_pixmap(static_zoom_state); - } else if (!transition_clock.paint(ctx, ctx_dim.width, ctx_dim.height)) { - // transition is not running, so paint the full image on a black background - set_source_color_from_string(ctx, "#000"); - - ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height); - ctx.fill(); - - paint_pixmap_with_background(ctx, scaled, scaled_pos.x, scaled_pos.y); - } - } - - private void repaint_pixmap() { - if (pixmap_ctx == null) - return; - - paint(pixmap_ctx, pixmap_dim); - invalidate_all(); - } - - public void repaint(Direction? direction = null) { - internal_repaint(false, direction); - } - - private void internal_repaint(bool fast, Direction? direction) { - // if not in view, assume a full repaint needed in future but do nothing more - if (!is_in_view()) { - pixmap = null; - scaled = null; - - return; - } - - // no image or window, no painting - if (unscaled == null || canvas.get_window() == null) - return; - - Gtk.Allocation allocation; - viewport.get_allocation(out allocation); - - int width = allocation.width; - int height = allocation.height; - - if (width <= 0 || height <= 0) - return; - - bool new_pixbuf = (scaled == null); - - // save if reporting an image being rescaled - Dimensions old_scaled_dim = Dimensions.for_rectangle(scaled_pos); - Gdk.Rectangle old_scaled_pos = scaled_pos; - - // attempt to reuse pixmap - if (pixmap_dim.width != width || pixmap_dim.height != height) - pixmap = null; - - // if necessary, create a pixmap as large as the entire viewport - bool new_pixmap = false; - if (pixmap == null) { - init_pixmap(width, height); - new_pixmap = true; - } - - if (new_pixbuf || new_pixmap) { - Dimensions unscaled_dim = Dimensions.for_pixbuf(unscaled); - - // determine scaled size of pixbuf ... if a max dimensions is set and not scaling up, - // respect it - Dimensions scaled_dim = Dimensions(); - if (!scale_up_to_viewport && max_dim.has_area() && max_dim.width < width && max_dim.height < height) - scaled_dim = max_dim; - else - scaled_dim = unscaled_dim.get_scaled_proportional(pixmap_dim); - - assert(width >= scaled_dim.width); - assert(height >= scaled_dim.height); - - // center pixbuf on the canvas - scaled_pos.x = (width - scaled_dim.width) / 2; - scaled_pos.y = (height - scaled_dim.height) / 2; - scaled_pos.width = scaled_dim.width; - scaled_pos.height = scaled_dim.height; - } - - Gdk.InterpType interp = (fast) ? FAST_INTERP : QUALITY_INTERP; - - // rescale if canvas rescaled or better quality is requested - if (scaled == null) { - scaled = resize_pixbuf(unscaled, Dimensions.for_rectangle(scaled_pos), interp); - - UpdateReason reason = UpdateReason.RESIZED_CANVAS; - if (new_pixbuf) - reason = UpdateReason.NEW_PIXBUF; - else if (!new_pixmap && interp == QUALITY_INTERP) - reason = UpdateReason.QUALITY_IMPROVEMENT; - - static_zoom_state = ZoomState(max_dim, pixmap_dim, - static_zoom_state.get_interpolation_factor(), - static_zoom_state.get_viewport_center()); - - updated_pixbuf(scaled, reason, old_scaled_dim); - } - - zoom_high_quality = !fast; - - if (direction != null && !transition_clock.is_in_progress()) { - Spit.Transitions.Visuals visuals = new Spit.Transitions.Visuals(old_scaled, - old_scaled_pos, scaled, scaled_pos, parse_color("#000")); - - transition_clock.start(visuals, direction.to_transition_direction(), transition_duration_msec, - repaint_pixmap); - } - - if (!transition_clock.is_in_progress()) - repaint_pixmap(); - } - - private void init_pixmap(int width, int height) { - assert(unscaled != null); - assert(canvas.get_window() != null); - - // Cairo backing surface (manual double-buffering) - pixmap = new Cairo.ImageSurface(Cairo.Format.ARGB32, width, height); - pixmap_dim = Dimensions(width, height); - - // Cairo context for drawing on the pixmap - pixmap_ctx = new Cairo.Context(pixmap); - - // need a new pixbuf to fit this scale - scaled = null; - - // Cairo context for drawing text on the pixmap - text_ctx = new Cairo.Context(pixmap); - set_source_color_from_string(text_ctx, "#fff"); - - - // no need to resize canvas, viewport does that automatically - - new_surface(pixmap_ctx, pixmap_dim); - } - - protected override bool on_context_keypress() { - return popup_context_menu(get_page_context_menu()); - } - - protected virtual void on_previous_photo() { - } - - protected virtual void on_next_photo() { - } - - public override bool key_press_event(Gdk.EventKey event) { - // if the user holds the arrow keys down, we will receive a steady stream of key press - // events for an operation that isn't designed for a rapid succession of output ... - // we staunch the supply of new photos to under a quarter second (#533) - bool nav_ok = (event.time - last_nav_key) > KEY_REPEAT_INTERVAL_MSEC; - - bool handled = true; - switch (Gdk.keyval_name(event.keyval)) { - case "Left": - case "KP_Left": - case "BackSpace": - if (nav_ok) { - on_previous_photo(); - last_nav_key = event.time; - } - break; - - case "Right": - case "KP_Right": - case "space": - if (nav_ok) { - on_next_photo(); - last_nav_key = event.time; - } - break; - - default: - handled = false; - break; - } - - if (handled) - return true; - - return (base.key_press_event != null) ? base.key_press_event(event) : true; - } - - private void on_colors_changed() { - invalidate_transparent_background(); - repaint(); - } -} - -// -// DragAndDropHandler attaches signals to a Page to 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). Assumes the Page's ViewCollection holds MediaSources. -// -public class DragAndDropHandler { - private enum TargetType { - XDS, - MEDIA_LIST - } - - private const Gtk.TargetEntry[] SOURCE_TARGET_ENTRIES = { - { "XdndDirectSave0", Gtk.TargetFlags.OTHER_APP, TargetType.XDS }, - { "shotwell/media-id-atom", Gtk.TargetFlags.SAME_APP, TargetType.MEDIA_LIST } - }; - - private static Gdk.Atom? XDS_ATOM = null; - private static Gdk.Atom? TEXT_ATOM = null; - private static uint8[]? XDS_FAKE_TARGET = null; - - private weak Page page; - private Gtk.Widget event_source; - private File? drag_destination = null; - private ExporterUI exporter = null; - - public DragAndDropHandler(Page page) { - this.page = page; - this.event_source = page.get_event_source(); - assert(event_source != null); - assert(event_source.get_has_window()); - - // 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.connect(on_drag_begin); - event_source.drag_data_get.connect(on_drag_data_get); - event_source.drag_end.connect(on_drag_end); - event_source.drag_failed.connect(on_drag_failed); - } - - ~DragAndDropHandler() { - if (event_source != null) { - event_source.drag_begin.disconnect(on_drag_begin); - event_source.drag_data_get.disconnect(on_drag_data_get); - event_source.drag_end.disconnect(on_drag_end); - event_source.drag_failed.disconnect(on_drag_failed); - } - - page = null; - event_source = null; - } - - private void on_drag_begin(Gdk.DragContext context) { - debug("on_drag_begin (%s)", page.get_page_name()); - - if (page == null || page.get_view().get_selected_count() == 0 || exporter != null) - return; - - drag_destination = null; - - // use the first media item as the icon - ThumbnailSource thumb = (ThumbnailSource) page.get_view().get_selected_at(0).get_source(); - - try { - Gdk.Pixbuf icon = thumb.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", thumb.to_string(), - err.message); - } - - // set the XDS property to indicate an XDS save is available -#if VALA_0_20 - Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE, - XDS_FAKE_TARGET, 1); -#else - Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE, - XDS_FAKE_TARGET); -#endif - } - - private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data, - uint target_type, uint time) { - debug("on_drag_data_get (%s)", page.get_page_name()); - - 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; - bool fetched = Gdk.property_get(context.get_source_window(), XDS_ATOM, TEXT_ATOM, - 0, data.length, 0, out actual_type, out actual_format, 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 && data != null && data.length > 0) - drag_destination = File.new_for_uri(uchar_array_to_string(data)).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; - - case TargetType.MEDIA_LIST: - Gee.Collection sources = - (Gee.Collection) page.get_view().get_selected_sources(); - - // convert the selected media sources to Gdk.Atom-encoded sourceID strings for - // internal drag-and-drop - selection_data.set(Gdk.Atom.intern_static_string("SourceIDAtom"), (int) sizeof(Gdk.Atom), - serialize_media_sources(sources)); - break; - - default: - warning("on_drag_data_get (%s): unknown target type %u", page.get_page_name(), - target_type); - break; - } - } - - private void on_drag_end() { - debug("on_drag_end (%s)", page.get_page_name()); - - if (page == null || page.get_view().get_selected_count() == 0 || drag_destination == null - || exporter != null) { - return; - } - - debug("Exporting to %s", drag_destination.get_path()); - - // drag-and-drop export doesn't pop up an export dialog, so use what are likely the - // most common export settings (the current -- or "working" -- file format, with - // all transformations applied, at the image's original size). - if (drag_destination.get_path() != null) { - exporter = new ExporterUI(new Exporter( - (Gee.Collection) page.get_view().get_selected_sources(), - drag_destination, Scaling.for_original(), ExportFormatParameters.current())); - exporter.export(on_export_completed); - } else { - AppWindow.error_message(_("Photos cannot be exported to this directory.")); - } - - drag_destination = null; - } - - private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) { - debug("on_drag_failed (%s): %d", page.get_page_name(), (int) drag_result); - - if (page == null) - return false; - - drag_destination = null; - - return false; - } - - private void on_export_completed() { - exporter = null; - } - -} diff --git a/src/PageMessagePane.vala b/src/PageMessagePane.vala new file mode 100644 index 0000000..e773dad --- /dev/null +++ b/src/PageMessagePane.vala @@ -0,0 +1,19 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +[GtkTemplate (ui = "/org/gnome/Shotwell/ui/message_pane.ui")] +private class PageMessagePane : Gtk.Box { + [GtkChild] + public unowned Gtk.Label label; + + [GtkChild] + public unowned Gtk.Image icon_image; + + public PageMessagePane() { + Object(); + } +} + diff --git a/src/Photo.vala b/src/Photo.vala index b67457e..f31a17d 100644 --- a/src/Photo.vala +++ b/src/Photo.vala @@ -155,7 +155,7 @@ public enum Rating { // particular photo without modifying the backing image file. The interface allows for // transformations to be stored persistently elsewhere or in memory until they're committed en // masse to an image file. -public abstract class Photo : PhotoSource, Dateable { +public abstract class Photo : PhotoSource, Dateable, Positionable { // Need to use "thumb" rather than "photo" for historical reasons -- this name is used // directly to load thumbnails from disk by already-existing filenames public const string TYPENAME = "thumb"; @@ -183,7 +183,7 @@ public abstract class Photo : PhotoSource, Dateable { "pns", "jps", "mpo", // RAW extensions - "3fr", "arw", "srf", "sr2", "bay", "crw", "cr2", "cap", "iiq", "eip", "dcs", "dcr", "drf", + "3fr", "arw", "srf", "sr2", "bay", "crw", "cr2", "cr3", "cap", "iiq", "eip", "dcs", "dcr", "drf", "k25", "kdc", "dng", "erf", "fff", "mef", "mos", "mrw", "nef", "nrw", "orf", "ptx", "pef", "pxn", "r3d", "raf", "raw", "rw2", "rwl", "rwz", "x3f", "srw" }; @@ -210,7 +210,7 @@ public abstract class Photo : PhotoSource, Dateable { // Here, we cache the exposure time to avoid paying to access the row every time we // need to know it. This is initially set in the constructor, and updated whenever // the exposure time is set (please see set_exposure_time() for details). - private time_t cached_exposure_time; + private DateTime? cached_exposure_time; public enum Exception { NONE = 0, @@ -640,7 +640,7 @@ public abstract class Photo : PhotoSource, Dateable { File file = File.new_for_path(bpr.filepath); FileInfo info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES, FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); - TimeVal timestamp = info.get_modification_time(); + var timestamp = info.get_modification_date_time(); PhotoFileInterrogator interrogator = new PhotoFileInterrogator( file, PhotoFileSniffer.Options.GET_ALL); @@ -655,7 +655,7 @@ public abstract class Photo : PhotoSource, Dateable { bpr.dim = detected.image_dim; bpr.filesize = info.get_size(); - bpr.timestamp = timestamp.tv_sec; + bpr.timestamp = timestamp; bpr.original_orientation = detected.metadata != null ? detected.metadata.get_orientation() : Orientation.TOP_LEFT; @@ -832,7 +832,7 @@ public abstract class Photo : PhotoSource, Dateable { if (!developments.has_key(d)) return; // we tried! - // Disgard changes. + // Discard changes. revert_to_master(false); // Switch master to the new photo. @@ -1185,7 +1185,7 @@ public abstract class Photo : PhotoSource, Dateable { return ImportResult.UNSUPPORTED_FORMAT; } - TimeVal timestamp = info.get_modification_time(); + var timestamp = info.get_modification_date_time(); // if all MD5s supplied, don't sniff for them if (params.exif_md5 != null && params.thumbnail_md5 != null && params.full_md5 != null) @@ -1217,8 +1217,9 @@ public abstract class Photo : PhotoSource, Dateable { } Orientation orientation = Orientation.TOP_LEFT; - time_t exposure_time = 0; + DateTime? exposure_time = null; string title = ""; + GpsCoords gps_coords = GpsCoords(); string comment = ""; Rating rating = Rating.UNRATED; @@ -1234,6 +1235,7 @@ public abstract class Photo : PhotoSource, Dateable { orientation = detected.metadata.get_orientation(); title = detected.metadata.get_title(); + gps_coords = detected.metadata.get_gps_coords(); comment = detected.metadata.get_comment(); params.keywords = detected.metadata.get_keywords(); rating = detected.metadata.get_rating(); @@ -1255,7 +1257,7 @@ public abstract class Photo : PhotoSource, Dateable { params.row.master.filepath = file.get_path(); params.row.master.dim = detected.image_dim; params.row.master.filesize = info.get_size(); - params.row.master.timestamp = timestamp.tv_sec; + params.row.master.timestamp = timestamp; params.row.exposure_time = exposure_time; params.row.orientation = orientation; params.row.master.original_orientation = orientation; @@ -1269,6 +1271,7 @@ public abstract class Photo : PhotoSource, Dateable { params.row.flags = 0; params.row.master.file_format = detected.file_format; params.row.title = title; + params.row.gps_coords = gps_coords; params.row.comment = comment; params.row.rating = rating; @@ -1296,8 +1299,8 @@ public abstract class Photo : PhotoSource, Dateable { params.row.master.filepath = file.get_path(); params.row.master.dim = Dimensions(0,0); params.row.master.filesize = 0; - params.row.master.timestamp = 0; - params.row.exposure_time = 0; + params.row.master.timestamp = null; + params.row.exposure_time = null; params.row.orientation = Orientation.TOP_LEFT; params.row.master.original_orientation = Orientation.TOP_LEFT; params.row.import_id = params.import_id; @@ -1310,6 +1313,7 @@ public abstract class Photo : PhotoSource, Dateable { params.row.flags = 0; params.row.master.file_format = PhotoFileFormat.JFIF; params.row.title = null; + params.row.gps_coords = GpsCoords(); params.row.comment = null; params.row.rating = Rating.UNRATED; @@ -1350,10 +1354,10 @@ public abstract class Photo : PhotoSource, Dateable { return null; } - TimeVal modification_time = info.get_modification_time(); + var modification_time = info.get_modification_date_time(); backing.filepath = file.get_path(); - backing.timestamp = modification_time.tv_sec; + backing.timestamp = modification_time; backing.filesize = info.get_size(); backing.file_format = detected.file_format; backing.dim = detected.image_dim; @@ -1462,14 +1466,22 @@ public abstract class Photo : PhotoSource, Dateable { list += "image:orientation"; updated_row.master.original_orientation = backing.original_orientation; } - + + GpsCoords gps_coords = GpsCoords(); + if (detected.metadata != null) { MetadataDateTime? date_time = detected.metadata.get_exposure_date_time(); - if (date_time != null && updated_row.exposure_time != date_time.get_timestamp()) + if (date_time != null && updated_row.exposure_time != null && + !updated_row.exposure_time.equal(date_time.get_timestamp())) list += "metadata:exposure-time"; if (updated_row.title != detected.metadata.get_title()) list += "metadata:name"; + + gps_coords = detected.metadata.get_gps_coords(); + if (updated_row.gps_coords != gps_coords) + list += "metadata:gps"; + if (updated_row.comment != detected.metadata.get_comment()) list += "metadata:comment"; @@ -1490,7 +1502,8 @@ public abstract class Photo : PhotoSource, Dateable { MetadataDateTime? date_time = detected.metadata.get_exposure_date_time(); if (date_time != null) updated_row.exposure_time = date_time.get_timestamp(); - + + updated_row.gps_coords = gps_coords; updated_row.title = detected.metadata.get_title(); updated_row.comment = detected.metadata.get_comment(); updated_row.rating = detected.metadata.get_rating(); @@ -1601,6 +1614,7 @@ public abstract class Photo : PhotoSource, Dateable { if (reimport_state.metadata != null) { set_title(reimport_state.metadata.get_title()); + set_gps_coords(reimport_state.metadata.get_gps_coords()); set_comment(reimport_state.metadata.get_comment()); set_rating(reimport_state.metadata.get_rating()); apply_user_metadata_for_reimport(reimport_state.metadata); @@ -1695,17 +1709,17 @@ public abstract class Photo : PhotoSource, Dateable { // Use this only if the master file's modification time has been changed (i.e. touched) public void set_master_timestamp(FileInfo info) { - TimeVal modification = info.get_modification_time(); + var modification = info.get_modification_date_time(); try { lock (row) { - if (row.master.timestamp == modification.tv_sec) + if (row.master.timestamp.equal(modification)) return; - PhotoTable.get_instance().update_timestamp(row.photo_id, modification.tv_sec); - row.master.timestamp = modification.tv_sec; + PhotoTable.get_instance().update_timestamp(row.photo_id, modification); + row.master.timestamp = modification; } - } catch (DatabaseError err) { + } catch (Error err) { AppWindow.database_error(err); return; @@ -1718,15 +1732,15 @@ public abstract class Photo : PhotoSource, Dateable { } // Use this only if the editable file's modification time has been changed (i.e. touched) - public void update_editable_modification_time(FileInfo info) throws DatabaseError { - TimeVal modification = info.get_modification_time(); + public void update_editable_modification_time(FileInfo info) throws Error { + var modification = info.get_modification_date_time(); bool altered = false; lock (row) { - if (row.editable_id.is_valid() && editable.timestamp != modification.tv_sec) { + if (row.editable_id.is_valid() && !editable.timestamp.equal(modification)) { BackingPhotoTable.get_instance().update_timestamp(row.editable_id, - modification.tv_sec); - editable.timestamp = modification.tv_sec; + modification); + editable.timestamp = modification; altered = true; } } @@ -1739,8 +1753,13 @@ public abstract class Photo : PhotoSource, Dateable { public static void update_many_editable_timestamps(Gee.Map map) throws DatabaseError { DatabaseTable.begin_transaction(); - foreach (Photo photo in map.keys) - photo.update_editable_modification_time(map.get(photo)); + foreach (Photo photo in map.keys) { + try { + photo.update_editable_modification_time(map.get(photo)); + } catch (Error err) { + debug("Failed to update modification time: %s", err.message); + } + } DatabaseTable.commit_transaction(); } @@ -1853,7 +1872,7 @@ public abstract class Photo : PhotoSource, Dateable { } } } - } catch (DatabaseError err) { + } catch (Error err) { AppWindow.database_error(err); } @@ -1906,7 +1925,7 @@ public abstract class Photo : PhotoSource, Dateable { } } } - } catch (DatabaseError err) { + } catch (Error err) { AppWindow.database_error(err); } @@ -1993,7 +2012,7 @@ public abstract class Photo : PhotoSource, Dateable { } } - public override time_t get_timestamp() { + public override DateTime? get_timestamp() { lock (row) { return backing_photo_row.timestamp; } @@ -2169,7 +2188,7 @@ public abstract class Photo : PhotoSource, Dateable { } } - public void set_master_metadata_dirty(bool dirty) throws DatabaseError { + public void set_master_metadata_dirty(bool dirty) throws Error { bool committed = false; lock (row) { if (row.metadata_dirty != dirty) { @@ -2277,7 +2296,7 @@ public abstract class Photo : PhotoSource, Dateable { error("Unable to read file information for %s: %s", to_string(), err.message); } - TimeVal timestamp = info.get_modification_time(); + var timestamp = info.get_modification_date_time(); // interrogate file for photo information PhotoFileInterrogator interrogator = new PhotoFileInterrogator(file); @@ -2297,7 +2316,7 @@ public abstract class Photo : PhotoSource, Dateable { bool success; lock (row) { success = PhotoTable.get_instance().master_exif_updated(get_photo_id(), info.get_size(), - timestamp.tv_sec, detected.md5, detected.exif_md5, detected.thumbnail_md5, row); + timestamp, detected.md5, detected.exif_md5, detected.thumbnail_md5, row); } if (success) @@ -2324,7 +2343,7 @@ public abstract class Photo : PhotoSource, Dateable { } } - public override time_t get_exposure_time() { + public override DateTime? get_exposure_time() { return cached_exposure_time; } @@ -2362,6 +2381,29 @@ public abstract class Photo : PhotoSource, Dateable { if (committed) notify_altered(new Alteration("metadata", "name")); } + + public GpsCoords get_gps_coords() { + lock (row) { + return row.gps_coords; + } + } + + public void set_gps_coords(GpsCoords gps_coords) { + DatabaseError dberr = null; + lock (row) { + try { + PhotoTable.get_instance().set_gps_coords(row.photo_id, gps_coords); + row.gps_coords = gps_coords; + } catch (DatabaseError err) { + dberr = err; + } + } + if (dberr == null) + notify_altered(new Alteration("metadata", "gps")); + else + warning("Unable to write gps coordinates for %s: %s", to_string(), dberr.message); + } + public override bool set_comment(string? comment) { string? new_comment = prep_comment(comment); @@ -2455,7 +2497,7 @@ public abstract class Photo : PhotoSource, Dateable { file_exif_updated(); } - public void set_exposure_time(time_t time) { + public void set_exposure_time(DateTime time) { bool committed; lock (row) { committed = PhotoTable.get_instance().set_exposure_time(row.photo_id, time); @@ -2469,7 +2511,7 @@ public abstract class Photo : PhotoSource, Dateable { notify_altered(new Alteration("metadata", "exposure-time")); } - public void set_exposure_time_persistent(time_t time) throws Error { + public void set_exposure_time_persistent(DateTime time) throws Error { PhotoFileReader source = get_source_reader(); // Try to write to backing file @@ -2742,7 +2784,8 @@ public abstract class Photo : PhotoSource, Dateable { lock (row) { return row.transformations == null && (row.orientation != backing_photo_row.original_orientation - || (date_time != null && row.exposure_time != date_time.get_timestamp())); + || (date_time != null && row.exposure_time != null && + !row.exposure_time.equal(date_time.get_timestamp()))); } } @@ -2763,7 +2806,7 @@ public abstract class Photo : PhotoSource, Dateable { // No, use file timestamp as date/time. lock (row) { // Did we manually set an exposure date? - if(backing_photo_row.timestamp != row.exposure_time) { + if(nullsafe_date_time_comperator(backing_photo_row.timestamp, row.exposure_time) != 0) { // Yes, we need to save this. return true; } @@ -2773,7 +2816,7 @@ public abstract class Photo : PhotoSource, Dateable { lock (row) { return row.transformations != null || row.orientation != backing_photo_row.original_orientation - || (date_time != null && row.exposure_time != date_time.get_timestamp()) + || (date_time != null && !row.exposure_time.equal(date_time.get_timestamp())) || (get_comment() != comment) || (get_title() != title); } @@ -3212,6 +3255,7 @@ public abstract class Photo : PhotoSource, Dateable { double orientation_time = 0.0; total_timer.start(); + #endif // get required fields all at once, to avoid holding the row lock @@ -3618,7 +3662,7 @@ public abstract class Photo : PhotoSource, Dateable { debug("Updating metadata of %s", writer.get_filepath()); - if (get_exposure_time() != 0) + if (get_exposure_time() != null) metadata.set_exposure_date_time(new MetadataDateTime(get_exposure_time())); else metadata.set_exposure_date_time(null); @@ -3714,7 +3758,7 @@ public abstract class Photo : PhotoSource, Dateable { metadata.set_comment(get_comment()); metadata.set_software(Resources.APP_TITLE, Resources.APP_VERSION); - if (get_exposure_time() != 0) + if (get_exposure_time() != null) metadata.set_exposure_date_time(new MetadataDateTime(get_exposure_time())); else metadata.set_exposure_date_time(null); @@ -3970,15 +4014,15 @@ public abstract class Photo : PhotoSource, Dateable { return; } - TimeVal timestamp = info.get_modification_time(); + var timestamp = info.get_modification_date_time(); - BackingPhotoTable.get_instance().update_attributes(editable_id, timestamp.tv_sec, + BackingPhotoTable.get_instance().update_attributes(editable_id, timestamp, info.get_size()); lock (row) { - timestamp_changed = editable.timestamp != timestamp.tv_sec; + timestamp_changed = !editable.timestamp.equal(timestamp); filesize_changed = editable.filesize != info.get_size(); - editable.timestamp = timestamp.tv_sec; + editable.timestamp = timestamp; editable.filesize = info.get_size(); } } else { @@ -4057,7 +4101,7 @@ public abstract class Photo : PhotoSource, Dateable { PhotoTable.get_instance().detach_editable(row); backing_photo_row = row.master; } - } catch (DatabaseError err) { + } catch (Error err) { warning("Unable to remove editable from PhotoTable: %s", err.message); } @@ -4976,7 +5020,12 @@ public class LibraryPhoto : Photo, Flaggable, Monitorable { this.import_keywords = null; thumbnail_scheduler = new OneShotScheduler("LibraryPhoto", generate_thumbnails); - + // import gps coords of photos imported with prior versions of shotwell + if (row.gps_coords.has_gps == -1) { + var gps_import_scheduler = new OneShotScheduler("LibraryPhoto", import_gps_metadata); + gps_import_scheduler.at_priority_idle(Priority.LOW); + } + // if marked in a state where they're held in an orphanage, rehydrate their backlinks if ((row.flags & (FLAG_TRASH | FLAG_OFFLINE)) != 0) rehydrate_backlinks(global, row.backlinks); @@ -5097,7 +5146,12 @@ public class LibraryPhoto : Photo, Flaggable, Monitorable { // fire signal that thumbnails have changed notify_thumbnail_altered(); } - + + private void import_gps_metadata() { + GpsCoords gps_coords = get_metadata().get_gps_coords(); + set_gps_coords(gps_coords); + } + // These keywords are only used during import and should not be relied upon elsewhere. public Gee.Collection? get_import_keywords() { return import_keywords; @@ -5218,7 +5272,7 @@ public class LibraryPhoto : Photo, Flaggable, Monitorable { if (location != null) { face.attach(dupe); FaceLocation.create(face.get_face_id(), dupe.get_photo_id(), - location.get_serialized_geometry()); + location.get_face_data()); } } } @@ -5332,10 +5386,14 @@ public class LibraryPhoto : Photo, Flaggable, Monitorable { PhotoMetadata? metadata = get_metadata(); if (metadata == null) - return tags != null || tags.size > 0 || get_rating() != Rating.UNRATED; + return tags != null || tags.size > 0 || get_rating() != Rating.UNRATED || get_gps_coords().has_gps != 0; if (get_rating() != metadata.get_rating()) return true; + + var old_coords = metadata.get_gps_coords(); + if (!get_gps_coords().equals(ref old_coords)) + return true; Gee.Set? keywords = metadata.get_keywords(); int tags_count = (tags != null) ? tags.size : 0; @@ -5366,6 +5424,7 @@ public class LibraryPhoto : Photo, Flaggable, Monitorable { metadata.set_keywords(null); metadata.set_rating(get_rating()); + metadata.set_gps_coords(get_gps_coords()); } protected override void apply_user_metadata_for_reimport(PhotoMetadata metadata) { diff --git a/src/PhotoPage.vala b/src/PhotoPage.vala index fd513b2..10ebb10 100644 --- a/src/PhotoPage.vala +++ b/src/PhotoPage.vala @@ -759,7 +759,7 @@ public abstract class EditingHostPage : SinglePhotoPage { return false; zoom_about_event_cursor_point(event, ZOOM_INCREMENT_SIZE); - return false; + return true; } protected override bool on_mousewheel_down(Gdk.EventScroll event) { @@ -767,7 +767,7 @@ public abstract class EditingHostPage : SinglePhotoPage { return false; zoom_about_event_cursor_point(event, -ZOOM_INCREMENT_SIZE); - return false; + return true; } protected override void restore_zoom_state() { @@ -1466,8 +1466,9 @@ public abstract class EditingHostPage : SinglePhotoPage { return; } - if (unscaled != null) + if (unscaled != null) { set_pixbuf(unscaled, max_dim); + } // create the PhotoCanvas object for a two-way interface to the tool EditingTools.PhotoCanvas photo_canvas = new EditingHostCanvas(this); @@ -1528,8 +1529,9 @@ public abstract class EditingHostPage : SinglePhotoPage { needs_improvement = true; } - if (replacement != null) + if (replacement != null) { set_pixbuf(replacement, new_max_dim); + } cancel_editing_pixbuf = null; // if this is a rough pixbuf, schedule an improvement @@ -2458,7 +2460,7 @@ public class LibraryPhotoPage : EditingHostPage { base.add_actions (map); map.add_action_entries (entries, this); - (get_action ("ViewRatings") as GLib.SimpleAction).change_state (Config.Facade.get_instance ().get_display_photo_ratings ()); + ((GLib.SimpleAction) get_action ("ViewRatings")).change_state (Config.Facade.get_instance ().get_display_photo_ratings ()); var d = Config.Facade.get_instance().get_default_raw_developer(); var action = new GLib.SimpleAction.stateful("RawDeveloper", GLib.VariantType.STRING, d == RawDeveloper.SHOTWELL ? "Shotwell" : "Camera"); @@ -2888,7 +2890,8 @@ public class LibraryPhotoPage : EditingHostPage { Gee.Collection photos = new Gee.ArrayList(); photos.add(photo); - remove_from_app(photos, _("Remove From Library"), _("Removing Photo From Library")); + remove_from_app(photos, GLib.dpgettext2(null, "Dialog Title", "Remove From Library"), + GLib.dpgettext2(null, "Dialog Title", "Removing Photo From Library")); } private void on_move_to_trash() { @@ -3012,7 +3015,7 @@ public class LibraryPhotoPage : EditingHostPage { if (!has_photo()) return; - ExportDialog export_dialog = new ExportDialog(_("Export Photo")); + ExportDialog export_dialog = new ExportDialog(GLib.dpgettext2(null, "Dialog Title", "Export Photo")); int scale; ScaleConstraint constraint; @@ -3171,7 +3174,7 @@ public class LibraryPhotoPage : EditingHostPage { } protected override void insert_faces_button(Gtk.Toolbar toolbar) { - faces_button = new Gtk.ToggleToolButton.from_stock(Resources.FACES_TOOL); + faces_button = new Gtk.ToggleToolButton(); faces_button.set_icon_name(Resources.ICON_FACES); faces_button.set_label(Resources.FACES_LABEL); faces_button.set_tooltip_text(Resources.FACES_TOOLTIP); diff --git a/src/Printing.vala b/src/Printing.vala index 988a456..bef3476 100644 --- a/src/Printing.vala +++ b/src/Printing.vala @@ -271,29 +271,29 @@ public class CustomPrintTab : Gtk.Box { private const int CENTIMETERS_COMBO_CHOICE = 1; [GtkChild] - private Gtk.RadioButton standard_size_radio; + private unowned Gtk.RadioButton standard_size_radio; [GtkChild] - private Gtk.RadioButton custom_size_radio; + private unowned Gtk.RadioButton custom_size_radio; [GtkChild] - private Gtk.RadioButton image_per_page_radio; + private unowned Gtk.RadioButton image_per_page_radio; [GtkChild] - private Gtk.ComboBoxText image_per_page_combo; + private unowned Gtk.ComboBoxText image_per_page_combo; [GtkChild] - private Gtk.ComboBoxText standard_sizes_combo; + private unowned Gtk.ComboBoxText standard_sizes_combo; [GtkChild] - private Gtk.ComboBoxText units_combo; + private unowned Gtk.ComboBoxText units_combo; [GtkChild] - private Gtk.Entry custom_width_entry; + private unowned Gtk.Entry custom_width_entry; [GtkChild] - private Gtk.Entry custom_height_entry; + private unowned Gtk.Entry custom_height_entry; [GtkChild] - private Gtk.Entry ppi_entry; + private unowned Gtk.Entry ppi_entry; [GtkChild] - private Gtk.CheckButton aspect_ratio_check; + private unowned Gtk.CheckButton aspect_ratio_check; [GtkChild] - private Gtk.CheckButton title_print_check; + private unowned Gtk.CheckButton title_print_check; [GtkChild] - private Gtk.FontButton title_print_font; + private unowned Gtk.FontButton title_print_font; private Measurement local_content_width = Measurement(5.0, MeasurementUnit.INCHES); private Measurement local_content_height = Measurement(5.0, MeasurementUnit.INCHES); @@ -683,7 +683,7 @@ public class CustomPrintTab : Gtk.Box { } private void set_print_titles_font(string fontname) { - title_print_font.set_font_name(fontname); + ((Gtk.FontChooser) title_print_font).set_font(fontname); } @@ -696,7 +696,7 @@ public class CustomPrintTab : Gtk.Box { } private string get_print_titles_font() { - return title_print_font.get_font_name(); + return ((Gtk.FontChooser) title_print_font).get_font(); } public PrintJob get_source_job() { diff --git a/src/ProfileBrowser.vala b/src/ProfileBrowser.vala new file mode 100644 index 0000000..4532a20 --- /dev/null +++ b/src/ProfileBrowser.vala @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: Jens Georg +// SPDX-License-Identifier: LGPL-2.1-or-later + +namespace Shotwell { + class ProfileEditor : Gtk.Dialog { + public string profile_name {get; set;} + public string id{get; default = Uuid.string_random();} + public string library_folder{get; set;} + public string data_folder{get; set;} + + public ProfileEditor() { + Object(use_header_bar : Resources.use_header_bar()); + } + + public override void constructed() { + base.constructed(); + + set_size_request(640, -1); + + add_buttons(_("Create"), Gtk.ResponseType.OK, _("Cancel"), Gtk.ResponseType.CANCEL, null); + var create_button = get_widget_for_response(Gtk.ResponseType.OK); + create_button.get_style_context().add_class("suggested-action"); + create_button.sensitive = false; + set_title(_("Create new Profile")); + + data_folder = Path.build_filename(Environment.get_user_data_dir(), "shotwell", "profiles", id); + library_folder = Environment.get_user_special_dir(UserDirectory.PICTURES); + + var grid = new Gtk.Grid(); + grid.hexpand = true; + grid.vexpand = true; + grid.margin = 6; + grid.set_row_spacing(12); + grid.set_column_spacing(12); + var label = new Gtk.Label(_("Name")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + grid.attach(label, 0, 0, 1, 1); + + var entry = new Gtk.Entry(); + entry.hexpand = true; + entry.bind_property("text", this, "profile-name", GLib.BindingFlags.DEFAULT); + entry.bind_property("text", create_button, "sensitive", GLib.BindingFlags.DEFAULT, (binding, from, ref to) => { + to = from.get_string() != ""; + return true; + }); + grid.attach(entry, 1, 0, 2, 1); + + label = new Gtk.Label(_("Library Folder")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + grid.attach(label, 0, 1, 1, 1); + + entry = new Gtk.Entry(); + entry.hexpand = true; + grid.attach(entry, 1, 1, 1, 1); + bind_property("library-folder", entry, "text", GLib.BindingFlags.SYNC_CREATE | GLib.BindingFlags.BIDIRECTIONAL); + entry.bind_property("text", create_button, "sensitive", GLib.BindingFlags.DEFAULT, (binding, from, ref to) => { + to = from.get_string() != ""; + return true; + }); + + var button = new Gtk.Button.from_icon_name("folder-symbolic", Gtk.IconSize.BUTTON); + button.hexpand = false; + button.vexpand = false; + button.halign = Gtk.Align.FILL; + button.clicked.connect(() => { + var dialog = new Gtk.FileChooserNative(_("Choose Library Folder"), this, Gtk.FileChooserAction.SELECT_FOLDER, _("_OK"), _("_Cancel")); + dialog.set_current_folder(library_folder); + var result = dialog.run(); + dialog.hide(); + if (result == Gtk.ResponseType.ACCEPT) { + library_folder = dialog.get_current_folder_file().get_path(); + } + dialog.destroy(); + }); + grid.attach(button, 2, 1, 1, 1); + + + label = new Gtk.Label(_("Data Folder")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + grid.attach(label, 0, 2, 1, 1); + + entry = new Gtk.Entry(); + entry.set_text(Environment.get_user_special_dir(UserDirectory.PICTURES)); + entry.hexpand = true; + bind_property("data-folder", entry, "text", GLib.BindingFlags.SYNC_CREATE | GLib.BindingFlags.BIDIRECTIONAL); + entry.bind_property("text", create_button, "sensitive", GLib.BindingFlags.DEFAULT, (binding, from, ref to) => { + to = from.get_string() != ""; + return true; + }); + grid.attach(entry, 1, 2, 1, 1); + + button = new Gtk.Button.from_icon_name("folder-symbolic", Gtk.IconSize.BUTTON); + button.hexpand = false; + button.vexpand = false; + button.halign = Gtk.Align.FILL; + button.clicked.connect(() => { + var dialog = new Gtk.FileChooserNative(_("Choose Data Folder"), this, Gtk.FileChooserAction.SELECT_FOLDER, _("_OK"), _("_Cancel")); + dialog.set_current_folder(data_folder); + var result = dialog.run(); + dialog.hide(); + if (result == Gtk.ResponseType.ACCEPT) { + data_folder = dialog.get_current_folder_file().get_path(); + } + dialog.destroy(); + }); + + grid.attach(button, 2, 2, 1, 1); + + get_content_area().add(grid); + + show_all(); + } + } + + private class ProfileRow : Gtk.Box { + public Profile profile{get; construct; } + + public ProfileRow(Profile profile) { + Object(orientation: Gtk.Orientation.VERTICAL, + profile: profile, margin_top: 6, margin_bottom:6, margin_start:6, margin_end:6); + } + + public override void constructed() { + base.constructed(); + var content = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6); + pack_start(content, true); + + var revealer = new Gtk.Revealer(); + revealer.margin_top = 6; + pack_end(revealer, true); + + var label = new Gtk.Label(null); + label.set_markup("%s".printf(profile.name)); + label.halign = Gtk.Align.START; + content.pack_start(label, true, true, 6); + + Gtk.Image image; + if (profile.active) { + image = new Gtk.Image.from_icon_name ("emblem-default-symbolic", Gtk.IconSize.SMALL_TOOLBAR); + image.set_tooltip_text(_("This is the currently active profile")); + + } else { + image = new Gtk.Image(); + } + content.pack_start(image, false, false, 6); + + var button = new Gtk.ToggleButton(); + button.get_style_context().add_class("flat"); + content.pack_start(button, false, false, 6); + button.bind_property("active", revealer, "reveal-child", BindingFlags.DEFAULT); + image = new Gtk.Image.from_icon_name("go-down-symbolic", Gtk.IconSize.SMALL_TOOLBAR); + button.add(image); + + // FIXME: Would love to use the facade here, but this is currently hardwired to use a fixed profile + // and that even is not yet initialized + string settings_path; + if (profile.id == Profile.SYSTEM) { + settings_path = "/org/gnome/shotwell/preferences/files/"; + } else { + settings_path = "/org/gnome/shotwell/profiles/" + profile.id + "/preferences/files/"; + } + + var settings = new Settings.with_path("org.gnome.shotwell.preferences.files", settings_path); + var import_dir = settings.get_string("import-dir"); + if (import_dir == "") { + import_dir = Environment.get_user_special_dir(UserDirectory.PICTURES); + } + + var grid = new Gtk.Grid(); + grid.get_style_context().add_class("content"); + grid.set_row_spacing(12); + grid.set_column_spacing(6); + revealer.add(grid); + label = new Gtk.Label(_("Library Folder")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + label.margin_start = 12; + grid.attach(label, 0, 0, 1, 1); + label = new Gtk.Label(import_dir); + label.halign = Gtk.Align.START; + label.set_ellipsize(Pango.EllipsizeMode.END); + grid.attach(label, 1, 0, 1, 1); + + label = new Gtk.Label(_("Data Folder")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + label.margin_start = 12; + grid.attach(label, 0, 1, 1, 1); + label = new Gtk.Label(profile.data_dir); + label.halign = Gtk.Align.START; + label.hexpand = true; + label.set_ellipsize(Pango.EllipsizeMode.END); + grid.attach(label, 1, 1, 1, 1); + + if (profile.id != Profile.SYSTEM && !profile.active) { + var remove_button = new Gtk.Button.with_label(_("Remove Profile")); + remove_button.get_style_context().add_class("destructive-action"); + remove_button.set_tooltip_text(_("Remove this profile")); + remove_button.hexpand = false; + remove_button.halign = Gtk.Align.END; + grid.attach(remove_button, 1, 2, 1, 1); + + remove_button.clicked.connect(() => { + var flags = Gtk.DialogFlags.DESTROY_WITH_PARENT | Gtk.DialogFlags.MODAL; + if (Resources.use_header_bar() == 1) { + flags |= Gtk.DialogFlags.USE_HEADER_BAR; + } + + var d = new Gtk.MessageDialog((Gtk.Window) this.get_toplevel(), flags, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, null); + var title = _("Remove profile “%s”").printf(profile.name); + var subtitle = _("None of the options will remove any of the images associated with this profile"); + d.set_markup(_("%s\n%s").printf(title, subtitle)); + + d.add_buttons(_("Remove profile and files"), Gtk.ResponseType.OK, _("Remove profile only"), Gtk.ResponseType.ACCEPT, _("Cancel"), Gtk.ResponseType.CANCEL); + d.get_widget_for_response(Gtk.ResponseType.OK).get_style_context().add_class("destructive-action"); + var response = d.run(); + d.destroy(); + if (response == Gtk.ResponseType.OK || response == Gtk.ResponseType.ACCEPT) { + ProfileManager.get_instance().remove(profile.id, response == Gtk.ResponseType.OK); + } + }); + } + } + } + + class ProfileBrowser : Gtk.Box { + public ProfileBrowser() { + Object(orientation: Gtk.Orientation.VERTICAL, vexpand: true, hexpand: true); + } + + public signal void profile_activated(string? profile); + + public override void constructed() { + var scrollable = new Gtk.ScrolledWindow(null, null); + scrollable.hexpand = true; + scrollable.vexpand = true; + + var list_box = new Gtk.ListBox(); + list_box.activate_on_single_click = false; + list_box.row_activated.connect((list_box, row) => { + var index = row.get_index(); + var profile = (Profile) ProfileManager.get_instance().get_item(index); + if (profile.id == Profile.SYSTEM) { + profile_activated(null); + } else { + profile_activated(profile.name); + } + }); + list_box.get_style_context().add_class("rich-list"); + list_box.hexpand = true; + list_box.vexpand = true; + scrollable.add (list_box); + list_box.bind_model(ProfileManager.get_instance(), on_widget_create); + list_box.set_header_func(on_header); + + var button = new Gtk.Button.with_label(_("Create new Profile")); + pack_start(button, false, false, 6); + button.clicked.connect(() => { + var editor = new ProfileEditor(); + editor.set_transient_for((Gtk.Window)get_ancestor(typeof(Gtk.Window))); + var result = editor.run(); + editor.hide(); + if (result == Gtk.ResponseType.OK) { + debug("Request to add new profile: %s %s %s %s", editor.id, editor.profile_name, editor.library_folder, editor.data_folder); + ProfileManager.get_instance().add_profile(editor.id, editor.profile_name, editor.library_folder, editor.data_folder); + } + editor.destroy(); + }); + add(scrollable); + show_all(); + } + + private Gtk.Widget on_widget_create(Object item) { + var row = new Gtk.ListBoxRow(); + row.add(new ProfileRow((Profile) item)); + row.show_all(); + + return row; + } + + private void on_header(Gtk.ListBoxRow row, Gtk.ListBoxRow? before) { + if (before == null || row.get_header() != null) { + return; + } + + var separator = new Gtk.Separator(Gtk.Orientation.HORIZONTAL); + separator.show(); + row.set_header(separator); + } + } +} diff --git a/src/Profiles.vala b/src/Profiles.vala new file mode 100644 index 0000000..ec52800 --- /dev/null +++ b/src/Profiles.vala @@ -0,0 +1,303 @@ +/* Copyright 2019 Jens Georg. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +namespace Shotwell { + class Profile : Object { + public const string SYSTEM = "__shotwell_default_system"; + public Profile(string name, string id, string data_dir, bool active) { + Object(name: name, id: id, data_dir: data_dir, active: active); + } + public string name {get; construct;} + public string id {get; construct;} + public string data_dir {get; construct;} + public bool active {get; construct;} + } + + class ProfileManager : Object, GLib.ListModel { + // ListModel implementations + Type get_item_type() { + return typeof(Profile); + } + + uint get_n_items() { + // All that is in the ini file plus one for the default profile + return profiles.get_groups().length + 1; + } + + GLib.Object? get_item (uint position) { + if (position == 0) { + return new Profile(_("System Profile"), Profile.SYSTEM, + Path.build_path(Path.DIR_SEPARATOR_S, Environment.get_user_data_dir(), "shotwell"), + this.profile == null); + } + + try { + var group = profiles.get_groups()[position - 1]; + var id = profiles.get_value(group, "Id"); + var name = profiles.get_value(group, "Name"); + var active = this.profile == name; + return new Profile(profiles.get_value(group, "Name"), + id, + get_data_dir_for_profile(id, group), + active); + } catch (KeyFileError err) { + if (err is KeyFileError.GROUP_NOT_FOUND) { + assert_not_reached(); + } + + warning("Profile configuration file corrupt: %s", err.message); + } + + return null; + } + + private static ProfileManager instance; + public static ProfileManager get_instance() { + if (instance == null) + instance = new ProfileManager(); + + return instance; + } + + private ProfileManager() { + Object(); + } + + private void write() { + try { + profiles.save_to_file(path); + } catch (Error error) { + critical("Failed to write profiles: %s", error.message); + } + } + + private KeyFile profiles; + private string profile = null; + private string path; + private string group_name; + + public override void constructed() { + base.constructed(); + + profiles = new KeyFile(); + path = Path.build_filename(Environment.get_user_config_dir(), "shotwell"); + DirUtils.create_with_parents(path, 0700); + path = Path.build_filename(path, "profiles.ini"); + + try { + profiles.load_from_file(path, KeyFileFlags.KEEP_COMMENTS); + } catch (Error error) { + debug("Could not read profiles: %s", error.message); + } + } + + public bool has_profile (string profile, out string group_name = null) { + group_name = Base64.encode (profile.data); + return profiles.has_group(group_name); + } + + public void set_profile(string profile) { + message("Using profile %s for this session", profile); + assert(this.profile == null); + + this.profile = profile; + + add_profile(Uuid.string_random(), profile, null, null); + } + + public void add_profile(string id, string name, string? library_folder, string? data_folder) { + if (has_profile(name, out this.group_name)) { + return; + } + + try { + profiles.set_string(group_name, "Name", name); + profiles.set_string(group_name, "Id", id); + if (data_folder != null) { + profiles.set_string(group_name, "DataDir", data_folder); + } + + // Need to set comment after setting keys since it does not create the group + profiles.set_comment(group_name, null, "Profile settings for \"%s\"".printf(name)); + + write(); + } catch (Error err) { + error("Failed to create profile: %s", err.message); + } + + if (library_folder != null) { + errno = 0; + var f = File.new_for_commandline_arg(library_folder); + try { + f.make_directory_with_parents(); + } catch (Error err) { + warning ("Failed to create library folder: %s", err.message); + } + var settings_path = "/org/gnome/shotwell/profiles/" + id + "/preferences/files/"; + + + var settings = new Settings.with_path("org.gnome.shotwell.preferences.files", settings_path); + settings.set_string("import-dir", library_folder); + } + + items_changed(profiles.get_groups().length, 0, 1); + } + + public string derive_data_dir(string? data_dir) { + if (data_dir != null) { + debug ("Using user-provided data dir %s", data_dir); + + try { + profiles.get_string(group_name, "DataDir"); + } catch (Error error) { + if (profile != null && profile != "") { + profiles.set_string(group_name ,"DataDir", data_dir); + debug("Using %s as data dir for profile %s", data_dir, profile); + write(); + } + } + + return data_dir; + } + + return Path.build_filename(Environment.get_user_data_dir(), "shotwell", "profiles", id()); + } + + public string id() { + // We are not running on any profile + if (profile == null || profile == "") + return ""; + + try { + return profiles.get_string(group_name, "Id"); + } catch (Error error) { + assert_not_reached(); + } + } + + private string get_data_dir_for_profile(string id, string group) throws KeyFileError { + if ("DataDir" in profiles.get_keys(group)) { + return profiles.get_value(group, "DataDir"); + } else { + return Path.build_filename(Environment.get_user_data_dir(), "shotwell", "profiles", id); + } + } + + public void print_profiles() { + print("Available profiles:\n"); + print("-------------------\n"); + try { + foreach (var group in profiles.get_groups()) { + print("Profile name: %s\n", profiles.get_value(group, "Name")); + var id = profiles.get_value(group, "Id"); + print("Profile Id: %s\n", id); + print("Data dir: %s\n", get_data_dir_for_profile(id, group)); + print("\n"); + } + } catch (Error error) { + print("Failed to print profiles: %s", error.message); + } + } + + const string SCHEMAS[] = { + "sharing", + "printing", + "plugins.enable-state", + "preferences.ui", + "preferences.slideshow", + "preferences.window", + "preferences.files", + "preferences.editing", + "preferences.export", + }; + + void reset_all_keys(Settings settings) { + SettingsSchema schema; + ((Object)settings).get("settings-schema", out schema, null); + + foreach (var key in schema.list_keys()) { + debug("Resetting key %s", key); + settings.reset(key); + } + + foreach (var c in settings.list_children()) { + debug("Checking children %s", c); + var child = settings.get_child (c); + reset_all_keys (child); + } + } + + private void remove_settings_recursively(string id) { + var source = SettingsSchemaSource.get_default(); + foreach (var schema in SCHEMAS) { + var path = "/org/gnome/shotwell/profiles/%s/%s/".printf(id, schema.replace(".", "/")); + var schema_name = "org.gnome.shotwell.%s".printf(schema); + debug("%s @ %s", schema_name, path); + var schema_definition = source.lookup(schema_name, false); + var settings = new Settings.full (schema_definition, null, path); + settings.delay(); + reset_all_keys (settings); + foreach (var key in schema_definition.list_keys()) { + debug("Resetting key %s", key); + settings.reset(key); + } + settings.apply(); + Settings.sync(); + } + } + + public void remove(string id, bool remove_all) { + debug("Request to remove profile %s, with files? %s", id, remove_all.to_string()); + int index = 1; + string group = null; + + foreach (var g in profiles.get_groups()) { + try { + if (profiles.get_value(g, "Id") == id) { + group = g; + break; + } + index++; + } catch (KeyFileError error) { + assert_not_reached(); + } + } + + if (group != null) { + string? data_dir = null; + + try { + data_dir = get_data_dir_for_profile(id, group); + // Remove profile + string? key = null; + profiles.remove_comment(group, key); + profiles.remove_group(group); + } catch (KeyFileError err) { + // We checked the existence of the group above. + assert_not_reached(); + } + + remove_settings_recursively(id); + + if (remove_all) { + try { + var file = File.new_for_commandline_arg(data_dir); + file.trash(); + } catch (Error error) { + warning("Failed to remove data folder: %s", error.message); + } + } + + Idle.add(() => { + items_changed(index, 1, 0); + + return false; + }); + write(); + } + } + } +} diff --git a/src/Properties.vala b/src/Properties.vala index ad0a041..c0cf2fd 100644 --- a/src/Properties.vala +++ b/src/Properties.vala @@ -4,12 +4,16 @@ * See the COPYING file in this distribution. */ -private abstract class Properties : Gtk.Grid { - uint line_count = 0; +private abstract class Properties : Gtk.Box { + protected Gtk.Grid grid = new Gtk.Grid(); + protected uint line_count = 0; protected Properties() { - row_spacing = 6; - column_spacing = 12; + Object(orientation: Gtk.Orientation.VERTICAL, homogeneous : false); + + grid.row_spacing = 6; + grid.column_spacing = 12; + pack_start(grid, false, false, 0); } protected void add_line(string label_text, string info_text, bool multi_line = false, string? href = null) { @@ -62,18 +66,18 @@ private abstract class Properties : Gtk.Grid { info = (Gtk.Widget) info_label; } - attach(label, 0, (int) line_count, 1, 1); + grid.attach(label, 0, (int) line_count, 1, 1); if (multi_line) { - attach(info, 1, (int) line_count, 1, 3); + grid.attach(info, 1, (int) line_count, 1, 3); } else { - attach(info, 1, (int) line_count, 1, 1); + grid.attach(info, 1, (int) line_count, 1, 1); } line_count++; } - protected string get_prettyprint_time(Time time) { + protected string get_prettyprint_time(DateTime time) { string timestring = time.format(Resources.get_hh_mm_format_string()); if (timestring[0] == '0') @@ -82,7 +86,7 @@ private abstract class Properties : Gtk.Grid { return timestring; } - protected string get_prettyprint_time_with_seconds(Time time) { + protected string get_prettyprint_time_with_seconds(DateTime time) { string timestring = time.format(Resources.get_hh_mm_ss_format_string()); if (timestring[0] == '0') @@ -91,12 +95,12 @@ private abstract class Properties : Gtk.Grid { return timestring; } - protected string get_prettyprint_date(Time date) { + protected string get_prettyprint_date(DateTime date) { string date_string = null; - Time today = Time.local(time_t()); - if (date.day_of_year == today.day_of_year && date.year == today.year) { + var today = new DateTime.now_local(); + if (date.get_day_of_year() == today.get_day_of_year() && date.get_year() == today.get_year()) { date_string = _("Today"); - } else if (date.day_of_year == (today.day_of_year - 1) && date.year == today.year) { + } else if (date.get_day_of_year() == (today.get_day_of_year() - 1) && date.get_year() == today.get_year()) { date_string = _("Yesterday"); } else { date_string = format_local_date(date); @@ -140,9 +144,9 @@ private abstract class Properties : Gtk.Grid { } protected virtual void clear_properties() { - foreach (Gtk.Widget child in get_children()) - remove(child); - + foreach (Gtk.Widget child in grid.get_children()) + grid.remove(child); + line_count = 0; } @@ -159,8 +163,8 @@ private abstract class Properties : Gtk.Grid { private class BasicProperties : Properties { private string title; - private time_t start_time = time_t(); - private time_t end_time = time_t(); + private DateTime? start_time = new DateTime.now_utc(); + private DateTime? end_time = new DateTime.now_utc(); private Dimensions dimensions; private int photo_count; private int event_count; @@ -173,13 +177,14 @@ private class BasicProperties : Properties { private string raw_assoc; public BasicProperties() { + base(); } protected override void clear_properties() { base.clear_properties(); title = ""; - start_time = 0; - end_time = 0; + start_time = null; + end_time = null; dimensions = Dimensions(0,0); photo_count = -1; event_count = -1; @@ -269,20 +274,20 @@ private class BasicProperties : Properties { video_count = 0; foreach (DataView view in iter) { DataSource source = view.get_source(); - - if (source is PhotoSource || source is PhotoImportSource) { - time_t exposure_time = (source is PhotoSource) ? + + if (source is PhotoSource || source is PhotoImportSource) { + var exposure_time = (source is PhotoSource) ? ((PhotoSource) source).get_exposure_time() : ((PhotoImportSource) source).get_exposure_time(); - if (exposure_time != 0) { - if (start_time == 0 || exposure_time < start_time) + if (exposure_time != null) { + if (start_time == null || exposure_time.compare(start_time) < 0) start_time = exposure_time; - if (end_time == 0 || exposure_time > end_time) + if (end_time == null || exposure_time.compare(end_time) > 0) end_time = exposure_time; } - + photo_count++; } else if (source is EventSource) { EventSource event_source = (EventSource) source; @@ -290,14 +295,14 @@ private class BasicProperties : Properties { if (event_count == -1) event_count = 0; - if ((start_time == 0 || event_source.get_start_time() < start_time) && - event_source.get_start_time() != 0 ) { + if ((start_time == null || event_source.get_start_time().compare(start_time) < 0) && + event_source.get_start_time() != null ) { start_time = event_source.get_start_time(); } - if ((end_time == 0 || event_source.get_end_time() > end_time) && - event_source.get_end_time() != 0 ) { + if ((end_time == null || event_source.get_end_time().compare(end_time) > 0) && + event_source.get_end_time() != null ) { end_time = event_source.get_end_time(); - } else if (end_time == 0 || event_source.get_start_time() > end_time) { + } else if (end_time == null || event_source.get_start_time().compare(end_time) > 0) { end_time = event_source.get_start_time(); } @@ -310,15 +315,15 @@ private class BasicProperties : Properties { video_count += event_video_count; event_count++; } else if (source is VideoSource || source is VideoImportSource) { - time_t exposure_time = (source is VideoSource) ? + var exposure_time = (source is VideoSource) ? ((VideoSource) source).get_exposure_time() : ((VideoImportSource) source).get_exposure_time(); - if (exposure_time != 0) { - if (start_time == 0 || exposure_time < start_time) + if (exposure_time != null) { + if (start_time == null || exposure_time.compare(start_time) < 0) start_time = exposure_time; - if (end_time == 0 || exposure_time > end_time) + if (end_time == null || exposure_time.compare(end_time) > 0) end_time = exposure_time; } @@ -330,9 +335,9 @@ private class BasicProperties : Properties { protected override void get_properties(Page current_page) { base.get_properties(current_page); - if (end_time == 0) + if (end_time == null) end_time = start_time; - if (start_time == 0) + if (start_time == null) start_time = end_time; } @@ -373,11 +378,11 @@ private class BasicProperties : Properties { add_line("", video_num_string); } - if (start_time != 0) { - string start_date = get_prettyprint_date(Time.local(start_time)); - string start_time = get_prettyprint_time(Time.local(start_time)); - string end_date = get_prettyprint_date(Time.local(end_time)); - string end_time = get_prettyprint_time(Time.local(end_time)); + if (start_time != null) { + string start_date = get_prettyprint_date(start_time.to_local()); + string start_time = get_prettyprint_time(start_time.to_local()); + string end_date = get_prettyprint_date(end_time.to_local()); + string end_time = get_prettyprint_time(end_time.to_local()); if (start_date == end_date) { // display only one date if start and end are the same @@ -485,7 +490,7 @@ private class ExtendedProperties : Properties { public ExtendedProperties() { base(); - row_spacing = 6; + grid.row_spacing = 6; } // Event stuff @@ -574,9 +579,9 @@ private class ExtendedProperties : Properties { copyright = metadata.get_copyright(); software = metadata.get_software(); exposure_bias = metadata.get_exposure_bias(); - time_t exposure_time_obj = metadata.get_exposure_date_time().get_timestamp(); - exposure_date = get_prettyprint_date(Time.local(exposure_time_obj)); - exposure_time = get_prettyprint_time_with_seconds(Time.local(exposure_time_obj)); + DateTime exposure_time_obj = metadata.get_exposure_date_time().get_timestamp(); + exposure_date = get_prettyprint_date(exposure_time_obj.to_local()); + exposure_time = get_prettyprint_time_with_seconds(exposure_time_obj.to_local()); comment = media.get_comment(); } else if (source is EventSource) { Event event = (Event) source; @@ -634,7 +639,7 @@ private class ExtendedProperties : Properties { add_line(_("GPS longitude:"), (gps_long != -1 && gps_long_ref != "" && gps_long_ref != null) ? "%f °%s".printf(gps_long, gps_long_ref) : NO_VALUE, false, osm_link); - add_line(_("Artist:"), (artist != "" && artist != null) ? artist : NO_VALUE); + add_line(_("Artist:"), (artist != "" && artist != null) ? Markup.escape_text(artist) : NO_VALUE); add_line(_("Copyright:"), (copyright != "" && copyright != null) ? copyright : NO_VALUE); diff --git a/src/Resources.vala b/src/Resources.vala index b65ec52..d03a214 100644 --- a/src/Resources.vala +++ b/src/Resources.vala @@ -24,6 +24,7 @@ namespace Resources { private const string LIBEXECDIR = _LIBEXECDIR; public const string PREFIX = _PREFIX; + public const string PIXBUF_LOADER_PATH = _PIXBUF_LOADER_PATH; public const double TRANSIENT_WINDOW_OPACITY = 0.90; @@ -69,12 +70,20 @@ along with Shotwell; if not, write to the Free Software Foundation, Inc., public const string GO_NEXT = "go-next-symbolic"; public const string GO_PREVIOUS = "go-previous-symbolic"; - public const string ICON_ABOUT_LOGO = "about-celle.jpg"; + public const string ICON_ABOUT_LOGO = "Delmenhorst_Rathaus.jpg"; public const string ICON_GENERIC_PLUGIN = "application-x-addon-symbolic"; public const string ICON_SLIDESHOW_EXTENSION_POINT = "slideshow-extension-point"; public const int ICON_FILTER_REJECTED_OR_BETTER_FIXED_SIZE = 32; public const int ICON_FILTER_UNRATED_OR_BETTER_FIXED_SIZE = 16; public const int ICON_ZOOM_SCALE = 16; + public const string ICON_GPS_MARKER = "gps-marker"; + public const string ICON_GPS_MARKER_HIGHLIGHTED = "gps-marker-highlighted"; + public const string ICON_GPS_MARKER_SELECTED = "gps-marker-selected"; + public const string ICON_GPS_GROUP_MARKER = "gps-markers-many"; + public const string ICON_GPS_GROUP_MARKER_HIGHLIGHTED = "gps-markers-many-highlighted"; + public const string ICON_GPS_GROUP_MARKER_SELECTED = "gps-markers-many-selected"; + public const string ICON_MAP_EDIT_LOCKED = "map-edit-locked"; + public const string ICON_MAP_EDIT_UNLOCKED = "map-edit-unlocked"; public const string ICON_CAMERAS = "camera-photo-symbolic"; public const string ICON_EVENTS = "multiple-events-symbolic"; @@ -403,6 +412,20 @@ along with Shotwell; if not, write to the Free Software Foundation, Inc., return ngettext ("Remove Face “%s” From Photo", "Remove Face “%s” From Photos", count).printf(name); } + + public string set_face_from_photo_menu(string name) { + /* Translators: This means to teach the face to the face recognition system */ + return _("_Train Face “%s” From Photo").printf(name); + } + + public string set_face_from_photo_label(string name) { + /* Translators: This means to teach the face to the face recognition system */ + return _("_Train Face “%s” From Photo").printf(name); + } + + public static string set_face_from_photo_error() { + return "Unable to set face as reference"; + } public string rename_face_menu(string name) { return _("Re_name Face “%s”…").printf(name); @@ -777,7 +800,8 @@ along with Shotwell; if not, write to the Free Software Foundation, Inc., private string END_MULTIMONTH_DATE_FORMAT_STRING = null; public void init () { - get_icon_theme_engine(); + init_icon_theme_engine(); + init_css_provider(); // load application-wide stock icons as IconSets generate_rating_strings(); } @@ -969,12 +993,21 @@ along with Shotwell; if not, write to the Free Software Foundation, Inc., return noninterpretable_badge_pixbuf; } + + private void init_css_provider() { + Gtk.CssProvider provider = new Gtk.CssProvider(); + provider.load_from_resource("/org/gnome/Shotwell/themes/org.gnome.Shotwell.css"); + Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); + } - public Gtk.IconTheme get_icon_theme_engine() { + private void init_icon_theme_engine() { Gtk.IconTheme icon_theme = Gtk.IconTheme.get_default(); icon_theme.add_resource_path("/org/gnome/Shotwell/icons"); - - return icon_theme; + icon_theme.add_resource_path("/org/gnome/Shotwell/icons/hicolor"); + icon_theme.add_resource_path("/org/gnome/Shotwell/Publishing/icons"); + icon_theme.add_resource_path("/org/gnome/Shotwell/Publishing/icons/hicolor"); + icon_theme.add_resource_path("/org/gnome/Shotwell/Transitions/icons"); + icon_theme.add_resource_path("/org/gnome/Shotwell/Transitions/icons/hicolor"); } // This method returns a reference to a cached pixbuf that may be shared throughout the system. @@ -1038,7 +1071,7 @@ along with Shotwell; if not, write to the Free Software Foundation, Inc., return (scale > 0) ? scale_pixbuf(pixbuf, scale, Gdk.InterpType.BILINEAR, false) : pixbuf; } - + // Get the directory where our help files live. Returns a string // describing the help path we want, or, if we're installed system // -wide already, returns null. diff --git a/src/SearchFilter.vala b/src/SearchFilter.vala index ad8b7ec..969591f 100644 --- a/src/SearchFilter.vala +++ b/src/SearchFilter.vala @@ -148,7 +148,7 @@ public abstract class SearchViewFilter : ViewFilter { } public void set_search_filter(string? text) { - search_filter = !is_string_empty(text) ? text.down() : null; + search_filter = !is_string_empty(text) ? String.remove_diacritics(text.down()) : null; search_filter_words = search_filter != null ? search_filter.split(" ") : null; } @@ -775,12 +775,13 @@ public class SearchFilterToolbar : Gtk.Revealer { switch (filter) { case RatingFilter.REJECTED_OR_HIGHER: - icon = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0); + var box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0); var image = new Gtk.Image.from_icon_name ("emblem-photos-symbolic", Gtk.IconSize.SMALL_TOOLBAR); image.margin_end = 2; - (icon as Gtk.Box).pack_start(image); + box.pack_start(image); image = new Gtk.Image.from_icon_name ("window-close-symbolic", Gtk.IconSize.SMALL_TOOLBAR); - (icon as Gtk.Box).pack_start(image); + box.pack_start(image); + icon = box; icon.show_all(); break; @@ -1015,7 +1016,8 @@ public class SearchFilterToolbar : Gtk.Revealer { } private SavedSearch get_search(Gtk.ListBoxRow row) { - DataButton button = (row.get_children().first().data as Gtk.Box).get_children().last().data as DataButton; + var box = (Gtk.Box) row.get_children().first().data; + DataButton button = box.get_children().last().data as DataButton; return button.search; } @@ -1191,7 +1193,7 @@ public class SearchFilterToolbar : Gtk.Revealer { bool has_flagged) { if (has_photos || has_raw) // As a user, I would expect, that a raw photo is still a photo. - // Let's enable the photo button even if there ar only raw photos. + // Let's enable the photo button even if there are only raw photos. toolbtn_photos.set_icon_name("filter-photos-symbolic"); else toolbtn_photos.set_icon_name("filter-photos-disabled-symbolic"); diff --git a/src/SinglePhotoPage.vala b/src/SinglePhotoPage.vala new file mode 100644 index 0000000..754a649 --- /dev/null +++ b/src/SinglePhotoPage.vala @@ -0,0 +1,537 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +public abstract class SinglePhotoPage : Page { + public const Gdk.InterpType FAST_INTERP = Gdk.InterpType.NEAREST; + public const Gdk.InterpType QUALITY_INTERP = Gdk.InterpType.BILINEAR; + public const int KEY_REPEAT_INTERVAL_MSEC = 200; + + public enum UpdateReason { + NEW_PIXBUF, + QUALITY_IMPROVEMENT, + RESIZED_CANVAS + } + + protected Gtk.DrawingArea canvas = new Gtk.DrawingArea(); + protected Gtk.Viewport viewport = new Gtk.Viewport(null, null); + + private bool scale_up_to_viewport; + private TransitionClock transition_clock; + private int transition_duration_msec = 0; + private Cairo.Surface pixmap = null; + private Cairo.Context pixmap_ctx = null; + private Cairo.Context text_ctx = null; + private Dimensions pixmap_dim = Dimensions(); + private Gdk.Pixbuf unscaled = null; + private Dimensions max_dim = Dimensions(); + private Gdk.Pixbuf scaled = null; + private Gdk.Pixbuf old_scaled = null; // previous scaled image + private Gdk.Rectangle scaled_pos = Gdk.Rectangle(); + private ZoomState static_zoom_state; + private bool zoom_high_quality = true; + private ZoomState saved_zoom_state; + private bool has_saved_zoom_state = false; + private uint32 last_nav_key = 0; + + protected SinglePhotoPage(string page_name, bool scale_up_to_viewport) { + base(page_name); + this.wheel_factor = 0.9999; + + this.scale_up_to_viewport = scale_up_to_viewport; + + transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock(); + + // With the current code automatically resizing the image to the viewport, scrollbars + // should never be shown, but this may change if/when zooming is supported + set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); + + set_border_width(0); + set_shadow_type(Gtk.ShadowType.NONE); + + viewport.set_shadow_type(Gtk.ShadowType.NONE); + viewport.set_border_width(0); + viewport.add(canvas); + + add(viewport); + + canvas.add_events(Gdk.EventMask.EXPOSURE_MASK | Gdk.EventMask.STRUCTURE_MASK + | Gdk.EventMask.SUBSTRUCTURE_MASK); + + viewport.size_allocate.connect(on_viewport_resize); + canvas.draw.connect(on_canvas_exposed); + + set_event_source(canvas); + Config.Facade.get_instance().colors_changed.connect(on_colors_changed); + } + + ~SinglePhotoPage() { + Config.Facade.get_instance().colors_changed.disconnect(on_colors_changed); + } + + public bool is_transition_in_progress() { + return transition_clock.is_in_progress(); + } + + public void cancel_transition() { + if (transition_clock.is_in_progress()) + transition_clock.cancel(); + } + + public void set_transition(string effect_id, int duration_msec) { + cancel_transition(); + + transition_clock = TransitionEffectsManager.get_instance().create_transition_clock(effect_id); + if (transition_clock == null) + transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock(); + + transition_duration_msec = duration_msec; + } + + // This method includes a call to pixmap_ctx.paint(). + private void render_zoomed_to_pixmap(ZoomState zoom_state) { + assert(is_zoom_supported()); + + Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content(); + + Gdk.Pixbuf zoomed; + if (get_zoom_buffer() != null) { + zoomed = (zoom_high_quality) ? get_zoom_buffer().get_zoomed_image(zoom_state) : + get_zoom_buffer().get_zoom_preview_image(zoom_state); + } else { + Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(unscaled); + + Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(unscaled, view_rect_proj.x, + view_rect_proj.y, view_rect_proj.width, view_rect_proj.height); + + zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height, + Gdk.InterpType.BILINEAR); + } + + if (zoomed == null) { + return; + } + + int draw_x = (pixmap_dim.width - view_rect.width) / 2; + draw_x = draw_x.clamp(0, int.MAX); + + int draw_y = (pixmap_dim.height - view_rect.height) / 2; + draw_y = draw_y.clamp(0, int.MAX); + paint_pixmap_with_background(pixmap_ctx, zoomed, draw_x, draw_y); + } + + protected void on_interactive_zoom(ZoomState interactive_zoom_state) { + assert(is_zoom_supported()); + + set_source_color_from_string(pixmap_ctx, "#000"); + pixmap_ctx.paint(); + + bool old_quality_setting = zoom_high_quality; + zoom_high_quality = false; + render_zoomed_to_pixmap(interactive_zoom_state); + zoom_high_quality = old_quality_setting; + + canvas.queue_draw(); + } + + protected void on_interactive_pan(ZoomState interactive_zoom_state) { + assert(is_zoom_supported()); + + set_source_color_from_string(pixmap_ctx, "#000"); + pixmap_ctx.paint(); + + bool old_quality_setting = zoom_high_quality; + zoom_high_quality = true; + render_zoomed_to_pixmap(interactive_zoom_state); + zoom_high_quality = old_quality_setting; + + canvas.queue_draw(); + } + + protected virtual bool is_zoom_supported() { + return false; + } + + protected virtual void cancel_zoom() { + if (pixmap != null) { + set_source_color_from_string(pixmap_ctx, "#000"); + pixmap_ctx.paint(); + } + } + + protected virtual void save_zoom_state() { + saved_zoom_state = static_zoom_state; + has_saved_zoom_state = true; + } + + protected virtual void restore_zoom_state() { + if (!has_saved_zoom_state) + return; + + static_zoom_state = saved_zoom_state; + repaint(); + has_saved_zoom_state = false; + } + + protected virtual ZoomBuffer? get_zoom_buffer() { + return null; + } + + protected ZoomState get_saved_zoom_state() { + return saved_zoom_state; + } + + protected void set_zoom_state(ZoomState zoom_state) { + assert(is_zoom_supported()); + + static_zoom_state = zoom_state; + } + + protected ZoomState get_zoom_state() { + assert(is_zoom_supported()); + + return static_zoom_state; + } + + public override void switched_to() { + base.switched_to(); + + if (unscaled != null) + repaint(); + } + + public override void set_container(Gtk.Window container) { + base.set_container(container); + + // scrollbar policy in fullscreen mode needs to be auto/auto, else the pixbuf will shift + // off the screen + if (container is FullscreenWindow) + set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); + } + + // max_dim represents the maximum size of the original pixbuf (i.e. pixbuf may be scaled and + // the caller capable of producing larger ones depending on the viewport size). max_dim + // is used when scale_up_to_viewport is set to true. Pass a Dimensions with no area if + // max_dim should be ignored (i.e. scale_up_to_viewport is false). + public void set_pixbuf(Gdk.Pixbuf unscaled, Dimensions max_dim, Direction? direction = null) { + static_zoom_state = ZoomState(max_dim, pixmap_dim, + static_zoom_state.get_interpolation_factor(), + static_zoom_state.get_viewport_center()); + + cancel_transition(); + + this.unscaled = unscaled; + this.max_dim = max_dim; + this.old_scaled = scaled; + scaled = null; + + // need to make sure this has happened + canvas.realize(); + + repaint(direction); + } + + public void blank_display() { + unscaled = null; + max_dim = Dimensions(); + scaled = null; + pixmap = null; + + // this has to have happened + canvas.realize(); + + // force a redraw + invalidate_all(); + } + + public Cairo.Surface? get_surface() { + return pixmap; + } + + public Dimensions get_surface_dim() { + return pixmap_dim; + } + + public Cairo.Context get_cairo_context() { + return pixmap_ctx; + } + + public void paint_text(Pango.Layout pango_layout, int x, int y) { + text_ctx.move_to(x, y); + Pango.cairo_show_layout(text_ctx, pango_layout); + } + + public Scaling get_canvas_scaling() { + return (get_container() is FullscreenWindow) ? Scaling.for_screen(AppWindow.get_instance(), scale_up_to_viewport) + : Scaling.for_widget(viewport, scale_up_to_viewport); + } + + public Gdk.Pixbuf? get_unscaled_pixbuf() { + return unscaled; + } + + public Gdk.Pixbuf? get_scaled_pixbuf() { + return scaled; + } + + // Returns a rectangle describing the pixbuf in relation to the canvas + public Gdk.Rectangle get_scaled_pixbuf_position() { + return scaled_pos; + } + + public bool is_inside_pixbuf(int x, int y) { + return coord_in_rectangle((int)Math.lround(x * Application.get_scale()), + (int)Math.lround(y * Application.get_scale()), scaled_pos); + } + + public void invalidate(Gdk.Rectangle rect) { + if (canvas.get_window() != null) + canvas.get_window().invalidate_rect(rect, false); + } + + public void invalidate_all() { + if (canvas.get_window() != null) + canvas.get_window().invalidate_rect(null, false); + } + + private void on_viewport_resize() { + // do fast repaints while resizing + internal_repaint(true, null); + } + + protected override void on_resize_finished(Gdk.Rectangle rect) { + base.on_resize_finished(rect); + + // when the resize is completed, do a high-quality repaint + repaint(); + } + + private bool on_canvas_exposed(Cairo.Context exposed_ctx) { + // draw pixmap onto canvas unless it's not been instantiated, in which case draw black + // (so either old image or contents of another page is not left on screen) + if (pixmap != null) { + pixmap.set_device_scale(Application.get_scale(), Application.get_scale()); + exposed_ctx.set_source_surface(pixmap, 0, 0); + } + else + set_source_color_from_string(exposed_ctx, "#000"); + + exposed_ctx.rectangle(0, 0, get_allocated_width(), get_allocated_height()); + exposed_ctx.paint(); + + if (pixmap != null) { + pixmap.set_device_scale(1.0, 1.0); + } + + return true; + } + + protected virtual void new_surface(Cairo.Context ctx, Dimensions ctx_dim) { + } + + protected virtual void updated_pixbuf(Gdk.Pixbuf pixbuf, UpdateReason reason, Dimensions old_dim) { + } + + protected virtual void paint(Cairo.Context ctx, Dimensions ctx_dim) { + if (is_zoom_supported() && (!static_zoom_state.is_default())) { + set_source_color_from_string(ctx, "#000"); + ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height); + ctx.fill(); + + render_zoomed_to_pixmap(static_zoom_state); + } else if (!transition_clock.paint(ctx, ctx_dim.width, ctx_dim.height)) { + // transition is not running, so paint the full image on a black background + set_source_color_from_string(ctx, "#000"); + + ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height); + ctx.fill(); + + //scaled.save("src%010d.png".printf(buffer_counter), "png"); + paint_pixmap_with_background(ctx, scaled, scaled_pos.x, scaled_pos.y); + //pixmap.write_to_png("%010d.png".printf(buffer_counter++)); + } + } + + private void repaint_pixmap() { + if (pixmap_ctx == null) + return; + + paint(pixmap_ctx, pixmap_dim); + invalidate_all(); + } + + public void repaint(Direction? direction = null) { + internal_repaint(false, direction); + } + + private void internal_repaint(bool fast, Direction? direction) { + // if not in view, assume a full repaint needed in future but do nothing more + if (!is_in_view()) { + pixmap = null; + scaled = null; + + return; + } + + // no image or window, no painting + if (unscaled == null || canvas.get_window() == null) + return; + + Gtk.Allocation allocation; + viewport.get_allocation(out allocation); + + int width = allocation.width; + int height = allocation.height; + + if (width <= 0 || height <= 0) + return; + + bool new_pixbuf = (scaled == null); + + // save if reporting an image being rescaled + Dimensions old_scaled_dim = Dimensions.for_rectangle(scaled_pos); + + Gdk.Rectangle old_scaled_pos = scaled_pos; + + // attempt to reuse pixmap + if (pixmap_dim.width != width || pixmap_dim.height != height) + pixmap = null; + + // if necessary, create a pixmap as large as the entire viewport + bool new_pixmap = false; + if (pixmap == null) { + init_pixmap((int)Math.lround(width * Application.get_scale()), (int)Math.lround(height * Application.get_scale())); + new_pixmap = true; + } + + if (new_pixbuf || new_pixmap) { + Dimensions unscaled_dim = Dimensions.for_pixbuf(unscaled); + + // determine scaled size of pixbuf ... if a max dimensions is set and not scaling up, + // respect it + Dimensions scaled_dim = Dimensions(); + if (!scale_up_to_viewport && max_dim.has_area() && max_dim.width < width && max_dim.height < height) + scaled_dim = max_dim; + else + scaled_dim = unscaled_dim.get_scaled_proportional(pixmap_dim); + + // center pixbuf on the canvas + scaled_pos.x = (int)Math.lround(((width * Application.get_scale()) - scaled_dim.width) / 2.0); + scaled_pos.y = (int)Math.lround(((height * Application.get_scale()) - scaled_dim.height) / 2.0); + scaled_pos.width = scaled_dim.width; + scaled_pos.height = scaled_dim.height; + } + + Gdk.InterpType interp = (fast) ? FAST_INTERP : QUALITY_INTERP; + + // rescale if canvas rescaled or better quality is requested + if (scaled == null) { + scaled = resize_pixbuf(unscaled, Dimensions.for_rectangle(scaled_pos), interp); + + UpdateReason reason = UpdateReason.RESIZED_CANVAS; + if (new_pixbuf) + reason = UpdateReason.NEW_PIXBUF; + else if (!new_pixmap && interp == QUALITY_INTERP) + reason = UpdateReason.QUALITY_IMPROVEMENT; + + static_zoom_state = ZoomState(max_dim, pixmap_dim, + static_zoom_state.get_interpolation_factor(), + static_zoom_state.get_viewport_center()); + + updated_pixbuf(scaled, reason, old_scaled_dim); + } + + zoom_high_quality = !fast; + + if (direction != null && !transition_clock.is_in_progress()) { + Spit.Transitions.Visuals visuals = new Spit.Transitions.Visuals(old_scaled, + old_scaled_pos, scaled, scaled_pos, parse_color("#000")); + + transition_clock.start(visuals, direction.to_transition_direction(), transition_duration_msec, + repaint_pixmap); + } + + if (!transition_clock.is_in_progress()) + repaint_pixmap(); + } + + private void init_pixmap(int width, int height) { + assert(unscaled != null); + assert(canvas.get_window() != null); + + // Cairo backing surface (manual double-buffering) + pixmap = new Cairo.ImageSurface(Cairo.Format.ARGB32, width, height); + pixmap_dim = Dimensions(width, height); + + // Cairo context for drawing on the pixmap + pixmap_ctx = new Cairo.Context(pixmap); + + // need a new pixbuf to fit this scale + scaled = null; + + // Cairo context for drawing text on the pixmap + text_ctx = new Cairo.Context(pixmap); + set_source_color_from_string(text_ctx, "#fff"); + + + // no need to resize canvas, viewport does that automatically + + new_surface(pixmap_ctx, pixmap_dim); + } + + protected override bool on_context_keypress() { + return popup_context_menu(get_page_context_menu()); + } + + protected virtual void on_previous_photo() { + } + + protected virtual void on_next_photo() { + } + + public override bool key_press_event(Gdk.EventKey event) { + // if the user holds the arrow keys down, we will receive a steady stream of key press + // events for an operation that isn't designed for a rapid succession of output ... + // we staunch the supply of new photos to under a quarter second (#533) + bool nav_ok = (event.time - last_nav_key) > KEY_REPEAT_INTERVAL_MSEC; + + bool handled = true; + switch (Gdk.keyval_name(event.keyval)) { + case "Left": + case "KP_Left": + case "BackSpace": + if (nav_ok) { + on_previous_photo(); + last_nav_key = event.time; + } + break; + + case "Right": + case "KP_Right": + case "space": + if (nav_ok) { + on_next_photo(); + last_nav_key = event.time; + } + break; + + default: + handled = false; + break; + } + + if (handled) + return true; + + return (base.key_press_event != null) ? base.key_press_event(event) : true; + } + + private void on_colors_changed() { + invalidate_transparent_background(); + repaint(); + } +} + + diff --git a/src/SlideshowPage.vala b/src/SlideshowPage.vala index 9810236..adfec7f 100644 --- a/src/SlideshowPage.vala +++ b/src/SlideshowPage.vala @@ -26,19 +26,19 @@ class SlideshowPage : SinglePhotoPage { [GtkTemplate (ui = "/org/gnome/Shotwell/ui/slideshow_settings.ui")] private class SettingsDialog : Gtk.Dialog { [GtkChild] - Gtk.Adjustment delay_adjustment; + unowned Gtk.Adjustment delay_adjustment; [GtkChild] - Gtk.SpinButton delay_entry; + unowned Gtk.SpinButton delay_entry; [GtkChild] - Gtk.ComboBoxText transition_effect_selector; + unowned Gtk.ComboBoxText transition_effect_selector; [GtkChild] - Gtk.Scale transition_effect_hscale; + unowned Gtk.Scale transition_effect_hscale; [GtkChild] - Gtk.SpinButton transition_effect_entry; + unowned Gtk.SpinButton transition_effect_entry; [GtkChild] - Gtk.Adjustment transition_effect_adjustment; + unowned Gtk.Adjustment transition_effect_adjustment; [GtkChild] - Gtk.CheckButton show_title_button; + unowned Gtk.CheckButton show_title_button; public SettingsDialog() { Object (use_header_bar: Resources.use_header_bar()); diff --git a/src/SortedList.vala b/src/SortedList.vala index 20e6771..420190d 100644 --- a/src/SortedList.vala +++ b/src/SortedList.vala @@ -142,7 +142,7 @@ public class SortedList : Object, Gee.Traversable, Gee.Iterable, Gee.Co return list.get(index); } - private int binary_search(G search, EqualFunc? equal_func) { + private int binary_search(G search, EqualFunc? equal_func) { assert(cmp != null); int min = 0; @@ -181,7 +181,7 @@ public class SortedList : Object, Gee.Traversable, Gee.Iterable, Gee.Co } // See notes at index_of for the difference between this method and it. - public int locate(G search, bool altered, EqualFunc equal_func = direct_equal) { + public int locate(G search, bool altered, EqualFunc equal_func = direct_equal) { if (cmp == null || altered) { int count = list.size; for (int ctr = 0; ctr < count; ctr++) { diff --git a/src/Tag.vala b/src/Tag.vala index 46cbfaa..baf5694 100644 --- a/src/Tag.vala +++ b/src/Tag.vala @@ -552,11 +552,13 @@ public class Tag : DataSource, ContainerSource, Proxyable, Indexable { // path should have already been prepared by prep_tag_name. public static Tag for_path(string name) { Tag? tag = global.fetch_by_name(name, true); - if (tag == null) + if (tag == null) { tag = global.restore_tag_from_holding_tank(name); + } - if (tag != null) + if (tag != null) { return tag; + } // create a new Tag for this name try { diff --git a/src/Thumbnail.vala b/src/Thumbnail.vala index f47fc69..51d2612 100644 --- a/src/Thumbnail.vala +++ b/src/Thumbnail.vala @@ -169,14 +169,15 @@ public class Thumbnail : MediaSourceItem { } public static int64 exposure_time_ascending_comparator(void *a, void *b) { - int64 time_a = (int64) (((Thumbnail *) a)->media.get_exposure_time()); - int64 time_b = (int64) (((Thumbnail *) b)->media.get_exposure_time()); - int64 result = (time_a - time_b); + var time_a = (((Thumbnail *) a)->media.get_exposure_time()); + var time_b = (((Thumbnail *) b)->media.get_exposure_time()); + + var result = nullsafe_date_time_comperator(time_a, time_b); return (result != 0) ? result : filename_ascending_comparator(a, b); } - public static int64 exposure_time_desending_comparator(void *a, void *b) { + public static int64 exposure_time_descending_comparator(void *a, void *b) { int64 result = exposure_time_ascending_comparator(b, a); return (result != 0) ? result : filename_descending_comparator(a, b); diff --git a/src/ThumbnailCache.vala b/src/ThumbnailCache.vala index a0b27fd..5585708 100644 --- a/src/ThumbnailCache.vala +++ b/src/ThumbnailCache.vala @@ -33,7 +33,8 @@ public class ThumbnailCache : Object { // so be careful before changing any of these values (and especially careful before arbitrarily // manipulating a Size enum) public enum Size { - LARGEST = 360, + LARGEST = 512, + LARGE = 512, BIG = 360, MEDIUM = 128, SMALLEST = 128; @@ -47,11 +48,18 @@ public class ThumbnailCache : Object { } public static Size get_best_size(int scale) { - return scale <= MEDIUM.get_scale() ? MEDIUM : BIG; + var real_scale = Application.get_scale() * scale; + + if (real_scale <= MEDIUM.get_scale()) + return MEDIUM; + if (real_scale <= BIG.get_scale()) + return BIG; + + return LARGE; } } - private static Size[] ALL_SIZES = { Size.BIG, Size.MEDIUM }; + private static Size[] ALL_SIZES = { Size.LARGE, Size.BIG, Size.MEDIUM }; public delegate void AsyncFetchCallback(Gdk.Pixbuf? pixbuf, Gdk.Pixbuf? unscaled, Dimensions dim, Gdk.InterpType interp, Error? err); @@ -167,9 +175,11 @@ public class ThumbnailCache : Object { public const ulong MAX_BIG_CACHED_BYTES = 40 * 1024 * 1024; public const ulong MAX_MEDIUM_CACHED_BYTES = 30 * 1024 * 1024; + public const ulong MAX_LARGE_CACHED_BYTES = 15 * 1024 * 1024; private static ThumbnailCache big = null; private static ThumbnailCache medium = null; + private static ThumbnailCache large = null; private static OneShotScheduler debug_scheduler = null; private static int cycle_fetched_thumbnails = 0; @@ -203,6 +213,7 @@ public class ThumbnailCache : Object { big = new ThumbnailCache(Size.BIG, MAX_BIG_CACHED_BYTES); medium = new ThumbnailCache(Size.MEDIUM, MAX_MEDIUM_CACHED_BYTES); + large = new ThumbnailCache(Size.LARGE, MAX_LARGE_CACHED_BYTES); } public static void terminate() { @@ -213,27 +224,33 @@ public class ThumbnailCache : Object { debug("import from source: %s", source.to_string()); big._import_from_source(source, force); medium._import_from_source(source, force); + large._import_from_source(source, force); } public static void import_thumbnails(ThumbnailSource source, Thumbnails thumbnails, bool force = false) throws Error { big._import_thumbnail(source, thumbnails.get(Size.BIG), force); medium._import_thumbnail(source, thumbnails.get(Size.MEDIUM), force); + large._import_thumbnail(source, thumbnails.get(Size.LARGE), force); } public static void duplicate(ThumbnailSource src_source, ThumbnailSource dest_source) { big._duplicate(src_source, dest_source); medium._duplicate(src_source, dest_source); + large._duplicate(src_source, dest_source); } public static void remove(ThumbnailSource source) { big._remove(source); medium._remove(source); + large._remove(source); } private static ThumbnailCache get_best_cache(int scale) { Size size = Size.get_best_size(scale); - if (size == Size.BIG) { + if (size == Size.LARGE) { + return large; + } else if (size == Size.BIG) { return big; } else { assert(size == Size.MEDIUM); @@ -244,6 +261,9 @@ public class ThumbnailCache : Object { private static ThumbnailCache get_cache_for(Size size) { switch (size) { + case Size.LARGE: + return large; + case Size.BIG: return big; diff --git a/src/TimedQueue.vala b/src/TimedQueue.vala index 47faf3c..4ea6a23 100644 --- a/src/TimedQueue.vala +++ b/src/TimedQueue.vala @@ -50,7 +50,7 @@ public class TimedQueue { // finding a workaround, namely using a delegate: // https://bugzilla.gnome.org/show_bug.cgi?id=628639 public TimedQueue(uint hold_msec, DequeuedCallback callback, - owned Gee.EqualDataFunc? equal_func = null, int priority = Priority.DEFAULT) { + owned Gee.EqualDataFunc? equal_func = null, int priority = Priority.DEFAULT) { this.hold_msec = hold_msec; this.callback = callback; diff --git a/src/Upgrades.vala b/src/Upgrades.vala index 85349ae..b06ccad 100644 --- a/src/Upgrades.vala +++ b/src/Upgrades.vala @@ -62,7 +62,7 @@ private interface UpgradeTask : Object{ // Deletes the mimics folder, if it still exists. // Note: for the step count to be consistent, files cannot be written -// to the mimcs folder for the duration of this task. +// to the mimics folder for the duration of this task. private class MimicsRemovalTask : Object, UpgradeTask { // Mimics folder (to be deleted, if present) private File mimic_dir = AppDirs.get_data_dir().get_child("mimics"); diff --git a/src/VideoMetadata.vala b/src/VideoMetadata.vala deleted file mode 100644 index 49ba8ef..0000000 --- a/src/VideoMetadata.vala +++ /dev/null @@ -1,655 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. - */ - -public class VideoMetadata : MediaMetadata { - - private MetadataDateTime timestamp = null; - private string title = null; - private string comment = null; - - public VideoMetadata() { - } - - ~VideoMetadata() { - } - - public override void read_from_file(File file) throws Error { - QuickTimeMetadataLoader quicktime = new QuickTimeMetadataLoader(file); - if (quicktime.is_supported()) { - timestamp = quicktime.get_creation_date_time(); - title = quicktime.get_title(); - // TODO: is there an quicktime.get_comment ?? - comment = null; - return; - } - AVIMetadataLoader avi = new AVIMetadataLoader(file); - if (avi.is_supported()) { - timestamp = avi.get_creation_date_time(); - title = avi.get_title(); - comment = null; - return; - } - - throw new IOError.NOT_SUPPORTED("File %s is not a supported video format", file.get_path()); - } - - public override MetadataDateTime? get_creation_date_time() { - return timestamp; - } - - public override string? get_title() { - return title; - } - - public override string? get_comment() { - return comment; - } - -} - -private class QuickTimeMetadataLoader { - - // Quicktime calendar date/time format is number of seconds since January 1, 1904. - // This converts to UNIX time (66 years + 17 leap days). - public const time_t QUICKTIME_EPOCH_ADJUSTMENT = 2082844800; - - private File file = null; - - public QuickTimeMetadataLoader(File file) { - this.file = file; - } - - public MetadataDateTime? get_creation_date_time() { - return new MetadataDateTime((time_t) get_creation_date_time_for_quicktime()); - } - - public string? get_title() { - // Not supported. - return null; - } - - // Checks if the given file is a QuickTime file. - public bool is_supported() { - QuickTimeAtom test = new QuickTimeAtom(file); - - bool ret = false; - try { - test.open_file(); - test.read_atom(); - - // Look for the header. - if ("ftyp" == test.get_current_atom_name()) { - ret = true; - } else { - // Some versions of QuickTime don't have - // an ftyp section, so we'll just look - // for the mandatory moov section. - while(true) { - if ("moov" == test.get_current_atom_name()) { - ret = true; - break; - } - test.next_atom(); - test.read_atom(); - if (test.is_last_atom()) { - break; - } - } - } - } catch (GLib.Error e) { - debug("Error while testing for QuickTime file for %s: %s", file.get_path(), e.message); - } - - try { - test.close_file(); - } catch (GLib.Error e) { - debug("Error while closing Quicktime file: %s", e.message); - } - return ret; - } - - private ulong get_creation_date_time_for_quicktime() { - QuickTimeAtom test = new QuickTimeAtom(file); - time_t timestamp = 0; - - try { - test.open_file(); - bool done = false; - while(!done) { - // Look for "moov" section. - test.read_atom(); - if (test.is_last_atom()) break; - if ("moov" == test.get_current_atom_name()) { - QuickTimeAtom child = test.get_first_child_atom(); - while (!done) { - // Look for "mvhd" section, or break if none is found. - child.read_atom(); - if (child.is_last_atom() || 0 == child.section_size_remaining()) { - done = true; - break; - } - - if ("mvhd" == child.get_current_atom_name()) { - // Skip 4 bytes (version + flags) - child.read_uint32(); - // Grab the timestamp. - timestamp = child.read_uint32() - QUICKTIME_EPOCH_ADJUSTMENT; - done = true; - break; - } - child.next_atom(); - } - } - test.next_atom(); - } - } catch (GLib.Error e) { - debug("Error while testing for QuickTime file: %s", e.message); - } - - try { - test.close_file(); - } catch (GLib.Error e) { - debug("Error while closing Quicktime file: %s", e.message); - } - - // Some Android phones package videos recorded with their internal cameras in a 3GP - // container that looks suspiciously like a QuickTime container but really isn't -- for - // the timestamps of these Android 3GP videos are relative to the UNIX epoch - // (January 1, 1970) instead of the QuickTime epoch (January 1, 1904). So, if we detect a - // QuickTime movie with a negative timestamp, we can be pretty sure it isn't a valid - // QuickTime movie that was shot before 1904 but is instead a non-compliant 3GP video - // file. If we detect such a video, we correct its time. See this Redmine ticket - // (http://redmine.yorba.org/issues/3314) for more information. - if (timestamp < 0) - timestamp += QUICKTIME_EPOCH_ADJUSTMENT; - - return (ulong) timestamp; - } -} - -private class QuickTimeAtom { - private GLib.File file = null; - private string section_name = ""; - private uint64 section_size = 0; - private uint64 section_offset = 0; - private GLib.DataInputStream input = null; - private QuickTimeAtom? parent = null; - - public QuickTimeAtom(GLib.File file) { - this.file = file; - } - - private QuickTimeAtom.with_input_stream(GLib.DataInputStream input, QuickTimeAtom parent) { - this.input = input; - this.parent = parent; - } - - public void open_file() throws GLib.Error { - close_file(); - input = new GLib.DataInputStream(file.read()); - input.set_byte_order(DataStreamByteOrder.BIG_ENDIAN); - section_size = 0; - section_offset = 0; - section_name = ""; - } - - public void close_file() throws GLib.Error { - if (null != input) { - input.close(); - input = null; - } - } - - private void advance_section_offset(uint64 amount) { - section_offset += amount; - if (null != parent) { - parent.advance_section_offset(amount); - } - } - - public QuickTimeAtom get_first_child_atom() { - // Child will simply have the input stream - // but not the size/offset. This works because - // child atoms follow immediately after a header, - // so no skipping is required to access the child - // from the current position. - return new QuickTimeAtom.with_input_stream(input, this); - } - - public uchar read_byte() throws GLib.Error { - advance_section_offset(1); - return input.read_byte(); - } - - public uint32 read_uint32() throws GLib.Error { - advance_section_offset(4); - return input.read_uint32(); - } - - public uint64 read_uint64() throws GLib.Error { - advance_section_offset(8); - return input.read_uint64(); - } - - public void read_atom() throws GLib.Error { - // Read atom size. - section_size = read_uint32(); - - // Read atom name. - GLib.StringBuilder sb = new GLib.StringBuilder(); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - section_name = sb.str; - - // Check string. - if (section_name.length != 4) { - throw new IOError.NOT_SUPPORTED("QuickTime atom name length is invalid for %s", - file.get_path()); - } - for (int i = 0; i < section_name.length; i++) { - if (!section_name[i].isprint()) { - throw new IOError.NOT_SUPPORTED("Bad QuickTime atom in file %s", file.get_path()); - } - } - - if (1 == section_size) { - // This indicates the section size is a 64-bit - // value, specified below the atom name. - section_size = read_uint64(); - } - } - - private void skip(uint64 skip_amount) throws GLib.Error { - skip_uint64(input, skip_amount); - } - - public uint64 section_size_remaining() { - assert(section_size >= section_offset); - return section_size - section_offset; - } - - public void next_atom() throws GLib.Error { - skip(section_size_remaining()); - section_size = 0; - section_offset = 0; - } - - public string get_current_atom_name() { - return section_name; - } - - public bool is_last_atom() { - return 0 == section_size; - } - -} - -private class AVIMetadataLoader { - - private File file = null; - - // A numerical date string, i.e 2010:01:28 14:54:25 - private const int NUMERICAL_DATE_LENGTH = 19; - - // Marker for timestamp section in a Nikon nctg blob. - private const uint16 NIKON_NCTG_TIMESTAMP_MARKER = 0x13; - - // Size limit to ensure we don't parse forever on a bad file. - private const int MAX_STRD_LENGTH = 100; - - public AVIMetadataLoader(File file) { - this.file = file; - } - - public MetadataDateTime? get_creation_date_time() { - return new MetadataDateTime((time_t) get_creation_date_time_for_avi()); - } - - public string? get_title() { - // Not supported. - return null; - } - - // Checks if the given file is an AVI file. - public bool is_supported() { - AVIChunk chunk = new AVIChunk(file); - bool ret = false; - try { - chunk.open_file(); - chunk.read_chunk(); - // Look for the header and identifier. - if ("RIFF" == chunk.get_current_chunk_name() && - "AVI " == chunk.read_name()) { - ret = true; - } - } catch (GLib.Error e) { - debug("Error while testing for AVI file: %s", e.message); - } - - try { - chunk.close_file(); - } catch (GLib.Error e) { - debug("Error while closing AVI file: %s", e.message); - } - return ret; - } - - // Parses a Nikon nctg tag. Based losely on avi_read_nikon() in FFmpeg. - private string read_nikon_nctg_tag(AVIChunk chunk) throws GLib.Error { - bool found_date = false; - while (chunk.section_size_remaining() > sizeof(uint16)*2) { - uint16 tag = chunk.read_uint16(); - uint16 size = chunk.read_uint16(); - if (NIKON_NCTG_TIMESTAMP_MARKER == tag) { - found_date = true; - break; - } - chunk.skip(size); - } - - if (found_date) { - // Read numerical date string, example: 2010:01:28 14:54:25 - GLib.StringBuilder sb = new GLib.StringBuilder(); - for (int i = 0; i < NUMERICAL_DATE_LENGTH; i++) { - sb.append_c((char) chunk.read_byte()); - } - return sb.str; - } - return ""; - } - - // Parses a Fujifilm strd tag. Based on information from: - // http://www.eden-foundation.org/products/code/film_date_stamp/index.html - private string read_fuji_strd_tag(AVIChunk chunk) throws GLib.Error { - chunk.skip(98); // Ignore 98-byte binary blob. - chunk.skip(8); // Ignore the string "FUJIFILM" - // Read until we find four colons, then two more chars. - int colons = 0; - int post_colons = 0; - GLib.StringBuilder sb = new GLib.StringBuilder(); - // End of date is two chars past the fourth colon. - while (colons <= 4 && post_colons < 2) { - char c = (char) chunk.read_byte(); - if (4 == colons) { - post_colons++; - } - if (':' == c) { - colons++; - } - if (c.isprint()) { - sb.append_c(c); - } - if (sb.len > MAX_STRD_LENGTH) { - return ""; // Give up searching. - } - } - - if (sb.str.length < NUMERICAL_DATE_LENGTH) { - return ""; - } - // Date is now at the end of the string. - return sb.str.substring(sb.str.length - NUMERICAL_DATE_LENGTH); - } - - // Recursively read file until the section is found. - private string? read_section(AVIChunk chunk) throws GLib.Error { - while (true) { - chunk.read_chunk(); - string name = chunk.get_current_chunk_name(); - if ("IDIT" == name) { - return chunk.section_to_string(); - } else if ("nctg" == name) { - return read_nikon_nctg_tag(chunk); - } else if ("strd" == name) { - return read_fuji_strd_tag(chunk); - } - - if ("LIST" == name) { - chunk.read_name(); // Read past list name. - string result = read_section(chunk.get_first_child_chunk()); - if (null != result) { - return result; - } - } - - if (chunk.is_last_chunk()) { - break; - } - chunk.next_chunk(); - } - return null; - } - - // Parses a date from a string. - // Largely based on GStreamer's avi/gstavidemux.c - // and the information here: - // http://www.eden-foundation.org/products/code/film_date_stamp/index.html - private ulong parse_date(string sdate) { - if (sdate.length == 0) { - return 0; - } - - Date date = Date(); - uint seconds = 0; - int year, month, day, hour, min, sec; - char weekday[4]; - char monthstr[4]; - - if (sdate[0].isdigit()) { - // Format is: 2005:08:17 11:42:43 - // Format is: 2010/11/30/ 19:42 - // Format is: 2010/11/30 19:42 - string tmp = sdate.dup(); - tmp.canon("0123456789 ", ' '); // strip everything but numbers and spaces - sec = 0; - int result = tmp.scanf("%d %d %d %d %d %d", out year, out month, out day, out hour, out min, out sec); - if(result < 5) { - return 0; - } - date.set_dmy((DateDay) day, (DateMonth) month, (DateYear) year); - seconds = sec + min * 60 + hour * 3600; - } else { - // Format is: Mon Mar 3 09:44:56 2008 - if(7 != sdate.scanf("%3s %3s %d %d:%d:%d %d", weekday, monthstr, out day, out hour, - out min, out sec, out year)) { - return 0; // Error - } - date.set_dmy((DateDay) day, month_from_string((string) monthstr), (DateYear) year); - seconds = sec + min * 60 + hour * 3600; - } - - Time time = Time(); - date.to_time(out time); - - // watch for overflow (happens on quasi-bogus dates, like Year 200) - time_t tm = time.mktime(); - ulong result = tm + seconds; - if (result < tm) { - debug("Overflow for timestamp in video file %s", file.get_path()); - - return 0; - } - - return result; - } - - private DateMonth month_from_string(string s) { - switch (s.down()) { - case "jan": - return DateMonth.JANUARY; - case "feb": - return DateMonth.FEBRUARY; - case "mar": - return DateMonth.MARCH; - case "apr": - return DateMonth.APRIL; - case "may": - return DateMonth.MAY; - case "jun": - return DateMonth.JUNE; - case "jul": - return DateMonth.JULY; - case "aug": - return DateMonth.AUGUST; - case "sep": - return DateMonth.SEPTEMBER; - case "oct": - return DateMonth.OCTOBER; - case "nov": - return DateMonth.NOVEMBER; - case "dec": - return DateMonth.DECEMBER; - } - return DateMonth.BAD_MONTH; - } - - private ulong get_creation_date_time_for_avi() { - AVIChunk chunk = new AVIChunk(file); - ulong timestamp = 0; - try { - chunk.open_file(); - chunk.nonsection_skip(12); // Advance past 12 byte header. - string sdate = read_section(chunk); - if (null != sdate) { - timestamp = parse_date(sdate.strip()); - } - } catch (GLib.Error e) { - debug("Error while reading AVI file: %s", e.message); - } - - try { - chunk.close_file(); - } catch (GLib.Error e) { - debug("Error while closing AVI file: %s", e.message); - } - return timestamp; - } -} - -private class AVIChunk { - private GLib.File file = null; - private string section_name = ""; - private uint64 section_size = 0; - private uint64 section_offset = 0; - private GLib.DataInputStream input = null; - private AVIChunk? parent = null; - private const int MAX_STRING_TO_SECTION_LENGTH = 1024; - - public AVIChunk(GLib.File file) { - this.file = file; - } - - private AVIChunk.with_input_stream(GLib.DataInputStream input, AVIChunk parent) { - this.input = input; - this.parent = parent; - } - - public void open_file() throws GLib.Error { - close_file(); - input = new GLib.DataInputStream(file.read()); - input.set_byte_order(DataStreamByteOrder.LITTLE_ENDIAN); - section_size = 0; - section_offset = 0; - section_name = ""; - } - - public void close_file() throws GLib.Error { - if (null != input) { - input.close(); - input = null; - } - } - - public void nonsection_skip(uint64 skip_amount) throws GLib.Error { - skip_uint64(input, skip_amount); - } - - public void skip(uint64 skip_amount) throws GLib.Error { - advance_section_offset(skip_amount); - skip_uint64(input, skip_amount); - } - - public AVIChunk get_first_child_chunk() { - return new AVIChunk.with_input_stream(input, this); - } - - private void advance_section_offset(uint64 amount) { - if ((section_offset + amount) > section_size) - amount = section_size - section_offset; - - section_offset += amount; - if (null != parent) { - parent.advance_section_offset(amount); - } - } - - public uchar read_byte() throws GLib.Error { - advance_section_offset(1); - return input.read_byte(); - } - - public uint16 read_uint16() throws GLib.Error { - advance_section_offset(2); - return input.read_uint16(); - } - - public void read_chunk() throws GLib.Error { - // don't use checked reads here because they advance the section offset, which we're trying - // to determine here - GLib.StringBuilder sb = new GLib.StringBuilder(); - sb.append_c((char) input.read_byte()); - sb.append_c((char) input.read_byte()); - sb.append_c((char) input.read_byte()); - sb.append_c((char) input.read_byte()); - section_name = sb.str; - section_size = input.read_uint32(); - section_offset = 0; - } - - public string read_name() throws GLib.Error { - GLib.StringBuilder sb = new GLib.StringBuilder(); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - return sb.str; - } - - public void next_chunk() throws GLib.Error { - skip(section_size_remaining()); - section_size = 0; - section_offset = 0; - } - - public string get_current_chunk_name() { - return section_name; - } - - public bool is_last_chunk() { - return section_size == 0; - } - - public uint64 section_size_remaining() { - assert(section_size >= section_offset); - return section_size - section_offset; - } - - // Reads section contents into a string. - public string section_to_string() throws GLib.Error { - GLib.StringBuilder sb = new GLib.StringBuilder(); - while (section_offset < section_size) { - sb.append_c((char) read_byte()); - if (sb.len > MAX_STRING_TO_SECTION_LENGTH) { - return sb.str; - } - } - return sb.str; - } - -} - diff --git a/src/VideoSupport.vala b/src/VideoSupport.vala deleted file mode 100644 index ec827ea..0000000 --- a/src/VideoSupport.vala +++ /dev/null @@ -1,1193 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU LGPL (version 2.1 or later). - * See the COPYING file in this distribution. - */ - -public errordomain VideoError { - FILE, // there's a problem reading the video container file (doesn't exist, no read - // permission, etc.) - - CONTENTS, // we can read the container file but its contents are indecipherable (no codec, - // malformed data, etc.) -} - -public class VideoImportParams { - // IN: - public File file; - public ImportID import_id = ImportID(); - public string? md5; - public time_t exposure_time_override; - - // IN/OUT: - public Thumbnails? thumbnails; - - // OUT: - public VideoRow row = new VideoRow(); - - public VideoImportParams(File file, ImportID import_id, string? md5, - Thumbnails? thumbnails = null, time_t exposure_time_override = 0) { - this.file = file; - this.import_id = import_id; - this.md5 = md5; - this.thumbnails = thumbnails; - this.exposure_time_override = exposure_time_override; - } -} - -public class VideoReader { - private const double UNKNOWN_CLIP_DURATION = -1.0; - private const uint THUMBNAILER_TIMEOUT = 10000; // In milliseconds. - - // File extensions for video containers that pack only metadata as per the AVCHD spec - private const string[] METADATA_ONLY_FILE_EXTENSIONS = { "bdm", "bdmv", "cpi", "mpl" }; - - private double clip_duration = UNKNOWN_CLIP_DURATION; - private Gdk.Pixbuf preview_frame = null; - private File file = null; - private GLib.Pid thumbnailer_pid = 0; - public DateTime? timestamp { get; private set; default = null; } - - public VideoReader(File file) { - this.file = file; - } - - public static bool is_supported_video_file(File file) { - var mime_type = ContentType.guess(file.get_basename(), new uchar[0], null); - // special case: deep-check content-type of files ending with .ogg - if (mime_type == "audio/ogg" && file.has_uri_scheme("file")) { - try { - var info = file.query_info(FileAttribute.STANDARD_CONTENT_TYPE, - FileQueryInfoFlags.NONE); - var content_type = info.get_content_type(); - if (content_type != null && content_type.has_prefix ("video/")) { - return true; - } - } catch (Error error) { - debug("Failed to query content type: %s", error.message); - } - } - - return is_supported_video_filename(file.get_basename()); - } - - public static bool is_supported_video_filename(string filename) { - string mime_type; - mime_type = ContentType.guess(filename, new uchar[0], null); - // Guessed mp4 from filename has application/ as prefix, so check for mp4 in the end - if (mime_type.has_prefix ("video/") || mime_type.has_suffix("mp4")) { - string? extension = null; - string? name = null; - disassemble_filename(filename, out name, out extension); - - if (extension == null) - return true; - - foreach (string s in METADATA_ONLY_FILE_EXTENSIONS) { - if (utf8_ci_compare(s, extension) == 0) - return false; - } - - return true; - } else { - debug("Skipping %s, unsupported mime type %s", filename, mime_type); - return false; - } - } - - public static ImportResult prepare_for_import(VideoImportParams params) { -#if MEASURE_IMPORT - Timer total_time = new Timer(); -#endif - File file = params.file; - - FileInfo info = null; - try { - info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES, - FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); - } catch (Error err) { - return ImportResult.FILE_ERROR; - } - - if (info.get_file_type() != FileType.REGULAR) - return ImportResult.NOT_A_FILE; - - if (!is_supported_video_file(file)) { - message("Not importing %s: file is marked as a video file but doesn't have a" + - "supported extension", file.get_path()); - - return ImportResult.UNSUPPORTED_FORMAT; - } - - TimeVal timestamp = info.get_modification_time(); - - // make sure params has a valid md5 - assert(params.md5 != null); - - time_t exposure_time = params.exposure_time_override; - string title = ""; - string comment = ""; - - VideoReader reader = new VideoReader(file); - bool is_interpretable = true; - double clip_duration = 0.0; - Gdk.Pixbuf preview_frame = reader.read_preview_frame(); - try { - clip_duration = reader.read_clip_duration(); - } catch (VideoError err) { - if (err is VideoError.FILE) { - return ImportResult.FILE_ERROR; - } else if (err is VideoError.CONTENTS) { - is_interpretable = false; - clip_duration = 0.0; - } else { - error("can't prepare video for import: an unknown kind of video error occurred"); - } - } - - try { - VideoMetadata metadata = reader.read_metadata(); - MetadataDateTime? creation_date_time = metadata.get_creation_date_time(); - - if (creation_date_time != null && creation_date_time.get_timestamp() != 0) - exposure_time = creation_date_time.get_timestamp(); - - string? video_title = metadata.get_title(); - string? video_comment = metadata.get_comment(); - if (video_title != null) - title = video_title; - if (video_comment != null) - comment = video_comment; - } catch (Error err) { - warning("Unable to read video metadata: %s", err.message); - } - - if (exposure_time == 0) { - // Use time reported by Gstreamer, if available. - exposure_time = (time_t) (reader.timestamp != null ? - reader.timestamp.to_unix() : 0); - } - - params.row.video_id = VideoID(); - params.row.filepath = file.get_path(); - params.row.filesize = info.get_size(); - params.row.timestamp = timestamp.tv_sec; - params.row.width = preview_frame.width; - params.row.height = preview_frame.height; - params.row.clip_duration = clip_duration; - params.row.is_interpretable = is_interpretable; - params.row.exposure_time = exposure_time; - params.row.import_id = params.import_id; - params.row.event_id = EventID(); - params.row.md5 = params.md5; - params.row.time_created = 0; - params.row.title = title; - params.row.comment = comment; - params.row.backlinks = ""; - params.row.time_reimported = 0; - params.row.flags = 0; - - if (params.thumbnails != null) { - params.thumbnails = new Thumbnails(); - ThumbnailCache.generate_for_video_frame(params.thumbnails, preview_frame); - } - -#if MEASURE_IMPORT - debug("IMPORT: total time to import video = %lf", total_time.elapsed()); -#endif - return ImportResult.SUCCESS; - } - - private void read_internal() throws VideoError { - if (!does_file_exist()) - throw new VideoError.FILE("video file '%s' does not exist or is inaccessible".printf( - file.get_path())); - - try { - Gst.PbUtils.Discoverer d = new Gst.PbUtils.Discoverer((Gst.ClockTime) (Gst.SECOND * 5)); - Gst.PbUtils.DiscovererInfo info = d.discover_uri(file.get_uri()); - - clip_duration = ((double) info.get_duration()) / 1000000000.0; - - // Get creation time. - // TODO: Note that TAG_DATE can be changed to TAG_DATE_TIME in the future - // (and the corresponding output struct) in order to implement #2836. - Date? video_date = null; - if (info.get_tags() != null && info.get_tags().get_date(Gst.Tags.DATE, out video_date)) { - // possible for get_date() to return true and a null Date - if (video_date != null) { - timestamp = new DateTime.local(video_date.get_year(), video_date.get_month(), - video_date.get_day(), 0, 0, 0); - } - } - } catch (Error e) { - debug("Video read error: %s", e.message); - throw new VideoError.CONTENTS("GStreamer couldn't extract clip information: %s" - .printf(e.message)); - } - } - - // Used by thumbnailer() to kill the external process if need be. - private bool on_thumbnailer_timer() { - debug("Thumbnailer timer called"); - if (thumbnailer_pid != 0) { - debug("Killing thumbnailer process: %d", thumbnailer_pid); -#if VALA_0_40 - Posix.kill(thumbnailer_pid, Posix.Signal.KILL); -#else - Posix.kill(thumbnailer_pid, Posix.SIGKILL); -#endif - } - return false; // Don't call again. - } - - // Performs video thumbnailing. - // Note: not thread-safe if called from the same instance of the class. - private Gdk.Pixbuf? thumbnailer(string video_file) { - // Use Shotwell's thumbnailer, redirect output to stdout. - debug("Launching thumbnailer process: %s", AppDirs.get_thumbnailer_bin().get_path()); - string[] argv = {AppDirs.get_thumbnailer_bin().get_path(), video_file}; - int child_stdout; - try { - GLib.Process.spawn_async_with_pipes(null, argv, null, GLib.SpawnFlags.SEARCH_PATH | - GLib.SpawnFlags.DO_NOT_REAP_CHILD, null, out thumbnailer_pid, null, out child_stdout, - null); - debug("Spawned thumbnailer, child pid: %d", (int) thumbnailer_pid); - } catch (Error e) { - debug("Error spawning process: %s", e.message); - if (thumbnailer_pid != 0) - GLib.Process.close_pid(thumbnailer_pid); - return null; - } - - // Start timer. - Timeout.add(THUMBNAILER_TIMEOUT, on_thumbnailer_timer); - - // Read pixbuf from stream. - Gdk.Pixbuf? buf = null; - try { - GLib.UnixInputStream unix_input = new GLib.UnixInputStream(child_stdout, true); - buf = new Gdk.Pixbuf.from_stream(unix_input, null); - } catch (Error e) { - debug("Error creating pixbuf: %s", e.message); - buf = null; - } - - // Make sure process exited properly. - int child_status = 0; - int ret_waitpid = Posix.waitpid(thumbnailer_pid, out child_status, 0); - if (ret_waitpid < 0) { - debug("waitpid returned error code: %d", ret_waitpid); - buf = null; - } else if (0 != Process.exit_status(child_status)) { - debug("Thumbnailer exited with error code: %d", - Process.exit_status(child_status)); - buf = null; - } - - GLib.Process.close_pid(thumbnailer_pid); - thumbnailer_pid = 0; - return buf; - } - - private bool does_file_exist() { - return FileUtils.test(file.get_path(), FileTest.EXISTS | FileTest.IS_REGULAR); - } - - public Gdk.Pixbuf? read_preview_frame() { - if (preview_frame != null) - return preview_frame; - - if (!does_file_exist()) - return null; - - // Get preview frame from thumbnailer. - preview_frame = thumbnailer(file.get_path()); - if (null == preview_frame) - preview_frame = Resources.get_noninterpretable_badge_pixbuf(); - - return preview_frame; - } - - public double read_clip_duration() throws VideoError { - if (clip_duration == UNKNOWN_CLIP_DURATION) - read_internal(); - - return clip_duration; - } - - public VideoMetadata read_metadata() throws Error { - VideoMetadata metadata = new VideoMetadata(); - metadata.read_from_file(File.new_for_path(file.get_path())); - - return metadata; - } -} - -public class Video : VideoSource, Flaggable, Monitorable, Dateable { - public const string TYPENAME = "video"; - - public const uint64 FLAG_TRASH = 0x0000000000000001; - public const uint64 FLAG_OFFLINE = 0x0000000000000002; - public const uint64 FLAG_FLAGGED = 0x0000000000000004; - - public class InterpretableResults { - internal Video video; - internal bool update_interpretable = false; - internal bool is_interpretable = false; - internal Gdk.Pixbuf? new_thumbnail = null; - - public InterpretableResults(Video video) { - this.video = video; - } - - public void foreground_finish() { - if (update_interpretable) - video.set_is_interpretable(is_interpretable); - - if (new_thumbnail != null) { - try { - ThumbnailCache.replace(video, ThumbnailCache.Size.BIG, new_thumbnail); - ThumbnailCache.replace(video, ThumbnailCache.Size.MEDIUM, new_thumbnail); - - video.notify_thumbnail_altered(); - } catch (Error err) { - message("Unable to update video thumbnails for %s: %s", video.to_string(), - err.message); - } - } - } - } - - private static bool normal_regen_complete; - private static bool offline_regen_complete; - public static VideoSourceCollection global; - - private VideoRow backing_row; - - public Video(VideoRow row) { - this.backing_row = row; - - // normalize user text - this.backing_row.title = prep_title(this.backing_row.title); - - if (((row.flags & FLAG_TRASH) != 0) || ((row.flags & FLAG_OFFLINE) != 0)) - rehydrate_backlinks(global, row.backlinks); - } - - public static void init(ProgressMonitor? monitor = null) { - // Must initialize static variables here. - // TODO: set values at declaration time once the following Vala bug is fixed: - // https://bugzilla.gnome.org/show_bug.cgi?id=655594 - normal_regen_complete = false; - offline_regen_complete = false; - - // initialize GStreamer, but don't pass it our actual command line arguments -- we don't - // want our end users to be able to parameterize the GStreamer configuration - unowned string[] args = null; - Gst.init(ref args); - - var registry = Gst.Registry.@get (); - - /* Update our local registr to not include vaapi stuff. This is basically to - * work-around concurrent access to VAAPI/X11 which it doesn't like, cf - * https://bugzilla.gnome.org/show_bug.cgi?id=762416 - */ - - var features = registry.feature_filter ((f) => { - return f.get_name ().has_prefix ("vaapi"); - }, false); - - foreach (var feature in features) { - debug ("Removing registry feature %s", feature.get_name ()); - registry.remove_feature (feature); - } - - global = new VideoSourceCollection(); - - Gee.ArrayList all = VideoTable.get_instance().get_all(); - Gee.ArrayList