1'''library_collection_area.py - Comic book library window that displays the collections.''' 2 3from xml.sax.saxutils import escape as xmlescape 4from gi.repository import Gdk, GdkPixbuf, Gtk, GLib 5 6from mcomix.preferences import prefs 7from mcomix import constants 8from mcomix import i18n 9from mcomix import status 10from mcomix import file_chooser_library_dialog 11from mcomix import message_dialog 12 13_dialog = None 14# The "All books" collection is not a real collection stored in the library, 15# but is represented by this ID in the library's TreeModels. 16_COLLECTION_ALL = -1 17_COLLECTION_RECENT = -2 18 19class _CollectionArea(Gtk.ScrolledWindow): 20 21 '''The _CollectionArea is the sidebar area in the library where 22 different collections are displayed in a tree. 23 ''' 24 25 def __init__(self, library): 26 super(_CollectionArea, self).__init__() 27 self._library = library 28 self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 29 30 self._treestore = Gtk.TreeStore(str, int) # (Name, ID) of collections. 31 self._treeview = Gtk.TreeView(model=self._treestore) 32 self._treeview.connect('cursor_changed', self._collection_selected) 33 self._treeview.connect('drag_data_received', self._drag_data_received) 34 self._treeview.connect('drag_motion', self._drag_motion) 35 self._treeview.connect_after('drag_begin', self._drag_begin) 36 self._treeview.connect('button_press_event', self._button_press) 37 self._treeview.connect('key_press_event', self._key_press) 38 self._treeview.connect('popup_menu', self._popup_menu) 39 self._treeview.connect('row_activated', self._expand_or_collapse_row) 40 self._treeview.set_headers_visible(False) 41 self._set_acceptable_drop(True) 42 self._treeview.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK, 43 [('collection', Gtk.TargetFlags.SAME_WIDGET, 44 constants.LIBRARY_DRAG_COLLECTION_ID)], 45 Gdk.DragAction.MOVE) 46 47 cellrenderer = Gtk.CellRendererText() 48 column = Gtk.TreeViewColumn(None, cellrenderer, markup=0) 49 self._treeview.append_column(column) 50 self.add(self._treeview) 51 52 self._ui_manager = Gtk.UIManager() 53 self._tooltipstatus = status.TooltipStatusHelper(self._ui_manager, 54 self._library.get_status_bar()) 55 ui_description = ''' 56 <ui> 57 <popup name="library collections"> 58 <menuitem action="_title" /> 59 <separator /> 60 <menuitem action="add" /> 61 <separator /> 62 <menuitem action="new" /> 63 <menuitem action="rename" /> 64 <menuitem action="duplicate" /> 65 <separator /> 66 <menuitem action="cleanup" /> 67 <menuitem action="remove" /> 68 </popup> 69 </ui> 70 ''' 71 self._ui_manager.add_ui_from_string(ui_description) 72 actiongroup = Gtk.ActionGroup(name='mcomix-library-collection-area') 73 actiongroup.add_actions([ 74 ('_title', None, _('Library collections'), None, None, 75 lambda *args: False), 76 ('add', Gtk.STOCK_ADD, _('_Add...'), None, 77 _('Add more books to the library.'), 78 lambda *args: file_chooser_library_dialog.open_library_filechooser_dialog(self._library)), 79 ('new', Gtk.STOCK_NEW, _('New'), None, 80 _('Add a new empty collection.'), 81 self.add_collection), 82 ('rename', Gtk.STOCK_EDIT, _('Re_name'), None, 83 _('Renames the selected collection.'), 84 self._rename_collection), 85 ('duplicate', Gtk.STOCK_COPY, _('_Duplicate'), None, 86 _('Creates a duplicate of the selected collection.'), 87 self._duplicate_collection), 88 ('cleanup', Gtk.STOCK_CLEAR, _('_Clean up'), None, 89 _('Removes no longer existant books from the collection.'), 90 self._clean_collection), 91 ('remove', Gtk.STOCK_REMOVE, _('_Remove'), None, 92 _('Deletes the selected collection.'), 93 self._remove_collection)]) 94 self._ui_manager.insert_action_group(actiongroup, 0) 95 96 self.display_collections() 97 98 def get_current_collection(self): 99 '''Return the collection ID for the currently selected collection, 100 or None if no collection is selected. 101 ''' 102 treepath, focuspath = self._treeview.get_cursor() 103 if treepath is not None: 104 return self._get_collection_at_path(treepath) 105 else: 106 return None 107 108 def display_collections(self): 109 '''Display the library collections by redrawing them from the 110 backend data. Should be called on startup or when the collections 111 hierarchy has been changed (e.g. after moving, adding, renaming). 112 Any row that was expanded before the call will have it's 113 corresponding new row also expanded after the call. 114 ''' 115 116 def _recursive_add(parent_iter, supercoll): 117 for coll in self._library.backend.get_collections_in_collection( 118 supercoll): 119 name = self._library.backend.get_collection_name(coll) 120 child_iter = self._treestore.append(parent_iter, 121 [xmlescape(name), coll]) 122 _recursive_add(child_iter, coll) 123 124 def _expand_and_select(treestore, path, iterator): 125 collection = treestore.get_value(iterator, 1) 126 if collection == prefs['last library collection']: 127 # Reset to trigger update of book area. 128 prefs['last library collection'] = None 129 self._treeview.expand_to_path(path) 130 self._treeview.set_cursor(path) 131 elif collection in expanded_collections: 132 self._treeview.expand_to_path(path) 133 134 def _expanded_rows_accumulator(treeview, path): 135 collection = self._get_collection_at_path(path) 136 expanded_collections.append(collection) 137 138 expanded_collections = [] 139 self._treeview.map_expanded_rows(_expanded_rows_accumulator) 140 self._treestore.clear() 141 self._treestore.append(None, ['<b>%s</b>' % xmlescape(_('All books')), 142 _COLLECTION_ALL]) 143 _recursive_add(None, None) 144 self._treestore.foreach(_expand_and_select) 145 146 def add_collection(self, *args): 147 '''Add a new collection to the library, through a dialog.''' 148 add_dialog = message_dialog.MessageDialog( 149 self._library, 150 flags=0, 151 message_type=Gtk.MessageType.INFO, 152 buttons=Gtk.ButtonsType.OK_CANCEL) 153 add_dialog.set_auto_destroy(False) 154 add_dialog.set_default_response(Gtk.ResponseType.OK) 155 add_dialog.set_text( 156 _('Add new collection?'), 157 _('Please enter a name for the new collection.') 158 ) 159 160 box = Gtk.HBox() # To get nice line-ups with the padding. 161 add_dialog.vbox.pack_start(box, True, True, 0) 162 entry = Gtk.Entry() 163 entry.set_activates_default(True) 164 box.pack_start(entry, True, True, 6) 165 box.show_all() 166 167 response = add_dialog.run() 168 name = entry.get_text() 169 add_dialog.destroy() 170 if response == Gtk.ResponseType.OK and name: 171 if self._library.backend.add_collection(name): 172 collection = self._library.backend.get_collection_by_name(name) 173 prefs['last library collection'] = collection.id 174 self._library.collection_area.display_collections() 175 else: 176 message = _('Could not add a new collection called "%s".') % (name) 177 if (self._library.backend.get_collection_by_name(name) 178 is not None): 179 message = '%s %s' % (message, 180 _('A collection by that name already exists.')) 181 self._library.set_status_message(message) 182 183 def clean_collection(self, collection): 184 ''' Check all books in the collection, removing those that 185 no longer exist. If C{collection} is None, the whole library 186 will be cleaned. ''' 187 188 removed = self._library.backend.clean_collection(collection) 189 190 msg = i18n.get_translation().ngettext( 191 'Removed %d book from the library.', 192 'Removed %d books from the library.', 193 removed) 194 self._library.set_status_message(msg % removed) 195 196 if removed > 0: 197 collection = self._library.collection_area.get_current_collection() 198 GLib.idle_add(self._library.book_area.display_covers, collection) 199 200 def _get_collection_at_path(self, path): 201 '''Return the collection ID of the collection at the (TreeView) 202 <path>. 203 ''' 204 iterator = self._treestore.get_iter(path) 205 return self._treestore.get_value(iterator, 1) 206 207 def _collection_selected(self, treeview): 208 '''Change the viewed collection (in the _BookArea) to the 209 currently selected one in the sidebar, if it has been changed. 210 ''' 211 collection = self.get_current_collection() 212 if (collection is None or 213 collection == prefs['last library collection']): 214 return 215 prefs['last library collection'] = collection 216 GLib.idle_add(self._library.book_area.display_covers, collection) 217 218 def _clean_collection(self, *args): 219 ''' Menu item hook to clean a collection. ''' 220 221 collection = self.get_current_collection() 222 223 # The backend expects _COLLECTION_ALL to be passed as None 224 if collection == _COLLECTION_ALL: 225 collection = None 226 227 self.clean_collection(collection) 228 229 def _remove_collection(self, action=None): 230 '''Remove the currently selected collection from the library.''' 231 collection = self.get_current_collection() 232 233 if collection not in (_COLLECTION_ALL, _COLLECTION_RECENT): 234 self._library.backend.remove_collection(collection) 235 prefs['last library collection'] = _COLLECTION_ALL 236 self.display_collections() 237 238 def _rename_collection(self, action): 239 '''Rename the currently selected collection, using a dialog.''' 240 collection = self.get_current_collection() 241 try: 242 old_name = self._library.backend.get_collection_name(collection) 243 except Exception: 244 return 245 rename_dialog = message_dialog.MessageDialog( 246 self._library, 247 flags=0, 248 message_type=Gtk.MessageType.INFO, 249 buttons=Gtk.ButtonsType.OK_CANCEL) 250 rename_dialog.set_auto_destroy(False) 251 rename_dialog.set_text( 252 _('Rename collection?'), 253 _('Please enter a new name for the selected collection.') 254 ) 255 rename_dialog.set_default_response(Gtk.ResponseType.OK) 256 257 box = Gtk.HBox() # To get nice line-ups with the padding. 258 rename_dialog.vbox.pack_start(box, True, True, 0) 259 entry = Gtk.Entry() 260 entry.set_text(old_name) 261 entry.set_activates_default(True) 262 box.pack_start(entry, True, True, 6) 263 box.show_all() 264 265 response = rename_dialog.run() 266 new_name = entry.get_text() 267 rename_dialog.destroy() 268 if response == Gtk.ResponseType.OK and new_name: 269 if self._library.backend.rename_collection(collection, new_name): 270 self.display_collections() 271 else: 272 message = _('Could not change the name to "%s".') % new_name 273 if (self._library.backend.get_collection_by_name(new_name) 274 is not None): 275 message = '%s %s' % (message, 276 _('A collection by that name already exists.')) 277 self._library.set_status_message(message) 278 279 def _duplicate_collection(self, action): 280 '''Duplicate the currently selected collection.''' 281 collection = self.get_current_collection() 282 if self._library.backend.duplicate_collection(collection): 283 self.display_collections() 284 else: 285 self._library.set_status_message( 286 _('Could not duplicate collection.')) 287 288 def _button_press(self, treeview, event): 289 '''Handle mouse button presses on the _CollectionArea.''' 290 291 if event.button == 3: 292 row = treeview.get_path_at_pos(int(event.x), int(event.y)) 293 if row: 294 path, column, x, y = row 295 collection = self._get_collection_at_path(path) 296 else: 297 collection = None 298 299 self._popup_collection_menu(collection) 300 301 def _popup_menu(self, treeview): 302 ''' Called to open the control's popup menu via 303 keyboard controls. ''' 304 305 model, iter = treeview.get_selection().get_selected() 306 if iter is not None: 307 book_path = model.get_path(iter)[0] 308 collection = self._get_collection_at_path(book_path) 309 else: 310 collection = None 311 312 self._popup_collection_menu(collection) 313 return True 314 315 def _popup_collection_menu(self, collection): 316 ''' Show the library collection popup. Depending on the 317 value of C{collection}, menu items will be disabled or enabled. ''' 318 319 is_collection_all = collection in (_COLLECTION_ALL, _COLLECTION_RECENT) 320 321 for path in ('rename', 'duplicate', 'remove'): 322 control = self._ui_manager.get_action( 323 '/library collections/' + path) 324 control.set_sensitive(collection is not None and 325 not is_collection_all) 326 327 self._ui_manager.get_action('/library collections/add').set_sensitive(collection is not None) 328 self._ui_manager.get_action('/library collections/cleanup').set_sensitive(collection is not None) 329 self._ui_manager.get_action('/library collections/_title').set_sensitive(False) 330 331 menu = self._ui_manager.get_widget('/library collections') 332 menu.popup(None, None, None, None, 3, Gtk.get_current_event_time()) 333 334 def _key_press(self, treeview, event): 335 '''Handle key presses on the _CollectionArea.''' 336 if event.keyval == Gdk.KEY_Delete: 337 self._remove_collection() 338 339 def _expand_or_collapse_row(self, treeview, path, column): 340 '''Expand or collapse the activated row.''' 341 if treeview.row_expanded(path): 342 treeview.collapse_row(path) 343 else: 344 treeview.expand_to_path(path) 345 346 def _drag_data_received(self, treeview, context, x, y, selection, drag_id, 347 eventtime): 348 '''Move books dragged from the _BookArea to the target collection, 349 or move some collection into another collection. 350 ''' 351 self._library.set_status_message('') 352 drop_row = treeview.get_dest_row_at_pos(x, y) 353 if drop_row is None: # Drop "after" the last row. 354 dest_path, pos = ((len(self._treestore) - 1,), 355 Gtk.TreeViewDropPosition.AFTER) 356 else: 357 dest_path, pos = drop_row 358 src_collection = self.get_current_collection() 359 dest_collection = self._get_collection_at_path(dest_path) 360 if drag_id == constants.LIBRARY_DRAG_COLLECTION_ID: 361 if pos in (Gtk.TreeViewDropPosition.BEFORE, Gtk.TreeViewDropPosition.AFTER): 362 dest_collection = self._library.backend.get_supercollection( 363 dest_collection) 364 self._library.backend.add_collection_to_collection( 365 src_collection, dest_collection) 366 self.display_collections() 367 elif drag_id == constants.LIBRARY_DRAG_BOOK_ID: 368 369 #FIXME 370 #tmp workaround for GTK bug, 2018 371 #see also _drag_data_get in book_area 372 #receaving as bytearray instead of text 373 for path_str in selection.get_data().decode().split(','): # IconView path 374 #for path_str in selection.get_text().split(','): # IconView path 375 book = self._library.book_area.get_book_at_path(int(path_str)) 376 self._library.backend.add_book_to_collection(book, 377 dest_collection) 378 if src_collection != _COLLECTION_ALL: 379 self._library.backend.remove_book_from_collection(book, 380 src_collection) 381 self._library.book_area.remove_book_at_path(int(path_str)) 382 383 def _drag_motion(self, treeview, context, x, y, *args): 384 '''Set the library statusbar text when hovering a drag-n-drop over 385 a collection (either books or from the collection area itself). 386 Also set the TreeView to accept drops only when we are hovering over 387 a valid drop position for the current drop type. 388 389 This isn't pretty, but the details of treeviews and drag-n-drops 390 are not pretty to begin with. 391 ''' 392 drop_row = treeview.get_dest_row_at_pos(x, y) 393 src_collection = self.get_current_collection() 394 # Why isn't the drag ID passed along with drag-motion events? 395 if Gtk.drag_get_source_widget(context) is self._treeview: # Moving collection. 396 model, src_iter = treeview.get_selection().get_selected() 397 if drop_row is None: # Drop "after" the last row. 398 dest_path, pos = (len(model) - 1,), Gtk.TreeViewDropPosition.AFTER 399 else: 400 dest_path, pos = drop_row 401 dest_iter = model.get_iter(dest_path) 402 if model.is_ancestor(src_iter, dest_iter): # No cycles! 403 self._set_acceptable_drop(False) 404 self._library.set_status_message('') 405 return 406 dest_collection = self._get_collection_at_path(dest_path) 407 if pos in (Gtk.TreeViewDropPosition.BEFORE, Gtk.TreeViewDropPosition.AFTER): 408 dest_collection = self._library.backend.get_supercollection( 409 dest_collection) 410 if (_COLLECTION_ALL in (src_collection, dest_collection) or 411 _COLLECTION_RECENT in (src_collection, dest_collection) or 412 src_collection == dest_collection): 413 self._set_acceptable_drop(False) 414 self._library.set_status_message('') 415 return 416 src_name = self._library.backend.get_collection_name( 417 src_collection) 418 if dest_collection is None: 419 dest_name = _('Root') 420 else: 421 dest_name = self._library.backend.get_collection_name( 422 dest_collection) 423 message = (_('Put the collection "%(subcollection)s" in the collection "%(supercollection)s".') % 424 {'subcollection': src_name, 'supercollection': dest_name}) 425 else: # Moving book(s). 426 if drop_row is None: 427 self._set_acceptable_drop(False) 428 self._library.set_status_message('') 429 return 430 dest_path, pos = drop_row 431 if pos in (Gtk.TreeViewDropPosition.BEFORE, Gtk.TreeViewDropPosition.AFTER): 432 self._set_acceptable_drop(False) 433 self._library.set_status_message('') 434 return 435 dest_collection = self._get_collection_at_path(dest_path) 436 if (src_collection == dest_collection or 437 dest_collection == _COLLECTION_ALL): 438 self._set_acceptable_drop(False) 439 self._library.set_status_message('') 440 return 441 dest_name = self._library.backend.get_collection_name( 442 dest_collection) 443 if src_collection == _COLLECTION_ALL: 444 message = _('Add books to "%s".') % dest_name 445 else: 446 src_name = self._library.backend.get_collection_name( 447 src_collection) 448 message = (_('Move books from "%(source collection)s" to "%(destination collection)s".') % 449 {'source collection': src_name, 450 'destination collection': dest_name}) 451 self._set_acceptable_drop(True) 452 self._library.set_status_message(message) 453 454 def _set_acceptable_drop(self, acceptable): 455 '''Set the TreeView to accept drops if <acceptable> is True.''' 456 if acceptable: 457 self._treeview.enable_model_drag_dest( 458 [('book', Gtk.TargetFlags.SAME_APP, constants.LIBRARY_DRAG_BOOK_ID), 459 ('collection', Gtk.TargetFlags.SAME_WIDGET, constants.LIBRARY_DRAG_COLLECTION_ID)], 460 Gdk.DragAction.MOVE) 461 else: 462 self._treeview.enable_model_drag_dest([], Gdk.DragAction.MOVE) 463 464 def _drag_begin(self, treeview, context): 465 '''Create a cursor image for drag-n-drop of collections. We use the 466 default one (i.e. the row with text), but put the hotspot in the 467 top left corner so that one can actually see where one is dropping, 468 which unfortunately isn't the default case. 469 ''' 470 path = treeview.get_cursor()[0] 471 surface = treeview.create_row_drag_icon(path) 472 width, height = surface.get_width(), surface.get_height() 473 pixbuf = Gdk.pixbuf_get_from_surface(surface, 0, 0, width, height) 474 Gtk.drag_set_icon_pixbuf(context, pixbuf, -5, -5) 475 476# vim: expandtab:sw=4:ts=4 477