From 5e9f4eea451a77ba3b93db3747841ed2bd969e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Frings-F=C3=BCrst?= Date: Sun, 30 Sep 2018 14:09:20 +0200 Subject: New upstream version 0.30.1 --- src/faces/Face.vala | 681 ++++++++++++++++++++++++++++++ src/faces/FaceLocation.vala | 209 ++++++++++ src/faces/FacePage.vala | 127 ++++++ src/faces/FaceShape.vala | 783 +++++++++++++++++++++++++++++++++++ src/faces/Faces.vala | 37 ++ src/faces/FacesBranch.vala | 146 +++++++ src/faces/FacesTool.vala | 977 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 2960 insertions(+) create mode 100644 src/faces/Face.vala create mode 100644 src/faces/FaceLocation.vala create mode 100644 src/faces/FacePage.vala create mode 100644 src/faces/FaceShape.vala create mode 100644 src/faces/Faces.vala create mode 100644 src/faces/FacesBranch.vala create mode 100644 src/faces/FacesTool.vala (limited to 'src/faces') diff --git a/src/faces/Face.vala b/src/faces/Face.vala new file mode 100644 index 0000000..9be33c9 --- /dev/null +++ b/src/faces/Face.vala @@ -0,0 +1,681 @@ +/* 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. + */ + +#if ENABLE_FACES +public class FaceSourceCollection : ContainerSourceCollection { + private Gee.HashMap name_map = new Gee.HashMap + ((Gee.HashDataFunc)Face.hash_name_string, (Gee.EqualDataFunc)Face.equal_name_strings); + private Gee.HashMap> source_map = + new Gee.HashMap>(); + + public FaceSourceCollection() { + base (Face.TYPENAME, "FaceSourceCollection", get_face_key); + + attach_collection(LibraryPhoto.global); + } + + public override bool holds_type_of_source(DataSource source) { + return source is Face; + } + + private static int64 get_face_key(DataSource source) { + return ((Face) source).get_instance_id(); + } + + protected override Gee.Collection? get_containers_holding_source(DataSource source) { + return fetch_for_source((MediaSource) source); + } + + public override ContainerSource? convert_backlink_to_container(SourceBacklink backlink) { + FaceID face_id = FaceID(backlink.instance_id); + + Face? face = fetch(face_id); + if (face != null) + return face; + + foreach (ContainerSource container in get_holding_tank()) { + face = (Face) container; + if (face.get_face_id().id == face_id.id) + return face; + } + + return null; + } + + public Face? fetch(FaceID face_id) { + return (Face) fetch_by_key(face_id.id); + } + + public bool exists(string name) { + return name_map.has_key(name); + } + + public Gee.Collection get_all_names() { + return name_map.keys; + } + + // Returns a list of all Faces associated with the media source in no particular order. + // + // NOTE: As a search optimization, this returns the list that is maintained by Faces.global. + // Do NOT modify this list. + public Gee.List? fetch_for_source(MediaSource source) { + return source_map.get(source); + } + + // Returns null if not Face with name exists. + public Face? fetch_by_name(string name) { + return name_map.get(name); + } + + public Face? restore_face_from_holding_tank(string name) { + Face? found = null; + foreach (ContainerSource container in get_holding_tank()) { + Face face = (Face) container; + if (face.get_name() == name) { + found = face; + + break; + } + } + + if (found != null) { + bool relinked = relink_from_holding_tank(found); + assert(relinked); + } + + return found; + } + + protected override void notify_items_added(Gee.Iterable added) { + foreach (DataObject object in added) { + Face face = (Face) object; + + assert(!name_map.has_key(face.get_name())); + name_map.set(face.get_name(), face); + } + + base.notify_items_added(added); + } + + protected override void notify_items_removed(Gee.Iterable removed) { + foreach (DataObject object in removed) { + Face face = (Face) object; + + bool unset = name_map.unset(face.get_name()); + assert(unset); + } + + base.notify_items_removed(removed); + } + + protected override void notify_items_altered(Gee.Map map) { + foreach (DataObject object in map.keys) { + Face face = (Face) object; + + string? old_name = null; + + // look for this face being renamed + Gee.MapIterator iter = name_map.map_iterator(); + while (iter.next()) { + if (!iter.get_value().equals(face)) + continue; + + old_name = iter.get_key(); + + break; + } + + assert(old_name != null); + + if (face.get_name() != old_name) { + name_map.unset(old_name); + name_map.set(face.get_name(), face); + } + } + + base.notify_items_altered(map); + } + + protected override void notify_container_contents_added(ContainerSource container, + Gee.Collection added, bool relinking) { + Face face = (Face) container; + Gee.Collection sources = (Gee.Collection) added; + + foreach (MediaSource source in sources) { + Gee.List? faces = source_map.get(source); + if (faces == null) { + faces = new Gee.ArrayList(); + source_map.set(source, faces); + } + + bool is_added = faces.add(face); + assert(is_added); + } + + base.notify_container_contents_added(container, added, relinking); + } + + protected override void notify_container_contents_removed(ContainerSource container, + Gee.Collection removed, bool unlinking) { + Face face = (Face) container; + Gee.Collection sources = (Gee.Collection) removed; + + foreach (MediaSource source in sources) { + Gee.List? faces = source_map.get(source); + assert(faces != null); + + bool is_removed = faces.remove(face); + assert(is_removed); + + if (faces.size == 0) + source_map.unset(source); + } + + base.notify_container_contents_removed(container, removed, unlinking); + } +} + +public class Face : DataSource, ContainerSource, Proxyable, Indexable { + public const string TYPENAME = "face"; + + private class FaceSnapshot : SourceSnapshot { + private FaceRow row; + private Gee.HashSet sources = new Gee.HashSet(); + + public FaceSnapshot(Face face) { + // stash current state of Face + row = face.row; + + // stash photos attached to this face ... if any are destroyed, the face + // cannot be reconstituted + foreach (MediaSource source in face.get_sources()) + sources.add(source); + + LibraryPhoto.global.item_destroyed.connect(on_source_destroyed); + } + + ~FaceSnapshot() { + LibraryPhoto.global.item_destroyed.disconnect(on_source_destroyed); + } + + public FaceRow get_row() { + return row; + } + + public override void notify_broken() { + row = new FaceRow(); + sources.clear(); + + base.notify_broken(); + } + + private void on_source_destroyed(DataSource source) { + if (sources.contains((MediaSource) source)) + notify_broken(); + } + } + + private class FaceProxy : SourceProxy { + public FaceProxy(Face face) { + base (face); + } + + public override DataSource reconstitute(int64 object_id, SourceSnapshot snapshot) { + return Face.reconstitute(object_id, ((FaceSnapshot) snapshot).get_row()); + } + } + + public static FaceSourceCollection global = null; + + private FaceRow row; + private ViewCollection media_views; + private string? name_collation_key = null; + private bool unlinking = false; + private bool relinking = false; + private string? indexable_keywords = null; + + private Face(FaceRow row, int64 object_id = INVALID_OBJECT_ID) { + base (object_id); + + this.row = row; + + // normalize user text + this.row.name = prep_face_name(this.row.name); + + Gee.Set photo_id_list = FaceLocation.get_photo_ids_by_face(this); + Gee.ArrayList photo_list = new Gee.ArrayList(); + Gee.ArrayList thumbnail_views = new Gee.ArrayList(); + if (photo_id_list != null) { + foreach (PhotoID photo_id in photo_id_list) { + MediaSource? current_source = + LibraryPhoto.global.fetch_by_source_id(PhotoID.upgrade_photo_id_to_source_id(photo_id)); + if (current_source == null) + continue; + + photo_list.add((Photo) current_source); + thumbnail_views.add(new ThumbnailView(current_source)); + } + } + + // add to internal ViewCollection, which maintains media sources associated with this face + media_views = new ViewCollection("ViewCollection for face %s".printf(row.face_id.id.to_string())); + media_views.add_many(thumbnail_views); + + // need to do this manually here because only want to monitor photo_contents_altered + // after add_many() here; but need to keep the FaceSourceCollection apprised + if (photo_list.size > 0) { + global.notify_container_contents_added(this, photo_list, false); + global.notify_container_contents_altered(this, photo_list, false, null, false); + } + + // monitor ViewCollection to (a) keep the in-memory list of source ids up-to-date, and + // (b) update the database whenever there's a change; + media_views.contents_altered.connect(on_media_views_contents_altered); + + // monitor the global collections to trap when photos are destroyed, then + // automatically remove from the face + LibraryPhoto.global.items_destroyed.connect(on_sources_destroyed); + + update_indexable_keywords(); + } + + ~Face() { + media_views.contents_altered.disconnect(on_media_views_contents_altered); + LibraryPhoto.global.items_destroyed.disconnect(on_sources_destroyed); + } + + public static void init(ProgressMonitor? monitor) { + global = new FaceSourceCollection(); + + // scoop up all the rows at once + Gee.List rows = null; + try { + rows = FaceTable.get_instance().get_all_rows(); + } catch (DatabaseError err) { + AppWindow.database_error(err); + } + + // turn them into Face objects + Gee.ArrayList faces = new Gee.ArrayList(); + Gee.ArrayList unlinked = new Gee.ArrayList(); + int count = rows.size; + for (int ctr = 0; ctr < count; ctr++) { + FaceRow row = rows.get(ctr); + + // make sure the face name is valid + string? name = prep_face_name(row.name); + if (name == null) { + // TODO: More graceful handling of this situation would be to rename the face or + // alert the user. + warning("Invalid face name \"%s\": removing from database", row.name); + try { + FaceTable.get_instance().remove(row.face_id); + } catch (DatabaseError err) { + warning("Unable to delete face \"%s\": %s", row.name, err.message); + } + + continue; + } + + row.name = name; + + Face face = new Face(row); + if (monitor != null) + monitor(ctr, count); + + if (face.get_sources_count() != 0) { + faces.add(face); + + continue; + } + + if (face.has_links()) { + face.rehydrate_backlinks(global, null); + unlinked.add(face); + + continue; + } + + warning("Empty face %s found with no backlinks, destroying", face.to_string()); + face.destroy_orphan(true); + } + + // add them all at once to the SourceCollection + global.add_many(faces); + global.init_add_many_unlinked(unlinked); + } + + public static void terminate() { + } + + public static int compare_names(void *a, void *b) { + Face *aface = (Face *) a; + Face *bface = (Face *) b; + + return String.precollated_compare(aface->get_name(), aface->get_name_collation_key(), + bface->get_name(), bface->get_name_collation_key()); + } + + public static uint hash_name_string(void *a) { + return String.collated_hash(a); + } + + public static bool equal_name_strings(void *a, void *b) { + return String.collated_equals(a, b); + } + + // Returns a Face for the name, creating a new empty one if it does not already exist. + // name should have already been prepared by prep_face_name. + public static Face for_name(string name) { + Face? face = global.fetch_by_name(name); + if (face == null) + face = global.restore_face_from_holding_tank(name); + + if (face != null) + return face; + + // create a new Face for this name + try { + face = new Face(FaceTable.get_instance().add(name)); + } catch (DatabaseError err) { + AppWindow.database_error(err); + } + + global.add(face); + + return face; + } + + // Utility function to cleanup a face name that comes from user input and prepare it for use + // in the system and storage in the database. Returns null if the name is unacceptable. + public static string? prep_face_name(string name) { + return prepare_input_text(name, PrepareInputTextOptions.DEFAULT, DEFAULT_USER_TEXT_INPUT_LENGTH); + } + + public override string get_typename() { + return TYPENAME; + } + + public override int64 get_instance_id() { + return get_face_id().id; + } + + public override string get_name() { + return row.name; + } + + public string get_name_collation_key() { + if (name_collation_key == null) + name_collation_key = row.name.collate_key(); + + return name_collation_key; + } + + public override string to_string() { + return "Face %s (%d sources)".printf(row.name, media_views.get_count()); + } + + public override bool equals(DataSource? source) { + // Validate uniqueness of primary key + Face? face = source as Face; + if (face != null) { + if (face != this) { + assert(face.row.face_id.id != row.face_id.id); + } + } + + return base.equals(source); + } + + public FaceID get_face_id() { + return row.face_id; + } + + public override SourceSnapshot? save_snapshot() { + return new FaceSnapshot(this); + } + + public SourceProxy get_proxy() { + return new FaceProxy(this); + } + + private static Face reconstitute(int64 object_id, FaceRow row) { + // fill in the row with the new FaceID for this reconstituted face + try { + row.face_id = FaceTable.get_instance().create_from_row(row); + } catch (DatabaseError err) { + AppWindow.database_error(err); + } + + Face face = new Face(row, object_id); + global.add(face); + + debug("Reconstituted %s", face.to_string()); + + return face; + } + + public bool has_links() { + return LibraryPhoto.global.has_backlink(get_backlink()); + } + + public SourceBacklink get_backlink() { + return new SourceBacklink.from_source(this); + } + + public void break_link(DataSource source) { + unlinking = true; + + detach((LibraryPhoto) source); + + unlinking = false; + } + + public void break_link_many(Gee.Collection sources) { + unlinking = true; + + detach_many((Gee.Collection) sources); + + unlinking = false; + } + + public void establish_link(DataSource source) { + relinking = true; + + attach((LibraryPhoto) source); + + relinking = false; + } + + public void establish_link_many(Gee.Collection sources) { + relinking = true; + + attach_many((Gee.Collection) sources); + + relinking = false; + } + + private void update_indexable_keywords() { + indexable_keywords = prepare_indexable_string(get_name()); + } + + public unowned string? get_indexable_keywords() { + return indexable_keywords; + } + + public void attach(MediaSource source) { + if (!media_views.has_view_for_source(source)) + media_views.add(new ThumbnailView(source)); + } + + public void attach_many(Gee.Collection sources) { + Gee.ArrayList view_list = new Gee.ArrayList(); + foreach (MediaSource source in sources) { + if (!media_views.has_view_for_source(source)) + view_list.add(new ThumbnailView(source)); + } + + if (view_list.size > 0) + media_views.add_many(view_list); + } + + public bool detach(MediaSource source) { + DataView? view = media_views.get_view_for_source(source); + if (view == null) + return false; + + media_views.remove_marked(media_views.mark(view)); + + return true; + } + + public int detach_many(Gee.Collection sources) { + int count = 0; + + Marker marker = media_views.start_marking(); + foreach (MediaSource source in sources) { + DataView? view = media_views.get_view_for_source(source); + if (view == null) + continue; + + marker.mark(view); + count++; + } + + media_views.remove_marked(marker); + + return count; + } + + // Returns false if the name already exists or a bad name. + public bool rename(string name) { + string? new_name = prep_face_name(name); + if (new_name == null) + return false; + + if (Face.global.exists(new_name)) + return false; + + try { + FaceTable.get_instance().rename(row.face_id, new_name); + } catch (DatabaseError err) { + AppWindow.database_error(err); + return false; + } + + row.name = new_name; + name_collation_key = null; + + update_indexable_keywords(); + + notify_altered(new Alteration.from_list("metadata:name, indexable:keywords")); + + return true; + } + + public bool contains(MediaSource source) { + return media_views.has_view_for_source(source); + } + + public int get_sources_count() { + return media_views.get_count(); + } + + public Gee.Collection get_sources() { + return (Gee.Collection) media_views.get_sources(); + } + + public void mirror_sources(ViewCollection view, CreateView mirroring_ctor) { + view.mirror(media_views, mirroring_ctor, null); + } + + private void on_media_views_contents_altered(Gee.Iterable? added, + Gee.Iterable? removed) { + Gee.Set? photo_id_list = FaceLocation.get_photo_ids_by_face(this); + + Gee.Collection added_photos = null; + if (added != null) { + added_photos = new Gee.ArrayList(); + foreach (DataView view in added) { + Photo photo = (Photo) view.get_source(); + + if (photo_id_list != null) + assert(!photo_id_list.contains(photo.get_photo_id())); + + bool is_added = added_photos.add(photo); + assert(is_added); + } + } + + Gee.Collection removed_photos = null; + if (removed != null) { + assert(photo_id_list != null); + + removed_photos = new Gee.ArrayList(); + foreach (DataView view in removed) { + Photo photo = (Photo) view.get_source(); + + assert(photo_id_list.contains(photo.get_photo_id())); + + bool is_added = removed_photos.add(photo); + assert(is_added); + } + } + + if (removed_photos != null) + foreach (Photo photo in removed_photos) + FaceLocation.destroy(get_face_id(), photo.get_photo_id()); + + // notify of changes to this face + if (added_photos != null) + global.notify_container_contents_added(this, added_photos, relinking); + + if (removed_photos != null) + global.notify_container_contents_removed(this, removed_photos, unlinking); + + if (added_photos != null || removed_photos != null) { + global.notify_container_contents_altered(this, added_photos, relinking, removed_photos, + unlinking); + } + + // if no more sources, face evaporates; do not touch "this" afterwards + if (media_views.get_count() == 0) + global.evaporate(this); + } + + private void on_sources_destroyed(Gee.Collection sources) { + detach_many((Gee.Collection) sources); + } + + public override void destroy() { + // detach all remaining sources from the face, so observers are informed ... need to detach + // the contents_altered handler because it will destroy this object when sources is empty, + // which is bad reentrancy mojo (but hook it back up for the dtor's sake) + if (media_views.get_count() > 0) { + media_views.contents_altered.disconnect(on_media_views_contents_altered); + + Gee.ArrayList removed = new Gee.ArrayList(); + removed.add_all((Gee.Collection) media_views.get_sources()); + + media_views.clear(); + + global.notify_container_contents_removed(this, removed, false); + global.notify_container_contents_altered(this, null, false, removed, false); + + media_views.contents_altered.connect(on_media_views_contents_altered); + } + + try { + FaceTable.get_instance().remove(row.face_id); + } catch (DatabaseError err) { + AppWindow.database_error(err); + } + + base.destroy(); + } +} + +#endif diff --git a/src/faces/FaceLocation.vala b/src/faces/FaceLocation.vala new file mode 100644 index 0000000..cc5c4cf --- /dev/null +++ b/src/faces/FaceLocation.vala @@ -0,0 +1,209 @@ +/* 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. + */ + +#if ENABLE_FACES + +public class FaceLocation : Object { + + private static Gee.Map> face_photos_map; + private static Gee.Map> photo_faces_map; + + private FaceLocationID face_location_id; + private FaceID face_id; + private PhotoID photo_id; + private string geometry; + + private FaceLocation(FaceLocationID face_location_id, FaceID face_id, PhotoID photo_id, + string geometry) { + this.face_location_id = face_location_id; + this.face_id = face_id; + this.photo_id = photo_id; + this.geometry = geometry; + } + + public static FaceLocation create(FaceID face_id, PhotoID photo_id, string geometry) { + FaceLocation face_location = null; + + // Test if that FaceLocation already exists (that face in that photo) ... + Gee.Map photos_map = face_photos_map.get(face_id); + Gee.Map faces_map = photo_faces_map.get(photo_id); + + if (photos_map != null && faces_map != null && faces_map.has_key(face_id)) { + + face_location = faces_map.get(face_id); + + if (face_location.get_serialized_geometry() != geometry) { + face_location.set_serialized_geometry(geometry); + + try { + FaceLocationTable.get_instance().update_face_location_serialized_geometry( + face_location); + } catch (DatabaseError err) { + AppWindow.database_error(err); + } + } + + return face_location; + } + + // ... or create a new FaceLocation. + try { + face_location = + FaceLocation.add_from_row( + FaceLocationTable.get_instance().add(face_id, photo_id, geometry)); + } catch (DatabaseError err) { + AppWindow.database_error(err); + } + + return face_location; + } + + public static void destroy(FaceID face_id, PhotoID photo_id) { + Gee.Map photos_map = face_photos_map.get(face_id); + Gee.Map faces_map = photo_faces_map.get(photo_id); + + assert(photos_map != null); + assert(faces_map != null); + + faces_map.unset(face_id); + if (faces_map.size == 0) + photo_faces_map.unset(photo_id); + + photos_map.unset(photo_id); + if (photos_map.size == 0) + face_photos_map.unset(face_id); + + try { + FaceLocationTable.get_instance().remove_face_from_source(face_id, photo_id); + } catch (DatabaseError err) { + AppWindow.database_error(err); + } + } + + public static FaceLocation add_from_row(FaceLocationRow row) { + + FaceLocation face_location = + new FaceLocation(row.face_location_id, row.face_id, row.photo_id, row.geometry); + + Gee.Map photos_map = face_photos_map.get(row.face_id); + if (photos_map == null) {photos_map = new Gee.HashMap + ((Gee.HashDataFunc)FaceLocation.photo_id_hash, (Gee.EqualDataFunc)FaceLocation.photo_ids_equal); + face_photos_map.set(row.face_id, photos_map); + } + photos_map.set(row.photo_id, face_location); + + Gee.Map faces_map = photo_faces_map.get(row.photo_id); + if (faces_map == null) {faces_map = new Gee.HashMap + ((Gee.HashDataFunc)FaceLocation.face_id_hash, (Gee.EqualDataFunc)FaceLocation.face_ids_equal); + + photo_faces_map.set(row.photo_id, faces_map); + } + faces_map.set(row.face_id, face_location); + + return face_location; + } + + public static Gee.Map? get_locations_by_photo(Photo photo) { + return photo_faces_map.get(photo.get_photo_id()); + } + + public static Gee.Map? get_locations_by_face(Face face) { + return face_photos_map.get(face.get_face_id()); + } + + public static Gee.Set? get_photo_ids_by_face(Face face) { + Gee.Map? photos_map = face_photos_map.get(face.get_face_id()); + if (photos_map == null) + return null; + + return photos_map.keys; + } + + public static FaceLocation? get_face_location(FaceID face_id, PhotoID photo_id) { + Gee.Map? faces_map = photo_faces_map.get(photo_id); + if (faces_map == null) + return null; + + return faces_map.get(face_id); + } + + public static bool photo_ids_equal(void *a, void *b) { + PhotoID *aid = (PhotoID *) a; + PhotoID *bid = (PhotoID *) b; + + return aid->id == bid->id; + } + + public static bool face_ids_equal(void *a, void *b) { + FaceID *aid = (FaceID *) a; + FaceID *bid = (FaceID *) b; + + return aid->id == bid->id; + } + + public static uint photo_id_hash(void *p) { + // Rotating XOR hash + uint8 u8 = (uint8) ((PhotoID *) p)->id; + uint hash = 0; + for (int ctr = 0; ctr < (sizeof(int64) / sizeof(uint8)); ctr++) { + hash = (hash << 4) ^ (hash >> 28) ^ (u8++); + } + + return hash; + } + + public static uint face_id_hash(void *p) { + // Rotating XOR hash + uint8 u8 = (uint8) ((FaceID *) p)->id; + uint hash = 0; + for (int ctr = 0; ctr < (sizeof(int64) / sizeof(uint8)); ctr++) { + hash = (hash << 4) ^ (hash >> 28) ^ (u8++); + } + + return hash; + } + + public static void init(ProgressMonitor? monitor) { + face_photos_map = new Gee.HashMap> + ((Gee.HashDataFunc)face_id_hash, (Gee.EqualDataFunc)face_ids_equal); + photo_faces_map = new Gee.HashMap> + ((Gee.HashDataFunc)photo_id_hash, (Gee.EqualDataFunc)photo_ids_equal); + + // scoop up all the rows at once + Gee.List rows = null; + try { + rows = FaceLocationTable.get_instance().get_all_rows(); + } catch (DatabaseError err) { + AppWindow.database_error(err); + } + + // turn them into FaceLocation objects + int count = rows.size; + for (int ctr = 0; ctr < count; ctr++) { + FaceLocation.add_from_row(rows.get(ctr)); + + if (monitor != null) + monitor(ctr, count); + } + } + + public static void terminate() { + } + + public FaceLocationID get_face_location_id() { + return face_location_id; + } + + public string get_serialized_geometry() { + return geometry; + } + + private void set_serialized_geometry(string geometry) { + this.geometry = geometry; + } +} + +#endif diff --git a/src/faces/FacePage.vala b/src/faces/FacePage.vala new file mode 100644 index 0000000..41d1cef --- /dev/null +++ b/src/faces/FacePage.vala @@ -0,0 +1,127 @@ +/* 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. + */ + +#if ENABLE_FACES + +public class FacePage : CollectionPage { + private Face face; + + public FacePage(Face face) { + base (face.get_name()); + + this.face = face; + + Face.global.items_altered.connect(on_faces_altered); + face.mirror_sources(get_view(), create_thumbnail); + + init_page_context_menu("FacesContextMenu"); + } + + ~FacePage() { + get_view().halt_mirroring(); + Face.global.items_altered.disconnect(on_faces_altered); + } + + protected override void init_collect_ui_filenames(Gee.List ui_filenames) { + base.init_collect_ui_filenames(ui_filenames); + ui_filenames.add("faces.ui"); + } + + public Face get_face() { + return face; + } + + protected override void get_config_photos_sort(out bool sort_order, out int sort_by) { + Config.Facade.get_instance().get_event_photos_sort(out sort_order, out sort_by); + } + + protected override void set_config_photos_sort(bool sort_order, int sort_by) { + Config.Facade.get_instance().set_event_photos_sort(sort_order, sort_by); + } + + private const GLib.ActionEntry[] entries = { + { "DeleteFace", on_delete_face }, + { "RenameFace", on_rename_face }, + { "RemoveFaceFromPhotos", on_remove_face_from_photos }, + { "DeleteFaceSidebar", on_delete_face }, + { "RenameFaceSidebar", on_rename_face } + }; + + protected override void init_actions(int selected_count, int count) { + base.init_actions(selected_count, count); + + set_action_sensitive("DeleteFace", true); + set_action_sensitive("RenameFace", true); + set_action_sensitive("RemoveFaceFromPhotos", true); + } + + + protected override void add_actions (GLib.ActionMap map) { + base.add_actions (map); + + map.add_action_entries (entries, this); + } + + protected override InjectionGroup[] init_collect_injection_groups() { + InjectionGroup[] groups = base.init_collect_injection_groups(); + groups += create_faces_menu_injectables(); + return groups; + } + + private InjectionGroup create_faces_menu_injectables(){ + InjectionGroup menuFaces = new InjectionGroup("FacesMenuPlaceholder"); + + menuFaces.add_menu_item(Resources.remove_face_from_photos_menu(this.face.get_name(), get_view().get_count()), "RemoveFaceFromPhotos", "r"); + menuFaces.add_menu_item(Resources.rename_face_menu(this.face.get_name()), "RenameFace", "e"); + menuFaces.add_menu_item(Resources.delete_face_menu(this.face.get_name()), "DeleteFace", "t"); + + return menuFaces; + } + + private void on_faces_altered(Gee.Map map) { + if (map.has_key(face)) { + set_page_name(face.get_name()); + update_actions(get_view().get_selected_count(), get_view().get_count()); + } + } + + protected override void update_actions(int selected_count, int count) { + set_action_details("DeleteFace", + Resources.delete_face_menu(face.get_name()), + null, + true); + + set_action_details("RenameFace", + Resources.rename_face_menu(face.get_name()), + null, + true); + + set_action_details("RemoveFaceFromPhotos", + Resources.remove_face_from_photos_menu(face.get_name(), get_view().get_count()), + null, + selected_count > 0); + + base.update_actions(selected_count, count); + } + + private void on_rename_face() { + LibraryWindow.get_app().rename_face_in_sidebar(face); + } + + private void on_delete_face() { + if (Dialogs.confirm_delete_face(face)) + AppWindow.get_command_manager().execute(new DeleteFaceCommand(face)); + } + + private void on_remove_face_from_photos() { + if (get_view().get_selected_count() > 0) { + get_command_manager().execute(new RemoveFacesFromPhotosCommand(face, + (Gee.Collection) get_view().get_selected_sources())); + } + } +} + +#endif diff --git a/src/faces/FaceShape.vala b/src/faces/FaceShape.vala new file mode 100644 index 0000000..c14b43b --- /dev/null +++ b/src/faces/FaceShape.vala @@ -0,0 +1,783 @@ +/* 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. + */ + +#if ENABLE_FACES + +public abstract class FaceShape : Object { + public const string SHAPE_TYPE = null; + + protected const int FACE_WINDOW_MARGIN = 5; + protected const int LABEL_MARGIN = 12; + protected const int LABEL_PADDING = 9; + + public signal void add_me_requested(FaceShape face_shape); + public signal void delete_me_requested(); + + protected FacesTool.EditingFaceToolWindow face_window; + protected Gdk.CursorType current_cursor_type = Gdk.CursorType.BOTTOM_RIGHT_CORNER; + protected EditingTools.PhotoCanvas canvas; + protected string serialized = null; + + private bool editable = true; + private bool visible = true; + private bool known = true; + + private weak FacesTool.FaceWidget face_widget = null; + + public FaceShape(EditingTools.PhotoCanvas canvas) { + this.canvas = canvas; + this.canvas.new_surface.connect(prepare_ctx); + + prepare_ctx(this.canvas.get_default_ctx(), this.canvas.get_surface_dim()); + + face_window = new FacesTool.EditingFaceToolWindow(this.canvas.get_container()); + face_window.key_pressed.connect(key_press_event); + + face_window.show_all(); + face_window.hide(); + + this.canvas.get_drawing_window().set_cursor(new Gdk.Cursor(current_cursor_type)); + } + + ~FaceShape() { + if (visible) + erase(); + + face_window.destroy(); + + canvas.new_surface.disconnect(prepare_ctx); + + // make sure the cursor isn't set to a modify indicator + canvas.get_drawing_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR)); + } + + public static FaceShape from_serialized(EditingTools.PhotoCanvas canvas, string serialized) + throws FaceShapeError { + FaceShape face_shape; + + string[] args = serialized.split(";"); + switch (args[0]) { + case "Rectangle": + face_shape = FaceRectangle.from_serialized(canvas, args); + + break; + default: + assert_not_reached(); + } + + face_shape.serialized = serialized; + + return face_shape; + } + + public void set_name(string face_name) { + face_window.entry.set_text(face_name); + } + + public string? get_name() { + string face_name = face_window.entry.get_text(); + + return face_name == "" ? null : face_name; + } + + public void set_known(bool known) { + this.known = known; + } + + public bool get_known() { + return known; + } + + public void set_widget(FacesTool.FaceWidget face_widget) { + this.face_widget = face_widget; + } + + public FacesTool.FaceWidget get_widget() { + assert(face_widget != null); + + return face_widget; + } + + public void hide() { + visible = false; + erase(); + + if (editable) + face_window.hide(); + + // make sure the cursor isn't set to a modify indicator + canvas.get_drawing_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR)); + } + + public void show() { + visible = true; + paint(); + + if (editable) { + update_face_window_position(); + face_window.show(); + face_window.present(); + + if (!known) + face_window.entry.select_region(0, -1); + } + } + + public bool is_visible() { + return visible; + } + + public bool is_editable() { + return editable; + } + + public void set_editable(bool editable) { + if (visible && editable != is_editable()) { + hide(); + this.editable = editable; + show(); + + return; + } + + this.editable = editable; + } + + public bool key_press_event(Gdk.EventKey event) { + switch (Gdk.keyval_name(event.keyval)) { + case "Escape": + delete_me_requested(); + break; + case "Return": + case "KP_Enter": + add_me_requested(this); + break; + default: + return false; + } + + return true; + } + + public abstract string serialize(); + public abstract void update_face_window_position(); + public abstract void prepare_ctx(Cairo.Context ctx, Dimensions dim); + public abstract void on_resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled); + public abstract void on_motion(int x, int y, Gdk.ModifierType mask); + public abstract void on_left_released(int x, int y); + public abstract bool on_left_click(int x, int y); + public abstract bool cursor_is_over(int x, int y); + public abstract bool equals(FaceShape face_shape); + public abstract double get_distance(int x, int y); + + protected abstract void paint(); + protected abstract void erase(); +} + +public class FaceRectangle : FaceShape { + public new const string SHAPE_TYPE = "Rectangle"; + + private const int FACE_MIN_SIZE = 8; + public const int NULL_SIZE = 0; + + private Box box; + private Box? label_box; + private BoxLocation in_manipulation = BoxLocation.OUTSIDE; + private Cairo.Context wide_black_ctx = null; + private Cairo.Context wide_white_ctx = null; + private Cairo.Context thin_white_ctx = null; + private int last_grab_x = -1; + private int last_grab_y = -1; + + public FaceRectangle(EditingTools.PhotoCanvas canvas, int x, int y, + int half_width = NULL_SIZE, int half_height = NULL_SIZE) { + base(canvas); + + Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position(); + x -= scaled_pixbuf_pos.x; + y -= scaled_pixbuf_pos.y; + + // If half_width is NULL_SIZE we are creating a new FaceShape, + // otherwise we are only showing a previously created one. + if (half_width == NULL_SIZE) { + box = Box(x, y, x, y); + + in_manipulation = BoxLocation.BOTTOM_RIGHT; + last_grab_x = x; + last_grab_y = y; + } else { + Dimensions pixbuf_dimensions = Dimensions.for_pixbuf(canvas.get_scaled_pixbuf()); + int right = (x + half_width).clamp(x, pixbuf_dimensions.width); + int bottom = (y + half_height).clamp(y, pixbuf_dimensions.height); + + box = Box(x - half_width, y - half_height, right, bottom); + } + } + + ~FaceRectangle() { + if (!is_editable()) + erase_label(); + } + + public static new FaceRectangle from_serialized(EditingTools.PhotoCanvas canvas, string[] args) + throws FaceShapeError { + assert(args[0] == SHAPE_TYPE); + + Photo photo = canvas.get_photo(); + Dimensions raw_dim = photo.get_raw_dimensions(); + + int x = (int) (raw_dim.width * double.parse(args[1])); + int y = (int) (raw_dim.height * double.parse(args[2])); + int half_width = (int) (raw_dim.width * double.parse(args[3])); + int half_height = (int) (raw_dim.height * double.parse(args[4])); + + Box box = Box(x - half_width, y - half_height, x + half_width, y + half_height); + + Dimensions current_dim = Dimensions.for_pixbuf(canvas.get_scaled_pixbuf()); + Box raw_cropped; + + if (photo.get_raw_crop(out raw_cropped)) { + box.left = box.left.clamp(raw_cropped.left, box.left) - raw_cropped.left; + box.right = box.right.clamp(box.right, raw_cropped.right) - raw_cropped.left; + box.top = box.top.clamp(raw_cropped.top, box.top) - raw_cropped.top; + box.bottom = box.bottom.clamp(box.bottom, raw_cropped.bottom) - raw_cropped.top; + + box = photo.get_orientation().rotate_box(raw_cropped.get_dimensions(), box); + + Box cropped; + photo.get_crop(out cropped); + box = box.get_scaled_similar(cropped.get_dimensions(), current_dim); + } else { + box = photo.get_orientation().rotate_box(raw_dim, box); + + box = box.get_scaled_similar(photo.get_dimensions(), current_dim); + } + + Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position(); + box.left += scaled_pixbuf_pos.x; + box.right += scaled_pixbuf_pos.x; + box.top += scaled_pixbuf_pos.y; + box.bottom += scaled_pixbuf_pos.y; + + half_width = box.get_width() / 2; + half_height = box.get_height() / 2; + + if (half_width < FACE_MIN_SIZE || half_height < FACE_MIN_SIZE) + throw new FaceShapeError.CANT_CREATE("FaceShape is out of cropped photo area"); + + return new FaceRectangle(canvas, box.left + half_width, box.top + half_height, + half_width, half_height); + } + + public override void update_face_window_position() { + AppWindow appWindow = AppWindow.get_instance(); + Gtk.Allocation face_window_alloc; + Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position(); + int x = 0; + int y = 0; + + if (canvas.get_container() == appWindow) { + appWindow.get_current_page().get_window().get_origin(out x, out y); + } else assert(canvas.get_container() is FullscreenWindow); + + face_window.get_allocation(out face_window_alloc); + + x += scaled_pixbuf_pos.x + box.left + ((box.get_width() - face_window_alloc.width) >> 1); + y += scaled_pixbuf_pos.y + box.bottom + FACE_WINDOW_MARGIN; + + face_window.move(x, y); + } + + protected override void paint() { + canvas.draw_box(wide_black_ctx, box); + canvas.draw_box(wide_white_ctx, box.get_reduced(1)); + canvas.draw_box(wide_white_ctx, box.get_reduced(2)); + + canvas.invalidate_area(box); + + if (!is_editable()) + paint_label(); + } + + protected override void erase() { + canvas.erase_box(box); + canvas.erase_box(box.get_reduced(1)); + canvas.erase_box(box.get_reduced(2)); + + canvas.invalidate_area(box); + + if (!is_editable()) + erase_label(); + } + + private void paint_label() { + Cairo.Context ctx = canvas.get_default_ctx(); + Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position(); + + ctx.save(); + + Cairo.TextExtents text_extents = Cairo.TextExtents(); + ctx.text_extents(get_name(), out text_extents); + + int width = (int) text_extents.width + LABEL_PADDING; + int height = (int) text_extents.height; + int x = box.left + (box.get_width() - width) / 2; + int y = box.bottom + LABEL_MARGIN; + + label_box = Box(x, y, x + width, y + height + LABEL_PADDING); + + x += scaled_pixbuf_pos.x; + y += scaled_pixbuf_pos.y; + + ctx.rectangle(x, y, width, height + LABEL_PADDING); + ctx.set_source_rgba(0, 0, 0, 0.6); + ctx.fill(); + + ctx.set_source_rgb(1, 1, 1); + ctx.move_to(x + LABEL_PADDING / 2, y + height + LABEL_PADDING / 2); + ctx.show_text(get_name()); + + ctx.restore(); + } + + private void erase_label() { + if (label_box == null) + return; + + Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position(); + int x = scaled_pixbuf_pos.x + label_box.left; + int y = scaled_pixbuf_pos.y + label_box.top; + + Cairo.Context ctx = canvas.get_default_ctx(); + ctx.save(); + + ctx.set_operator(Cairo.Operator.OVER); + ctx.rectangle(x, y, label_box.get_width(), label_box.get_height()); + + ctx.set_source_rgb(0.0, 0.0, 0.0); + ctx.fill_preserve(); + + ctx.set_source_surface(canvas.get_scaled_surface(), + scaled_pixbuf_pos.x, scaled_pixbuf_pos.y); + ctx.fill(); + + canvas.invalidate_area(label_box); + label_box = null; + + ctx.restore(); + } + + public override string serialize() { + if (serialized != null) + return serialized; + + double x; + double y; + double half_width; + double half_height; + + get_geometry(out x, out y, out half_width, out half_height); + + serialized = "%s;%s;%s;%s;%s".printf(SHAPE_TYPE, x.to_string(), + y.to_string(), half_width.to_string(), half_height.to_string()); + + return serialized; + } + + public void get_geometry(out double x, out double y, + out double half_width, out double half_height) { + Photo photo = canvas.get_photo(); + Dimensions raw_dim = photo.get_raw_dimensions(); + + Box temp_box = box; + + Dimensions current_dim = Dimensions.for_pixbuf(canvas.get_scaled_pixbuf()); + Box cropped; + + if (photo.get_crop(out cropped)) { + temp_box = temp_box.get_scaled_similar(current_dim, cropped.get_dimensions()); + + Box raw_cropped; + photo.get_raw_crop(out raw_cropped); + + temp_box = + photo.get_orientation().derotate_box(raw_cropped.get_dimensions(), temp_box); + + temp_box.left += raw_cropped.left; + temp_box.right += raw_cropped.left; + temp_box.top += raw_cropped.top; + temp_box.bottom += raw_cropped.top; + } else { + temp_box = temp_box.get_scaled_similar(current_dim, photo.get_dimensions()); + + temp_box = photo.get_orientation().derotate_box(raw_dim, temp_box); + } + + x = (temp_box.left + (temp_box.get_width() / 2)) / (double) raw_dim.width; + y = (temp_box.top + (temp_box.get_height() / 2)) / (double) raw_dim.height; + + double width_left_end = temp_box.left / (double) raw_dim.width; + double width_right_end = temp_box.right / (double) raw_dim.width; + double height_top_end = temp_box.top / (double) raw_dim.height; + double height_bottom_end = temp_box.bottom / (double) raw_dim.height; + + half_width = (width_right_end - width_left_end) / 2; + half_height = (height_bottom_end - height_top_end) / 2; + } + + public override bool equals(FaceShape face_shape) { + return serialize() == face_shape.serialize(); + } + + public override void prepare_ctx(Cairo.Context ctx, Dimensions dim) { + wide_black_ctx = new Cairo.Context(ctx.get_target()); + set_source_color_from_string(wide_black_ctx, "#000"); + wide_black_ctx.set_line_width(1); + + wide_white_ctx = new Cairo.Context(ctx.get_target()); + set_source_color_from_string(wide_black_ctx, "#FFF"); + wide_white_ctx.set_line_width(1); + + thin_white_ctx = new Cairo.Context(ctx.get_target()); + set_source_color_from_string(wide_black_ctx, "#FFF"); + thin_white_ctx.set_line_width(0.5); + } + + private bool on_canvas_manipulation(int x, int y) { + Gdk.Rectangle scaled_pos = canvas.get_scaled_pixbuf_position(); + + // box is maintained in coordinates non-relative to photo's position on canvas ... + // but bound tool to photo itself + x -= scaled_pos.x; + if (x < 0) + x = 0; + else if (x >= scaled_pos.width) + x = scaled_pos.width - 1; + + y -= scaled_pos.y; + if (y < 0) + y = 0; + else if (y >= scaled_pos.height) + y = scaled_pos.height - 1; + + // need to make manipulations outside of box structure, because its methods do sanity + // checking + int left = box.left; + int top = box.top; + int right = box.right; + int bottom = box.bottom; + + // get extra geometric information needed to enforce constraints + int photo_right_edge = canvas.get_scaled_pixbuf().width - 1; + int photo_bottom_edge = canvas.get_scaled_pixbuf().height - 1; + + switch (in_manipulation) { + case BoxLocation.LEFT_SIDE: + left = x; + break; + + case BoxLocation.TOP_SIDE: + top = y; + break; + + case BoxLocation.RIGHT_SIDE: + right = x; + break; + + case BoxLocation.BOTTOM_SIDE: + bottom = y; + break; + + case BoxLocation.TOP_LEFT: + top = y; + left = x; + break; + + case BoxLocation.BOTTOM_LEFT: + bottom = y; + left = x; + break; + + case BoxLocation.TOP_RIGHT: + top = y; + right = x; + break; + + case BoxLocation.BOTTOM_RIGHT: + bottom = y; + right = x; + break; + + case BoxLocation.INSIDE: + assert(last_grab_x >= 0); + assert(last_grab_y >= 0); + + int delta_x = (x - last_grab_x); + int delta_y = (y - last_grab_y); + + last_grab_x = x; + last_grab_y = y; + + int width = right - left + 1; + int height = bottom - top + 1; + + left += delta_x; + top += delta_y; + right += delta_x; + bottom += delta_y; + + // bound box inside of photo + if (left < 0) + left = 0; + + if (top < 0) + top = 0; + + if (right >= scaled_pos.width) + right = scaled_pos.width - 1; + + if (bottom >= scaled_pos.height) + bottom = scaled_pos.height - 1; + + int adj_width = right - left + 1; + int adj_height = bottom - top + 1; + + // don't let adjustments affect the size of the box + if (adj_width != width) { + if (delta_x < 0) + right = left + width - 1; + else left = right - width + 1; + } + + if (adj_height != height) { + if (delta_y < 0) + bottom = top + height - 1; + else top = bottom - height + 1; + } + break; + + default: + // do nothing, not even a repaint + return false; + } + + // Check if the mouse has gone out of bounds, and if it has, make sure that the + // face shape edges stay within the photo bounds. + int width = right - left + 1; + int height = bottom - top + 1; + + if (left < 0) + left = 0; + if (top < 0) + top = 0; + if (right > photo_right_edge) + right = photo_right_edge; + if (bottom > photo_bottom_edge) + bottom = photo_bottom_edge; + + width = right - left + 1; + height = bottom - top + 1; + + switch (in_manipulation) { + case BoxLocation.LEFT_SIDE: + case BoxLocation.TOP_LEFT: + case BoxLocation.BOTTOM_LEFT: + if (width < FACE_MIN_SIZE) + left = right - FACE_MIN_SIZE; + break; + + case BoxLocation.RIGHT_SIDE: + case BoxLocation.TOP_RIGHT: + case BoxLocation.BOTTOM_RIGHT: + if (width < FACE_MIN_SIZE) + right = left + FACE_MIN_SIZE; + break; + + default: + break; + } + + switch (in_manipulation) { + case BoxLocation.TOP_SIDE: + case BoxLocation.TOP_LEFT: + case BoxLocation.TOP_RIGHT: + if (height < FACE_MIN_SIZE) + top = bottom - FACE_MIN_SIZE; + break; + + case BoxLocation.BOTTOM_SIDE: + case BoxLocation.BOTTOM_LEFT: + case BoxLocation.BOTTOM_RIGHT: + if (height < FACE_MIN_SIZE) + bottom = top + FACE_MIN_SIZE; + break; + + default: + break; + } + + Box new_box = Box(left, top, right, bottom); + + if (!box.equals(new_box)) { + erase(); + + if (in_manipulation != BoxLocation.INSIDE) + check_resized_box(new_box); + + box = new_box; + paint(); + } + + if (is_editable()) + update_face_window_position(); + + serialized = null; + + return false; + } + + private void check_resized_box(Box new_box) { + Box horizontal; + bool horizontal_enlarged; + Box vertical; + bool vertical_enlarged; + BoxComplements complements = box.resized_complements(new_box, out horizontal, + out horizontal_enlarged, out vertical, out vertical_enlarged); + + // this should never happen ... this means that the operation wasn't a resize + assert(complements != BoxComplements.NONE); + } + + private void update_cursor(int x, int y) { + // box is not maintained relative to photo's position on canvas + Gdk.Rectangle scaled_pos = canvas.get_scaled_pixbuf_position(); + Box offset_scaled_box = box.get_offset(scaled_pos.x, scaled_pos.y); + + Gdk.CursorType cursor_type = Gdk.CursorType.LEFT_PTR; + switch (offset_scaled_box.approx_location(x, y)) { + case BoxLocation.LEFT_SIDE: + cursor_type = Gdk.CursorType.LEFT_SIDE; + break; + + case BoxLocation.TOP_SIDE: + cursor_type = Gdk.CursorType.TOP_SIDE; + break; + + case BoxLocation.RIGHT_SIDE: + cursor_type = Gdk.CursorType.RIGHT_SIDE; + break; + + case BoxLocation.BOTTOM_SIDE: + cursor_type = Gdk.CursorType.BOTTOM_SIDE; + break; + + case BoxLocation.TOP_LEFT: + cursor_type = Gdk.CursorType.TOP_LEFT_CORNER; + break; + + case BoxLocation.BOTTOM_LEFT: + cursor_type = Gdk.CursorType.BOTTOM_LEFT_CORNER; + break; + + case BoxLocation.TOP_RIGHT: + cursor_type = Gdk.CursorType.TOP_RIGHT_CORNER; + break; + + case BoxLocation.BOTTOM_RIGHT: + cursor_type = Gdk.CursorType.BOTTOM_RIGHT_CORNER; + break; + + case BoxLocation.INSIDE: + cursor_type = Gdk.CursorType.FLEUR; + break; + + default: + // use Gdk.CursorType.LEFT_PTR + break; + } + + if (cursor_type != current_cursor_type) { + Gdk.Cursor cursor = new Gdk.Cursor(cursor_type); + canvas.get_drawing_window().set_cursor(cursor); + current_cursor_type = cursor_type; + } + } + + public override void on_motion(int x, int y, Gdk.ModifierType mask) { + // only deal with manipulating the box when click-and-dragging one of the edges + // or the interior + if (in_manipulation != BoxLocation.OUTSIDE) + on_canvas_manipulation(x, y); + + update_cursor(x, y); + } + + public override bool on_left_click(int x, int y) { + Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position(); + + // box is not maintained relative to photo's position on canvas + Box offset_scaled_box = box.get_offset(scaled_pixbuf_pos.x, scaled_pixbuf_pos.y); + + // determine where the mouse down landed and store for future events + in_manipulation = offset_scaled_box.approx_location(x, y); + last_grab_x = x -= scaled_pixbuf_pos.x; + last_grab_y = y -= scaled_pixbuf_pos.y; + + return box.approx_location(x, y) != BoxLocation.OUTSIDE; + } + + public override void on_left_released(int x, int y) { + if (box.get_width() < FACE_MIN_SIZE) { + delete_me_requested(); + + return; + } + + if (is_editable()) { + face_window.show(); + face_window.present(); + } + + // nothing to do if released outside of the face box + if (in_manipulation == BoxLocation.OUTSIDE) + return; + + // end manipulation + in_manipulation = BoxLocation.OUTSIDE; + last_grab_x = -1; + last_grab_y = -1; + + update_cursor(x, y); + } + + public override void on_resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled) { + Dimensions new_dim = Dimensions.for_pixbuf(scaled); + Dimensions uncropped_dim = canvas.get_photo().get_original_dimensions(); + + Box new_box = box.get_scaled_similar(old_dim, uncropped_dim); + + // rescale back to new size + box = new_box.get_scaled_similar(uncropped_dim, new_dim); + update_face_window_position(); + } + + public override bool cursor_is_over(int x, int y) { + // box is not maintained relative to photo's position on canvas + Gdk.Rectangle scaled_pos = canvas.get_scaled_pixbuf_position(); + Box offset_scaled_box = box.get_offset(scaled_pos.x, scaled_pos.y); + + return offset_scaled_box.approx_location(x, y) != BoxLocation.OUTSIDE; + } + + public override double get_distance(int x, int y) { + double center_x = box.left + box.get_width() / 2.0; + double center_y = box.top + box.get_height() / 2.0; + + return Math.sqrt((center_x - x) * (center_x - x) + (center_y - y) * (center_y - y)); + } +} + +#endif diff --git a/src/faces/Faces.vala b/src/faces/Faces.vala new file mode 100644 index 0000000..3f0623a --- /dev/null +++ b/src/faces/Faces.vala @@ -0,0 +1,37 @@ +/* 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. + */ + +#if ENABLE_FACES + +namespace Faces { + +public void init() throws Error { + Faces.SidebarEntry.init(); +} + +public void terminate() { + Faces.SidebarEntry.terminate(); +} + +} + +#else + +namespace Faces { + +public void init() throws Error { + // do nothing; this method is here only + // to make the unitizing mechanism happy +} + +public void terminate() { + // do nothing; this method is here only + // to make the unitizing mechanism happy +} + +} + +#endif diff --git a/src/faces/FacesBranch.vala b/src/faces/FacesBranch.vala new file mode 100644 index 0000000..1eb25cf --- /dev/null +++ b/src/faces/FacesBranch.vala @@ -0,0 +1,146 @@ +/* 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. + */ + +#if ENABLE_FACES + +public class Faces.Branch : Sidebar.Branch { + private Gee.HashMap entry_map = new Gee.HashMap(); + + public Branch() { + base (new Faces.Grouping(), + Sidebar.Branch.Options.HIDE_IF_EMPTY + | Sidebar.Branch.Options.AUTO_OPEN_ON_NEW_CHILD + | Sidebar.Branch.Options.STARTUP_EXPAND_TO_FIRST_CHILD, + comparator); + + // seed the branch with existing faces + on_faces_added_removed(Face.global.get_all(), null); + + // monitor collection for future events + Face.global.contents_altered.connect(on_faces_added_removed); + Face.global.items_altered.connect(on_faces_altered); + } + + ~Branch() { + Face.global.contents_altered.disconnect(on_faces_added_removed); + Face.global.items_altered.disconnect(on_faces_altered); + } + + public Faces.SidebarEntry? get_entry_for_face(Face face) { + return entry_map.get(face); + } + + private static int comparator(Sidebar.Entry a, Sidebar.Entry b) { + if (a == b) + return 0; + + return Face.compare_names(((Faces.SidebarEntry) a).for_face(), + ((Faces.SidebarEntry) b).for_face()); + } + + private void on_faces_added_removed(Gee.Iterable? added, Gee.Iterable? removed) { + if (added != null) { + foreach (DataObject object in added) { + Face face = (Face) object; + + Faces.SidebarEntry entry = new Faces.SidebarEntry(face); + entry_map.set(face, entry); + + graft(get_root(), entry); + } + } + + if (removed != null) { + foreach (DataObject object in removed) { + Face face = (Face) object; + + Faces.SidebarEntry? entry = entry_map.get(face); + assert(entry != null); + + bool is_removed = entry_map.unset(face); + assert(is_removed); + + prune(entry); + } + } + } + + private void on_faces_altered(Gee.Map altered) { + foreach (DataObject object in altered.keys) { + if (!altered.get(object).has_detail("metadata", "name")) + continue; + + Face face = (Face) object; + Faces.SidebarEntry? entry = entry_map.get(face); + assert(entry != null); + + entry.sidebar_name_changed(face.get_name()); + entry.sidebar_tooltip_changed(face.get_name()); + reorder(entry); + } + } +} + +public class Faces.Grouping : Sidebar.Header { + public Grouping() { + base (_("Faces")); + } +} + +public class Faces.SidebarEntry : Sidebar.SimplePageEntry, Sidebar.RenameableEntry, + Sidebar.DestroyableEntry { + private static string single_face_icon = Resources.ICON_ONE_FACE; + + private Face face; + + public SidebarEntry(Face face) { + this.face = face; + } + + internal static void init() { + } + + internal static void terminate() { + } + + public Face for_face() { + return face; + } + + public bool is_user_renameable() { + return true; + } + + public override string get_sidebar_name() { + return face.get_name(); + } + + public override string? get_sidebar_icon() { + return single_face_icon; + } + + protected override Page create_page() { + return new FacePage(face); + } + + public void rename(string new_name) { + string? prepped = Face.prep_face_name(new_name); + if (prepped == null) + return; + + if (!Face.global.exists(prepped)) + AppWindow.get_command_manager().execute(new RenameFaceCommand(face, prepped)); + else if (prepped != face.get_name()) + AppWindow.error_message(Resources.rename_face_exists_message(prepped)); + } + + public void destroy_source() { + if (Dialogs.confirm_delete_face(face)) + AppWindow.get_command_manager().execute(new DeleteFaceCommand(face)); + } +} + +#endif diff --git a/src/faces/FacesTool.vala b/src/faces/FacesTool.vala new file mode 100644 index 0000000..cf53736 --- /dev/null +++ b/src/faces/FacesTool.vala @@ -0,0 +1,977 @@ +/* Copyright 2018 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. + */ + +#if ENABLE_FACES +public errordomain FaceShapeError { + CANT_CREATE +} + +public class FacesTool : EditingTools.EditingTool { + protected const int CONTROL_SPACING = 8; + protected const int FACE_LABEL_MAX_CHARS = 15; + + private enum EditingPhase { + CLICK_TO_EDIT, + NOT_EDITING, + CREATING_DRAGGING, + CREATING_EDITING, + EDITING, + DETECTING_FACES, + DETECTING_FACES_FINISHED + } + + public class FaceWidget : Gtk.Box { + private static Pango.AttrList attrs_bold; + private static Pango.AttrList attrs_normal; + + public signal void face_hidden(); + + public Gtk.Button edit_button; + public Gtk.Button delete_button; + public Gtk.Label label; + + public weak FaceShape face_shape; + + static construct { + attrs_bold = new Pango.AttrList(); + attrs_bold.insert(Pango.attr_weight_new(Pango.Weight.BOLD)); + attrs_normal = new Pango.AttrList(); + attrs_normal.insert(Pango.attr_weight_new(Pango.Weight.NORMAL)); + } + + public FaceWidget (FaceShape face_shape) { + spacing = CONTROL_SPACING; + + edit_button = new Gtk.Button.with_label(Resources.EDIT_LABEL); + edit_button.set_use_underline(true); + delete_button = new Gtk.Button.with_label(Resources.DELETE_LABEL); + delete_button.set_use_underline(true); + + label = new Gtk.Label(face_shape.get_name()); + label.halign = Gtk.Align.START; + label.valign = Gtk.Align.CENTER; + label.ellipsize = Pango.EllipsizeMode.END; + label.width_chars = FACE_LABEL_MAX_CHARS; + + pack_start(label, true); + pack_start(edit_button, false); + pack_start(delete_button, false); + + this.face_shape = face_shape; + face_shape.set_widget(this); + } + + public bool on_enter_notify_event() { + activate_label(); + + if (face_shape.is_editable()) + return false; + + // This check is necessary to avoid painting the face twice --see + // note in on_leave_notify_event. + if (!face_shape.is_visible()) + face_shape.show(); + + return true; + } + + public bool on_leave_notify_event() { + // This check is necessary because GTK+ will throw enter/leave_notify + // events when the pointer passes though windows, even if one window + // belongs to a widget that is a child of the widget that throws this + // signal. So, this check is necessary to avoid "deactivation" of + // the label if the pointer enters one of the buttons in this FaceWidget. + if (!is_pointer_over(get_window())) { + deactivate_label(); + + if (face_shape.is_editable()) + return false; + + face_shape.hide(); + face_hidden(); + } + + return true; + } + + public void activate_label() { + label.set_attributes(attrs_bold); + } + + public void deactivate_label() { + label.set_attributes(attrs_normal); + } + } + + private class FacesToolWindow : EditingTools.EditingToolWindow { + public signal void face_hidden(); + public signal void face_edit_requested(string face_name); + public signal void face_delete_requested(string face_name); + public signal void detection_canceled(); + + public Gtk.Button detection_button = new Gtk.Button.with_label(_("Detect faces…")); + public Gtk.Button ok_button; + public Gtk.Button cancel_button; + public Gtk.Button cancel_detection_button; + + private EditingPhase editing_phase = EditingPhase.NOT_EDITING; + private Gtk.Box help_layout = null; + private Gtk.Box response_layout = null; + private Gtk.HSeparator buttons_text_separator = null; + private Gtk.Label help_text = null; + private Gtk.Box face_widgets_layout = null; + private Gtk.Box layout = null; + + public FacesToolWindow(Gtk.Window container) { + base(container); + + ok_button = new Gtk.Button.with_label(Resources.OK_LABEL); + ok_button.set_use_underline(true); + + cancel_button = new Gtk.Button.with_label(Resources.CANCEL_LABEL); + cancel_button.set_use_underline(true); + + cancel_detection_button = new Gtk.Button.with_label(Resources.CANCEL_LABEL); + cancel_detection_button.set_use_underline(true); + + detection_button.set_tooltip_text(_("Detect faces on this photo")); + + cancel_detection_button.set_tooltip_text(_("Cancel face detection")); + cancel_detection_button.set_image_position(Gtk.PositionType.LEFT); + cancel_detection_button.clicked.connect(on_cancel_detection); + + cancel_button.set_tooltip_text(_("Close the Faces tool without saving changes")); + cancel_button.set_image_position(Gtk.PositionType.LEFT); + + ok_button.set_image_position(Gtk.PositionType.LEFT); + + face_widgets_layout = new Gtk.Box(Gtk.Orientation.VERTICAL, CONTROL_SPACING); + + help_text = new Gtk.Label(_("Click and drag to tag a face")); + help_layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING); + help_layout.pack_start(help_text, true); + + response_layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING); + response_layout.add(detection_button); + response_layout.add(cancel_button); + response_layout.add(ok_button); + + layout = new Gtk.Box(Gtk.Orientation.VERTICAL, CONTROL_SPACING); + layout.pack_start(face_widgets_layout, false); + layout.pack_start(help_layout, false); + layout.pack_start(new Gtk.HSeparator(), false); + layout.pack_start(response_layout, false); + + add(layout); + } + + public void set_editing_phase(EditingPhase phase, FaceShape? face_shape = null) { + if (editing_phase == EditingPhase.DETECTING_FACES && + phase != EditingPhase.DETECTING_FACES_FINISHED) + return; + + switch (phase) { + case EditingPhase.CLICK_TO_EDIT: + assert(face_shape != null); + + help_text.set_markup(Markup.printf_escaped(_("Click to edit face %s"), + face_shape.get_name())); + + break; + case EditingPhase.NOT_EDITING: + help_text.set_text(_("Click and drag to tag a face")); + + break; + case EditingPhase.CREATING_DRAGGING: + help_text.set_text(_("Stop dragging to add your face and name it.")); + + break; + case EditingPhase.CREATING_EDITING: + help_text.set_text(_("Type a name for this face, then press Enter")); + + break; + case EditingPhase.EDITING: + help_text.set_text(_("Move or modify the face shape or name and press Enter")); + + break; + case EditingPhase.DETECTING_FACES: + help_text.set_text(_("Detecting faces")); + + if (cancel_detection_button.get_parent() == null) + help_layout.pack_start(cancel_detection_button, false); + + detection_button.set_sensitive(false); + cancel_detection_button.set_sensitive(true); + cancel_detection_button.show(); + + break; + case EditingPhase.DETECTING_FACES_FINISHED: + help_text.set_text(_("If you don’t set the name of unknown faces they won’t be saved.")); + + break; + default: + assert_not_reached(); + } + + if (editing_phase == EditingPhase.DETECTING_FACES && editing_phase != phase) { + cancel_detection_button.hide(); + detection_button.set_sensitive(true); + } + + editing_phase = phase; + } + + public EditingPhase get_editing_phase() { + return editing_phase; + } + + public void ok_button_set_sensitive(bool sensitive) { + if (sensitive) + ok_button.set_tooltip_text(_("Save changes and close the Faces tool")); + else + ok_button.set_tooltip_text(_("No changes to save")); + + ok_button.set_sensitive(sensitive); + } + + public void add_face(FaceShape face_shape) { + FaceWidget face_widget = new FaceWidget(face_shape); + + face_widget.face_hidden.connect(on_face_hidden); + face_widget.edit_button.clicked.connect(edit_face); + face_widget.delete_button.clicked.connect(delete_face); + + Gtk.EventBox event_box = new Gtk.EventBox(); + event_box.add(face_widget); + event_box.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK); + event_box.enter_notify_event.connect(face_widget.on_enter_notify_event); + event_box.leave_notify_event.connect(face_widget.on_leave_notify_event); + + face_widgets_layout.pack_start(event_box, false); + + if (buttons_text_separator == null) { + buttons_text_separator = new Gtk.HSeparator(); + face_widgets_layout.pack_end(buttons_text_separator, false); + } + + face_widgets_layout.show_all(); + } + + private void edit_face(Gtk.Button button) { + FaceWidget widget = (FaceWidget) button.get_parent(); + + face_edit_requested(widget.label.get_text()); + } + + private void delete_face(Gtk.Button button) { + FaceWidget widget = (FaceWidget) button.get_parent(); + + face_delete_requested(widget.label.get_text()); + + widget.get_parent().destroy(); + + if (face_widgets_layout.get_children().length() == 1) { + buttons_text_separator.destroy(); + buttons_text_separator = null; + } + } + + private void on_face_hidden() { + face_hidden(); + } + + private void on_cancel_detection() { + detection_canceled(); + } + } + + public class EditingFaceToolWindow : EditingTools.EditingToolWindow { + public signal bool key_pressed(Gdk.EventKey event); + + public Gtk.Entry entry; + + private Gtk.Box layout = null; + + public EditingFaceToolWindow(Gtk.Window container) { + base(container); + + entry = new Gtk.Entry(); + + layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING); + layout.add(entry); + + add(layout); + } + + public override bool key_press_event(Gdk.EventKey event) { + return key_pressed(event) || base.key_press_event(event); + } + } + + private class FaceDetectionJob : BackgroundJob { + private Gee.Queue faces = null; + private string image_path; + private string output; + public SpawnError? spawnError; + + public FaceDetectionJob(FacesToolWindow owner, string image_path, + CompletionCallback completion_callback, Cancellable cancellable, + CancellationCallback cancellation_callback) { + base(owner, completion_callback, cancellable, cancellation_callback); + + this.image_path = image_path; + } + + public override void execute() { + try { + string[] argv = { + AppDirs.get_facedetect_bin().get_path(), + "--cascade=" + AppDirs.get_haarcascade_file().get_path(), + "--scale=1.2", + image_path + }; + Process.spawn_sync(null, argv, null, SpawnFlags.STDERR_TO_DEV_NULL, null, out output); + + } catch (SpawnError e) { + spawnError = e; + critical(e.message); + + return; + } + + faces = new Gee.PriorityQueue(); + string[] lines = output.split("\n"); + foreach (string line in lines) { + if (line.length == 0) + continue; + + string[] type_and_serialized = line.split(";"); + if (type_and_serialized.length != 2) { + critical("Wrong serialized line in face detection program output."); + assert_not_reached(); + } + + switch (type_and_serialized[0]) { + case "face": + StringBuilder serialized_geometry = new StringBuilder(); + serialized_geometry.append(FaceRectangle.SHAPE_TYPE); + serialized_geometry.append(";"); + serialized_geometry.append(parse_serialized_geometry(type_and_serialized[1])); + + faces.add(serialized_geometry.str); + break; + + case "warning": + warning("%s\n", type_and_serialized[1]); + break; + + case "error": + critical("%s\n", type_and_serialized[1]); + assert_not_reached(); + + default: + assert_not_reached(); + } + } + } + + private string parse_serialized_geometry(string serialized_geometry) { + string[] serialized_geometry_pieces = serialized_geometry.split("&"); + if (serialized_geometry_pieces.length != 4) { + critical("Wrong serialized line in face detection program output."); + assert_not_reached(); + } + + double x = 0; + double y = 0; + double width = 0; + double height = 0; + foreach (string piece in serialized_geometry_pieces) { + + string[] name_and_value = piece.split("="); + if (name_and_value.length != 2) { + critical("Wrong serialized line in face detection program output."); + assert_not_reached(); + } + + switch (name_and_value[0]) { + case "x": + x = name_and_value[1].to_double(); + break; + + case "y": + y = name_and_value[1].to_double(); + break; + + case "width": + width = name_and_value[1].to_double(); + break; + + case "height": + height = name_and_value[1].to_double(); + break; + + default: + critical("Wrong serialized line in face detection program output."); + assert_not_reached(); + } + } + + double half_width = width / 2; + double half_height = height / 2; + + return "%s;%s;%s;%s".printf((x + half_width).to_string(), (y + half_height).to_string(), + half_width.to_string(), half_height.to_string()); + } + + public string? get_next() { + if (faces == null) + return null; + + return faces.poll(); + } + + public void reset() { + faces = null; + } + } + + private Cairo.Surface image_surface = null; + private Gee.HashMap face_shapes; + private Gee.HashMap original_face_locations; + private Cancellable face_detection_cancellable; + private FaceDetectionJob face_detection; + private Workers workers; + private FaceShape editing_face_shape = null; + private FacesToolWindow faces_tool_window = null; + + private FacesTool() { + base("FacesTool"); + } + + public static FacesTool factory() { + return new FacesTool(); + } + + public override void activate(EditingTools.PhotoCanvas canvas) { + face_shapes = new Gee.HashMap(); + original_face_locations = new Gee.HashMap(); + + bind_canvas_handlers(canvas); + + if (image_surface != null) + image_surface = null; + + Gdk.Rectangle scaled_pixbuf_position = canvas.get_scaled_pixbuf_position(); + image_surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, + scaled_pixbuf_position.width, + scaled_pixbuf_position.height); + + faces_tool_window = new FacesToolWindow(canvas.get_container()); + + Gee.Map? face_locations = + FaceLocation.get_locations_by_photo(canvas.get_photo()); + if (face_locations != null) + foreach (Gee.Map.Entry entry in face_locations.entries) { + FaceShape new_face_shape; + string serialized_geometry = entry.value.get_serialized_geometry(); + try { + new_face_shape = FaceShape.from_serialized(canvas, serialized_geometry); + } catch (FaceShapeError e) { + if (e is FaceShapeError.CANT_CREATE) + continue; + + assert_not_reached(); + } + Face? face = Face.global.fetch(entry.key); + assert(face != null); + string face_name = face.get_name(); + new_face_shape.set_name(face_name); + + add_face(new_face_shape); + original_face_locations.set(face_name, serialized_geometry); + } + + set_ok_button_sensitivity(); + + face_detection_cancellable = new Cancellable(); + workers = new Workers(1, false); + face_detection = new FaceDetectionJob(faces_tool_window, + canvas.get_photo().get_file().get_path(), on_faces_detected, + face_detection_cancellable, on_detection_cancelled); + + bind_window_handlers(); + + base.activate(canvas); + } + + public override void deactivate() { + if (canvas != null) + unbind_canvas_handlers(canvas); + + if (faces_tool_window != null) { + unbind_window_handlers(); + faces_tool_window.hide(); + faces_tool_window.destroy(); + faces_tool_window = null; + } + + base.deactivate(); + } + + private void bind_canvas_handlers(EditingTools.PhotoCanvas canvas) { + canvas.new_surface.connect(prepare_ctx); + canvas.resized_scaled_pixbuf.connect(on_resized_pixbuf); + } + + private void unbind_canvas_handlers(EditingTools.PhotoCanvas canvas) { + canvas.new_surface.disconnect(prepare_ctx); + canvas.resized_scaled_pixbuf.disconnect(on_resized_pixbuf); + } + + private void bind_window_handlers() { + faces_tool_window.key_press_event.connect(on_keypress); + faces_tool_window.ok_button.clicked.connect(on_faces_ok); + faces_tool_window.cancel_button.clicked.connect(notify_cancel); + faces_tool_window.detection_button.clicked.connect(detect_faces); + faces_tool_window.face_hidden.connect(on_face_hidden); + faces_tool_window.face_edit_requested.connect(edit_face); + faces_tool_window.face_delete_requested.connect(delete_face); + faces_tool_window.detection_canceled.connect(cancel_face_detection); + } + + private void unbind_window_handlers() { + faces_tool_window.key_press_event.disconnect(on_keypress); + faces_tool_window.ok_button.clicked.disconnect(on_faces_ok); + faces_tool_window.cancel_button.clicked.disconnect(notify_cancel); + faces_tool_window.detection_button.clicked.disconnect(detect_faces); + faces_tool_window.face_hidden.disconnect(on_face_hidden); + faces_tool_window.face_edit_requested.disconnect(edit_face); + faces_tool_window.face_delete_requested.disconnect(delete_face); + faces_tool_window.detection_canceled.disconnect(cancel_face_detection); + } + + private void prepare_ctx(Cairo.Context ctx, Dimensions dim) { + if (editing_face_shape != null) + editing_face_shape.prepare_ctx(ctx, dim); + } + + private void on_resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled, Gdk.Rectangle scaled_position) { + if (image_surface != null) + image_surface = null; + + image_surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, scaled.width, scaled.height); + Cairo.Context ctx = new Cairo.Context(image_surface); + ctx.set_source_rgba(255.0, 255.0, 255.0, 0.0); + ctx.paint(); + + if (editing_face_shape != null) + editing_face_shape.on_resized_pixbuf(old_dim, scaled); + + if (face_shapes != null) + foreach (FaceShape face_shape in face_shapes.values) + face_shape.on_resized_pixbuf(old_dim, scaled); + } + + public override bool on_keypress(Gdk.EventKey event) { + string event_keyval = Gdk.keyval_name(event.keyval); + + if (event_keyval == "Return" || event_keyval == "KP_Enter") { + on_faces_ok(); + return true; + } + + return base.on_keypress(event); + } + + public override void on_left_click(int x, int y) { + if (editing_face_shape != null && editing_face_shape.on_left_click(x, y)) + return; + + foreach (FaceShape face_shape in face_shapes.values) { + if (face_shape.is_visible() && face_shape.cursor_is_over(x, y)) { + edit_face_shape(face_shape); + face_shape.set_editable(true); + + return; + } + } + + new_face_shape(x, y); + } + + public override void on_left_released(int x, int y) { + if (editing_face_shape != null) { + editing_face_shape.on_left_released(x, y); + + if (faces_tool_window.get_editing_phase() == EditingPhase.CREATING_DRAGGING) + faces_tool_window.set_editing_phase(EditingPhase.CREATING_EDITING); + } + } + + public override void on_motion(int x, int y, Gdk.ModifierType mask) { + if (editing_face_shape == null) { + FaceShape to_show = null; + double distance = 0; + double new_distance; + + foreach (FaceShape face_shape in face_shapes.values) { + bool cursor_is_over = face_shape.cursor_is_over(x, y); + + // The FaceShape that will be shown needs to be repainted + // even if it is already visible, since it could be erased by + // another hiding FaceShape -and for the same + // reason it needs to be painted after all + // hiding faces are already erased. + // Also, we paint the FaceShape whose center is closer + // to the pointer. + if (cursor_is_over) { + face_shape.hide(); + face_shape.get_widget().deactivate_label(); + + if (to_show == null) { + to_show = face_shape; + distance = face_shape.get_distance(x, y); + } else { + new_distance = face_shape.get_distance(x, y); + + if (new_distance < distance) { + to_show = face_shape; + distance = new_distance; + } + } + } else if (!cursor_is_over && face_shape.is_visible()) { + face_shape.hide(); + face_shape.get_widget().deactivate_label(); + } + } + + if (to_show == null) { + faces_tool_window.set_editing_phase(EditingPhase.NOT_EDITING); + } else { + faces_tool_window.set_editing_phase(EditingPhase.CLICK_TO_EDIT, to_show); + + to_show.show(); + to_show.get_widget().activate_label(); + } + } else editing_face_shape.on_motion(x, y, mask); + } + + public override bool on_leave_notify_event() { + // This check is a workaround for bug #3896. + if (is_pointer_over(canvas.get_drawing_window()) && + !is_pointer_over(faces_tool_window.get_window())) + return false; + + if (editing_face_shape != null) + return base.on_leave_notify_event(); + + foreach (FaceShape face_shape in face_shapes.values) { + if (face_shape.is_editable()) + return base.on_leave_notify_event(); + + if (face_shape.is_visible()) { + face_shape.hide(); + face_shape.get_widget().deactivate_label(); + + break; + } + } + + faces_tool_window.set_editing_phase(EditingPhase.NOT_EDITING); + + return base.on_leave_notify_event(); + } + + public override EditingTools.EditingToolWindow? get_tool_window() { + return faces_tool_window; + } + + public override void paint(Cairo.Context default_ctx) { + // fill region behind the image surface with neutral color + int w = canvas.get_drawing_window().get_width(); + int h = canvas.get_drawing_window().get_height(); + + default_ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0); + default_ctx.rectangle(0, 0, w, h); + default_ctx.fill(); + default_ctx.paint(); + + Cairo.Context ctx = new Cairo.Context(image_surface); + ctx.set_operator(Cairo.Operator.SOURCE); + ctx.set_source_rgba(0.0, 0.0, 0.0, 0.0); + ctx.paint(); + + canvas.paint_surface(image_surface, true); + + // paint face shape last + if (editing_face_shape != null) + editing_face_shape.show(); + } + + private void new_face_shape(int x, int y) { + edit_face_shape(new FaceRectangle(canvas, x, y), true); + } + + private void edit_face_shape(FaceShape face_shape, bool creating = false) { + hide_visible_face(); + + if (editing_face_shape != null) { + // We need to do this because it could be one of the already + // created faces being edited, and if that is the case it + // will not be destroyed. + editing_face_shape.hide(); + editing_face_shape.set_editable(false); + + // This is to allow the user to edit a FaceShape's shape + // without pressing the Enter button. + if (face_shapes.values.contains(editing_face_shape)) + set_ok_button_sensitivity(); + + editing_face_shape = null; + } + + if (creating) { + faces_tool_window.set_editing_phase(EditingPhase.CREATING_DRAGGING); + } else { + face_shape.show(); + + faces_tool_window.set_editing_phase(EditingPhase.EDITING); + } + + editing_face_shape = face_shape; + editing_face_shape.add_me_requested.connect(add_face); + editing_face_shape.delete_me_requested.connect(release_face_shape); + } + + private void release_face_shape() { + if (editing_face_shape == null) + return; + + // We need to do this because it could be one of the already + // created faces being edited, and if that is the case it + // will not be destroyed. + if (editing_face_shape in face_shapes.values) { + editing_face_shape.hide(); + editing_face_shape.set_editable(false); + + editing_face_shape.get_widget().deactivate_label(); + } + + editing_face_shape = null; + + faces_tool_window.set_editing_phase(EditingPhase.NOT_EDITING); + faces_tool_window.present(); + } + + private void hide_visible_face() { + foreach (FaceShape face_shape in face_shapes.values) { + if (face_shape.is_visible()) { + face_shape.hide(); + + break; + } + } + } + + private void on_faces_ok() { + if (face_shapes == null) + return; + + Gee.Map new_faces = new Gee.HashMap(); + foreach (FaceShape face_shape in face_shapes.values) { + if (!face_shape.get_known()) + continue; + + Face new_face = Face.for_name(face_shape.get_name()); + + new_faces.set(new_face, face_shape.serialize()); + } + + ModifyFacesCommand command = new ModifyFacesCommand(canvas.get_photo(), new_faces); + applied(command, null, canvas.get_photo().get_dimensions(), false); + } + + private void on_face_hidden() { + if (editing_face_shape != null) + editing_face_shape.show(); + } + + private void add_face(FaceShape face_shape) { + string? prepared_face_name = Face.prep_face_name(face_shape.get_name()); + + if (prepared_face_name != null) { + face_shape.set_name(prepared_face_name); + + if (face_shapes.values.contains(face_shape)) { + foreach (Gee.Map.Entry entry in face_shapes.entries) { + if (entry.value == face_shape) { + if (entry.key == prepared_face_name) + break; + + face_shapes.unset(entry.key); + face_shapes.set(prepared_face_name, face_shape); + + face_shape.set_known(true); + face_shape.get_widget().label.set_text(face_shape.get_name()); + + break; + } + } + } else if (!face_shapes.has_key(prepared_face_name)) { + faces_tool_window.add_face(face_shape); + face_shapes.set(prepared_face_name, face_shape); + } else return; + + face_shape.hide(); + face_shape.set_editable(false); + + set_ok_button_sensitivity(); + release_face_shape(); + } + } + + private void edit_face(string face_name) { + FaceShape face_shape = face_shapes.get(face_name); + assert(face_shape != null); + + face_shape.set_editable(true); + edit_face_shape(face_shape); + } + + private void delete_face(string face_name) { + face_shapes.unset(face_name); + + // It is posible to have two visible faces at the same time, this happens + // if you are editing one face and you move the pointer around the + // FaceWidgets area in FacesToolWindow. And you can delete one of that + // faces, so the other visible face must be repainted. + foreach (FaceShape face_shape in face_shapes.values) { + if (face_shape.is_visible()) { + face_shape.hide(); + face_shape.show(); + + break; + } + } + + set_ok_button_sensitivity(); + } + + private void set_ok_button_sensitivity() { + Gee.Map known_face_shapes = new Gee.HashMap(); + foreach (Gee.Map.Entry face_shape in face_shapes.entries) { + if (face_shape.value.get_known()) { + known_face_shapes.set(face_shape.key, face_shape.value); + } + } + + if (original_face_locations.size != known_face_shapes.size) { + faces_tool_window.ok_button_set_sensitive(true); + + return; + } + + foreach (Gee.Map.Entry face_shape in known_face_shapes.entries) { + bool found = false; + + foreach (Gee.Map.Entry face_location in original_face_locations.entries) { + if (face_location.key == face_shape.key) { + if (face_location.value == face_shape.value.serialize()) { + found = true; + + break; + } else { + faces_tool_window.ok_button_set_sensitive(true); + + return; + } + } + } + + if (!found) { + faces_tool_window.ok_button_set_sensitive(true); + + return; + } + } + + faces_tool_window.ok_button_set_sensitive(false); + } + + private void detect_faces() { + faces_tool_window.detection_button.set_sensitive(false); + faces_tool_window.set_editing_phase(EditingPhase.DETECTING_FACES); + + workers.enqueue(face_detection); + } + + private void pick_faces_from_autodetected() { + int c = 0; + while (true) { + string? serialized_geometry = face_detection.get_next(); + if (serialized_geometry == null) { + faces_tool_window.set_editing_phase(EditingPhase.DETECTING_FACES_FINISHED); + + return; + } + + FaceShape face_shape; + try { + face_shape = FaceShape.from_serialized(canvas, serialized_geometry); + } catch (FaceShapeError e) { + if (e is FaceShapeError.CANT_CREATE) + continue; + + assert_not_reached(); + } + + bool found = false; + foreach (FaceShape existing_face_shape in face_shapes.values) { + if (existing_face_shape.equals(face_shape)) { + found = true; + + break; + } + } + + if (found) + continue; + + c++; + + face_shape.set_name("Unknown face #%d".printf(c)); + face_shape.set_known(false); + add_face(face_shape); + } + } + + private void on_faces_detected() { + face_detection_cancellable.reset(); + + if (face_detection.spawnError != null){ + string spawnErrorMessage = _("Error trying to spawn face detection program:\n"); + AppWindow.error_message(spawnErrorMessage + face_detection.spawnError.message + "\n"); + faces_tool_window.set_editing_phase(EditingPhase.DETECTING_FACES_FINISHED); + } else + pick_faces_from_autodetected(); + } + + private void on_detection_cancelled(BackgroundJob job) { + ((FaceDetectionJob) job).reset(); + face_detection_cancellable.reset(); + + faces_tool_window.set_editing_phase(EditingPhase.DETECTING_FACES_FINISHED); + } + + private void cancel_face_detection() { + faces_tool_window.cancel_detection_button.set_sensitive(false); + + face_detection.cancel(); + } +} + +#endif -- cgit v1.2.3