From c8fe87ee2d322c27c00b09c13bc25d441168e31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius?= Date: Mon, 31 Aug 2020 03:05:06 -0300 Subject: [PATCH] receiver: implementation of GESTURE 2 params; improved UI for multiple toggle --- lib/logitech_receiver/hidpp20.py | 58 +++++- lib/logitech_receiver/settings.py | 174 ++++++++++++++++- lib/logitech_receiver/settings_templates.py | 28 ++- lib/solaar/ui/config_panel.py | 206 +++++++++++++++++--- 4 files changed, 424 insertions(+), 42 deletions(-) diff --git a/lib/logitech_receiver/hidpp20.py b/lib/logitech_receiver/hidpp20.py index 98921b5e..6eb6ec10 100644 --- a/lib/logitech_receiver/hidpp20.py +++ b/lib/logitech_receiver/hidpp20.py @@ -739,6 +739,40 @@ PARAM = _NamedInts( ) PARAM._fallback = lambda x: 'unknown:%04X' % x + +class SubParam: + __slots__ = ('id', 'length', 'minimum', 'maximum', 'widget') + + def __init__(self, id, length, minimum=None, maximum=None, widget=None): + self.id = id + self.length = length + self.minimum = minimum if minimum is not None else 0 + self.maximum = maximum if maximum is not None else ((1 << 8 * length) - 1) + self.widget = widget if widget is not None else 'Scale' + + def __str__(self): + return self.id + + def __repr__(self): + return self.id + + +SUB_PARAM = { # (byte count, minimum, maximum) + PARAM['ExtraCapabilities']: None, # ignore + PARAM['PixelZone']: ( # TODO: replace min and max with the correct values + SubParam('left', 2, 0x0000, 0xFFFF, 'SpinButton'), + SubParam('bottom', 2, 0x0000, 0xFFFF, 'SpinButton'), + SubParam('width', 2, 0x0000, 0xFFFF, 'SpinButton'), + SubParam('height', 2, 0x0000, 0xFFFF, 'SpinButton')), + PARAM['RatioZone']: ( # TODO: replace min and max with the correct values + SubParam('left', 1, 0x00, 0xFF, 'SpinButton'), + SubParam('bottom', 1, 0x00, 0xFF, 'SpinButton'), + SubParam('width', 1, 0x00, 0xFF, 'SpinButton'), + SubParam('height', 1, 0x00, 0xFF, 'SpinButton')), + PARAM['ScaleFactor']: ( + SubParam('scale', 2, 0x002E, 0x01FF, 'Scale'), ) +} + # Spec Ids for feature GESTURE_2 SPEC = _NamedInts( DVI_field_width=1, @@ -774,7 +808,7 @@ ACTION_ID._fallback = lambda x: 'unknown:%04X' % x class Gesture(object): - index = {} + gesture_index = {} def __init__(self, device, low, high): self._device = device @@ -788,8 +822,8 @@ class Gesture(object): self.default_enabled = high & 0x20 self.index = None if self.can_be_enabled or self.default_enabled: - self.index = Gesture.index.get(device, 0) - Gesture.index[device] = self.index + 1 + self.index = Gesture.gesture_index.get(device, 0) + Gesture.gesture_index[device] = self.index + 1 self.offset, self.mask = self._offset_mask() def _offset_mask(self): # offset and mask @@ -829,7 +863,7 @@ class Gesture(object): class Param(object): - param_index = 0 + param_index = {} def __init__(self, device, low, high): self._device = device @@ -839,8 +873,12 @@ class Param(object): self.show_in_ui = bool(high & 0x1F) self._value = None self._default_value = None - self.index = Param.param_index - Param.param_index += 1 + self.index = Param.param_index.get(device, 0) + Param.param_index[device] = self.index + 1 + + @property + def sub_params(self): + return SUB_PARAM.get(self.id, None) @property def value(self): @@ -868,6 +906,12 @@ class Param(object): self._value = bytes return feature_request(self._device, FEATURE.GESTURE_2, 0x80, self.index, bytes, 0xFF) + def __str__(self): + return str(self.param) + + def __int__(self): + return self.id + class Spec: def __init__(self, device, low, high): @@ -1108,6 +1152,8 @@ def get_keys(device): def get_gestures(device): + if getattr(device, '_gestures', None) is not None: + return device._gestures if FEATURE.GESTURE_2 in device.features: return Gestures(device) diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index b5f27674..fa89ede6 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -37,7 +37,7 @@ del getLogger # # -KIND = _NamedInts(toggle=0x01, choice=0x02, range=0x04, map_choice=0x0A, multiple_toggle=0x10) +KIND = _NamedInts(toggle=0x01, choice=0x02, range=0x04, map_choice=0x0A, multiple_toggle=0x10, multiple_range=0x40) class Setting(object): @@ -286,6 +286,103 @@ class Settings(Setting): return value +class LongSettings(Setting): + """A setting descriptor for multiple choices, being a map from keys to values. + Allows multiple write requests, if the options don't fit in 16 bytes. + The validator must return a list. + Needs to be instantiated for each specific device.""" + def read(self, cached=True): + assert hasattr(self, '_value') + assert hasattr(self, '_device') + if _log.isEnabledFor(_DEBUG): + _log.debug('%s: settings read %r from %s', self.name, self._value, self._device) + + self._pre_read(cached) + + if cached and self._value is not None: + return self._value + + if self._device.online: + reply_map = {} + # Reading one item at a time. This can probably be optimised + for item in self._validator.items: + r = self._validator.prepare_read_item(item) + reply = self._rw.read(self._device, r) + if reply: + # keys are ints, because that is what the device uses, + # encoded into strings because JSON requires strings as keys + reply_map[str(int(item))] = self._validator.validate_read_item(reply, item) + self._value = reply_map + if self.persist and getattr(self._device, 'persister', None) and self.name not in self._device.persister: + # Don't update the persister if it already has a value, + # otherwise the first read might overwrite the value we wanted. + self._device.persister[self.name] = self._value + return self._value + + def read_item(self, item, cached=True): + assert hasattr(self, '_value') + assert hasattr(self, '_device') + assert item is not None + if _log.isEnabledFor(_DEBUG): + _log.debug('%s: settings read %r item %r from %s', self.name, self._value, item, self._device) + + self._pre_read(cached) + if cached and self._value is not None: + return self._value[str(int(item))] + + if self._device.online: + r = self._validator.prepare_read_item(item) + reply = self._rw.read(self._device, r) + if reply: + self._value[str(int(item))] = self._validator.validate_read_item(reply, item) + if self.persist and getattr(self._device, 'persister', None) and self.name not in self._device.persister: + self._device.persister[self.name] = self._value + return self._value[str(int(item))] + + def write(self, map): + assert hasattr(self, '_value') + assert hasattr(self, '_device') + assert map is not None + + if _log.isEnabledFor(_DEBUG): + _log.debug('%s: settings write %r to %s', self.name, map, self._device) + if self._device.online: + self._value = map + self._pre_write() + for item, value in map.items(): + data_bytes_list = self._validator.prepare_write(self._value) + if data_bytes_list is not None: + for data_bytes in data_bytes_list: + if data_bytes is not None: + if _log.isEnabledFor(_DEBUG): + _log.debug('%s: settings prepare map write(%s,%s) => %r', self.name, item, value, data_bytes) + reply = self._rw.write(self._device, data_bytes) + if not reply: + return None + return map + + def write_item_value(self, item, value): + assert hasattr(self, '_value') + assert hasattr(self, '_device') + assert item is not None + assert value is not None + + if _log.isEnabledFor(_DEBUG): + _log.debug('%s: settings write item %r value %r to %s', self.name, item, value, self._device) + + if self._device.online: + data_bytes = self._validator.prepare_write_item(item, value) + self._value[str(int(item))] = value + self._pre_write() + if data_bytes is not None: + if _log.isEnabledFor(_DEBUG): + _log.debug('%s: settings prepare item value write(%s,%s) => %r', self.name, item, value, data_bytes) + reply = self._rw.write(self._device, data_bytes) + if not reply: + return None + return value + + class BitFieldSetting(Setting): """A setting descriptor for a set of choices represented by one bit each, being a map from options to booleans. Needs to be instantiated for each specific device.""" @@ -347,7 +444,6 @@ class BitFieldSetting(Setting): if _log.isEnabledFor(_DEBUG): _log.debug('%s: settings write %r to %s', self.name, map, self._device) - if self._device.online: self._value = map self._pre_write() @@ -639,13 +735,15 @@ class BitFieldWithOffsetAndMaskValidator(object): def __init__(self, options, byte_count=None): assert (isinstance(options, list)) + # each element of options must have .offset and .mask, + # and its int representation must be its id (not its index) self.options = options # to retrieve the options efficiently: self._option_from_key = {} self._mask_from_offset = {} self._option_from_offset_mask = {} for opt in options: - self._option_from_key[opt.gesture] = opt + self._option_from_key[int(opt)] = opt try: self._mask_from_offset[opt.offset] |= opt.mask except KeyError: @@ -848,3 +946,73 @@ class RangeValidator(object): if new_value < self.min_value or new_value > self.max_value: raise ValueError('invalid choice %r' % new_value) return _int2bytes(new_value, self._byte_count) + + +class MultipleRangeValidator: + + kind = KIND.multiple_range + + def __init__(self, items, sub_items): + assert isinstance(items, list) # each element must have .index and its __int__ must return its id (not its index) + assert isinstance(sub_items, dict) + # sub_items: items -> class with .minimum, .maximum, .length (in bytes), .id (a string) and .widget (e.g. 'Scale') + self.items = items + self._item_from_id = {int(k): k for k in items} + self.sub_items = sub_items + + def prepare_read_item(self, item): + return _int2bytes((self._item_from_id[int(item)].index << 1) | 0xFF, 2) + + def validate_read_item(self, reply_bytes, item): + item = self._item_from_id[int(item)] + start = 0 + value = {} + for sub_item in self.sub_items[item]: + r = reply_bytes[start:start + sub_item.length] + if len(r) < sub_item.length: + r += b'\x00' * (sub_item.length - len(value)) + v = _bytes2int(r) + if not (sub_item.minimum < v < sub_item.maximum): + _log.warn( + f'{self.__class__.__name__}: failed to validate read value for {item}.{sub_item}: ' + + f'{v} not in [{sub_item.minimum}..{sub_item.maximum}]' + ) + value[str(sub_item)] = v + start += sub_item.length + return value + + def prepare_write(self, value): + seq = [] + w = b'' + for item in value.keys(): + _item = self._item_from_id[int(item)] + b = _int2bytes(_item.index, 1) + for sub_item in self.sub_items[_item]: + try: + v = value[str(int(item))][str(sub_item)] + except KeyError: + return None + if not (sub_item.minimum <= v <= sub_item.maximum): + raise ValueError( + f'invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]' + ) + b += _int2bytes(v, sub_item.length) + if len(w) + len(b) > 15: + seq.append(b + b'\xFF') + w = b'' + w += b + seq.append(w + b'\xFF') + return seq + + def prepare_write_item(self, item, value): + _item = self._item_from_id[int(item)] + w = _int2bytes(_item.index, 1) + for sub_item in self.sub_items[_item]: + try: + v = value[str(sub_item)] + except KeyError: + return None + if not (sub_item.minimum <= v <= sub_item.maximum): + raise ValueError(f'invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]') + w += _int2bytes(v, sub_item.length) + return w + b'\xFF' diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index 163d16b6..3ef2a9e7 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -40,6 +40,8 @@ from .settings import ChoicesMapValidator as _ChoicesMapV from .settings import ChoicesValidator as _ChoicesV from .settings import FeatureRW as _FeatureRW from .settings import FeatureRWMap as _FeatureRWMap +from .settings import LongSettings as _LongSettings +from .settings import MultipleRangeValidator as _MultipleRangeV from .settings import RangeValidator as _RangeV from .settings import RegisterRW as _RegisterRW from .settings import Setting as _Setting @@ -94,6 +96,7 @@ _THUMB_SCROLL_MODE = ('thumb-scroll-mode', _('HID++ Thumb Scrolling'), _('Effectively turns off thumb scrolling in Linux.')) _THUMB_SCROLL_INVERT = ('thumb-scroll-invert', _('Thumb Scroll Invert'), _('Invert thumb scroll direction.')) _GESTURE2_GESTURES = ('gesture2-gestures', _('Gestures'), _('Tweaks the mouse/touchpad behaviour.')) +_GESTURE2_PARAMS = ('gesture2-params', _('Gesture params'), _('Changes numerical parameters of a mouse/touchpad.')) # yapf: enable # Setting template functions need to set up the setting itself, the validator, and the reader/writer. @@ -404,15 +407,31 @@ def _feature_thumb_invert(): return _Setting(_THUMB_SCROLL_INVERT, rw, validator, device_kind=(_DK.mouse, _DK.trackball)) -def _feature_gesture2_gesture_callback(device): +def _feature_gesture2_gestures_callback(device): options = [g for g in _hidpp20.get_gestures(device).gestures.values() if g.can_be_enabled or g.default_enabled] return _BitFieldOMV(options) if options else None -def _feature_gesture2_gesture(): +def _feature_gesture2_gestures(): rw = _FeatureRW(_F.GESTURE_2, read_fnid=0x10, write_fnid=0x20) return _BitFieldOMSetting( - _GESTURE2_GESTURES, rw, callback=_feature_gesture2_gesture_callback, device_kind=(_DK.touchpad, _DK.mouse) + _GESTURE2_GESTURES, rw, callback=_feature_gesture2_gestures_callback, device_kind=(_DK.touchpad, _DK.mouse) + ) + + +def _feature_gesture2_params_callback(device): + params = _hidpp20.get_gestures(device).params.values() + items = [i for i in params if i.sub_params] + if not items: + return None + sub_items = {i: i.sub_params for i in items} + return _MultipleRangeV(items, sub_items) + + +def _feature_gesture2_params(): + rw = _FeatureRW(_F.GESTURE_2, read_fnid=0x70, write_fnid=0x80) + return _LongSettings( + _GESTURE2_PARAMS, rw, callback=_feature_gesture2_params_callback, device_kind=(_DK.touchpad, _DK.mouse) ) @@ -447,7 +466,8 @@ _SETTINGS_TABLE = [ _S(_CHANGE_HOST, _F.CHANGE_HOST, _feature_change_host), _S(_THUMB_SCROLL_MODE, _F.THUMB_WHEEL, _feature_thumb_mode), _S(_THUMB_SCROLL_INVERT, _F.THUMB_WHEEL, _feature_thumb_invert), - _S(_GESTURE2_GESTURES, _F.GESTURE_2, _feature_gesture2_gesture), + _S(_GESTURE2_GESTURES, _F.GESTURE_2, _feature_gesture2_gestures), + _S(_GESTURE2_PARAMS, _F.GESTURE_2, _feature_gesture2_params), ] _SETTINGS_LIST = namedtuple('_SETTINGS_LIST', [s[4] for s in _SETTINGS_TABLE]) diff --git a/lib/solaar/ui/config_panel.py b/lib/solaar/ui/config_panel.py index b818585a..cd6cf295 100644 --- a/lib/solaar/ui/config_panel.py +++ b/lib/solaar/ui/config_panel.py @@ -40,7 +40,7 @@ def _read_async(setting, force_read, sbox, device_is_online): def _write_async(setting, value, sbox): - _ignore, failed, spinner, control = sbox.get_children() + failed, spinner, control = _get_failed_spinner_control(sbox) control.set_sensitive(False) failed.set_visible(False) spinner.set_visible(True) @@ -54,7 +54,7 @@ def _write_async(setting, value, sbox): def _write_async_key_value(setting, key, value, sbox): - _ignore, failed, spinner, control = sbox.get_children() + failed, spinner, control = _get_failed_spinner_control(sbox) control.set_sensitive(False) failed.set_visible(False) spinner.set_visible(True) @@ -67,6 +67,20 @@ def _write_async_key_value(setting, key, value, sbox): _ui_async(_do_write_key_value, setting, key, value, sbox) +def _write_async_item_value(setting, item, value, sbox): + failed, spinner, control = _get_failed_spinner_control(sbox) + control.set_sensitive(False) + failed.set_visible(False) + spinner.set_visible(True) + spinner.start() + + def _do_write_item_value(s, k, v, sb): + v = setting.write_item_value(k, v) + GLib.idle_add(_update_setting_item, sb, {k: v}, True, priority=99) + + _ui_async(_do_write_item_value, setting, item, value, sbox) + + # # # @@ -174,12 +188,15 @@ def _create_multiple_toggle_control(setting): new_state = control.get_active() if setting._value[key] != new_state: setting._value[key] = new_state - _write_async_key_value(setting, key, new_state, control.get_parent().get_parent().get_parent().get_parent()) + p = control + for _ in range(5): + p = p.get_parent() + _write_async_key_value(setting, key, new_state, p) def _toggle_display(lb): lb._showing = not lb._showing if not lb._showing: - for c in lb.get_children()[1:]: + for c in lb.get_children(): lb._hidden_rows.append(c) lb.remove(c) else: @@ -192,19 +209,94 @@ def _create_multiple_toggle_control(setting): lb._toggle_display = (lambda l: (lambda: _toggle_display(l)))(lb) lb.set_selection_mode(Gtk.SelectionMode.NONE) btn = Gtk.Button('? / ?') - lb.add(btn) lb._showing = True for k in setting._validator.all_options(): - h = Gtk.HBox(homogeneous=False, spacing=0) + h = Gtk.HBox(homogeneous=True, spacing=0) lbl = Gtk.Label(k) control = Gtk.Switch() control._setting_key = str(int(k)) control.connect('notify::active', _toggle_notify, setting) - h.pack_start(lbl, False, False, 0) - h.pack_end(control, False, False, 0) + h.pack_start(lbl, True, True, 0) + h.pack_end(control, True, True, 0) lb.add(h) btn.connect('clicked', lambda _: lb._toggle_display()) - return lb + + hbox = Gtk.HBox(homogeneous=False, spacing=6) + hbox.pack_end(btn, True, True, 0) + vbox = Gtk.VBox(homogeneous=False, spacing=6) + vbox.pack_start(hbox, True, True, 0) + vbox.pack_end(lb, True, True, 0) + return vbox + + +def _create_multiple_range_control(setting): + def _write(control, setting, item, sub_item): + control._timer.cancel() + delattr(control, '_timer') + new_state = int(control.get_value()) + if setting._value[str(int(item))][str(sub_item)] != new_state: + setting._value[str(int(item))][str(sub_item)] = new_state + p = control + for _i in range(7): + p = p.get_parent() + _write_async_item_value(setting, str(int(item)), setting._value[str(int(item))], p) + + def _changed(control, setting, item, sub_item): + if control.get_sensitive(): + if hasattr(control, '_timer'): + control._timer.cancel() + control._timer = _Timer(0.5, lambda: GLib.idle_add(_write, control, setting, item, sub_item)) + control._timer.start() + + def _toggle_display(lb): + lb._showing = not lb._showing + if not lb._showing: + for c in lb.get_children(): + lb._hidden_rows.append(c) + lb.remove(c) + else: + for c in lb._hidden_rows: + lb.add(c) + lb._hidden_rows = [] + + lb = Gtk.ListBox() + lb._hidden_rows = [] + lb._toggle_display = (lambda l: (lambda: _toggle_display(l)))(lb) + lb.set_selection_mode(Gtk.SelectionMode.NONE) + btn = Gtk.Button('???') + lb._showing = True + for item in setting._validator.items: + item_lbl = Gtk.Label(item) + lb.add(item_lbl) + item_lb = Gtk.ListBox() + item_lb.set_selection_mode(Gtk.SelectionMode.NONE) + for sub_item in setting._validator.sub_items[item]: + h = Gtk.HBox(homogeneous=True, spacing=0) + sub_item_lbl = Gtk.Label(sub_item) + h.pack_start(sub_item_lbl, True, True, 0) + if sub_item.widget == 'Scale': + control = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, sub_item.minimum, sub_item.maximum, 1) + control.set_round_digits(0) + control.set_digits(0) + elif sub_item.widget == 'SpinButton': + control = Gtk.SpinButton.new_with_range(sub_item.minimum, sub_item.maximum, 1) + control.set_digits(0) + else: + raise NotImplementedError + h.pack_end(control, True, True, 0) + control.connect('value-changed', _changed, setting, item, sub_item) + item_lb.add(h) + h._setting_sub_item = sub_item + item_lb._setting_item = item + lb.add(item_lb) + btn.connect('clicked', lambda _: lb._toggle_display()) + + hbox = Gtk.HBox(homogeneous=False, spacing=6) + hbox.pack_end(btn, True, True, 0) + vbox = Gtk.VBox(homogeneous=False, spacing=6) + vbox.pack_start(hbox, True, True, 0) + vbox.pack_end(lb, True, True, 0) + return vbox # @@ -214,7 +306,8 @@ def _create_multiple_toggle_control(setting): def _create_sbox(s): sbox = Gtk.HBox(homogeneous=False, spacing=6) - sbox.pack_start(Gtk.Label(s.label), False, False, 0) + label = Gtk.Label(s.label) + sbox.pack_start(label, False, False, 0) spinner = Gtk.Spinner() spinner.set_tooltip_text(_('Working') + '...') @@ -235,15 +328,28 @@ def _create_sbox(s): control = _create_map_choice_control(s) sbox.pack_end(control, True, True, 0) elif s.kind == _SETTING_KIND.multiple_toggle: - control = _create_multiple_toggle_control(s) - sbox.get_children()[0].set_valign(Gtk.Align.START) - sbox.pack_end(control, False, False, 0) + vbox = _create_multiple_toggle_control(s) + control = vbox.get_children()[1] + sbox.remove(label) + vbox.get_children()[0].pack_start(label, True, True, 0) + sbox.pack_start(vbox, True, True, 0) + elif s.kind == _SETTING_KIND.multiple_range: + vbox = _create_multiple_range_control(s) + control = vbox.get_children()[1] + sbox.remove(label) + vbox.get_children()[0].pack_start(label, True, True, 0) + sbox.pack_start(vbox, True, True, 0) else: raise Exception('NotImplemented') - control.set_sensitive(False) # the first read will enable it - sbox.pack_end(spinner, False, False, 0) - sbox.pack_end(failed, False, False, 0) + control.kind = s.kind + + if s.kind in [_SETTING_KIND.multiple_toggle, _SETTING_KIND.multiple_range]: + vbox.get_children()[0].pack_end(spinner, False, False, 0) + vbox.get_children()[0].pack_end(failed, False, False, 0) + else: + sbox.pack_end(spinner, False, False, 0) + sbox.pack_end(failed, False, False, 0) if s.description: sbox.set_tooltip_text(s.description) @@ -257,7 +363,7 @@ def _create_sbox(s): def _update_setting_item(sbox, value, is_online=True): - _ignore, failed, spinner, control = sbox.get_children() # depends on box layout + failed, spinner, control = _get_failed_spinner_control(sbox) spinner.set_visible(False) spinner.stop() @@ -279,23 +385,65 @@ def _update_setting_item(sbox, value, is_online=True): if value.get(kbox.get_active_id()): vbox.set_active_id(str(value.get(kbox.get_active_id()))) elif isinstance(control, Gtk.ListBox): - hidden = getattr(control, '_hidden_rows', []) - total = len(control.get_children()) + len(hidden) - 1 - active = 0 - for ch in control.get_children()[1:] + hidden: - elem = ch.get_children()[0].get_children()[-1] - v = value.get(elem._setting_key, None) - if v is not None: - elem.set_active(v) - if elem.get_active(): - active += 1 - control.get_children()[0].get_children()[0].set_label(f'{active} / {total}') - + if control.kind == _SETTING_KIND.multiple_toggle: + hidden = getattr(control, '_hidden_rows', []) + total = len(control.get_children()) + len(hidden) + active = 0 + to_join = [] + for ch in control.get_children() + hidden: + elem = ch.get_children()[0].get_children()[-1] + v = value.get(elem._setting_key, None) + if v is not None: + elem.set_active(v) + if elem.get_active(): + active += 1 + to_join.append(elem.get_parent().get_children()[0].get_text() + ': ' + str(elem.get_active())) + b = ', '.join(to_join) + btn = control.get_parent().get_children()[0].get_children()[-1] + btn.set_label(f'{active} / {total}') + btn.set_tooltip_text(b) + elif control.kind == _SETTING_KIND.multiple_range: + hidden = getattr(control, '_hidden_rows', []) + b = '' + n = 0 + for ch in control.get_children()[1:] + hidden: + # item + item = ch.get_children()[0]._setting_item + v = value.get(str(int(item)), None) + if v is not None: + b += str(item) + ': (' + to_join = [] + for c in ch.get_children()[0].get_children(): + # sub-item + row = c.get_children()[0] + sub_item = row._setting_sub_item + elem = row.get_children()[-1] + elem.set_value(v[str(sub_item)]) + n += 1 + to_join.append(str(sub_item) + f'={v[str(sub_item)]}') + b += ', '.join(to_join) + ') ' + btn = control.get_parent().get_children()[0].get_children()[-1] + btn.set_label(f'{n} value' + ('s' if n != 1 else '')) # TODO: i18n, singular/plural + btn.set_tooltip_text(b) + else: + raise NotImplementedError else: raise Exception('NotImplemented') control.set_sensitive(True) +def _get_failed_spinner_control(sbox): + children = sbox.get_children() + if len(children) == 4: + _ignore, failed, spinner, control = sbox.get_children() # depends on box layout + else: + assert len(children) == 1 + control = children[0].get_children()[-1] + failed = children[0].get_children()[0].get_children()[1] + spinner = children[0].get_children()[0].get_children()[2] + return failed, spinner, control + + # # #