summaryrefslogtreecommitdiff
path: root/src/faces
diff options
context:
space:
mode:
authorJörg Frings-Fürst <debian@jff.email>2018-09-30 14:09:20 +0200
committerJörg Frings-Fürst <debian@jff.email>2018-09-30 14:09:20 +0200
commit5e9f4eea451a77ba3b93db3747841ed2bd969e9f (patch)
tree75046a38ca68975261d853a2e56ff7bf6b3e1daa /src/faces
parent18b52c2983a1b3409011f72d27f15de576c5eb1c (diff)
New upstream version 0.30.1upstream/0.30.1
Diffstat (limited to 'src/faces')
-rw-r--r--src/faces/Face.vala681
-rw-r--r--src/faces/FaceLocation.vala209
-rw-r--r--src/faces/FacePage.vala127
-rw-r--r--src/faces/FaceShape.vala783
-rw-r--r--src/faces/Faces.vala37
-rw-r--r--src/faces/FacesBranch.vala146
-rw-r--r--src/faces/FacesTool.vala977
7 files changed, 2960 insertions, 0 deletions
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<string, Face> name_map = new Gee.HashMap<string, Face>
+ ((Gee.HashDataFunc)Face.hash_name_string, (Gee.EqualDataFunc)Face.equal_name_strings);
+ private Gee.HashMap<MediaSource, Gee.List<Face>> source_map =
+ new Gee.HashMap<MediaSource, Gee.List<Face>>();
+
+ 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<ContainerSource>? 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<string> 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<Face>? 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<DataObject> 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<DataObject> 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<DataObject, Alteration> map) {
+ foreach (DataObject object in map.keys) {
+ Face face = (Face) object;
+
+ string? old_name = null;
+
+ // look for this face being renamed
+ Gee.MapIterator<string, Face> 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<DataObject> added, bool relinking) {
+ Face face = (Face) container;
+ Gee.Collection<MediaSource> sources = (Gee.Collection<MediaSource>) added;
+
+ foreach (MediaSource source in sources) {
+ Gee.List<Face>? faces = source_map.get(source);
+ if (faces == null) {
+ faces = new Gee.ArrayList<Face>();
+ 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<DataObject> removed, bool unlinking) {
+ Face face = (Face) container;
+ Gee.Collection<MediaSource> sources = (Gee.Collection<MediaSource>) removed;
+
+ foreach (MediaSource source in sources) {
+ Gee.List<Face>? 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<MediaSource> sources = new Gee.HashSet<MediaSource>();
+
+ 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<PhotoID?> photo_id_list = FaceLocation.get_photo_ids_by_face(this);
+ Gee.ArrayList<Photo> photo_list = new Gee.ArrayList<Photo>();
+ Gee.ArrayList<ThumbnailView> thumbnail_views = new Gee.ArrayList<ThumbnailView>();
+ 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<FaceRow?> rows = null;
+ try {
+ rows = FaceTable.get_instance().get_all_rows();
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ // turn them into Face objects
+ Gee.ArrayList<Face> faces = new Gee.ArrayList<Face>();
+ Gee.ArrayList<Face> unlinked = new Gee.ArrayList<Face>();
+ 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<DataSource> sources) {
+ unlinking = true;
+
+ detach_many((Gee.Collection<LibraryPhoto>) sources);
+
+ unlinking = false;
+ }
+
+ public void establish_link(DataSource source) {
+ relinking = true;
+
+ attach((LibraryPhoto) source);
+
+ relinking = false;
+ }
+
+ public void establish_link_many(Gee.Collection<DataSource> sources) {
+ relinking = true;
+
+ attach_many((Gee.Collection<LibraryPhoto>) 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<MediaSource> sources) {
+ Gee.ArrayList<ThumbnailView> view_list = new Gee.ArrayList<ThumbnailView>();
+ 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<MediaSource> 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<MediaSource> get_sources() {
+ return (Gee.Collection<MediaSource>) 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<DataView>? added,
+ Gee.Iterable<DataView>? removed) {
+ Gee.Set<PhotoID?>? photo_id_list = FaceLocation.get_photo_ids_by_face(this);
+
+ Gee.Collection<Photo> added_photos = null;
+ if (added != null) {
+ added_photos = new Gee.ArrayList<Photo>();
+ 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<Photo> removed_photos = null;
+ if (removed != null) {
+ assert(photo_id_list != null);
+
+ removed_photos = new Gee.ArrayList<Photo>();
+ 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<DataSource> sources) {
+ detach_many((Gee.Collection<MediaSource>) 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<MediaSource> removed = new Gee.ArrayList<MediaSource>();
+ removed.add_all((Gee.Collection<MediaSource>) 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<FaceID?, Gee.Map<PhotoID?, FaceLocation>> face_photos_map;
+ private static Gee.Map<PhotoID?, Gee.Map<FaceID?, FaceLocation>> 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<PhotoID?, FaceLocation> photos_map = face_photos_map.get(face_id);
+ Gee.Map<FaceID?, FaceLocation> 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<PhotoID?, FaceLocation> photos_map = face_photos_map.get(face_id);
+ Gee.Map<FaceID?, FaceLocation> 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<PhotoID?, FaceLocation> photos_map = face_photos_map.get(row.face_id);
+ if (photos_map == null) {photos_map = new Gee.HashMap<PhotoID?, FaceLocation>
+ ((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<FaceID?, FaceLocation> faces_map = photo_faces_map.get(row.photo_id);
+ if (faces_map == null) {faces_map = new Gee.HashMap<FaceID?, FaceLocation>
+ ((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<FaceID?, FaceLocation>? get_locations_by_photo(Photo photo) {
+ return photo_faces_map.get(photo.get_photo_id());
+ }
+
+ public static Gee.Map<PhotoID?, FaceLocation>? get_locations_by_face(Face face) {
+ return face_photos_map.get(face.get_face_id());
+ }
+
+ public static Gee.Set<PhotoID?>? get_photo_ids_by_face(Face face) {
+ Gee.Map<PhotoID?, FaceLocation>? 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<FaceID?, FaceLocation>? 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<FaceID?, Gee.HashMap<PhotoID?, FaceLocation>>
+ ((Gee.HashDataFunc)face_id_hash, (Gee.EqualDataFunc)face_ids_equal);
+ photo_faces_map = new Gee.HashMap<PhotoID?, Gee.HashMap<FaceID?, FaceLocation>>
+ ((Gee.HashDataFunc)photo_id_hash, (Gee.EqualDataFunc)photo_ids_equal);
+
+ // scoop up all the rows at once
+ Gee.List<FaceLocationRow?> 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<string> 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", "<Primary>r");
+ menuFaces.add_menu_item(Resources.rename_face_menu(this.face.get_name()), "RenameFace", "<Primary>e");
+ menuFaces.add_menu_item(Resources.delete_face_menu(this.face.get_name()), "DeleteFace", "<Primary>t");
+
+ return menuFaces;
+ }
+
+ private void on_faces_altered(Gee.Map<DataObject, Alteration> 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<MediaSource>) 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<Face, Faces.SidebarEntry> entry_map = new Gee.HashMap<Face, Faces.SidebarEntry>();
+
+ 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<DataObject>? added, Gee.Iterable<DataObject>? 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<DataObject, Alteration> 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 <i>%s</i>"),
+ 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<string> 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>();
+ 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<string, FaceShape> face_shapes;
+ private Gee.HashMap<string, string> 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<string, FaceShape>();
+ original_face_locations = new Gee.HashMap<string, string>();
+
+ 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<FaceID?, FaceLocation>? face_locations =
+ FaceLocation.get_locations_by_photo(canvas.get_photo());
+ if (face_locations != null)
+ foreach (Gee.Map.Entry<FaceID?, FaceLocation> 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<Face, string> new_faces = new Gee.HashMap<Face, string>();
+ 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<string, FaceShape> 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<string, FaceShape> known_face_shapes = new Gee.HashMap<string, FaceShape>();
+ foreach (Gee.Map.Entry<string, FaceShape> 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<string, FaceShape> face_shape in known_face_shapes.entries) {
+ bool found = false;
+
+ foreach (Gee.Map.Entry<string, string> 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