1# Copyright (C) 2008-2010 Adam Olsen
2#
3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation; either version 2, or (at your option)
6# any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
16#
17#
18# The developers of the Exaile media player hereby grant permission
19# for non-GPL compatible GStreamer and Exaile plugins to be used and
20# distributed together with GStreamer and Exaile. This permission is
21# above and beyond the permissions granted by the GPL license by which
22# Exaile is covered. If you modify this code, you may extend this
23# exception to your version of the code, but you are not obligated to
24# do so. If you do not wish to do so, delete this exception statement
25# from your version.
26
27import locale
28import logging
29import os
30
31from gi.repository import Gdk, GdkPixbuf, Gio, GLib, GObject, Gtk, Pango
32
33from xl import common, event, metadata, settings, trax
34from xl.nls import gettext as _
35from xl.trax.util import recursive_tracks_from_file
36from xlgui import guiutil, icons, panel, xdg
37
38from xlgui.panel import menus
39from xlgui.widgets.common import DragTreeView
40
41
42logger = logging.getLogger(__name__)
43
44
45def gfile_enumerate_children(gfile, attributes, follow_symlinks=True):
46    """Like Gio.File.enumerate_children but ignores errors"""
47    flags = (
48        Gio.FileQueryInfoFlags.NONE
49        if follow_symlinks
50        else Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS
51    )
52    infos = gfile.enumerate_children(attributes, flags, None)
53    it = iter(infos)
54    while True:
55        try:
56            yield next(it)
57        except StopIteration:
58            break
59        except GLib.Error:
60            logger.warning(
61                "Error while iterating on %r", gfile.get_parse_name(), exc_info=True
62            )
63
64
65class FilesPanel(panel.Panel):
66    """
67    The Files panel
68    """
69
70    __gsignals__ = {
71        'append-items': (GObject.SignalFlags.RUN_LAST, None, (object, bool)),
72        'replace-items': (GObject.SignalFlags.RUN_LAST, None, (object,)),
73        'queue-items': (GObject.SignalFlags.RUN_LAST, None, (object,)),
74    }
75
76    ui_info = ('files.ui', 'FilesPanel')
77
78    def __init__(self, parent, collection, name):
79        """
80        Initializes the files panel
81        """
82        panel.Panel.__init__(self, parent, name, _('Files'))
83        self.collection = collection
84
85        self.box = self.builder.get_object('FilesPanel')
86
87        self.targets = [Gtk.TargetEntry.new('text/uri-list', 0, 0)]
88
89        self._setup_tree()
90        self._setup_widgets()
91        self.menu = menus.FilesContextMenu(self)
92
93        self.key_id = None
94        self.i = 0
95
96        first_dir = Gio.File.new_for_commandline_arg(
97            settings.get_option('gui/files_panel_dir', xdg.homedir)
98        )
99        self.history = [first_dir]
100        self.load_directory(first_dir, False)
101
102    def _setup_tree(self):
103        """
104        Sets up tree widget for the files panel
105        """
106        self.model = Gtk.ListStore(Gio.File, GdkPixbuf.Pixbuf, str, str, bool)
107        self.tree = tree = FilesDragTreeView(self, receive=False, source=True)
108        tree.set_model(self.model)
109        tree.connect('row-activated', self.row_activated)
110        tree.connect('key-release-event', self.on_key_released)
111
112        selection = tree.get_selection()
113        selection.set_mode(Gtk.SelectionMode.MULTIPLE)
114        self.scroll = scroll = Gtk.ScrolledWindow()
115        scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
116        scroll.add(tree)
117        scroll.set_shadow_type(Gtk.ShadowType.IN)
118        self.box.pack_start(scroll, True, True, 0)
119
120        pb = Gtk.CellRendererPixbuf()
121        text = Gtk.CellRendererText()
122        self.colname = colname = Gtk.TreeViewColumn(_('Filename'))
123        colname.pack_start(pb, False)
124        colname.pack_start(text, True)
125        if settings.get_option('gui/ellipsize_text_in_panels', False):
126            text.set_property('ellipsize-set', True)
127            text.set_property('ellipsize', Pango.EllipsizeMode.END)
128        else:
129            colname.connect('notify::width', self.set_column_width)
130
131            width = settings.get_option('gui/files_filename_col_width', 130)
132
133            colname.set_fixed_width(width)
134            colname.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
135
136        colname.set_resizable(True)
137        colname.set_attributes(pb, pixbuf=1)
138        colname.set_attributes(text, text=2)
139        colname.set_expand(True)
140
141        tree.append_column(self.colname)
142
143        text = Gtk.CellRendererText()
144        text.set_property('xalign', 1.0)
145        # TRANSLATORS: File size column in the file browser
146        self.colsize = colsize = Gtk.TreeViewColumn(_('Size'))
147        colsize.set_resizable(True)
148        colsize.pack_start(text, False)
149        colsize.set_attributes(text, text=3)
150        colsize.set_expand(False)
151        tree.append_column(colsize)
152
153    def _setup_widgets(self):
154        """
155        Sets up the widgets for the files panel
156        """
157        self.directory = icons.MANAGER.pixbuf_from_icon_name(
158            'folder', Gtk.IconSize.SMALL_TOOLBAR
159        )
160        self.track = icons.MANAGER.pixbuf_from_icon_name(
161            'audio-x-generic', Gtk.IconSize.SMALL_TOOLBAR
162        )
163        self.back = self.builder.get_object('files_back_button')
164        self.back.connect('clicked', self.go_back)
165        self.forward = self.builder.get_object('files_forward_button')
166        self.forward.connect('clicked', self.go_forward)
167        self.up = self.builder.get_object('files_up_button')
168        self.up.connect('clicked', self.go_up)
169        self.builder.get_object('files_refresh_button').connect('clicked', self.refresh)
170        self.builder.get_object('files_home_button').connect('clicked', self.go_home)
171
172        # Set up the location bar
173        self.location_bar = self.builder.get_object('files_entry')
174        self.location_bar.connect('changed', self.on_location_bar_changed)
175        event.add_ui_callback(
176            self.fill_libraries_location, 'libraries_modified', self.collection
177        )
178        self.fill_libraries_location()
179        self.location_bar.set_row_separator_func(lambda m, i: m[i][1] is None)
180        self.entry = self.location_bar.get_children()[0]
181        self.entry.connect('activate', self.entry_activate)
182
183        # Set up the search entry
184        self.filter = guiutil.SearchEntry(self.builder.get_object('files_search_entry'))
185        self.filter.connect(
186            'activate',
187            lambda *e: self.load_directory(
188                self.current,
189                history=False,
190                keyword=self.filter.get_text(),
191            ),
192        )
193
194    def fill_libraries_location(self, *e):
195        libraries = []
196        for library in self.collection._serial_libraries:
197            f = Gio.File.new_for_commandline_arg(library['location'])
198            libraries.append((f.get_parse_name(), f.get_uri()))
199
200        mounts = []
201        for mount in Gio.VolumeMonitor.get().get_mounts():
202            name = mount.get_name()
203            uri = mount.get_default_location().get_uri()
204            mounts.append((name, uri))
205        mounts.sort(key=lambda row: locale.strxfrm(row[0]))
206
207        model = self.location_bar.get_model()
208        model.clear()
209        for row in libraries:
210            model.append(row)
211        if libraries and mounts:
212            model.append((None, None))
213        for row in mounts:
214            model.append(row)
215        self.location_bar.set_model(model)
216
217    def on_location_bar_changed(self, widget, *args):
218        # Find out which one is selected, if any.
219        iter = self.location_bar.get_active_iter()
220        if not iter:
221            return
222        model = self.location_bar.get_model()
223        uri = model.get_value(iter, 1)
224        if uri:
225            self.load_directory(Gio.File.new_for_uri(uri))
226
227    def on_key_released(self, widget, event):
228        """
229        Called when a key is released in the tree
230        """
231        if event.keyval == Gdk.KEY_Menu:
232            Gtk.Menu.popup(self.menu, None, None, None, None, 0, event.time)
233            return True
234
235        if (
236            event.keyval == Gdk.KEY_Left
237            and Gdk.ModifierType.MOD1_MASK & event.get_state()
238        ):
239            self.go_back(self.tree)
240            return True
241
242        if (
243            event.keyval == Gdk.KEY_Right
244            and Gdk.ModifierType.MOD1_MASK & event.get_state()
245        ):
246            self.go_forward(self.tree)
247            return True
248
249        if (
250            event.keyval == Gdk.KEY_Up
251            and Gdk.ModifierType.MOD1_MASK & event.get_state()
252        ):
253            self.go_up(self.tree)
254            return True
255
256        if event.keyval == Gdk.KEY_BackSpace:
257            self.go_up(self.tree)
258            return True
259
260        if event.keyval == Gdk.KEY_F5:
261            self.refresh(self.tree)
262            return True
263        return False
264
265    def row_activated(self, *i):
266        """
267        Called when someone double clicks a row
268        """
269        selection = self.tree.get_selection()
270        model, paths = selection.get_selected_rows()
271
272        for path in paths:
273            if model[path][4]:
274                self.load_directory(model[path][0])
275            else:
276                self.emit('append-items', self.tree.get_selected_tracks(), True)
277
278    def refresh(self, widget):
279        """
280        Refreshes the current view
281        """
282        treepath = self.tree.get_cursor()[0]
283        cursorf = self.model[treepath][0] if treepath else None
284        self.load_directory(self.current, history=False, cursor_file=cursorf)
285        self.fill_libraries_location()
286
287    def entry_activate(self, widget, event=None):
288        """
289        Called when the user presses enter in the entry box
290        """
291        path = self.entry.get_text()
292        if path.startswith('~'):
293            path = os.path.expanduser(path)
294        f = Gio.file_parse_name(path)
295        try:
296            ftype = f.query_info(
297                'standard::type', Gio.FileQueryInfoFlags.NONE, None
298            ).get_file_type()
299        except GLib.GError as e:
300            logger.exception(e)
301            self.entry.set_text(self.current.get_parse_name())
302            return
303        if ftype != Gio.FileType.DIRECTORY:
304            f = f.get_parent()
305        self.load_directory(f)
306
307    def focus(self):
308        self.tree.grab_focus()
309
310    def go_forward(self, widget):
311        """
312        Goes to the next entry in history
313        """
314        assert 0 <= self.i < len(self.history)
315        if self.i == len(self.history) - 1:
316            return
317        self.i += 1
318        self.load_directory(
319            self.history[self.i], history=False, cursor_file=self.current
320        )
321        if self.i >= len(self.history) - 1:
322            self.forward.set_sensitive(False)
323        if self.history:
324            self.back.set_sensitive(True)
325
326    def go_back(self, widget):
327        """
328        Goes to the previous entry in history
329        """
330        assert 0 <= self.i < len(self.history)
331        if self.i == 0:
332            return
333        self.i -= 1
334        self.load_directory(
335            self.history[self.i], history=False, cursor_file=self.current
336        )
337        if self.i == 0:
338            self.back.set_sensitive(False)
339        if self.history:
340            self.forward.set_sensitive(True)
341
342    def go_up(self, widget):
343        """
344        Moves up one directory
345        """
346        parent = self.current.get_parent()
347        if parent:
348            self.load_directory(parent, cursor_file=self.current)
349
350    def go_home(self, widget):
351        """
352        Goes to the user's home directory
353        """
354        home = Gio.File.new_for_commandline_arg(xdg.homedir)
355        if home.get_uri() == self.current.get_uri():
356            self.refresh(widget)
357        else:
358            self.load_directory(home, cursor_file=self.current)
359
360    def set_column_width(self, col, stuff=None):
361        """
362        Called when the user resizes a column
363        """
364        name = {self.colname: 'filename', self.colsize: 'size'}[col]
365        name = "gui/files_%s_col_width" % name
366
367        # this option gets triggered all the time, which is annoying when debugging,
368        # so only set it when it actually changes
369        w = col.get_width()
370        if settings.get_option(name, w) != w:
371            settings.set_option(name, w, save=False)
372
373    @common.threaded
374    def load_directory(self, directory, history=True, keyword=None, cursor_file=None):
375        """
376        Load a directory into the files view.
377
378        :param history: whether to record in history
379        :param keyword: filter string
380        :param cursor_file: file to (attempt to) put the cursor on.
381            Will put the cursor on a subdirectory if the file is under it.
382        """
383        self.current = directory
384        try:
385            infos = gfile_enumerate_children(
386                directory,
387                'standard::display-name,standard::is-hidden,standard::name,standard::type',
388            )
389        except GLib.Error as e:
390            logger.exception(e)
391            if directory.get_path() != xdg.homedir:  # Avoid infinite recursion.
392                self.load_directory(
393                    Gio.File.new_for_commandline_arg(xdg.homedir),
394                    history,
395                    keyword,
396                    cursor_file,
397                )
398            return
399        if self.current != directory:  # Modified from another thread.
400            return
401
402        settings.set_option('gui/files_panel_dir', directory.get_uri())
403
404        subdirs = []
405        subfiles = []
406        for info in infos:
407            if info.get_is_hidden():
408                # Ignore hidden files. They can still be accessed manually from
409                # the location bar.
410                continue
411            name = info.get_display_name()
412            low_name = name.lower()
413            if keyword and keyword.lower() not in low_name:
414                continue
415            f = directory.get_child(info.get_name())
416
417            ftype = info.get_file_type()
418            sortname = locale.strxfrm(name)
419            if ftype == Gio.FileType.DIRECTORY:
420                subdirs.append((sortname, name, f))
421            elif any(low_name.endswith('.' + ext) for ext in metadata.formats):
422                subfiles.append((sortname, name, f))
423
424        subdirs.sort()
425        subfiles.sort()
426
427        def idle():
428            if self.current != directory:  # Modified from another thread.
429                return
430
431            model = self.model
432            view = self.tree
433
434            if cursor_file:
435                cursor_uri = cursor_file.get_uri()
436            cursor_row = -1
437
438            model.clear()
439            row = 0
440            for sortname, name, f in subdirs:
441                model.append((f, self.directory, name, '', True))
442                uri = f.get_uri()
443                if (
444                    cursor_file
445                    and cursor_row == -1
446                    and (cursor_uri == uri or cursor_uri.startswith(uri + '/'))
447                ):
448                    cursor_row = row
449                row += 1
450            for sortname, name, f in subfiles:
451                size = (
452                    f.query_info(
453                        'standard::size', Gio.FileQueryInfoFlags.NONE, None
454                    ).get_size()
455                    // 1000
456                )
457
458                # TRANSLATORS: File size (1 kB = 1000 bytes)
459                size = _('%s kB') % locale.format_string('%d', size, True)
460
461                model.append((f, self.track, name, size, False))
462                if cursor_file and cursor_row == -1 and cursor_uri == f.get_uri():
463                    cursor_row = row
464                row += 1
465
466            if cursor_file and cursor_row != -1:
467                view.set_cursor((cursor_row,))
468            else:
469                view.set_cursor((0,))
470                if view.get_realized():
471                    view.scroll_to_point(0, 0)
472
473            self.entry.set_text(directory.get_parse_name())
474            if history:
475                self.back.set_sensitive(True)
476                self.history[self.i + 1 :] = [self.current]
477                self.i = len(self.history) - 1
478                self.forward.set_sensitive(False)
479            self.up.set_sensitive(bool(directory.get_parent()))
480
481        GLib.idle_add(idle)
482
483    def drag_get_data(self, treeview, context, selection, target_id, etime):
484        """
485        Called when a drag source wants data for this drag operation
486        """
487        tracks = self.tree.get_selected_tracks()
488        if not tracks:
489            return
490        for track in tracks:
491            DragTreeView.dragged_data[track.get_loc_for_io()] = track
492        uris = trax.util.get_uris_from_tracks(tracks)
493        selection.set_uris(uris)
494
495
496class FilesDragTreeView(DragTreeView):
497    """
498    Custom DragTreeView to retrieve data from files
499    """
500
501    def get_selection_empty(self):
502        '''Returns True if there are no selected items'''
503        return self.get_selection().count_selected_rows() == 0
504
505    def get_selection_is_computed(self):
506        """
507        Returns True if anything in the selection is a directory
508        """
509        selection = self.get_selection()
510        model, paths = selection.get_selected_rows()
511
512        for path in paths:
513            if model[path][4]:
514                return True
515
516        return False
517
518    def get_selected_tracks(self):
519        """
520        Returns the currently selected tracks
521        """
522        selection = self.get_selection()
523        model, paths = selection.get_selected_rows()
524        tracks = []
525
526        for path in paths:
527            f = model[path][0]
528            self.append_recursive(tracks, f)
529
530        return trax.sort_tracks(common.BASE_SORT_TAGS, tracks, artist_compilations=True)
531
532    def append_recursive(self, songs, f):
533        """
534        Appends recursively
535        """
536        ftype = f.query_info(
537            'standard::type', Gio.FileQueryInfoFlags.NONE, None
538        ).get_file_type()
539        if ftype == Gio.FileType.DIRECTORY:
540            file_infos = gfile_enumerate_children(f, 'standard::name')
541            files = (f.get_child(fi.get_name()) for fi in file_infos)
542            for subf in files:
543                self.append_recursive(songs, subf)
544        else:
545            tr = self.get_track(f)
546            if tr:
547                songs.append(tr)
548
549    def get_track(self, f):
550        """
551        Returns a single track from a Gio.File
552        """
553        uri = f.get_uri()
554        if not trax.is_valid_track(uri):
555            return None
556        tr = trax.Track(uri)
557        return tr
558
559    def get_tracks_for_path(self, path):
560        """
561        Get tracks for a path from model (expand item)
562        :param path: Gtk.TreePath
563        :return: list of tracks [xl.trax.Track]
564        """
565        return recursive_tracks_from_file(self.get_model()[path][0])
566