File: statusicon.py

package info (click to toggle)
syncthing-gtk 0.9.4.4%2Bds%2Bgit20221205%2B12a9702d29ab-2
  • links: PTS, VCS
  • area: main
  • in suites: bookworm
  • size: 2,888 kB
  • sloc: python: 8,077; sh: 259; xml: 134; makefile: 6
file content (556 lines) | stat: -rw-r--r-- 19,969 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Syncthing-GTK - StatusIcon

"""


import locale
import logging
import os

from gi.repository import GLib, GObject, Gtk

from syncthing_gtk.tools import _  # gettext function
from syncthing_gtk.tools import IS_CINNAMON, IS_KDE, IS_LXQT, IS_UNITY


log = logging.getLogger("StatusIcon")


#                | KDE5            | MATE      | Unity      | Cinnamon   | Cairo-Dock (classic) | Cairo-Dock (modern) | KDE4      |
# ----------------+-----------------+-----------+------------+------------+----------------------+---------------------+-----------+
# StatusIconQt5  | very good (KF5) | -         | -          | -          | -                    | -                   | -         |
# StatusIconAppI | good²           | none      | excellent  | none       | none                 | excellent           | good²     |
# StatusIconGTK3 | good            | excellent | none       | very good¹ | very good¹           | none                | good⁴     |
#
# Notes:
#  - StatusIconQt5:
#     - It's pretty unstable and leads to crashes
#     - Only tested on Qt 5.4 which only supports Qt5 through a KDE frameworks plugin
#  - StatusIconAppIndicator does not implement any fallback (but the original libappindicator did)
#  - Markers:
#     ¹ Icon cropped
#     ² Does not support left-click
#     ³ It works, but looks ugly and does not support left-click
#     ⁴ Does not support icon states
#     ⁵ For some menu items the standard GTK icons are used instead of the monotone ones


class StatusIcon(GObject.GObject):
    """
    Base class for all status icon backends
    """
    TRAY_TITLE = _("Syncthing")

    __gsignals__ = {
        "clicked": (GObject.SIGNAL_RUN_FIRST, None, ()),
    }

    __gproperties__ = {
        "active": (
            GObject.TYPE_BOOLEAN,
            "is the icon user-visible?",
            "does the icon back-end think that anything is might be shown to the user?",
            True,
            GObject.PARAM_READWRITE
        )
    }

    def __init__(self, icon_path, popupmenu, force=False):
        GObject.GObject.__init__(self)
        self.__icon_path = os.path.normpath(os.path.abspath(icon_path))
        self.__popupmenu = popupmenu
        self.__active = True
        self.__visible = False
        self.__hidden = False
        self.__icon = "si-syncthing-unknown"
        self.__text = ""
        self.__force = force

    def get_active(self):
        """
        Return whether there is at least a chance that the icon might be shown to the user

        If this returns `False` then the icon will definetely not be shown, but if it returns `True` it doesn't have to
        be visible...

        <em>Note:</em> This value is not directly influenced by calling `hide()` and `show()`.

        @return {bool}
        """
        return self.get_property("active")

    def set(self, icon=None, text=None):
        """
        Set the status icon image and descriptive text

        If either of these are `None` their previous value will be used.

        @param {String} icon
               The name of the icon to show (i.e. `si-syncthing-idle`)
        @param {String} text
               Some text that indicates what the application is currently doing (generally this be used for the tooltip)
        """
        if IS_KDE and isinstance(self, StatusIconDBus) and not icon.startswith("si-syncthing"):
            # KDE seems to be the only platform that has proper support for icon states
            # (all other implementations just hide the icon completely when its passive)
            self.__visible = False
        elif not icon.endswith("-0"):  # si-syncthing-0
            # Ignore first syncing icon state to prevent the icon from flickering
            # into the main notification bar during initialization
            self.__visible = True

        if self.__hidden:
            self._set_visible(False)
        else:
            self._set_visible(self.__visible)

    def hide(self):
        """
        Hide the icon

        This method tries its best to ensure the icon is hidden, but there are no guarantees as to how use well its
        going to work.
        """
        self.__hidden = True
        self._set_visible(False)

    def show(self):
        """
        Show a previously hidden icon

        This method tries its best to ensure the icon is hidden, but there are no guarantees as to how use well its
        going to work.
        """
        self.__hidden = False
        self._set_visible(self.__visible)

    def _is_forced(self):
        return self.__force

    def _on_click(self, *a):
        self.emit("clicked")

    def _get_icon(self, icon=None):
        """
        @internal

        Use `set()` instead.
        """
        if icon:
            self.__icon = icon
        return self.__icon

    def _get_text(self, text=None):
        """
        @internal

        Use `set()` instead.
        """
        if text:
            self.__text = text
        return self.__text

    def _get_popupmenu(self):
        """
        @internal
        """
        return self.__popupmenu

    def _set_visible(self, visible):
        """
        @internal
        """

    def do_get_property(self, property):
        if property.name == "active":
            return self.__active
        else:
            raise AttributeError("Unknown property %s" % property.name)

    def do_set_property(self, property, value):
        if property.name == "active":
            self.__active = value
        else:
            raise AttributeError("unknown property %s" % property.name)


class StatusIconDummy(StatusIcon):
    """
    Dummy status icon implementation that does nothing
    """

    def __init__(self, *args, **kwargs):
        StatusIcon.__init__(self, *args, **kwargs)

        # Pretty unlikely that this will be visible...
        self.set_property("active", False)
        if IS_UNITY or IS_KDE:
            log.warning("Failed to load modules required for status icon. "
                        "Please, make sure libappindicator package and python "
                        "bindings are installed.")
        else:
            log.warning("Failed to load modules required for status icon")

    def set(self, icon=None, text=None):
        StatusIcon.set(self, icon, text)

        self._get_icon(icon)
        self._get_text(text)


class StatusIconGTK3(StatusIcon):
    """
    Gtk.StatusIcon based status icon backend
    """

    def __init__(self, *args, **kwargs):
        StatusIcon.__init__(self, *args, **kwargs)

        if not self._is_forced():
            if IS_UNITY:
                # Unity fakes SysTray support but actually hides all icons...
                raise NotImplementedError

        self._tray = Gtk.StatusIcon()

        self._tray.connect("activate", self._on_click)
        self._tray.connect("popup-menu", self._on_rclick)
        self._tray.connect("notify::embedded", self._on_embedded_change)

        self._tray.set_visible(True)
        self._tray.set_name("syncthing-gtk")
        self._tray.set_title(self.TRAY_TITLE)

        # self._tray.is_embedded() must be called asynchronously
        # See: http://stackoverflow.com/a/6365904/277882
        GLib.idle_add(self._on_embedded_change)

    def set(self, icon=None, text=None):
        StatusIcon.set(self, icon, text)

        self._tray.set_from_icon_name(self._get_icon(icon))
        self._tray.set_tooltip_text(self._get_text(text))

    def _on_embedded_change(self, *args):
        # Without an icon update at this point GTK might consider the icon embedded and visible even through
        # it can't actually be seen...
        self._tray.set_from_icon_name(self._get_icon())

        # An invisible tray icon will never be embedded but it also should not be replaced
        # by a fallback icon
        is_embedded = self._tray.is_embedded() or not self._tray.get_visible()
        # On some desktops, above check fails but tray is always visible
        is_embedded = is_embedded or IS_KDE or IS_LXQT or IS_CINNAMON
        if is_embedded != self.get_property("active"):
            self.set_property("active", is_embedded)

    def _on_rclick(self, si, button, time):
        self._get_popupmenu().popup(None, None, None, None, button, time)

    def _set_visible(self, active):
        StatusIcon._set_visible(self, active)

        self._tray.set_visible(active)


class StatusIconDBus(StatusIcon):
    pass


class StatusIconQt(StatusIconDBus):
    """
    Base implementation for all Qt-based backends that provides GMenu to QMenu conversion services
    """

    def _make_qt_action(self, menu_child_gtk, menu_qt):
        # This is a separate function to make sure that the Qt callback function are executed
        # in the correct `locale()` context and do net trigger events on the wrong Gtk menu item

        # Create menu item
        action = self._qt_types["QAction"](menu_qt)

        # Convert item to separator if appropriate
        action.setSeparator(isinstance(menu_child_gtk, Gtk.SeparatorMenuItem))

        # Copy sensitivity
        def set_sensitive(*args):
            action.setEnabled(menu_child_gtk.is_sensitive())
        menu_child_gtk.connect("notify::sensitive", set_sensitive)
        set_sensitive()

        # Copy checkbox state
        if isinstance(menu_child_gtk, Gtk.CheckMenuItem):
            action.setCheckable(True)

            def _set_visible(*args):
                action.setChecked(menu_child_gtk.get_active())
            menu_child_gtk.connect("notify::active", _set_visible)
            _set_visible()

        # Copy icon
        if isinstance(menu_child_gtk, Gtk.ImageMenuItem):
            def set_image(*args):
                image = menu_child_gtk.get_image()
                if image and image.get_storage_type() == Gtk.ImageType.PIXBUF:
                    # Converting GdkPixbufs to QIcons might be a bit inefficient this way,
                    # but it requires only very little code and looks very stable
                    png_buffer = image.get_pixbuf().save_to_bufferv("png", [], [])[1]
                    image = self._qt_types["QImage"].fromData(png_buffer)
                    pixmap = self._qt_types["QPixmap"].fromImage(image)

                    action.setIcon(self._qt_types["QIcon"](pixmap))
                elif image:
                    icon_name = None
                    if image.get_storage_type() == Gtk.ImageType.ICON_NAME:
                        icon_name = image.get_icon_name()[0]
                    if image.get_storage_type() == Gtk.ImageType.STOCK:
                        icon_name = image.get_stock()[0]

                    action.setIcon(self._get_icon_by_name(icon_name))
                else:
                    action.setIcon(self._get_icon_by_name(None))
            menu_child_gtk.connect("notify::image", set_image)
            set_image()

        # Set label
        def set_label(*args):
            label = menu_child_gtk.get_label()
            if isinstance(menu_child_gtk, Gtk.ImageMenuItem) and menu_child_gtk.get_use_stock():
                label = Gtk.stock_lookup(label).label
            if isinstance(label, str):
                label = label.decode(locale.getpreferredencoding())
            if menu_child_gtk.get_use_underline():
                label = label.replace("_", "&")
            action.setText(label)
        menu_child_gtk.connect("notify::label", set_label)
        set_label()

        # Add submenus
        def set_popupmenu(*args):
            action.setMenu(self._get_popupmenu(menu_child_gtk.get_submenu()))
        menu_child_gtk.connect("notify::popupmenu", set_popupmenu)
        set_popupmenu()

        # Hook up Qt signals to their GTK counterparts
        action.triggered.connect(lambda *a: menu_child_gtk.emit("activate"))

        return action

    def _get_icon_by_name(self, icon_name):
        if icon_name:
            icon_file = self._gtk_icon_theme.lookup_icon(icon_name, 48, 0)
            if not icon_file:
                log.info("Skipping unknown icon file: %s" % (icon_name))
                return self._qt_types["QIcon"]()

            icon_path = icon_file.get_filename()
            if not icon_path:
                return self._qt_types["QIcon"]()

            icon_dir, icon_basename = os.path.split(
                os.path.realpath(icon_path))

            # If we don't resolve all icon names (i.e.: realpath) before passing them to Qt
            # SOME OF THEM will be dropped (especially if their name started with "gtk-" originally)
            icon_name = os.path.splitext(icon_basename)[0]

            # Make sure that Qt can find this icon by its name, by adding
            # the directory to the icon theme search path
            # This extra step is required because we have to set the application
            # style to "motif" during Qt initialization
            if icon_dir not in self._qt_types["QIcon"].themeSearchPaths():
                theme_search_paths = self._qt_types["QIcon"].themeSearchPaths()
                theme_search_paths.prepend(icon_dir)
                self._qt_types["QIcon"].setThemeSearchPaths(theme_search_paths)

            return self._qt_types["QIcon"].fromTheme(icon_name, self._qt_types["QIcon"](icon_path))

        return self._qt_types["QIcon"]()

    def _set_qt_types(self, **kwargs):
        self._gtk_icon_theme = Gtk.IconTheme.get_default()

        self._qt_types = kwargs

    def _get_popupmenu(self, menu_gtk=False):
        menu_gtk = menu_gtk if menu_gtk is not False else StatusIcon._get_popupmenu(
            self)
        if not menu_gtk:
            return None

        menu_qt = self._qt_types["QMenu"]()
        for menu_child_gtk in menu_gtk.get_children():
            menu_qt.addAction(self._make_qt_action(menu_child_gtk, menu_qt))

        return menu_qt


class StatusIconAppIndicator(StatusIconDBus):
    """
    Unity's AppIndicator3.Indicator based status icon backend
    """

    def __init__(self, *args, **kwargs):
        StatusIcon.__init__(self, *args, **kwargs)

        try:
            import gi
            gi.require_version('AyatanaAppIndicator3', '0.1')
            from gi.repository import AyatanaAppIndicator3 as appindicator
        except (ImportError, ValueError):
            try:
                import gi
                gi.require_version('AppIndicator3', '0.1')
                from gi.repository import AppIndicator3 as appindicator
            except (ImportError, ValueError):
                raise NotImplementedError

        self._status_active = appindicator.IndicatorStatus.ACTIVE
        self._status_passive = appindicator.IndicatorStatus.PASSIVE

        category = appindicator.IndicatorCategory.APPLICATION_STATUS
        # Whatever icon is set here will be used as a tooltip icon during the entire time to icon is shown
        self._tray = appindicator.Indicator.new(
            "syncthing-gtk", self._get_icon(), category)
        self._tray.set_menu(self._get_popupmenu())
        self._tray.set_title(self.TRAY_TITLE)

    def _set_visible(self, active):
        StatusIcon._set_visible(self, active)

        self._tray.set_status(
            self._status_active if active else self._status_passive)

    def set(self, icon=None, text=None):
        StatusIcon.set(self, icon, text)

        self._tray.set_icon_full(self._get_icon(icon), self._get_text(text))


class StatusIconProxy(StatusIcon):
    def __init__(self, *args, **kwargs):
        StatusIcon.__init__(self, *args, **kwargs)

        self._arguments = (args, kwargs)
        self._status_fb = None
        self._status_gtk = None
        self.set("si-syncthing-unknown", "")

        # Do not ever force-show indicators when they do not think they'll work
        if "force" in self._arguments[1]:
            del self._arguments[1]["force"]

        try:
            # Try loading GTK native status icon
            self._status_gtk = StatusIconGTK3(*args, **kwargs)
            self._status_gtk.connect("clicked",        self._on_click)
            self._status_gtk.connect(
                "notify::active", self._on_notify_active_gtk)
            self._on_notify_active_gtk()

            log.info("Using backend StatusIconGTK3 (primary)")
        except NotImplementedError:
            # Directly load fallback implementation
            self._load_fallback()

    def _on_click(self, *args):
        self.emit("clicked")

    def _on_notify_active_gtk(self, *args):
        if self._status_fb:
            # Hide fallback icon if GTK icon is active and vice-versa
            if self._status_gtk.get_active():
                self._status_fb.hide()
            else:
                self._status_fb.show()
        elif not self._status_gtk.get_active():
            # Load fallback implementation
            self._load_fallback()

    def _on_notify_active_fb(self, *args):
        active = False
        if self._status_gtk and self._status_gtk.get_active():
            active = True
        if self._status_fb and self._status_fb.get_active():
            active = True
        self.set_property("active", active)

    def _load_fallback(self):
        if IS_KDE:
            status_icon_backends = [
                StatusIconAppIndicator, StatusIconQt5, StatusIconDummy]
        else:
            status_icon_backends = [StatusIconAppIndicator, StatusIconDummy]

        if not self._status_fb:
            for StatusIconBackend in status_icon_backends:
                try:
                    self._status_fb = StatusIconBackend(
                        *self._arguments[0], **self._arguments[1])
                    self._status_fb.connect("clicked",        self._on_click)
                    self._status_fb.connect(
                        "notify::active", self._on_notify_active_fb)
                    self._on_notify_active_fb()

                    log.warning("StatusIcon: Using backend %s (fallback)" %
                                StatusIconBackend.__name__)
                    break
                except NotImplementedError:
                    continue

            # At least the dummy backend should have been loaded at this point...
            assert self._status_fb

        # Update fallback icon
        self.set(self._icon, self._text)

    def set(self, icon=None, text=None):
        self._icon = icon
        self._text = text

        if self._status_gtk:
            self._status_gtk.set(icon, text)
        if self._status_fb:
            self._status_fb.set(icon, text)

    def hide(self):
        if self._status_gtk:
            self._status_gtk.hide()
        if self._status_fb:
            self._status_fb.hide()

    def show(self):
        if self._status_gtk:
            self._status_gtk.show()
        if self._status_fb:
            self._status_fb.show()


def get_status_icon(*args, **kwargs):
    # Try selecting backend based on environment variable
    if "SYNCTHING_STATUS_BACKEND" in os.environ:
        kwargs["force"] = True

        status_icon_backend_name = "StatusIcon%s" % (
            os.environ.get("SYNCTHING_STATUS_BACKEND"))
        if status_icon_backend_name in globals():
            try:
                status_icon = globals()[status_icon_backend_name](
                    *args, **kwargs)
                log.info("StatusIcon: Using requested backend %s" %
                         (status_icon_backend_name))
                return status_icon
            except NotImplementedError:
                log.error("StatusIcon: Requested backend %s is not supported" % (
                    status_icon_backend_name))
        else:
            log.error("StatusIcon: Requested backend %s does not exist" %
                      (status_icon_backend_name))

        return StatusIconDummy(*args, **kwargs)

    # Use proxy backend to determine the correct backend while the application is running
    return StatusIconProxy(*args, **kwargs)