1# Copyright 2005 Joe Wreschnig, Michael Urman
2#           2012 Christoph Reiter
3#          2016-17 Nick Boultbee
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2 of the License, or
8# (at your option) any later version.
9
10import os
11import signal
12import socket
13from urllib.parse import urlparse
14
15import gi
16gi.require_version("Gtk", "3.0")
17
18from gi.repository import Gtk
19from gi.repository import Gdk
20from gi.repository import GLib, GObject, PangoCairo
21from senf import fsn2bytes, bytes2fsn, uri2fsn
22
23from quodlibet.util import print_d, print_w, is_windows, is_osx
24
25
26def show_uri(label, uri):
27    """Shows a uri. The uri can be anything handled by GIO or a quodlibet
28    specific one.
29
30    Currently handled quodlibet uris:
31        - quodlibet:///prefs/plugins/<plugin id>
32
33    Args:
34        label (str)
35        uri (str) the uri to show
36    Returns:
37        True on success, False on error
38    """
39
40    parsed = urlparse(uri)
41    if parsed.scheme == "quodlibet":
42        if parsed.netloc != "":
43            print_w("Unknown QuodLibet URL format (%s)" % uri)
44            return False
45        else:
46            return __show_quodlibet_uri(parsed)
47    elif parsed.scheme == "file" and (is_windows() or is_osx()):
48        # Gio on non-Linux can't handle file URIs for some reason,
49        # fall back to our own implementation for now
50        from quodlibet.qltk.showfiles import show_files
51
52        try:
53            filepath = uri2fsn(uri)
54        except ValueError:
55            return False
56        else:
57            return show_files(filepath, [])
58    else:
59        # Gtk.show_uri_on_window exists since 3.22
60        try:
61            if hasattr(Gtk, "show_uri_on_window"):
62                from quodlibet.qltk import get_top_parent
63                return Gtk.show_uri_on_window(get_top_parent(label), uri, 0)
64            else:
65                return Gtk.show_uri(None, uri, 0)
66        except GLib.Error:
67            return False
68
69
70def __show_quodlibet_uri(uri):
71    if uri.path.startswith("/prefs/plugins/"):
72        from .pluginwin import PluginWindow
73        print_d("Showing plugin prefs resulting from URI (%s)" % (uri, ))
74        return PluginWindow().move_to(uri.path[len("/prefs/plugins/"):])
75    else:
76        return False
77
78
79def get_fg_highlight_color(widget):
80    """Returns a color useable for highlighting things on top of the standard
81    background color.
82
83    Args:
84        widget (Gtk.Widget)
85    Returns:
86        Gdk.RGBA
87    """
88
89    context = widget.get_style_context()
90    if hasattr(Gtk.StateFlags, "LINK"):
91        # gtk+ >=3.12
92        context.save()
93        context.set_state(Gtk.StateFlags.LINK)
94        color = context.get_color(context.get_state())
95        context.restore()
96    else:
97        value = GObject.Value()
98        value.init(Gdk.Color)
99        value.set_boxed(None)
100        context.get_style_property("link-color", value)
101        color = Gdk.RGBA()
102        old_color = value.get_boxed()
103        if old_color is not None:
104            color.parse(old_color.to_string())
105    return color
106
107
108def get_primary_accel_mod():
109    """Returns the primary Gdk.ModifierType modifier.
110
111    cmd on osx, ctrl everywhere else.
112    """
113
114    return Gtk.accelerator_parse("<Primary>")[1]
115
116
117def redraw_all_toplevels():
118    """A hack to trigger redraws for all windows and widgets."""
119
120    for widget in Gtk.Window.list_toplevels():
121        if not widget.get_realized():
122            continue
123        if widget.is_active():
124            widget.queue_draw()
125            continue
126        sensitive = widget.get_sensitive()
127        widget.set_sensitive(not sensitive)
128        widget.set_sensitive(sensitive)
129
130
131def selection_set_songs(selection_data, songs):
132    """Stores filenames of the passed songs in a Gtk.SelectionData"""
133
134    filenames = []
135    for filename in (song["~filename"] for song in songs):
136        filenames.append(fsn2bytes(filename, "utf-8"))
137    type_ = Gdk.atom_intern("text/x-quodlibet-songs", True)
138    selection_data.set(type_, 8, b"\x00".join(filenames))
139
140
141def selection_get_filenames(selection_data):
142    """Extracts the filenames of songs set with selection_set_songs()
143    from a Gtk.SelectionData.
144    """
145
146    data_type = selection_data.get_data_type()
147    assert data_type.name() == "text/x-quodlibet-songs"
148
149    items = selection_data.get_data().split(b"\x00")
150    return [bytes2fsn(i, "utf-8") for i in items]
151
152
153def get_top_parent(widget):
154    """Return the ultimate parent of a widget; the assumption that code
155    using this makes is that it will be a Gtk.Window, i.e. the widget
156    is fully packed when this is called."""
157
158    parent = widget and widget.get_toplevel()
159    if parent and parent.is_toplevel():
160        return parent
161    else:
162        return None
163
164
165def get_menu_item_top_parent(widget):
166    """Returns the toplevel for a menu item or None if the menu
167    and none of its parents isn't attached to a widget
168    """
169
170    while isinstance(widget, Gtk.MenuItem):
171        menu = widget.get_parent()
172        if not menu:
173            return
174        widget = menu.get_attach_widget()
175    return get_top_parent(widget)
176
177
178def find_widgets(widget, type_):
179    """Given a widget, find all children that are a subclass of type_
180    (including itself)
181
182    Args:
183        widget (Gtk.Widget)
184        type_ (type)
185    Returns:
186        List[Gtk.Widget]
187    """
188
189    found = []
190
191    if isinstance(widget, type_):
192        found.append(widget)
193
194    if isinstance(widget, Gtk.Container):
195        for child in widget.get_children():
196            found.extend(find_widgets(child, type_))
197
198    return found
199
200
201def menu_popup(menu, shell, item, func, *args):
202    """Wrapper to fix API break:
203    https://git.gnome.org/browse/gtk+/commit/?id=8463d0ee62b4b22fa
204    """
205
206    if func is not None:
207        def wrap_pos_func(menu, *args):
208            return func(menu, args[-1])
209    else:
210        wrap_pos_func = None
211
212    return menu.popup(shell, item, wrap_pos_func, *args)
213
214
215def _popup_menu_at_widget(menu, widget, button, time, under):
216
217    def pos_func(menu, data, widget=widget):
218        screen = widget.get_screen()
219        ref = get_top_parent(widget)
220        menu.set_screen(screen)
221        x, y = widget.translate_coordinates(ref, 0, 0)
222        dx, dy = ref.get_window().get_origin()[1:]
223        wa = widget.get_allocation()
224
225        # fit menu to screen, aligned per text direction
226        screen_width = screen.get_width()
227        screen_height = screen.get_height()
228        menu.realize()
229        ma = menu.get_allocation()
230
231        menu_y_under = y + dy + wa.height
232        menu_y_above = y + dy - ma.height
233        if under:
234            menu_y = menu_y_under
235            if menu_y + ma.height > screen_height and menu_y_above > 0:
236                menu_y = menu_y_above
237        else:
238            menu_y = menu_y_above
239            if menu_y < 0 and menu_y_under + ma.height < screen_height:
240                menu_y = menu_y_under
241
242        if Gtk.Widget.get_default_direction() == Gtk.TextDirection.LTR:
243            menu_x = min(x + dx, screen_width - ma.width)
244        else:
245            menu_x = max(0, x + dx - ma.width + wa.width)
246
247        return (menu_x, menu_y, True) # x, y, move_within_screen
248    menu_popup(menu, None, None, pos_func, None, button, time)
249
250
251def _ensure_menu_attached(menu, widget):
252    assert widget is not None
253
254    # Workaround the menu inheriting the wrong colors with the Ubuntu 12.04
255    # default themes. Attaching to the parent kinda works... submenus still
256    # have the wrong color.
257    if isinstance(widget, Gtk.Button):
258        widget = widget.get_parent() or widget
259
260    attached_widget = menu.get_attach_widget()
261    if attached_widget is widget:
262        return
263    if attached_widget is not None:
264        menu.detach()
265    menu.attach_to_widget(widget, None)
266
267
268def popup_menu_under_widget(menu, widget, button, time):
269    _ensure_menu_attached(menu, widget)
270    _popup_menu_at_widget(menu, widget, button, time, True)
271
272
273def popup_menu_above_widget(menu, widget, button, time):
274    _ensure_menu_attached(menu, widget)
275    _popup_menu_at_widget(menu, widget, button, time, False)
276
277
278def popup_menu_at_widget(menu, widget, button, time):
279    _ensure_menu_attached(menu, widget)
280    menu_popup(menu, None, None, None, None, button, time)
281
282
283def add_fake_accel(widget, accel):
284    """Accelerators are only for window menus and global keyboard shortcuts.
285
286    Since we want to use them in context menus as well, to indicate which
287    key events the parent widget knows about, we use a global fake
288    accelgroup without any actions..
289    """
290
291    if not hasattr(add_fake_accel, "_group"):
292        add_fake_accel._group = Gtk.AccelGroup()
293    group = add_fake_accel._group
294
295    key, val = Gtk.accelerator_parse(accel)
296    assert key is not None
297    assert val is not None
298    widget.add_accelerator(
299        'activate', group, key, val, Gtk.AccelFlags.VISIBLE)
300
301
302def is_accel(event, *accels):
303    """Checks if the given keypress Gdk.Event matches
304    any of accelerator strings.
305
306    example: is_accel(event, "<shift><ctrl>z")
307
308    Args:
309        *accels: one ore more `str`
310    Returns:
311        bool
312    Raises:
313        ValueError: in case any of the accels could not be parsed
314    """
315
316    assert accels
317
318    if event.type != Gdk.EventType.KEY_PRESS:
319        return False
320
321    # ctrl+shift+x gives us ctrl+shift+X and accelerator_parse returns
322    # lowercase values for matching, so lowercase it if possible
323    keyval = event.keyval
324    if not keyval & ~0xFF:
325        keyval = ord(chr(keyval).lower())
326
327    default_mod = Gtk.accelerator_get_default_mod_mask()
328    keymap = Gdk.Keymap.get_default()
329
330    for accel in accels:
331        accel_keyval, accel_mod = Gtk.accelerator_parse(accel)
332        if accel_keyval == 0 and accel_mod == 0:
333            raise ValueError("Invalid accel: %s" % accel)
334
335        # If the accel contains non default modifiers matching will
336        # never work and since no one should use them, complain
337        non_default = accel_mod & ~default_mod
338        if non_default:
339            print_w("Accelerator '%s' contains a non default modifier '%s'." %
340                (accel, Gtk.accelerator_name(0, non_default) or ""))
341
342        # event.state contains the real mod mask + the virtual one, while
343        # we usually pass only virtual one as text. This adds the real one
344        # so they match in the end.
345        accel_mod = keymap.map_virtual_modifiers(accel_mod)[1]
346
347        # Remove everything except default modifiers and compare
348        if (accel_keyval, accel_mod) == (keyval, event.state & default_mod):
349            return True
350
351    return False
352
353
354def add_css(widget, css):
355    """Add css for the widget, overriding the theme.
356
357    Can raise GLib.GError in case the css is invalid
358    """
359
360    if not isinstance(css, bytes):
361        css = css.encode("utf-8")
362
363    provider = Gtk.CssProvider()
364    provider.load_from_data(css)
365    context = widget.get_style_context()
366    context.add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
367
368
369def remove_padding(widget):
370    """Removes padding on supplied widget"""
371    return add_css(widget, " * { padding: 0px; } ")
372
373
374def is_instance_of_gtype_name(instance, name):
375    """Returns False if the gtype can't be found"""
376
377    try:
378        gtype = GObject.type_from_name(name)
379    except Exception:
380        return False
381    else:
382        pytype = gtype.pytype
383        if pytype is None:
384            return False
385        return isinstance(instance, pytype)
386
387
388def is_wayland():
389    display = Gdk.Display.get_default()
390    if display is None:
391        return False
392    return is_instance_of_gtype_name(display, "GdkWaylandDisplay")
393
394
395def get_backend_name():
396    """The GDK backend name"""
397
398    display = Gdk.Display.get_default()
399    if display is not None:
400        name = display.__gtype__.name
401        if name.startswith("Gdk"):
402            name = name[3:]
403        if name.endswith("Display"):
404            name = name[:-7]
405        return name
406    return u"Unknown"
407
408
409def get_font_backend_name() -> str:
410    """The PangoCairo font backend name"""
411
412    font_map = PangoCairo.FontMap.get_default()
413    name = font_map.__gtype__.name.lower()
414    name = name.split("pangocairo")[-1].split("fontmap")[0]
415    if name == "fc":
416        name = "fontconfig"
417    return name
418
419
420gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version(),
421               Gtk.get_micro_version())
422
423pygobject_version = gi.version_info
424
425
426def io_add_watch(fd, prio, condition, func, *args, **kwargs):
427    try:
428        # The new gir bindings don't fail with an invalid fd,
429        # and we can't do the same with the static ones (return a valid
430        # source ID..) so fail with newer pygobject as well.
431        if isinstance(fd, int) and fd < 0:
432            raise ValueError("invalid fd")
433        elif hasattr(fd, "fileno") and fd.fileno() < 0:
434            raise ValueError("invalid fd")
435        return GLib.io_add_watch(fd, prio, condition, func, *args, **kwargs)
436    except TypeError:
437        # older pygi
438        kwargs["priority"] = prio
439        return GLib.io_add_watch(fd, condition, func, *args, **kwargs)
440
441
442def add_signal_watch(signal_action, _sockets=[]):
443    """Catches signals which should exit the program and calls `signal_action`
444    after the main loop has started, even if the signal occurred before the
445    main loop has started.
446    """
447
448    # See https://bugzilla.gnome.org/show_bug.cgi?id=622084 for details
449
450    sig_names = ["SIGINT", "SIGTERM", "SIGHUP"]
451    if os.name == "nt":
452        sig_names = ["SIGINT", "SIGTERM"]
453
454    signals = {}
455    for name in sig_names:
456        id_ = getattr(signal, name, None)
457        if id_ is None:
458            continue
459        signals[id_] = name
460
461    for signum, name in signals.items():
462        # Before the mainloop starts we catch signals in python
463        # directly and idle_add the app.quit
464        def idle_handler(signum, frame):
465            print_d("Python signal handler activated: %s" % signals[signum])
466            GLib.idle_add(signal_action, priority=GLib.PRIORITY_HIGH)
467
468        print_d("Register Python signal handler: %r" % name)
469        signal.signal(signum, idle_handler)
470
471    read_socket, write_socket = socket.socketpair()
472    for sock in [read_socket, write_socket]:
473        sock.setblocking(False)
474        # prevent it from being GCed and leak it
475        _sockets.append(sock)
476
477    def signal_notify(source, condition):
478        if condition & GLib.IOCondition.IN:
479            try:
480                return bool(read_socket.recv(1))
481            except EnvironmentError:
482                return False
483        else:
484            return False
485
486    if os.name == "nt":
487        channel = GLib.IOChannel.win32_new_socket(read_socket.fileno())
488    else:
489        channel = GLib.IOChannel.unix_new(read_socket.fileno())
490    io_add_watch(channel, GLib.PRIORITY_HIGH,
491                 (GLib.IOCondition.IN | GLib.IOCondition.HUP |
492                  GLib.IOCondition.NVAL | GLib.IOCondition.ERR),
493                 signal_notify)
494
495    signal.set_wakeup_fd(write_socket.fileno())
496
497
498def enqueue(songs):
499    songs = [s for s in songs if s.can_add]
500    if songs:
501        from quodlibet import app
502        app.window.playlist.enqueue(songs)
503
504
505class ThemeOverrider(object):
506    """Allows registering global Gtk.StyleProviders for a specific theme.
507    They get activated when the theme gets active and removed when the theme
508    changes to something else.
509    """
510
511    def __init__(self):
512        self._providers = {}
513        self._active_providers = []
514        settings = Gtk.Settings.get_default()
515        settings.connect("notify::gtk-theme-name", self._on_theme_name_notify)
516        self._update_providers()
517
518    def register_provider(self, theme_name, provider):
519        """
520        Args:
521            theme_name (str): A gtk+ theme name e.g. "Adwaita" or empty to
522                apply to all themes
523            provider (Gtk.StyleProvider)
524        """
525
526        self._providers.setdefault(theme_name, []).append(provider)
527        self._update_providers()
528
529    def _update_providers(self):
530        settings = Gtk.Settings.get_default()
531
532        theme_name = settings.get_property("gtk-theme-name")
533        wanted_providers = \
534            self._providers.get(theme_name, []) + self._providers.get("", [])
535
536        for provider in list(self._active_providers):
537            if provider not in wanted_providers:
538                Gtk.StyleContext.remove_provider_for_screen(
539                    Gdk.Screen.get_default(), provider)
540            self._active_providers.remove(provider)
541
542        for provider in wanted_providers:
543            if provider not in self._active_providers:
544                Gtk.StyleContext.add_provider_for_screen(
545                    Gdk.Screen.get_default(),
546                    provider,
547                    Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
548                )
549                self._active_providers.append(provider)
550
551    def _on_theme_name_notify(self, settings, gparam):
552        self._update_providers()
553
554
555from .msg import Message, ErrorMessage, WarningMessage
556from .x import Align, Button, ToggleButton, Notebook, SeparatorMenuItem, \
557    WebImage, MenuItem, Frame, EntryCompletion
558from .icons import Icons
559from .window import Window, UniqueWindow, Dialog
560from .paned import ConfigRPaned, ConfigRHPaned
561
562Message, ErrorMessage, WarningMessage
563Align, Button, ToggleButton, Notebook, SeparatorMenuItem, \
564    WebImage, MenuItem, Frame, EntryCompletion
565Icons
566Window, UniqueWindow, Dialog
567ConfigRPaned, ConfigRHPaned
568