Merge pull request #544 from acq4/dockarea-updates

Dockarea updates
This commit is contained in:
Luke Campagnola 2017-10-12 11:05:42 -07:00 committed by GitHub
commit a63fd24442
6 changed files with 416 additions and 145 deletions

View File

@ -17,16 +17,20 @@ class Container(object):
def containerChanged(self, c):
self._container = c
if c is None:
self.area = None
else:
self.area = c.area
def type(self):
return None
def insert(self, new, pos=None, neighbor=None):
# remove from existing parent first
new.setParent(None)
if not isinstance(new, list):
new = [new]
for n in new:
# remove from existing parent first
n.setParent(None)
if neighbor is None:
if pos == 'before':
index = 0
@ -40,34 +44,37 @@ class Container(object):
index += 1
for n in new:
#print "change container", n, " -> ", self
n.containerChanged(self)
#print "insert", n, " -> ", self, index
self._insertItem(n, index)
#print "change container", n, " -> ", self
n.containerChanged(self)
index += 1
n.sigStretchChanged.connect(self.childStretchChanged)
#print "child added", self
self.updateStretch()
def apoptose(self, propagate=True):
##if there is only one (or zero) item in this container, disappear.
# if there is only one (or zero) item in this container, disappear.
# if propagate is True, then also attempt to apoptose parent containers.
cont = self._container
c = self.count()
if c > 1:
return
if self.count() == 1: ## if there is one item, give it to the parent container (unless this is the top)
if self is self.area.topContainer:
if c == 1: ## if there is one item, give it to the parent container (unless this is the top)
ch = self.widget(0)
if (self.area is not None and self is self.area.topContainer and not isinstance(ch, Container)) or self.container() is None:
return
self.container().insert(self.widget(0), 'before', self)
self.container().insert(ch, 'before', self)
#print "apoptose:", self
self.close()
if propagate and cont is not None:
cont.apoptose()
def close(self):
self.area = None
self._container = None
self.setParent(None)
if self.area is not None and self.area.topContainer is self:
self.area.topContainer = None
self.containerChanged(None)
def childEvent(self, ev):
ch = ev.child()
@ -92,7 +99,6 @@ class Container(object):
###Set the stretch values for this container to reflect its contents
pass
def stretch(self):
"""Return the stretch factors for this container"""
return self._stretch

View File

@ -36,6 +36,7 @@ class Dock(QtGui.QWidget, DockDrop):
self.widgetArea.setLayout(self.layout)
self.widgetArea.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding)
self.widgets = []
self._container = None
self.currentRow = 0
#self.titlePos = 'top'
self.raiseOverlay()
@ -187,9 +188,6 @@ class Dock(QtGui.QWidget, DockDrop):
def name(self):
return self._name
def container(self):
return self._container
def addWidget(self, widget, row=None, col=0, rowspan=1, colspan=1):
"""
Add a new widget to the interior of this Dock.
@ -202,7 +200,6 @@ class Dock(QtGui.QWidget, DockDrop):
self.layout.addWidget(widget, row, col, rowspan, colspan)
self.raiseOverlay()
def startDrag(self):
self.drag = QtGui.QDrag(self)
mime = QtCore.QMimeData()
@ -216,21 +213,30 @@ class Dock(QtGui.QWidget, DockDrop):
def float(self):
self.area.floatDock(self)
def container(self):
return self._container
def containerChanged(self, c):
if self._container is not None:
# ask old container to close itself if it is no longer needed
self._container.apoptose()
#print self.name(), "container changed"
self._container = c
if c.type() != 'tab':
self.moveLabel = True
self.label.setDim(False)
if c is None:
self.area = None
else:
self.moveLabel = False
self.setOrientation(force=True)
self.area = c.area
if c.type() != 'tab':
self.moveLabel = True
self.label.setDim(False)
else:
self.moveLabel = False
self.setOrientation(force=True)
def raiseDock(self):
"""If this Dock is stacked underneath others, raise it to the top."""
self.container().raiseDock(self)
def close(self):
"""Remove this dock from the DockArea it lives inside."""

View File

@ -61,6 +61,8 @@ class DockArea(Container, QtGui.QWidget, DockDrop):
if isinstance(relativeTo, basestring):
relativeTo = self.docks[relativeTo]
container = self.getContainer(relativeTo)
if container is None:
raise TypeError("Dock %s is not contained in a DockArea; cannot add another dock relative to it." % relativeTo)
neighbor = relativeTo
## what container type do we need?
@ -98,7 +100,6 @@ class DockArea(Container, QtGui.QWidget, DockDrop):
#print "request insert", dock, insertPos, neighbor
old = dock.container()
container.insert(dock, insertPos, neighbor)
dock.area = self
self.docks[dock.name()] = dock
if old is not None:
old.apoptose()
@ -142,23 +143,19 @@ class DockArea(Container, QtGui.QWidget, DockDrop):
def insert(self, new, pos=None, neighbor=None):
if self.topContainer is not None:
# Adding new top-level container; addContainer() should
# take care of giving the old top container a new home.
self.topContainer.containerChanged(None)
self.layout.addWidget(new)
new.containerChanged(self)
self.topContainer = new
#print self, "set top:", new
new._container = self
self.raiseOverlay()
#print "Insert top:", new
def count(self):
if self.topContainer is None:
return 0
return 1
#def paintEvent(self, ev):
#self.drawDockOverlay()
def resizeEvent(self, ev):
self.resizeOverlay(self.size())
@ -180,7 +177,6 @@ class DockArea(Container, QtGui.QWidget, DockDrop):
area.win.resize(dock.size())
area.moveDock(dock, 'top', None)
def removeTempArea(self, area):
self.tempAreas.remove(area)
#print "close window", area.window()
@ -212,14 +208,20 @@ class DockArea(Container, QtGui.QWidget, DockDrop):
childs.append(self.childState(obj.widget(i)))
return (obj.type(), childs, obj.saveState())
def restoreState(self, state):
def restoreState(self, state, missing='error', extra='bottom'):
"""
Restore Dock configuration as generated by saveState.
Note that this function does not create any Docks--it will only
This function does not create any Docks--it will only
restore the arrangement of an existing set of Docks.
By default, docks that are described in *state* but do not exist
in the dock area will cause an exception to be raised. This behavior
can be changed by setting *missing* to 'ignore' or 'create'.
Extra docks that are in the dockarea but that are not mentioned in
*state* will be added to the bottom of the dockarea, unless otherwise
specified by the *extra* argument.
"""
## 1) make dict of all docks and list of existing containers
@ -229,17 +231,22 @@ class DockArea(Container, QtGui.QWidget, DockDrop):
## 2) create container structure, move docks into new containers
if state['main'] is not None:
self.buildFromState(state['main'], docks, self)
self.buildFromState(state['main'], docks, self, missing=missing)
## 3) create floating areas, populate
for s in state['float']:
a = self.addTempArea()
a.buildFromState(s[0]['main'], docks, a)
a.buildFromState(s[0]['main'], docks, a, missing=missing)
a.win.setGeometry(*s[1])
a.apoptose() # ask temp area to close itself if it is empty
## 4) Add any remaining docks to the bottom
## 4) Add any remaining docks to a float
for d in docks.values():
self.moveDock(d, 'below', None)
if extra == 'float':
a = self.addTempArea()
a.addDock(d, 'below')
else:
self.moveDock(d, extra, None)
#print "\nKill old containers:"
## 5) kill old containers
@ -248,8 +255,7 @@ class DockArea(Container, QtGui.QWidget, DockDrop):
for a in oldTemps:
a.apoptose()
def buildFromState(self, state, docks, root, depth=0):
def buildFromState(self, state, docks, root, depth=0, missing='error'):
typ, contents, state = state
pfx = " " * depth
if typ == 'dock':
@ -257,7 +263,15 @@ class DockArea(Container, QtGui.QWidget, DockDrop):
obj = docks[contents]
del docks[contents]
except KeyError:
raise Exception('Cannot restore dock state; no dock with name "%s"' % contents)
if missing == 'error':
raise Exception('Cannot restore dock state; no dock with name "%s"' % contents)
elif missing == 'create':
obj = Dock(name=contents)
elif missing == 'ignore':
return
else:
raise ValueError('"missing" argument must be one of "error", "create", or "ignore".')
else:
obj = self.makeContainer(typ)
@ -266,10 +280,11 @@ class DockArea(Container, QtGui.QWidget, DockDrop):
if typ != 'dock':
for o in contents:
self.buildFromState(o, docks, obj, depth+1)
self.buildFromState(o, docks, obj, depth+1, missing=missing)
# remove this container if possible. (there are valid situations when a restore will
# generate empty containers, such as when using missing='ignore')
obj.apoptose(propagate=False)
obj.restoreState(state) ## this has to be done later?
obj.restoreState(state) ## this has to be done later?
def findAll(self, obj=None, c=None, d=None):
if obj is None:
@ -295,14 +310,15 @@ class DockArea(Container, QtGui.QWidget, DockDrop):
d.update(d2)
return (c, d)
def apoptose(self):
def apoptose(self, propagate=True):
# remove top container if possible, close this area if it is temporary.
#print "apoptose area:", self.temporary, self.topContainer, self.topContainer.count()
if self.topContainer.count() == 0:
if self.topContainer is None or self.topContainer.count() == 0:
self.topContainer = None
if self.temporary:
self.home.removeTempArea(self)
#self.close()
def clear(self):
docks = self.findAll()[1]
for dock in docks.values():
@ -322,12 +338,38 @@ class DockArea(Container, QtGui.QWidget, DockDrop):
def dropEvent(self, *args):
DockDrop.dropEvent(self, *args)
def printState(self, state=None, name='Main'):
# for debugging
if state is None:
state = self.saveState()
print("=== %s dock area ===" % name)
if state['main'] is None:
print(" (empty)")
else:
self._printAreaState(state['main'])
for i, float in enumerate(state['float']):
self.printState(float[0], name='float %d' % i)
class TempAreaWindow(QtGui.QMainWindow):
def _printAreaState(self, area, indent=0):
if area[0] == 'dock':
print(" " * indent + area[0] + " " + str(area[1:]))
return
else:
print(" " * indent + area[0])
for ch in area[1]:
self._printAreaState(ch, indent+1)
class TempAreaWindow(QtGui.QWidget):
def __init__(self, area, **kwargs):
QtGui.QMainWindow.__init__(self, **kwargs)
self.setCentralWidget(area)
QtGui.QWidget.__init__(self, **kwargs)
self.layout = QtGui.QGridLayout()
self.setLayout(self.layout)
self.layout.setContentsMargins(0, 0, 0, 0)
self.dockarea = area
self.layout.addWidget(area)
def closeEvent(self, *args, **kwargs):
self.centralWidget().clear()
QtGui.QMainWindow.closeEvent(self, *args, **kwargs)
def closeEvent(self, *args):
self.dockarea.clear()
QtGui.QWidget.closeEvent(self, *args)

View File

@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
import pytest
import pyqtgraph as pg
from pyqtgraph.ordereddict import OrderedDict
pg.mkQApp()
import pyqtgraph.dockarea as da
def test_dockarea():
a = da.DockArea()
d1 = da.Dock("dock 1")
a.addDock(d1, 'left')
assert a.topContainer is d1.container()
assert d1.container().container() is a
assert d1.area is a
assert a.topContainer.widget(0) is d1
d2 = da.Dock("dock 2")
a.addDock(d2, 'right')
assert a.topContainer is d1.container()
assert a.topContainer is d2.container()
assert d1.container().container() is a
assert d2.container().container() is a
assert d2.area is a
assert a.topContainer.widget(0) is d1
assert a.topContainer.widget(1) is d2
d3 = da.Dock("dock 3")
a.addDock(d3, 'bottom')
assert a.topContainer is d3.container()
assert d2.container().container() is d3.container()
assert d1.container().container() is d3.container()
assert d1.container().container().container() is a
assert d2.container().container().container() is a
assert d3.container().container() is a
assert d3.area is a
assert d2.area is a
assert a.topContainer.widget(0) is d1.container()
assert a.topContainer.widget(1) is d3
d4 = da.Dock("dock 4")
a.addDock(d4, 'below', d3)
assert d4.container().type() == 'tab'
assert d4.container() is d3.container()
assert d3.container().container() is d2.container().container()
assert d4.area is a
a.printState()
# layout now looks like:
# vcontainer
# hcontainer
# dock 1
# dock 2
# tcontainer
# dock 3
# dock 4
# test save/restore state
state = a.saveState()
a2 = da.DockArea()
# default behavior is to raise exception if docks are missing
with pytest.raises(Exception):
a2.restoreState(state)
# test restore with ignore missing
a2.restoreState(state, missing='ignore')
assert a2.topContainer is None
# test restore with auto-create
a2.restoreState(state, missing='create')
assert a2.saveState() == state
a2.printState()
# double-check that state actually matches the output of saveState()
c1 = a2.topContainer
assert c1.type() == 'vertical'
c2 = c1.widget(0)
c3 = c1.widget(1)
assert c2.type() == 'horizontal'
assert c2.widget(0).name() == 'dock 1'
assert c2.widget(1).name() == 'dock 2'
assert c3.type() == 'tab'
assert c3.widget(0).name() == 'dock 3'
assert c3.widget(1).name() == 'dock 4'
# test restore with docks already present
a3 = da.DockArea()
a3docks = []
for i in range(1, 5):
dock = da.Dock('dock %d' % i)
a3docks.append(dock)
a3.addDock(dock, 'right')
a3.restoreState(state)
assert a3.saveState() == state
# test restore with extra docks present
a3 = da.DockArea()
a3docks = []
for i in [1, 2, 5, 4, 3]:
dock = da.Dock('dock %d' % i)
a3docks.append(dock)
a3.addDock(dock, 'left')
a3.restoreState(state)
a3.printState()
# test a more complex restore
a4 = da.DockArea()
state1 = {'float': [], 'main':
('horizontal', [
('vertical', [
('horizontal', [
('tab', [
('dock', 'dock1', {}),
('dock', 'dock2', {}),
('dock', 'dock3', {}),
('dock', 'dock4', {})
], {'index': 1}),
('vertical', [
('dock', 'dock5', {}),
('horizontal', [
('dock', 'dock6', {}),
('dock', 'dock7', {})
], {'sizes': [184, 363]})
], {'sizes': [355, 120]})
], {'sizes': [9, 552]})
], {'sizes': [480]}),
('dock', 'dock8', {})
], {'sizes': [566, 69]})
}
state2 = {'float': [], 'main':
('horizontal', [
('vertical', [
('horizontal', [
('dock', 'dock2', {}),
('vertical', [
('dock', 'dock5', {}),
('horizontal', [
('dock', 'dock6', {}),
('dock', 'dock7', {})
], {'sizes': [492, 485]})
], {'sizes': [936, 0]})
], {'sizes': [172, 982]})
], {'sizes': [941]}),
('vertical', [
('dock', 'dock8', {}),
('dock', 'dock4', {}),
('dock', 'dock1', {})
], {'sizes': [681, 225, 25]})
], {'sizes': [1159, 116]})}
a4.restoreState(state1, missing='create')
# dock3 not mentioned in restored state; stays in dockarea by default
c, d = a4.findAll()
assert d['dock3'].area is a4
a4.restoreState(state2, missing='ignore', extra='float')
a4.printState()
c, d = a4.findAll()
# dock3 not mentioned in restored state; goes to float due to `extra` argument
assert d['dock3'].area is not a4
assert d['dock1'].container() is d['dock4'].container() is d['dock8'].container()
assert d['dock6'].container() is d['dock7'].container()
assert a4 is d['dock2'].area is d['dock2'].container().container().container()
assert a4 is d['dock5'].area is d['dock5'].container().container().container().container()
# States should be the same with two exceptions:
# dock3 is in a float because it does not appear in state2
# a superfluous vertical splitter in state2 has been removed
state4 = a4.saveState()
state4['main'][1][0] = state4['main'][1][0][1][0]
assert clean_state(state4['main']) == clean_state(state2['main'])
def clean_state(state):
# return state dict with sizes removed
ch = [clean_state(x) for x in state[1]] if isinstance(state[1], list) else state[1]
state = (state[0], ch, {})
if __name__ == '__main__':
test_dockarea()

View File

@ -20,108 +20,112 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
from UserDict import DictMixin
import sys
if sys.version[0] > '2':
from collections import OrderedDict
else:
from UserDict import DictMixin
class OrderedDict(dict, DictMixin):
class OrderedDict(dict, DictMixin):
def __init__(self, *args, **kwds):
if len(args) > 1:
raise TypeError('expected at most 1 arguments, got %d' % len(args))
try:
self.__end
except AttributeError:
self.clear()
self.update(*args, **kwds)
def __init__(self, *args, **kwds):
if len(args) > 1:
raise TypeError('expected at most 1 arguments, got %d' % len(args))
try:
self.__end
except AttributeError:
self.clear()
self.update(*args, **kwds)
def clear(self):
self.__end = end = []
end += [None, end, end] # sentinel node for doubly linked list
self.__map = {} # key --> [key, prev, next]
dict.clear(self)
def clear(self):
self.__end = end = []
end += [None, end, end] # sentinel node for doubly linked list
self.__map = {} # key --> [key, prev, next]
dict.clear(self)
def __setitem__(self, key, value):
if key not in self:
def __setitem__(self, key, value):
if key not in self:
end = self.__end
curr = end[1]
curr[2] = end[1] = self.__map[key] = [key, curr, end]
dict.__setitem__(self, key, value)
def __delitem__(self, key):
dict.__delitem__(self, key)
key, prev, next = self.__map.pop(key)
prev[2] = next
next[1] = prev
def __iter__(self):
end = self.__end
curr = end[2]
while curr is not end:
yield curr[0]
curr = curr[2]
def __reversed__(self):
end = self.__end
curr = end[1]
curr[2] = end[1] = self.__map[key] = [key, curr, end]
dict.__setitem__(self, key, value)
while curr is not end:
yield curr[0]
curr = curr[1]
def __delitem__(self, key):
dict.__delitem__(self, key)
key, prev, next = self.__map.pop(key)
prev[2] = next
next[1] = prev
def popitem(self, last=True):
if not self:
raise KeyError('dictionary is empty')
if last:
key = reversed(self).next()
else:
key = iter(self).next()
value = self.pop(key)
return key, value
def __iter__(self):
end = self.__end
curr = end[2]
while curr is not end:
yield curr[0]
curr = curr[2]
def __reduce__(self):
items = [[k, self[k]] for k in self]
tmp = self.__map, self.__end
del self.__map, self.__end
inst_dict = vars(self).copy()
self.__map, self.__end = tmp
if inst_dict:
return (self.__class__, (items,), inst_dict)
return self.__class__, (items,)
def __reversed__(self):
end = self.__end
curr = end[1]
while curr is not end:
yield curr[0]
curr = curr[1]
def keys(self):
return list(self)
def popitem(self, last=True):
if not self:
raise KeyError('dictionary is empty')
if last:
key = reversed(self).next()
else:
key = iter(self).next()
value = self.pop(key)
return key, value
setdefault = DictMixin.setdefault
update = DictMixin.update
pop = DictMixin.pop
values = DictMixin.values
items = DictMixin.items
iterkeys = DictMixin.iterkeys
itervalues = DictMixin.itervalues
iteritems = DictMixin.iteritems
def __reduce__(self):
items = [[k, self[k]] for k in self]
tmp = self.__map, self.__end
del self.__map, self.__end
inst_dict = vars(self).copy()
self.__map, self.__end = tmp
if inst_dict:
return (self.__class__, (items,), inst_dict)
return self.__class__, (items,)
def __repr__(self):
if not self:
return '%s()' % (self.__class__.__name__,)
return '%s(%r)' % (self.__class__.__name__, self.items())
def keys(self):
return list(self)
def copy(self):
return self.__class__(self)
setdefault = DictMixin.setdefault
update = DictMixin.update
pop = DictMixin.pop
values = DictMixin.values
items = DictMixin.items
iterkeys = DictMixin.iterkeys
itervalues = DictMixin.itervalues
iteritems = DictMixin.iteritems
@classmethod
def fromkeys(cls, iterable, value=None):
d = cls()
for key in iterable:
d[key] = value
return d
def __repr__(self):
if not self:
return '%s()' % (self.__class__.__name__,)
return '%s(%r)' % (self.__class__.__name__, self.items())
def copy(self):
return self.__class__(self)
@classmethod
def fromkeys(cls, iterable, value=None):
d = cls()
for key in iterable:
d[key] = value
return d
def __eq__(self, other):
if isinstance(other, OrderedDict):
if len(self) != len(other):
return False
for p, q in zip(self.items(), other.items()):
if p != q:
def __eq__(self, other):
if isinstance(other, OrderedDict):
if len(self) != len(other):
return False
return True
return dict.__eq__(self, other)
for p, q in zip(self.items(), other.items()):
if p != q:
return False
return True
return dict.__eq__(self, other)
def __ne__(self, other):
return not self == other
def __ne__(self, other):
return not self == other

24
test.py Normal file
View File

@ -0,0 +1,24 @@
"""
Script for invoking pytest with options to select Qt library
"""
import sys
import pytest
args = sys.argv[1:]
if '--pyside' in args:
args.remove('--pyside')
import PySide
elif '--pyqt4' in args:
args.remove('--pyqt4')
import PyQt4
elif '--pyqt5' in args:
args.remove('--pyqt5')
import PyQt5
import pyqtgraph as pg
pg.systemInfo()
pytest.main(args)