1# Copyright 2004-2007 Joe Wreschnig, Michael Urman, Iñigo Serna
2#           2009-2010 Steven Robertson
3#           2012-2018 Nick Boultbee
4#           2009-2014 Christoph Reiter
5#
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10
11from __future__ import absolute_import
12
13import os
14
15from gi.repository import Gtk, Pango, Gdk, Gio
16
17from .prefs import Preferences, DEFAULT_PATTERN_TEXT
18from quodlibet.browsers.albums.models import (AlbumModel,
19    AlbumFilterModel, AlbumSortModel)
20from quodlibet.browsers.albums.main import (get_cover_size,
21    AlbumTagCompletion, PreferencesButton, VisibleUpdate)
22
23import quodlibet
24from quodlibet import app
25from quodlibet import ngettext
26from quodlibet import config
27from quodlibet import qltk
28from quodlibet import util
29from quodlibet import _
30from quodlibet.browsers import Browser
31from quodlibet.browsers._base import DisplayPatternMixin
32from quodlibet.query import Query
33from quodlibet.qltk.information import Information
34from quodlibet.qltk.properties import SongProperties
35from quodlibet.qltk.songsmenu import SongsMenu
36from quodlibet.qltk.x import MenuItem, Align, ScrolledWindow, RadioMenuItem
37from quodlibet.qltk.x import SymbolicIconImage
38from quodlibet.qltk.searchbar import SearchBarBox
39from quodlibet.qltk.menubutton import MenuButton
40from quodlibet.qltk import Icons
41from quodlibet.util import connect_destroy
42from quodlibet.util.library import background_filter
43from quodlibet.util import connect_obj
44from quodlibet.qltk.cover import get_no_cover_pixbuf
45from quodlibet.qltk.image import add_border_widget, get_surface_for_pixbuf
46from quodlibet.qltk import popup_menu_at_widget
47
48
49class PreferencesButton(PreferencesButton):
50    def __init__(self, browser, model):
51        Gtk.HBox.__init__(self)
52
53        sort_orders = [
54            (_("_Title"), self.__compare_title),
55            (_("_Artist"), self.__compare_artist),
56            (_("_Date"), self.__compare_date),
57            (_("_Genre"), self.__compare_genre),
58            (_("_Rating"), self.__compare_rating),
59        ]
60
61        menu = Gtk.Menu()
62
63        sort_item = Gtk.MenuItem(
64            label=_(u"Sort _by…"), use_underline=True)
65        sort_menu = Gtk.Menu()
66
67        active = config.getint('browsers', 'album_sort', 1)
68
69        item = None
70        for i, (label, func) in enumerate(sort_orders):
71            item = RadioMenuItem(group=item, label=label,
72                                 use_underline=True)
73            model.set_sort_func(100 + i, func)
74            if i == active:
75                model.set_sort_column_id(100 + i, Gtk.SortType.ASCENDING)
76                item.set_active(True)
77            item.connect("toggled",
78                         util.DeferredSignal(self.__sort_toggled_cb),
79                         model, i)
80            sort_menu.append(item)
81
82        sort_item.set_submenu(sort_menu)
83        menu.append(sort_item)
84
85        pref_item = MenuItem(_("_Preferences"), Icons.PREFERENCES_SYSTEM)
86        menu.append(pref_item)
87        connect_obj(pref_item, "activate", Preferences, browser)
88
89        menu.show_all()
90
91        button = MenuButton(
92                SymbolicIconImage(Icons.EMBLEM_SYSTEM, Gtk.IconSize.MENU),
93                arrow=True)
94        button.set_menu(menu)
95        self.pack_start(button, True, True, 0)
96
97
98class IconView(Gtk.IconView):
99    # XXX: disable height for width etc. Speeds things up and doesn't seem
100    # to break anyhting in a scrolled window
101
102    def do_get_preferred_width_for_height(self, height):
103        return (1, 1)
104
105    def do_get_preferred_width(self):
106        return (1, 1)
107
108    def do_get_preferred_height(self):
109        return (1, 1)
110
111    def do_get_preferred_height_for_width(self, width):
112        return (1, 1)
113
114
115class CoverGrid(Browser, util.InstanceTracker, VisibleUpdate,
116                DisplayPatternMixin):
117    __gsignals__ = Browser.__gsignals__
118    __model = None
119    __last_render = None
120    __last_render_surface = None
121
122    _PATTERN_FN = os.path.join(quodlibet.get_user_dir(), "album_pattern")
123    _DEFAULT_PATTERN_TEXT = DEFAULT_PATTERN_TEXT
124    STAR = ["~people", "album"]
125
126    name = _("Cover Grid")
127    accelerated_name = _("_Cover Grid")
128    keys = ["CoverGrid"]
129    priority = 5
130
131    def pack(self, songpane):
132        container = self.songcontainer
133        container.pack1(self, True, False)
134        container.pack2(songpane, True, False)
135        return container
136
137    def unpack(self, container, songpane):
138        container.remove(songpane)
139        container.remove(self)
140
141    @classmethod
142    def init(klass, library):
143        super(CoverGrid, klass).load_pattern()
144
145    def finalize(self, restored):
146        if not restored:
147            # Select the "All Albums" album, which is None
148            self.select_by_func(lambda r: r[0].album is None, one=True)
149
150    @classmethod
151    def _destroy_model(klass):
152        klass.__model.destroy()
153        klass.__model = None
154
155    @classmethod
156    def toggle_text(klass):
157        on = config.getboolean("browsers", "album_text", True)
158        for covergrid in klass.instances():
159            covergrid.__text_cells.set_visible(on)
160            covergrid.view.queue_resize()
161
162    @classmethod
163    def toggle_wide(klass):
164        wide = config.getboolean("browsers", "covergrid_wide", False)
165        for covergrid in klass.instances():
166            covergrid.songcontainer.set_orientation(
167                Gtk.Orientation.HORIZONTAL if wide
168                else Gtk.Orientation.VERTICAL)
169
170    @classmethod
171    def update_mag(klass):
172        mag = config.getfloat("browsers", "covergrid_magnification", 3.)
173        for covergrid in klass.instances():
174            covergrid.__cover.set_property('width', get_cover_size() * mag + 8)
175            covergrid.__cover.set_property('height',
176                get_cover_size() * mag + 8)
177            covergrid.view.set_item_width(get_cover_size() * mag + 8)
178            covergrid.view.queue_resize()
179            covergrid.redraw()
180
181    def redraw(self):
182        model = self.__model
183        for iter_, item in model.iterrows():
184            album = item.album
185            if album is not None:
186                item.scanned = False
187                model.row_changed(model.get_path(iter_), iter_)
188
189    @classmethod
190    def _init_model(klass, library):
191        klass.__model = AlbumModel(library)
192        klass.__library = library
193
194    @classmethod
195    def _refresh_albums(klass, albums):
196        """We signal all other open album views that we changed something
197        (Only needed for the cover atm) so they redraw as well."""
198        if klass.__library:
199            klass.__library.albums.refresh(albums)
200
201    @util.cached_property
202    def _no_cover(self):
203        """Returns a cairo surface representing a missing cover"""
204
205        mag = config.getfloat("browsers", "covergrid_magnification", 3.)
206
207        cover_size = get_cover_size()
208        scale_factor = self.get_scale_factor() * mag
209        pb = get_no_cover_pixbuf(cover_size, cover_size, scale_factor)
210        return get_surface_for_pixbuf(self, pb)
211
212    def __init__(self, library):
213        Browser.__init__(self, spacing=6)
214        self.set_orientation(Gtk.Orientation.VERTICAL)
215        self.songcontainer = qltk.paned.ConfigRVPaned(
216            "browsers", "covergrid_pos", 0.4)
217        if config.getboolean("browsers", "covergrid_wide", False):
218            self.songcontainer.set_orientation(Gtk.Orientation.HORIZONTAL)
219
220        self._register_instance()
221        if self.__model is None:
222            self._init_model(library)
223
224        self._cover_cancel = Gio.Cancellable()
225
226        self.scrollwin = sw = ScrolledWindow()
227        sw.set_shadow_type(Gtk.ShadowType.IN)
228        model_sort = AlbumSortModel(model=self.__model)
229        model_filter = AlbumFilterModel(child_model=model_sort)
230        self.view = view = IconView(model_filter)
231        #view.set_item_width(get_cover_size() + 12)
232        self.view.set_row_spacing(config.getint("browsers", "row_spacing", 6))
233        self.view.set_column_spacing(config.getint("browsers",
234            "column_spacing", 6))
235        self.view.set_item_padding(config.getint("browsers",
236            "item_padding", 6))
237        self.view.set_has_tooltip(True)
238        self.view.connect("query-tooltip", self._show_tooltip)
239
240        self.__bg_filter = background_filter()
241        self.__filter = None
242        model_filter.set_visible_func(self.__parse_query)
243
244        mag = config.getfloat("browsers", "covergrid_magnification", 3.)
245
246        self.view.set_item_width(get_cover_size() * mag + 8)
247
248        self.__cover = render = Gtk.CellRendererPixbuf()
249        render.set_property('width', get_cover_size() * mag + 8)
250        render.set_property('height', get_cover_size() * mag + 8)
251        view.pack_start(render, False)
252
253        def cell_data_pb(view, cell, model, iter_, no_cover):
254            item = model.get_value(iter_)
255
256            if item.album is None:
257                surface = None
258            elif item.cover:
259                pixbuf = item.cover
260                pixbuf = add_border_widget(pixbuf, self.view)
261                surface = get_surface_for_pixbuf(self, pixbuf)
262                # don't cache, too much state has an effect on the result
263                self.__last_render_surface = None
264            else:
265                surface = no_cover
266
267            if self.__last_render_surface == surface:
268                return
269            self.__last_render_surface = surface
270            cell.set_property("surface", surface)
271
272        view.set_cell_data_func(render, cell_data_pb, self._no_cover)
273
274        self.__text_cells = render = Gtk.CellRendererText()
275        render.set_visible(config.getboolean("browsers", "album_text", True))
276        render.set_property('alignment', Pango.Alignment.CENTER)
277        render.set_property('xalign', 0.5)
278        render.set_property('ellipsize', Pango.EllipsizeMode.END)
279        view.pack_start(render, False)
280
281        def cell_data(view, cell, model, iter_, data):
282            album = model.get_album(iter_)
283
284            if album is None:
285                text = "<b>%s</b>" % _("All Albums")
286                text += "\n" + ngettext("%d album", "%d albums",
287                        len(model) - 1) % (len(model) - 1)
288                markup = text
289            else:
290                markup = self.display_pattern % album
291
292            if self.__last_render == markup:
293                return
294            self.__last_render = markup
295            cell.markup = markup
296            cell.set_property('markup', markup)
297
298        view.set_cell_data_func(render, cell_data, None)
299
300        view.set_selection_mode(Gtk.SelectionMode.MULTIPLE)
301        sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
302        sw.add(view)
303
304        view.connect('item-activated', self.__play_selection, None)
305
306        self.__sig = connect_destroy(
307            view, 'selection-changed',
308            util.DeferredSignal(self.__update_songs, owner=self))
309
310        targets = [("text/x-quodlibet-songs", Gtk.TargetFlags.SAME_APP, 1),
311                   ("text/uri-list", 0, 2)]
312        targets = [Gtk.TargetEntry.new(*t) for t in targets]
313
314        view.drag_source_set(
315            Gdk.ModifierType.BUTTON1_MASK, targets, Gdk.DragAction.COPY)
316        view.connect("drag-data-get", self.__drag_data_get) # NOT WORKING
317        connect_obj(view, 'button-press-event',
318            self.__rightclick, view, library)
319        connect_obj(view, 'popup-menu', self.__popup, view, library)
320
321        self.accelerators = Gtk.AccelGroup()
322        search = SearchBarBox(completion=AlbumTagCompletion(),
323                              accel_group=self.accelerators)
324        search.connect('query-changed', self.__update_filter)
325        connect_obj(search, 'focus-out', lambda w: w.grab_focus(), view)
326        self.__search = search
327
328        prefs = PreferencesButton(self, model_sort)
329        search.pack_start(prefs, False, True, 0)
330        self.pack_start(Align(search, left=6, top=6), False, True, 0)
331        self.pack_start(sw, True, True, 0)
332
333        self.connect("destroy", self.__destroy)
334
335        self.enable_row_update(view, sw, self.view)
336
337        self.__update_filter()
338
339        self.connect('key-press-event', self.__key_pressed, library.librarian)
340
341        if app.cover_manager:
342            connect_destroy(
343                app.cover_manager, "cover-changed", self._cover_changed)
344
345        self.show_all()
346
347    def _cover_changed(self, manager, songs):
348        model = self.__model
349        songs = set(songs)
350        for iter_, item in model.iterrows():
351            album = item.album
352            if album is not None and songs & album.songs:
353                item.scanned = False
354                model.row_changed(model.get_path(iter_), iter_)
355
356    def __key_pressed(self, widget, event, librarian):
357        if qltk.is_accel(event, "<Primary>I"):
358            songs = self.__get_selected_songs()
359            if songs:
360                window = Information(librarian, songs, self)
361                window.show()
362            return True
363        elif qltk.is_accel(event, "<Primary>Return", "<Primary>KP_Enter"):
364            qltk.enqueue(self.__get_selected_songs(sort=True))
365            return True
366        elif qltk.is_accel(event, "<alt>Return"):
367            songs = self.__get_selected_songs()
368            if songs:
369                window = SongProperties(librarian, songs, self)
370                window.show()
371            return True
372        return False
373
374    def _row_needs_update(self, model, iter_):
375        item = model.get_value(iter_)
376        return item.album is not None and not item.scanned
377
378    def _update_row(self, filter_model, iter_):
379        sort_model = filter_model.get_model()
380        model = sort_model.get_model()
381        iter_ = filter_model.convert_iter_to_child_iter(iter_)
382        iter_ = sort_model.convert_iter_to_child_iter(iter_)
383        tref = Gtk.TreeRowReference.new(model, model.get_path(iter_))
384        mag = config.getfloat("browsers", "covergrid_magnification", 3.)
385
386        def callback():
387            path = tref.get_path()
388            if path is not None:
389                model.row_changed(path, model.get_iter(path))
390            # XXX: icon view seems to ignore row_changed signals for pixbufs..
391            self.queue_draw()
392
393        item = model.get_value(iter_)
394        scale_factor = self.get_scale_factor() * mag
395        item.scan_cover(scale_factor=scale_factor,
396                        callback=callback,
397                        cancel=self._cover_cancel)
398
399    def __destroy(self, browser):
400        self._cover_cancel.cancel()
401        self.disable_row_update()
402
403        self.view.set_model(None)
404
405        klass = type(browser)
406        if not klass.instances():
407            klass._destroy_model()
408
409    def __update_filter(self, entry=None, text=None, scroll_up=True,
410                        restore=False):
411        model = self.view.get_model()
412
413        self.__filter = None
414        query = self.__search.get_query(self.STAR)
415        if not query.matches_all:
416            self.__filter = query.search
417        self.__bg_filter = background_filter()
418
419        self.__inhibit()
420
421        # If we're hiding "All Albums", then there will always
422        # be something to filter ­— probably there's a better
423        # way to implement this
424
425        if (not restore or self.__filter or self.__bg_filter) or (not
426            config.getboolean("browsers", "covergrid_all", True)):
427            model.refilter()
428
429        self.__uninhibit()
430
431    def __parse_query(self, model, iter_, data):
432        f, b = self.__filter, self.__bg_filter
433        album = model.get_album(iter_)
434
435        if f is None and b is None and album is not None:
436            return True
437        else:
438            if album is None:
439                return config.getboolean("browsers", "covergrid_all", True)
440            elif b is None:
441                return f(album)
442            elif f is None:
443                return b(album)
444            else:
445                return b(album) and f(album)
446
447    def __search_func(self, model, column, key, iter_, data):
448        album = model.get_album(iter_)
449        if album is None:
450            return config.getboolean("browsers", "covergrid_all", True)
451        key = key.lower()
452        title = album.title.lower()
453        if key in title:
454            return False
455        if config.getboolean("browsers", "album_substrings"):
456            people = (p.lower() for p in album.list("~people"))
457            for person in people:
458                if key in person:
459                    return False
460        return True
461
462    def __rightclick(self, view, event, library):
463        x = int(event.x)
464        y = int(event.y)
465        current_path = view.get_path_at_pos(x, y)
466        if event.button == Gdk.BUTTON_SECONDARY and current_path:
467            if not view.path_is_selected(current_path):
468                view.unselect_all()
469            view.select_path(current_path)
470            self.__popup(view, library)
471
472    def __popup(self, view, library):
473
474        albums = self.__get_selected_albums()
475        songs = self.__get_songs_from_albums(albums)
476
477        items = []
478        num = len(albums)
479        button = MenuItem(
480            ngettext("Reload album _cover", "Reload album _covers", num),
481            Icons.VIEW_REFRESH)
482        button.connect('activate', self.__refresh_album, view)
483        items.append(button)
484
485        menu = SongsMenu(library, songs, items=[items])
486        menu.show_all()
487        popup_menu_at_widget(menu, view,
488            Gdk.BUTTON_SECONDARY,
489            Gtk.get_current_event_time())
490
491    def _show_tooltip(self, widget, x, y, keyboard_tip, tooltip):
492        w = self.scrollwin.get_hadjustment().get_value()
493        z = self.scrollwin.get_vadjustment().get_value()
494        path = widget.get_path_at_pos(int(x + w), int(y + z))
495        if path is None:
496            return False
497        model = widget.get_model()
498        iter = model.get_iter(path)
499        album = model.get_album(iter)
500        if album is None:
501            text = "<b>%s</b>" % _("All Albums")
502            text += "\n" + ngettext("%d album",
503                "%d albums", len(model) - 1) % (len(model) - 1)
504            markup = text
505        else:
506            markup = self.display_pattern % album
507        tooltip.set_markup(markup)
508        return True
509
510    def __refresh_album(self, menuitem, view):
511        items = self.__get_selected_items()
512        for item in items:
513            item.scanned = False
514        model = self.view.get_model()
515        for iter_, item in model.iterrows():
516            if item in items:
517                model.row_changed(model.get_path(iter_), iter_)
518
519    def __get_selected_items(self):
520        model = self.view.get_model()
521        paths = self.view.get_selected_items()
522        return model.get_items(paths)
523
524    def __get_selected_albums(self):
525        model = self.view.get_model()
526        paths = self.view.get_selected_items()
527        return model.get_albums(paths)
528
529    def __get_songs_from_albums(self, albums, sort=True):
530        # Sort first by how the albums appear in the model itself,
531        # then within the album using the default order.
532        songs = []
533        if sort:
534            for album in albums:
535                songs.extend(sorted(album.songs, key=lambda s: s.sort_key))
536        else:
537            for album in albums:
538                songs.extend(album.songs)
539        return songs
540
541    def __get_selected_songs(self, sort=True):
542        albums = self.__get_selected_albums()
543        return self.__get_songs_from_albums(albums, sort)
544
545    def __drag_data_get(self, view, ctx, sel, tid, etime):
546        songs = self.__get_selected_songs()
547        if tid == 1:
548            qltk.selection_set_songs(sel, songs)
549        else:
550            sel.set_uris([song("~uri") for song in songs])
551
552    def __play_selection(self, view, indices, col):
553        self.songs_activated()
554
555    def active_filter(self, song):
556        for album in self.__get_selected_albums():
557            if song in album.songs:
558                return True
559        return False
560
561    def can_filter_text(self):
562        return True
563
564    def filter_text(self, text):
565        self.__search.set_text(text)
566        if Query(text).is_parsable:
567            self.__update_filter(self.__search, text)
568            # self.__inhibit()
569            #self.view.set_cursor((0,), None, False)
570            # self.__uninhibit()
571            self.activate()
572
573    def get_filter_text(self):
574        return self.__search.get_text()
575
576    def can_filter(self, key):
577        # Numerics are different for collections, and although title works,
578        # it's not of much use here.
579        if key is not None and (key.startswith("~#") or key == "title"):
580            return False
581        return super(CoverGrid, self).can_filter(key)
582
583    def can_filter_albums(self):
584        return True
585
586    def list_albums(self):
587        model = self.view.get_model()
588        return [row[0].album.key for row in model if row[0].album]
589
590    def select_by_func(self, func, scroll=True, one=False):
591        model = self.view.get_model()
592        if not model:
593            return False
594
595        selection = self.view.get_selected_items()
596        first = True
597        for row in model:
598            if func(row):
599                if not first:
600                    selection.select_path(row.path)
601                    continue
602                self.view.unselect_all()
603                self.view.select_path(row.path)
604                self.view.set_cursor(row.path, None, False)
605                if scroll:
606                    self.view.scroll_to_path(row.path, True, 0.5, 0.5)
607                first = False
608                if one:
609                    break
610        return not first
611
612    def filter_albums(self, values):
613        self.__inhibit()
614        changed = self.select_by_func(
615            lambda r: r[0].album and r[0].album.key in values)
616        self.view.grab_focus()
617        self.__uninhibit()
618        if changed:
619            self.activate()
620
621    def unfilter(self):
622        self.filter_text("")
623
624    def activate(self):
625        self.view.emit('selection-changed')
626
627    def __inhibit(self):
628        self.view.handler_block(self.__sig)
629
630    def __uninhibit(self):
631        self.view.handler_unblock(self.__sig)
632
633    def restore(self):
634        text = config.gettext("browsers", "query_text")
635        entry = self.__search
636        entry.set_text(text)
637
638        # update_filter expects a parsable query
639        if Query(text).is_parsable:
640            self.__update_filter(entry, text, scroll_up=False, restore=True)
641
642        keys = config.gettext("browsers", "covergrid", "").split("\n")
643
644        self.__inhibit()
645        if keys != [""]:
646            def select_fun(row):
647                album = row[0].album
648                if not album:  # all
649                    return False
650                return album.str_key in keys
651            self.select_by_func(select_fun)
652        else:
653            self.select_by_func(lambda r: r[0].album is None)
654        self.__uninhibit()
655
656    def scroll(self, song):
657        album_key = song.album_key
658        select = lambda r: r[0].album and r[0].album.key == album_key
659        self.select_by_func(select, one=True)
660
661    def __get_config_string(self):
662        model = self.view.get_model()
663        paths = self.view.get_selected_items()
664
665        # All is selected
666        if model.contains_all(paths):
667            return ""
668
669        # All selected albums
670        albums = model.get_albums(paths)
671
672        confval = "\n".join((a.str_key for a in albums))
673        # ConfigParser strips a trailing \n so we move it to the front
674        if confval and confval[-1] == "\n":
675            confval = "\n" + confval[:-1]
676        return confval
677
678    def save(self):
679        conf = self.__get_config_string()
680        config.settext("browsers", "covergrid", conf)
681        text = self.__search.get_text()
682        config.settext("browsers", "query_text", text)
683
684    def __update_songs(self, selection):
685        songs = self.__get_selected_songs(sort=False)
686        self.songs_selected(songs)
687