## Copyright (C) 2012-2013 Daniel Pavel ## ## This program is free software; you can redistribute it and/or modify ## it under the terms of the GNU General Public License as published by ## the Free Software Foundation; either version 2 of the License, or ## (at your option) any later version. ## ## This program is distributed in the hope that it will be useful, ## but WITHOUT ANY WARRANTY; without even the implied warranty of ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ## GNU General Public License for more details. ## ## You should have received a copy of the GNU General Public License along ## with this program; if not, write to the Free Software Foundation, Inc., ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import logging import os from time import time as _timestamp import gi from gi.repository import GLib, Gtk from gi.repository.Gdk import ScrollDirection import solaar.gtk as gtk from solaar import NAME from solaar.i18n import _ from . import icons as _icons from .about import show_window as _show_about_window from .action import make as _make from .window import popup as _window_popup from .window import toggle as _window_toggle logger = logging.getLogger(__name__) # # constants # _TRAY_ICON_SIZE = 48 _MENU_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR # # # def _create_menu(quit_handler): menu = Gtk.Menu() # per-device menu entries will be generated as-needed no_receiver = Gtk.MenuItem.new_with_label(_("No supported device found")) no_receiver.set_sensitive(False) menu.append(no_receiver) menu.append(Gtk.SeparatorMenuItem.new()) menu.append(_make("help-about", _("About %s") % NAME, _show_about_window, stock_id="help-about").create_menu_item()) menu.append(_make("application-exit", _("Quit %s") % NAME, quit_handler, stock_id="application-exit").create_menu_item()) menu.show_all() return menu _last_scroll = 0 def _scroll(tray_icon, event, direction=None): if direction is None: direction = event.direction now = event.time / 1000.0 else: now = None if direction != ScrollDirection.UP and direction != ScrollDirection.DOWN: # ignore all other directions return if sum(map(lambda i: i[1] is not None, _devices_info)) < 2: # don't bother even trying to scroll if less than two devices return # scroll events come way too fast (at least 5-6 at once) # so take a little break between them global _last_scroll now = now or _timestamp() if now - _last_scroll < 0.33: # seconds return _last_scroll = now # if logger.isEnabledFor(logging.DEBUG): # logger.debug("scroll direction %s", direction) global _picked_device candidate = None if _picked_device is None: for info in _devices_info: # pick first peripheral found if info[1] is not None: candidate = info break else: found = False for info in _devices_info: if not info[1]: # only conside peripherals continue # compare peripherals if info[0:2] == _picked_device[0:2]: if direction == ScrollDirection.UP and candidate: # select previous device break found = True else: if found: candidate = info if direction == ScrollDirection.DOWN: break # if direction is up, but no candidate found before _picked, # let it run through all candidates, will get stuck with the last one else: if direction == ScrollDirection.DOWN: # only use the first one, in case no candidates are after _picked if candidate is None: candidate = info else: candidate = info # if the last _picked_device is gone, clear it # the candidate will be either the first or last one remaining, # depending on the scroll direction if not found: _picked_device = None _picked_device = candidate or _picked_device if logger.isEnabledFor(logging.DEBUG): logger.debug("scroll: picked %s", _picked_device) _update_tray_icon() try: try: gi.require_version("AyatanaAppIndicator3", "0.1") from gi.repository import AyatanaAppIndicator3 as AppIndicator3 ayatana_appindicator_found = True except ValueError: try: gi.require_version("AppIndicator3", "0.1") from gi.repository import AppIndicator3 ayatana_appindicator_found = False except ValueError as exc: # treat unavailable versions the same as unavailable packages raise ImportError from exc if logger.isEnabledFor(logging.DEBUG): logger.debug(f"using {'Ayatana ' if ayatana_appindicator_found else ''}AppIndicator3") # Defense against AppIndicator3 bug that treats files in current directory as icon files # https://bugs.launchpad.net/ubuntu/+source/libappindicator/+bug/1363277 # Defense against bug that shows up in XFCE 4.16 where icons are not upscaled def _icon_file(icon_name): if gtk.tray_icon_size is None and not os.path.isfile(icon_name): return icon_name icon_info = Gtk.IconTheme.get_default().lookup_icon( icon_name, gtk.tray_icon_size or _TRAY_ICON_SIZE, Gtk.IconLookupFlags.FORCE_SVG ) return icon_info.get_filename() if icon_info else icon_name def _create(menu): _icons._init_icon_paths() ind = AppIndicator3.Indicator.new( "indicator-solaar", _icon_file(_icons.TRAY_INIT), AppIndicator3.IndicatorCategory.HARDWARE ) ind.set_title(NAME) ind.set_status(AppIndicator3.IndicatorStatus.ACTIVE) # ind.set_attention_icon_full(_icon_file(_icons.TRAY_ATTENTION), '') # works poorly for XFCE 16 # ind.set_label(NAME.lower(), NAME.lower()) ind.set_menu(menu) ind.connect("scroll-event", _scroll) return ind def _hide(indicator): indicator.set_status(AppIndicator3.IndicatorStatus.PASSIVE) def _show(indicator): indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) def _update_tray_icon(): if _picked_device and gtk.battery_icons_style != "solaar": _ignore, _ignore, name, device = _picked_device battery_level = device.battery_info.level if device.battery_info is not None else None battery_charging = device.battery_info.charging() if device.battery_info is not None else None tray_icon_name = _icons.battery(battery_level, battery_charging) description = f"{name}: {device.status_string()}" else: # there may be a receiver, but no peripherals tray_icon_name = _icons.TRAY_OKAY if _devices_info else _icons.TRAY_INIT description_lines = _generate_description_lines() description = "\n".join(description_lines).rstrip("\n") # icon_file = _icons.icon_file(icon_name, _TRAY_ICON_SIZE) _icon.set_icon_full(_icon_file(tray_icon_name), description) def _update_menu_icon(image_widget, icon_name): image_widget.set_from_icon_name(icon_name, _MENU_ICON_SIZE) # icon_file = _icons.icon_file(icon_name, _MENU_ICON_SIZE) # image_widget.set_from_file(icon_file) # image_widget.set_pixel_size(_TRAY_ICON_SIZE) def attention(reason=None): if _icon.get_status() != AppIndicator3.IndicatorStatus.ATTENTION: # _icon.set_attention_icon_full(_icon_file(_icons.TRAY_ATTENTION), reason or '') # works poorly for XFCe 16 _icon.set_status(AppIndicator3.IndicatorStatus.ATTENTION) GLib.timeout_add(10 * 1000, _icon.set_status, AppIndicator3.IndicatorStatus.ACTIVE) except ImportError: if logger.isEnabledFor(logging.DEBUG): logger.debug("using StatusIcon") def _create(menu): icon = Gtk.StatusIcon.new_from_icon_name(_icons.TRAY_INIT) icon.set_name(NAME.lower()) icon.set_title(NAME) icon.set_tooltip_text(NAME) icon.connect("activate", _window_toggle) icon.connect("scroll-event", _scroll) icon.connect("popup-menu", lambda icon, button, time: menu.popup(None, None, icon.position_menu, icon, button, time)) return icon def _hide(icon): icon.set_visible(False) def _show(icon): icon.set_visible(True) def _update_tray_icon(): tooltip_lines = _generate_tooltip_lines() tooltip = "\n".join(tooltip_lines).rstrip("\n") _icon.set_tooltip_markup(tooltip) if _picked_device and gtk.battery_icons_style != "solaar": _ignore, _ignore, name, device = _picked_device battery_level = device.battery_info.level if device.battery_info is not None else None battery_charging = device.battery_info.charging() if device.battery_info is not None else None tray_icon_name = _icons.battery(battery_level, battery_charging) else: # there may be a receiver, but no peripherals tray_icon_name = _icons.TRAY_OKAY if _devices_info else _icons.TRAY_ATTENTION _icon.set_from_icon_name(tray_icon_name) def _update_menu_icon(image_widget, icon_name): image_widget.set_from_icon_name(icon_name, _MENU_ICON_SIZE) _icon_before_attention = None def _blink(count): global _icon_before_attention if count % 2: _icon.set_from_icon_name(_icons.TRAY_ATTENTION) else: _icon.set_from_icon_name(_icon_before_attention) if count > 0: GLib.timeout_add(1000, _blink, count - 1) else: _icon_before_attention = None def attention(reason=None): global _icon_before_attention if _icon_before_attention is None: _icon_before_attention = _icon.get_icon_name() GLib.idle_add(_blink, 9) # # # def _generate_tooltip_lines(): if not _devices_info: yield f"{NAME}: " + _("no receiver") return yield from _generate_description_lines() def _generate_description_lines(): if not _devices_info: yield _("no receiver") return for _ignore, number, name, device in _devices_info: if number is None: # receiver continue p = device.status_string() if p: # does it have any properties to print? yield f"{name}" if device.online: yield f"\t{p}" else: yield f"\t{p} (" + _("offline") + ")" else: if device.online: yield f"{name} (" + _("no status") + ")" else: yield f"{name} (" + _("offline") + ")" def _pick_device_with_lowest_battery(): if not _devices_info: return None picked = None picked_level = 1000 for info in _devices_info: if info[1] is None: # is receiver continue level = info[-1].battery_info.level if info[-1].battery_info is not None else None if level is not None and picked_level > level: picked = info picked_level = level or 0 if logger.isEnabledFor(logging.DEBUG): logger.debug("picked device with lowest battery: %s", picked) return picked # # # def _add_device(device): assert device index = 0 receiver_path = device.receiver.path if device.receiver is not None else device.path if device.receiver is not None: # if receiver insert into devices for the receiver in device number order for idx, (path, _ignore, _ignore, _ignore) in enumerate(_devices_info): if path and path == receiver_path: index = idx + 1 # the first entry matching the receiver serial should be for the receiver itself break while index < len(_devices_info): path, number, _ignore, _ignore = _devices_info[index] if not path == receiver_path: break assert number != device.number if number > device.number: break index = index + 1 new_device_info = (receiver_path, device.number, device.name, device) _devices_info.insert(index, new_device_info) label_prefix = " " new_menu_item = Gtk.ImageMenuItem.new_with_label((label_prefix if device.number else "") + device.name) new_menu_item.set_image(Gtk.Image()) new_menu_item.show_all() new_menu_item.connect("activate", _window_popup, receiver_path, device.number) _menu.insert(new_menu_item, index) return index def _remove_device(index): assert index is not None menu_items = _menu.get_children() _menu.remove(menu_items[index]) removed_device = _devices_info.pop(index) global _picked_device if _picked_device and _picked_device[0:2] == removed_device[0:2]: # the current pick was unpaired _picked_device = None def _add_receiver(receiver): index = len(_devices_info) new_receiver_info = (receiver.path, None, receiver.name, None) _devices_info.insert(index, new_receiver_info) new_menu_item = Gtk.ImageMenuItem.new_with_label(receiver.name) icon_set = _icons.device_icon_set(receiver.name) new_menu_item.set_image(Gtk.Image().new_from_icon_name(icon_set.names[0], _MENU_ICON_SIZE)) new_menu_item.show_all() new_menu_item.connect("activate", _window_popup, receiver.path) _menu.insert(new_menu_item, index) return 0 def _remove_receiver(receiver): index = 0 # remove all entries in devices_info that match this receiver while index < len(_devices_info): path, _ignore, _ignore, _ignore = _devices_info[index] if path == receiver.path: _remove_device(index) else: index += 1 def _update_menu_item(index, device): if device is None: logger.warning("updating an inactive device %s, assuming disconnected", device) return None menu_items = _menu.get_children() menu_item = menu_items[index] level = device.battery_info.level if device.battery_info is not None else None charging = device.battery_info.charging() if device.battery_info is not None else None icon_name = _icons.battery(level, charging) menu_item.set_label((" " if 0 < device.number <= 6 else "") + device.name + ": " + device.status_string()) image_widget = menu_item.get_image() image_widget.set_sensitive(bool(device.online)) _update_menu_icon(image_widget, icon_name) # # # # for which device to show the battery info in systray, if more than one # it's actually an entry in _devices_info _picked_device = None # cached list of devices and some of their properties # contains tuples of (receiver path, device number, name, device) _devices_info = [] _menu = None _icon = None def init(_quit_handler): global _menu, _icon assert _menu is None _menu = _create_menu(_quit_handler) assert _icon is None _icon = _create(_menu) update() def destroy(): global _icon, _menu, _devices_info if _icon is not None: i, _icon = _icon, None _hide(i) i = None _icon = None _menu = None _devices_info = None def update(device=None): if _icon is None: return if device is not None: if device.kind is None: # receiver is_alive = bool(device) receiver_path = device.path if is_alive: index = None for idx, (path, _ignore, _ignore, _ignore) in enumerate(_devices_info): if path == receiver_path: index = idx break if index is None: _add_receiver(device) else: _remove_receiver(device) else: # peripheral is_paired = bool(device) receiver_path = device.receiver.path if device.receiver is not None else device.path index = None for idx, (path, number, _ignore, _ignore) in enumerate(_devices_info): if path == receiver_path and number == device.number: index = idx if is_paired: if index is None: index = _add_device(device) _update_menu_item(index, device) else: # was just unpaired or unplugged if index is not None: _remove_device(index) menu_items = _menu.get_children() no_receivers_index = len(_devices_info) menu_items[no_receivers_index].set_visible(not _devices_info) global _picked_device if (not _picked_device or _last_scroll == 0) and device is not None and device.kind is not None: # if it's just a receiver update, it's unlikely the picked device would change _picked_device = _pick_device_with_lowest_battery() _update_tray_icon() if _icon: if not _devices_info: _hide(_icon) else: _show(_icon)