diff --git a/lib/solaar/appinstance.py b/lib/solaar/appinstance.py index 74601aa6..5e149914 100644 --- a/lib/solaar/appinstance.py +++ b/lib/solaar/appinstance.py @@ -12,16 +12,22 @@ import fcntl as _fcntl import os.path as _path import os as _os +from logging import getLogger, DEBUG as _DEBUG +_log = getLogger('solaar.appinstance') +del getLogger + def check(): # ensure no more than a single instance runs at a time lock_fd = None for p in _os.environ.get('XDG_RUNTIME_DIR'), '/run/lock', '/var/lock', _os.environ.get('TMPDIR', '/tmp'): + # pick the first temporary writable folder if p and _path.isdir(p) and _os.access(p, _os.W_OK): lock_path = _path.join(p, 'solaar.single-instance.%d' % _os.getuid()) try: lock_fd = open(lock_path, 'wb') - # print ("Single instance lock file is %s" % lock_path) + if _log.isEnabledFor(_DEBUG): + _log.debug("single-instance lock file is %s", lock_path) break except: pass @@ -29,9 +35,12 @@ def check(): if lock_fd: try: _fcntl.flock(lock_fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB) + if _log.isEnabledFor(_DEBUG): + _log.debug("acquired single-instance lock (%s)", lock_fd) return lock_fd except IOError as e: if e.errno == 11: + _log.warn("lock file is busy, solaar already running: %s", e) import sys sys.exit("solaar: error: Solaar is already running.") else: @@ -42,6 +51,8 @@ def check(): def close(lock_fd): + if _log.isEnabledFor(_DEBUG): + _log.debug("releasing single-instance lock (%s)", lock_fd) if lock_fd: _fcntl.flock(lock_fd, _fcntl.LOCK_UN) lock_fd.close() diff --git a/lib/solaar/gtk.py b/lib/solaar/gtk.py index 7cc89cf4..ec86358c 100644 --- a/lib/solaar/gtk.py +++ b/lib/solaar/gtk.py @@ -40,6 +40,10 @@ def _parse_arguments(): def _run(args): + from logging import getLogger + _log = getLogger('solaar.gtk') + del getLogger + import solaar.ui as ui ui.notify.init() @@ -54,6 +58,8 @@ def _run(args): assert action is not None assert device_info is not None + _log.info("receiver event %s: %s", action, device_info) + # whatever the action, stop any previous receivers at this path l = listeners.pop(device_info.path, None) if l is not None: @@ -94,10 +100,10 @@ def _run(args): GLib.idle_add(ui.status_icon.update, status_icon, device) if alert & ALERT.ATTENTION: - GLib.idle_add(ui.status_icon.attention, status_icon) + GLib.idle_add(ui.status_icon.attention, status_icon, reason) - popup_window = alert & (ALERT.SHOW_WINDOW | ALERT.ATTENTION) - GLib.idle_add(ui.main_window.update, device, popup_window, status_icon) + need_popup = alert & (ALERT.SHOW_WINDOW | ALERT.ATTENTION) + GLib.idle_add(ui.main_window.update, device, need_popup, status_icon) if alert & ALERT.NOTIFICATION: GLib.idle_add(ui.notify.show, device, reason) @@ -105,15 +111,21 @@ def _run(args): # ugly... def _startup_check_receiver(): if not listeners: + # this is called on the Main (GTK) thread, so we can make direct calls ui.notify.alert('No receiver found.') ui.status_icon.update(status_icon) + ui.status_icon.attention(status_icon, 'No receiver found.') + # check for a receiver 1 second after the app was started GLib.timeout_add(1000, _startup_check_receiver) from logitech.unifying_receiver import base as _base + # receiver add/remove events will start/stop listener threads GLib.timeout_add(10, _base.notify_on_receivers, handle_receivers_events) from gi.repository import Gtk Gtk.main() - # ui.status_icon.destroy(status_icon) + # this is unnecessary for the Gtk.StatusIcon implementation + # but the AppIdicator implementation may need it to make the indicator go away + ui.status_icon.destroy(status_icon) for l in listeners.values(): l.stop() diff --git a/lib/solaar/ui/main_window.py b/lib/solaar/ui/main_window.py index facd1f4a..9cc2503d 100644 --- a/lib/solaar/ui/main_window.py +++ b/lib/solaar/ui/main_window.py @@ -471,7 +471,7 @@ def _update_device_box(frame, dev): _config_panel.update(frame) -def update(device, popup=False, status_icon=None): +def update(device, need_popup=False, status_icon=None): assert device is not None # print ("main_window.update", device) @@ -483,7 +483,7 @@ def update(device, popup=False, status_icon=None): if w: if receiver: - if popup: + if need_popup: _show(w, status_icon) vbox = w.get_child() frames = list(vbox.get_children()) diff --git a/lib/solaar/ui/status_icon.py b/lib/solaar/ui/status_icon.py index 2033c562..f2126cd3 100644 --- a/lib/solaar/ui/status_icon.py +++ b/lib/solaar/ui/status_icon.py @@ -8,18 +8,27 @@ from logging import getLogger, DEBUG as _DEBUG _log = getLogger('solaar.ui.tray') 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 . import action as _action, icons as _icons from logitech.unifying_receiver import status as _status +_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_common(icon, menu_activate_callback): icon._devices_info = [] @@ -28,12 +37,13 @@ def _create_common(icon, menu_activate_callback): icon._menu_activate_callback = menu_activate_callback icon._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) - - # per-device menu entries will be generated as-needed menu.append(Gtk.SeparatorMenuItem.new()) + menu.append(_action.about.create_menu_item()) menu.append(_action.make('application-exit', 'Quit', Gtk.main_quit).create_menu_item()) menu.show_all() @@ -42,55 +52,139 @@ def _create_common(icon, menu_activate_callback): try: from gi.repository import AppIndicator3 - _log.debug("using 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(ind._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 ind._devices_info: + # pick first peripheral found + if info[1] is not None: + candidate = info + break + else: + found = False + for info in ind._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(ind) - # def _scroll(ind, delta, direction): - # if _log.isEnabledFor(_DEBUG): - # _log.debug("scroll delta %s direction %s", delta, direction) def create(activate_callback, menu_activate_callback): assert activate_callback assert menu_activate_callback - ind = AppIndicator3.Indicator.new( + theme_paths = Gtk.IconTheme.get_default().get_search_path() + + ind = AppIndicator3.Indicator.new_with_path( 'indicator-solaar', _icons.TRAY_INIT, - AppIndicator3.IndicatorCategory.HARDWARE) + AppIndicator3.IndicatorCategory.HARDWARE, + ':'.join(theme_paths)) ind.set_status(AppIndicator3.IndicatorStatus.ACTIVE) - ind.set_label(NAME, NAME) - - # theme_paths = Gtk.IconTheme.get_default().get_search_path() - # ind.set_icon_theme_path(':'.join(theme_paths)) - - # ind.set_icon(_icons.TRAY_INIT) - ind.set_attention_icon(_icons.TRAY_ATTENTION) + ind.set_attention_icon_full(_icons.TRAY_ATTENTION, '') + # ind.set_label(NAME, NAME) _create_common(ind, menu_activate_callback) ind.set_menu(ind._menu) - # ind.connect('scroll-event', _scroll) + ind.connect('scroll-event', _scroll) return ind - # def destroy(ind): - # ind.set_status(AppIndicator3.IndicatorStatus.PASSIVE) + def destroy(ind): + ind.set_status(AppIndicator3.IndicatorStatus.PASSIVE) - def _update_icon(ind, icon_name, tooltip): - icon_file = _icons.icon_file(icon_name, 32) - ind.set_icon(icon_file) - # ind.set_icon_full(icon_name, tooltip) + def _update_tray_icon(ind): + if _picked_device: + _, _, name, _, device_status = _picked_device + battery_level = device_status.get(_status.BATTERY_LEVEL) + battery_charging = device_status.get(_status.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 ind._devices_info else _icons.TRAY_INIT + description = '%s: no receivers found' % NAME + + # icon_file = _icons.icon_file(icon_name, _TRAY_ICON_SIZE) + ind.set_icon_full(tray_icon_name, description) - def attention(ind): + 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(ind, reason=None): if ind.get_status != AppIndicator3.IndicatorStatus.ATTENTION: + ind.set_attention_icon_full(_icons.TRAY_ATTENTION, reason or '') ind.set_status(AppIndicator3.IndicatorStatus.ATTENTION) GLib.timeout_add(10 * 1000, ind.set_status, AppIndicator3.IndicatorStatus.ACTIVE) except ImportError: - _log.debug("using StatusIcon") + _log.info("using StatusIcon") def create(activate_callback, menu_activate_callback): assert activate_callback @@ -110,31 +204,46 @@ except ImportError: return icon - # def destroy(icon): - # icon.set_visible(False) + def destroy(icon): + icon.set_visible(False) - def _update_icon(icon, icon_name, tooltip): - icon.set_from_icon_name(icon_name) + def _update_tray_icon(icon): + tooltip_lines = _generate_tooltip_lines(icon._devices_info) + 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(_status.BATTERY_LEVEL) + battery_charging = device_status.get(_status.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 icon._devices_info else _icons.TRAY_ATTENTION + icon.set_from_icon_name(tray_icon_name) - _icon_after_attention = None + + 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(icon, count): - global _icon_after_attention + global _icon_before_attention if count % 2: icon.set_from_icon_name(_icons.TRAY_ATTENTION) else: - icon.set_from_icon_name(_icon_after_attention) + icon.set_from_icon_name(_icon_before_attention) if count > 0: GLib.timeout_add(1000, _blink, icon, count - 1) - def attention(icon): - global _icon_after_attention - if _icon_after_attention is None: - _icon_after_attention = icon.get_icon_name() + def attention(icon, reason=None): + global _icon_before_attention + if _icon_before_attention is None: + _icon_before_attention = icon.get_icon_name() GLib.idle_add(_blink, icon, 9) # @@ -142,6 +251,10 @@ except ImportError: # def _generate_tooltip_lines(devices_info): + if not devices_info: + yield '%s: no receivers' % NAME + return + yield '%s' % NAME yield '' @@ -165,30 +278,26 @@ def _generate_tooltip_lines(devices_info): yield '' -def _generate_icon_name(icon): - if not icon._devices_info: - return _icons.TRAY_INIT +def _pick_device_with_lowest_battery(devices_info): + if not devices_info: + return None - battery_status = None - battery_level = 1000 + picked = None + picked_level = 1000 - for _, serial, name, _, status in icon._devices_info: - if serial is None: # is receiver + for info in devices_info: + if info[1] is None: # is receiver/separator continue - level = status.get(_status.BATTERY_LEVEL) - if level is not None and level < battery_level: - battery_status = status - battery_level = level + level = info[-1].get(_status.BATTERY_LEVEL) + if not picked or (level is not None and picked_level > level): + picked = info + picked_level = level or 0 - if battery_status is None: - return _icons.TRAY_OKAY + if _log.isEnabledFor(_DEBUG): + _log.debug("picked device with lowest battery: %s", picked) + + return picked - assert battery_level < 1000 - charging = battery_status.get(_status.BATTERY_CHARGING) - icon_name = _icons.battery(battery_level, charging) - if icon_name and 'missing' in icon_name: - icon_name = None - return icon_name or _icons.TRAY_OKAY # # @@ -219,22 +328,31 @@ def _add_device(icon, device): # print ("status_icon: added", index, ":", device_info) - menu_item = Gtk.ImageMenuItem.new_with_label(' ' + device.name) - icon._menu.insert(menu_item, index) + # label_prefix = b'\xE2\x94\x84 '.decode('utf-8') + label_prefix = ' ' + + menu_item = Gtk.ImageMenuItem.new_with_label(label_prefix + device.name) menu_item.set_image(Gtk.Image()) menu_item.show_all() menu_item.connect('activate', icon._menu_activate_callback, device.receiver.path, icon) + icon._menu.insert(menu_item, index) + return index def _remove_device(icon, index): - # print ("remove device", index) assert index is not None - del icon._devices_info[index] + menu_items = icon._menu.get_children() icon._menu.remove(menu_items[index]) + removed_device = icon._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(icon, receiver): device_info = (receiver.serial, None, receiver.name, None, None) @@ -258,13 +376,15 @@ def _add_receiver(icon, receiver): def _remove_receiver(icon, receiver): index = 0 found = False + + # remove all entries in devices_info that match this receiver while index < len(icon._devices_info): rserial, _, _, _, _ = icon._devices_info[index] - # print ("remove receiver", index, rserial) if rserial == receiver.serial: found = True _remove_device(icon, index) elif found and rserial == '-': + # the separator after this receiver _remove_device(icon, index) break else: @@ -275,19 +395,19 @@ def _update_menu_item(icon, index, device_status): menu_items = icon._menu.get_children() menu_item = menu_items[index] - image = menu_item.get_image() level = device_status.get(_status.BATTERY_LEVEL) charging = device_status.get(_status.BATTERY_CHARGING) - image.set_from_icon_name(_icons.battery(level, charging), _MENU_ICON_SIZE) - image.set_sensitive(bool(device_status)) + 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) # # # def update(icon, device=None): - # print ("icon update", device, icon._devices_info) - if device is not None: if device.kind is None: # receiver @@ -325,8 +445,8 @@ def update(icon, device=None): menu_items[no_receivers_index].set_visible(not icon._devices_info) menu_items[no_receivers_index + 1].set_visible(not icon._devices_info) - tooltip_lines = _generate_tooltip_lines(icon._devices_info) - tooltip = '\n'.join(tooltip_lines).rstrip('\n') - _update_icon(icon, _generate_icon_name(icon), tooltip) + global _picked_device + if not _picked_device: + _picked_device = _pick_device_with_lowest_battery(icon._devices_info) - # print ("icon updated", device, icon._devices_info) + _update_tray_icon(icon)