Commit 2b7e1270 authored by Jim Nelson's avatar Jim Nelson

#57: Import photos from camera. Tons of refactoring to share layout and...

#57: Import photos from camera.  Tons of refactoring to share layout and functionality between multiple pages.
parent ae606325
......@@ -3,6 +3,7 @@ public class AppWindow : Gtk.Window {
public static const string TITLE = "Shotwell";
public static const string VERSION = "0.0.1";
public static const string DATA_DIR = ".photo";
public static const string PHOTOS_DIR = "Pictures";
public static Gdk.Color BG_COLOR = parse_color("#777");
......@@ -28,7 +29,7 @@ public class AppWindow : Gtk.Window {
} catch (Error err) {
error("%s", err.message);
}
uiManager = new Gtk.UIManager();
File uiFile = get_exec_dir().get_child("photo.ui");
......@@ -65,6 +66,10 @@ public class AppWindow : Gtk.Window {
return File.new_for_path(Environment.get_home_dir()).get_child(DATA_DIR);
}
public static File get_photos_dir() {
return File.new_for_path(Environment.get_home_dir()).get_child(PHOTOS_DIR);
}
public static File get_data_subdir(string name, string? subname = null) {
File subdir = get_data_dir().get_child(name);
if (subname != null) {
......@@ -86,9 +91,13 @@ public class AppWindow : Gtk.Window {
private Gtk.TreeStore pageTreeStore = null;
private Gtk.TreeView pageTreeView = null;
private Gtk.TreePath collectionPath = null;
private Gtk.TreePath importPath = null;
private CollectionPage collectionPage = null;
private PhotoPage photoPage = null;
private ImportPage importPage = null;
private PhotoPage importPreviewPage = null;
private PhotoTable photoTable = new PhotoTable();
......@@ -114,6 +123,8 @@ public class AppWindow : Gtk.Window {
Gtk.TreeIter parent, child;
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, "Photos");
collectionPath = pageTreeStore.get_path(parent);
pageTreeView.get_selection().select_path(collectionPath);
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, "Events");
......@@ -132,6 +143,7 @@ public class AppWindow : Gtk.Window {
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, "Import");
importPath = pageTreeStore.get_path(parent);
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, "Recent");
......@@ -139,6 +151,19 @@ public class AppWindow : Gtk.Window {
pageTreeStore.append(out parent, null);
pageTreeStore.set(parent, 0, "Trash");
pageTreeView.cursor_changed += () => {
Gtk.TreePath selected;
pageTreeView.get_cursor(out selected, null);
if (selected.compare(collectionPath) == 0) {
switch_to_collection_page();
} else if (selected.compare(importPath) == 0) {
switch_to_import_page();
} else {
debug("unknown");
}
};
// 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);
......@@ -149,13 +174,16 @@ public class AppWindow : Gtk.Window {
}
collectionPage = new CollectionPage();
photoPage = new PhotoPage();
photoPage = new PhotoPage("Photo");
importPage = new ImportPage();
importPreviewPage = new PhotoPage("ImportPreview");
add_accel_group(uiManager.get_accel_group());
create_start_page();
}
private void import(File file) {
public void import(File file) {
FileType type = file.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
if(type == FileType.REGULAR) {
if (!import_file(file)) {
......@@ -218,7 +246,7 @@ public class AppWindow : Gtk.Window {
private bool import_file(File file) {
debug("Importing file %s", file.get_path());
// load full-scale photo and convert to pixbuf
// TODO: Attempt to discover photo information from its metadata
Gdk.Pixbuf original;
try {
original = new Gdk.Pixbuf.from_file(file.get_path());
......@@ -248,11 +276,11 @@ public class AppWindow : Gtk.Window {
Gtk.drag_finish(context, true, false, time);
// import
collectionPage.begin_adding();
foreach (string uri in uris) {
import(File.new_for_uri(uri));
}
collectionPage.end_adding();
collectionPage.refresh();
}
public void switch_to_collection_page() {
......@@ -260,10 +288,19 @@ public class AppWindow : Gtk.Window {
}
public void switch_to_photo_page(PhotoID photoID) {
photoPage.display_photo(photoID);
photoPage.display_photo(photoID, collectionPage);
switch_to_page(photoPage);
}
public void switch_to_import_preview_page(Gdk.Pixbuf pixbuf, Exif.Data exifData) {
importPreviewPage.display_pixbuf(pixbuf, exifData, importPage);
switch_to_page(importPreviewPage);
}
public void switch_to_import_page() {
switch_to_page(importPage);
}
private Gtk.Box layout = null;
private Gtk.Box pageBox = null;
private Gtk.Box clientBox = null;
......@@ -291,10 +328,16 @@ public class AppWindow : Gtk.Window {
layout.pack_end(clientBox, true, true, 0);
add(layout);
currentPage.switched_to();
show_all();
}
private void switch_to_page(Page page) {
public void switch_to_page(Page page) {
if (page == currentPage)
return;
currentPage.switching_from();
pageBox.remove(currentPage);
......
public abstract class LayoutItem : Gtk.Alignment {
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";
// 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.
protected Gtk.Image image = new Gtk.Image();
protected Gtk.Label title = new Gtk.Label("");
protected Gtk.Frame frame = new Gtk.Frame(null);
private bool selected = false;
public LayoutItem() {
// bottom-align everything
set(0, 1, 0, 0);
title.set_use_underline(false);
title.set_justify(Gtk.Justification.LEFT);
title.set_alignment(0, 0);
title.modify_fg(Gtk.StateType.NORMAL, parse_color(TEXT_COLOR));
Gtk.Widget panel = get_control_panel();
// store everything in a vbox, with the expandable image on top followed by a widget
// on the bottom for display and controls
Gtk.VBox vbox = new Gtk.VBox(false, 0);
vbox.set_border_width(FRAME_PADDING);
vbox.pack_start(image, false, false, 0);
vbox.pack_end(title, false, false, LABEL_PADDING);
if (panel != null)
vbox.pack_end(panel, false, false, 0);
// surround everything with a frame
frame.set_shadow_type(Gtk.ShadowType.NONE);
frame.modify_bg(Gtk.StateType.NORMAL, parse_color(UNSELECTED_COLOR));
frame.add(vbox);
add(frame);
}
public Gtk.Widget? get_control_panel() {
return null;
}
public virtual void exposed() {
}
public virtual void unexposed() {
}
public virtual void select() {
selected = true;
frame.set_shadow_type(Gtk.ShadowType.OUT);
frame.modify_bg(Gtk.StateType.NORMAL, parse_color(SELECTED_COLOR));
title.modify_fg(Gtk.StateType.NORMAL, parse_color(SELECTED_COLOR));
Gtk.Widget panel = get_control_panel();
if (panel != null)
panel.modify_fg(Gtk.StateType.NORMAL, parse_color(SELECTED_COLOR));
}
public virtual void unselect() {
selected = false;
frame.set_shadow_type(Gtk.ShadowType.NONE);
frame.modify_bg(Gtk.StateType.NORMAL, parse_color(UNSELECTED_COLOR));
title.modify_fg(Gtk.StateType.NORMAL, parse_color(UNSELECTED_COLOR));
Gtk.Widget panel = get_control_panel();
if (panel != null)
panel.modify_fg(Gtk.StateType.NORMAL, parse_color(UNSELECTED_COLOR));
}
public bool toggle_select() {
if (selected) {
unselect();
} else {
select();
}
return selected;
}
public bool is_selected() {
return selected;
}
}
public class CollectionLayout : Gtk.Layout {
public static const int TOP_PADDING = 16;
public static const int BOTTOM_PADDING = 16;
......@@ -9,7 +101,7 @@ public class CollectionLayout : Gtk.Layout {
public static const int RIGHT_PADDING = 16;
public static const int COLUMN_GUTTER_PADDING = 24;
private Gee.ArrayList<Thumbnail> thumbnails = new Gee.ArrayList<Thumbnail>();
private Gee.ArrayList<LayoutItem> items = new Gee.ArrayList<LayoutItem>();
public CollectionLayout() {
modify_bg(Gtk.StateType.NORMAL, AppWindow.BG_COLOR);
......@@ -17,35 +109,43 @@ public class CollectionLayout : Gtk.Layout {
size_allocate += on_resize;
}
public void append(Thumbnail thumbnail) {
thumbnails.add(thumbnail);
public void append(LayoutItem item) {
items.add(item);
// need to do this to have its size requisitioned in refresh()
thumbnail.show_all();
item.show_all();
}
public void remove_thumbnail(Thumbnail thumbnail) {
thumbnails.remove(thumbnail);
remove(thumbnail);
public void remove_item(LayoutItem item) {
items.remove(item);
remove(item);
}
public Thumbnail? get_thumbnail_at(double xd, double yd) {
public LayoutItem? get_item_at(double xd, double yd) {
int x = (int) xd;
int y = (int) yd;
foreach (Thumbnail thumbnail in thumbnails) {
Gtk.Allocation alloc = thumbnail.allocation;
foreach (LayoutItem item in items) {
Gtk.Allocation alloc = item.allocation;
if ((x >= alloc.x) && (y >= alloc.y) && (x <= (alloc.x + alloc.width))
&& (y <= (alloc.y + alloc.height))) {
return thumbnail;
return item;
}
}
return null;
}
public void clear() {
foreach (LayoutItem item in items) {
remove(item);
}
items.clear();
}
public void refresh() {
if (thumbnails.size == 0)
if (items.size == 0)
return;
// don't bother until layout is of some appreciable size
......@@ -59,12 +159,12 @@ public class CollectionLayout : Gtk.Layout {
int rowWidth = 0;
int widestRow = 0;
foreach (Thumbnail thumbnail in thumbnails) {
foreach (LayoutItem item in items) {
// perform size requests first time through, but not thereafter
Gtk.Requisition req;
thumbnail.size_request(out req);
item.size_request(out req);
// carriage return (i.e. this thumbnail will overflow the view)
// carriage return (i.e. this item will overflow the view)
if ((x + req.width + RIGHT_PADDING) > allocation.width) {
if (rowWidth > widestRow) {
widestRow = rowWidth;
......@@ -97,12 +197,12 @@ public class CollectionLayout : Gtk.Layout {
int totalWidth = 0;
col = 0;
int[] columnWidths = new int[maxCols];
int[] rowHeights = new int[(thumbnails.size / maxCols) + 1];
int[] rowHeights = new int[(items.size / maxCols) + 1];
int gutter = 0;
for (;;) {
foreach (Thumbnail thumbnail in thumbnails) {
Gtk.Requisition req = thumbnail.requisition;
foreach (LayoutItem item in items) {
Gtk.Requisition req = item.requisition;
if (req.height > tallest)
tallest = req.height;
......@@ -126,7 +226,7 @@ public class CollectionLayout : Gtk.Layout {
if (col != 0)
rowHeights[row] = tallest;
// Step 3: Calculate the gutter between the thumbnails as being equidistant of the
// Step 3: Calculate the gutter between the items as being equidistant of the
// remaining space (adding one gutter to account for the right-hand one)
gutter = (allocation.width - totalWidth) / (maxCols + 1);
......@@ -144,38 +244,40 @@ public class CollectionLayout : Gtk.Layout {
tallest = 0;
totalWidth = 0;
columnWidths = new int[maxCols];
rowHeights = new int[(thumbnails.size / maxCols) + 1];
rowHeights = new int[(items.size / maxCols) + 1];
debug("refresh(): readjusting columns: maxCols=%d", maxCols);
} else {
break;
}
}
/*
debug("refresh(): width:%d totalWidth:%d maxCols:%d gutter:%d", allocation.width, totalWidth,
maxCols, gutter);
*/
// Step 4: Lay out the thumbnails in the space using all the information gathered
// Step 4: Lay out the items in the space using all the information gathered
x = gutter;
int y = TOP_PADDING;
col = 0;
row = 0;
foreach (Thumbnail thumbnail in thumbnails) {
Gtk.Requisition req = thumbnail.requisition;
foreach (LayoutItem item in items) {
Gtk.Requisition req = item.requisition;
// this centers the thumbnail in the column
// this centers the item in the column
int xpadding = (columnWidths[col] - req.width) / 2;
assert(xpadding >= 0);
// this bottom-aligns the thumbnail along the row
// this bottom-aligns the item along the row
int ypadding = (rowHeights[row] - req.height);
assert(ypadding >= 0);
// if thumbnail was recently appended, it needs to be put() rather than move()'d
if (thumbnail.parent == (Gtk.Widget) this) {
move(thumbnail, x + xpadding, y + ypadding);
// if item was recently appended, it needs to be put() rather than move()'d
if (item.parent == (Gtk.Widget) this) {
move(item, x + xpadding, y + ypadding);
} else {
put(thumbnail, x + xpadding, y + ypadding);
put(item, x + xpadding, y + ypadding);
}
x += columnWidths[col] + gutter;
......@@ -190,14 +292,14 @@ public class CollectionLayout : Gtk.Layout {
}
// Step 5: Define the total size of the page as the size of the allocated width and
// the height of all the thumbnails plus padding
// the height of all the items plus padding
set_size(allocation.width, y + rowHeights[row] + BOTTOM_PADDING);
}
private int lastWidth = 0;
private void on_resize() {
// only refresh() if the viewport width has changed
// only refresh() if the width has changed
if (allocation.width != lastWidth) {
lastWidth = allocation.width;
refresh();
......@@ -220,12 +322,12 @@ public class CollectionLayout : Gtk.Layout {
int exposedCount = 0;
int unexposedCount = 0;
foreach (Thumbnail thumbnail in thumbnails) {
if (visibleRect.intersect((Gdk.Rectangle) thumbnail.allocation, bitbucket)) {
thumbnail.exposed();
foreach (LayoutItem item in items) {
if (visibleRect.intersect((Gdk.Rectangle) item.allocation, bitbucket)) {
item.exposed();
exposedCount++;
} else {
thumbnail.unexposed();
item.unexposed();
unexposedCount++;
}
}
......
public class CollectionPage : Page {
public class CollectionPage : CheckerboardPage {
public static const int THUMB_X_PADDING = 20;
public static const int THUMB_Y_PADDING = 20;
......@@ -8,17 +8,14 @@ public class CollectionPage : Page {
public static const int SLIDER_STEPPING = 1;
private static const int IMPROVAL_PRIORITY = Priority.LOW;
private static const int IMPROVAL_DELAY_MS = 500;
private static const int IMPROVAL_DELAY_MS = 250;
private PhotoTable photoTable = new PhotoTable();
private CollectionLayout layout = new CollectionLayout();
private Gtk.ActionGroup mainActionGroup = new Gtk.ActionGroup("CollectionActionGroup");
private Gtk.ActionGroup contextActionGroup = new Gtk.ActionGroup("CollectionContextActionGroup");
private Gtk.Toolbar toolbar = new Gtk.Toolbar();
private Gtk.HScale slider = null;
private Gtk.ToolButton rotateButton = null;
private Gee.ArrayList<Thumbnail> thumbnailList = new Gee.ArrayList<Thumbnail>();
private Gee.HashSet<Thumbnail> selectedList = new Gee.HashSet<Thumbnail>();
private int scale = Thumbnail.DEFAULT_SCALE;
private bool improval_scheduled = false;
private bool displayTitles = true;
......@@ -48,7 +45,7 @@ public class CollectionPage : Page {
{ "Remove", Gtk.STOCK_DELETE, "_Remove", "Delete", "Remove the selected photos from the library", on_remove },
{ "CollectionRotateClockwise", STOCK_CLOCKWISE, "Rotate c_lockwise", "<Ctrl>R", "Rotate the selected photos clockwise", on_rotate_clockwise },
{ "CollectionRotateCounterclockwise", STOCK_COUNTERCLOCKWISE, "Rotate c_ounterclockwise", "<Ctrl><Shift>R", "Rotate the selected photos counterclockwise", on_rotate_counterclockwise },
{ "Mirror", null, "_Mirror", "<Ctrl>M", "Make mirror images of the selected photos", on_mirror }
{ "CollectionMirror", null, "_Mirror", "<Ctrl>M", "Make mirror images of the selected photos", on_mirror }
};
construct {
......@@ -91,24 +88,18 @@ public class CollectionPage : Page {
// scrollbar policy
set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
// this schedules thumbnail improvement whenever the window size changes (and new thumbnails
// may be exposed)
size_allocate += schedule_thumbnail_improval;
// this schedules thumbnail improvement whenever the window is scrolled (and new
// thumbnails may be exposed)
get_hadjustment().value_changed += schedule_thumbnail_improval;
get_vadjustment().value_changed += schedule_thumbnail_improval;
add(layout);
File[] photoFiles = photoTable.get_photo_files();
foreach (File file in photoFiles) {
PhotoID photoID = photoTable.get_id(file);
add_photo(photoID, file);
}
layout.refresh();
refresh();
schedule_thumbnail_improval();
......@@ -123,92 +114,54 @@ public class CollectionPage : Page {
return "/CollectionMenuBar";
}
public override string? get_context_menu_path() {
return "/CollectionContextMenu";
}
public override void switched_to() {
// need to refresh the layout in case any of the thumbnail dimensions were altered while we
// were gone
layout.refresh();
}
public void begin_adding() {
}
public void add_photo(PhotoID photoID, File file) {
Thumbnail thumbnail = Thumbnail.create(photoID, file, scale);
thumbnail.display_title(displayTitles);
thumbnailList.add(thumbnail);
refresh();
layout.append(thumbnail);
// schedule improvement in case any new photos were added
schedule_thumbnail_improvement();
}
public void end_adding() {
layout.refresh();
protected override void on_selection_changed(int count) {
rotateButton.sensitive = (count > 0);
}
public int get_count() {
return thumbnailList.size;
}
public void select_all() {
foreach (Thumbnail thumbnail in thumbnailList) {
selectedList.add(thumbnail);
thumbnail.select();
}
protected override void on_item_activated(LayoutItem item) {
Thumbnail thumbnail = (Thumbnail) item;
rotateButton.sensitive = true;
}
public void unselect_all() {
foreach (Thumbnail thumbnail in selectedList) {
assert(thumbnail.is_selected());
thumbnail.unselect();
}
selectedList = new Gee.HashSet<Thumbnail>();
rotateButton.sensitive = false;
}
public Thumbnail[] get_selected() {
Thumbnail[] thumbnails = new Thumbnail[selectedList.size];
int ctr = 0;
foreach (Thumbnail thumbnail in selectedList) {
assert(thumbnail.is_selected());
thumbnails[ctr++] = thumbnail;
}
return thumbnails;
}
public void select(Thumbnail thumbnail) {
thumbnail.select();
selectedList.add(thumbnail);
rotateButton.sensitive = true;
}
public void unselect(Thumbnail thumbnail) {
thumbnail.unselect();
selectedList.remove(thumbnail);
rotateButton.sensitive = (selectedList.size != 0);
// switch to full-page view
debug("switching to %s [%d]", thumbnail.get_file().get_path(),
thumbnail.get_photo_id().id);
AppWindow.get_main_window().switch_to_photo_page(thumbnail.get_photo_id());
}
public void toggle_select(Thumbnail thumbnail) {
if (thumbnail.toggle_select()) {
// now selected
selectedList.add(thumbnail);
} else {
// now unselected
selectedList.remove(thumbnail);
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;
schedule_thumbnail_improval();
}
rotateButton.sensitive = (selectedList.size != 0);
return false;
}
public int get_selected_count() {
return selectedList.size;
public void add_photo(PhotoID photoID, File file) {
Thumbnail thumbnail = Thumbnail.create(photoID, file, scale);
thumbnail.display_title(displayTitles);
add_item(thumbnail);
}
public int increase_thumb_size() {
......@@ -245,11 +198,11 @@ public class CollectionPage : Page {
scale = newScale;
foreach (Thumbnail thumbnail in thumbnailList) {
thumbnail.resize(scale);
foreach (LayoutItem item in get_items()) {
((Thumbnail) item).resize(scale);
}
layout.refresh();
refresh();
schedule_thumbnail_improval();
}
......@@ -273,7 +226,8 @@ public class CollectionPage : Page {
return true;
}
foreach (Thumbnail thumbnail in thumbnailList) {
foreach (LayoutItem item in get_items()) {
Thumbnail thumbnail = (Thumbnail) item;
if (thumbnail.is_exposed()) {
thumbnail.paint_high_quality();
}
......@@ -315,128 +269,55 @@ public class CollectionPage : Page {
slider.set_value(scaleToSlider(scale));
}
private override bool on_left_click(Gdk.EventButton event) {
// only interested in single-click and double-clicks for now
if ((event.type != Gdk.EventType.BUTTON_PRESS)
&& (event.type != Gdk.EventType.2BUTTON_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 = layout.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
toggle_select(thumbnail);
} break;
case Gdk.ModifierType.SHIFT_MASK: {
// TODO
} break;
case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK: {
// TODO
} break;
default: {
if (event.type == Gdk.EventType.2BUTTON_PRESS) {
// switch to full-page view
debug("switching to %s [%d]", thumbnail.get_file().get_path(),
thumbnail.get_photo_id().id);
AppWindow.get_main_window().switch_to_photo_page(thumbnail.get_photo_id());
} else {
// a "raw" single-click deselects all thumbnails and selects the single chosen
unselect_all();
select(thumbnail);
}
} break;
}
} else {
// user clicked on "dead" area
unselect_all();
}
return true;
}
private override 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 = layout.get_thumbnail_at(event.x, event.y);
if (thumbnail != null) {
// this counts as a select with all others de-selected
unselect_all();
select(thumbnail);
Gtk.Menu contextMenu = (Gtk.Menu) AppWindow.get_ui_manager().get_widget("/CollectionContextMenu");
contextMenu.popup(null, null, null, event.button, event.time);
return true;
}
return false;
}
private void on_remove() {
// get a full list of the selected thumbnails, then iterate over that, as you can't remove
// from a list you're iterating over
Thumbnail[] thumbnails = get_selected();
foreach (Thumbnail thumbnail in thumbnails) {
thumbnailList.remove(thumbnail);
selectedList.remove(thumbnail);
// iterate over selected and remove them from cache and database
foreach (LayoutItem item in get_selected()) {
Thumbnail thumbnail = (Thumbnail) item;