metapho.gtkpho

Package metapho :: Module gtkpho |
[hide private]

# Source Code for Module metapho.gtkpho

      1  #!/usr/bin/env python
      2
      3  '''
      4  GTK UI classes for metapho: an image tagger and viewer.
      5  '''
      6
      7  # Copyright 2013 by Akkana Peck: share and enjoy under the GPL v2 or later.
      8
      9  from . import metapho
     10
     11  import gtk
     12  import gc
     13  import glib, gobject
     14  import os
     15  import collections
     16
     17 -class TagViewer(metapho.Tagger, gtk.Table) :
     18      '''A PyGTK widget for showing tags.
     19      '''
     20 -    def __init__(self, parentwin) :
     21          metapho.Tagger.__init__(self)
     22          self.num_rows = 26
     23          gtk.Table.__init__(self, 4, self.num_rows, False)
     24
     25          self.parentwin = parentwin
     26
     27          self.title = gtk.Label("Tags")
     28          self.attach(self.title, 0, 4, 0, 1 );
     29
     30          hbox = gtk.HBox()
     31          hbox.pack_start(gtk.Label("Tag category:"), expand=False)
     32
     33          edit_btn = gtk.Button("Edit")
     34          edit_btn.connect("clicked", self.edit_categories)
     35          hbox.pack_end(edit_btn, expand=False)
     36          edit_btn.unset_flags(gtk.CAN_FOCUS)
     37
     38          # Set up a combobox with a text entry, so the user can change cats.
     39          # To make it editable is immensely more complicated than just
     40          # calling gtk.combo_box_entry_new_text(); thanks to Juhaz on #pygtk
     41          # for an example of how to set it up.
     42          # self.categorysel = gtk.combo_box_entry_new_text()
     43          self.cat_list_store = gtk.ListStore(str)
     44          self.categorysel = gtk.ComboBox(self.cat_list_store)
     45          # self.categorysel = gtk.ComboBoxEntry(self.cat_list_store, 0)
     46          cr = gtk.CellRendererText()
     47          self.categorysel.pack_start(cr)
     48          self.categorysel.set_attributes(cr, text=0)
     49          # Try to keep focus out of the combobox -- but it's not possible.
     50          self.categorysel.unset_flags(gtk.CAN_FOCUS)
     51
     52          hbox.pack_start(self.categorysel, expand=True)
     53
     54          self.attach(hbox, 0, 4, 1, 2 );
     55
     56          self.cur_img = None
     57          self.highlight_bg = gtk.gdk.color_parse("#FFFFFF")
     58          self.greyBG = gtk.gdk.color_parse("#DDDDDD")
     59          self.matchBG = gtk.gdk.color_parse("#DDFFEE")
     60          self.ignore_events = False
     61
     62          self.editing = False
     63
     64          # Set up a bunch of entries, also setting the table size:
     65          self.buttons = []
     66          self.entries = []
     67          self.button_names = []
     68          for j in range(0, 2) :
     69              for i in range(0, self.num_rows) :
     70                  if j <= 0 :
     71                      buttonchar = chr(i + ord('a'))
     72                      left = 0
     73                  else :
     74                      buttonchar = chr(i + ord('A'))
     75                      left = 2
     76
     77                  button = gtk.ToggleButton(buttonchar)
     78                  self.attach(button, left, left+1, i+2, i+3 );
     79                  self.buttons.append(button)
     80                  button.connect("toggled", self.toggled, len(self.entries))
     81
     82                  entry = gtk.Entry()
     83                  entry.set_width_chars(25)
     84                  #entry.connect("changed", self.entry_changed, i)
     85                  #entry.connect("focus-in-event", self.focus_in, i)
     86                  entry.connect("focus-out-event", self.focus_out,
     87                                len(self.entries))
     88                  self.attach(entry, left+1, left+2, i+2, i+3 );
     89                  self.entries.append(entry)
     90
     91          self.show()
     92
     93 -    def change_tag(self, tagno, newstr) :
     94          '''Update a tag: called on focus_out from one of the text entries'''
     95          if tagno < len(self.categories[self.current_category]) :
     96              self.tag_list[self.categories[self.current_category][tagno]] = newstr
     97          else :
     98              newtag = self.add_tag(newstr, self.cur_img)
     99              self.highlight_tag(newtag, True)
    100
    101 -    def clear_tags(self, img) :
    102          metapho.Tagger.clear_tags(self, img)
    103
    104          # also update the UI
    105          for i in xrange(len((self.entries))) :
    106              self.highlight_tag(i, False)
    107
    108          # leave nothing focused
    109          self.focus_none()
    110
    111 -    def unhighlight_empty_entries(self) :
    112          '''Check whether any entries are empty.
    113             If so, make sure they're unhighlighted.
    114          '''
    115          for i, ent in enumerate(self.entries) :
    116              if self.buttons[i].get_active() and not ent.get_text() :
    117                  self.highlight_tag(i, False)
    118
    119 -    def focus_none(self) :
    120          '''Un-focus any currently focused text entry,
    121             leaving nothing focused.
    122             If there was a focused entry and it was empty,
    123             de-select the corresponding toggle button.
    124          '''
    125          focused = self.parentwin.get_focus()
    126
    127          # if focus was in a text entry, un-highlight that entry.
    128          # if (type(focused) is gtk.Entry) :
    129          #     print "It's an entry"
    130          #     entryno = self.entries.index(focused)
    131          #     self.highlight_tag(entryno, False)
    132
    133          # Make sure we're leaving nothing focused:
    134          self.unhighlight_empty_entries()
    135          self.parentwin.set_focus(None)
    136
    137 -    def focus_out(self, entry, event, tagno) :
    138          entry_text = entry.get_text()
    139          # Ignore blank entries
    140          if entry_text.strip() == '' :
    141              return
    142          self.change_tag(tagno, entry_text)
    143          return True
    144
    145 -    def toggled(self, button, tagno) :
    146          # We'll get a recursion loop if we don't block events here --
    147          # adding and removing tags update the GUI state, which
    148          # changes the toggle button state, which calls toggled() again.
    149          if self.ignore_events :
    150              return
    151
    152          # get_active() is the state *after* the button has been pressed.
    153          if button.get_active() :
    154              # Was off, now on, so add the tag.
    155              self.add_tag(tagno, self.cur_img)
    156          else :
    157              # It's already on, so toggle it off.
    158              self.remove_tag(tagno, self.cur_img)
    159
    160          # Often when the user clicks on a button it's because
    161          # focus was in a text field. We definitely don't want it
    162          # to stay there.
    163          self.focus_none()
    164
    165          return True
    166
    167 -    def check_entry_tag(focused_widget) :
    168          '''At certain times, such as just before exit, the main window
    169             may call us to alert us that a tag may have changed.
    170             We need to find out which entry contains it and check the tag.
    171          '''
    172          for i, ent in enumerate(self.entries) :
    173              if focused_widget == ent :
    174                  self.focus_out(ent, None, i)
    175
    176 -    def display_tags(self) :
    177          '''Called after read_tags() has been read for all directories.'''
    178
    179          # Add our categories to the combo.
    180          if self.categories :
    181              for catname in self.categories.keys() :
    182                  # self.categorysel.append_text(catname)
    183                  self.cat_list_store.append((catname,))
    184          else :
    185              self.cat_list_store.append((self.current_category,))
    186
    187          # Set the first category as current, and display its tags.
    188          if self.categories :
    189              self.current_category = self.categories.keys()[0]
    190          # else the current category should still be at the default.
    191          self.display_tags_for_category(self.current_category)
    192
    193          self.categorysel.set_active(0)
    194          self.categorysel.connect("changed", self.change_category)
    195
    196 -    def display_tags_for_category(self, catname) :
    197          # Is this a new category, not in the list?
    198          if catname not in self.categories.keys() :
    199              print catname, "was not in the category list"
    200              for i in range(len(self.entries)) :
    201                  self.entries[i].set_text("")
    202                  self.highlight_tag(i, False)
    203              return
    204
    205          if self.cur_img and self.cur_img.tags :
    206              cur_img_tags = []
    207              for i in self.cur_img.tags :
    208                  try :
    209                      cur_img_tags.append(self.tag_list[i])
    210                  except IndexError :
    211                      print i, "is out of range, we only have", \
    212                          len(self.tag_list), "tags"
    213          else :
    214              cur_img_tags = []
    215          self.current_category = catname
    216          for i in range(len(self.entries)) :
    217              if i < len(self.categories[catname]) :
    218                  curtag = self.tag_list[self.categories[catname][i]]
    219                  self.entries[i].set_text(curtag)
    220                  self.highlight_tag(i, curtag in cur_img_tags)
    221
    222              else :
    223                  self.entries[i].set_text("")
    224                  self.highlight_tag(i, False)
    225
    226          if len(self.categories[catname]) > len(self.entries) :
    227              print "Too many tags in category %s -- can't show all %d" % \
    228                  (catname, len(self.categories[catname]))
    229
    230 -    def change_category(self, combobox) :
    231          '''The callback when the combobox is changed by the user'''
    232          self.display_tags_for_category(combobox.get_active_text())
    233
    234 -    def next_category(self, howmany) :
    235          '''Advance to the next category (if howmany==1) or some other category.
    236          '''
    237          keys = self.categories.keys()
    238          catno = keys.index(self.current_category)
    239          catno = (catno + howmany) % len(keys)
    240          self.show_category_by_number(catno)
    241
    242 -    def show_category_by_number(self, catno) :
    243          '''Show a specific category by number.
    244             Raises IndexError if catno is out of range.
    245          '''
    246          self.display_tags_for_category(self.categories.keys()[catno])
    247          self.categorysel.set_active(catno)
    248
    249 -    def edit_categories(self, w) :
    250          d = gtk.Dialog('Edit categories', self.parentwin,
    251                         buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_CLOSE,
    252                                  gtk.STOCK_OK, gtk.RESPONSE_OK))
    253          d.set_default_size(300, 500)
    254
    255          v = gtk.VBox()
    256
    257          self.edit_cell = None
    258          self.edit_path = None
    259
    260          def category_edited(cr, path, text):
    261              self.cat_list_store[path][0] = text
    262
    263          def get_cell(cr, editable, path) :
    264              self.edit_cell = editable
    265              self.edit_path = path
    266
    267          t = gtk.TreeView(self.cat_list_store)
    268          cr = gtk.CellRendererText()
    269          cr.props.editable = True
    270          cr.connect('edited', category_edited)
    271          cr.connect('editing_started', get_cell)
    272          # Don't handle editing_canceled because it will be called on OK.
    273          # cr.connect('editing_canceled', clear_cell)
    274
    275          col = gtk.TreeViewColumn('Category', cr, text=0)
    276          t.insert_column(col, -1)
    277
    278          sw = gtk.ScrolledWindow()
    279          sw.add(t)
    280          v.add(sw)
    281
    282          def save_current() :
    283              if self.edit_cell and self.edit_path :
    284                  self.cat_list_store[self.edit_path][0] = \
    285                      self.edit_cell.get_text()
    286
    287          def add_category(b):
    288              save_current()
    289              self.cat_list_store.append(('New category',))
    290              t.set_cursor(self.cat_list_store[-1].path, col, True)
    291
    292          b = gtk.Button('Add...')
    293          b.connect('clicked', add_category)
    294          v.pack_start(b, False, False)
    295
    296          d.get_content_area().add(v)
    297
    298          d.show_all()
    299
    300          while gtk.events_pending():
    301              gtk.main_iteration(False)
    302
    303          response = d.run()
    304          if response == gtk.RESPONSE_OK :
    305              save_current()
    306
    307              # Update the category list to reflect the new names.
    308              # Since it's an OrderedDict, we can't just replace keys,
    309              # we have to build a whole new dict.
    310              # Save keys and position of current category:
    311              oldkeys = self.categories.keys()
    312              old_cur_pos = oldkeys.index(self.current_category)
    313              i = 0
    314
    315              newcats = collections.OrderedDict()
    316
    317              # Iterate over the list that was in the dialog
    318              # and is now in the combobox:
    319              iter = self.cat_list_store.get_iter_first()
    320              while iter:
    321                  item = self.cat_list_store.get_value(iter, 0)
    322                  if i < len(oldkeys) :
    323                      newcats[item] = self.categories[oldkeys[i]]
    324                  else :
    325                      newcats[item] = []
    326                  if i == old_cur_pos :
    327                      self.current_category = item
    328                  iter = self.cat_list_store.iter_next(iter)
    329                  i += 1
    330
    331              self.categories = newcats
    332              if self.current_category > i :
    333                  self.current_category = 0
    334              self.show_category_by_number(self.current_category)
    335
    336          d.destroy()
    337
    338          self.focus_none()
    339
    340 -    def highlight_tag(self, tagno, val) :
    341          '''Turn tag number tagno on (if val=True) or off (val=False).'''
    342
    343          if len(self.buttons) < tagno :
    344              print "Argh! Tried to highlight tag", tagno
    345          if self.buttons[tagno].get_active() != val :
    346              self.ignore_events = True
    347              self.buttons[tagno].set_active(val)
    348              self.ignore_events = False
    349
    350          if val :
    351              self.entries[tagno].modify_base(gtk.STATE_NORMAL, self.highlight_bg)
    352              # If a tag is highlighted and the associated entry is empty,
    353              # put focus there so the user can type something.
    354              if not self.entries[tagno].get_text().strip() :
    355                  self.parentwin.set_focus(self.entries[tagno])
    356          else :
    357              self.entries[tagno].modify_base(gtk.STATE_NORMAL, self.greyBG)
    358              if self.parentwin.get_focus() == self.entries[tagno] :
    359                  self.focus_none()
    360
    361 -    def show_matches(self, pat) :
    362          '''Colorize any tags that match the given pattern.
    363             If pat == None, un-colorize everything.
    364          '''
    365          if pat :
    366              self.title.set_text("search: " + pat)
    367          else :
    368              self.title.set_text(os.path.basename(self.cur_img.filename))
    369          pat = pat.lower()
    370          for i, ent in enumerate(self.entries) :
    371              if pat and (ent.get_text().lower().find(pat) >= 0) :
    372                  ent.modify_base(gtk.STATE_NORMAL, self.matchBG)
    373              elif self.buttons[i].get_active() :
    374                  ent.modify_base(gtk.STATE_NORMAL, self.highlight_bg)
    375              else :
    376                  ent.modify_base(gtk.STATE_NORMAL, self.greyBG)
    377
    378 -    def focus_first_match(self, pat) :
    379          '''Focus the first text field matching the pattern.'''
    380          self.title.set_text(os.path.basename(self.cur_img.filename))
    381          pat = pat.lower()
    382          for i, ent in enumerate(self.entries) :
    383              if pat and (ent.get_text().lower().find(pat) >= 0) :
    384                  self.buttons[i].set_active(True)
    385                  ent.modify_base(gtk.STATE_NORMAL, self.matchBG)
    386                  return
    387
    388 -    def set_image(self, img) :
    389          self.cur_img = img
    390
    391          self.title.set_text(os.path.basename(img.filename))
    392
    393          self.display_tags_for_category(self.current_category)
    394
    395          return
    396
    397 -    def add_tag(self, tag, img) :
    398          '''Add a tag to the given image.
    399             img is a metapho.Image.
    400             tag may be a string, which can be a new string or an existing one,
    401             or an integer index into the tag list.
    402             Return the index (in the global tags list) of the tag just added,
    403             or None if error.
    404          '''
    405          # Call the base class to make sure the tag exists:
    406          tagindex = metapho.Tagger.add_tag(self, tag, img)
    407
    408          # Now display it, if possible
    409          if tagindex < len(self.entries) :
    410              self.highlight_tag(tagindex, True)
    411
    412          return tagindex
    413
    414 -    def remove_tag(self, tag, img) :
    415          if not type(tag) is int :
    416              tagstr = tag
    417              tag = self.tag_list.index(tagstr)
    418              if tagstr < 0 :
    419                  print "No such tag", tagstr
    420                  return
    421
    422          metapho.Tagger.remove_tag(self, tag, img)
    423
    424          self.highlight_tag(tag, False)
    425
    426 -    def toggle_tag(self, tagno, img) :
    427          '''Toggle tag number tagno for the given img.'''
    428          metapho.Tagger.toggle_tag(self, tagno, img)
    429          if tagno < len(self.categories[self.current_category]) :
    430              self.highlight_tag(tagno, not self.buttons[tagno].get_active())
    431
    432 -    def toggle_tag_by_letter(self, tagchar, img) :
    433          '''Toggle the tag corresponding to the letter typed by the user'''
    434          if tagchar.islower() :
    435              tagno = ord(tagchar) - ord('a')
    436          else :
    437              tagno = ord(tagchar) - ord('A') + self.num_rows
    438          if tagno >= len(self.tag_list) :
    439              print "We don't have a tag", tagchar, "yet"
    440              return
    441          self.toggle_tag(tagno, img)
    442
    443 -    def focus_next_entry(self) :
    444          '''Set focus to the next available entry.
    445             If we're already typing in a new tag entry that hasn't been
    446             saved yet, save it first before switching to the new one.
    447          '''
    448          newindex = len(self.categories[self.current_category])
    449
    450          # No need to save this entry's new contents explicitly;
    451          # when we call highlight_tag it'll get a focus out which
    452          # will automatically save. But we do need to increment newindex
    453          # if the user typed anything here.
    454
    455          curtext = self.entries[newindex].get_text()
    456          if curtext.strip() != '' :
    457              newindex += 1
    458
    459          self.parentwin.set_focus(self.entries[newindex])
    460          self.highlight_tag(newindex, True)
    461
    462 -class ImageViewer(gtk.DrawingArea) :
    463      '''A PyGTK image viewer widget for metapho.
    464      '''
    465
    466 -    def __init__(self) :
    467          super(ImageViewer, self).__init__()
    468          self.connect("expose-event", self.expose_handler)
    469          self.gc = None
    470          self.pixbuf = None
    471          self.imgwidth = None
    472          self.imgheight = None
    473          self.cur_img = None
    474
    475 -    def expose_handler(self, widget, event) :
    476          #print "Expose"
    477
    478          if not self.gc :
    479              self.gc = widget.window.new_gc()
    480              x, y, self.imgwidth, self.imgheight = self.get_allocation()
    481
    482              # Have we had load_image called, but we weren't ready for it?
    483              # Now, theoretically, we are ... so call it again.
    484              if self.cur_img and not self.pixbuf :
    485                  self.load_image(self.cur_img)
    486
    487          self.show_image()
    488
    489      # Mapping from EXIF orientation tag to degrees rotated.
    490      # http://sylvana.net/jpegcrop/exif_orientation.html
    491      exif_rot_table = [ 0, 0, 180, 180, 270, 270, 90, 90 ]
    492      # Note that orientations 2, 4, 5 and 7 also involve a flip.
    493      # We're not implementing that right now, because nobody
    494      # uses it in practice.
    495
    496 -    def load_image(self, img) :
    497          '''Load the image passed in, and show it.
    498             img is a metapho.Image object.
    499             Return True for success, False for error.
    500          '''
    501
    502          self.cur_img = img
    503
    504          # Clean up memory from any existing pixbuf.
    505          # This still needs to be garbage collected before returning.
    506          if self.pixbuf :
    507              self.pixbuf = None
    508
    509          try :
    510              newpb = gtk.gdk.pixbuf_new_from_file(img.filename)
    511
    512              # We can't do any of the rotation until the window appears
    513              # so we know our window size.
    514              # But we have to load the first pixbuf anyway, because
    515              # otherwise we may end up pointing to an image that can't
    516              # be loaded. Super annoying! We'll end up reloading the
    517              # pixbuf again after the window appears, so this will
    518              # slow down the initial window slightly.
    519              if not self.imgwidth :
    520                  return True
    521
    522              # Do we need to check rotation info for this image?
    523              if img.rot == None :
    524                  # Get the EXIF embedded rotation info.
    525                  orient = newpb.get_option('orientation')
    526                  if orient == None :    # No orientation specified; use 0
    527                      orient = 0
    528                  else :                 # convert to int array index
    529                      orient = int(orient) - 1
    530                  img.rot = self.exif_rot_table[orient]
    531
    532              # Scale the image to our display image size.
    533              # We need it to fit in the space available.
    534              # If we're not changing aspect ratios, that's easy.
    535              oldw = newpb.get_width()
    536              oldh = newpb.get_height()
    537              if img.rot in [ 0, 180] :
    538                  if oldw > oldh :     # horizontal format photo
    539                      neww = self.imgwidth
    540                      newh = oldh * self.imgwidth / oldw
    541                  else :               # vertical format
    542                      newh = self.imgheight
    543                      neww = oldw * self.imgheight / oldh
    544
    545              # If the image needs to be rotated 90 or 270 degrees,
    546              # scale so that the scaled width will fit in the image
    547              # height area -- even though it's still width because we
    548              # haven't rotated yet.
    549              else :     # We'll be changing aspect ratios
    550                  if oldw > oldh :     # horizontal format, will be vertical
    551                      neww = self.imgheight
    552                      newh = oldh * self.imgheight / oldw
    553                  else :               # vertical format, will be horiz
    554                      neww = self.imgwidth
    555                      newh = oldh * self.imgwidth / oldw
    556
    557              # Finally, do the scale:
    558              newpb = newpb.scale_simple(neww, newh,
    559                                               gtk.gdk.INTERP_BILINEAR)
    560
    561              # Rotate the image if needed
    562              if img.rot != 0 :
    563                  newpb = newpb.rotate_simple(img.rot)
    564
    565              # newpb = newpb.apply_embedded_orientation()
    566
    567              self.pixbuf = newpb
    568
    569              self.show_image()
    570              loaded = True
    571
    572          except glib.GError :
    573              self.pixbuf = None
    574              loaded = False
    575
    576          # garbage collect the old pixbuf, if any, and the one we just read in:
    577          newpb = None
    578          gc.collect()
    579
    580          return loaded
    581
    582 -    def show_image(self) :
    583          if not self.gc :
    584              return
    585
    586          if not self.pixbuf :
    587              return
    588
    589          # Clear the drawing area first
    590          self.window.draw_rectangle(self.gc, True, 0, 0,
    591                                     self.imgwidth, self.imgheight)
    592
    593          x = (self.imgwidth - self.pixbuf.get_width()) / 2
    594          y = (self.imgheight - self.pixbuf.get_height()) / 2
    595          self.window.draw_pixbuf(self.gc, self.pixbuf, 0, 0, x, y)
    596
    597 -    def rotate(self, rot) :
    598          self.cur_img.rot = (self.cur_img.rot + rot + 360) % 360
    599
    600          # XXX we don't always need to reload: could make this more efficient.
    601          self.load_image(self.cur_img)
    602