Skip to content

Commit

Permalink
Merge pull request #19 from astrofrog/selection
Browse files Browse the repository at this point in the history
Added SelectionCallbackProperty,
  • Loading branch information
astrofrog committed Jul 21, 2017
2 parents d55bf70 + b1510f0 commit 6ad54cc
Show file tree
Hide file tree
Showing 8 changed files with 473 additions and 10 deletions.
1 change: 1 addition & 0 deletions echo/__init__.py
@@ -1,2 +1,3 @@
from .core import * # noqa
from .list import * # noqa
from .selection import * # noqa
28 changes: 23 additions & 5 deletions echo/core.py
@@ -1,6 +1,7 @@
from __future__ import absolute_import, division, print_function

import weakref
from itertools import chain
from weakref import WeakKeyDictionary
from contextlib import contextmanager

Expand Down Expand Up @@ -84,6 +85,15 @@ def setter(self, func):
self._setter = func
return self

def _get_full_info(self, instance):
# Some callback subclasses may contain additional info in addition
# to the main value, and we need to use this full information when
# comparing old and new 'values', so this method is used in that
# case. The result should be a tuple where the first item is the
# actual primary value of the property and the second item is any
# additional data to use in the comparison.
return self.__get__(instance), None

def notify(self, instance, old, new):
"""
Call all callback functions with the current value
Expand Down Expand Up @@ -205,7 +215,7 @@ def _process_delayed_global_callbacks(self, properties):
for prop, new_value in properties.items():
old_value = self._delayed_properties.pop(prop)
if old_value != new_value:
kwargs[prop] = new_value
kwargs[prop] = new_value[0]
self._notify_global(**kwargs)

def _notify_global_lists(self, *args):
Expand Down Expand Up @@ -457,8 +467,8 @@ def __enter__(self):

if (self.instance, prop) not in self.delay_count:
self.delay_count[self.instance, prop] = 1
self.old_values[self.instance, prop] = p.__get__(self.instance)
delay_props[prop] = p.__get__(self.instance)
self.old_values[self.instance, prop] = p._get_full_info(self.instance)
delay_props[prop] = p._get_full_info(self.instance)
else:
self.delay_count[self.instance, prop] += 1

Expand All @@ -485,9 +495,9 @@ def __exit__(self, *args):
self.delay_count.pop((self.instance, prop))
old = self.old_values.pop((self.instance, prop))
p.enable(self.instance)
new = p.__get__(self.instance)
new = p._get_full_info(self.instance)
if old != new:
notifications.append((p, (self.instance, old, new)))
notifications.append((p, (self.instance, old[0], new[0])))
resume_props[prop] = new

if isinstance(self.instance, HasCallbackProperties):
Expand Down Expand Up @@ -557,6 +567,8 @@ def __init__(self, instance1, prop1, instance2, prop2):

self._syncing = False

self.enabled = False

self.enable_syncing()

def prop1_from_prop2(self, value):
Expand All @@ -572,11 +584,17 @@ def prop2_from_prop1(self, value):
self._syncing = False

def enable_syncing(self, *args):
if self.enabled:
return
add_callback(self.instance1(), self.prop1, self.prop2_from_prop1)
add_callback(self.instance2(), self.prop2, self.prop1_from_prop2)
self.enabled = True

def disable_syncing(self, *args):
if not self.enabled:
return
if self.instance1() is not None:
remove_callback(self.instance1(), self.prop1, self.prop2_from_prop1)
if self.instance2() is not None:
remove_callback(self.instance2(), self.prop2, self.prop1_from_prop2)
self.enabled = False
4 changes: 3 additions & 1 deletion echo/qt/autoconnect.py
Expand Up @@ -8,7 +8,8 @@
connect_combo_text,
connect_float_text,
connect_text,
connect_button)
connect_button,
connect_combo_selection)

__all__ = ['autoconnect_callbacks_to_qt']

Expand All @@ -20,6 +21,7 @@
HANDLERS['combodata'] = connect_combo_data
HANDLERS['combotext'] = connect_combo_text
HANDLERS['button'] = connect_button
HANDLERS['combodatasel'] = connect_combo_selection


def autoconnect_callbacks_to_qt(instance, widget, connect_kwargs={}):
Expand Down
80 changes: 77 additions & 3 deletions echo/qt/connect.py
Expand Up @@ -6,10 +6,15 @@
import math
from functools import partial

from .. import add_callback
from qtpy import QtGui
from qtpy.QtCore import Qt

from ..core import add_callback
from ..selection import SelectionCallbackProperty, ChoiceSeparator

__all__ = ['connect_checkable_button', 'connect_text', 'connect_combo_data',
'connect_combo_text', 'connect_float_text', 'connect_value']
'connect_combo_text', 'connect_float_text', 'connect_value',
'connect_combo_selection']


def connect_checkable_button(instance, prop, widget):
Expand Down Expand Up @@ -270,8 +275,10 @@ def _find_combo_data(widget, value):
Raises a ValueError if data is not found
"""
# Here we check that the result is True, because some classes may overload
# == and return other kinds of objects whether true or false.
for idx in range(widget.count()):
if widget.itemData(idx) == value:
if widget.itemData(idx) is value or (widget.itemData(idx) == value) is True:
return idx
else:
raise ValueError("%s not found in combo box" % (value,))
Expand All @@ -288,3 +295,70 @@ def _find_combo_text(widget, value):
raise ValueError("%s not found in combo box" % value)
else:
return i


def connect_combo_selection(instance, prop, widget, display=str):

if not isinstance(getattr(type(instance), prop), SelectionCallbackProperty):
raise TypeError('connect_combo_selection requires a SelectionCallbackProperty')

def update_widget(value):

# Update choices in the combo box

combo_data = [widget.itemData(idx) for idx in range(widget.count())]

choices = getattr(type(instance), prop).get_choices(instance)
choice_labels = getattr(type(instance), prop).get_choice_labels(instance)

if combo_data == choices:
choices_updated = False
else:

widget.blockSignals(True)
widget.clear()

if len(choices) == 0:
return

combo_model = widget.model()

for index, (label, choice) in enumerate(zip(choice_labels, choices)):

widget.addItem(label, userData=choice)

# We interpret None data as being disabled rows (used for headers)
if isinstance(choice, ChoiceSeparator):
item = combo_model.item(index)
palette = widget.palette()
item.setFlags(item.flags() & ~(Qt.ItemIsSelectable | Qt.ItemIsEnabled))
item.setData(palette.color(QtGui.QPalette.Disabled, QtGui.QPalette.Text))

choices_updated = True

# Update current selection
try:
idx = _find_combo_data(widget, value)
except ValueError:
if value is None:
idx = -1
else:
raise

if idx == widget.currentIndex() and not choices_updated:
return

widget.setCurrentIndex(idx)
widget.blockSignals(False)
widget.currentIndexChanged.emit(idx)

def update_prop(idx):
if idx == -1:
setattr(instance, prop, None)
else:
setattr(instance, prop, widget.itemData(idx))

add_callback(instance, prop, update_widget)
widget.currentIndexChanged.connect(update_prop)

update_widget(getattr(instance, prop))
19 changes: 18 additions & 1 deletion echo/qt/tests/test_connect.py
@@ -1,13 +1,14 @@
from __future__ import absolute_import, division, print_function

import pytest
from mock import MagicMock

from qtpy import QtWidgets

from echo import CallbackProperty
from echo.qt.connect import (connect_checkable_button, connect_text,
connect_combo_data, connect_combo_text,
connect_float_text, connect_value)
connect_float_text, connect_value, connect_button)


def test_connect_checkable_button():
Expand Down Expand Up @@ -197,3 +198,19 @@ class Test(object):

t.c = 10
assert slider.value() == 75


def test_connect_button():

class Example(object):
a = MagicMock()

e = Example()

button = QtWidgets.QPushButton('OK')

connect_button(e, 'a', button)

assert e.a.call_count == 0
button.clicked.emit()
assert e.a.call_count == 1
126 changes: 126 additions & 0 deletions echo/qt/tests/test_connect_combo_selection.py
@@ -0,0 +1,126 @@
from __future__ import absolute_import, division, print_function

import pytest

from qtpy import QtWidgets

from ...core import CallbackProperty
from ...selection import SelectionCallbackProperty, ChoiceSeparator
from ..connect import connect_combo_selection


class Example(object):
a = SelectionCallbackProperty(default_index=1)
b = CallbackProperty()


def test_connect_combo_selection():

t = Example()

a_prop = getattr(type(t), 'a')
a_prop.set_choices(t, [4, 3.5])
a_prop.set_display_func(t, lambda x: 'value: {0}'.format(x))

combo = QtWidgets.QComboBox()

connect_combo_selection(t, 'a', combo)

assert combo.itemText(0) == 'value: 4'
assert combo.itemText(1) == 'value: 3.5'
assert combo.itemData(0) == 4
assert combo.itemData(1) == 3.5

combo.setCurrentIndex(1)
assert t.a == 3.5

combo.setCurrentIndex(0)
assert t.a == 4

combo.setCurrentIndex(-1)
assert t.a is None

t.a = 3.5
assert combo.currentIndex() == 1

t.a = 4
assert combo.currentIndex() == 0

with pytest.raises(ValueError) as exc:
t.a = 2
assert exc.value.args[0] == 'value 2 is not in valid choices'

t.a = None
assert combo.currentIndex() == -1

# Changing choices should change Qt combo box. Let's first try with a case
# in which there is a matching data value in the new combo box

t.a = 3.5
assert combo.currentIndex() == 1

a_prop.set_choices(t, (4, 5, 3.5))
assert combo.count() == 3

assert t.a == 3.5
assert combo.currentIndex() == 2

assert combo.itemText(0) == 'value: 4'
assert combo.itemText(1) == 'value: 5'
assert combo.itemText(2) == 'value: 3.5'
assert combo.itemData(0) == 4
assert combo.itemData(1) == 5
assert combo.itemData(2) == 3.5

# Now we change the choices so that there is no matching data - in this case
# the index should change to that given by default_index

a_prop.set_choices(t, (4, 5, 6))

assert t.a == 5
assert combo.currentIndex() == 1
assert combo.count() == 3

assert combo.itemText(0) == 'value: 4'
assert combo.itemText(1) == 'value: 5'
assert combo.itemText(2) == 'value: 6'
assert combo.itemData(0) == 4
assert combo.itemData(1) == 5
assert combo.itemData(2) == 6

# Finally, if there are too few choices for the default_index to be valid,
# pick the last item in the combo

a_prop.set_choices(t, (9,))

assert t.a == 9
assert combo.currentIndex() == 0
assert combo.count() == 1

assert combo.itemText(0) == 'value: 9'
assert combo.itemData(0) == 9

# Now just make sure that ChoiceSeparator works

separator = ChoiceSeparator('header')
a_prop.set_choices(t, (separator, 1, 2))

assert combo.count() == 3
assert combo.itemText(0) == 'header'
assert combo.itemData(0) is separator

# And setting choices to an empty iterable shouldn't cause issues

a_prop.set_choices(t, ())
assert combo.count() == 0


def test_connect_combo_selection_invalid():

t = Example()

combo = QtWidgets.QComboBox()

with pytest.raises(TypeError) as exc:
connect_combo_selection(t, 'b', combo)
assert exc.value.args[0] == 'connect_combo_selection requires a SelectionCallbackProperty'

0 comments on commit 6ad54cc

Please sign in to comment.