Commit 83560c4d authored by Jim Nelson's avatar Jim Nelson

Major refactoring in preparation for photo editing. New Photo object...

Major refactoring in preparation for photo editing.  New Photo object centralizes pixbuf generation and photo manipulation, and introduces GObject signals into the system so various interested parties (esp. various pages) are notified when a photo is modified or removed.  Also refactored Dimensions struct, as what started out as a utility record has become a first-class citizen of Shotwell.  Also, while refactoring, went ahead and made changes to reflect Yorba's coding style (i.e. camelCase for variables -> underscored_names).  In the process, knocked out #198: Databases and thumbnail cache always in sync with modifications.  #202: Black-and-white shading with crop tool.
parent afc5c855
This diff is collapsed.
public enum BoxLocation {
OUTSIDE,
INSIDE,
TOP_SIDE,
LEFT_SIDE,
RIGHT_SIDE,
BOTTOM_SIDE,
TOP_LEFT,
BOTTOM_LEFT,
TOP_RIGHT,
BOTTOM_RIGHT
}
public struct Box {
public static const int HAND_GRENADES = 6;
public int left;
public int top;
public int right;
public int bottom;
public Box(int left, int top, int right, int bottom) {
assert(left >= 0);
assert(top >= 0);
assert(right >= left);
assert(bottom >= top);
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
}
public static Box from_rectangle(Gdk.Rectangle rect) {
return Box(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height);
}
public int get_width() {
assert(right >= left);
return right - left;
}
public int get_height() {
assert(bottom >= top);
return bottom - top;
}
public bool is_valid() {
return (left >= 0) && (top >= 0) && (right >= left) && (bottom >= top);
}
public Box get_scaled(Dimensions orig, Dimensions scaled) {
double x_scale = (double) scaled.width / (double) orig.width;
double y_scale = (double) scaled.height / (double) orig.height;
Box box = Box((int) (left * x_scale), (int) (top * y_scale), (int) (right * x_scale),
(int) (bottom * y_scale));
return box;
}
public Box get_offset(int xofs, int yofs) {
return Box(left + xofs, top + yofs, right + xofs, bottom + yofs);
}
public Gdk.Rectangle get_rectangle() {
Gdk.Rectangle rect = Gdk.Rectangle();
rect.x = left;
rect.y = top;
rect.width = get_width();
rect.height = get_height();
return rect;
}
public string to_string() {
return "%d,%d %d,%d".printf(left, top, right, bottom);
}
private static bool in_zone(double pos, int zone) {
int top_zone = zone - HAND_GRENADES;
int bottom_zone = zone + HAND_GRENADES;
return in_between(pos, top_zone, bottom_zone);
}
private static bool in_between(double pos, int top, int bottom) {
int ipos = (int) pos;
return (ipos > top) && (ipos < bottom);
}
private static bool near_in_between(double pos, int top, int bottom) {
int ipos = (int) pos;
int top_zone = top - HAND_GRENADES;
int bottom_zone = bottom + HAND_GRENADES;
return (ipos > top_zone) && (ipos < bottom_zone);
}
public BoxLocation location(int x, int y) {
bool near_width = near_in_between(x, left, right);
bool near_height = near_in_between(y, top, bottom);
if (in_zone(x, left) && near_height) {
if (in_zone(y, top)) {
return BoxLocation.TOP_LEFT;
} else if (in_zone(y, bottom)) {
return BoxLocation.BOTTOM_LEFT;
} else {
return BoxLocation.LEFT_SIDE;
}
} else if (in_zone(x, right) && near_height) {
if (in_zone(y, top)) {
return BoxLocation.TOP_RIGHT;
} else if (in_zone(y, bottom)) {
return BoxLocation.BOTTOM_RIGHT;
} else {
return BoxLocation.RIGHT_SIDE;
}
} else if (in_zone(y, top) && near_width) {
// if left or right was in zone, already caught top left & top right
return BoxLocation.TOP_SIDE;
} else if (in_zone(y, bottom) && near_width) {
// if left or right was in zone, already caught bottom left & bottom right
return BoxLocation.BOTTOM_SIDE;
} else if (in_between(x, left, right) && in_between(y, top, bottom)) {
return BoxLocation.INSIDE;
} else {
return BoxLocation.OUTSIDE;
}
}
}
......@@ -6,8 +6,8 @@ public abstract class LayoutItem : Gtk.Alignment {
public static const string SELECTED_COLOR = "#FF0";
public static const string UNSELECTED_COLOR = "#FFF";
// Due to the potential for thousands or tens of thousands of thumbnails being present in a
// particular view, all widgets used here and by subclasses should be NOWINDOW widgets.
// Due to the potential for thousands or tens of thousands of thumbnails being present in the
// system, all widgets used here and by subclasses should be NOWINDOW widgets.
protected Gtk.Image image = new Gtk.Image();
protected Gtk.Label title = new Gtk.Label("");
protected Gtk.Frame frame = new Gtk.Frame(null);
......@@ -51,8 +51,6 @@ public abstract class LayoutItem : Gtk.Alignment {
return title.get_text();
}
public abstract void on_backing_changed();
public virtual void exposed() {
}
......@@ -127,6 +125,7 @@ public class CollectionLayout : Gtk.Layout {
public CollectionLayout() {
modify_bg(Gtk.StateType.NORMAL, AppWindow.BG_COLOR);
// TODO:
// This is commented out because Vala is generating bogus ccode from it (it's trying to
// unref the Gdk.Color as though it was a Gee.Collection) ... I suspect it has to do with
// the SortedList class. Will probably need to rip out SortedList and sort all lists by
......
......@@ -12,18 +12,20 @@ public class CollectionPage : CheckerboardPage {
// steppings should divide evenly into (Thumbnail.MAX_SCALE - Thumbnail.MIN_SCALE)
public static const int MANUAL_STEPPING = 16;
public static const int SLIDER_STEPPING = 1;
public static const int SLIDER_STEPPING = 2;
private static const int IMPROVAL_PRIORITY = Priority.LOW;
private static const int IMPROVAL_DELAY_MS = 250;
private PhotoTable photo_table = new PhotoTable();
private Gtk.Toolbar toolbar = new Gtk.Toolbar();
private Gtk.HScale slider = null;
private Gtk.ToolButton rotateButton = null;
private Gtk.ToolButton rotate_button = null;
private int scale = Thumbnail.DEFAULT_SCALE;
private bool improval_scheduled = false;
private bool in_view = false;
private int last_width = 0;
private int last_height = 0;
private bool reschedule_improval = false;
// TODO: Mark fields for translation
private const Gtk.ActionEntry[] ACTIONS = {
......@@ -60,18 +62,18 @@ public class CollectionPage : CheckerboardPage {
{ "SortDescending", null, "D_escending", null, "Sort photos in a descending order", SORT_ORDER_DESCENDING }
};
public CollectionPage(PhotoID[] photos, string? ui_filename = null, Gtk.ActionEntry[]? child_actions = null) {
public CollectionPage(string? ui_filename = null, Gtk.ActionEntry[]? child_actions = null) {
base("Photos");
init_ui_start("collection.ui", "CollectionActionGroup", ACTIONS, TOGGLE_ACTIONS);
actionGroup.add_radio_actions(SORT_CRIT_ACTIONS, SORT_BY_NAME, on_sort_changed);
actionGroup.add_radio_actions(SORT_ORDER_ACTIONS, SORT_ORDER_ASCENDING, on_sort_changed);
action_group.add_radio_actions(SORT_CRIT_ACTIONS, SORT_BY_NAME, on_sort_changed);
action_group.add_radio_actions(SORT_ORDER_ACTIONS, SORT_ORDER_ASCENDING, on_sort_changed);
if (ui_filename != null)
init_load_ui(ui_filename);
if (child_actions != null)
actionGroup.add_actions(child_actions, this);
action_group.add_actions(child_actions, this);
init_ui_bind("/CollectionMenuBar");
init_context_menu("/CollectionContextMenu");
......@@ -81,12 +83,12 @@ public class CollectionPage : CheckerboardPage {
// set up page's toolbar (used by AppWindow for layout)
//
// rotate tool
rotateButton = new Gtk.ToolButton.from_stock(STOCK_CLOCKWISE);
rotateButton.label = "Rotate Clockwise";
rotateButton.sensitive = false;
rotateButton.clicked += on_rotate_clockwise;
rotate_button = new Gtk.ToolButton.from_stock(STOCK_CLOCKWISE);
rotate_button.label = "Rotate";
rotate_button.sensitive = false;
rotate_button.clicked += on_rotate_clockwise;
toolbar.insert(rotateButton, -1);
toolbar.insert(rotate_button, -1);
// separator to force slider to right side of toolbar
Gtk.SeparatorToolItem separator = new Gtk.SeparatorToolItem();
......@@ -116,11 +118,6 @@ public class CollectionPage : CheckerboardPage {
get_hadjustment().value_changed += schedule_thumbnail_improval;
get_vadjustment().value_changed += schedule_thumbnail_improval;
// load photos into view
foreach (PhotoID photo_id in photos) {
add_photo(photo_id);
}
// turn this off until we're switched to
set_refresh_on_resize(false);
......@@ -153,15 +150,14 @@ public class CollectionPage : CheckerboardPage {
}
protected override void on_selection_changed(int count) {
rotateButton.sensitive = (count > 0);
rotate_button.sensitive = (count > 0);
}
protected override void on_item_activated(LayoutItem item) {
Thumbnail thumbnail = (Thumbnail) item;
// switch to full-page view
debug("switching to %s [%lld]", thumbnail.get_file().get_path(),
thumbnail.get_photo_id().id);
debug("switching to %s", thumbnail.get_photo().to_string());
AppWindow.get_instance().switch_to_photo_page(this, thumbnail);
}
......@@ -177,22 +173,18 @@ public class CollectionPage : CheckerboardPage {
}
// use the first item of the selected collection to start things off
foreach (LayoutItem item in iter) {
foreach (LayoutItem item in iter)
return item;
}
return null;
}
private int lastWidth = 0;
private int lastHeight = 0;
protected override bool on_resize(Gdk.Rectangle rect) {
// this schedules thumbnail improvement whenever the window size changes (and new thumbnails
// may be exposed), therefore, uninterested in window position move
if ((lastWidth != rect.width) || (lastHeight != rect.height)) {
lastWidth = rect.width;
lastHeight = rect.height;
if ((last_width != rect.width) || (last_height != rect.height)) {
last_width = rect.width;
last_height = rect.height;
schedule_thumbnail_improval();
}
......@@ -200,18 +192,40 @@ public class CollectionPage : CheckerboardPage {
return false;
}
public void add_photo(PhotoID photoID) {
Thumbnail thumbnail = new Thumbnail(photoID, scale);
public void add_photo(Photo photo) {
photo.removed += on_photo_removed;
photo.altered += on_photo_altered;
Thumbnail thumbnail = new Thumbnail(photo, scale);
thumbnail.display_title(display_titles());
add_item(thumbnail);
}
public bool remove_photo(PhotoID photo_id) {
private void on_photo_removed(Photo photo) {
debug("on_photo_removed");
bool removed = remove_photo(photo);
assert(removed);
}
private void on_photo_altered(Photo photo) {
debug("on_photo_altered");
// the thumbnail is only going to reload a low-quality interp, so schedule improval
schedule_thumbnail_improval();
// since the geometry might have changed, refresh the layout
if (in_view)
refresh();
}
// This does not remove the photo from the system, only from this layout
public bool remove_photo(Photo photo) {
Thumbnail found = null;
foreach (LayoutItem item in get_items()) {
Thumbnail thumbnail = (Thumbnail) item;
if (thumbnail.get_photo_id().id == photo_id.id) {
if (thumbnail.get_photo().equals(photo)) {
found = thumbnail;
break;
......@@ -224,25 +238,13 @@ public class CollectionPage : CheckerboardPage {
return (found != null);
}
public void report_backing_changed(PhotoID photo_id) {
foreach (LayoutItem item in get_items()) {
Thumbnail thumbnail = (Thumbnail) item;
if (thumbnail.get_photo_id().id == photo_id.id) {
thumbnail.on_backing_changed();
break;
}
}
}
public int increase_thumb_size() {
if (scale == Thumbnail.MAX_SCALE)
return scale;
scale += MANUAL_STEPPING;
if (scale > Thumbnail.MAX_SCALE) {
if (scale > Thumbnail.MAX_SCALE)
scale = Thumbnail.MAX_SCALE;
}
set_thumb_size(scale);
......@@ -254,9 +256,8 @@ public class CollectionPage : CheckerboardPage {
return scale;
scale -= MANUAL_STEPPING;
if (scale < Thumbnail.MIN_SCALE) {
if (scale < Thumbnail.MIN_SCALE)
scale = Thumbnail.MIN_SCALE;
}
set_thumb_size(scale);
......@@ -269,17 +270,14 @@ public class CollectionPage : CheckerboardPage {
scale = newScale;
foreach (LayoutItem item in get_items()) {
foreach (LayoutItem item in get_items())
((Thumbnail) item).resize(scale);
}
refresh();
schedule_thumbnail_improval();
}
private bool reschedule_improval = false;
private void schedule_thumbnail_improval() {
// don't bother if not in view
if (!in_view)
......@@ -303,9 +301,8 @@ public class CollectionPage : CheckerboardPage {
foreach (LayoutItem item in get_items()) {
Thumbnail thumbnail = (Thumbnail) item;
if (thumbnail.is_exposed()) {
if (thumbnail.is_exposed())
thumbnail.paint_high_quality();
}
}
improval_scheduled = false;
......@@ -345,74 +342,38 @@ public class CollectionPage : CheckerboardPage {
}
private void on_remove() {
// iterate over selected and remove them from cache and databases
foreach (LayoutItem item in get_selected()) {
Thumbnail thumbnail = (Thumbnail) item;
AppWindow.get_instance().remove_photo(thumbnail.get_photo_id(), this);
}
// iterate over selected photos and remove them from entire system .. this will result
// in them being removed from this view in on_photo_removed
foreach (LayoutItem item in get_selected())
((Thumbnail) item).get_photo().remove();
remove_selected();
refresh();
}
private delegate Exif.Orientation RotationFunc(Exif.Orientation orientation);
private void do_rotations(string desc, Gee.Iterable<LayoutItem> c, RotationFunc rotate) {
bool rotationPerformed = false;
private void do_rotations(Gee.Iterable<LayoutItem> c, Photo.Rotation rotation) {
bool rotation_performed = false;
foreach (LayoutItem item in c) {
Thumbnail thumbnail = (Thumbnail) item;
File file = thumbnail.get_file();
PhotoExif exif = PhotoExif.create(file);
Photo photo = ((Thumbnail) item).get_photo();
photo.rotate(rotation);
Exif.Orientation orientation = exif.get_orientation();
Exif.Orientation rotated = rotate(orientation);
debug("Rotating %s %s from %s to %s", desc, thumbnail.get_file().get_path(),
orientation.get_description(), rotated.get_description());
// update file itself
exif.set_orientation(rotated);
// TODO: Write this in background
try {
exif.commit();
} catch (Error err) {
error("%s", err.message);
}
// update database
photo_table.set_orientation(thumbnail.get_photo_id(), rotated);
// update everyone who cares (including this thumbnail)
AppWindow.get_instance().report_backing_changed(thumbnail.get_photo_id());
rotationPerformed = true;
rotation_performed = true;
}
if (rotationPerformed) {
schedule_thumbnail_improval();
// geometry could've changed
if (rotation_performed)
refresh();
}
}
private void on_rotate_clockwise() {
do_rotations("clockwise", get_selected(), (orientation) => {
return orientation.rotate_clockwise();
});
do_rotations(get_selected(), Photo.Rotation.CLOCKWISE);
}
private void on_rotate_counterclockwise() {
do_rotations("counterclockwise", get_selected(), (orientation) => {
return orientation.rotate_counterclockwise();
});
do_rotations(get_selected(), Photo.Rotation.COUNTERCLOCKWISE);
}
private void on_mirror() {
do_rotations("mirror", get_selected(), (orientation) => {
return orientation.flip_left_to_right();
});
do_rotations(get_selected(), Photo.Rotation.MIRROR);
}
private void on_view_menu() {
......@@ -428,9 +389,8 @@ public class CollectionPage : CheckerboardPage {
private void on_display_titles(Gtk.Action action) {
bool display = ((Gtk.ToggleAction) action).get_active();
foreach (LayoutItem item in get_items()) {
foreach (LayoutItem item in get_items())
item.display_title(display);
}
refresh();
}
......@@ -456,19 +416,17 @@ public class CollectionPage : CheckerboardPage {
}
private override bool on_ctrl_pressed(Gdk.EventKey event) {
rotateButton.set_stock_id(STOCK_COUNTERCLOCKWISE);
rotateButton.label = "Rotate Counterclockwise";
rotateButton.clicked -= on_rotate_clockwise;
rotateButton.clicked += on_rotate_counterclockwise;
rotate_button.set_stock_id(STOCK_COUNTERCLOCKWISE);
rotate_button.clicked -= on_rotate_clockwise;
rotate_button.clicked += on_rotate_counterclockwise;
return false;
}
private override bool on_ctrl_released(Gdk.EventKey event) {
rotateButton.set_stock_id(STOCK_CLOCKWISE);
rotateButton.label = "Rotate Clockwise";
rotateButton.clicked -= on_rotate_counterclockwise;
rotateButton.clicked += on_rotate_clockwise;
rotate_button.set_stock_id(STOCK_CLOCKWISE);
rotate_button.clicked -= on_rotate_counterclockwise;
rotate_button.clicked += on_rotate_clockwise;
return false;
}
......@@ -511,13 +469,13 @@ public class CollectionPage : CheckerboardPage {
private class CompareDate : Comparator<LayoutItem> {
public override int64 compare(LayoutItem a, LayoutItem b) {
return ((Thumbnail) a).get_exposure_time() - ((Thumbnail) b).get_exposure_time();
return ((Thumbnail) a).get_photo().get_exposure_time() - ((Thumbnail) b).get_photo().get_exposure_time();
}
}
private class ReverseCompareDate : Comparator<LayoutItem> {
public override int64 compare(LayoutItem a, LayoutItem b) {
return ((Thumbnail) b).get_exposure_time() - ((Thumbnail) a).get_exposure_time();
return ((Thumbnail) b).get_photo().get_exposure_time() - ((Thumbnail) a).get_photo().get_exposure_time();
}
}
......
......@@ -90,7 +90,8 @@ public class PhotoTable : DatabaseTable {
+ "exposure_time INTEGER, "
+ "orientation INTEGER, "
+ "import_id INTEGER, "
+ "event_id INTEGER"
+ "event_id INTEGER, "
+ "transformations TEXT"
+ ")", -1, out stmt);
assert(res == Sqlite.OK);
......@@ -346,15 +347,15 @@ public class PhotoTable : DatabaseTable {
assert(res == Sqlite.OK);
res = stmt.step();
if (res == Sqlite.ROW) {
if (res != Sqlite.ROW) {
if (res != Sqlite.DONE) {
fatal("get_dimensions", res);
}
return Dimensions(stmt.column_int(0), stmt.column_int(1));
return null;
}
return null;
return Dimensions(stmt.column_int(0), stmt.column_int(1));
}
public Exif.Orientation get_orientation(PhotoID photo_id) {
......@@ -461,6 +462,128 @@ public class PhotoTable : DatabaseTable {
return true;
}
private string? get_raw_transformations(PhotoID photo_id) {
Sqlite.Statement stmt;
int res = db.prepare_v2("SELECT transformations FROM PhotoTable WHERE id=?", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_int64(1, photo_id.id);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.ROW) {
if (res != Sqlite.DONE) {
fatal("get_raw_transformations", res);
}
return null;
}
return stmt.column_text(0);
}
private bool set_raw_transformations(PhotoID photo_id, string trans) {
Sqlite.Statement stmt;
int res = db.prepare_v2("UPDATE PhotoTable SET transformations = ? WHERE id = ?", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_text(1, trans);
assert(res == Sqlite.OK);
res = stmt.bind_int64(2, photo_id.id);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE) {
fatal("set_raw_transformations", res);
return false;
}
return true;
}
public KeyValueMap? get_transformation(PhotoID photo_id, string object) {
string trans = get_raw_transformations(photo_id);
if (trans == null)
return null;
try {
KeyFile keyfile = new KeyFile();
if (!keyfile.load_from_data(trans, trans.length, KeyFileFlags.NONE))
return null;
string[] keys = keyfile.get_keys(object);
if (keys == null || keys.length == 0)
return null;
KeyValueMap map = new KeyValueMap(object);
foreach (string key in keys)
map.set_string(key, keyfile.get_string(object, key));
return map;
} catch (Error err) {
error("%s", err.message);
return null;
}
}
public bool set_transformation(PhotoID photo_id, KeyValueMap map) {
string trans = get_raw_transformations(photo_id);
try {
KeyFile keyfile = new KeyFile();
if (trans != null) {
if (!keyfile.load_from_data(trans, trans.length, KeyFileFlags.NONE))
return false;
}
Gee.Set<string> keys = map.get_keys();
foreach (string key in keys) {
string value = map.get_string(key, null);
assert(value != null);
keyfile.set_string(map.get_group(), key, value);
}
int length;
trans = keyfile.to_data(out length);
assert(trans != null);
assert(trans.length > 0);
} catch (Error err) {
error("%s", err.message);
return false;
}
return set_raw_transformations(photo_id, trans);
}
public bool remove_transformation(PhotoID photo_id, string object) {
string trans = get_raw_transformations(photo_id);
if (trans == null || trans.length == 0)
return true;
try {
KeyFile keyfile = new KeyFile();
if (!keyfile.load_from_data(trans, trans.length, KeyFileFlags.NONE))
return false;
keyfile.remove_group(object);
int length;