"Fossies" - the Fresh Open Source Software Archive

Member "PhotoCollage-1.4.5/photocollage/gtkgui.py" (9 Jul 2021, 31042 Bytes) of package /linux/privat/PhotoCollage-1.4.5.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. For more information about "gtkgui.py" see the Fossies "Dox" file reference documentation and the latest Fossies "Diffs" side-by-side code changes report: 1.4.4_vs_1.4.5.

    1 # Copyright (C) 2013 Adrien Vergé
    2 #
    3 # This program is free software; you can redistribute it and/or modify
    4 # it under the terms of the GNU General Public License as published by
    5 # the Free Software Foundation; either version 2 of the License, or
    6 # (at your option) any later version.
    7 #
    8 # This program is distributed in the hope that it will be useful,
    9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
   10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   11 # GNU General Public License for more details.
   12 #
   13 # You should have received a copy of the GNU General Public License along
   14 # with this program; if not, write to the Free Software Foundation, Inc.,
   15 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
   16 
   17 import copy
   18 import gettext
   19 from io import BytesIO
   20 import math
   21 import os.path
   22 import random
   23 import sys
   24 import urllib
   25 
   26 import cairo
   27 import gi
   28 
   29 from photocollage import APP_NAME, artwork, collage, render
   30 from photocollage.render import PIL_SUPPORTED_EXTS as EXTS
   31 
   32 gi.require_version('Gtk', '3.0')
   33 from gi.repository import Gtk, Gdk, GObject, GdkPixbuf  # noqa: E402, I100
   34 
   35 
   36 gettext.textdomain(APP_NAME)
   37 _ = gettext.gettext
   38 _n = gettext.ngettext
   39 # xgettext --keyword=_n:1,2 -o po/photocollage.pot $(find . -name '*.py')
   40 # cp po/photocollage.pot po/fr.po
   41 # msgfmt -o po/fr.mo po/fr.po
   42 
   43 
   44 def pil_image_to_cairo_surface(src):
   45     # TODO: cairo.ImageSurface.create_for_data() is not yet available in
   46     # Python 3, so we use PNG as an intermediate.
   47     buf = BytesIO()
   48     src.save(buf, "png")
   49     buf.seek(0)
   50     surface = cairo.ImageSurface.create_from_png(buf)
   51     buf.close()
   52     return surface
   53 
   54 
   55 def get_all_save_image_exts():
   56     all_types = dict(list(EXTS.RW.items()) + list(EXTS.WO.items()))
   57     all = []
   58     for type in all_types:
   59         for ext in all_types[type]:
   60             all.append(ext)
   61 
   62     return all
   63 
   64 
   65 def set_open_image_filters(dialog):
   66     """Set our own filter because Gtk.FileFilter.add_pixbuf_formats() contains
   67     formats not supported by PIL.
   68 
   69     """
   70     # Do not show the filter to the user, just limit selectable files
   71     imgfilter = Gtk.FileFilter()
   72     imgfilter.set_name(_("All supported image formats"))
   73 
   74     all_types = dict(list(EXTS.RW.items()) + list(EXTS.RO.items()))
   75     for type in all_types:
   76         for ext in all_types[type]:
   77             imgfilter.add_pattern("*." + ext)
   78             imgfilter.add_pattern("*." + ext.upper())
   79 
   80     dialog.add_filter(imgfilter)
   81     dialog.set_filter(imgfilter)
   82 
   83 
   84 def set_save_image_filters(dialog):
   85     """Set our own filter because Gtk.FileFilter.add_pixbuf_formats() contains
   86     formats not supported by PIL.
   87 
   88     """
   89     all_types = dict(list(EXTS.RW.items()) + list(EXTS.WO.items()))
   90     filters = []
   91 
   92     filters.append(Gtk.FileFilter())
   93     flt = filters[-1]
   94     flt.set_name(_("All supported image formats"))
   95     for ext in get_all_save_image_exts():
   96         flt.add_pattern("*." + ext)
   97         flt.add_pattern("*." + ext.upper())
   98     dialog.add_filter(flt)
   99     dialog.set_filter(flt)
  100 
  101     for type in all_types:
  102         filters.append(Gtk.FileFilter())
  103         flt = filters[-1]
  104         name = _("%s image") % type
  105         name += " (." + ", .".join(all_types[type]) + ")"
  106         flt.set_name(name)
  107         for ext in all_types[type]:
  108             flt.add_pattern("*." + ext)
  109             flt.add_pattern("*." + ext.upper())
  110         dialog.add_filter(flt)
  111 
  112 
  113 def gtk_run_in_main_thread(fn):
  114     def my_fn(*args, **kwargs):
  115         GObject.idle_add(fn, *args, **kwargs)
  116     return my_fn
  117 
  118 
  119 class UserCollage:
  120     """Represents a user-defined collage
  121 
  122     A UserCollage contains a list of photos (referenced by filenames) and a
  123     collage.Page object describing their layout in a final poster.
  124 
  125     """
  126     def __init__(self, photolist):
  127         self.photolist = photolist
  128 
  129     def make_page(self, opts):
  130         # Define the output image height / width ratio
  131         ratio = 1.0 * opts.out_h / opts.out_w
  132 
  133         # Compute a good number of columns. It depends on the ratio, the number
  134         # of images and the average ratio of these images. According to my
  135         # calculations, the number of column should be inversely proportional
  136         # to the square root of the output image ratio, and proportional to the
  137         # square root of the average input images ratio.
  138         avg_ratio = (sum(1.0 * photo.h / photo.w for photo in self.photolist) /
  139                      len(self.photolist))
  140         # Virtual number of images: since ~ 1 image over 3 is in a multi-cell
  141         # (i.e. takes two columns), it takes the space of 4 images.
  142         # So it's equivalent to 1/3 * 4 + 2/3 = 2 times the number of images.
  143         virtual_no_imgs = 2 * len(self.photolist)
  144         no_cols = int(round(math.sqrt(avg_ratio / ratio * virtual_no_imgs)))
  145 
  146         self.page = collage.Page(1.0, ratio, no_cols)
  147         random.shuffle(self.photolist)
  148         for photo in self.photolist:
  149             self.page.add_cell(photo)
  150         self.page.adjust()
  151 
  152     def duplicate(self):
  153         return UserCollage(copy.copy(self.photolist))
  154 
  155 
  156 class PhotoCollageWindow(Gtk.Window):
  157     TARGET_TYPE_TEXT = 1
  158     TARGET_TYPE_URI = 2
  159 
  160     def __init__(self):
  161         super().__init__(title=_("PhotoCollage"))
  162         self.history = []
  163         self.history_index = 0
  164 
  165         class Options:
  166             def __init__(self):
  167                 self.border_w = 0.01
  168                 self.border_c = "black"
  169                 self.out_w = 800
  170                 self.out_h = 600
  171 
  172         self.opts = Options()
  173 
  174         self.make_window()
  175 
  176     def make_window(self):
  177         self.set_border_width(10)
  178 
  179         box_window = Gtk.Box(spacing=10, orientation=Gtk.Orientation.VERTICAL)
  180         self.add(box_window)
  181 
  182         # -----------------------
  183         #  Input and output pan
  184         # -----------------------
  185 
  186         box = Gtk.Box(spacing=6, orientation=Gtk.Orientation.HORIZONTAL)
  187         box_window.pack_start(box, False, False, 0)
  188 
  189         self.btn_choose_images = Gtk.Button(label=_("Add images..."))
  190         self.btn_choose_images.set_image(Gtk.Image.new_from_stock(
  191             Gtk.STOCK_OPEN, Gtk.IconSize.LARGE_TOOLBAR))
  192         self.btn_choose_images.set_always_show_image(True)
  193         self.btn_choose_images.connect("clicked", self.choose_images)
  194         box.pack_start(self.btn_choose_images, False, False, 0)
  195 
  196         self.btn_save = Gtk.Button(label=_("Save poster..."))
  197         self.btn_save.set_image(Gtk.Image.new_from_stock(
  198             Gtk.STOCK_SAVE_AS, Gtk.IconSize.LARGE_TOOLBAR))
  199         self.btn_save.set_always_show_image(True)
  200         self.btn_save.connect("clicked", self.save_poster)
  201         box.pack_start(self.btn_save, False, False, 0)
  202 
  203         # -----------------------
  204         #  Tools pan
  205         # -----------------------
  206 
  207         box.pack_start(Gtk.SeparatorToolItem(), True, True, 0)
  208 
  209         self.btn_undo = Gtk.Button()
  210         self.btn_undo.set_image(Gtk.Image.new_from_stock(
  211             Gtk.STOCK_UNDO, Gtk.IconSize.LARGE_TOOLBAR))
  212         self.btn_undo.connect("clicked", self.select_prev_layout)
  213         box.pack_start(self.btn_undo, False, False, 0)
  214         self.lbl_history_index = Gtk.Label(" ")
  215         box.pack_start(self.lbl_history_index, False, False, 0)
  216         self.btn_redo = Gtk.Button()
  217         self.btn_redo.set_image(Gtk.Image.new_from_stock(
  218             Gtk.STOCK_REDO, Gtk.IconSize.LARGE_TOOLBAR))
  219         self.btn_redo.connect("clicked", self.select_next_layout)
  220         box.pack_start(self.btn_redo, False, False, 0)
  221         self.btn_new_layout = Gtk.Button(label=_("Regenerate"))
  222         self.btn_new_layout.set_image(Gtk.Image.new_from_stock(
  223             Gtk.STOCK_REFRESH, Gtk.IconSize.LARGE_TOOLBAR))
  224         self.btn_new_layout.set_always_show_image(True)
  225         self.btn_new_layout.connect("clicked", self.regenerate_layout)
  226         box.pack_start(self.btn_new_layout, False, False, 0)
  227 
  228         box.pack_start(Gtk.SeparatorToolItem(), True, True, 0)
  229 
  230         self.btn_settings = Gtk.Button()
  231         self.btn_settings.set_image(Gtk.Image.new_from_stock(
  232             Gtk.STOCK_PREFERENCES, Gtk.IconSize.LARGE_TOOLBAR))
  233         self.btn_settings.set_always_show_image(True)
  234         self.btn_settings.connect("clicked", self.set_settings)
  235         box.pack_end(self.btn_settings, False, False, 0)
  236 
  237         # -------------------
  238         #  Image preview pan
  239         # -------------------
  240 
  241         box = Gtk.Box(spacing=10)
  242         box_window.pack_start(box, True, True, 0)
  243 
  244         self.img_preview = ImagePreviewArea(self)
  245         self.img_preview.set_size_request(600, 400)
  246         self.img_preview.connect("drag-data-received", self.on_drag)
  247         self.img_preview.drag_dest_set(Gtk.DestDefaults.ALL, [],
  248                                        Gdk.DragAction.COPY)
  249         targets = Gtk.TargetList.new([])
  250         targets.add_text_targets(PhotoCollageWindow.TARGET_TYPE_TEXT)
  251         targets.add_uri_targets(PhotoCollageWindow.TARGET_TYPE_URI)
  252         self.img_preview.drag_dest_set_target_list(targets)
  253 
  254         box.pack_start(self.img_preview, True, True, 0)
  255 
  256         self.btn_save.set_sensitive(False)
  257 
  258         self.btn_undo.set_sensitive(False)
  259         self.btn_redo.set_sensitive(False)
  260 
  261         self.update_photolist([])
  262 
  263     def update_photolist(self, new_images):
  264         try:
  265             photolist = []
  266             if self.history_index < len(self.history):
  267                 photolist = copy.copy(
  268                     self.history[self.history_index].photolist)
  269             photolist.extend(render.build_photolist(new_images))
  270 
  271             if len(photolist) > 0:
  272                 new_collage = UserCollage(photolist)
  273                 new_collage.make_page(self.opts)
  274                 self.render_from_new_collage(new_collage)
  275             else:
  276                 self.update_tool_buttons()
  277         except render.BadPhoto as e:
  278             dialog = ErrorDialog(
  279                 self, _("This image could not be opened:\n\"%(imgname)s\".")
  280                 % {"imgname": e.photoname})
  281             dialog.run()
  282             dialog.destroy()
  283 
  284     def choose_images(self, button):
  285         dialog = PreviewFileChooserDialog(title=_("Choose images"),
  286                                           parent=button.get_toplevel(),
  287                                           action=Gtk.FileChooserAction.OPEN,
  288                                           select_multiple=True,
  289                                           modal=True)
  290 
  291         if dialog.run() == Gtk.ResponseType.OK:
  292             files = dialog.get_filenames()
  293             dialog.destroy()
  294             self.update_photolist(files)
  295         else:
  296             dialog.destroy()
  297 
  298     def on_drag(self, widget, drag_context, x, y, data, info, time):
  299         if info == PhotoCollageWindow.TARGET_TYPE_TEXT:
  300             files = data.get_text().splitlines()
  301         elif info == PhotoCollageWindow.TARGET_TYPE_URI:
  302             # Can only handle local URIs
  303             files = [f for f in data.get_uris() if f.startswith("file://")]
  304 
  305         for i in range(len(files)):
  306             if files[i].startswith("file://"):
  307                 files[i] = urllib.parse.unquote(files[i][7:])
  308         self.update_photolist(files)
  309 
  310     def render_preview(self):
  311         collage = self.history[self.history_index]
  312 
  313         # If the desired ratio changed in the meantime (e.g. from landscape to
  314         # portrait), it needs to be re-updated
  315         collage.page.target_ratio = 1.0 * self.opts.out_h / self.opts.out_w
  316         collage.page.adjust_cols_heights()
  317 
  318         w = self.img_preview.get_allocation().width
  319         h = self.img_preview.get_allocation().height
  320         collage.page.scale_to_fit(w, h)
  321 
  322         # Display a "please wait" dialog and do the job.
  323         compdialog = ComputingDialog(self)
  324 
  325         def on_update(img, fraction_complete):
  326             self.img_preview.set_collage(img, collage)
  327             compdialog.update(fraction_complete)
  328 
  329         def on_complete(img):
  330             self.img_preview.set_collage(img, collage)
  331             compdialog.destroy()
  332             self.btn_save.set_sensitive(True)
  333 
  334         def on_fail(exception):
  335             dialog = ErrorDialog(self, "{}:\n\n{}".format(
  336                 _("An error occurred while rendering image:"), exception))
  337             compdialog.destroy()
  338             dialog.run()
  339             dialog.destroy()
  340             self.btn_save.set_sensitive(False)
  341 
  342         t = render.RenderingTask(
  343             collage.page,
  344             border_width=self.opts.border_w * max(collage.page.w,
  345                                                   collage.page.h),
  346             border_color=self.opts.border_c,
  347             on_update=gtk_run_in_main_thread(on_update),
  348             on_complete=gtk_run_in_main_thread(on_complete),
  349             on_fail=gtk_run_in_main_thread(on_fail))
  350         t.start()
  351 
  352         response = compdialog.run()
  353         if response == Gtk.ResponseType.CANCEL:
  354             t.abort()
  355             compdialog.destroy()
  356 
  357     def render_from_new_collage(self, collage):
  358         self.history.append(collage)
  359         self.history_index = len(self.history) - 1
  360         self.update_tool_buttons()
  361         self.render_preview()
  362 
  363     def regenerate_layout(self, button=None):
  364         new_collage = self.history[self.history_index].duplicate()
  365         new_collage.make_page(self.opts)
  366         self.render_from_new_collage(new_collage)
  367 
  368     def select_prev_layout(self, button):
  369         self.history_index -= 1
  370         self.update_tool_buttons()
  371         self.render_preview()
  372 
  373     def select_next_layout(self, button):
  374         self.history_index += 1
  375         self.update_tool_buttons()
  376         self.render_preview()
  377 
  378     def set_settings(self, button):
  379         dialog = SettingsDialog(self)
  380         response = dialog.run()
  381         if response == Gtk.ResponseType.OK:
  382             dialog.apply_opts(self.opts)
  383             dialog.destroy()
  384             if self.history:
  385                 self.render_preview()
  386         else:
  387             dialog.destroy()
  388 
  389     def save_poster(self, button):
  390         collage = self.history[self.history_index]
  391 
  392         enlargement = float(self.opts.out_w) / collage.page.w
  393         collage.page.scale(enlargement)
  394 
  395         dialog = Gtk.FileChooserDialog(_("Save image"), button.get_toplevel(),
  396                                        Gtk.FileChooserAction.SAVE)
  397         dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
  398         dialog.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
  399         dialog.set_do_overwrite_confirmation(True)
  400         set_save_image_filters(dialog)
  401         if dialog.run() != Gtk.ResponseType.OK:
  402             dialog.destroy()
  403             return
  404         savefile = dialog.get_filename()
  405         base, ext = os.path.splitext(savefile)
  406         if ext == "" or not ext[1:].lower() in get_all_save_image_exts():
  407             savefile += ".jpg"
  408         dialog.destroy()
  409 
  410         # Display a "please wait" dialog and do the job.
  411         compdialog = ComputingDialog(self)
  412 
  413         def on_update(img, fraction_complete):
  414             compdialog.update(fraction_complete)
  415 
  416         def on_complete(img):
  417             compdialog.destroy()
  418 
  419         def on_fail(exception):
  420             dialog = ErrorDialog(self, "{}:\n\n{}".format(
  421                 _("An error occurred while rendering image:"), exception))
  422             compdialog.destroy()
  423             dialog.run()
  424             dialog.destroy()
  425 
  426         t = render.RenderingTask(
  427             collage.page, output_file=savefile,
  428             border_width=self.opts.border_w * max(collage.page.w,
  429                                                   collage.page.h),
  430             border_color=self.opts.border_c,
  431             on_update=gtk_run_in_main_thread(on_update),
  432             on_complete=gtk_run_in_main_thread(on_complete),
  433             on_fail=gtk_run_in_main_thread(on_fail))
  434         t.start()
  435 
  436         response = compdialog.run()
  437         if response == Gtk.ResponseType.CANCEL:
  438             t.abort()
  439             compdialog.destroy()
  440 
  441     def update_tool_buttons(self):
  442         self.btn_undo.set_sensitive(self.history_index > 0)
  443         self.btn_redo.set_sensitive(self.history_index < len(self.history) - 1)
  444         if self.history_index < len(self.history):
  445             self.lbl_history_index.set_label(str(self.history_index + 1))
  446         else:
  447             self.lbl_history_index.set_label(" ")
  448         self.btn_save.set_sensitive(
  449             self.history_index < len(self.history))
  450         self.btn_new_layout.set_sensitive(
  451             self.history_index < len(self.history))
  452 
  453 
  454 class ImagePreviewArea(Gtk.DrawingArea):
  455     """Area to display the poster preview and react to user actions"""
  456     INSENSITIVE, FLYING, SWAPPING_OR_MOVING = range(3)
  457 
  458     def __init__(self, parent):
  459         super().__init__()
  460         self.parent = parent
  461 
  462         parse, color = Gdk.Color.parse("#888888")
  463         self.modify_bg(Gtk.StateType.NORMAL, color)
  464 
  465         # http://www.pygtk.org/pygtk2tutorial/sec-EventHandling.html
  466         # https://developer.gnome.org/gdk3/stable/gdk3-Events.html#GdkEventMask
  467         self.connect("draw", self.draw)
  468         self.connect("motion-notify-event", self.motion_notify_event)
  469         self.connect("leave-notify-event", self.motion_notify_event)
  470         self.connect("button-press-event", self.button_press_event)
  471         self.connect("button-release-event", self.button_release_event)
  472         self.set_events(Gdk.EventMask.EXPOSURE_MASK |
  473                         Gdk.EventMask.LEAVE_NOTIFY_MASK |
  474                         Gdk.EventMask.BUTTON_PRESS_MASK |
  475                         Gdk.EventMask.BUTTON_RELEASE_MASK |
  476                         Gdk.EventMask.POINTER_MOTION_MASK)
  477 
  478         self.image = None
  479         self.mode = self.INSENSITIVE
  480 
  481         class SwapEnd:
  482             def __init__(self, cell=None, x=0, y=0):
  483                 self.cell = cell
  484                 self.x = x
  485                 self.y = y
  486 
  487         self.x, self.y = 0, 0
  488         self.swap_origin = SwapEnd()
  489         self.swap_dest = SwapEnd()
  490 
  491     def set_collage(self, image, collage):
  492         self.image = pil_image_to_cairo_surface(image)
  493         # The Collage object must be deeply copied.
  494         # Otherwise, SWAPPING_OR_MOVING photos in a new page would also affect
  495         # the original page (in history).
  496         # The deep copy is done here (not in button_release_event) because
  497         # references to cells are gathered in other functions, so that making
  498         # the copy at the end would invalidate these references.
  499         self.collage = copy.deepcopy(collage)
  500         self.mode = self.FLYING
  501         self.queue_draw()
  502 
  503     def get_image_offset(self):
  504         return (round((self.get_allocation().width -
  505                        self.image.get_width()) / 2.0),
  506                 round((self.get_allocation().height -
  507                        self.image.get_height()) / 2.0))
  508 
  509     def get_pos_in_image(self, x, y):
  510         if self.image is not None:
  511             x0, y0 = self.get_image_offset()
  512             return (int(round(x - x0)), int(round(y - y0)))
  513         return (int(round(x)), int(round(y)))
  514 
  515     def paint_image_border(self, context, cell, dash=None):
  516         x0, y0 = self.get_image_offset()
  517 
  518         context.set_source_rgb(1.0, 1.0, 0.0)
  519         context.set_line_width(2)
  520         if dash is not None:
  521             context.set_dash(dash)
  522         context.rectangle(x0 + cell.x + 1, y0 + cell.y + 1,
  523                           cell.w - 2, cell.h - 2)
  524         context.stroke()
  525 
  526     def paint_image_delete_button(self, context, cell):
  527         x0, y0 = self.get_image_offset()
  528 
  529         x = x0 + cell.x + cell.w - 12
  530         y = y0 + cell.y + 12
  531 
  532         context.arc(x, y, 8, 0, 6.2832)
  533         context.set_source_rgb(0.8, 0.0, 0.0)
  534         context.fill()
  535         context.arc(x, y, 8, 0, 6.2832)
  536         context.set_source_rgb(0.0, 0.0, 0.0)
  537         context.set_line_width(1)
  538         context.move_to(x - 4, y - 4)
  539         context.line_to(x + 4, y + 4)
  540         context.move_to(x - 4, y + 4)
  541         context.line_to(x + 4, y - 4)
  542         context.stroke()
  543 
  544     def draw(self, widget, context):
  545         if self.image is not None:
  546             x0, y0 = self.get_image_offset()
  547             context.set_source_surface(self.image, x0, y0)
  548             context.paint()
  549 
  550             if self.mode == self.FLYING:
  551                 cell = self.collage.page.get_cell_at_position(self.x, self.y)
  552                 if cell:
  553                     self.paint_image_border(context, cell)
  554                     self.paint_image_delete_button(context, cell)
  555             elif self.mode == self.SWAPPING_OR_MOVING:
  556                 self.paint_image_border(context, self.swap_origin.cell, (3, 3))
  557                 cell = self.collage.page.get_cell_at_position(self.x, self.y)
  558                 if cell and cell != self.swap_origin.cell:
  559                     self.paint_image_border(context, cell, (3, 3))
  560         else:
  561             # Display the drag & drop image
  562             dnd_image = artwork.load_cairo_surface(artwork.ICON_DRAG_AND_DROP)
  563             context.set_source_surface(
  564                 dnd_image,
  565                 round((self.get_allocation().width -
  566                        dnd_image.get_width()) / 2.0),
  567                 round((self.get_allocation().height -
  568                        dnd_image.get_height()) / 2.0))
  569             context.paint()
  570 
  571         return False
  572 
  573     def motion_notify_event(self, widget, event):
  574         self.x, self.y = self.get_pos_in_image(event.x, event.y)
  575         widget.queue_draw()
  576 
  577     def button_press_event(self, widget, event):
  578         if self.mode == self.FLYING:
  579             x, y = self.get_pos_in_image(event.x, event.y)
  580             cell = self.collage.page.get_cell_at_position(x, y)
  581             if not cell:
  582                 return
  583             # Has the user clicked the delete button?
  584             dist = (cell.x + cell.w - 12 - x) ** 2 + (cell.y + 12 - y) ** 2
  585             if dist <= 8 * 8:
  586                 self.collage.photolist.remove(cell.photo)
  587                 if self.collage.photolist:
  588                     self.collage.make_page(self.parent.opts)
  589                     self.parent.render_from_new_collage(self.collage)
  590                 else:
  591                     self.image = None
  592                     self.mode = self.INSENSITIVE
  593                     self.parent.history_index = len(self.parent.history)
  594                     self.parent.update_tool_buttons()
  595             # Otherwise, the user wants to swap this image with another
  596             else:
  597                 self.swap_origin.x, self.swap_origin.y = x, y
  598                 self.swap_origin.cell = cell
  599                 self.mode = self.SWAPPING_OR_MOVING
  600         widget.queue_draw()
  601 
  602     def button_release_event(self, widget, event):
  603         if self.mode == self.SWAPPING_OR_MOVING:
  604             self.swap_dest.x, self.swap_dest.y = \
  605                 self.get_pos_in_image(event.x, event.y)
  606             self.swap_dest.cell = self.collage.page.get_cell_at_position(
  607                 self.swap_dest.x, self.swap_dest.y)
  608             if self.swap_dest.cell \
  609                     and self.swap_origin.cell != self.swap_dest.cell:
  610                 # different cell: SWAPPING
  611                 self.collage.page.swap_photos(self.swap_origin.cell,
  612                                               self.swap_dest.cell)
  613                 self.parent.render_from_new_collage(self.collage)
  614             elif self.swap_dest.cell:
  615                 # same cell: MOVING
  616                 move_x = (self.swap_origin.x - self.x) / self.swap_dest.cell.w
  617                 move_y = (self.swap_origin.y - self.y) / self.swap_dest.cell.h
  618                 self.swap_dest.cell.photo.move(move_x, move_y)
  619                 self.parent.render_from_new_collage(self.collage)
  620             self.mode = self.FLYING
  621         widget.queue_draw()
  622 
  623 
  624 class SettingsDialog(Gtk.Dialog):
  625     def __init__(self, parent):
  626         super().__init__(
  627             _("Settings"), parent, 0,
  628             (Gtk.STOCK_OK, Gtk.ResponseType.OK,
  629              Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL))
  630         self.set_border_width(10)
  631 
  632         self.selected_border_color = parent.opts.border_c
  633 
  634         box = self.get_content_area()
  635         vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
  636         box.add(vbox)
  637 
  638         label = Gtk.Label(xalign=0)
  639         label.set_markup("<big><b>%s</b></big>" % _("Output image size"))
  640         vbox.pack_start(label, False, False, 0)
  641 
  642         box = Gtk.Box(spacing=6)
  643         vbox.pack_start(box, False, False, 0)
  644         self.etr_outw = Gtk.Entry(text=str(parent.opts.out_w))
  645         self.etr_outw.connect("changed", self.validate_int)
  646         self.etr_outw.last_valid_text = self.etr_outw.get_text()
  647         box.pack_start(self.etr_outw, False, False, 0)
  648         box.pack_start(Gtk.Label("×", xalign=0), False, False, 0)
  649         self.etr_outh = Gtk.Entry(text=str(parent.opts.out_h))
  650         self.etr_outh.connect("changed", self.validate_int)
  651         self.etr_outh.last_valid_text = self.etr_outh.get_text()
  652         box.pack_start(self.etr_outh, False, False, 0)
  653 
  654         box.pack_end(Gtk.Label(_("pixels"), xalign=0), False, False, 0)
  655 
  656         templates = (
  657             ("", None),
  658             ("800 × 600", (800, 600)),
  659             ("1600 × 1200", (1600, 1200)),
  660             ("A4 landscape (300ppi)", (3508, 2480)),
  661             ("A4 portrait (300ppi)", (2480, 3508)),
  662             ("A3 landscape (300ppi)", (4960, 3508)),
  663             ("A3 portrait (300ppi)", (3508, 4960)),
  664             ("US-Letter landscape (300ppi)", (3300, 2550)),
  665             ("US-Letter portrait (300ppi)", (2550, 3300)),
  666         )
  667 
  668         def apply_template(combo):
  669             t = combo.get_model()[combo.get_active_iter()][1]
  670             if t:
  671                 dims = dict(templates)[t]
  672                 self.etr_outw.set_text(str(dims[0]))
  673                 self.etr_outh.set_text(str(dims[1]))
  674                 self.cmb_template.set_active(0)
  675 
  676         box = Gtk.Box(spacing=6)
  677         vbox.pack_start(box, False, False, 0)
  678         box.pack_start(Gtk.Label(_("Apply a template:"), xalign=0),
  679                        True, True, 0)
  680 
  681         self.cmb_template = Gtk.ComboBoxText()
  682         for t, d in templates:
  683             self.cmb_template.append(t, t)
  684         self.cmb_template.set_active(0)
  685         self.cmb_template.connect("changed", apply_template)
  686         box.pack_start(self.cmb_template, False, False, 0)
  687 
  688         vbox.pack_start(Gtk.SeparatorToolItem(), True, True, 0)
  689 
  690         label = Gtk.Label(xalign=0)
  691         label.set_markup("<big><b>%s</b></big>" % _("Border"))
  692         vbox.pack_start(label, False, False, 0)
  693 
  694         box = Gtk.Box(spacing=6)
  695         vbox.pack_start(box, False, False, 0)
  696         label = Gtk.Label(_("Thickness:"), xalign=0)
  697         box.pack_start(label, False, False, 0)
  698         self.etr_border = Gtk.Entry(text=str(100.0 * parent.opts.border_w))
  699         self.etr_border.connect("changed", self.validate_float)
  700         self.etr_border.last_valid_text = self.etr_border.get_text()
  701         self.etr_border.set_width_chars(4)
  702         self.etr_border.set_alignment(1.0)
  703         box.pack_start(self.etr_border, False, False, 0)
  704         label = Gtk.Label("%", xalign=0)
  705         box.pack_start(label, False, False, 0)
  706 
  707         label = Gtk.Label(_("Color:"), xalign=1)
  708         box.pack_start(label, True, True, 0)
  709         self.colorbutton = Gtk.ColorButton()
  710         color = Gdk.RGBA()
  711         color.parse(parent.opts.border_c)
  712         self.colorbutton.set_rgba(color)
  713         box.pack_end(self.colorbutton, False, False, 0)
  714 
  715         vbox.pack_start(Gtk.SeparatorToolItem(), True, True, 0)
  716 
  717         self.show_all()
  718 
  719     def validate_int(self, entry):
  720         entry_text = entry.get_text() or '0'
  721         try:
  722             int(entry_text)
  723             entry.last_valid_text = entry_text
  724         except ValueError:
  725             entry.set_text(entry.last_valid_text)
  726 
  727     def validate_float(self, entry):
  728         entry_text = entry.get_text() or '0'
  729         try:
  730             float(entry_text)
  731             entry.last_valid_text = entry_text
  732         except ValueError:
  733             entry.set_text(entry.last_valid_text)
  734 
  735     def apply_opts(self, opts):
  736         opts.out_w = int(self.etr_outw.get_text() or '1')
  737         opts.out_h = int(self.etr_outh.get_text() or '1')
  738         opts.border_w = float(self.etr_border.get_text() or '0') / 100.0
  739         opts.border_c = self.colorbutton.get_rgba().to_string()
  740 
  741 
  742 class ComputingDialog(Gtk.Dialog):
  743     """Simple "please wait" dialog, with a "cancel" button"""
  744     def __init__(self, parent):
  745         super().__init__(
  746             _("Please wait"), parent, 0, (Gtk.STOCK_CANCEL,
  747                                           Gtk.ResponseType.CANCEL))
  748         self.set_default_size(300, -1)
  749         self.set_border_width(10)
  750 
  751         box = self.get_content_area()
  752         vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
  753         box.add(vbox)
  754 
  755         label = Gtk.Label(_("Performing image computation..."))
  756         vbox.pack_start(label, True, True, 0)
  757 
  758         self.progressbar = Gtk.ProgressBar()
  759         self.progressbar.set_fraction(0)
  760         vbox.pack_start(self.progressbar, True, True, 0)
  761 
  762         self.show_all()
  763 
  764     def update(self, fraction):
  765         self.progressbar.set_fraction(fraction)
  766 
  767 
  768 class ErrorDialog(Gtk.Dialog):
  769     def __init__(self, parent, message):
  770         super().__init__(_("Error"), parent, 0,
  771                          (Gtk.STOCK_OK, Gtk.ResponseType.OK))
  772         self.set_border_width(10)
  773         box = self.get_content_area()
  774         box.add(Gtk.Label(message))
  775         self.show_all()
  776 
  777 
  778 class PreviewFileChooserDialog(Gtk.FileChooserDialog):
  779     PREVIEW_MAX_SIZE = 256
  780 
  781     def __init__(self, **kw):
  782         super().__init__(**kw)
  783 
  784         self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
  785         self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
  786 
  787         set_open_image_filters(self)
  788 
  789         self._preview = Gtk.Image()
  790         # Don't let preview size down horizontally for skinny images, cause
  791         # that looks distracting
  792         self._preview.set_size_request(
  793             PreviewFileChooserDialog.PREVIEW_MAX_SIZE, -1)
  794         self.set_preview_widget(self._preview)
  795         self.set_use_preview_label(False)
  796         self.connect("update-preview", self.update_preview_cb)
  797 
  798     def update_preview_cb(self, file_chooser):
  799         filename = self.get_preview_filename()
  800         if filename is None or os.path.isdir(filename):
  801             self.set_preview_widget_active(False)
  802             return
  803         try:
  804             pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
  805                 filename,
  806                 PreviewFileChooserDialog.PREVIEW_MAX_SIZE,
  807                 PreviewFileChooserDialog.PREVIEW_MAX_SIZE)
  808             self._preview.set_from_pixbuf(pixbuf)
  809         except Exception as e:
  810             print(e)
  811             self.set_preview_widget_active(False)
  812             return
  813         self.set_preview_widget_active(True)
  814 
  815 
  816 def main():
  817     # Enable threading. Without that, threads hang!
  818     GObject.threads_init()
  819 
  820     win = PhotoCollageWindow()
  821     win.connect("delete-event", Gtk.main_quit)
  822     win.show_all()
  823 
  824     # If arguments are given, treat them as input images
  825     if len(sys.argv) > 1:
  826         win.update_photolist(sys.argv[1:])
  827 
  828     Gtk.main()