diff --git a/lib/solaar/gtk.py b/lib/solaar/gtk.py index 2ae9b694..66539694 100644 --- a/lib/solaar/gtk.py +++ b/lib/solaar/gtk.py @@ -42,13 +42,13 @@ def _parse_arguments(): def _run(args): import solaar.ui as ui - ui.init() - import solaar.listener as listener listener.start_scanner(ui.status_changed, ui.error_dialog) # main UI event loop + ui.init() ui.run_loop() + ui.destroy() listener.stop_all() diff --git a/lib/solaar/ui/__init__.py b/lib/solaar/ui/__init__.py index 85c2d406..7def7b3a 100644 --- a/lib/solaar/ui/__init__.py +++ b/lib/solaar/ui/__init__.py @@ -20,64 +20,62 @@ def _error_dialog(reason, object): '\n' 'If you\'ve just installed Solaar, try removing the receiver\n' 'and plugging it back in.' % object) + elif reason == 'unpair': + title = 'Unpairing failed' + text = ('Failed to unpair device\n%s .' % object) else: raise Exception("ui.error_dialog: don't know how to handle (%s, %s)", reason, object) + assert text + assert text + m = Gtk.MessageDialog(None, Gtk.DialogFlags.MODAL, Gtk.MessageType.ERROR, Gtk.ButtonsType.CLOSE, text) m.set_title(title) m.run() m.destroy() + def error_dialog(reason, object): + assert reason is not None GLib.idle_add(_error_dialog, reason, object) # # # -_tray_icon = None + +from . import notify, tray, window def init(): notify.init() - - global _tray_icon - _tray_icon = status_icon.create(main_window.toggle_all, main_window.popup) - assert _tray_icon + tray.init() + window.init() def run_loop(): - global _tray_icon Gtk.main() - t, _tray_icon = _tray_icon, None - status_icon.destroy(t) + +def destroy(): + tray.destroy() + window.destroy() notify.uninit() + from logitech.unifying_receiver.status import ALERT def _status_changed(device, alert, reason): assert device is not None if _log.isEnabledFor(_DEBUG): _log.debug("status changed: %s, %s, %s", device, alert, reason) - status_icon.update(_tray_icon, device) + tray.update(device) if alert & ALERT.ATTENTION: - status_icon.attention(_tray_icon, reason) + tray.attention(reason) need_popup = alert & (ALERT.SHOW_WINDOW | ALERT.ATTENTION) - main_window.update(device, need_popup, _tray_icon) + window.update(device, need_popup) if alert & ALERT.NOTIFICATION: notify.show(device, reason) + def status_changed(device, alert=ALERT.NONE, reason=None): GLib.idle_add(_status_changed, device, alert, reason) - -# -# -# - -from . import status_icon -from . import notify, main_window - -from . import icons -# for some reason, set_icon_name does not always work on windows -Gtk.Window.set_default_icon_name(main_window.NAME.lower()) -Gtk.Window.set_default_icon_from_file(icons.icon_file(main_window.NAME.lower(), 32)) diff --git a/lib/solaar/ui/about.py b/lib/solaar/ui/about.py index 64b4beb6..fd783cae 100644 --- a/lib/solaar/ui/about.py +++ b/lib/solaar/ui/about.py @@ -25,6 +25,7 @@ def _create(): about.set_authors(('Daniel Pavel http://github.com/pwr',)) try: + about.add_credit_section('GUI design', ('Julien Gascard',)) about.add_credit_section('Testing', ( 'Douglas Wagner', 'Julien Gascard', @@ -56,7 +57,7 @@ def _create(): return about -def show_window(_): +def show_window(trigger=None): global _dialog if _dialog is None: _dialog = _create() diff --git a/lib/solaar/ui/action.py b/lib/solaar/ui/action.py index b99683fd..4ce6e89f 100644 --- a/lib/solaar/ui/action.py +++ b/lib/solaar/ui/action.py @@ -43,10 +43,11 @@ about = make('help-about', 'About ' + NAME, _show_about_window) # from . import pair_window -def _pair_device(action, frame): - window = frame.get_toplevel() +def pair(window, receiver): + assert receiver is not None + assert receiver.kind is None - pair_dialog = pair_window.create(action, frame._device) + pair_dialog = pair_window.create(receiver) pair_dialog.set_transient_for(window) pair_dialog.set_destroy_with_parent(True) pair_dialog.set_modal(True) @@ -54,14 +55,12 @@ def _pair_device(action, frame): pair_dialog.set_position(Gtk.WindowPosition.CENTER) pair_dialog.present() -def pair(frame): - return make('list-add', 'Pair new device', _pair_device, frame) - from ..ui import error_dialog -def _unpair_device(action, frame): - window = frame.get_toplevel() - device = frame._device +def unpair(window, device): + assert device is not None + assert device.kind is not None + qdialog = Gtk.MessageDialog(window, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, "Unpair device\n%s ?" % device.name) @@ -71,10 +70,11 @@ def _unpair_device(action, frame): choice = qdialog.run() qdialog.destroy() if choice == Gtk.ResponseType.ACCEPT: - try: - del device.receiver[device.number] - except: - error_dialog(window, 'Unpairing failed', 'Failed to unpair device\n%s .' % device.name) + receiver = device.receiver + assert receiver + device_number = device.number -def unpair(frame): - return make('edit-delete', 'Unpair', _unpair_device, frame) + try: + del receiver[device_number] + except: + error_dialog('unpair', device) diff --git a/lib/solaar/ui/config_panel.py b/lib/solaar/ui/config_panel.py index d3950e68..61dd6176 100644 --- a/lib/solaar/ui/config_panel.py +++ b/lib/solaar/ui/config_panel.py @@ -81,54 +81,53 @@ def _combo_notify(cbbox, setting, spinner): # return True -def _add_settings(box, device): - for s in device.settings: - sbox = Gtk.HBox(homogeneous=False, spacing=8) - sbox.pack_start(Gtk.Label(s.label), False, False, 0) +def _create_sbox(s): + sbox = Gtk.HBox(homogeneous=False, spacing=8) + sbox.pack_start(Gtk.Label(s.label), False, False, 0) - spinner = Gtk.Spinner() - spinner.set_tooltip_text('Working...') + spinner = Gtk.Spinner() + spinner.set_tooltip_text('Working...') - failed = Gtk.Image.new_from_icon_name('dialog-warning', Gtk.IconSize.SMALL_TOOLBAR) - failed.set_tooltip_text('Failed to read value from the device.') + failed = Gtk.Image.new_from_icon_name('dialog-warning', Gtk.IconSize.SMALL_TOOLBAR) + failed.set_tooltip_text('Failed to read value from the device.') - if s.kind == _settings.KIND.toggle: - control = Gtk.Switch() - control.connect('notify::active', _switch_notify, s, spinner) - elif s.kind == _settings.KIND.choice: - control = Gtk.ComboBoxText() - for entry in s.choices: - control.append(str(entry), str(entry)) - control.connect('changed', _combo_notify, s, spinner) - # elif s.kind == _settings.KIND.range: - # first, second = s.choices[:2] - # last = s.choices[-1:][0] - # control = Gtk.HScale.new_with_range(first, last, second - first) - # control.set_draw_value(False) - # control.set_has_origin(False) - # for entry in s.choices: - # control.add_mark(int(entry), Gtk.PositionType.TOP, str(entry)) - # control.connect('change-value', _snap_to_markers, s) - # control.connect('value-changed', _scale_notify, s, spinner) - else: - raise NotImplemented + if s.kind == _settings.KIND.toggle: + control = Gtk.Switch() + control.connect('notify::active', _switch_notify, s, spinner) + elif s.kind == _settings.KIND.choice: + control = Gtk.ComboBoxText() + for entry in s.choices: + control.append(str(entry), str(entry)) + control.connect('changed', _combo_notify, s, spinner) + # elif s.kind == _settings.KIND.range: + # first, second = s.choices[:2] + # last = s.choices[-1:][0] + # control = Gtk.HScale.new_with_range(first, last, second - first) + # control.set_draw_value(False) + # control.set_has_origin(False) + # for entry in s.choices: + # control.add_mark(int(entry), Gtk.PositionType.TOP, str(entry)) + # control.connect('change-value', _snap_to_markers, s) + # control.connect('value-changed', _scale_notify, s, spinner) + else: + raise NotImplemented - control.set_sensitive(False) # the first read will enable it - sbox.pack_end(control, False, False, 0) - sbox.pack_end(spinner, False, False, 0) - sbox.pack_end(failed, False, False, 0) + control.set_sensitive(False) # the first read will enable it + sbox.pack_end(control, False, False, 0) + sbox.pack_end(spinner, False, False, 0) + sbox.pack_end(failed, False, False, 0) - if s.description: - sbox.set_tooltip_text(s.description) + if s.description: + sbox.set_tooltip_text(s.description) - sbox.show_all() - spinner.start() # the first read will stop it - failed.set_visible(False) - box.pack_start(sbox, False, False, 0) - yield sbox + sbox.show_all() + spinner.start() # the first read will stop it + failed.set_visible(False) + + return sbox -def _update_setting_item(sbox, value): +def _update_setting_item(sbox, value, is_active=True): _, failed, spinner, control = sbox.get_children() spinner.set_visible(False) spinner.stop() @@ -136,7 +135,7 @@ def _update_setting_item(sbox, value): # print ("update", control, "with new value", value) if value is None: control.set_sensitive(False) - failed.set_visible(True) + failed.set_visible(is_active) return failed.set_visible(False) @@ -156,45 +155,43 @@ def _update_setting_item(sbox, value): def create(): b = Gtk.VBox(homogeneous=False, spacing=4) - b.set_property('margin', 8) + # b.set_property('margin', 8) + b._last_device = None + b._items = {} return b -def update(frame): - box = frame._config_box - assert box - device = frame._device +def update(box, device, is_active): + assert box is not None + assert device is not None - if device is None: - # remove all settings widgets - # if another device gets paired here, it will add its own widgets - _remove_children(box) - return + # if the device changed since last update, clear the box first + if not box._last_device: + box._last_device = None + if device.serial != box._last_device: + box.set_visible(False) - if not box.get_visible(): - # no point in doing this right now, is there? - return - - if not device.settings: - # nothing to do here - return - - force_read = False - items = box.get_children() - if len(device.settings) != len(items): - _remove_children(box) - if device.status: - items = list(_add_settings(box, device)) - assert len(device.settings) == len(items) - # force_read = True - - device_active = bool(device.status) # if the device just became active, re-read the settings - # force_read |= device_active and not box.get_sensitive() - box.set_sensitive(device_active) - if device_active: - for sbox, s in zip(items, device.settings): - _apply_queue.put(('read', s, force_read, sbox)) + box.foreach(lambda x, s: x.set_visible(x.get_name() == s), device.serial) + + if device.serial != box._last_device: + box._last_device = device.serial + box.set_visible(True) + + for s in device.settings: + k = device.serial + '_' + s.name + if k not in box._items: + sbox = _create_sbox(s) + sbox.set_name(device.serial) + box._items[k] = sbox + box.pack_start(sbox, False, False, 0) + else: + sbox = box._items[k] + + if is_active: + _apply_queue.put(('read', s, False, sbox)) + else: + _update_setting_item(sbox, None, False) def _remove_children(container): diff --git a/lib/solaar/ui/pair_window.py b/lib/solaar/ui/pair_window.py index e3464fba..267a7506 100644 --- a/lib/solaar/ui/pair_window.py +++ b/lib/solaar/ui/pair_window.py @@ -175,10 +175,13 @@ def _pairing_succeeded(assistant, receiver, device): assistant.commit() -def create(action, receiver): +def create(receiver): + assert receiver is not None + assert receiver.kind is None + assistant = Gtk.Assistant() - assistant.set_title(action.get_label()) - assistant.set_icon_name(action.get_icon_name()) + assistant.set_title(receiver.name + ': pair new device') + assistant.set_icon_name('list-add') assistant.set_size_request(400, 240) assistant.set_resizable(False) diff --git a/lib/solaar/ui/status_icon.py b/lib/solaar/ui/tray.py similarity index 55% rename from lib/solaar/ui/status_icon.py rename to lib/solaar/ui/tray.py index c4d340a4..f0d048b9 100644 --- a/lib/solaar/ui/status_icon.py +++ b/lib/solaar/ui/tray.py @@ -14,8 +14,15 @@ 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 +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 @@ -29,13 +36,8 @@ _picked_device = None -def _create_common(icon, menu_activate_callback): - icon._devices_info = [] - - icon.set_title(NAME) - - icon._menu_activate_callback = menu_activate_callback - icon._menu = menu = Gtk.Menu() +def _create_menu(): + menu = Gtk.Menu() # per-device menu entries will be generated as-needed @@ -44,12 +46,18 @@ def _create_common(icon, menu_activate_callback): menu.append(no_receiver) 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()) + 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") @@ -61,7 +69,7 @@ try: # ignore all other directions return - if len(ind._devices_info) < 4: + 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 @@ -81,14 +89,14 @@ try: candidate = None if _picked_device is None: - for info in ind._devices_info: + for info in _devices_info: # pick first peripheral found if info[1] is not None: candidate = info break else: found = False - for info in ind._devices_info: + for info in _devices_info: if not info[1]: # only conside peripherals continue @@ -122,13 +130,10 @@ try: _picked_device = candidate or _picked_device if _log.isEnabledFor(_DEBUG): _log.debug("scroll: picked %s", _picked_device) - _update_tray_icon(ind) + _update_tray_icon() - def create(activate_callback, menu_activate_callback): - assert activate_callback - assert menu_activate_callback - + def _create(menu): theme_paths = Gtk.IconTheme.get_default().get_search_path() ind = AppIndicator3.Indicator.new_with_path( @@ -136,39 +141,38 @@ try: _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) - _create_common(ind, menu_activate_callback) - ind.set_menu(ind._menu) - + ind.set_menu(menu) ind.connect('scroll-event', _scroll) return ind - def destroy(ind): - ind.set_status(AppIndicator3.IndicatorStatus.PASSIVE) + def _destroy(indicator): + indicator.set_status(AppIndicator3.IndicatorStatus.PASSIVE) - def _update_tray_icon(ind): + def _update_tray_icon(): if _picked_device: _, _, name, _, device_status = _picked_device - battery_level = device_status.get(_status.BATTERY_LEVEL) - battery_charging = device_status.get(_status.BATTERY_CHARGING) + 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 ind._devices_info else _icons.TRAY_INIT + tray_icon_name = _icons.TRAY_OKAY if _devices_info else _icons.TRAY_INIT - tooltip_lines = _generate_tooltip_lines(ind._devices_info) + tooltip_lines = _generate_tooltip_lines() description = '\n'.join(tooltip_lines).rstrip('\n') # icon_file = _icons.icon_file(icon_name, _TRAY_ICON_SIZE) - ind.set_icon_full(tray_icon_name, description) + _icon.set_icon_full(tray_icon_name, description) def _update_menu_icon(image_widget, icon_name): @@ -178,52 +182,48 @@ try: # 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) + 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(activate_callback, menu_activate_callback): - assert activate_callback - assert menu_activate_callback - + 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', activate_callback) + icon.connect('activate', _window_toggle) - _create_common(icon, menu_activate_callback) icon.connect('popup_menu', - lambda icon, button, time, menu: - icon._menu.popup(None, None, icon.position_menu, icon, button, time), - icon._menu) + lambda icon, button, time: + menu.popup(None, None, icon.position_menu, icon, button, time)) return icon - def destroy(icon): + def _destroy(icon): icon.set_visible(False) - def _update_tray_icon(icon): - tooltip_lines = _generate_tooltip_lines(icon._devices_info) + def _update_tray_icon(): + tooltip_lines = _generate_tooltip_lines() tooltip = '\n'.join(tooltip_lines).rstrip('\n') - icon.set_tooltip_markup(tooltip) + _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) + 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 icon._devices_info else _icons.TRAY_ATTENTION - icon.set_from_icon_name(tray_icon_name) + 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): @@ -232,35 +232,35 @@ except ImportError: _icon_before_attention = None - def _blink(icon, count): + def _blink(count): global _icon_before_attention if count % 2: - icon.set_from_icon_name(_icons.TRAY_ATTENTION) + _icon.set_from_icon_name(_icons.TRAY_ATTENTION) else: - icon.set_from_icon_name(_icon_before_attention) + _icon.set_from_icon_name(_icon_before_attention) if count > 0: - GLib.timeout_add(1000, _blink, icon, count - 1) + GLib.timeout_add(1000, _blink, count - 1) - def attention(icon, reason=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, icon, 9) + _icon_before_attention = _icon.get_icon_name() + GLib.idle_add(_blink, 9) # # # -def _generate_tooltip_lines(devices_info): - if not devices_info: +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: + for _, serial, name, _, status in _devices_info: if serial is None: # receiver continue @@ -280,17 +280,17 @@ def _generate_tooltip_lines(devices_info): yield '' -def _pick_device_with_lowest_battery(devices_info): - if not devices_info: +def _pick_device_with_lowest_battery(): + if not _devices_info: return None picked = None picked_level = 1000 - for info in devices_info: + for info in _devices_info: if info[1] is None: # is receiver/separator continue - level = info[-1].get(_status.BATTERY_LEVEL) + 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 @@ -305,10 +305,15 @@ def _pick_device_with_lowest_battery(devices_info): # # -def _add_device(icon, device): +def _add_device(device): + assert device + assert device.receiver + receiver_path = device.receiver.path + assert receiver_path + index = None - for idx, (rserial, _, _, _, _) in enumerate(icon._devices_info): - if rserial == device.receiver.serial: + 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 @@ -316,89 +321,86 @@ def _add_device(icon, device): # proper ordering (according to device.number) for a receiver's devices while True: - rserial, _, _, number, _ = icon._devices_info[index] - if rserial == '-': + path, _, _, number, _ = _devices_info[index] + if path == '-': break - assert rserial == device.receiver.serial + assert path == receiver_path assert number != device.number if number > device.number: break index = index + 1 - device_info = (device.receiver.serial, device.serial, device.name, device.number, device.status) - icon._devices_info.insert(index, device_info) - - # print ("status_icon: added", index, ":", device_info) + 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 = ' ' - 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) + 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(icon, index): +def _remove_device(index): assert index is not None - menu_items = icon._menu.get_children() - icon._menu.remove(menu_items[index]) + menu_items = _menu.get_children() + _menu.remove(menu_items[index]) - removed_device = icon._devices_info.pop(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(icon, receiver): - device_info = (receiver.serial, None, receiver.name, None, None) - icon._devices_info.insert(0, device_info) +def _add_receiver(receiver): + device_info = (receiver.path, None, receiver.name, None, None) + _devices_info.insert(0, device_info) - menu_item = Gtk.ImageMenuItem.new_with_label(receiver.name) - icon._menu.insert(menu_item, 0) + new_menu_item = Gtk.ImageMenuItem.new_with_label(receiver.name) + _menu.insert(new_menu_item, 0) icon_set = _icons.device_icon_set(receiver.name) - menu_item.set_image(Gtk.Image().new_from_icon_set(icon_set, _MENU_ICON_SIZE)) - menu_item.show_all() - menu_item.connect('activate', icon._menu_activate_callback, receiver.path, icon) + 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) - icon._devices_info.insert(1, ('-', None, None, None, None)) + _devices_info.insert(1, ('-', None, None, None, None)) separator = Gtk.SeparatorMenuItem.new() separator.set_visible(True) - icon._menu.insert(separator, 1) + _menu.insert(separator, 1) return 0 -def _remove_receiver(icon, receiver): +def _remove_receiver(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] - if rserial == receiver.serial: + while index < len(_devices_info): + path, _, _, _, _ = _devices_info[index] + if path == receiver.path: found = True - _remove_device(icon, index) - elif found and rserial == '-': + _remove_device(index) + elif found and path == '-': # the separator after this receiver - _remove_device(icon, index) + _remove_device(index) break else: index += 1 -def _update_menu_item(icon, index, device_status): - menu_items = icon._menu.get_children() +def _update_menu_item(index, device_status): + menu_items = _menu.get_children() menu_item = menu_items[index] - level = device_status.get(_status.BATTERY_LEVEL) - charging = device_status.get(_status.BATTERY_CHARGING) + level = device_status.get(_BATTERY_LEVEL) + charging = device_status.get(_BATTERY_CHARGING) icon_name = _icons.battery(level, charging) image_widget = menu_item.get_image() @@ -409,46 +411,72 @@ def _update_menu_item(icon, index, device_status): # # -def update(icon, device=None): +_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, (rserial, _, _, _, _) in enumerate(icon._devices_info): - if rserial == receiver.serial: + for idx, (path, _, _, _, _) in enumerate(_devices_info): + if path == receiver_path: index = idx break if index is None: - _add_receiver(icon, receiver) + _add_receiver(receiver) else: - _remove_receiver(icon, receiver) + _remove_receiver(receiver) else: + receiver_path = device.receiver.path # peripheral index = None - for idx, (rserial, serial, name, _, _) in enumerate(icon._devices_info): - if rserial == device.receiver.serial and serial == device.serial: + 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(icon, index) + _remove_device(index) else: if index is None: - index = _add_device(icon, device) - _update_menu_item(icon, index, device.status) + index = _add_device(device) + _update_menu_item(index, device.status) - menu_items = icon._menu.get_children() - no_receivers_index = len(icon._devices_info) - menu_items[no_receivers_index].set_visible(not icon._devices_info) - menu_items[no_receivers_index + 1].set_visible(not icon._devices_info) + 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(icon._devices_info) + _picked_device = _pick_device_with_lowest_battery() - _update_tray_icon(icon) + _update_tray_icon() diff --git a/lib/solaar/ui/window.py b/lib/solaar/ui/window.py new file mode 100644 index 00000000..18cfa51e --- /dev/null +++ b/lib/solaar/ui/window.py @@ -0,0 +1,674 @@ +# +# +# + +from __future__ import absolute_import, division, print_function, unicode_literals + +from logging import getLogger +_log = getLogger(__name__) +del getLogger + +from gi.repository import Gtk, Gdk +from gi.repository.GObject import TYPE_PYOBJECT + +from solaar import NAME +# from solaar import __version__ as VERSION +from logitech.unifying_receiver import hidpp10 as _hidpp10 +from logitech.unifying_receiver.common import NamedInts as _NamedInts +from logitech.unifying_receiver.status import ( + BATTERY_LEVEL as _BATTERY_LEVEL, + BATTERY_CHARGING as _BATTERY_CHARGING, + LIGHT_LEVEL as _LIGHT_LEVEL, + ENCRYPTED as _ENCRYPTED, + ) +from . import config_panel as _config_panel +from . import action as _action, icons as _icons +from .about import show_window as _show_about_window + + +# +# constants +# + +_SMALL_BUTTON_ICON_SIZE = Gtk.IconSize.MENU +_NORMAL_BUTTON_ICON_SIZE = Gtk.IconSize.BUTTON +_TREE_ICON_SIZE = Gtk.IconSize.BUTTON +_INFO_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR +_DEVICE_ICON_SIZE = Gtk.IconSize.DND + +# tree model columns +_COLUMN = _NamedInts(ID=0, ACTIVE=1, NAME=2, ICON=3, STATUS_ICON=4, DEVICE=5) +_COLUMN_TYPES = (str, bool, str, str, str, TYPE_PYOBJECT) +_TREE_SEPATATOR = (None, False, None, None, None, None) + +_TOOLTIP_LINK_SECURE = 'The wireless link between this device and its receiver is not encrypted.' +_TOOLTIP_LINK_INSECURE = ('The wireless link between this device and its receiver is not encrypted.\n' + '\n' + 'For pointing devices (mice, trackballs, trackpads), this is a minor security issue.\n' + '\n' + 'It is, however, a major security issue for text-input devices (keyboards, numpads),\n' + 'because typed text can be sniffed inconspicuously by 3rd parties within range.') + +_UNIFYING_RECEIVER_TEXT = ( + 'No paired devices.\n\nUp to %d devices can be paired to this receiver.', + '%d paired device(s).\n\nUp to %d devices can be paired to this receiver.', + ) +_NANO_RECEIVER_TEXT = ( + 'No paired device.\n\n \n ', + ' \n\nOnly one device can be paired to this receiver.', + ) + +# +# create UI layout +# + +Gtk.Window.set_default_icon_name(NAME.lower()) +Gtk.Window.set_default_icon_from_file(_icons.icon_file(NAME.lower(), 32)) + +def _new_button(label, icon_name=None, icon_size=_NORMAL_BUTTON_ICON_SIZE, tooltip=None, toggle=False, clicked=None): + if toggle: + b = Gtk.ToggleButton() + else: + b = Gtk.Button(label) if label else Gtk.Button() + + if icon_name: + image = Gtk.Image.new_from_icon_name(icon_name, icon_size) + b.set_image(image) + + if tooltip: + b.set_tooltip_text(tooltip) + + if not label and icon_size < _NORMAL_BUTTON_ICON_SIZE: + b.set_relief(Gtk.ReliefStyle.NONE) + b.set_focus_on_click(False) + + if clicked is not None: + b.connect('clicked', clicked) + + return b + + +def _create_receiver_panel(): + p = Gtk.Box.new(Gtk.Orientation.VERTICAL, 4) + + p._count = Gtk.Label() + p._count.set_padding(32, 0) + p._count.set_alignment(0, 0.5) + p.pack_start(p._count, True, True, 0) + + p._scanning = Gtk.Label('Scanning...') + p._spinner = Gtk.Spinner() + + bp = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 8) + bp.pack_start(Gtk.Label(' '), True, True, 0) + bp.pack_start(p._scanning, False, False, 0) + bp.pack_end(p._spinner, False, False, 0) + p.pack_end(bp, False, False, 0) + + return p + + +def _create_device_panel(): + p = Gtk.Box.new(Gtk.Orientation.VERTICAL, 4) + + def _status_line(label_text): + b = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 8) + b.set_size_request(10, 28) + + b._label = Gtk.Label(label_text) + b._label.set_alignment(0, 0.5) + b._label.set_size_request(170, 10) + b.pack_start(b._label, False, False, 0) + + b._icon = Gtk.Image() + b.pack_start(b._icon, False, False, 0) + + b._text = Gtk.Label() + b._text.set_alignment(0, 0.5) + b.pack_start(b._text, True, True, 0) + + return b + + p._battery = _status_line('Battery') + p.pack_start(p._battery, False, False, 0) + + p._secure = _status_line('Wireless Link') + p._secure._icon.set_from_icon_name('dialog-warning', _INFO_ICON_SIZE) + p.pack_start(p._secure, False, False, 0) + + p._lux = _status_line('Lighting') + p.pack_start(p._lux, False, False, 0) + + p._config = _config_panel.create() + p.pack_end(p._config, False, False, 8) + + return p + + +def _create_details_panel(): + p = Gtk.Frame() + # p.set_border_width(2) + p.set_shadow_type(Gtk.ShadowType.ETCHED_OUT) + + p._text = Gtk.Label() + p._text.set_padding(4, 4) + p._text.set_alignment(0, 0) + p._text.set_selectable(True) + p.add(p._text) + + return p + + +def _create_buttons_box(): + bb = Gtk.ButtonBox(Gtk.Orientation.HORIZONTAL) + bb.set_layout(Gtk.ButtonBoxStyle.END) + + bb._details = _new_button(None, 'dialog-information', _SMALL_BUTTON_ICON_SIZE, + tooltip='Show Details', toggle=True, clicked=_update_details) + bb.add(bb._details) + bb.set_child_secondary(bb._details, True) + bb.set_child_non_homogeneous(bb._details, True) + + def _pair_new_device(trigger): + receiver = _find_selected_device() + assert receiver is not None + assert receiver.kind is None + _action.pair(_window, receiver) + + bb._pair = _new_button('Pair new device', 'list-add', clicked=_pair_new_device) + bb.add(bb._pair) + + def _unpair_current_device(trigger): + device = _find_selected_device() + assert device is not None + assert device.kind is not None + _action.unpair(_window, device) + + bb._unpair = _new_button('Unpair', 'edit-delete', clicked=_unpair_current_device) + bb.add(bb._unpair) + + return bb + + +def _create_empty_panel(): + p = Gtk.Label() + p.set_markup('Select a device') + p.set_sensitive(False) + + return p + + +def _create_info_panel(): + p = Gtk.Box.new(Gtk.Orientation.VERTICAL, 4) + + p._title = Gtk.Label(' ') + p._title.set_alignment(0, 0.5) + p._icon = Gtk.Image() + + b1 = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 4) + b1.pack_start(p._title, True, True, 0) + b1.pack_start(p._icon, False, False, 0) + p.pack_start(b1, False, False, 0) + + p.pack_start(Gtk.Separator.new(Gtk.Orientation.HORIZONTAL), False, False, 0) # spacer + + p._receiver = _create_receiver_panel() + p.pack_start(p._receiver, True, True, 0) + + p._device = _create_device_panel() + p.pack_start(p._device, True, True, 0) + + p.pack_start(Gtk.Separator.new(Gtk.Orientation.HORIZONTAL), False, False, 0) # spacer + + p._buttons = _create_buttons_box() + p.pack_end(p._buttons, False, False, 0) + + return p + + +def _create_tree(model): + tree = Gtk.TreeView() + tree.set_size_request(240, 120) + tree.set_headers_visible(False) + tree.set_show_expanders(False) + tree.set_level_indentation(16) + tree.set_enable_tree_lines(True) + # tree.set_rules_hint(True) + tree.set_model(model) + + def _is_separator(model, item, _=None): + return model.get_value(item, _COLUMN.ID) is None + tree.set_row_separator_func(_is_separator, None) + + icon_cell_renderer = Gtk.CellRendererPixbuf() + icon_cell_renderer.set_property('stock-size', _TREE_ICON_SIZE) + icon_column = Gtk.TreeViewColumn('Icon', icon_cell_renderer) + icon_column.add_attribute(icon_cell_renderer, 'sensitive', _COLUMN.ACTIVE) + icon_column.add_attribute(icon_cell_renderer, 'icon-name', _COLUMN.ICON) + icon_column.set_fixed_width(1) + tree.append_column(icon_column) + + name_cell_renderer = Gtk.CellRendererText() + name_column = Gtk.TreeViewColumn('Name', name_cell_renderer) + name_column.add_attribute(name_cell_renderer, 'sensitive', _COLUMN.ACTIVE) + name_column.add_attribute(name_cell_renderer, 'text', _COLUMN.NAME) + name_column.set_expand(True) + tree.append_column(name_column) + tree.set_expander_column(name_column) + + battery_cell_renderer = Gtk.CellRendererPixbuf() + battery_cell_renderer.set_property('stock-size', _TREE_ICON_SIZE) + battery_column = Gtk.TreeViewColumn('Status', battery_cell_renderer) + battery_column.add_attribute(battery_cell_renderer, 'sensitive', _COLUMN.ACTIVE) + battery_column.add_attribute(battery_cell_renderer, 'icon-name', _COLUMN.STATUS_ICON) + battery_column.set_fixed_width(1) + tree.append_column(battery_column) + + return tree + + +def _create_window_layout(): + assert _tree is not None + assert _details is not None + assert _info is not None + assert _empty is not None + + assert _tree.get_selection().get_mode() == Gtk.SelectionMode.SINGLE + _tree.get_selection().connect('changed', _device_selected) + + tree_panel = Gtk.Box.new(Gtk.Orientation.VERTICAL, 4) + tree_panel.pack_start(_tree, True, True, 0) + tree_panel.pack_start(_details, True, True, 0) + + panel = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 16) + panel.pack_start(tree_panel, False, False, 0) + panel.pack_start(_info, True, True, 0) + panel.pack_start(_empty, True, True, 0) + + about_button = _new_button('About', 'help-about', clicked=_show_about_window) + # about_button.set_relief(Gtk.ReliefStyle.NONE) + + bottom_buttons_box = Gtk.ButtonBox(Gtk.Orientation.HORIZONTAL) + bottom_buttons_box.set_layout(Gtk.ButtonBoxStyle.START) + bottom_buttons_box.add(about_button) + + # solaar_version = Gtk.Label() + # solaar_version.set_markup('' + NAME + ' v' + VERSION + '') + # bottom_buttons_box.add(solaar_version) + # bottom_buttons_box.set_child_secondary(solaar_version, True) + + vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 8) + vbox.set_border_width(8) + vbox.pack_start(panel, True, True, 0) + vbox.pack_end(bottom_buttons_box, False, False, 0) + vbox.show_all() + + _details.set_visible(False) + _info.set_visible(False) + return vbox + + +def _create(): + window = Gtk.Window() + window.set_title(NAME) + window.set_role('status-window') + + # window.set_type_hint(Gdk.WindowTypeHint.UTILITY) + window.set_skip_taskbar_hint(True) + window.set_skip_pager_hint(True) + window.set_keep_above(True) + window.connect('delete-event', _hide) + + vbox = _create_window_layout() + window.add(vbox) + + geometry = Gdk.Geometry() + geometry.min_width = 600 + geometry.min_height = 320 + geometry.max_width = 800 + geometry.max_height = 600 + window.set_geometry_hints(vbox, geometry, Gdk.WindowHints.MIN_SIZE | Gdk.WindowHints.MAX_SIZE) + window.set_position(Gtk.WindowPosition.CENTER) + + return window + +# +# window updates +# + +def _find_selected_device(): + selection = _tree.get_selection() + model, item = selection.get_selected() + device = model.get_value(item, _COLUMN.DEVICE) if item else None + return device or None + + +# triggered by changing selection in the tree +def _device_selected(selection): + model, item = selection.get_selected() + device = model.get_value(item, _COLUMN.DEVICE) if item else None + _update_info_panel(device, full=True) + + +def _receiver_row(receiver_path, receiver=None): + item = _model.get_iter_first() + while item: + if _model.get_value(item, _COLUMN.ID) == receiver_path: + return item + item = _model.iter_next(item) + + if not item and receiver is not None: + _model.insert(None, 0, _TREE_SEPATATOR) + row_data = (receiver_path, True, receiver.name, _icons.device_icon_name(receiver.name), '', receiver) + item = _model.insert(None, 0, row_data) + + return item or None + + +def _device_row(receiver_path, device_serial, device=None): + receiver_row = _receiver_row(receiver_path, None if device is None else device.receiver) + item = _model.iter_children(receiver_row) + while item: + if _model.get_value(item, _COLUMN.ID) == device_serial: + return item + item = _model.iter_next(item) + + if not item and device is not None: + # print ("new device row", device) + row_data = (device_serial, bool(device.status), device.codename, _icons.device_icon_name(device.name, device.kind), '', device) + item = _model.append(receiver_row, row_data) + + return item or None + +# +# +# + +def select(receiver_path, device_id=None): + assert _window + assert receiver_path is not None + if device_id is None: + item = _receiver_row(receiver_path) + else: + item = _device_row(receiver_path, device_id) + if item: + selection = _tree.get_selection() + selection.select_iter(item) + + +def _hide(w, _=None): + assert w == _window + # some window managers move the window to 0,0 after hide() + # so try to remember the last position + position = _window.get_position() + _window.hide() + _window.move(*position) + return True + + +def popup(trigger=None, receiver_path=None, device_id=None): + if receiver_path: + select(receiver_path, device_id) + _window.present() + return True + + +def toggle(trigger=None): + if _window.get_visible(): + _hide(_window) + else: + _window.present() + +# +# +# + +def _update_details(button): + assert button + visible = button.get_active() + device = _find_selected_device() + + if visible: + _details._text.set_markup('reading...') + + def _details_items(device): + if device.kind is None: + yield ('Path', device.path) + else: + hid = device.protocol + yield ('Protocol', 'HID++ %1.1f' % hid if hid else 'unknown') + if device.polling_rate: + yield ('Polling rate', '%d ms' % device.polling_rate) + yield ('Wireless PID', device.wpid) + + yield ('Serial', device.serial) + + for fw in device.firmware: + yield (fw.kind, (fw.name + ' ' + fw.version).strip()) + + if device.kind is None or device.status: + # don't show notifications for offline devices + notification_flags = _hidpp10.get_notification_flags(device) + if notification_flags: + notification_flags = _hidpp10.NOTIFICATION_FLAG.flag_names(notification_flags) + else: + notification_flags = ('(none)',) + + yield ('Notifications', ('\n%16s' % ' ').join(notification_flags)) + + items = _details_items(device) + markup_text = '' + '\n'.join('%-14s: %s' % i for i in items if i) + '' + _details._text.set_markup(markup_text) + + _details.set_visible(visible) + + +def _update_receiver_panel(receiver, panel, buttons, full=False): + devices_count = len(receiver) + if receiver.max_devices > 1: + if devices_count == 0: + panel._count.set_markup(_UNIFYING_RECEIVER_TEXT[0] % receiver.max_devices) + else: + panel._count.set_markup(_UNIFYING_RECEIVER_TEXT[1] % (devices_count, receiver.max_devices)) + else: + if devices_count == 0: + panel._count.set_text(_NANO_RECEIVER_TEXT[0]) + else: + panel._count.set_markup(_NANO_RECEIVER_TEXT[1]) + + is_pairing = receiver and receiver.status.lock_open + if is_pairing: + panel._scanning.set_visible(True) + if not panel._spinner.get_visible(): + panel._spinner.start() + panel._spinner.set_visible(True) + else: + panel._scanning.set_visible(False) + if panel._spinner.get_visible(): + panel._spinner.stop() + panel._spinner.set_visible(False) + + panel.set_visible(True) + + # b._insecure.set_visible(False) + buttons._unpair.set_visible(False) + buttons._pair.set_sensitive(devices_count < receiver.max_devices and not is_pairing) + buttons._pair.set_visible(True) + + +def _update_device_panel(device, panel, buttons, full=False): + is_active = bool(device.status) + panel.set_sensitive(is_active) + + battery_level = device.status.get(_BATTERY_LEVEL) + if battery_level is None: + icon_name = _icons.battery() + panel._battery._icon.set_sensitive(False) + panel._battery._icon.set_from_icon_name(icon_name, _INFO_ICON_SIZE) + panel._battery._text.set_sensitive(True) + panel._battery._text.set_markup('unknown') + else: + charging = device.status.get(_BATTERY_CHARGING) + icon_name = _icons.battery(battery_level, charging) + panel._battery._icon.set_from_icon_name(icon_name, _INFO_ICON_SIZE) + panel._battery._icon.set_sensitive(True) + + text = '%d%%' % battery_level + if bool(device.status): + if charging: + text += ' (charging)' + panel._battery._text.set_sensitive(True) + else: + text += ' (last known)' + panel._battery._text.set_sensitive(False) + panel._battery._text.set_markup(text) + + if is_active: + not_secure = device.status.get(_ENCRYPTED) == False + if not_secure: + panel._secure._text.set_text('not encrypted') + panel._secure._icon.set_from_icon_name('security-low', _INFO_ICON_SIZE) + panel._secure.set_tooltip_text(_TOOLTIP_LINK_INSECURE) + else: + panel._secure._text.set_text('encrypted') + panel._secure._icon.set_from_icon_name('security-high', _INFO_ICON_SIZE) + panel._secure.set_tooltip_text(_TOOLTIP_LINK_SECURE) + panel._secure._icon.set_visible(True) + else: + panel._secure._text.set_markup('offline') + panel._secure._icon.set_visible(False) + panel._secure.set_tooltip_text('') + + if is_active: + light_level = device.status.get(_LIGHT_LEVEL) + if light_level is None: + panel._lux.set_visible(False) + else: + panel._lux._icon.set_from_icon_name(_icons.lux(light_level), _INFO_ICON_SIZE) + panel._lux._text.set_text('%d lux' % light_level) + panel._lux.set_visible(True) + else: + panel._lux.set_visible(False) + + buttons._pair.set_visible(False) + buttons._unpair.set_visible(True) + + panel.set_visible(True) + + if full: + _config_panel.update(panel._config, device, is_active) + + +def _update_info_panel(device, full=False): + if device is None: + _details.set_visible(False) + _info.set_visible(False) + _empty.set_visible(True) + return + + is_active = bool(device.status) + + _info._title.set_markup('%s' % device.name) + _info._title.set_sensitive(is_active) + icon_name = _icons.device_icon_name(device.name, device.kind) + _info._icon.set_from_icon_name(icon_name, _DEVICE_ICON_SIZE) + _info._icon.set_sensitive(is_active) + + if device.kind is None: + _info._device.set_visible(False) + _update_receiver_panel(device, _info._receiver, _info._buttons, full) + else: + _info._receiver.set_visible(False) + _update_device_panel(device, _info._device, _info._buttons, full) + + _empty.set_visible(False) + _info.set_visible(True) + + if full: + _update_details(_info._buttons._details) + +# +# window layout: +# +--------------------------------+ +# | tree | receiver | empty | +# | | or device | | +# |------------| status | | +# | details | | | +# |--------------------------------| +# | (about) | +# +--------------------------------| +# either the status or empty panel is visible at any point +# the details panel can be toggle on/off + +_model = None +_tree = None +_details = None +_info = None +_empty = None +_window = None + + +def init(): + global _model, _tree, _details, _info, _empty, _window + _model = Gtk.TreeStore(*_COLUMN_TYPES) + _tree = _create_tree(_model) + _details = _create_details_panel() + _info = _create_info_panel() + _empty = _create_empty_panel() + _window = _create() + + +def destroy(): + global _model, _tree, _details, _info, _empty, _window + w, _window = _window, None + w.destroy() + w = None + + _empty = None + _info = None + _details = None + _tree = None + _model = None + + +def update(device, need_popup=False): + if _window is None: + return + + assert device is not None + + if need_popup: + popup() + + if device.kind is None: + is_alive = bool(device) + item = _receiver_row(device.path, device if is_alive else None) + assert item + if is_alive: + _model.set_value(item, _COLUMN.ACTIVE, True) + elif item: + separator = _model.iter_next(item) + _model.remove(separator) + _model.remove(item) + + is_pairing = is_alive and device.status.lock_open + _model.set_value(item, _COLUMN.STATUS_ICON, 'network-wireless' if is_pairing else '') + + else: + is_alive = device.status is not None + item = _device_row(device.receiver.path, device.serial, device if is_alive else None) + assert item + _model.set_value(item, _COLUMN.ACTIVE, bool(device.status)) + battery_level = device.status.get(_BATTERY_LEVEL) + if battery_level is None: + charging = device.status.get(_BATTERY_CHARGING) + battery_icon_name = _icons.battery(battery_level, charging) + _model.set_value(item, _COLUMN.STATUS_ICON, '' if battery_level is None else battery_icon_name) + + # make sure all rows are visible + _tree.expand_all() + selected_device = _find_selected_device() + if device == selected_device: + _update_info_panel(device, need_popup) + elif selected_device is None and device.kind is not None: + select(device.receiver.path, device.serial)