1# -*- coding: utf-8 -*-
2#
3# Picard, the next-generation MusicBrainz tagger
4#
5# Copyright (C) 2006-2008 Lukáš Lalinský
6# Copyright (C) 2008 Hendrik van Antwerpen
7# Copyright (C) 2008-2009, 2019-2021 Philipp Wolfer
8# Copyright (C) 2011 Andrew Barnert
9# Copyright (C) 2012-2013 Michael Wiencek
10# Copyright (C) 2013 Wieland Hoffmann
11# Copyright (C) 2013, 2017 Sophist-UK
12# Copyright (C) 2013, 2018-2019 Laurent Monin
13# Copyright (C) 2015 Jeroen Kromwijk
14# Copyright (C) 2016-2017 Sambhav Kothari
15# Copyright (C) 2018 Vishal Choudhary
16#
17# This program is free software; you can redistribute it and/or
18# modify it under the terms of the GNU General Public License
19# as published by the Free Software Foundation; either version 2
20# of the License, or (at your option) any later version.
21#
22# This program is distributed in the hope that it will be useful,
23# but WITHOUT ANY WARRANTY; without even the implied warranty of
24# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
25# GNU General Public License for more details.
26#
27# You should have received a copy of the GNU General Public License
28# along with this program; if not, write to the Free Software
29# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
30
31
32import os
33
34from PyQt5 import (
35    QtCore,
36    QtWidgets,
37)
38from PyQt5.QtCore import QStandardPaths
39
40from picard import log
41from picard.config import (
42    BoolOption,
43    TextOption,
44    get_config,
45)
46from picard.const.sys import IS_MACOS
47from picard.formats import supported_formats
48from picard.util import find_existing_path
49
50
51def _macos_find_root_volume():
52    try:
53        for entry in os.scandir('/Volumes/'):
54            if entry.is_symlink() and os.path.realpath(entry.path) == '/':
55                return entry.path
56    except OSError:
57        log.warning('Could not detect macOS boot volume', exc_info=True)
58    return None
59
60
61def _macos_extend_root_volume_path(path):
62    if not path.startswith('/Volumes/'):
63        root_volume = _macos_find_root_volume()
64        if root_volume:
65            if path.startswith('/'):
66                path = path[1:]
67            path = os.path.join(root_volume, path)
68    return path
69
70
71_default_current_browser_path = QStandardPaths.writableLocation(QStandardPaths.HomeLocation)
72
73if IS_MACOS:
74    _default_current_browser_path = _macos_extend_root_volume_path(_default_current_browser_path)
75
76
77class FileBrowser(QtWidgets.QTreeView):
78
79    options = [
80        TextOption("persist", "current_browser_path", _default_current_browser_path),
81        BoolOption("persist", "show_hidden_files", False),
82    ]
83
84    def __init__(self, parent):
85        super().__init__(parent)
86        self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
87        self.setDragEnabled(True)
88        self.load_selected_files_action = QtWidgets.QAction(_("&Load selected files"), self)
89        self.load_selected_files_action.triggered.connect(self.load_selected_files)
90        self.addAction(self.load_selected_files_action)
91        self.move_files_here_action = QtWidgets.QAction(_("&Move tagged files here"), self)
92        self.move_files_here_action.triggered.connect(self.move_files_here)
93        self.addAction(self.move_files_here_action)
94        self.toggle_hidden_action = QtWidgets.QAction(_("Show &hidden files"), self)
95        self.toggle_hidden_action.setCheckable(True)
96        config = get_config()
97        self.toggle_hidden_action.setChecked(config.persist["show_hidden_files"])
98        self.toggle_hidden_action.toggled.connect(self.show_hidden)
99        self.addAction(self.toggle_hidden_action)
100        self.set_as_starting_directory_action = QtWidgets.QAction(_("&Set as starting directory"), self)
101        self.set_as_starting_directory_action.triggered.connect(self.set_as_starting_directory)
102        self.addAction(self.set_as_starting_directory_action)
103        self.doubleClicked.connect(self.load_file_for_item)
104        self.focused = False
105        self._set_model()
106
107    def contextMenuEvent(self, event):
108        menu = QtWidgets.QMenu(self)
109        menu.addAction(self.load_selected_files_action)
110        menu.addSeparator()
111        menu.addAction(self.move_files_here_action)
112        menu.addAction(self.toggle_hidden_action)
113        menu.addAction(self.set_as_starting_directory_action)
114        menu.exec_(event.globalPos())
115        event.accept()
116
117    def _set_model(self):
118        self.model = QtWidgets.QFileSystemModel()
119        self.model.layoutChanged.connect(self._layout_changed)
120        self.model.setRootPath("")
121        self._set_model_filter()
122        filters = []
123        for exts, name in supported_formats():
124            filters.extend("*" + e for e in exts)
125        self.model.setNameFilters(filters)
126        # Hide unsupported files completely
127        self.model.setNameFilterDisables(False)
128        self.model.sort(0, QtCore.Qt.AscendingOrder)
129        self.setModel(self.model)
130        if IS_MACOS:
131            self.setRootIndex(self.model.index("/Volumes"))
132        header = self.header()
133        header.hideSection(1)
134        header.hideSection(2)
135        header.hideSection(3)
136        header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
137        header.setStretchLastSection(False)
138        header.setVisible(False)
139
140    def _set_model_filter(self):
141        config = get_config()
142        model_filter = QtCore.QDir.AllDirs | QtCore.QDir.Files | QtCore.QDir.Drives | QtCore.QDir.NoDotAndDotDot
143        if config.persist["show_hidden_files"]:
144            model_filter |= QtCore.QDir.Hidden
145        self.model.setFilter(model_filter)
146
147    def _layout_changed(self):
148        def scroll():
149            # XXX The currentIndex seems to change while QFileSystemModel is
150            # populating itself (so setCurrentIndex in __init__ won't last).
151            # The time it takes to load varies and there are no signals to find
152            # out when it's done. As a workaround, keep restoring the state as
153            # long as the layout is updating, and the user hasn't focused yet.
154            if not self.focused:
155                self._restore_state()
156            self.scrollTo(self.currentIndex())
157        QtCore.QTimer.singleShot(0, scroll)
158
159    def scrollTo(self, index, scrolltype=QtWidgets.QAbstractItemView.EnsureVisible):
160        # QTreeView.scrollTo resets the horizontal scroll position to 0.
161        # Reimplemented to instead scroll to horizontal parent position or keep previous position.
162        config = get_config()
163        if index and config.setting['filebrowser_horizontal_autoscroll']:
164            level = -1
165            parent = index.parent()
166            root = self.rootIndex()
167            while parent.isValid() and parent != root:
168                parent = parent.parent()
169                level += 1
170            pos_x = max(self.indentation() * level, 0)
171        else:
172            pos_x = self.horizontalScrollBar().value()
173        super().scrollTo(index, scrolltype)
174        self.horizontalScrollBar().setValue(pos_x)
175
176    def mousePressEvent(self, event):
177        super().mousePressEvent(event)
178        index = self.indexAt(event.pos())
179        if index.isValid():
180            self.selectionModel().setCurrentIndex(index, QtCore.QItemSelectionModel.NoUpdate)
181
182    def focusInEvent(self, event):
183        self.focused = True
184        super().focusInEvent(event)
185
186    def show_hidden(self, state):
187        config = get_config()
188        config.persist["show_hidden_files"] = state
189        self._set_model_filter()
190
191    def save_state(self):
192        indexes = self.selectedIndexes()
193        if indexes:
194            path = self.model.filePath(indexes[0])
195            config = get_config()
196            config.persist["current_browser_path"] = os.path.normpath(path)
197
198    def restore_state(self):
199        pass
200
201    def _restore_state(self):
202        config = get_config()
203        if config.setting["starting_directory"]:
204            path = config.setting["starting_directory_path"]
205            scrolltype = QtWidgets.QAbstractItemView.PositionAtTop
206        else:
207            path = config.persist["current_browser_path"]
208            scrolltype = QtWidgets.QAbstractItemView.PositionAtCenter
209        if path:
210            index = self.model.index(find_existing_path(path))
211            self.setCurrentIndex(index)
212            self.expand(index)
213            self.scrollTo(index, scrolltype)
214
215    def _get_destination_from_path(self, path):
216        destination = os.path.normpath(path)
217        if not os.path.isdir(destination):
218            destination = os.path.dirname(destination)
219        return destination
220
221    def load_file_for_item(self, index):
222        if not self.model.isDir(index):
223            QtCore.QObject.tagger.add_paths([
224                self.model.filePath(index)
225            ])
226
227    def load_selected_files(self):
228        indexes = self.selectedIndexes()
229        if not indexes:
230            return
231        paths = set(self.model.filePath(index) for index in indexes)
232        QtCore.QObject.tagger.add_paths(paths)
233
234    def move_files_here(self):
235        indexes = self.selectedIndexes()
236        if not indexes:
237            return
238        config = get_config()
239        path = self.model.filePath(indexes[0])
240        config.setting["move_files_to"] = self._get_destination_from_path(path)
241
242    def set_as_starting_directory(self):
243        indexes = self.selectedIndexes()
244        if indexes:
245            config = get_config()
246            path = self.model.filePath(indexes[0])
247            config.setting["starting_directory_path"] = self._get_destination_from_path(path)
248