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