1# Copyright (C) 2012 Mathias Brodala
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
18from collections import namedtuple
19import logging
20import sys
21
22from gi.repository import Gdk
23from gi.repository import GLib
24from gi.repository import Gtk
25
26from xl import event, player, settings as xl_settings
27from xl.nls import gettext as _
28from xlgui.widgets import info
29from xlgui import guiutil
30
31from . import osd_preferences
32
33
34LOGGER = logging.getLogger(__name__)
35
36
37Point = namedtuple('Point', 'x y')
38
39
40def do_assert(is_bool):
41    """
42    Simulates the `assert` statement
43    """
44    if not is_bool:
45        raise AssertionError()
46
47
48def _sanitize_window_geometry(
49    window, current_allocation, padding, width_fill, height_fill
50):
51    """
52    Sanitizes (x-offset, y-offset, width, height) of the given window,
53    to make the window show on the screen.
54
55    :param width_fill, height_fill: specifies the maximum width or height
56        of a monitor to fill. 1.0 means "fill the whole monitor"
57    :param padding: specifies the padding (from workarea border) to leave empty
58    """
59    work_area = guiutil.get_workarea_dimensions(window)
60    cural = current_allocation
61    newal = Gdk.Rectangle()
62
63    newal.x = max(padding, cural.x)
64    newal.y = max(padding, cural.y)
65
66    newal.width = min(work_area.width // width_fill, cural.width)
67    newal.height = min(work_area.height // height_fill, cural.height)
68
69    newal.x = min(newal.x, work_area.x + work_area.width - newal.width - padding)
70    newal.y = min(newal.y, work_area.y + work_area.height - newal.height - padding)
71
72    if newal == cural:
73        return None
74
75    if cural.x != newal.x or cural.y != newal.y:
76        if cural.width != newal.width or cural.height != newal.height:
77            window.get_window().move_resize(newal.x, newal.y, newal.width, newal.height)
78        else:
79            window.move(newal.x, newal.y)
80    else:
81        if cural.width != newal.width or cural.height != newal.height:
82            window.resize(newal.width, newal.height)
83    return newal
84
85
86class OSDPlugin:
87    """
88    The plugin for showing an On-Screen Display.
89    This object holds all the stuff which may live longer than the window.
90    Please note that the window has to be destroyed during plugin runtime,
91    see the OSDWindow docstring below for details.
92    """
93
94    __window = None
95    __css_provider = None
96    __options = None
97
98    def enable(self, _exaile):
99        """
100        Enables the on screen display plugin
101        """
102        do_assert(self.__window is None)
103
104        # Note: Moving windows will not work on Wayland by design, because Wayland does not know
105        # absolute window positioning. Gtk.Window.move() does not work there.
106        # See https://blog.gtk.org/2016/07/15/future-of-relative-window-positioning/
107        # and https://lists.freedesktop.org/archives/wayland-devel/2015-September/024464.html
108        if guiutil.platform_is_wayland():
109            raise EnvironmentError("This plugin does not work on Wayland backend.")
110
111        # Cached option values
112        self.__options = {
113            'background': None,
114            'display_duration': None,
115            'border_radius': None,
116            'use_alpha': False,
117        }
118        self.__css_provider = Gtk.CssProvider()
119        if sys.platform.startswith("win32"):
120            # Setting opacity on Windows crashes with segfault,
121            # see https://bugzilla.gnome.org/show_bug.cgi?id=674449
122            self.__options['use_alpha'] = False
123            LOGGER.warning(
124                "OSD: Disabling alpha channel because it is not supported on Windows."
125            )
126        else:
127            self.__options['use_alpha'] = True
128
129    def teardown(self, _exaile):
130        """
131        Shuts down the on screen display plugin
132        """
133        do_assert(self.__window is not None)
134        self.__window.destroy_osd()
135        event.remove_callback(self.__on_option_set, 'plugin_osd_option_set')
136        event.remove_callback(self.__on_playback_track_start, 'playback_track_start')
137        event.remove_callback(self.__on_playback_toggle_pause, 'playback_toggle_pause')
138        event.remove_callback(self.__on_playback_player_end, 'playback_player_end')
139        event.remove_callback(self.__on_playback_error, 'playback_error')
140        self.__window = None
141
142    def disable(self, _exaile):
143        """
144        Disables the on screen display plugin
145        """
146        self.teardown(_exaile)
147
148    def on_gui_loaded(self):
149        """
150        Called when Exaile mostly finished loading
151        """
152        do_assert(self.__window is None)
153        event.add_callback(self.__on_option_set, 'plugin_osd_option_set')
154        self.__prepare_osd(False)
155        # TODO: OSD looks ugly with CSS not applied on first show. Why is that?
156
157        event.add_callback(self.__on_playback_track_start, 'playback_track_start')
158        event.add_callback(self.__on_playback_toggle_pause, 'playback_toggle_pause')
159        event.add_callback(self.__on_playback_player_end, 'playback_player_end')
160        event.add_callback(self.__on_playback_error, 'playback_error')
161
162    def get_preferences_pane(self):
163        """
164        Called when the user wants to see the preferences pane for this plugin
165        """
166        osd_preferences.OSDPLUGIN = self
167        return osd_preferences
168
169    def make_osd_editable(self, be_editable):
170        """
171        Rebuilds the OSD to make it movable and resizable
172        """
173        do_assert(self.__window is not None)
174        self.__window.destroy_osd()
175        self.__window = None
176        self.__prepare_osd(be_editable)
177        self.__window.show_for_a_while()
178
179    def __prepare_osd(self, be_editable):
180        do_assert(self.__window is None)
181        self.__window = OSDWindow(self.__css_provider, self.__options, be_editable)
182        # Trigger initial setup through options.
183        for option in (
184            'format',
185            'background',
186            'display_duration',
187            'show_progress',
188            'position',
189            'width',
190            'height',
191            'border_radius',
192        ):
193            self.__on_option_set(
194                'plugin_osd_option_set',
195                xl_settings,
196                'plugin/osd/{option}'.format(option=option),
197            )
198        self.__window.restore_geometry_and_show()
199
200    def __on_option_set(self, _event, settings, option):
201        """
202        Updates appearance on setting change
203        """
204        if option == 'plugin/osd/format':
205            self.__window.info_area.set_info_format(
206                settings.get_option(option, osd_preferences.FormatPreference.default)
207            )
208        elif option == 'plugin/osd/background':
209            if not self.__options['background']:
210                self.__options['background'] = Gdk.RGBA()
211            rgba = self.__options['background']
212            rgba.parse(
213                settings.get_option(
214                    option, osd_preferences.BackgroundPreference.default
215                )
216            )
217            if self.__options['use_alpha'] is True:
218                if rgba.alpha > 0.995:
219                    # Bug: We need to set opacity to some value < 1 here
220                    # otherwise both corners and fade out transition will look ugly
221                    rgba.alpha = 0.99
222                    settings.set_option(option, rgba.to_string())
223            else:
224                if rgba.alpha < 1:
225                    rgba.to_color()
226                    settings.set_option(option, rgba.to_string())
227            GLib.idle_add(self.__update_css_provider)
228        elif option == 'plugin/osd/border_radius':
229            value = settings.get_option(
230                option, osd_preferences.BorderRadiusPreference.default
231            )
232            self.__window.set_border_width(max(6, value // 2))
233            self.__options['border_radius'] = value
234            GLib.idle_add(self.__update_css_provider)
235            self.__window.emit('size-allocate', self.__window.get_allocation())
236        elif option == 'plugin/osd/display_duration':
237            self.__options['display_duration'] = int(
238                settings.get_option(
239                    option, osd_preferences.DisplayDurationPreference.default
240                )
241            )
242        elif option == 'plugin/osd/show_progress':
243            self.__window.info_area.set_display_progress(
244                settings.get_option(
245                    option, osd_preferences.ShowProgressPreference.default
246                )
247            )
248        elif option == 'plugin/osd/position':
249            position = Point._make(settings.get_option(option, [20, 20]))
250            self.__window.geometry['x'] = position.x
251            self.__window.geometry['y'] = position.y
252        elif option == 'plugin/osd/width':
253            width = settings.get_option(option, 300)
254            self.__window.geometry['width'] = width
255        elif option == 'plugin/osd/height':
256            height = settings.get_option(option, 120)
257            self.__window.geometry['height'] = height
258
259    def __update_css_provider(self):
260        bgcolor = self.__options['background']
261        radius = self.__options['border_radius']
262        if bgcolor is None or radius is None:
263            return  # seems like we are in early initialization state
264        if self.__options['use_alpha'] is True:
265            color_str = guiutil.css_from_rgba(bgcolor)
266        else:
267            color_str = guiutil.css_from_rgba_without_alpha(bgcolor)
268        data_str = "window { background-color: %s; border-radius: %spx; }" % (
269            color_str,
270            str(radius),
271        )
272        self.__css_provider.load_from_data(data_str.encode('utf-8'))
273        return False
274
275    def __on_playback_track_start(self, _event, _player, _track):
276        self.__window.show_for_a_while()
277
278    def __on_playback_toggle_pause(self, _event, _player, _track):
279        self.__window.show_for_a_while()
280
281    def __on_playback_player_end(self, _event, _player, track):
282        if track is None:
283            self.__window.hide_immediately()
284        else:
285            self.__window.show_for_a_while()
286
287    def __on_playback_error(self, _event, _player, _message):
288        # TODO: show error instead?
289        self.__window.hide_immediately()
290
291
292plugin_class = OSDPlugin
293
294
295class OSDWindow(Gtk.Window):
296    """
297    A popup window showing information of the currently playing track
298
299    Due to the way, the Gtk+ API and some of the many different window managers work,
300    the OSD cannot be resizable and movable and have no keyboard focus nor decorations
301    at the same time.
302    Additionally, in some cases the Gtk+ API specifies that functions may not
303    be called after the window has been realized (). In some other cases, Gtk+ API does
304    not guarantee that a function works after Gtk.Window.show() is called
305    (e.g. Gtk.Window.set_decorated(), set_deletable(), set_titlebar()).
306    For these reasons, we need to destroy and rebuild the OSD when we want it to be
307    resizable and movable by simple drag operations.
308
309    Another related bug report:
310    https://bugzilla.gnome.org/show_bug.cgi?id=782117:
311         If a window was initially shown undecorated and set_decorated(True) is called,
312         titlebar is drawn inside the window
313    """
314
315    __hide_id = None
316    __fadeout_id = None
317    __autohide = True
318    __options = None
319    geometry = dict(x=20, y=20, width=300, height=120)  # the default
320
321    def __init__(self, css_provider, options, allow_resize_move):
322        """
323        Initializes the OSD Window.
324        Important: Do not call this constructor before Exaile finished loading,
325            otherwise the internal TrackInfoPane will re-render label and icon on each
326            `track_tags_changed` event, which causes unnecessary CPU load and delays startup.
327
328        Apply the options after this object was initialized.
329        """
330        Gtk.Window.__init__(self, type=Gtk.WindowType.TOPLEVEL)
331        self.__options = options
332
333        self.set_title('Exaile OSD')
334        self.set_keep_above(True)
335        self.stick()
336
337        # the next two options don't work on GNOME/Wayland due to a bug
338        # between Gtk+ and gnome-shell:
339        # https://bugzilla.gnome.org/show_bug.cgi?id=771329
340        # there is no API guaranty that they work on other platforms.
341        self.set_skip_pager_hint(True)
342        self.set_skip_taskbar_hint(True)
343        # There is no API guaranty that set_deletable() will work
344        self.set_deletable(False)
345        self.connect('delete-event', lambda _widget, _event: self.hide_immediately)
346
347        self.connect('screen-changed', self.__on_screen_changed)
348
349        style_context = self.get_style_context()
350        style_context.add_provider(
351            css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
352        )
353
354        # Init child widgets
355        self.info_area = info.TrackInfoPane(player.PLAYER)
356        self.info_area.set_default_text(_('No track played yet'))
357        # enable updating OSD contents
358        # this is very expensive if done during Exaile startup!
359        self.info_area.set_auto_update(True)
360        self.info_area.cover.set_property('visible', True)
361        # If we don't do this, the label text will be selected if the user
362        # pressed the mouse button while the OSD is shown for the first time.
363        self.info_area.info_label.set_selectable(False)
364        self.info_area.show_all()
365        self.add(self.info_area)
366
367        self.__setup_resize_move_related_stuff(allow_resize_move)
368
369        # callbacks needed to show the OSD long enough:
370        self.add_events(Gdk.EventMask.LEAVE_NOTIFY_MASK)
371        self.connect('leave-notify-event', self.__on_leave_notify_event)
372
373        # Also, maximize, minimize, etc. might happen and we want to undo that
374        self.add_events(Gdk.EventMask.STRUCTURE_MASK)
375        self.connect('window-state-event', self.__on_window_state_event)
376
377        # Needed to acquire size
378        self.info_area.set_display_progress(True)
379
380        # set up the window visual
381        self.__on_screen_changed(self, None)
382
383    def __setup_resize_move_related_stuff(self, allow_resize_move):
384        # Without decorations, the window cannot be resized on some desktops
385        # this especially effects GNOME/Wayland and is probably caused by
386        # missing client-side decorations (CSD). This code might break when
387        # using client side decorations. In this case, we probably shoud hide
388        # the titlebar instead of removing the decorations.
389        # Removing decorations is ignored on some platforms to enable
390        # the resize grid.
391        self.set_decorated(False)
392        self.set_resizable(allow_resize_move)
393
394        if allow_resize_move:
395            self.set_type_hint(Gdk.WindowTypeHint.NORMAL)
396            self.set_title(_("Move or resize OSD"))
397            # This is often ignored, but we could try:
398            self.connect(
399                'realize',
400                lambda _widget: self.get_window().set_decorations(
401                    Gdk.WMDecoration.RESIZEH | Gdk.WMDecoration.TITLE
402                ),
403            )
404        else:
405            # On X11 (at least XWayland), this will make the window be not movable,
406            # but makes sure the user can still type.
407            self.set_type_hint(Gdk.WindowTypeHint.NOTIFICATION)
408
409        # We do not want to disturb user keyboard input
410        self.set_accept_focus(allow_resize_move)
411
412        self.__autohide = not allow_resize_move
413
414    def __on_window_state_event(self, _widget, win_state):
415        illegal_states = (
416            Gdk.WindowState.FULLSCREEN
417            | Gdk.WindowState.ICONIFIED
418            | Gdk.WindowState.TILED
419            | Gdk.WindowState.MAXIMIZED
420            | Gdk.WindowState.BELOW
421        )
422        if (
423            win_state.changed_mask & illegal_states
424            and win_state.new_window_state & illegal_states
425        ):
426            # Just returning Gdk.EVENT_STOP doesn't stop the window manager
427            # from changing window state.
428            # TODO: This often does not work at all.
429            GLib.idle_add(self.restore_geometry_and_show)
430            return Gdk.EVENT_STOP
431        else:
432            return Gdk.EVENT_PROPAGATE
433
434    def destroy_osd(self):
435        """
436        Cleanups
437        """
438        # Getting the position can only work on a window being shown on screen
439        # This is no problem since the window will be shown permanently during configuration.
440        if self.is_visible():
441            # X11: Position is off by OSD, because it is fetched with OSD but set without OSD
442            #    There is no simple way to fix this because we don't know the OSD geometry.
443            # Wayland: Position does not work at all.
444            xl_settings.set_option('plugin/osd/position', list(self.get_position()))
445
446            # Don't use Gdk.Window.get_width() here, it may include client-side window decorations!
447            # This is not guaranteed to work according to Gtk.Window docs, but it works for me.
448            width, height = self.get_size()
449            xl_settings.set_option('plugin/osd/width', width)
450            xl_settings.set_option('plugin/osd/height', height)
451        self.hide_immediately()
452        if self.__fadeout_id:
453            GLib.source_remove(self.__fadeout_id)
454            self.__fadeout_id = None
455        if self.__hide_id:
456            GLib.source_remove(self.__hide_id)
457            self.__hide_id = None
458        Gtk.Window.destroy(self)
459
460    def __start_fadeout(self):
461        """
462        Starts fadeout of the window.
463        Hides the window it immediately if fadeout is disabled
464        """
465        self.__hide_id = None
466
467        gdk_display = self.get_window().get_display()
468        # Keep showing the OSD in case the pointer is still over the OSD
469        if (
470            Gtk.get_major_version() > 3
471            or Gtk.get_major_version() == 3
472            and Gtk.get_minor_version() >= 20
473        ):
474            gdk_seat = gdk_display.get_default_seat()
475            gdk_device = gdk_seat.get_pointer()
476        else:
477            gdk_device_manager = gdk_display.get_device_manager()
478            gdk_device = gdk_device_manager.get_client_pointer()
479        window, _posx, _posy = gdk_device.get_window_at_position()
480        if window and window is self.get_window():
481            self.show_for_a_while()
482            return
483
484        if self.__options['use_alpha'] is True:
485            if self.__fadeout_id is None:
486                self.__fadeout_id = GLib.timeout_add(30, self.__do_fadeout_step)
487        else:
488            Gtk.Window.hide(self)
489        return False
490
491    def show_for_a_while(self):
492        """
493        This method makes sure that the OSD is shown. Any previous hiding
494        timers or fading transitions will be stopped.
495        If hiding is allowed through self.__autohide, a new hiding timer
496        will be started.
497        """
498        # unset potential fadeout process
499        if self.__fadeout_id:
500            GLib.source_remove(self.__fadeout_id)
501            self.__fadeout_id = None
502        if Gtk.Widget.get_opacity(self) < 1:
503            Gtk.Widget.set_opacity(self, 1)
504        # unset potential hide process
505        if self.__hide_id:
506            do_assert(self.__fadeout_id is None)
507            GLib.source_remove(self.__hide_id)
508            self.__hide_id = None
509        # (re)start hide process
510        if self.__autohide:
511            self.__hide_id = GLib.timeout_add_seconds(
512                self.__options['display_duration'], self.__start_fadeout
513            )
514        Gtk.Window.present(self)
515
516    def restore_geometry_and_show(self):
517        """
518        Restores window geometry from options and shows the window afterwards.
519        """
520        geo = self.geometry
521        # automatically resizes to minimum required size
522        self.set_default_size(geo['width'], geo['height'])
523
524        self.move(geo['x'], geo['y'])
525        self.show_for_a_while()
526        # screen size might have changed
527        allocation = Gdk.Rectangle()
528        allocation.x = geo['x']
529        allocation.y = geo['y']
530        allocation.width = geo['width']
531        allocation.height = geo['height']
532        _sanitize_window_geometry(super(Gtk.Window, self), allocation, 10, 0.2, 0.2)
533
534    def set_autohide(self, do_autohide):
535        """
536        Permanently shows the OSD during configuration.
537        This method should only be used from osd_preferences.
538        """
539        self.__autohide = do_autohide
540        if do_autohide:
541            do_assert(self.__hide_id is None)
542            do_assert(self.__fadeout_id is None)
543        GLib.idle_add(self.show_for_a_while)
544
545    def __do_fadeout_step(self):
546        """
547        Constantly decreases the opacity to fade out the window
548        """
549        do_assert(self.__hide_id is None)
550        if Gtk.Widget.get_opacity(self) > 0.001:
551            Gtk.Widget.set_opacity(self, Gtk.Widget.get_opacity(self) - 0.05)
552            return True
553        else:
554            self.__fadeout_id = None
555            Gtk.Window.hide(self)
556            return False
557
558    def __on_screen_changed(self, _widget, _oldscreen):
559        """
560        Updates the used colormap
561        """
562        screen = self.get_screen()
563        visual = screen.get_rgba_visual()
564        if visual is None:
565            # This might happen if there is no X compositor so the X Server
566            # does not support transparency
567            visual = screen.get_system_visual()
568            self.__options['use_alpha'] = False
569            LOGGER.warning(
570                "OSD: Disabling alpha channel because the Gtk+ "
571                "backend does not support it."
572            )
573        self.set_visual(visual)
574
575    '''
576    def __on_size_allocate(self, _widget, _allocation):
577        """
578            Applies the non-rectangular shape
579        """
580        # TODO: make this work again
581        # Bug in pycairo: cairo_region_* functions are not available before
582        # version 1.11.0, see https://bugs.freedesktop.org/show_bug.cgi?id=44336
583        # we might want to enable this code below once pycairo is distributed on
584        # most Linux distros.
585        # cairo_region = cairo.Region.create_rectangle(allocation)
586        # as a result, calling
587        # self.get_window().shape_combine_region(cairo_region, 0, 0)
588        # is impossible. Thus, it is impossible to shape the window.
589        # Instead, we have to work around this issue by leaving parts
590        # of the window undrawn.
591
592        # leave the old code here for reference:
593        width, height = allocation.width, allocation.height
594        mask = Gdk.Pixmap(None, width, height, 1)
595        context = mask.cairo_create()
596
597        context.set_source_rgb(0, 0, 0)
598        context.set_operator(cairo.OPERATOR_CLEAR)
599        context.paint()
600
601        radius = self.__options['border_radius']
602        inner = (radius, radius, width - radius, height - radius)
603
604        context.set_source_rgb(1, 1, 1)
605        context.set_operator(cairo.OPERATOR_SOURCE)
606        # Top left corner
607        context.arc(inner.x,     inner.y,      radius, 1.0 * pi, 1.5 * pi)
608        # Top right corner
609        context.arc(inner.width, inner.y,      radius, 1.5 * pi, 2.0 * pi)
610        # Bottom right corner
611        context.arc(inner.width, inner.height, radius, 0.0 * pi, 0.5 * pi)
612        # Bottom left corner
613        context.arc(inner.x,     inner.height, radius, 0.5 * pi, 1.0 * pi)
614        context.fill()
615
616        self.shape_combine_mask(mask, 0, 0)
617    '''
618
619    def __on_leave_notify_event(self, _widget, event_crossing):
620        # NotifyType.NONLINEAR means the pointer left the window, not just the widget.
621        if event_crossing.detail == Gdk.NotifyType.NONLINEAR:
622            self.show_for_a_while()
623        return Gdk.EVENT_PROPAGATE
624
625    def hide_immediately(self):
626        """
627        Immediately hides the OSD and removes all remaining timers or transitions
628        """
629        if self.__fadeout_id:
630            GLib.source_remove(self.__fadeout_id)
631            self.__fadeout_id = None
632        if self.__hide_id:
633            GLib.source_remove(self.__hide_id)
634            self.__hide_id = None
635        Gtk.Window.hide(self)
636