Commit bf66ffeb authored by Jim Nelson's avatar Jim Nelson

Added adjustable thumbnail sizing, however, need to add UI slider for

ticket to be marked off.  Implemented a persistent thumbnail cache.  
Began work to have thumbnail objects on-screen load and unload their 
pixbufs dynamically.
parent b09360ae
......@@ -17,6 +17,10 @@ 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 },
{ "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 },
{ "Help", null, "_Help", null, null, null },
{ "About", Gtk.STOCK_ABOUT, "_About", null, "About this application", on_about }
};
......@@ -52,6 +56,8 @@ public class AppWindow : Gtk.Window {
private PhotoTable photoTable = null;
construct {
ThumbnailCache.set_app_data_dir(get_exec_dir().get_child("data"));
// set up display
title = TITLE;
set_default_size(800, 600);
......@@ -100,7 +106,7 @@ public class AppWindow : Gtk.Window {
return;
}
}
photoTable = new PhotoTable(db);
collectionPage = new CollectionPage(db);
......@@ -126,7 +132,9 @@ public class AppWindow : Gtk.Window {
message("Importing file %s", file.get_path());
if (photoTable.add_photo(file)) {
collectionPage.add_photo(file);
int id = photoTable.get_photo_id(file);
ThumbnailCache.big.import(id, file);
collectionPage.add_photo(id, file);
} else {
Gtk.MessageDialog dialog = new Gtk.MessageDialog(this, Gtk.DialogFlags.MODAL,
Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, "%s already stored",
......@@ -168,7 +176,9 @@ public class AppWindow : Gtk.Window {
if (type == FileType.REGULAR) {
message("Importing file %s", file.get_path());
if (photoTable.add_photo(file)) {
collectionPage.add_photo(file);
int id = photoTable.get_photo_id(file);
ThumbnailCache.big.import(id, file);
collectionPage.add_photo(id, file);
} else {
// TODO: Better error reporting
}
......@@ -243,7 +253,7 @@ public class AppWindow : Gtk.Window {
return false;
}
private void set_item_sensitive(string path, bool sensitive) {
Gtk.Widget widget = uiManager.get_widget(path);
widget.set_sensitive(sensitive);
......@@ -259,14 +269,26 @@ public class AppWindow : Gtk.Window {
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());
}
collectionPage.repack();
}
private void on_select_all() {
collectionPage.select_all();
}
private void on_photos_menu() {
}
private void on_increase_size() {
collectionPage.increase_thumb_size();
}
private void on_decrease_size() {
collectionPage.decrease_thumb_size();
}
}
......@@ -14,11 +14,12 @@ public class CollectionPage : Gtk.ScrolledWindow {
private PhotoTable photoTable = null;
private Gtk.Viewport viewport = new Gtk.Viewport(null, null);
private Gtk.Table layoutTable = new Gtk.Table(0, 0, false);
private List<Thumbnail> thumbnailList = new List<Thumbnail>();
private Gee.ArrayList<Thumbnail> thumbnailList = new Gee.ArrayList<Thumbnail>();
private int currentX = 0;
private int currentY = 0;
private int cols = 0;
private int thumbCount = 0;
private int scale = Thumbnail.DEFAULT_SCALE;
construct {
// scrollbar policy
......@@ -43,6 +44,12 @@ public class CollectionPage : Gtk.ScrolledWindow {
// and responds properly to resizing
viewport.realize += on_viewport_realized;
Gtk.Scrollbar hscroll = (Gtk.Scrollbar) get_hscrollbar();
Gtk.Scrollbar vscroll = (Gtk.Scrollbar) get_vscrollbar();
hscroll.value_changed += check_exposure;
vscroll.value_changed += check_exposure;
add(viewport);
}
......@@ -52,10 +59,10 @@ public class CollectionPage : Gtk.ScrolledWindow {
photoTable = new PhotoTable(db);
}
public void add_photo(File file) {
Thumbnail thumbnail = new Thumbnail(file);
public void add_photo(int id, File file) {
Thumbnail thumbnail = new Thumbnail(id, file, scale);
thumbnailList.append(thumbnail);
thumbnailList.add(thumbnail);
thumbCount++;
attach_thumbnail(thumbnail);
......@@ -74,13 +81,13 @@ public class CollectionPage : Gtk.ScrolledWindow {
public void repack() {
int rows = (thumbCount / cols) + 1;
message("repack() rows=%d cols=%d", rows, cols);
message("repack() scale=%d thumbCount=%d rows=%d cols=%d", scale, thumbCount, rows, cols);
layoutTable.resize(rows, cols);
currentX = 0;
currentY = 0;
foreach (Thumbnail thumbnail in thumbnailList) {
layoutTable.remove(thumbnail);
attach_thumbnail(thumbnail);
......@@ -100,11 +107,13 @@ public class CollectionPage : Gtk.ScrolledWindow {
}
private void on_resize(Gtk.Viewport v, Gdk.Rectangle allocation) {
int newCols = allocation.width / (Thumbnail.THUMB_WIDTH + THUMB_X_PADDING + THUMB_X_PADDING);
int newCols = allocation.width / (Thumbnail.get_max_width(scale) + (THUMB_X_PADDING * 2));
if (newCols < 1)
newCols = 1;
if (cols != newCols) {
message("width:%d cols:%d", allocation.width, newCols);
cols = newCols;
repack();
}
......@@ -166,11 +175,66 @@ public class CollectionPage : Gtk.ScrolledWindow {
return count;
}
public void increase_thumb_size() {
if (scale == Thumbnail.MAX_SCALE)
return;
scale += Thumbnail.SCALE_STEPPING;
foreach (Thumbnail thumbnail in thumbnailList) {
thumbnail.resize(scale);
}
layoutTable.resize_children();
}
public void decrease_thumb_size() {
if (scale == Thumbnail.MIN_SCALE)
return;
scale -= Thumbnail.SCALE_STEPPING;
foreach (Thumbnail thumbnail in thumbnailList) {
thumbnail.resize(scale);
}
layoutTable.resize_children();
}
private void on_viewport_realized() {
File[] photoFiles = photoTable.get_photo_files();
foreach (File file in photoFiles) {
add_photo(file);
int id = photoTable.get_photo_id(file);
ThumbnailCache.big.import(id, file);
add_photo(id, file);
}
check_exposure();
}
private void check_exposure() {
Gdk.Rectangle viewrect = Gdk.Rectangle();
viewrect.x = (int) viewport.get_hadjustment().get_value();
viewrect.y = (int) viewport.get_vadjustment().get_value();
viewrect.width = viewport.allocation.width;
viewrect.height = viewport.allocation.height;
Gdk.Rectangle thumbrect = Gdk.Rectangle();
Gdk.Rectangle bitbucket = Gdk.Rectangle();
foreach (Thumbnail thumbnail in thumbnailList) {
Gtk.Allocation alloc = thumbnail.get_exposure();
thumbrect.x = alloc.x;
thumbrect.y = alloc.y;
thumbrect.width = alloc.width;
thumbrect.height = alloc.height;
if (viewrect.intersect(thumbrect, bitbucket)) {
thumbnail.exposed();
} else {
thumbnail.unexposed();
}
}
}
}
......
......@@ -8,11 +8,14 @@ SRC_FILES = \
AppWindow.vala \
CollectionPage.vala \
Thumbnail.vala \
PhotoTable.vala
PhotoTable.vala \
ThumbnailCache.vala \
image_util.vala
PKGS = \
gtk+-2.0 \
sqlite3
sqlite3 \
vala-1.0
all: $(TARGET)
......
......@@ -44,7 +44,7 @@ public class PhotoTable {
return false;
}
return true;
}
......@@ -70,12 +70,16 @@ public class PhotoTable {
}
public bool is_photo_stored(File file) {
return (get_photo_id(file) != 0);
}
public int get_photo_id(File file) {
Sqlite.Statement stmt;
int res = db.prepare_v2("SELECT ID FROM PhotoTable WHERE filename=?", -1, out stmt);
if (res != Sqlite.OK) {
error("preparing select stmt: %s [%d]", db.errmsg(), res);
return false;
return 0;
}
stmt.bind_text(1, file.get_path());
......@@ -84,10 +88,10 @@ public class PhotoTable {
if (res != Sqlite.DONE && res != Sqlite.ROW) {
error("is_photo_stored: %s [%d]", db.errmsg(), res);
return false;
return 0;
}
return (stmt.column_count() > 0);
return stmt.column_int(0);
}
public File[] get_photo_files() {
......
public class Thumbnail : Gtk.Alignment {
public static const int THUMB_WIDTH = 128;
public static const int THUMB_HEIGHT = 128;
public static const int LABEL_PADDING = 4;
public static const int FRAME_PADDING = 4;
public static const string TEXT_COLOR = "#FFF";
public static const string SELECTED_COLOR = "#FF0";
public static const string UNSELECTED_COLOR = "#FFF";
public static const int MIN_SCALE = 64;
public static const int MAX_SCALE = 360;
public static const int DEFAULT_SCALE = 128;
public static const int SCALE_STEPPING = 4;
public static const Gdk.InterpType DEFAULT_INTERP = Gdk.InterpType.NEAREST;
// Due to the potential for thousands or tens of thousands of thumbnails being present in a
// particular view, all widgets used here should be NOWINDOW widgets.
private File file = null;
private Gtk.Image image = null;
private int id;
private File file;
private int scale;
private Gtk.Image image = new Gtk.Image();
private Gtk.Label title = null;
private Gtk.Frame frame = null;
private bool selected = false;
......@@ -18,31 +25,23 @@ public class Thumbnail : Gtk.Alignment {
construct {
}
public Thumbnail(File file) {
public Thumbnail(int id, File file, int scale = DEFAULT_SCALE) {
this.id = id;
this.file = file;
this.scale = scale;
// bottom-align everything
set(0, 1, 0, 0);
Gdk.Pixbuf pixbuf = null;
try {
pixbuf = new Gdk.Pixbuf.from_file(file.get_path());
} catch (Error err) {
error("Error loading image: %s", err.message);
return;
}
Gdk.Pixbuf cached = ThumbnailCache.big.fetch(id);
image.set_from_pixbuf(scale_pixbuf(file, cached, scale, DEFAULT_INTERP));
pixbuf = scale(pixbuf, THUMB_WIDTH, THUMB_HEIGHT);
image = new Gtk.Image.from_pixbuf(pixbuf);
title = new Gtk.Label(file.get_basename());
title.set_use_underline(false);
title.modify_fg(Gtk.StateType.NORMAL, parse_color(TEXT_COLOR));
Gtk.VBox vbox = new Gtk.VBox(false, 0);
vbox.set_border_width(4);
vbox.set_border_width(FRAME_PADDING);
vbox.pack_start(image, false, false, 0);
vbox.pack_end(title, false, false, LABEL_PADDING);
......@@ -54,31 +53,19 @@ public class Thumbnail : Gtk.Alignment {
add(frame);
}
public Gdk.Pixbuf scale(Gdk.Pixbuf pixbuf, int maxWidth, int maxHeight) {
int width = pixbuf.get_width();
int height = pixbuf.get_height();
int diffWidth = width - maxWidth;
int diffHeight = height - maxHeight;
double ratio = 0.0;
if (diffWidth > diffHeight) {
ratio = (double) maxWidth / (double) width;
} else {
ratio = (double) maxHeight / (double) height;
}
int newWidth = (int) ((double) width * ratio);
int newHeight = (int) ((double) height * ratio);
message("%s %d x %d * %lf%% -> %d x %d", file.get_path(), width, height, ratio, newWidth, newHeight);
return pixbuf.scale_simple(newWidth, newHeight, Gdk.InterpType.NEAREST);
public static int get_max_width(int scale) {
// TODO: Be more precise about this ... the magic 32 at the end is merely a dart on the board
// for accounting for extra pixels used by the frame
return scale + (FRAME_PADDING * 2) + 32;
}
public File get_file() {
return file;
}
public Gtk.Allocation get_exposure() {
return image.allocation;
}
public void select() {
selected = true;
......@@ -107,5 +94,25 @@ public class Thumbnail : Gtk.Alignment {
public bool is_selected() {
return selected;
}
public void resize(int newScale) {
assert((newScale >= MIN_SCALE) && (newScale <= MAX_SCALE));
if (scale == newScale)
return;
Gdk.Pixbuf cached = ThumbnailCache.big.fetch(id);
scale = newScale;
image.set_from_pixbuf(scale_pixbuf(file, cached, scale, DEFAULT_INTERP));
}
public void exposed() {
message("Exposed %s", file.get_path());
}
public void unexposed() {
message("Unexposed %s", file.get_path());
}
}
public class ThumbnailCache {
public static ThumbnailCache big = null;
public static void set_app_data_dir(File appDataDir) {
big = new ThumbnailCache(appDataDir, 360);
}
private File cacheDir;
private int scale;
private Gdk.InterpType interp;
private string jpegQuality;
private Gee.HashMap<int, Gdk.Pixbuf> pixbufMap = new Gee.HashMap<int, Gdk.Pixbuf>(direct_hash,
direct_equal, direct_equal);
private ThumbnailCache(File appDataDir, int scale, Gdk.InterpType interp = Gdk.InterpType.NEAREST,
int jpegQuality = 90) {
assert(scale != 0);
assert((jpegQuality >= 0) && (jpegQuality <= 100));
this.cacheDir = appDataDir.get_child("thumbs").get_child("thumbs%d".printf(scale));
this.scale = scale;
this.interp = interp;
this.jpegQuality = "%d".printf(jpegQuality);
try {
if (this.cacheDir.query_exists(null) == false) {
if (this.cacheDir.make_directory_with_parents(null) == false) {
error("Unable to create cache dir %s", this.cacheDir.get_path());
}
}
} catch (Error err) {
error("%s", err.message);
}
}
public Gdk.Pixbuf? fetch(int id) {
Gdk.Pixbuf thumbnail = null;
if (pixbufMap.contains(id)) {
thumbnail = pixbufMap.get(id);
if (thumbnail != null) {
return thumbnail;
}
}
File cached = get_cached_file(id);
message("Loading from disk %d %s", id, cached.get_path());
try {
thumbnail = new Gdk.Pixbuf.from_file(cached.get_path());
pixbufMap.set(id, thumbnail);
} catch (Error err) {
error("%s", err.message);
}
return thumbnail;
}
public bool import(int id, File file, bool force = false) {
File cached = get_cached_file(id);
message("Importing %d %s", id, cached.get_path());
// if not forcing the cache operation, check if file exists before performing
if (!force) {
if (cached.query_exists(null))
return true;
}
// load full-scale photo and convert to pixbuf
Gdk.Pixbuf original;
try {
original = new Gdk.Pixbuf.from_file(file.get_path());
} catch (Error err) {
error("Error loading image %s: %s", file.get_path(), err.message);
return false;
}
// scale according to cache's parameters
Gdk.Pixbuf thumbnail = scale_pixbuf(file, original, scale, interp);
// save scaled image as JPEG
try {
if (thumbnail.save(cached.get_path(), "jpeg", "quality", jpegQuality) == false) {
error("Unable to save thumbnail %s", cached.get_path());
return false;
}
} catch (Error err) {
error("Error saving thumbnail to %s: %s", cached.get_path(), err.message);
return false;
}
return true;
}
public void remove(int id) {
File cached = get_cached_file(id);
message("Removing %d %s", id, cached.get_path());
try {
if (cached.delete(null) == false) {
error("Unable to delete cached thumb %s", cached.get_path());
}
} catch (Error err) {
error("Error deleting cached thumb: %s", err.message);
}
}
private File get_cached_file(int id) {
return cacheDir.get_child("thumb%08x.jpg".printf(id));
}
}
static const bool DEBUG = false;
Gdk.Pixbuf scale_pixbuf(File file, Gdk.Pixbuf pixbuf, int scale, Gdk.InterpType interp) {
int width = pixbuf.get_width();
int height = pixbuf.get_height();
int diffWidth = width - scale;
int diffHeight = height - scale;
int newWidth = 0;
int newHeight = 0;
if (diffWidth == diffHeight) {
// square image -- unlikely -- but this is the easy case
newWidth = scale;
newHeight = scale;
} else if (diffWidth <= 0) {
if (diffHeight <= 0) {
// if both dimensions are less than the scaled size, return image as-is
return pixbuf;
}
// height needs to be scaled down, so it determines the ratio
double ratio = (double) scale / (double) height;
newWidth = (int) Math.round((double) width * ratio);
newHeight = scale;
} else if (diffHeight <= 0) {
// already know that width is greater than scale, so width determines the ratio
newWidth = scale;
double ratio = (double) scale / (double) width;
newHeight = (int) Math.round((double) height * ratio);
} else if (diffWidth > diffHeight) {
// width is greater, so it's the determining factor
newWidth = scale;
double ratio = (double) scale / (double) width;
newHeight = (int) Math.round((double) height * ratio);
} else {
// height is the determining factor
double ratio = (double) scale / (double) height;
newWidth = (int) Math.round((double) width * ratio);
newHeight = scale;
}
if (DEBUG)
message("%s %d x %d -> %d x %d", file.get_path(), width, height, newWidth, newHeight);
return pixbuf.scale_simple(newWidth, newHeight, interp);
}
......@@ -10,6 +10,11 @@
<menuitem name="EditRemove" action="Remove" />
</menu>
<menu name="PhotosMenu" action="Photos">
<menuitem name="PhotosIncreaseSize" action="IncreaseSize" />
<menuitem name="PhotosDecreaseSize" action="DecreaseSize" />
</menu>
<menu name="HelpMenu" action="Help">
<menuitem name="HelpAbout" action="About" />
</menu>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment