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