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