1# Copyright (C) 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 27""" 28 Shared GUI widgets 29""" 30 31from collections import namedtuple 32from urllib.parse import urlparse 33 34from gi.repository import Gio 35from gi.repository import Gdk 36from gi.repository import GLib 37from gi.repository import GObject 38from gi.repository import Gtk 39 40from xl import common, playlist as xl_playlist, trax 41 42from xlgui import icons 43from xlgui.guiutil import get_workarea_size 44 45 46class AttachedWindow(Gtk.Window): 47 """ 48 A window attachable to arbitrary widgets, 49 follows the movement of its parent 50 """ 51 52 __gsignals__ = {'show': 'override'} 53 54 def __init__(self, parent): 55 Gtk.Window.__init__(self, Gtk.WindowType.TOPLEVEL) 56 57 self.set_decorated(False) 58 self.props.skip_taskbar_hint = True 59 self.set_keep_above(True) 60 61 # Only allow resizing 62 self.realize() 63 self.get_window().set_functions(Gdk.WMFunction.RESIZE) 64 65 self.parent_widget = parent 66 self.parent_window_connections = [] 67 parent.connect('hierarchy-changed', self._on_parent_hierarchy_changed) 68 69 def update_location(self): 70 """ 71 Makes sure the window is 72 always fully visible 73 """ 74 workarea = Gdk.Rectangle() 75 workarea.x = workarea.y = 0 76 workarea.width, workarea.height = get_workarea_size() 77 parent_alloc = self.parent_widget.get_allocation() 78 toplevel_position = ( 79 self.parent_widget.get_toplevel().get_window().get_position() 80 ) 81 # Use absolute screen position 82 parent_alloc.x += toplevel_position[0] 83 parent_alloc.y += toplevel_position[1] 84 85 alloc = self.get_allocation() 86 if workarea.width - parent_alloc.x < alloc.width: 87 # Parent rightmost 88 x = parent_alloc.x + parent_alloc.width - alloc.width 89 else: 90 # Parent leftmost 91 x = parent_alloc.x 92 93 if workarea.height - parent_alloc.y < alloc.height: 94 # Parent at bottom 95 y = parent_alloc.y - alloc.height 96 else: 97 # Parent at top 98 y = parent_alloc.y + parent_alloc.height 99 100 self.move(x, y) 101 102 def do_show(self): 103 """ 104 Updates the location upon show 105 """ 106 Gtk.Window.do_show(self) 107 self.update_location() 108 109 def _on_parent_hierarchy_changed(self, parent_widget, previous_toplevel): 110 """(Dis)connect from/to the parent's toplevel window signals""" 111 conns = self.parent_window_connections 112 for conn in conns: 113 previous_toplevel.disconnect(conn) 114 conns[:] = () 115 toplevel = parent_widget.get_toplevel() 116 if not isinstance(toplevel, Gtk.Window): # Not anchored 117 return 118 self.set_transient_for(toplevel) 119 conns.append( 120 toplevel.connect('configure-event', self._on_parent_window_configure_event) 121 ) 122 conns.append(toplevel.connect('hide', self._on_parent_window_hide)) 123 124 def _on_parent_window_configure_event(self, _widget, _event): 125 """Update location when parent window is moved""" 126 if self.props.visible: 127 self.update_location() 128 129 def _on_parent_window_hide(self, _window): 130 """Emit the "hide" signal on self when the parent window is hidden. 131 132 If there is a "transient for" relationship between two windows, when 133 the parent is hidden, the child is hidden without emitting "hide". 134 Here we manually emit it to simplify usage. 135 """ 136 self.emit('hide') 137 138 139class AutoScrollTreeView(Gtk.TreeView): 140 """ 141 A TreeView which handles autoscrolling upon DnD operations 142 """ 143 144 def __init__(self): 145 Gtk.TreeView.__init__(self) 146 147 self._SCROLL_EDGE_SIZE = 15 # As in gtktreeview.c 148 self.__autoscroll_timeout_id = None 149 150 self.connect("drag-motion", self._on_drag_motion) 151 self.connect("drag-leave", self._on_drag_leave) 152 153 def _on_drag_motion(self, widget, context, x, y, timestamp): 154 """ 155 Initiates automatic scrolling 156 """ 157 if not self.__autoscroll_timeout_id: 158 self.__autoscroll_timeout_id = GLib.timeout_add( 159 50, self._on_autoscroll_timeout 160 ) 161 162 def _on_drag_leave(self, widget, context, timestamp): 163 """ 164 Stops automatic scrolling 165 """ 166 autoscroll_timeout_id = self.__autoscroll_timeout_id 167 168 if autoscroll_timeout_id: 169 GLib.source_remove(autoscroll_timeout_id) 170 self.__autoscroll_timeout_id = None 171 172 def _on_autoscroll_timeout(self): 173 """ 174 Automatically scrolls during drag operations 175 176 Adapted from gtk_tree_view_vertical_autoscroll() in gtktreeview.c 177 """ 178 _, x, y, _ = self.props.window.get_pointer() 179 x, y = self.convert_widget_to_tree_coords(x, y) 180 visible_rect = self.get_visible_rect() 181 # Calculate offset from the top edge 182 offset = y - ( 183 visible_rect.y + 3 * self._SCROLL_EDGE_SIZE 184 ) # 3: Scroll faster upwards 185 186 # Check if we are near the bottom edge instead 187 if offset > 0: 188 # Calculate offset based on the bottom edge 189 offset = y - ( 190 visible_rect.y + visible_rect.height - 2 * self._SCROLL_EDGE_SIZE 191 ) 192 193 # Skip if we are not near to top or bottom edge 194 if offset < 0: 195 return True 196 197 vadjustment = self.get_vadjustment() 198 vadjustment.props.value = common.clamp( 199 vadjustment.props.value + offset, 200 0, 201 vadjustment.props.upper - vadjustment.props.page_size, 202 ) 203 self.set_vadjustment(vadjustment) 204 205 return True 206 207 208class DragTreeView(AutoScrollTreeView): 209 """ 210 A TextView that does easy dragging/selecting/popup menu 211 """ 212 213 class EventData( 214 namedtuple('DragTreeView_EventData', 'event modifier triggers_menu target') 215 ): 216 """ 217 Objects that goes inside pending events list 218 """ 219 220 class Target( 221 namedtuple('DragTreeView_EventData_Target', 'path column is_selected') 222 ): 223 """ 224 Contains target path info 225 """ 226 227 targets = [Gtk.TargetEntry.new("text/uri-list", 0, 0)] 228 dragged_data = dict() 229 230 def __init__(self, container, receive=True, source=True, drop_pos=None): 231 """ 232 Initializes the tree and sets up the various callbacks 233 :param container: The container to place the TreeView into 234 :param receive: True if the TreeView should receive drag events 235 :param source: True if the TreeView should send drag events 236 :param drop_pos: Indicates where a drop operation should occur 237 w.r.t. existing entries: 'into', 'between', or None (both). 238 """ 239 AutoScrollTreeView.__init__(self) 240 self.container = container 241 self.pending_events = [] 242 243 if source: 244 self.drag_source_set( 245 Gdk.ModifierType.BUTTON1_MASK, 246 self.targets, 247 Gdk.DragAction.COPY | Gdk.DragAction.MOVE, 248 ) 249 250 if receive: 251 self.drop_pos = drop_pos 252 self.drag_dest_set( 253 Gtk.DestDefaults.ALL, 254 self.targets, 255 Gdk.DragAction.COPY | Gdk.DragAction.DEFAULT | Gdk.DragAction.MOVE, 256 ) 257 self.connect('drag_data_received', self.container.drag_data_received) 258 self.connect('drag_data_delete', self.container.drag_data_delete) 259 self.receive = receive 260 self.drag_context = None 261 self.show_cover_drag_icon = True 262 self.connect('drag-begin', self.on_drag_begin) 263 self.connect('drag-end', self.on_drag_end) 264 self.connect('drag-motion', self.on_drag_motion) 265 self.connect('button-release-event', self.on_button_release) 266 self.connect('button-press-event', self.on_button_press) 267 268 if source: 269 self.connect('drag-data-get', self.container.drag_get_data) 270 self.drag_source_set_icon_name('gtk-dnd') 271 272 def get_selected_tracks(self): 273 """ 274 Returns the currently selected tracks (stub) 275 """ 276 pass 277 278 def get_target_for(self, event): 279 """ 280 Gets target 281 :see: Gtk.TreeView.get_path_at_pos 282 :param event: Gdk.Event 283 :return: DragTreeView.EventData.Target or None if no target path 284 """ 285 target = self.get_path_at_pos(int(event.x), int(event.y)) 286 if target: 287 return DragTreeView.EventData.Target( 288 path=target[0], 289 column=target[1], 290 is_selected=self.get_selection().path_is_selected(target[0]), 291 ) 292 293 def set_cursor_at(self, target): 294 """ 295 Sets the cursor at target 296 :param target: DragTreeView.EventData.Target 297 :return: None 298 """ 299 self.set_cursor(target.path, target.column, False) 300 301 def set_selection_status(self, enabled): 302 """ 303 Change the set selection function 304 :param enabled: bool 305 :return: None 306 """ 307 self.get_selection().set_select_function(lambda *args: enabled, None) 308 309 def reset_selection_status(self): 310 """ 311 Reset 312 :return: None 313 """ 314 self.set_selection_status(True) 315 del self.pending_events[:] 316 317 def on_drag_end(self, list, context): 318 """ 319 Called when the dnd is ended 320 """ 321 self.drag_context = None 322 self.unset_rows_drag_dest() 323 self.drag_dest_set( 324 Gtk.DestDefaults.ALL, 325 self.targets, 326 Gdk.DragAction.COPY | Gdk.DragAction.MOVE, 327 ) 328 329 def on_drag_begin(self, widget, context): 330 """ 331 Sets the cover of dragged tracks as drag icon 332 """ 333 self.drag_context = context 334 Gdk.drag_abort(context, Gtk.get_current_event_time()) 335 336 self.reset_selection_status() 337 338 # Load covers 339 drag_cover_icon = None 340 get_tracks_for_path = getattr(self, 'get_tracks_for_path', None) 341 if get_tracks_for_path: 342 model, paths = self.get_selection().get_selected_rows() 343 drag_cover_icon = icons.MANAGER.get_drag_cover_icon( 344 map(get_tracks_for_path, paths) 345 ) 346 347 if drag_cover_icon is None: 348 # Set default icon 349 icon_name = ( 350 'gtk-dnd-multiple' 351 if self.get_selection().count_selected_rows() > 1 352 else 'gtk-dnd' 353 ) 354 Gtk.drag_set_icon_name(context, icon_name, 0, 0) 355 else: 356 Gtk.drag_set_icon_pixbuf(context, drag_cover_icon, 0, 0) 357 358 def on_drag_motion(self, treeview, context, x, y, timestamp): 359 """ 360 Called when a row is dragged over this treeview 361 """ 362 if not self.receive: 363 return False 364 self.enable_model_drag_dest(self.targets, Gdk.DragAction.DEFAULT) 365 if self.drop_pos is None: 366 return False 367 info = treeview.get_dest_row_at_pos(x, y) 368 if not info: 369 return False 370 path, pos = info 371 if self.drop_pos == 'into': 372 # Only allow dropping into entries. 373 if pos == Gtk.TreeViewDropPosition.BEFORE: 374 pos = Gtk.TreeViewDropPosition.INTO_OR_BEFORE 375 elif pos == Gtk.TreeViewDropPosition.AFTER: 376 pos = Gtk.TreeViewDropPosition.INTO_OR_AFTER 377 elif self.drop_pos == 'between': 378 # Only allow dropping between entries. 379 if pos == Gtk.TreeViewDropPosition.INTO_OR_BEFORE: 380 pos = Gtk.TreeViewDropPosition.BEFORE 381 elif pos == Gtk.TreeViewDropPosition.INTO_OR_AFTER: 382 pos = Gtk.TreeViewDropPosition.AFTER 383 treeview.set_drag_dest_row(path, pos) 384 context.drag_status(context.suggested_action, timestamp) 385 return True 386 387 def on_button_press(self, button, event): 388 """ 389 Called when a button is pressed 390 """ 391 # Always grab focus is a workaround to do not loose first click 392 self.grab_focus() 393 394 self.reset_selection_status() 395 396 # Only treats 1st button press 397 if event.type == Gdk.EventType.BUTTON_PRESS: 398 modifier = event.state & Gtk.accelerator_get_default_mod_mask() 399 target = self.get_target_for(event) 400 401 if target is None: 402 if modifier == 0: 403 # Unselects items if the user press any mouse button on an 404 # empty area of the TreeView and no modifier key is active 405 self.get_selection().unselect_all() 406 407 return True # Ignore clicks on empty areas 408 409 # Declare 410 triggers_menu = event.triggers_context_menu() 411 412 # Disable select function to to do not modify selection 413 # Triggering menu will only accept selection 414 if target.is_selected and (not modifier or triggers_menu): 415 self.set_selection_status(False) 416 417 # If it's not a DnD, it will be treated at button release event 418 self.pending_events.append( 419 DragTreeView.EventData(event, modifier, triggers_menu, target) 420 ) 421 422 # Calls `button_press` function on container (if present) 423 try: 424 button_press_function = self.container.button_press 425 except AttributeError: 426 return False 427 else: 428 return button_press_function(button, event) 429 430 def on_button_release(self, button, event): 431 """ 432 Called when a button is released 433 Treats the pending events added at button press event 434 435 Handles the popup menu that is displayed when you right click in 436 the TreeView list (calls `container.menu` if present) 437 """ 438 self.drag_context = None 439 440 # Get pending event 441 try: 442 event_data = self.pending_events.pop() 443 except IndexError: 444 return False 445 446 self.reset_selection_status() 447 448 # Do not set cursor if has a modifier key pressed 449 if event_data.modifier == 0 and not ( 450 event_data.triggers_menu and event_data.target.is_selected 451 ): 452 self.set_cursor_at(event_data.target) 453 454 if event_data.triggers_menu: 455 # Uses menu from container (if present) 456 menu = getattr(self.container, 'menu', None) 457 if menu: 458 menu.popup(event_data.event) 459 return True 460 461 return False 462 463 # TODO maybe move this somewhere else? (along with _handle_unknown_drag_data) 464 def get_drag_data(self, locs, compile_tracks=True, existing_tracks=[]): 465 """ 466 Handles the locations from drag data 467 468 @param locs: locations we are dealing with (can 469 be anything from a file to a folder) 470 @param compile_tracks: if true any tracks in the playlists 471 that are not found as tracks are added to the list of tracks 472 @param existing_tracks: a list of tracks that have already 473 been loaded from files (used to skip loading the dragged 474 tracks from the filesystem) 475 476 @returns: a 2 tuple in which the first part is a list of tracks 477 and the second is a list of playlist (note: any files that are 478 in a playlist are not added to the list of tracks, but a track could 479 be both in as a found track and part of a playlist) 480 """ 481 # TODO handle if they pass in existing tracks 482 trs = [] 483 playlists = [] 484 for loc in locs: 485 (found_tracks, found_playlist) = self._handle_unknown_drag_data(loc) 486 trs.extend(found_tracks) 487 playlists.extend(found_playlist) 488 489 if compile_tracks: 490 # Add any tracks in the playlist to the master list of tracks 491 for playlist in playlists: 492 for track in playlist.get_tracks(): 493 if track not in trs: 494 trs.append(track) 495 496 return (trs, playlists) 497 498 def _handle_unknown_drag_data(self, loc): 499 """ 500 Handles unknown drag data that has been recieved by 501 drag_data_received. Unknown drag data is classified as 502 any loc (location) that is not in the collection of tracks 503 (i.e. a new song, or a new playlist) 504 505 @param loc: 506 the location of the unknown drag data 507 508 @returns: a 2 tuple in which the first part is a list of tracks 509 and the second is a list of playlist 510 """ 511 filetype = None 512 info = urlparse(loc) 513 514 # don't use gio to test the filetype if it's a non-local file 515 # (otherwise gio will try to connect to every remote url passed in and 516 # cause the gui to hang) 517 if info.scheme in ('file', ''): 518 try: 519 filetype = ( 520 Gio.File.new_for_uri(loc) 521 .query_info('standard::type', Gio.FileQueryInfoFlags.NONE, None) 522 .get_file_type() 523 ) 524 except GLib.Error: 525 filetype = None 526 527 if trax.is_valid_track(loc) or info.scheme not in ('file', ''): 528 new_track = trax.Track(loc) 529 return ([new_track], []) 530 elif xl_playlist.is_valid_playlist(loc): 531 # User is dragging a playlist into the playlist list 532 # so we add all of the songs in the playlist 533 # to the list 534 new_playlist = xl_playlist.import_playlist(loc) 535 return ([], [new_playlist]) 536 elif filetype == Gio.FileType.DIRECTORY: 537 return (trax.get_tracks_from_uri(loc), []) 538 else: # We don't know what they dropped 539 return ([], []) 540 541 542class ClickableCellRendererPixbuf(Gtk.CellRendererPixbuf): 543 """ 544 Custom :class:`Gtk.CellRendererPixbuf` emitting 545 an *clicked* signal upon activation of the pixbuf 546 """ 547 548 __gsignals__ = { 549 'clicked': ( 550 GObject.SignalFlags.RUN_LAST, 551 GObject.TYPE_BOOLEAN, 552 (GObject.TYPE_PYOBJECT,), 553 GObject.signal_accumulator_true_handled, 554 ) 555 } 556 557 def __init__(self): 558 Gtk.CellRendererPixbuf.__init__(self) 559 self.props.mode = Gtk.CellRendererMode.ACTIVATABLE 560 561 def do_activate(self, event, widget, path, background_area, cell_area, flags): 562 """ 563 Emits the *clicked* signal 564 """ 565 self.emit('clicked', path) 566 return 567