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 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603
|
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Syncthing-GTK - StatusIcon
"""
import locale
import os
import sys
import logging
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gtk
from syncthing_gtk.tools import IS_UNITY, IS_KDE, IS_CINNAMON, IS_LXQT
from syncthing_gtk.tools import _ # gettext function
log = logging.getLogger("StatusIcon")
# | KDE5 | MATE | Unity | Cinnamon | Cairo-Dock (classic) | Cairo-Dock (modern) | KDE4 |
#----------------+-----------------+-----------+------------+------------+----------------------+---------------------+-----------+
# StatusIconKDE4 | excellent | usable³ | very good⁵ | usable³ | usable³ | excellent | excellent |
# StatusIconQt5 | very good (KF5) | - | - | - | - | - | - |
# StatusIconAppI | good² | none | excellent | none | none | excellent | good² |
# StatusIconGTK3 | none | 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
"""
pass
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
if IS_KDE:
# While the GTK backend works fine on KDE 4, the StatusIconKDE4 backend will achieve better
# results and should be available on any standard KDE 4 installation
# (since several KDE applications depend on it)
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_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 StatusIconKDE4(StatusIconQt):
"""
PyKDE5.kdeui.KStatusNotifierItem based status icon backend
"""
def __init__(self, *args, **kwargs):
StatusIcon.__init__(self, *args, **kwargs)
try:
import PyQt4.Qt as qt
import PyQt4.QtGui as qtgui
import PyKDE4.kdeui as kdeui
self._set_qt_types(
QAction = qtgui.QAction,
QMenu = kdeui.KMenu,
QIcon = qtgui.QIcon,
QImage = qtgui.QImage,
QPixmap = qtgui.QPixmap
)
self._status_active = kdeui.KStatusNotifierItem.Active
self._status_passive = kdeui.KStatusNotifierItem.Passive
except ImportError:
raise NotImplementedError
if "GNOME_DESKTOP_SESSION_ID" in os.environ:
del os.environ["GNOME_DESKTOP_SESSION_ID"]
# Create Qt GUI application (required by the KdeUI libraries)
# We force "--style=motif" here to prevent Qt to load platform theme
# integration libraries for "Gtk+" style that cause GTK 3 to abort like this:
# Gtk-ERROR **: GTK+ 2.x symbols detected. Using GTK+ 2.x and GTK+ 3 in the same process is not supported
self._qt_app = qt.QApplication([sys.argv[0], "--style=motif"])
# Keep reference to KMenu object to prevent SegFault...
self._kde_menu = self._get_popupmenu()
self._tray = kdeui.KStatusNotifierItem("syncthing-gtk", None)
self._tray.setStandardActionsEnabled(False) # Prevent KDE quit item from showing
self._tray.setContextMenu(self._kde_menu)
self._tray.setCategory(kdeui.KStatusNotifierItem.ApplicationStatus)
self._tray.setTitle(self.TRAY_TITLE)
self._tray.activateRequested.connect(self._on_click)
def _set_visible(self, active):
StatusIcon._set_visible(self, active)
self._tray.setStatus(self._status_active if active else self._status_passive)
def set(self, icon=None, text=""):
StatusIcon.set(self, icon, text)
self._tray.setIconByName(self._get_icon(icon))
self._tray.setToolTip(self._get_icon(icon), self._get_text(text), "")
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_UNITY or IS_KDE:
status_icon_backends = [StatusIconAppIndicator, StatusIconKDE4, StatusIconDummy]
else:
status_icon_backends = [StatusIconKDE4, 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)
|