# # # from __future__ import absolute_import, division, print_function, unicode_literals from logging import getLogger, DEBUG as _DEBUG _log = getLogger(__name__) del getLogger from time import time as _timestamp from gi.repository import Gtk, GLib from gi.repository.Gdk import ScrollDirection from solaar import NAME from logitech.unifying_receiver.status import ( BATTERY_LEVEL as _BATTERY_LEVEL, BATTERY_CHARGING as _BATTERY_CHARGING, ) from . import icons as _icons from .window import ( popup as _window_popup, toggle as _window_toggle ) _TRAY_ICON_SIZE = 32 # pixels _MENU_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR # # # # for which device to show the battery info in systray, if more than one _picked_device = None def _create_menu(): menu = Gtk.Menu() # per-device menu entries will be generated as-needed no_receiver = Gtk.MenuItem.new_with_label('No receiver found') no_receiver.set_sensitive(False) menu.append(no_receiver) menu.append(Gtk.SeparatorMenuItem.new()) from .action import about, make menu.append(about.create_menu_item()) menu.append(make('application-exit', 'Quit', Gtk.main_quit).create_menu_item()) del about, make menu.show_all() return menu try: # raise ImportError from gi.repository import AppIndicator3 _log.info("using AppIndicator3") _last_scroll = 0 def _scroll(ind, _, direction): if direction != ScrollDirection.UP and direction != ScrollDirection.DOWN: # ignore all other directions return if len(_devices_info) < 4: # don't bother with scrolling when there's only one receiver # with only one device (3 = [receiver, device, separator]) return # scroll events come way too fast (at least 5-6 at once) # so take a little break between them global _last_scroll now = _timestamp() if now - _last_scroll < 0.33: # seconds return _last_scroll = now # if _log.isEnabledFor(_DEBUG): # _log.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 peripheral serials if info[1] == _picked_device[1]: 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 _log.isEnabledFor(_DEBUG): _log.debug("scroll: picked %s", _picked_device) _update_tray_icon() def _create(menu): theme_paths = Gtk.IconTheme.get_default().get_search_path() ind = AppIndicator3.Indicator.new_with_path( 'indicator-solaar', _icons.TRAY_INIT, AppIndicator3.IndicatorCategory.HARDWARE, ':'.join(theme_paths)) ind.set_title(NAME) ind.set_status(AppIndicator3.IndicatorStatus.ACTIVE) ind.set_attention_icon_full(_icons.TRAY_ATTENTION, '') # ind.set_label(NAME, NAME) ind.set_menu(menu) ind.connect('scroll-event', _scroll) return ind def _destroy(indicator): indicator.set_status(AppIndicator3.IndicatorStatus.PASSIVE) def _update_tray_icon(): if _picked_device: _, _, name, _, device_status = _picked_device battery_level = device_status.get(_BATTERY_LEVEL) battery_charging = device_status.get(_BATTERY_CHARGING) tray_icon_name = _icons.battery(battery_level, battery_charging) description = '%s: %s' % (name, device_status) else: # there may be a receiver, but no peripherals tray_icon_name = _icons.TRAY_OKAY if _devices_info else _icons.TRAY_INIT tooltip_lines = _generate_tooltip_lines() description = '\n'.join(tooltip_lines).rstrip('\n') # icon_file = _icons.icon_file(icon_name, _TRAY_ICON_SIZE) _icon.set_icon_full(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(_icons.TRAY_ATTENTION, reason or '') _icon.set_status(AppIndicator3.IndicatorStatus.ATTENTION) GLib.timeout_add(10 * 1000, _icon.set_status, AppIndicator3.IndicatorStatus.ACTIVE) except ImportError: _log.info("using StatusIcon") def _create(menu): icon = Gtk.StatusIcon.new_from_icon_name(_icons.TRAY_INIT) icon.set_name(NAME) icon.set_title(NAME) icon.set_tooltip_text(NAME) icon.connect('activate', _window_toggle) icon.connect('popup_menu', lambda icon, button, time: menu.popup(None, None, icon.position_menu, icon, button, time)) return icon def _destroy(icon): icon.set_visible(False) def _update_tray_icon(): tooltip_lines = _generate_tooltip_lines() tooltip = '\n'.join(tooltip_lines).rstrip('\n') _icon.set_tooltip_markup(tooltip) if _picked_device: _, _, name, _, device_status = _picked_device battery_level = device_status.get(_BATTERY_LEVEL) battery_charging = device_status.get(_BATTERY_CHARGING) 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) 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 '%s: no receivers' % NAME return yield '%s' % NAME yield '' for _, serial, name, _, status in _devices_info: if serial is None: # receiver continue yield '%s' % name p = str(status) if p: # does it have any properties to print? if status: yield '\t%s' % p else: yield '\t%s (inactive)' % p else: if status: yield '\tno status' else: yield '\t(inactive)' yield '' 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/separator continue level = info[-1].get(_BATTERY_LEVEL) if not picked or (level is not None and picked_level > level): picked = info picked_level = level or 0 if _log.isEnabledFor(_DEBUG): _log.debug("picked device with lowest battery: %s", picked) return picked # # # def _add_device(device): assert device assert device.receiver receiver_path = device.receiver.path assert receiver_path index = None for idx, (path, _, _, _, _) in enumerate(_devices_info): if path == receiver_path: # the first entry matching the receiver serial should be for the receiver itself index = idx + 1 break assert index is not None # proper ordering (according to device.number) for a receiver's devices while True: path, _, _, number, _ = _devices_info[index] if path == '-': break assert path == receiver_path assert number != device.number if number > device.number: break index = index + 1 new_device_info = (receiver_path, device.serial, device.name, device.number, device.status) _devices_info.insert(index, new_device_info) # label_prefix = b'\xE2\x94\x84 '.decode('utf-8') label_prefix = ' ' new_menu_item = Gtk.ImageMenuItem.new_with_label(label_prefix + device.name) new_menu_item.set_image(Gtk.Image()) new_menu_item.show_all() new_menu_item.connect('activate', _window_popup, receiver_path, device.serial) _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[1] == removed_device[1]: # the current pick was unpaired _picked_device = None def _add_receiver(receiver): device_info = (receiver.path, None, receiver.name, None, None) _devices_info.insert(0, device_info) new_menu_item = Gtk.ImageMenuItem.new_with_label(receiver.name) _menu.insert(new_menu_item, 0) icon_set = _icons.device_icon_set(receiver.name) new_menu_item.set_image(Gtk.Image().new_from_icon_set(icon_set, _MENU_ICON_SIZE)) new_menu_item.show_all() new_menu_item.connect('activate', _window_popup, receiver.path) _devices_info.insert(1, ('-', None, None, None, None)) separator = Gtk.SeparatorMenuItem.new() separator.set_visible(True) _menu.insert(separator, 1) return 0 def _remove_receiver(receiver): index = 0 found = False # remove all entries in devices_info that match this receiver while index < len(_devices_info): path, _, _, _, _ = _devices_info[index] if path == receiver.path: found = True _remove_device(index) elif found and path == '-': # the separator after this receiver _remove_device(index) break else: index += 1 def _update_menu_item(index, device_status): menu_items = _menu.get_children() menu_item = menu_items[index] level = device_status.get(_BATTERY_LEVEL) charging = device_status.get(_BATTERY_CHARGING) icon_name = _icons.battery(level, charging) image_widget = menu_item.get_image() image_widget.set_sensitive(bool(device_status)) _update_menu_icon(image_widget, icon_name) # # # _devices_info = [] _menu = None _icon = None def init(): global _menu, _icon _menu = _create_menu() _icon = _create(_menu) def destroy(): global _icon, _menu, _devices_info i, _icon = _icon, None _destroy(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 receiver = device receiver_path = receiver.path if receiver: index = None for idx, (path, _, _, _, _) in enumerate(_devices_info): if path == receiver_path: index = idx break if index is None: _add_receiver(receiver) else: _remove_receiver(receiver) else: receiver_path = device.receiver.path # peripheral index = None for idx, (path, serial, name, _, _) in enumerate(_devices_info): if path == receiver_path and serial == device.serial: index = idx if device.status is None: # was just unpaired assert index is not None _remove_device(index) else: if index is None: index = _add_device(device) _update_menu_item(index, device.status) menu_items = _menu.get_children() no_receivers_index = len(_devices_info) menu_items[no_receivers_index].set_visible(not _devices_info) menu_items[no_receivers_index + 1].set_visible(not _devices_info) global _picked_device if not _picked_device: _picked_device = _pick_device_with_lowest_battery() _update_tray_icon()