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