def test_actor_controller(self): """Make sure the actors are behaving.""" from actor import ActorController control = ActorController() link = get_obj('maps_link') map_view.center_on(50, 50) self.assertEqual(control.label.get_text(), 'N 50.00000, E 50.00000') self.assertEqual(link.get_current_uri()[:45], 'http://maps.google.com/maps?ll=50.0,50.0&spn=') map_view.center_on(-10, -30) self.assertEqual(control.label.get_text(), 'S 10.00000, W 30.00000') self.assertEqual(link.get_current_uri()[:47], 'http://maps.google.com/maps?ll=-10.0,-30.0&spn=') for rot in control.xhair.get_rotation(Clutter.RotateAxis.Z_AXIS): self.assertEqual(rot, 0) control.animate_in(10) self.assertEqual(control.xhair.get_rotation(Clutter.RotateAxis.Z_AXIS)[0], 45) self.assertEqual(control.xhair.get_size(), (8, 9)) self.assertEqual(control.black.get_width(), map_view.get_width())
class GottenGeography(): """Provides a graphical interface to automagically geotag photos. Just load your photos, and load a GPX file, and GottenGeography will automatically cross-reference the timestamps on the photos to the timestamps in the GPX to determine the three-dimensional coordinates of each photo. """ ################################################################################ # File data handling. These methods interact with files (loading, saving, etc) ################################################################################ def open_files(self, files): """Attempt to load all of the specified files.""" self.progressbar.show() invalid, total = [], len(files) for i, name in enumerate(files, 1): self.redraw_interface(i / total, basename(name)) try: try: self.load_img_from_file(name) except IOError: self.load_gpx_from_file(name) except IOError: invalid.append(basename(name)) if invalid: self.status_message(_('Could not open: ') + ', '.join(invalid)) self.progressbar.hide() self.labels.selection.emit('changed') map_view.emit('animation-completed') def load_img_from_file(self, uri): """Create or update a row in the ListStore. Checks if the file has already been loaded, and if not, creates a new row in the ListStore. Either way, it then populates that row with photo metadata as read from disk. Effectively, this is used both for loading new photos, and reverting old photos, discarding any changes. Raises IOError if filename refers to a file that is not a photograph. """ photo = photos.get(uri) or Photograph(uri, self.modify_summary) photo.read() if uri not in photos: photo.iter = self.liststore.append() photo.label = self.labels.add(uri) photos[uri] = photo photo.position_label() modified.discard(photo) self.liststore.set_row( photo.iter, [uri, photo.long_summary(), photo.thumb, photo.timestamp]) auto_timestamp_comparison(photo) def load_gpx_from_file(self, uri): """Parse GPX data, drawing each GPS track segment on the map.""" start_time = clock() open_file = KMLFile if uri[-3:].lower() == 'kml' else GPXFile gpx = open_file(uri, self.progressbar) # Emitting this signal ensures the new tracks get the correct color. get_obj('colorselection').emit('color-changed') self.status_message( _('%d points loaded in %.2fs.') % (len(gpx.tracks), clock() - start_time), True) if len(gpx.tracks) < 2: return points.update(gpx.tracks) metadata.alpha = min(metadata.alpha, gpx.alpha) metadata.omega = max(metadata.omega, gpx.omega) map_view.emit('realize') map_view.set_zoom_level(map_view.get_max_zoom_level()) bounds = Champlain.BoundingBox.new() for poly in polygons: bounds.compose(poly.get_bounding_box()) gpx.latitude, gpx.longitude = bounds.get_center() map_view.ensure_visible(bounds, False) self.prefs.gpx_timezone = gpx.lookup_geoname() self.prefs.set_timezone() gpx_sensitivity() def apply_selected_photos(self, button, view): """Manually apply map center coordinates to all selected photos.""" for photo in selected: photo.manual = True photo.set_location(view.get_property('latitude'), view.get_property('longitude')) self.labels.selection.emit('changed') def revert_selected_photos(self, button=None): """Discard any modifications to all selected photos.""" self.open_files([photo.filename for photo in modified & selected]) def close_selected_photos(self, button=None): """Discard all selected photos.""" for photo in selected.copy(): self.labels.layer.remove_marker(photo.label) del photos[photo.filename] modified.discard(photo) self.liststore.remove(photo.iter) self.labels.select_all.set_active(False) def save_all_files(self, widget=None): """Ensure all loaded files are saved.""" self.progressbar.show() total = len(modified) for i, photo in enumerate(list(modified), 1): self.redraw_interface(i / total, basename(photo.filename)) try: photo.write() except Exception as inst: self.status_message(str(inst)) else: modified.discard(photo) self.liststore.set_value(photo.iter, SUMMARY, photo.long_summary()) self.progressbar.hide() self.labels.selection.emit('changed') ################################################################################ # Data manipulation. These methods modify the loaded files in some way. ################################################################################ def time_offset_changed(self, widget): """Update all photos each time the camera's clock is corrected.""" seconds = self.secbutton.get_value() minutes = self.minbutton.get_value() offset = int((minutes * 60) + seconds) if offset != metadata.delta: metadata.delta = offset if abs(seconds) == 60 and abs(minutes) != 60: minutes += seconds / 60 self.secbutton.set_value(0) self.minbutton.set_value(minutes) for photo in photos.values(): auto_timestamp_comparison(photo) def modify_summary(self, photo): """Insert the current photo summary into the liststore.""" modified.add(photo) self.liststore.set_value(photo.iter, SUMMARY, ('<b>%s</b>' % photo.long_summary())) ################################################################################ # Dialogs. Various dialog-related methods for user interaction. ################################################################################ def update_preview(self, chooser, label, image): """Display photo thumbnail and geotag data in file chooser.""" label.set_label(self.strings.preview) image.set_from_stock(Gtk.STOCK_FILE, Gtk.IconSize.DIALOG) try: photo = Photograph(chooser.get_preview_filename(), lambda x: None, 300) photo.read() except IOError: return image.set_from_pixbuf(photo.thumb) label.set_label('\n'.join([photo.short_summary(), photo.maps_link()])) def add_files_dialog(self, button, chooser): """Display a file chooser, and attempt to load chosen files.""" response = chooser.run() chooser.hide() if response == Gtk.ResponseType.OK: self.open_files(chooser.get_filenames()) def confirm_quit_dialog(self, *args): """Teardown method, inform user of unsaved files, if any.""" if len(modified) == 0: Gtk.main_quit() return True dialog = get_obj('quit') dialog.format_secondary_markup(self.strings.quit % len(modified)) response = dialog.run() dialog.hide() self.redraw_interface() if response == Gtk.ResponseType.ACCEPT: self.save_all_files() if response != Gtk.ResponseType.CANCEL: Gtk.main_quit() return True ################################################################################ # Initialization and Gtk boilerplate/housekeeping type stuff and such. ################################################################################ def __init__(self): self.progressbar = get_obj('progressbar') self.error = Struct({ 'message': get_obj('error_message'), 'icon': get_obj('error_icon'), 'bar': get_obj('error_bar') }) self.error.bar.connect('response', lambda widget, signal: widget.hide()) self.strings = Struct({ 'quit': get_obj('quit').get_property('secondary-text'), 'preview': get_obj('preview_label').get_text() }) self.liststore = get_obj('loaded_photos') self.liststore.set_sort_column_id(TIMESTAMP, Gtk.SortType.ASCENDING) cell_string = Gtk.CellRendererText() cell_thumb = Gtk.CellRendererPixbuf() cell_thumb.set_property('stock-id', Gtk.STOCK_MISSING_IMAGE) cell_thumb.set_property('ypad', 6) cell_thumb.set_property('xpad', 12) column = Gtk.TreeViewColumn('Photos') column.pack_start(cell_thumb, False) column.add_attribute(cell_thumb, 'pixbuf', THUMB) column.pack_start(cell_string, False) column.add_attribute(cell_string, 'markup', SUMMARY) column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) get_obj('photos_view').append_column(column) self.drag = DragController(self.open_files) self.navigator = NavigationController() self.search = SearchController() self.prefs = PreferencesController() self.labels = LabelController() self.actors = ActorController() about = get_obj('about') about.set_version(REVISION) about.set_program_name(APPNAME) about.set_logo( GdkPixbuf.Pixbuf.new_from_file_at_size( join(PKG_DATA_DIR, PACKAGE + '.svg'), 192, 192)) click_handlers = { 'open_button': [self.add_files_dialog, get_obj('open')], 'save_button': [self.save_all_files], 'clear_button': [clear_all_gpx], 'close_button': [self.close_selected_photos], 'revert_button': [self.revert_selected_photos], 'about_button': [lambda b, d: d.run() and d.hide(), about], 'apply_button': [self.apply_selected_photos, map_view], 'select_all_button': [toggle_selected_photos, self.labels.selection] } for button, handler in click_handlers.items(): get_obj(button).connect('clicked', *handler) accel = Gtk.AccelGroup() window = get_obj('main') window.resize(*gst.get('window-size')) window.connect('delete_event', self.confirm_quit_dialog) window.add_accel_group(accel) window.show_all() # Hide the unused button that appears beside the map source menu. get_obj('map_source_menu_button').get_child().get_children( )[0].set_visible(False) save_size = lambda v, s, size: gst.set_window_size(size()) for prop in ['width', 'height']: map_view.connect('notify::' + prop, save_size, window.get_size) accel.connect(Gdk.keyval_from_name('q'), Gdk.ModifierType.CONTROL_MASK, 0, self.confirm_quit_dialog) self.labels.selection.emit('changed') clear_all_gpx() metadata.delta = 0 self.secbutton, self.minbutton = get_obj('seconds'), get_obj('minutes') for spinbutton in [self.secbutton, self.minbutton]: spinbutton.connect('value-changed', self.time_offset_changed) gst.bind('offset-minutes', self.minbutton, 'value') gst.bind('offset-seconds', self.secbutton, 'value') gst.bind('left-pane-page', get_obj('photo_camera_gps'), 'page') get_obj('open').connect('update-preview', self.update_preview, get_obj('preview_label'), get_obj('preview_image')) def redraw_interface(self, fraction=None, text=None): """Tell Gtk to redraw the user interface, so it doesn't look hung. Primarily used to update the progressbar, but also for disappearing some dialogs while things are processing in the background. Won't modify the progressbar if called with no arguments. """ if fraction is not None: self.progressbar.set_fraction(fraction) if text is not None: self.progressbar.set_text(str(text)) while Gtk.events_pending(): Gtk.main_iteration() def status_message(self, message, info=False): """Display a message with the GtkInfoBar.""" self.error.message.set_markup('<b>%s</b>' % message) self.error.bar.set_message_type( Gtk.MessageType.INFO if info else Gtk.MessageType.WARNING) self.error.icon.set_from_stock( Gtk.STOCK_DIALOG_INFO if info else Gtk.STOCK_DIALOG_WARNING, 6) self.error.bar.show() def main(self, anim_start=200): """Animate the crosshair and begin user interaction.""" if argv[1:]: self.open_files([abspath(f) for f in argv[1:]]) anim_start = 10 self.actors.animate_in(anim_start) Gtk.main()
def __init__(self): self.progressbar = get_obj('progressbar') self.error = Struct({ 'message': get_obj('error_message'), 'icon': get_obj('error_icon'), 'bar': get_obj('error_bar') }) self.error.bar.connect('response', lambda widget, signal: widget.hide()) self.strings = Struct({ 'quit': get_obj('quit').get_property('secondary-text'), 'preview': get_obj('preview_label').get_text() }) self.liststore = get_obj('loaded_photos') self.liststore.set_sort_column_id(TIMESTAMP, Gtk.SortType.ASCENDING) cell_string = Gtk.CellRendererText() cell_thumb = Gtk.CellRendererPixbuf() cell_thumb.set_property('stock-id', Gtk.STOCK_MISSING_IMAGE) cell_thumb.set_property('ypad', 6) cell_thumb.set_property('xpad', 12) column = Gtk.TreeViewColumn('Photos') column.pack_start(cell_thumb, False) column.add_attribute(cell_thumb, 'pixbuf', THUMB) column.pack_start(cell_string, False) column.add_attribute(cell_string, 'markup', SUMMARY) column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) get_obj('photos_view').append_column(column) self.drag = DragController(self.open_files) self.navigator = NavigationController() self.search = SearchController() self.prefs = PreferencesController() self.labels = LabelController() self.actors = ActorController() about = get_obj('about') about.set_version(REVISION) about.set_program_name(APPNAME) about.set_logo( GdkPixbuf.Pixbuf.new_from_file_at_size( join(PKG_DATA_DIR, PACKAGE + '.svg'), 192, 192)) click_handlers = { 'open_button': [self.add_files_dialog, get_obj('open')], 'save_button': [self.save_all_files], 'clear_button': [clear_all_gpx], 'close_button': [self.close_selected_photos], 'revert_button': [self.revert_selected_photos], 'about_button': [lambda b, d: d.run() and d.hide(), about], 'apply_button': [self.apply_selected_photos, map_view], 'select_all_button': [toggle_selected_photos, self.labels.selection] } for button, handler in click_handlers.items(): get_obj(button).connect('clicked', *handler) accel = Gtk.AccelGroup() window = get_obj('main') window.resize(*gst.get('window-size')) window.connect('delete_event', self.confirm_quit_dialog) window.add_accel_group(accel) window.show_all() # Hide the unused button that appears beside the map source menu. get_obj('map_source_menu_button').get_child().get_children( )[0].set_visible(False) save_size = lambda v, s, size: gst.set_window_size(size()) for prop in ['width', 'height']: map_view.connect('notify::' + prop, save_size, window.get_size) accel.connect(Gdk.keyval_from_name('q'), Gdk.ModifierType.CONTROL_MASK, 0, self.confirm_quit_dialog) self.labels.selection.emit('changed') clear_all_gpx() metadata.delta = 0 self.secbutton, self.minbutton = get_obj('seconds'), get_obj('minutes') for spinbutton in [self.secbutton, self.minbutton]: spinbutton.connect('value-changed', self.time_offset_changed) gst.bind('offset-minutes', self.minbutton, 'value') gst.bind('offset-seconds', self.secbutton, 'value') gst.bind('left-pane-page', get_obj('photo_camera_gps'), 'page') get_obj('open').connect('update-preview', self.update_preview, get_obj('preview_label'), get_obj('preview_image'))
class GottenGeography(): """Provides a graphical interface to automagically geotag photos. Just load your photos, and load a GPX file, and GottenGeography will automatically cross-reference the timestamps on the photos to the timestamps in the GPX to determine the three-dimensional coordinates of each photo. """ ################################################################################ # File data handling. These methods interact with files (loading, saving, etc) ################################################################################ def open_files(self, files): """Attempt to load all of the specified files.""" self.progressbar.show() invalid, total = [], len(files) for i, name in enumerate(files, 1): self.redraw_interface(i / total, basename(name)) try: try: self.load_img_from_file(name) except IOError: self.load_gpx_from_file(name) except IOError: invalid.append(basename(name)) if len(invalid) > 0: self.status_message(_('Could not open: ') + ', '.join(invalid)) self.progressbar.hide() self.labels.selection.emit('changed') map_view.emit('animation-completed') def load_img_from_file(self, uri): """Create or update a row in the ListStore. Checks if the file has already been loaded, and if not, creates a new row in the ListStore. Either way, it then populates that row with photo metadata as read from disk. Effectively, this is used both for loading new photos, and reverting old photos, discarding any changes. Raises IOError if filename refers to a file that is not a photograph. """ photo = photos.get(uri) or Photograph(uri, self.modify_summary) photo.read() if uri not in photos: photo.iter = self.liststore.append() photo.label = self.labels.add(uri) photos[uri] = photo photo.position_label() modified.discard(photo) self.liststore.set_row(photo.iter, [uri, photo.long_summary(), photo.thumb, photo.timestamp]) auto_timestamp_comparison(photo) def load_gpx_from_file(self, uri): """Parse GPX data, drawing each GPS track segment on the map.""" start_time = clock() open_file = KMLFile if uri[-3:].lower() == 'kml' else GPXFile gpx = open_file(uri, self.progressbar) # Emitting this signal ensures the new tracks get the correct color. get_obj('colorselection').emit('color-changed') self.status_message(_('%d points loaded in %.2fs.') % (len(gpx.tracks), clock() - start_time), True) if len(gpx.tracks) < 2: return points.update(gpx.tracks) metadata.alpha = min(metadata.alpha, gpx.alpha) metadata.omega = max(metadata.omega, gpx.omega) map_view.emit('realize') map_view.set_zoom_level(map_view.get_max_zoom_level()) bounds = Champlain.BoundingBox.new() for poly in polygons: bounds.compose(poly.get_bounding_box()) gpx.latitude, gpx.longitude = bounds.get_center() map_view.ensure_visible(bounds, False) self.prefs.gpx_timezone = gpx.lookup_geoname() self.prefs.set_timezone() gpx_sensitivity() def apply_selected_photos(self, button, view): """Manually apply map center coordinates to all selected photos.""" for photo in selected: photo.manual = True photo.set_location( view.get_property('latitude'), view.get_property('longitude')) self.labels.selection.emit('changed') def revert_selected_photos(self, button=None): """Discard any modifications to all selected photos.""" self.open_files([photo.filename for photo in modified & selected]) def close_selected_photos(self, button=None): """Discard all selected photos.""" for photo in selected.copy(): self.labels.layer.remove_marker(photo.label) del photos[photo.filename] modified.discard(photo) self.liststore.remove(photo.iter) self.labels.select_all.set_active(False) def save_all_files(self, widget=None): """Ensure all loaded files are saved.""" self.progressbar.show() total = len(modified) for i, photo in enumerate(list(modified), 1): self.redraw_interface(i / total, basename(photo.filename)) try: photo.write() except Exception as inst: self.status_message(str(inst)) else: modified.discard(photo) self.liststore.set_value(photo.iter, SUMMARY, photo.long_summary()) self.progressbar.hide() self.labels.selection.emit('changed') ################################################################################ # Data manipulation. These methods modify the loaded files in some way. ################################################################################ def time_offset_changed(self, widget): """Update all photos each time the camera's clock is corrected.""" seconds = self.secbutton.get_value() minutes = self.minbutton.get_value() offset = int((minutes * 60) + seconds) if offset != metadata.delta: metadata.delta = offset if abs(seconds) == 60 and abs(minutes) != 60: minutes += seconds / 60 self.secbutton.set_value(0) self.minbutton.set_value(minutes) for photo in photos.values(): auto_timestamp_comparison(photo) def modify_summary(self, photo): """Insert the current photo summary into the liststore.""" modified.add(photo) self.liststore.set_value(photo.iter, SUMMARY, ('<b>%s</b>' % photo.long_summary())) ################################################################################ # Dialogs. Various dialog-related methods for user interaction. ################################################################################ def update_preview(self, chooser, label, image): """Display photo thumbnail and geotag data in file chooser.""" label.set_label(self.strings.preview) image.set_from_stock(Gtk.STOCK_FILE, Gtk.IconSize.DIALOG) try: photo = Photograph(chooser.get_preview_filename(), lambda x: None, 300) photo.read() except IOError: return image.set_from_pixbuf(photo.thumb) label.set_label( '\n'.join([photo.short_summary(), photo.maps_link()])) def add_files_dialog(self, button, chooser): """Display a file chooser, and attempt to load chosen files.""" response = chooser.run() chooser.hide() if response == Gtk.ResponseType.OK: self.open_files(chooser.get_filenames()) def confirm_quit_dialog(self, *args): """Teardown method, inform user of unsaved files, if any.""" if len(modified) == 0: Gtk.main_quit() return True dialog = get_obj('quit') dialog.format_secondary_markup(self.strings.quit % len(modified)) response = dialog.run() dialog.hide() self.redraw_interface() if response == Gtk.ResponseType.ACCEPT: self.save_all_files() if response != Gtk.ResponseType.CANCEL: Gtk.main_quit() return True ################################################################################ # Initialization and Gtk boilerplate/housekeeping type stuff and such. ################################################################################ def __init__(self): self.progressbar = get_obj('progressbar') self.error = Struct({ 'message': get_obj('error_message'), 'icon': get_obj('error_icon'), 'bar': get_obj('error_bar') }) self.error.bar.connect('response', lambda widget, signal: widget.hide()) self.strings = Struct({ 'quit': get_obj('quit').get_property('secondary-text'), 'preview': get_obj('preview_label').get_text() }) self.liststore = get_obj('loaded_photos') self.liststore.set_sort_column_id(TIMESTAMP, Gtk.SortType.ASCENDING) cell_string = Gtk.CellRendererText() cell_thumb = Gtk.CellRendererPixbuf() cell_thumb.set_property('stock-id', Gtk.STOCK_MISSING_IMAGE) cell_thumb.set_property('ypad', 6) cell_thumb.set_property('xpad', 12) column = Gtk.TreeViewColumn('Photos') column.pack_start(cell_thumb, False) column.add_attribute(cell_thumb, 'pixbuf', THUMB) column.pack_start(cell_string, False) column.add_attribute(cell_string, 'markup', SUMMARY) column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) get_obj('photos_view').append_column(column) self.drag = DragController(self.open_files) self.navigator = NavigationController() self.search = SearchController() self.prefs = PreferencesController() self.labels = LabelController() self.actors = ActorController() about = get_obj('about') about.set_version(REVISION) about.set_program_name(APPNAME) about.set_logo(GdkPixbuf.Pixbuf.new_from_file_at_size( join(PKG_DATA_DIR, PACKAGE + '.svg'), 192, 192)) click_handlers = { 'open_button': [self.add_files_dialog, get_obj('open')], 'save_button': [self.save_all_files], 'clear_button': [clear_all_gpx], 'close_button': [self.close_selected_photos], 'revert_button': [self.revert_selected_photos], 'about_button': [lambda b, d: d.run() and d.hide(), about], 'apply_button': [self.apply_selected_photos, map_view], 'select_all_button': [toggle_selected_photos, self.labels.selection] } for button, handler in click_handlers.items(): get_obj(button).connect('clicked', *handler) accel = Gtk.AccelGroup() window = get_obj('main') window.resize(*gst.get('window-size')) window.connect('delete_event', self.confirm_quit_dialog) window.add_accel_group(accel) window.show_all() # Hide the unused button that appears beside the map source menu. get_obj('map_source_menu_button').get_child().get_children()[0].set_visible(False) save_size = lambda v, s, size: gst.set_window_size(size()) for prop in ['width', 'height']: map_view.connect('notify::' + prop, save_size, window.get_size) accel.connect(Gdk.keyval_from_name('q'), Gdk.ModifierType.CONTROL_MASK, 0, self.confirm_quit_dialog) self.labels.selection.emit('changed') clear_all_gpx() metadata.delta = 0 self.secbutton, self.minbutton = get_obj('seconds'), get_obj('minutes') for spinbutton in [ self.secbutton, self.minbutton ]: spinbutton.connect('value-changed', self.time_offset_changed) gst.bind('offset-minutes', self.minbutton, 'value') gst.bind('offset-seconds', self.secbutton, 'value') gst.bind('left-pane-page', get_obj('photo_camera_gps'), 'page') get_obj('open').connect('update-preview', self.update_preview, get_obj('preview_label'), get_obj('preview_image')) def redraw_interface(self, fraction=None, text=None): """Tell Gtk to redraw the user interface, so it doesn't look hung. Primarily used to update the progressbar, but also for disappearing some dialogs while things are processing in the background. Won't modify the progressbar if called with no arguments. """ if fraction is not None: self.progressbar.set_fraction(fraction) if text is not None: self.progressbar.set_text(str(text)) while Gtk.events_pending(): Gtk.main_iteration() def status_message(self, message, info=False): """Display a message with the GtkInfoBar.""" self.error.message.set_markup('<b>%s</b>' % message) self.error.bar.set_message_type( Gtk.MessageType.INFO if info else Gtk.MessageType.WARNING) self.error.icon.set_from_stock( Gtk.STOCK_DIALOG_INFO if info else Gtk.STOCK_DIALOG_WARNING, 6) self.error.bar.show() def main(self, anim_start=200): """Animate the crosshair and begin user interaction.""" if argv[1:]: self.open_files([abspath(f) for f in argv[1:]]) anim_start = 10 self.actors.animate_in(anim_start) Gtk.main()
def __init__(self): self.progressbar = get_obj('progressbar') self.error = Struct({ 'message': get_obj('error_message'), 'icon': get_obj('error_icon'), 'bar': get_obj('error_bar') }) self.error.bar.connect('response', lambda widget, signal: widget.hide()) self.strings = Struct({ 'quit': get_obj('quit').get_property('secondary-text'), 'preview': get_obj('preview_label').get_text() }) self.liststore = get_obj('loaded_photos') self.liststore.set_sort_column_id(TIMESTAMP, Gtk.SortType.ASCENDING) cell_string = Gtk.CellRendererText() cell_thumb = Gtk.CellRendererPixbuf() cell_thumb.set_property('stock-id', Gtk.STOCK_MISSING_IMAGE) cell_thumb.set_property('ypad', 6) cell_thumb.set_property('xpad', 12) column = Gtk.TreeViewColumn('Photos') column.pack_start(cell_thumb, False) column.add_attribute(cell_thumb, 'pixbuf', THUMB) column.pack_start(cell_string, False) column.add_attribute(cell_string, 'markup', SUMMARY) column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) get_obj('photos_view').append_column(column) self.drag = DragController(self.open_files) self.navigator = NavigationController() self.search = SearchController() self.prefs = PreferencesController() self.labels = LabelController() self.actors = ActorController() about = get_obj('about') about.set_version(REVISION) about.set_program_name(APPNAME) about.set_logo(GdkPixbuf.Pixbuf.new_from_file_at_size( join(PKG_DATA_DIR, PACKAGE + '.svg'), 192, 192)) click_handlers = { 'open_button': [self.add_files_dialog, get_obj('open')], 'save_button': [self.save_all_files], 'clear_button': [clear_all_gpx], 'close_button': [self.close_selected_photos], 'revert_button': [self.revert_selected_photos], 'about_button': [lambda b, d: d.run() and d.hide(), about], 'apply_button': [self.apply_selected_photos, map_view], 'select_all_button': [toggle_selected_photos, self.labels.selection] } for button, handler in click_handlers.items(): get_obj(button).connect('clicked', *handler) accel = Gtk.AccelGroup() window = get_obj('main') window.resize(*gst.get('window-size')) window.connect('delete_event', self.confirm_quit_dialog) window.add_accel_group(accel) window.show_all() # Hide the unused button that appears beside the map source menu. get_obj('map_source_menu_button').get_child().get_children()[0].set_visible(False) save_size = lambda v, s, size: gst.set_window_size(size()) for prop in ['width', 'height']: map_view.connect('notify::' + prop, save_size, window.get_size) accel.connect(Gdk.keyval_from_name('q'), Gdk.ModifierType.CONTROL_MASK, 0, self.confirm_quit_dialog) self.labels.selection.emit('changed') clear_all_gpx() metadata.delta = 0 self.secbutton, self.minbutton = get_obj('seconds'), get_obj('minutes') for spinbutton in [ self.secbutton, self.minbutton ]: spinbutton.connect('value-changed', self.time_offset_changed) gst.bind('offset-minutes', self.minbutton, 'value') gst.bind('offset-seconds', self.secbutton, 'value') gst.bind('left-pane-page', get_obj('photo_camera_gps'), 'page') get_obj('open').connect('update-preview', self.update_preview, get_obj('preview_label'), get_obj('preview_image'))
class GottenGeography(): """Provides a graphical interface to automagically geotag photos. Just load your photos, and load a GPX file, and GottenGeography will automatically cross-reference the timestamps on the photos to the timestamps in the GPX to determine the three-dimensional coordinates of each photo. """ ################################################################################ # File data handling. These methods interact with files (loading, saving, etc) ################################################################################ def open_files(self, files): """Attempt to load all of the specified files.""" self.progressbar.show() invalid, total = [], len(files) for i, name in enumerate(files, 1): self.redraw_interface(i / total, basename(name)) try: try: self.load_img_from_file(name) except IOError: self.load_gpx_from_file(name) except IOError: invalid.append(basename(name)) if len(invalid) > 0: self.status_message(_('Could not open: ') + ', '.join(invalid)) # Ensure camera has found correct timezone regardless of the order # that the GPX/KML files were loaded in. for camera in known_cameras.values(): camera.set_timezone() self.progressbar.hide() self.labels.selection.emit('changed') map_view.emit('animation-completed') def load_img_from_file(self, uri): """Create or update a row in the ListStore. Checks if the file has already been loaded, and if not, creates a new row in the ListStore. Either way, it then populates that row with photo metadata as read from disk. Effectively, this is used both for loading new photos, and reverting old photos, discarding any changes. Raises IOError if filename refers to a file that is not a photograph. """ photo = photos.get(uri) or Photograph(uri) photo.read() if uri not in photos: photo.label = self.labels.add(uri) photos[uri] = photo # If the user has selected the lookup method, then the timestamp # was probably calculated incorrectly the first time (before the # timezone was discovered). So call it again to get the correct value. if photo.camera.gst.get_string('timezone-method') == 'lookup': photo.calculate_timestamp() modified.discard(photo) def load_gpx_from_file(self, uri): """Parse GPX data, drawing each GPS track segment on the map.""" start_time = clock() gpx = get_trackfile(uri) self.status_message(_('%d points loaded in %.2fs.') % (len(gpx.tracks), clock() - start_time), True) if len(gpx.tracks) < 2: return metadata.alpha = min(metadata.alpha, gpx.alpha) metadata.omega = max(metadata.omega, gpx.omega) map_view.emit('realize') map_view.set_zoom_level(map_view.get_max_zoom_level()) bounds = Champlain.BoundingBox.new() for trackfile in known_trackfiles.values(): for polygon in trackfile.polygons: bounds.compose(polygon.get_bounding_box()) map_view.ensure_visible(bounds, False) for camera in known_cameras.values(): camera.set_found_timezone(gpx.timezone) def apply_selected_photos(self, button): """Manually apply map center coordinates to all unpositioned photos.""" for photo in photos.values(): if photo.manual: continue photo.manual = True photo.set_location( map_view.get_property('latitude'), map_view.get_property('longitude')) self.labels.selection.emit('changed') def save_all_files(self, widget=None): """Ensure all loaded files are saved.""" self.progressbar.show() total = len(modified) for i, photo in enumerate(list(modified), 1): self.redraw_interface(i / total, basename(photo.filename)) try: photo.write() except Exception as inst: self.status_message(str(inst)) self.progressbar.hide() self.labels.selection.emit('changed') def jump_to_photo(self, button): """Center on the first selected photo.""" photo = selected.copy().pop() if photo.valid_coords(): map_view.emit('realize') map_view.center_on(photo.latitude, photo.longitude) ################################################################################ # Dialogs. Various dialog-related methods for user interaction. ################################################################################ def update_preview(self, chooser, label, image): """Display photo thumbnail and geotag data in file chooser.""" label.set_label(self.strings.preview) image.set_from_stock(Gtk.STOCK_FILE, Gtk.IconSize.DIALOG) try: photo = Photograph(chooser.get_preview_filename(), 300) photo.read() except IOError: return image.set_from_pixbuf(photo.thumb) label.set_label( '\n'.join([photo.short_summary(), photo.maps_link()])) def add_files_dialog(self, button, chooser): """Display a file chooser, and attempt to load chosen files.""" response = chooser.run() chooser.hide() if response == Gtk.ResponseType.OK: self.open_files(chooser.get_filenames()) def confirm_quit_dialog(self, *args): """Teardown method, inform user of unsaved files, if any.""" if len(modified) == 0: Gtk.main_quit() return True dialog = get_obj('quit') dialog.format_secondary_markup(self.strings.quit % len(modified)) response = dialog.run() dialog.hide() self.redraw_interface() if response == Gtk.ResponseType.ACCEPT: self.save_all_files() if response != Gtk.ResponseType.CANCEL: Gtk.main_quit() return True ################################################################################ # Initialization and Gtk boilerplate/housekeeping type stuff and such. ################################################################################ def __init__(self): self.message_timeout_source = None self.progressbar = get_obj('progressbar') self.error = Struct({ 'message': get_obj('error_message'), 'icon': get_obj('error_icon'), 'bar': get_obj('error_bar') }) self.error.bar.connect('response', lambda widget, signal: widget.hide()) self.strings = Struct({ 'quit': get_obj('quit').get_property('secondary-text'), 'preview': get_obj('preview_label').get_text() }) self.liststore = get_obj('loaded_photos') self.liststore.set_sort_column_id(TIMESTAMP, Gtk.SortType.ASCENDING) cell_string = Gtk.CellRendererText() cell_string.set_property('wrap-mode', Pango.WrapMode.WORD) cell_string.set_property('wrap-width', 200) cell_thumb = Gtk.CellRendererPixbuf() cell_thumb.set_property('stock-id', Gtk.STOCK_MISSING_IMAGE) cell_thumb.set_property('ypad', 6) cell_thumb.set_property('xpad', 12) column = Gtk.TreeViewColumn('Photos') column.pack_start(cell_thumb, False) column.add_attribute(cell_thumb, 'pixbuf', THUMB) column.pack_start(cell_string, False) column.add_attribute(cell_string, 'markup', SUMMARY) column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) # Deal with multiple selection drag and drop. self.defer_select = False photos_view = get_obj('photos_view') photos_view.connect('button-press-event', self.photoview_pressed) photos_view.connect('button-release-event', self.photoview_released) photos_view.append_column(column) self.drag = DragController(self.open_files) self.navigator = NavigationController() self.search = SearchController() self.labels = LabelController() self.actors = ActorController() about = get_obj('about') about.set_version(REVISION) about.set_program_name(APPNAME) about.set_logo(GdkPixbuf.Pixbuf.new_from_file_at_size( join(PKG_DATA_DIR, PACKAGE + '.svg'), 192, 192)) click_handlers = { 'open_button': [self.add_files_dialog, get_obj('open')], 'save_button': [self.save_all_files], 'close_button': [lambda btn: [p.destroy() for p in selected.copy()]], 'revert_button': [lambda btn: self.open_files( [p.filename for p in modified & selected])], 'about_button': [lambda yes, you_can: you_can.run() and you_can.hide(), about], 'help_button': [lambda *ignore: Gtk.show_uri(Gdk.Screen.get_default(), 'ghelp:gottengeography', Gdk.CURRENT_TIME)], 'jump_button': [self.jump_to_photo], 'apply_button': [self.apply_selected_photos], } for button, handler in click_handlers.items(): get_obj(button).connect('clicked', *handler) # Hide the unused button that appears beside the map source menu. ugly = get_obj('map_source_menu_button').get_child().get_children()[0] ugly.set_no_show_all(True) ugly.hide() accel = Gtk.AccelGroup() window = get_obj('main') window.resize(*gst.get('window-size')) window.connect('delete_event', self.confirm_quit_dialog) window.add_accel_group(accel) window.show_all() save_size = lambda v, s, size: gst.set_window_size(size()) for prop in ['width', 'height']: map_view.connect('notify::' + prop, save_size, window.get_size) accel.connect(Gdk.keyval_from_name('q'), Gdk.ModifierType.CONTROL_MASK, 0, self.confirm_quit_dialog) self.labels.selection.emit('changed') clear_all_gpx() button = get_obj('apply_button') gst.bind('left-pane-page', get_obj('photo_camera_gps'), 'page') gst.bind('use-dark-theme', Gtk.Settings.get_default(), 'gtk-application-prefer-dark-theme') # This bit of magic will only show the apply button when there is # at least one photo loaded that is not manually positioned. # In effect, it allows you to manually drag & drop some photos, # then batch-apply all the rest btn_sense = lambda *x: button.set_sensitive( [photo for photo in photos.values() if not photo.manual]) self.liststore.connect('row-changed', btn_sense) self.liststore.connect('row-deleted', btn_sense) empty = get_obj('empty_photo_list') empty_visible = lambda l, *x: empty.set_visible(l.get_iter_first() is None) self.liststore.connect('row-changed', empty_visible) self.liststore.connect('row-deleted', empty_visible) toolbar = get_obj('photo_btn_bar') bar_visible = lambda l, *x: toolbar.set_visible(l.get_iter_first() is not None) self.liststore.connect('row-changed', bar_visible) self.liststore.connect('row-deleted', bar_visible) get_obj('open').connect('update-preview', self.update_preview, get_obj('preview_label'), get_obj('preview_image')) def redraw_interface(self, fraction=None, text=None): """Tell Gtk to redraw the user interface, so it doesn't look hung. Primarily used to update the progressbar, but also for disappearing some dialogs while things are processing in the background. Won't modify the progressbar if called with no arguments. """ if fraction is not None: self.progressbar.set_fraction(fraction) if text is not None: self.progressbar.set_text(str(text)) while Gtk.events_pending(): Gtk.main_iteration() def dismiss_message(self): """Responsible for hiding the GtkInfoBar after a timeout.""" self.message_timeout_source = None self.error.bar.hide() return False def status_message(self, message, info=False): """Display a message with the GtkInfoBar.""" self.error.message.set_markup('<b>%s</b>' % message) self.error.bar.set_message_type( Gtk.MessageType.INFO if info else Gtk.MessageType.WARNING) self.error.icon.set_from_stock( Gtk.STOCK_DIALOG_INFO if info else Gtk.STOCK_DIALOG_WARNING, 6) self.error.bar.show() # Remove any previous message timeout if self.message_timeout_source is not None: GLib.source_remove(self.message_timeout_source) if info: self.message_timeout_source = \ GLib.timeout_add_seconds(5, self.dismiss_message) # Multiple selection drag and drop copied from Kevin Mehall, adapted # to use it with the standard GtkTreeView. # http://blog.kevinmehall.net/2010/pygtk_multi_select_drag_drop def photoview_pressed(self, tree, event): """Allow the user to drag photos without losing the selection.""" target = tree.get_path_at_pos(int(event.x), int(event.y)) selection = tree.get_selection() if (target and event.type == Gdk.EventType.BUTTON_PRESS and not (event.state & (Gdk.ModifierType.CONTROL_MASK|Gdk.ModifierType.SHIFT_MASK)) and selection.path_is_selected(target[0])): # disable selection selection.set_select_function(lambda *ignore: False, None) self.defer_select = target[0] def photoview_released(self, tree, event): """Restore normal selection behavior while not dragging.""" tree.get_selection().set_select_function(lambda *ignore: True, None) target = tree.get_path_at_pos(int(event.x), int(event.y)) if (self.defer_select and target and self.defer_select == target[0] and not (event.x == 0 and event.y == 0)): # certain drag and drop tree.set_cursor(target[0], target[1], False) def main(self, anim=True): """Animate the crosshair and begin user interaction.""" if argv[1:]: self.open_files([abspath(f) for f in argv[1:]]) anim=False self.actors.animate_in(anim) Gtk.main()
def __init__(self): self.message_timeout_source = None self.progressbar = get_obj('progressbar') self.error = Struct({ 'message': get_obj('error_message'), 'icon': get_obj('error_icon'), 'bar': get_obj('error_bar') }) self.error.bar.connect('response', lambda widget, signal: widget.hide()) self.strings = Struct({ 'quit': get_obj('quit').get_property('secondary-text'), 'preview': get_obj('preview_label').get_text() }) self.liststore = get_obj('loaded_photos') self.liststore.set_sort_column_id(TIMESTAMP, Gtk.SortType.ASCENDING) cell_string = Gtk.CellRendererText() cell_string.set_property('wrap-mode', Pango.WrapMode.WORD) cell_string.set_property('wrap-width', 200) cell_thumb = Gtk.CellRendererPixbuf() cell_thumb.set_property('stock-id', Gtk.STOCK_MISSING_IMAGE) cell_thumb.set_property('ypad', 6) cell_thumb.set_property('xpad', 12) column = Gtk.TreeViewColumn('Photos') column.pack_start(cell_thumb, False) column.add_attribute(cell_thumb, 'pixbuf', THUMB) column.pack_start(cell_string, False) column.add_attribute(cell_string, 'markup', SUMMARY) column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) # Deal with multiple selection drag and drop. self.defer_select = False photos_view = get_obj('photos_view') photos_view.connect('button-press-event', self.photoview_pressed) photos_view.connect('button-release-event', self.photoview_released) photos_view.append_column(column) self.drag = DragController(self.open_files) self.navigator = NavigationController() self.search = SearchController() self.labels = LabelController() self.actors = ActorController() about = get_obj('about') about.set_version(REVISION) about.set_program_name(APPNAME) about.set_logo(GdkPixbuf.Pixbuf.new_from_file_at_size( join(PKG_DATA_DIR, PACKAGE + '.svg'), 192, 192)) click_handlers = { 'open_button': [self.add_files_dialog, get_obj('open')], 'save_button': [self.save_all_files], 'close_button': [lambda btn: [p.destroy() for p in selected.copy()]], 'revert_button': [lambda btn: self.open_files( [p.filename for p in modified & selected])], 'about_button': [lambda yes, you_can: you_can.run() and you_can.hide(), about], 'help_button': [lambda *ignore: Gtk.show_uri(Gdk.Screen.get_default(), 'ghelp:gottengeography', Gdk.CURRENT_TIME)], 'jump_button': [self.jump_to_photo], 'apply_button': [self.apply_selected_photos], } for button, handler in click_handlers.items(): get_obj(button).connect('clicked', *handler) # Hide the unused button that appears beside the map source menu. ugly = get_obj('map_source_menu_button').get_child().get_children()[0] ugly.set_no_show_all(True) ugly.hide() accel = Gtk.AccelGroup() window = get_obj('main') window.resize(*gst.get('window-size')) window.connect('delete_event', self.confirm_quit_dialog) window.add_accel_group(accel) window.show_all() save_size = lambda v, s, size: gst.set_window_size(size()) for prop in ['width', 'height']: map_view.connect('notify::' + prop, save_size, window.get_size) accel.connect(Gdk.keyval_from_name('q'), Gdk.ModifierType.CONTROL_MASK, 0, self.confirm_quit_dialog) self.labels.selection.emit('changed') clear_all_gpx() button = get_obj('apply_button') gst.bind('left-pane-page', get_obj('photo_camera_gps'), 'page') gst.bind('use-dark-theme', Gtk.Settings.get_default(), 'gtk-application-prefer-dark-theme') # This bit of magic will only show the apply button when there is # at least one photo loaded that is not manually positioned. # In effect, it allows you to manually drag & drop some photos, # then batch-apply all the rest btn_sense = lambda *x: button.set_sensitive( [photo for photo in photos.values() if not photo.manual]) self.liststore.connect('row-changed', btn_sense) self.liststore.connect('row-deleted', btn_sense) empty = get_obj('empty_photo_list') empty_visible = lambda l, *x: empty.set_visible(l.get_iter_first() is None) self.liststore.connect('row-changed', empty_visible) self.liststore.connect('row-deleted', empty_visible) toolbar = get_obj('photo_btn_bar') bar_visible = lambda l, *x: toolbar.set_visible(l.get_iter_first() is not None) self.liststore.connect('row-changed', bar_visible) self.liststore.connect('row-deleted', bar_visible) get_obj('open').connect('update-preview', self.update_preview, get_obj('preview_label'), get_obj('preview_image'))