receiver: implementation of GESTURE 2 params; improved UI for multiple toggle

This commit is contained in:
Vinícius 2020-08-31 03:05:06 -03:00 committed by Peter F. Patel-Schneider
parent aa067b2774
commit c8fe87ee2d
4 changed files with 424 additions and 42 deletions

View File

@ -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)

View File

@ -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'

View File

@ -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])

View File

@ -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
#
#
#