Пример #1
0
 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())
Пример #2
0
 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())
Пример #3
0
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()
Пример #4
0
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()
Пример #5
0
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()