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