Commit b2636cea authored by Jim Nelson's avatar Jim Nelson

#85: Added right-click/Remove on thumbnail. #87: Photo scaling is

improved by generating higher-quality thumbnails to disk and better 
in-memory scaling.  #90 and #91: Detailed in Wiki, implemented 
throughout.
parent bb471325
......@@ -5,8 +5,8 @@ public class AppWindow : Gtk.Window {
public static const string DATA_DIR = ".photo";
private static AppWindow mainWindow = null;
private static Gtk.UIManager uiManager = null;
private static string[] args = null;
private static Sqlite.Database db = null;
// TODO: Mark fields for translation
private const Gtk.ActionEntry[] ACTIONS = {
......@@ -17,7 +17,7 @@ public class AppWindow : Gtk.Window {
{ "SelectAll", Gtk.STOCK_SELECT_ALL, "Select _All", "<Ctrl>A", "Select all the photos in the library", on_select_all },
{ "Remove", Gtk.STOCK_DELETE, "_Remove", "Delete", "Remove the selected photos from the library", on_remove },
{ "Photos", null, "_Photos", null, null, on_photos_menu },
{ "Photos", null, "_Photos", null, null, null },
{ "IncreaseSize", Gtk.STOCK_ZOOM_IN, "Zoom _in", "KP_Add", "Increase the magnification of the thumbnails", on_increase_size },
{ "DecreaseSize", Gtk.STOCK_ZOOM_OUT, "Zoom _out", "KP_Subtract", "Decrease the magnification of the thumbnails", on_decrease_size },
......@@ -44,20 +44,26 @@ public class AppWindow : Gtk.Window {
error("%s", err.message);
}
File dbFile = get_data_subdir("data").get_child("photo.db");
int res = Sqlite.Database.open_v2(dbFile.get_path(), out db,
Sqlite.OPEN_READWRITE | Sqlite.OPEN_CREATE, null);
if (res != Sqlite.OK) {
error("Unable to open/create photo database %s: %d", dbFile.get_path(), res);
}
uiManager = new Gtk.UIManager();
File uiFile = get_exec_dir().get_child("photo.ui");
assert(uiFile != null);
ThumbnailCache.init();
try {
uiManager.add_ui_from_file(uiFile.get_path());
} catch (GLib.Error gle) {
error("Error loading UI: %s", gle.message);
}
}
public static AppWindow get_main_window() {
return mainWindow;
}
public static Gtk.UIManager get_ui_manager() {
return uiManager;
}
public static string[] get_commandline_args() {
return args;
}
......@@ -93,14 +99,9 @@ public class AppWindow : Gtk.Window {
return subdir;
}
public static unowned Sqlite.Database get_db() {
return db;
}
private Gtk.UIManager uiManager = new Gtk.UIManager();
private CollectionPage collectionPage = null;
private PhotoTable photoTable = null;
construct {
// set up display
title = TITLE;
......@@ -114,20 +115,51 @@ public class AppWindow : Gtk.Window {
uiManager.insert_action_group(actionGroup, 0);
GLib.File uiFile = get_exec_dir().get_child("photo.ui");
assert(uiFile != null);
try {
uiManager.add_ui_from_file(uiFile.get_path());
} catch (GLib.Error gle) {
// TODO: Exit app immediately
error("Error loading UI: %s", gle.message);
}
// primary widgets
Gtk.MenuBar menubar = (Gtk.MenuBar) uiManager.get_widget("/MenuBar");
add_accel_group(uiManager.get_accel_group());
Gtk.TreeStore pageTreeStore = new Gtk.TreeStore(1, typeof(string));
Gtk.TreeView pageTreeView = new Gtk.TreeView.with_model(pageTreeStore);
pageTreeView.modify_bg(Gtk.StateType.NORMAL, parse_color(CollectionPage.BG_COLOR));
var text = new Gtk.CellRendererText();
text.size_points = 9.0;
var column = new Gtk.TreeViewColumn();
column.pack_start(text, true);
column.add_attribute(text, "text", 0);
pageTreeView.append_column(column);
pageTreeView.set_headers_visible(false);
Gtk.TreeIter parent, child;
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, "Photos");
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, "Events");
pageTreeStore.append(out child, parent);
pageTreeStore.set(child, 0, "New Year's");
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, "Albums");
pageTreeStore.append(out child, parent);
pageTreeStore.set(child, 0, "Parties");
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, null);
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, "Import");
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, "Recent");
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, "Trash");
// set up as a drag-and-drop destination
// this.drag_data_received() is called when a drop occurs
Gtk.drag_dest_set(this, Gtk.DestDefaults.ALL, TARGET_ENTRIES, Gdk.DragAction.COPY);
......@@ -137,15 +169,19 @@ public class AppWindow : Gtk.Window {
mainWindow = this;
}
button_press_event += on_button_press;
photoTable = new PhotoTable();
collectionPage = new CollectionPage();
// layout the selection tree and the view side-by-side
Gtk.HBox hbox = new Gtk.HBox(false, 0);
hbox.pack_start(pageTreeView, false, false, 0);
hbox.pack_end(collectionPage, true, true, 0);
// layout widgets in vertical box
// layout everything vertically inside the main window
Gtk.VBox vbox = new Gtk.VBox(false, 0);
vbox.pack_start(menubar, false, false, 0);
vbox.pack_end(collectionPage, true, true, 0);
vbox.pack_end(hbox, true, true, 0);
add(vbox);
}
......@@ -161,12 +197,12 @@ public class AppWindow : Gtk.Window {
private void import(File file) {
FileType type = file.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
if(type == FileType.REGULAR) {
message("Importing file %s", file.get_path());
debug("Importing file %s", file.get_path());
if (photoTable.add_photo(file)) {
int id = photoTable.get_photo_id(file);
ThumbnailCache.big.import(id, file);
collectionPage.add_photo(id, file);
if (photoTable.add(file)) {
PhotoID photoID = photoTable.get_id(file);
ThumbnailCache.big.import(photoID, file);
collectionPage.add_photo(photoID, file);
} else {
Gtk.MessageDialog dialog = new Gtk.MessageDialog(this, Gtk.DialogFlags.MODAL,
Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, "%s already stored",
......@@ -177,12 +213,12 @@ public class AppWindow : Gtk.Window {
return;
} else if (type != FileType.DIRECTORY) {
message("Skipping file %s (neither a directory nor a file)", file.get_path());
debug("Skipping file %s (neither a directory nor a file)", file.get_path());
return;
}
message("Importing directory %s", file.get_path());
debug("Importing directory %s", file.get_path());
import_dir(file);
}
......@@ -206,19 +242,21 @@ public class AppWindow : Gtk.Window {
FileType type = info.get_file_type();
if (type == FileType.REGULAR) {
message("Importing file %s", file.get_path());
if (photoTable.add_photo(file)) {
int id = photoTable.get_photo_id(file);
ThumbnailCache.big.import(id, file);
collectionPage.add_photo(id, file);
debug("Importing file %s", file.get_path());
if (photoTable.add(file)) {
PhotoID photoID = photoTable.get_id(file);
ThumbnailCache.big.import(photoID, file);
collectionPage.add_photo(photoID, file);
} else {
// TODO: Better error reporting
}
} else if (type == FileType.DIRECTORY) {
message("Importing directory %s", file.get_path());
debug("Importing directory %s", file.get_path());
import_dir(file);
} else {
message("Skipped %s", file.get_path());
debug("Skipped %s", file.get_path());
}
}
} catch (Error err) {
......@@ -239,53 +277,6 @@ public class AppWindow : Gtk.Window {
}
}
private bool on_button_press(AppWindow aw, Gdk.EventButton event) {
// don't handle anything but primary button for now
if (event.button != 1) {
return false;
}
// only interested in single-clicks presses for now
if (event.type != Gdk.EventType.BUTTON_PRESS) {
return false;
}
// mask out the modifiers we're interested in
uint state = event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK);
Thumbnail thumbnail = collectionPage.get_thumbnail_at(event.x, event.y);
if (thumbnail != null) {
message("clicked on %s", thumbnail.get_file().get_basename());
switch (state) {
case Gdk.ModifierType.CONTROL_MASK: {
// with only Ctrl pressed, multiple selections are possible ... chosen item
// is toggled
thumbnail.toggle_select();
} break;
case Gdk.ModifierType.SHIFT_MASK: {
// TODO
} break;
case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK: {
// TODO
} break;
default: {
// a "raw" click deselects all thumbnails and selects the single chosen
collectionPage.unselect_all();
thumbnail.select();
} break;
}
} else {
// user clicked on "dead" area
collectionPage.unselect_all();
}
return false;
}
private void set_item_sensitive(string path, bool sensitive) {
Gtk.Widget widget = uiManager.get_widget(path);
widget.set_sensitive(sensitive);
......@@ -299,10 +290,9 @@ public class AppWindow : Gtk.Window {
private void on_remove() {
Thumbnail[] thumbnails = collectionPage.get_selected();
foreach (Thumbnail thumbnail in thumbnails) {
message("Removing %s", thumbnail.get_file().get_basename());
collectionPage.remove_photo(thumbnail);
ThumbnailCache.big.remove(photoTable.get_photo_id(thumbnail.get_file()));
photoTable.remove_photo(thumbnail.get_file());
ThumbnailCache.big.remove(photoTable.get_id(thumbnail.get_file()));
photoTable.remove(thumbnail.get_file());
}
collectionPage.repack();
......@@ -312,9 +302,6 @@ public class AppWindow : Gtk.Window {
collectionPage.select_all();
}
private void on_photos_menu() {
}
private void on_increase_size() {
collectionPage.increase_thumb_size();
}
......
Gdk.Color parse_color(string color) {
Gdk.Color c;
if (!Gdk.Color.parse(color, out c))
error("can't parse color");
return c;
}
public class CollectionPage : Gtk.ScrolledWindow {
public static const int THUMB_X_PADDING = 20;
......@@ -20,6 +14,11 @@ public class CollectionPage : Gtk.ScrolledWindow {
private int thumbCount = 0;
private int scale = Thumbnail.DEFAULT_SCALE;
// TODO: Mark fields for translation
private const Gtk.ActionEntry[] RIGHT_CLICK_ACTIONS = {
{ "Remove", Gtk.STOCK_DELETE, "_Remove", "Delete", "Remove the selected photos from the library", on_remove }
};
construct {
// scrollbar policy
set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
......@@ -27,6 +26,10 @@ public class CollectionPage : Gtk.ScrolledWindow {
// set table column and row padding ... this is done globally rather than per-thumbnail
layoutTable.set_col_spacings(THUMB_X_PADDING);
layoutTable.set_row_spacings(THUMB_Y_PADDING);
Gtk.ActionGroup actionGroup = new Gtk.ActionGroup("CollectionPageActionGroup");
actionGroup.add_actions(RIGHT_CLICK_ACTIONS, this);
AppWindow.get_ui_manager().insert_action_group(actionGroup, 0);
// need to manually build viewport to set its background color
viewport.add(layoutTable);
......@@ -49,14 +52,16 @@ public class CollectionPage : Gtk.ScrolledWindow {
viewport.expose_event += on_viewport_exposed;
add(viewport);
button_press_event += on_click;
}
public CollectionPage() {
photoTable = new PhotoTable();
}
public void add_photo(int id, File file) {
Thumbnail thumbnail = new Thumbnail(id, file, scale);
public void add_photo(PhotoID photoID, File file) {
Thumbnail thumbnail = new Thumbnail(photoID, file, scale);
thumbnailList.add(thumbnail);
thumbCount++;
......@@ -77,7 +82,7 @@ public class CollectionPage : Gtk.ScrolledWindow {
public void repack() {
int rows = (thumbCount / cols) + 1;
message("repack() scale=%d thumbCount=%d rows=%d cols=%d", scale, thumbCount, rows, cols);
debug("repack() scale=%d thumbCount=%d rows=%d cols=%d", scale, thumbCount, rows, cols);
layoutTable.resize(rows, cols);
......@@ -108,7 +113,7 @@ public class CollectionPage : Gtk.ScrolledWindow {
newCols = 1;
if (cols != newCols) {
message("width:%d cols:%d", allocation.width, newCols);
debug("width:%d cols:%d", allocation.width, newCols);
cols = newCols;
repack();
......@@ -201,9 +206,8 @@ public class CollectionPage : Gtk.ScrolledWindow {
private void on_viewport_realized() {
File[] photoFiles = photoTable.get_photo_files();
foreach (File file in photoFiles) {
int id = photoTable.get_photo_id(file);
ThumbnailCache.big.import(id, file);
add_photo(id, file);
PhotoID photoID = photoTable.get_id(file);
add_photo(photoID, file);
}
}
......@@ -216,6 +220,95 @@ public class CollectionPage : Gtk.ScrolledWindow {
return false;
}
private bool on_click(CollectionPage c, Gdk.EventButton event) {
switch (event.button) {
case 1:
return on_left_click(event);
case 3:
return on_right_click(event);
default:
return false;
}
}
private bool on_left_click(Gdk.EventButton event) {
// only interested in single-clicks presses for now
if (event.type != Gdk.EventType.BUTTON_PRESS) {
return false;
}
// mask out the modifiers we're interested in
uint state = event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK);
Thumbnail thumbnail = get_thumbnail_at(event.x, event.y);
if (thumbnail != null) {
message("clicked on %s", thumbnail.get_file().get_basename());
switch (state) {
case Gdk.ModifierType.CONTROL_MASK: {
// with only Ctrl pressed, multiple selections are possible ... chosen item
// is toggled
thumbnail.toggle_select();
} break;
case Gdk.ModifierType.SHIFT_MASK: {
// TODO
} break;
case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK: {
// TODO
} break;
default: {
// a "raw" click deselects all thumbnails and selects the single chosen
unselect_all();
thumbnail.select();
} break;
}
} else {
// user clicked on "dead" area
unselect_all();
}
return false;
}
private bool on_right_click(Gdk.EventButton event) {
// only interested in single-clicks for now
if (event.type != Gdk.EventType.BUTTON_PRESS) {
return false;
}
Thumbnail thumbnail = get_thumbnail_at(event.x, event.y);
if (thumbnail != null) {
// this counts as a select
unselect_all();
thumbnail.select();
Gtk.Menu contextMenu = (Gtk.Menu) AppWindow.get_ui_manager().get_widget("/CollectionRightClickMenu");
contextMenu.popup(null, null, null, event.button, event.time);
return true;
} else {
// clicked on a "dead" area
}
return false;
}
private void on_remove() {
Thumbnail[] thumbnails = get_selected();
foreach (Thumbnail thumbnail in thumbnails) {
remove_photo(thumbnail);
ThumbnailCache.big.remove(photoTable.get_id(thumbnail.get_file()));
photoTable.remove(thumbnail.get_file());
}
repack();
}
private void check_exposure() {
Gdk.Rectangle viewrect = Gdk.Rectangle();
viewrect.x = (int) viewport.get_hadjustment().get_value();
......
public class DatabaseTable : Object {
protected static Sqlite.Database db;
// Doing this because static construct {} not working
public static void init() {
File dbFile = AppWindow.get_data_subdir("data").get_child("photo.db");
int res = Sqlite.Database.open_v2(dbFile.get_path(), out db,
Sqlite.OPEN_READWRITE | Sqlite.OPEN_CREATE, null);
if (res != Sqlite.OK) {
error("Unable to open/create photo database %s: %d", dbFile.get_path(), res);
}
}
// TODO: errmsg() is global, and so this will not be accurate in a threaded situation
protected static void fatal(string op, int res) {
error("%s: [%d] %s", op, res, db.errmsg());
}
// TODO: errmsg() is global, and so this will not be accurate in a threaded situation
protected static void warning(string op, int res) {
GLib.warning("%s: [%d] %s", op, res, db.errmsg());
}
}
public class PhotoID {
public int id;
public PhotoID(int id) {
this.id = id;
}
}
public class PhotoTable : DatabaseTable {
public PhotoTable() {
Sqlite.Statement stmt;
int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS PhotoTable ("
+ "id INTEGER PRIMARY KEY, "
+ "filename TEXT UNIQUE NOT NULL"
+ ")", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE) {
fatal("create photo table", res);
}
}
public bool add(File file) {
Sqlite.Statement stmt;
int res = db.prepare_v2("INSERT INTO PhotoTable (filename) VALUES (?)", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_text(1, file.get_path());
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE) {
if (res != Sqlite.CONSTRAINT)
fatal("add_photo", res);
return false;
}
return true;
}
public bool remove(File file) {
Sqlite.Statement stmt;
int res = db.prepare_v2("DELETE FROM PhotoTable WHERE filename=?", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_text(1, file.get_path());
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE) {
warning("remove_photo", res);
return false;
}
return true;
}
public bool is_photo_stored(File file) {
return (get_id(file) != null);
}
public PhotoID? get_id(File file) {
Sqlite.Statement stmt;
int res = db.prepare_v2("SELECT ID FROM PhotoTable WHERE filename=?", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_text(1, file.get_path());
assert(res == Sqlite.OK);
res = stmt.step();
if(res == Sqlite.ROW) {
return new PhotoID(stmt.column_int(0));
}
warning("get_photo_id", res);
return null;
}
public File[] get_photo_files() {
Sqlite.Statement stmt;
int res = db.prepare_v2("SELECT filename FROM PhotoTable", -1, out stmt);
assert(res == Sqlite.OK);
File[] photoFiles = new File[0];
for (;;) {
res = stmt.step();
if (res == Sqlite.DONE) {
break;
} else if (res != Sqlite.ROW) {
fatal("get_photo_files", res);
break;
}
photoFiles += File.new_for_path(stmt.column_text(0));
}
return photoFiles;
}
}
public class ThumbnailCacheTable : DatabaseTable {
private string tableName;
public ThumbnailCacheTable(int scale) {
assert(scale > 0);
this.tableName = "Thumb%dTable".printf(scale);
Sqlite.Statement stmt;
int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
+ tableName
+ "("
+ "id INTEGER PRIMARY KEY, "
+ "photo_id INTEGER UNIQUE, "
+ "width INTEGER, "
+ "height INTEGER, "
+ "filesize INTEGER"
+ ")", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE) {
fatal("create thumbnail cache table", res);
}
}
public bool remove(PhotoID photoID) {
Sqlite.Statement stmt;
int res = db.prepare_v2("DELETE FROM %s WHERE photo_id=?".printf(tableName), -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_int(1, photoID.id);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE) {
warning("remove photo", res);
return false;
}
return true;
}
public bool exists(PhotoID photoID) {
Sqlite.Statement stmt;
int res = db.prepare_v2("SELECT id FROM %s WHERE photo_id=?".printf(tableName), -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_int(1, photoID.id);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.ROW) {
if (res != Sqlite.DONE) {
fatal("exists", res);
}
return false;
}
return true;
}
public void add(PhotoID photoID, int filesize, Dimensions dim) {
Sqlite.Statement stmt;
int res = db.prepare_v2(
"INSERT INTO %s (photo_id, filesize, width, height) VALUES (?, ?, ?, ?)".printf(tableName),
-1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_int(1, photoID.id);
assert(res == Sqlite.OK);
res = stmt.bind_int(2, filesize);
assert(res == Sqlite.OK);
stmt.bind_int(3, dim.width);
assert(res == Sqlite.OK);
res = stmt.bind_int(4, dim.height);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE) {
fatal("add", res);
}
}
public Dimensions? get_dimensions(PhotoID photoID) {
Sqlite.Statement stmt;
int res = db.prepare_v2("SELECT width, height FROM %s WHERE photo_id=?".printf(tableName),
-1, out stmt);