Merged ports branch with trunk

This commit is contained in:
Arjan Molenaar 2008-12-08 20:07:15 +00:00
parent fbb31fddcf
commit cdf08dffbe
23 changed files with 2212 additions and 613 deletions

23
demo.py
View File

@ -24,21 +24,19 @@ import math
import gtk
import cairo
from gaphas import Canvas, GtkView, View
from gaphas.examples import Box, Text, FatLine, Circle, DefaultExampleTool
from gaphas.examples import Box, BoxX, Text, FatLine, Circle
from gaphas.item import Line, NW, SE
from gaphas.tool import PlacementTool, HandleTool
from gaphas.tool import PlacementTool, HandleTool, LineSegmentTool
from gaphas.painter import ItemPainter
from gaphas import state
from gaphas.util import text_extents
from gaphas import painter
#painter.DEBUG_DRAW_BOUNDING_BOX = True
# Ensure data gets picked well:
import gaphas.picklers
#painter.DEBUG_DRAW_BOUNDING_BOX = True
# Global undo list
undo_list = []
@ -102,7 +100,6 @@ class MyText(Text):
def create_window(canvas, title, zoom=1.0):
view = GtkView()
view.tool = DefaultExampleTool()
w = gtk.Window()
w.set_title(title)
@ -161,7 +158,8 @@ def create_window(canvas, title, zoom=1.0):
def on_clicked(button):
if isinstance(view.focused_item, Line):
view.focused_item.split_segment(0)
tool = LineSegmentTool()
tool.split_segment(view.focused_item, 0)
view.queue_draw_item(view.focused_item, handles=True)
b.connect('clicked', on_clicked)
@ -354,7 +352,6 @@ def create_canvas(c=None):
fl.matrix.translate(100, 100)
c.add(fl)
circle = Circle()
h1, h2 = circle.handles()
circle.radius = 20
@ -372,13 +369,21 @@ def create_canvas(c=None):
# bb.matrix.rotate(math.pi/4.0 * i / 10.0)
# c.add(bb, parent=b)
b=BoxX()
b.min_width = 40
b.min_height = 50
b.width = b.height = 60
b.matrix.translate(55, 55)
c.add(b)
t=MyText('Single line')
t.matrix.translate(70,70)
c.add(t)
l=MyLine()
l.handles()[1].pos = (30, 30)
l.split_segment(0, 3)
tool = LineSegmentTool()
tool.split_segment(l, 0, 3)
l.matrix.translate(30, 60)
c.add(l)
l.orthogonal = True

4
doc/conf.py Normal file
View File

@ -0,0 +1,4 @@
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.todo']
project = 'Gaphas'
source_suffix = '.txt'
master_doc = 'contents'

14
doc/contents.txt Normal file
View File

@ -0,0 +1,14 @@
Gaphas Documentation
====================
.. toctree::
:maxdepth: 2
ports
state
undo
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

71
doc/ports.txt Normal file
View File

@ -0,0 +1,71 @@
Ports
=====
Port is a part of an item, which defines connectable part of an item.
This concept has been introduced Gaphas version 0.4.0 to make Gaphas
connection system more flexible.
Port Types
----------
There are two types of ports implemented in Gaphor (see `gaphas.connector`
module).
First one is point port, as is often use in
`EDA <http://en.wikipedia.org/wiki/Electronic_design_automation>`_
applications, a handle connecting to such port is always kept at specific
position, which equals to port's position.
Other port type is line port. A handle connecting to such port is kept on
a line defined by start and end positions of line port.
Line port is used by items provided by `gaphas.item` module. `Element`
item has one port defined at every edge of its rectangular shape (this is 4
ports). `Line` item has one port per line segment.
Different types of ports can be invented like circle port or area port, they
should implement interface defined by `gaphas.connector.Port` class to fit
into Gaphas' connection system.
Ports and Constraints (Covering Handles)
----------------------------------------
Diagram items can have internal constraints, which can be used to position
item's ports within an item itself.
For example, `Element` item could create constraints to position ports over
its edges of rectanglular area. The result is duplication of constraints as
`Element` already constraints position of handles to keep them in
a rectangle.
*Therefore, when port covers handles, then it should reference handle
positions*.
For example, an item, which is a horizontal line could be implemented
like::
class HorizontalLine(gaphas.item.Item):
def __init__(self):
super(HorizontalLine, self).__init__()
self.start = Handle()
self.end = Handle()
self.port = LinePort(self.start.pos, self.end.pos)
self.constraint(horizontal(self.start.pos, self.end.pos))
In case of `Element` item, each line port references positions of two
handles, which keeps ports to lie over edges of rectangle. The same applies
to `Line` item - every port is defined between positions of two neighbour
handles. When `Line` item is orthogonal, then handle and ports share the
same constraints, which guard line orthogonality.
This way, item's constraints are reused and their amount is limited to
minimum.
Connections
-----------
Connection between two items is established by creating a constraint
between handle's postion and port's positions (positions are constraint
solver variables).
`ConnectHandleTool` class provides functionality to allow an user to
perform connections between items.

View File

@ -270,7 +270,7 @@ children):
[<gaphas.item.Item object at 0x...>]
connector.py: Handle
---------------
--------------------
Changing the Handle's position is reversible:
>>> from gaphas import Handle
@ -375,8 +375,9 @@ This is our basis for further testing.
>>> del undo_list[:]
>>> l.split_segment(0)
[<Handle object on (5, 5)>]
>>> from gaphas import tool
>>> tool.LineSegmentTool().split_segment(l, 0) # doctest: +ELLIPSIS
([<Handle object on (5, 5)>], [<gaphas.connector.LinePort object at 0x...>])
>>> l.handles()
[<Handle object on (0, 0)>, <Handle object on (5, 5)>, <Handle object on (10, 10)>]

View File

@ -27,6 +27,7 @@ class Context(object):
... except: 'got exc'
'got exc'
"""
def __init__(self, **kwargs):
self.__dict__.update(**kwargs)
@ -41,7 +42,6 @@ class Canvas(object):
def __init__(self):
self._tree = tree.Tree()
self._tree_indexer = tree.TreeIndexer(self._tree, '_canvas_index')
self._solver = solver.Solver()
self._dirty_items = set()
self._dirty_matrix_items = set()
@ -255,14 +255,14 @@ class Canvas(object):
"""
Return a set of items that are connected to ``item``.
The list contains tuples (item, handle). As a result an item may be
in the list more than once (depending on the number of handles that
are connected). If ``item`` is connected to itself it will also appear
in the list.
in the list more than once (depending on the number of handles that
are connected). If ``item`` is connected to itself it will also appear
in the list.
>>> c = Canvas()
>>> from gaphas import item
>>> i = item.Line()
>>> c.add(i)
>>> c = Canvas()
>>> from gaphas import item
>>> i = item.Line()
>>> c.add(i)
>>> ii = item.Line()
>>> c.add(ii)
>>> iii = item.Line()
@ -304,7 +304,7 @@ class Canvas(object):
>>> s[0] is i1 and s[1] is i2 and s[2] is i3
True
"""
return self._tree_indexer.sort(items, reverse=reverse)
return self._tree.sort(items, index_key='_canvas_index', reverse=reverse)
#{ Matrices
@ -606,7 +606,7 @@ class Canvas(object):
Provide each item in the canvas with an index attribute. This makes
for fast searching of items.
"""
self._tree_indexer.index_tree()
self._tree.index_nodes('_canvas_index')
#{ Views
@ -685,6 +685,22 @@ class Canvas(object):
#self.update()
def project(self, item, *points):
"""
Project item's points into canvas coordinate system.
If there is only one point returned than projected point is
returned. If there are more than one points, then tuple of
projected points is returned.
"""
if len(points) == 1:
return CanvasProjection(points[0], item)
elif len(points) > 1:
return tuple(CanvasProjection(p, item) for p in points)
else:
raise AttributeError('There should be at least one point specified')
class VariableProjection(solver.Projection):
"""
Project a single `solver.Variable` to another space/coordinate system.

View File

@ -5,21 +5,30 @@ Basic connectors such as Ports and Handles.
__version__ = "$Revision: 2341 $"
# $HeadURL: https://svn.devjavu.com/gaphor/gaphas/trunk/gaphas/item.py $
from solver import solvable, WEAK, NORMAL, STRONG, VERY_STRONG
from state import observed, reversible_property, disable_dispatching
from gaphas.solver import solvable, WEAK, NORMAL, STRONG, VERY_STRONG
from gaphas.state import observed, reversible_property, disable_dispatching
from gaphas.geometry import distance_line_point, distance_point_point
from gaphas.constraint import LineConstraint, PositionConstraint
class Connector(object):
class VariablePoint(object):
"""
Basic object for connections.
A point constructed of two ``Variable``s.
>>> vp = VariablePoint((3, 5))
>>> vp.x, vp.y
(Variable(3, 20), Variable(5, 20))
>>> vp.pos
(Variable(3, 20), Variable(5, 20))
>>> vp[0], vp[1]
(Variable(3, 20), Variable(5, 20))
"""
_x = solvable(varname='_v_x')
_y = solvable(varname='_v_y')
def __init__(self, x=0, y=0, strength=NORMAL):
self._x = x
self._y = y
def __init__(self, pos, strength=NORMAL):
self._x, self._y = pos
self._x.strength = strength
self._y.strength = strength
@ -54,16 +63,16 @@ class Connector(object):
"""
Shorthand for returning the x(0) or y(1) component of the point.
>>> h = Handle(3, 5)
>>> h[0]
Variable(3, 20)
>>> h[1]
Variable(5, 20)
>>> h = VariablePoint((3, 5))
>>> h[0]
Variable(3, 20)
>>> h[1]
Variable(5, 20)
"""
return (self.x, self.y)[index]
class Handle(Connector):
class Handle(VariablePoint):
"""
Handles are used to support modifications of Items.
@ -77,8 +86,8 @@ class Handle(Connector):
not capable of pickling ``instancemethod`` or ``function`` objects.
"""
def __init__(self, x=0, y=0, strength=NORMAL, connectable=False, movable=True):
super(Handle, self).__init__(x, y, strength)
def __init__(self, pos=(0, 0), strength=NORMAL, connectable=False, movable=True):
super(Handle, self).__init__(pos, strength)
# Flags.. can't have enough of those
self._connectable = connectable
@ -131,39 +140,107 @@ class Handle(Connector):
disconnect = reversible_property(lambda s: s._disconnect or (lambda: None), _set_disconnect)
class Port(object):
"""
Port connectable part of an item. Item's handle connects to a port.
"""
def __init__(self):
super(Port, self).__init__()
self._connectable = True
@observed
def _set_pos(self, pos):
def _set_connectable(self, connectable):
self._connectable = connectable
connectable = reversible_property(lambda s: s._connectable, _set_connectable)
def glue(self, pos):
"""
Set handle position (Item coordinates).
Get glue point on the port and distance to the port.
"""
self.x, self.y = pos
raise NotImplemented('Glue method not implemented')
pos = property(lambda s: (s.x, s.y), _set_pos)
def __str__(self):
return '<%s object on (%g, %g)>' % (self.__class__.__name__, float(self.x), float(self.y))
__repr__ = __str__
def __getitem__(self, index):
def constraint(self, canvas, item, handle, glue_item):
"""
Shorthand for returning the x(0) or y(1) component of the point.
>>> h = Handle(3, 5)
>>> h[0]
Variable(3, 20)
>>> h[1]
Variable(5, 20)
Create connection constraint between item's handle and glue item.
"""
return (self.x, self.y)[index]
raise NotImplemented('Constraint method not implemented')
class Port(Connector):
class LinePort(Port):
"""
A Port functions as a place on an Item where a Handle can connect.
Port defined as a line between two handles.
"""
def __init__(self, x=0, y=0, strength=NORMAL):
super(Port, self).__init__(x, y, strength)
def __init__(self, start, end):
super(LinePort, self).__init__()
self.start = start
self.end = end
def glue(self, pos):
"""
Get glue point on the port and distance to the port.
>>> p1, p2 = (0.0, 0.0), (100.0, 100.0)
>>> port = LinePort(p1, p2)
>>> port.glue((50, 50))
((50.0, 50.0), 0.0)
>>> port.glue((0, 10))
((5.0, 5.0), 7.0710678118654755)
"""
d, pl = distance_line_point(self.start, self.end, pos)
return pl, d
def constraint(self, canvas, item, handle, glue_item):
"""
Create connection line constraint between item's handle and the
port.
"""
line = canvas.project(glue_item, self.start, self.end)
point = canvas.project(item, handle.pos)
return LineConstraint(line, point)
class PointPort(Port):
"""
Port defined as a point.
"""
def __init__(self, point):
super(PointPort, self).__init__()
self.point = point
def glue(self, pos):
"""
Get glue point on the port and distance to the port.
>>> h = Handle((10, 10))
>>> port = PointPort(h.pos)
>>> port.glue((10, 0))
((Variable(10, 20), Variable(10, 20)), 10.0)
"""
d = distance_point_point(self.point, pos)
return self.point, d
def constraint(self, canvas, item, handle, glue_item):
"""
Return connection position constraint between item's handle and the
port.
"""
origin = canvas.project(glue_item, self.point)
point = canvas.project(item, handle.pos)
return PositionConstraint(origin, point)
# vim: sw=4:et:ai

View File

@ -30,6 +30,7 @@ appropriate value.
from __future__ import division
import operator
import math
from solver import Projection
@ -140,17 +141,19 @@ class EqualsConstraint(Constraint):
Variable(10.8, 20)
"""
def __init__(self, a=None, b=None):
def __init__(self, a=None, b=None, delta=0.0):
super(EqualsConstraint, self).__init__(a, b)
self.a = a
self.b = b
self._delta = delta
def solve_for(self, var):
assert var in (self.a, self.b)
_update(*((var is self.a) and \
(self.a, self.b.value) or \
(self.b, self.a.value)))
(self.a, self.b.value + self._delta) or \
(self.b, self.a.value + self._delta)))
@ -505,4 +508,81 @@ class LineConstraint(Constraint):
_update(py, y)
class PositionConstraint(Constraint):
"""
Ensure that point is always in origin position.
Attributes:
- _origin: origin position
- _point: point to be in origin position
"""
def __init__(self, origin, point):
super(PositionConstraint, self).__init__(origin[0], origin[1],
point[0], point[1])
self._origin = origin
self._point = point
def solve_for(self, var=None):
"""
Ensure that point's coordinates are the same as coordinates of the
origin position.
"""
x, y = self._origin[0].value, self._origin[1].value
_update(self._point[0], x)
_update(self._point[1], y)
class LineAlignConstraint(Constraint):
"""
Ensure a point is kept on a line in position specified by align and padding
information.
Align is specified as a number between 0 and 1, for example
0
keep point at one end of the line
1
keep point at other end of the line
0.5
keep point in the middle of the line
Align can be adjusted with `delta` parameter, which specifies the padding of
the point.
:Attributes:
_line
Line defined by tuple ((x1, y1), (x2, y2)).
_point
Point defined by tuple (x, y).
_align
Align of point.
_delta
Padding of the align.
"""
def __init__(self, line, point, align=0.5, delta=0.0):
super(LineAlignConstraint, self).__init__(line[0][0], line[0][1], line[1][0], line[1][1], point[0], point[1])
self._line = line
self._point = point
self._align = align
self._delta = delta
def solve_for(self, var=None):
sx, sy = self._line[0]
ex, ey = self._line[1]
px, py = self._point
a = math.atan2(ey.value - sy.value, ex.value - sx.value)
x = sx.value + (ex.value - sx.value) * self._align + self._delta * math.cos(a)
y = sy.value + (ey.value - sy.value) * self._align + self._delta * math.sin(a)
_update(px, x)
_update(py, y)
# vim:sw=4:et:ai

View File

@ -6,13 +6,12 @@ These items are used in various tests.
__version__ = "$Revision$"
# $HeadURL$
from item import Handle, Element, Item
from item import NW, NE,SW, SE
from solver import solvable
from gaphas.item import Element, Item, NW, NE,SW, SE
from gaphas.connector import Handle, PointPort
from gaphas.constraint import LessThanConstraint, EqualsConstraint, \
BalanceConstraint
from gaphas.solver import solvable, WEAK
import tool
from constraint import LineConstraint, LessThanConstraint, EqualsConstraint
from canvas import CanvasProjection
from geometry import point_on_rectangle, distance_rectangle_point
from util import text_extents, text_align, text_multiline, path_ellipse
from cairo import Matrix
@ -38,17 +37,53 @@ class Box(Element):
c.set_source_rgb(0,0,0.8)
c.stroke()
def glue(self, item, handle, x, y):
"""
Special glue method used by the ConnectingHandleTool to find
a connection point.
"""
h = self._handles
h_se = h[SE]
r = (0, 0, h_se.x, h_se.y)
por = point_on_rectangle(r, (x, y), border=True)
p = distance_rectangle_point(r, (x, y))
return p, por
class BoxX(Box):
"""
It is a Box but with additional port (see "x" below).
NW +--------+ NE
| |
| |
| |x
| |
SW +--------+ SE
"""
def __init__(self, width=10, height=10):
super(BoxX, self).__init__(width, height)
self._hx = Handle(strength=WEAK)
#self._hx.movable = False
#self._hx.visible = False
self._handles.append(self._hx)
# define 'x' port
self._ports.append(PointPort(self._hx.pos))
# keep hx handle at right edge, at 80% of height of the box
ne = self._handles[NE]
se = self._handles[SE]
hxc1 = EqualsConstraint(ne.x, self._hx.x, delta=10)
#hxc2 = BalanceConstraint(band=(ne.y, se.y), v=self._hx.y, balance=0.8)
self._constraints.append(hxc1)
#self._constraints.append(hxc2)
def draw(self, context):
super(BoxX, self).draw(context)
c = context.cairo
# draw 'x' port
hx = self._hx
c.rectangle(hx.x - 20 , hx.y - 5, 20, 10)
c.rectangle(hx.x - 1 , hx.y - 1, 2, 2)
if context.hovered:
c.set_source_rgba(.0, .8, 0, .8)
else:
c.set_source_rgba(.9, .0, .0, .8)
c.fill_preserve()
c.set_source_rgb(0,0,0.8)
c.stroke()
class Text(Item):
@ -74,7 +109,7 @@ class Text(Item):
else:
text_align(cr, 0, 0, self.text, self.align_x, self.align_y)
def point(self, x, y):
def point(self, pos):
return 0
@ -143,151 +178,5 @@ class Circle(Item):
cr.stroke()
class DisconnectHandle(object):
def __init__(self, canvas, item, handle):
self.canvas = canvas
self.item = item
self.handle = handle
def __call__(self):
self.handle_disconnect()
def handle_disconnect(self):
canvas = self.canvas
item = self.item
handle = self.handle
try:
canvas.solver.remove_constraint(handle.connection_data)
except KeyError:
print 'constraint was already removed for', item, handle
pass # constraint was alreasy removed
else:
print 'constraint removed for', item, handle
handle.connection_data = None
handle.connected_to = None
# Remove disconnect handler:
handle.disconnect = None
class ConnectingHandleTool(tool.HandleTool):
"""
This is a HandleTool which supports a simple connection algorithm,
using LineConstraint.
"""
def glue(self, view, item, handle, wx, wy):
"""
It allows the tool to glue to a Box or (other) Line item.
The distance from the item to the handle is determined in canvas
coordinates, using a 10 pixel glue distance.
"""
if not handle.connectable:
return
# Make glue distance depend on the zoom ratio (should be about 10 pixels)
inverse = Matrix(*view.matrix)
inverse.invert()
#glue_distance, dummy = inverse.transform_distance(10, 0)
glue_distance = 10
glue_point = None
glue_item = None
for i in view.canvas.get_all_items():
if not i is item:
v2i = view.get_matrix_v2i(i).transform_point
ix, iy = v2i(wx, wy)
try:
distance, point = i.glue(item, handle, ix, iy)
if distance <= glue_distance:
glue_distance = distance
i2v = view.get_matrix_i2v(i).transform_point
glue_point = i2v(*point)
glue_item = i
except AttributeError:
pass
if glue_point:
v2i = view.get_matrix_v2i(item).transform_point
handle.x, handle.y = v2i(*glue_point)
return glue_item
def connect(self, view, item, handle, wx, wy):
"""
Connect a handle to another item.
In this "method" the following assumptios are made:
1. The only item that accepts handle connections are the Box instances
2. The only items with connectable handles are Line's
"""
def side(handle, glued):
handles = glued.handles()
hx, hy = view.get_matrix_i2v(item).transform_point(handle.x, handle.y)
ax, ay = view.get_matrix_i2v(glued).transform_point(handles[NW].x, handles[NW].y)
bx, by = view.get_matrix_i2v(glued).transform_point(handles[SE].x, handles[SE].y)
if abs(hx - ax) < 0.01:
return handles[NW], handles[SW]
elif abs(hy - ay) < 0.01:
return handles[NW], handles[NE]
elif abs(hx - bx) < 0.01:
return handles[NE], handles[SE]
else:
return handles[SW], handles[SE]
assert False
#print 'Handle.connect', view, item, handle, wx, wy
glue_item = self.glue(view, item, handle, wx, wy)
if glue_item and glue_item is handle.connected_to:
try:
view.canvas.solver.remove_constraint(handle.connection_data)
except KeyError:
pass # constraint was already removed
h1, h2 = side(handle, glue_item)
handle.connection_data = LineConstraint(line=(CanvasProjection(h1.pos, glue_item),
CanvasProjection(h2.pos, glue_item)),
point=CanvasProjection(handle.pos, item))
view.canvas.solver.add_constraint(handle.connection_data)
handle.disconnect = DisconnectHandle(view.canvas, item, handle)
return
# drop old connetion
if handle.connected_to:
handle.disconnect()
if glue_item:
if isinstance(glue_item, Element):
h1, h2 = side(handle, glue_item)
# Make a constraint that keeps into account item coordinates.
handle.connection_data = \
LineConstraint(line=(CanvasProjection(h1.pos, glue_item),
CanvasProjection(h2.pos, glue_item)),
point=CanvasProjection(handle.pos, item))
view.canvas.solver.add_constraint(handle.connection_data)
handle.connected_to = glue_item
handle.disconnect = DisconnectHandle(view.canvas, item, handle)
def disconnect(self, view, item, handle):
if handle.connected_to:
#print 'Handle.disconnect', view, item, handle
view.canvas.solver.remove_constraint(handle.connection_data)
def DefaultExampleTool():
"""
The default tool chain build from HoverTool, ItemTool and HandleTool.
"""
chain = tool.ToolChain()
chain.append(tool.HoverTool())
chain.append(ConnectingHandleTool())
chain.append(tool.ItemTool())
chain.append(tool.TextEditTool())
chain.append(tool.RubberbandTool())
return chain
# vim: sw=4:et:ai

View File

@ -10,9 +10,9 @@ from weakref import WeakKeyDictionary
from matrix import Matrix
from geometry import distance_line_point, distance_rectangle_point
from connector import Handle
from gaphas.connector import Handle, LinePort
from solver import solvable, WEAK, NORMAL, STRONG, VERY_STRONG
from constraint import EqualsConstraint, LessThanConstraint
from constraint import EqualsConstraint, LessThanConstraint, LineConstraint, LineAlignConstraint
from state import observed, reversible_method, reversible_pair, reversible_property, disable_dispatching
class Item(object):
@ -30,6 +30,7 @@ class Item(object):
- _canvas: canvas, which owns an item
- _handles: list of handles owned by an item
- _ports: list of ports, connectable areas of an item
- _matrix_i2c: item to canvas coordinates matrix
- _matrix_c2i: canvas to item coordinates matrix
- _matrix_i2v: item to view coordinates matrices
@ -42,6 +43,7 @@ class Item(object):
self._matrix = Matrix()
self._handles = []
self._constraints = []
self._ports = []
# used by gaphas.canvas.Canvas to hold conversion matrices
self._matrix_i2c = None
@ -153,13 +155,81 @@ class Item(object):
"""
pass
def constraint(self,
horizontal=None,
vertical=None,
left_of=None,
above=None,
line=None,
delta=0.0,
align=None):
"""
Utility method to create item's internal constraint between
two positions or between a position and a line.
Position is a tuple of coordinates, i.e. ``(2, 4)``.
Line is a tuple of positions, i.e. ``((2, 3), (4, 2))``.
This method shall not be used to create constraints between
two different items.
Created constraint is returned.
:Parameters:
horizontal=(p1, p2)
Keep positions ``p1`` and ``p2`` aligned horizontally.
vertical=(p1, p2)
Keep positions ``p1`` and ``p2`` aligned vertically.
left_of=(p1, p2)
Keep position ``p1`` on the left side of position ``p2``.
above=(p1, p2)
Keep position ``p1`` above position ``p2``.
line=(p, l)
Keep position ``p`` on line ``l``.
"""
cc = None # created constraint
if horizontal is not None:
p1, p2 = horizontal
cc = EqualsConstraint(p1[1], p2[1])
elif vertical is not None:
p1, p2 = vertical
cc = EqualsConstraint(p1[0], p2[0])
elif left_of is not None:
p1, p2 = left_of
cc = LessThanConstraint(p1[1], p2[1], delta)
elif above is not None:
p1, p2 = above
cc = LessThanConstraint(p1[0], p2[0], delta)
elif line is not None:
pos, l = line
if align is None:
cc = LineConstraint(line=l, point=pos)
else:
cc = LineAlignConstraint(line=l, point=pos, align=align, delta=delta)
else:
raise ValueError('Constraint incorrectly specified')
assert cc is not None
self._constraints.append(cc)
return cc
def handles(self):
"""
Return a list of handles owned by the item.
"""
return self._handles
def point(self, x, y):
def ports(self):
"""
Return list of ports.
"""
return self._ports
def point(self, pos):
"""
Get the distance from a point (``x``, ``y``) to the item.
``x`` and ``y`` are in item coordinates.
@ -209,34 +279,35 @@ class Element(Item):
super(Element, self).__init__()
self._handles = [ h(strength=VERY_STRONG) for h in [Handle]*4 ]
eq = EqualsConstraint
lt = LessThanConstraint
handles = self._handles
h_nw = handles[NW]
h_ne = handles[NE]
h_sw = handles[SW]
h_se = handles[SE]
# create minimal size constraints
self._c_min_w = LessThanConstraint(smaller=h_nw.x, bigger=h_se.x, delta=10)
self._c_min_h = LessThanConstraint(smaller=h_nw.y, bigger=h_se.y, delta=10)
# edge of element define default element ports
self._ports = [
LinePort(h_nw.pos, h_ne.pos),
LinePort(h_ne.pos, h_se.pos),
LinePort(h_se.pos, h_sw.pos),
LinePort(h_sw.pos, h_nw.pos)
]
# setup constraints
self.constraints.extend([
eq(a=h_nw.y, b=h_ne.y),
eq(a=h_nw.x, b=h_sw.x),
eq(a=h_se.y, b=h_sw.y),
eq(a=h_se.x, b=h_ne.x),
# set h_nw < h_se constraints
# with minimal size functionality
self._c_min_w,
self._c_min_h,
])
self.constraint(horizontal=(h_nw.pos, h_ne.pos))
self.constraint(horizontal=(h_se.pos, h_sw.pos))
self.constraint(vertical=(h_nw.pos, h_sw.pos))
self.constraint(vertical=(h_se.pos, h_ne.pos))
# create minimal size constraints
self._c_min_w = self.constraint(left_of=(h_nw.pos, h_se.pos), delta=10)
self._c_min_h = self.constraint(above=(h_nw.pos, h_se.pos), delta=10)
# set width/height when minimal size constraints exist
self.width = width
self.height = height
def setup_canvas(self):
super(Element, self).setup_canvas()
@ -328,13 +399,13 @@ class Element(Item):
min_height = reversible_property(lambda s: s._c_min_h.delta, _set_min_height)
def point(self, x, y):
def point(self, pos):
"""
Distance from the point (x, y) to the item.
"""
h = self._handles
hnw, hse = h[NW], h[SE]
return distance_rectangle_point(map(float, (hnw.x, hnw.y, hse.x, hse.y)), (x, y))
return distance_rectangle_point(map(float, (hnw.x, hnw.y, hse.x, hse.y)), pos)
class Line(Item):
@ -358,7 +429,9 @@ class Line(Item):
def __init__(self):
super(Line, self).__init__()
self._handles = [Handle(connectable=True), Handle(10, 10, connectable=True)]
self._handles = [Handle(connectable=True), Handle((10, 10), connectable=True)]
self._ports = []
self._update_ports()
self._line_width = 2
self._fuzziness = 0
@ -395,8 +468,8 @@ class Line(Item):
return
h = self._handles
if len(h) < 3:
self.split_segment(0)
#if len(h) < 3:
# self.split_segment(0)
eq = EqualsConstraint #lambda a, b: a - b
add = self.canvas.solver.add_constraint
cons = []
@ -478,98 +551,35 @@ class Line(Item):
reversible_pair(_reversible_insert_handle, _reversible_remove_handle, \
bind1={'index': lambda self, handle: self._handles.index(handle)})
@observed
def _reversible_insert_port(self, index, port):
self._ports.insert(index, port)
def split_segment(self, segment, parts=2):
@observed
def _reversible_remove_port(self, port):
self._ports.remove(port)
reversible_pair(_reversible_insert_port, _reversible_remove_port, \
bind1={'index': lambda self, port: self._ports.index(port)})
def _create_handle(self, pos, strength=WEAK):
return Handle(pos, strength=strength)
def _create_port(self, h1, h2):
return LinePort(h1.pos, h2.pos)
def _update_ports(self):
"""
Split one segment in the Line in ``parts`` equal pieces.
``segment`` 0 is the first segment (between handles 0 and 1).
The min number of parts is 2.
A list of new handles is returned.
Note that ``split_segment`` is not able to reconnect constraints that
are connected to the segment.
>>> a = Line()
>>> a.handles()[1].pos = (20, 0)
>>> len(a.handles())
2
>>> a.split_segment(0)
[<Handle object on (10, 0)>]
>>> a.handles()
[<Handle object on (0, 0)>, <Handle object on (10, 0)>, <Handle object on (20, 0)>]
A line segment can be split into multiple (equal) parts:
>>> b = Line()
>>> b.handles()[1].pos = (20, 16)
>>> b.handles()
[<Handle object on (0, 0)>, <Handle object on (20, 16)>]
>>> b.split_segment(0, parts=4)
[<Handle object on (5, 4)>, <Handle object on (10, 8)>, <Handle object on (15, 12)>]
>>> len(b.handles())
5
>>> b.handles()
[<Handle object on (0, 0)>, <Handle object on (5, 4)>, <Handle object on (10, 8)>, <Handle object on (15, 12)>, <Handle object on (20, 16)>]
Update line ports.
"""
assert parts >= 2
assert segment >= 0
def do_split(segment, parts):
h0 = self._handles[segment]
h1 = self._handles[segment + 1]
dx, dy = h1.x - h0.x, h1.y - h0.y
new_h = Handle(h0.x + dx / parts, h0.y + dy / parts, strength=WEAK)
self._reversible_insert_handle(segment + 1, new_h)
if parts > 2:
do_split(segment + 1, parts - 1)
do_split(segment, parts)
# Force orthogonal constraints to be recreated
self._update_orthogonal_constraints(self.orthogonal)
return self._handles[segment+1:segment+parts]
def merge_segment(self, segment, parts=2):
"""
Merge the ``segment`` and the next.
The parts parameter indicates how many segments should be merged
The deleted handles are returned as a list.
>>> a = Line()
>>> a.handles()[1].pos = (20, 0)
>>> _ = a.split_segment(0)
>>> a.handles()
[<Handle object on (0, 0)>, <Handle object on (10, 0)>, <Handle object on (20, 0)>]
>>> a.merge_segment(0)
[<Handle object on (10, 0)>]
>>> a.handles()
[<Handle object on (0, 0)>, <Handle object on (20, 0)>]
>>> try: a.merge_segment(0)
... except AssertionError: print 'okay'
okay
More than two segments can be merged at once:
>>> _ = a.split_segment(0)
>>> _ = a.split_segment(0)
>>> _ = a.split_segment(0)
>>> a.handles()
[<Handle object on (0, 0)>, <Handle object on (2.5, 0)>, <Handle object on (5, 0)>, <Handle object on (10, 0)>, <Handle object on (20, 0)>]
>>> a.merge_segment(0, parts=4)
[<Handle object on (2.5, 0)>, <Handle object on (5, 0)>, <Handle object on (10, 0)>]
>>> a.handles()
[<Handle object on (0, 0)>, <Handle object on (20, 0)>]
"""
assert len(self._handles) > 2, 'Not enough segments'
if 0 >= segment > len(self._handles) - 1:
raise IndexError("index out of range (0 > %d > %d)" % (segment, len(self._handles) - 1))
if segment == 0: segment = 1
deleted_handles = self._handles[segment:segment+parts-1]
self._reversible_remove_handle(self._handles[segment])
if parts > 2:
self.merge_segment(segment, parts - 1)
else:
# Force orthogonal constraints to be recreated
self._update_orthogonal_constraints(self.orthogonal)
return deleted_handles
assert len(self._handles) >= 2, 'Not enough segments'
self._ports = []
handles = self._handles
for h1, h2 in zip(handles[:-1], handles[1:]):
self._ports.append(LinePort(h1.pos, h2.pos))
def opposite(self, handle):
@ -593,7 +603,7 @@ class Line(Item):
h1, h0 = self._handles[-2:]
self._tail_angle = atan2(h1.y - h0.y, h1.x - h0.x)
def closest_segment(self, x, y):
def closest_segment(self, pos):
"""
Obtain a tuple (distance, point_on_line, segment).
Distance is the distance from point to the closest line segment
@ -601,32 +611,30 @@ class Line(Item):
Segment is the line segment closest to (x, y)
>>> a = Line()
>>> a.closest_segment(4, 5)
>>> a.closest_segment((4, 5))
(0.70710678118654757, (4.5, 4.5), 0)
"""
h = self._handles
# create a list of (distance, point_on_line) tuples:
distances = map(distance_line_point, h[:-1], h[1:], [(x, y)] * (len(h) - 1))
distances = map(distance_line_point, h[:-1], h[1:], [pos] * (len(h) - 1))
distances, pols = zip(*distances)
return reduce(min, zip(distances, pols, range(len(distances))))
def point(self, x, y):
def point(self, pos):
"""
>>> a = Line()
>>> a.handles()[1].pos = 30, 30
>>> a.split_segment(0)
[<Handle object on (15, 15)>]
>>> a.handles()[1].pos = 25, 5
>>> a.point(-1, 0)
>>> a._handles.append(a._create_handle((30, 30)))
>>> a.point((-1, 0))
1.0
>>> '%.3f' % a.point(5, 4)
>>> '%.3f' % a.point((5, 4))
'2.942'
>>> '%.3f' % a.point(29, 29)
>>> '%.3f' % a.point((29, 29))
'0.784'
"""
h = self._handles
distance, point, segment = self.closest_segment(x, y)
distance, point, segment = self.closest_segment(pos)
return max(0, distance - self.fuzziness)
def draw_head(self, context):
@ -666,6 +674,15 @@ class Line(Item):
draw_line_end(self._handles[-1], self._tail_angle, self.draw_tail)
cr.stroke()
### debug code to draw line ports
### cr.set_line_width(1)
### cr.set_source_rgb(1.0, 0.0, 0.0)
### for p in self.ports():
### cr.move_to(*p.start)
### cr.line_to(*p.end)
### cr.stroke()
__test__ = {
'Line._set_orthogonal': Line._set_orthogonal,

View File

@ -12,8 +12,9 @@ __version__ = "$Revision$"
from cairo import Matrix, ANTIALIAS_NONE, LINE_JOIN_ROUND
from canvas import Context
from geometry import Rectangle
from gaphas.canvas import Context
from gaphas.geometry import Rectangle
from gaphas.item import Line
DEBUG_DRAW_BOUNDING_BOX = False
@ -258,7 +259,6 @@ class BoundingBoxPainter(ItemPainter):
for item in items:
self._draw_item(item, view, cairo)
def paint(self, context):
cairo = context.cairo
view = context.view
@ -271,13 +271,15 @@ class HandlePainter(Painter):
Draw handles of items that are marked as selected in the view.
"""
def _draw_handles(self, item, view, cairo, opacity):
def _draw_handles(self, item, view, cairo, opacity=None):
"""
Draw handles for an item.
The handles are drawn in non-antialiased mode for clearity.
"""
cairo.save()
i2v = view.get_matrix_i2v(item)
if not opacity:
opacity = (item is view.focused_item) and .7 or .4
cairo.set_line_width(1)
@ -310,17 +312,13 @@ class HandlePainter(Painter):
view = context.view
canvas = view.canvas
cairo = context.cairo
# Selected items are already ordered:
focused = view.focused_item
for item in view.selected_items:
self._draw_handles(item, view, cairo,
opacity=(item is focused) and .7 or .4)
# Order matters here:
for item in canvas.sort(view.selected_items):
self._draw_handles(item, view, cairo)
# Draw nice opaque handles when hovering an item:
hovered = view.hovered_item
if hovered:
self._draw_handles(hovered, view, cairo, opacity=.25)
item = view.hovered_item
if item and item not in view.selected_items:
self._draw_handles(item, view, cairo, opacity=.25)
class ToolPainter(Painter):
@ -339,6 +337,41 @@ class ToolPainter(Painter):
cairo.restore()
class LineSegmentPainter(Painter):
"""
This painter draws pseudo-hanldes on gaphas.item.Line objects. Each
line can be split by dragging those points, which will result in
a new handle.
ConnectHandleTool take care of performing the user
interaction required for this feature.
"""
def paint(self, context):
view = context.view
item = view.hovered_item
if item and item is view.focused_item and isinstance(item, Line):
cr = context.cairo
h = item.handles()
for h1, h2 in zip(h[:-1], h[1:]):
cx = (h1.x + h2.x) / 2
cy = (h1.y + h2.y) / 2
cr.save()
cr.identity_matrix()
m = Matrix(*view.get_matrix_i2v(item))
cr.set_antialias(ANTIALIAS_NONE)
cr.translate(*m.transform_point(cx, cy))
cr.rectangle(-3, -3, 6, 6)
cr.set_source_rgba(0, 0.5, 0, .4)
cr.fill_preserve()
cr.set_source_rgba(.25, .25, .25, .6)
cr.set_line_width(1)
cr.stroke()
cr.restore()
def DefaultPainter():
"""
Default painter, containing item, handle and tool painters.
@ -346,6 +379,7 @@ def DefaultPainter():
chain = PainterChain()
chain.append(ItemPainter())
chain.append(HandlePainter())
chain.append(LineSegmentPainter())
chain.append(ToolPainter())
return chain

View File

@ -0,0 +1,69 @@
import unittest
from gaphas.solver import Variable
from gaphas.constraint import PositionConstraint, LineAlignConstraint
class PositionTestCase(unittest.TestCase):
def test_pos_constraint(self):
"""Test position constraint"""
x1, y1 = Variable(10), Variable(11)
x2, y2 = Variable(12), Variable(13)
pc = PositionConstraint(origin=(x1, y1), point=(x2, y2))
pc.solve_for()
# origin shall remain the same
self.assertEquals(10, x1)
self.assertEquals(11, y1)
# point shall be moved to origin
self.assertEquals(10, x2)
self.assertEquals(11, y2)
# change just x of origin
x1.value = 15
pc.solve_for()
self.assertEquals(15, x2)
# change just y of origin
y1.value = 14
pc.solve_for()
self.assertEquals(14, y2)
class LineAlignConstraintTestCase(unittest.TestCase):
"""
Line align constraint test case.
"""
def test_delta(self):
"""Test line align delta
"""
line = (Variable(0), Variable(0)), (Variable(30), Variable(20))
point = (Variable(15), Variable(10))
lc = LineAlignConstraint(line=line, point=point, align=0.5, delta=5)
lc.solve_for()
self.assertAlmostEqual(19.16, point[0].value, 0.01)
self.assertAlmostEqual(12.77, point[1].value, 0.01)
line[1][0].value = 40
line[1][1].value = 30
lc.solve_for()
self.assertAlmostEqual(24.00, point[0].value, 0.01)
self.assertAlmostEqual(18.00, point[1].value, 0.01)
def test_delta_below_zero(self):
"""Test line align with delta below zero
"""
line = (Variable(0), Variable(0)), (Variable(30), Variable(20))
point = (Variable(15), Variable(10))
lc = LineAlignConstraint(line=line, point=point, align=0.5, delta=-5)
lc.solve_for()
self.assertAlmostEqual(10.83, point[0].value, 0.01)
self.assertAlmostEqual(7.22, point[1].value, 0.01)
line[1][0].value = 40
line[1][1].value = 30
lc.solve_for()
self.assertAlmostEqual(16.25, point[0].value, 0.01)
self.assertAlmostEqual(12.00, point[1].value, 0.01)

96
gaphas/tests/test_item.py Normal file
View File

@ -0,0 +1,96 @@
"""
Generic gaphas item tests.
"""
import unittest
from gaphas.item import Item
from gaphas.constraint import LineAlignConstraint, LineConstraint, \
EqualsConstraint, LessThanConstraint
from gaphas.solver import Variable
class ItemConstraintTestCase(unittest.TestCase):
"""
Item constraint creation tests. The test check functionality of
`Item.constraint` method, not constraints themselves.
"""
def test_line_constraint(self):
"""
Test line creation constraint.
"""
item = Item()
pos = Variable(1), Variable(2)
line = (Variable(3), Variable(4)), (Variable(5), Variable(6))
item.constraint(line=(pos, line))
self.assertEquals(1, len(item._constraints))
c = item._constraints[0]
self.assertTrue(isinstance(c, LineConstraint))
self.assertEquals((1, 2), c._point)
self.assertEquals(((3, 4), (5, 6)), c._line)
def test_horizontal_constraint(self):
"""
Test horizontal constraint creation.
"""
item = Item()
p1 = Variable(1), Variable(2)
p2 = Variable(3), Variable(4)
item.constraint(horizontal=(p1, p2))
self.assertEquals(1, len(item._constraints))
c = item._constraints[0]
self.assertTrue(isinstance(c, EqualsConstraint))
# expect constraint on y-axis
self.assertEquals(2, c.a)
self.assertEquals(4, c.b)
def test_vertical_constraint(self):
"""
Test vertical constraint creation.
"""
item = Item()
p1 = Variable(1), Variable(2)
p2 = Variable(3), Variable(4)
item.constraint(vertical=(p1, p2))
self.assertEquals(1, len(item._constraints))
c = item._constraints[0]
self.assertTrue(isinstance(c, EqualsConstraint))
# expect constraint on x-axis
self.assertEquals(1, c.a)
self.assertEquals(3, c.b)
def test_left_of_constraint(self):
"""
Test "less than" constraint (horizontal) creation.
"""
item = Item()
p1 = Variable(1), Variable(2)
p2 = Variable(3), Variable(4)
item.constraint(left_of=(p1, p2))
self.assertEquals(1, len(item._constraints))
c = item._constraints[0]
self.assertTrue(isinstance(c, LessThanConstraint))
self.assertEquals(2, c.smaller)
self.assertEquals(4, c.bigger)
def test_above_constraint(self):
"""
Test "less than" constraint (vertical) creation.
"""
item = Item()
p1 = Variable(1), Variable(2)
p2 = Variable(3), Variable(4)
item.constraint(above=(p1, p2))
self.assertEquals(1, len(item._constraints))
c = item._constraints[0]
self.assertTrue(isinstance(c, LessThanConstraint))
self.assertEquals(1, c.smaller)
self.assertEquals(3, c.bigger)

View File

@ -20,8 +20,12 @@ def undo():
redo_list[:] = undo_list[:]
del undo_list[:]
class LineTestCase(unittest.TestCase):
class TestCaseBase(unittest.TestCase):
"""
Abstract test case class with undo support.
"""
def setUp(self):
state.observers.add(state.revert_handler)
state.subscribers.add(undo_handler)
@ -30,80 +34,71 @@ class LineTestCase(unittest.TestCase):
state.observers.remove(state.revert_handler)
state.subscribers.remove(undo_handler)
class LineTestCase(TestCaseBase):
"""
Basic line item tests.
"""
def test_initial_ports(self):
"""Test initial ports amount
"""
line = Line()
self.assertEquals(1, len(line.ports()))
def test_orthogonal_horizontal_undo(self):
"""
Orthogonal line constraints bug (#107)
"""Test orthogonal line constraints bug (#107)
"""
canvas = Canvas()
line = Line()
canvas.add(line)
assert len(canvas.solver._constraints) == 0
line.orthogonal = True
assert len(canvas.solver._constraints) == 2
after_ortho = set(canvas.solver._constraints)
del undo_list[:]
line.horizontal = True
assert len(canvas.solver._constraints) == 2
undo()
assert not line.horizontal
assert len(canvas.solver._constraints) == 2, canvas.solver._constraints
line.horizontal = True
assert line.horizontal
assert len(canvas.solver._constraints) == 2, canvas.solver._constraints
def test_orthogonal_line_split_segment(self):
canvas = Canvas()
line = Line()
canvas.add(line)
assert len(canvas.solver._constraints) == 0
line.orthogonal = True
assert len(canvas.solver._constraints) == 2
self.assertEquals(1, len(canvas.solver._constraints))
after_ortho = set(canvas.solver._constraints)
assert len(line.handles()) == 3
del undo_list[:]
line.horizontal = True
line.split_segment(0)
assert len(canvas.solver._constraints) == 3
assert len(line.handles()) == 4
self.assertEquals(1, len(canvas.solver._constraints))
undo()
assert len(canvas.solver._constraints) == 2
assert len(line.handles()) == 3
assert canvas.solver._constraints == after_ortho
self.assertFalse(line.horizontal)
self.assertEquals(1, len(canvas.solver._constraints))
line.split_segment(0)
line.horizontal = True
assert len(canvas.solver._constraints) == 3
assert len(line.handles()) == 4
after_split = set(canvas.solver._constraints)
self.assertTrue(line.horizontal)
self.assertEquals(1, len(canvas.solver._constraints))
del undo_list[:]
line.merge_segment(0)
def test_orthogonal_line_undo(self):
"""Test orthogonal line undo
"""
canvas = Canvas()
line = Line()
canvas.add(line)
assert len(canvas.solver._constraints) == 2
assert len(line.handles()) == 3
# start with no orthogonal constraints
assert len(canvas.solver._constraints) == 0
line.orthogonal = True
# check orthogonal constraints
assert len(canvas.solver._constraints) == 1
assert len(line.handles()) == 2
undo()
assert len(canvas.solver._constraints) == 3
assert len(line.handles()) == 4
assert canvas.solver._constraints == after_split
self.assertFalse(line.orthogonal)
self.assertEquals(0, len(canvas.solver._constraints))
self.assertEquals(2, len(line.handles()))
# vim:sw=4:et

View File

@ -161,16 +161,16 @@ class PickleTestCase(unittest.TestCase):
def test_pickle_with_gtk_view_with_connection(self):
canvas = create_canvas()
box = canvas._tree.nodes[1]
box = canvas._tree.nodes[0]
assert isinstance(box, Box)
line = canvas._tree.nodes[2]
assert isinstance(line, Line)
view = GtkView(canvas=canvas)
from gaphas.examples import ConnectingHandleTool
handle_tool = ConnectingHandleTool()
handle_tool.connect(view, line, line.handles()[0], 0, 0)
from gaphas.tool import ConnectHandleTool
handle_tool = ConnectHandleTool()
handle_tool.connect(view, line, line.handles()[0], (40, 0))
assert line.handles()[0].connected_to is box, line.handles()[0].connected_to
assert line.handles()[0].connection_data
assert line.handles()[0].disconnect

746
gaphas/tests/test_tool.py Normal file
View File

@ -0,0 +1,746 @@
"""
Test all the tools provided by gaphas.
"""
import unittest
from gaphas.tool import ConnectHandleTool, LineSegmentTool
from gaphas.canvas import Canvas
from gaphas.examples import Box
from gaphas.item import Item, Element, Line
from gaphas.view import View, GtkView
from gaphas.constraint import LineConstraint
from gaphas.canvas import Context
from gaphas import state
Event = Context
undo_list = []
redo_list = []
def undo_handler(event):
undo_list.append(event)
def undo():
apply_me = list(undo_list)
del undo_list[:]
apply_me.reverse()
for e in apply_me:
state.saveapply(*e)
redo_list[:] = undo_list[:]
del undo_list[:]
def simple_canvas(self):
"""
This decorator adds view, canvas and handle connection tool to a test
case. Two boxes and a line are added to the canvas as well.
"""
self.canvas = Canvas()
self.box1 = Box()
self.canvas.add(self.box1)
self.box1.matrix.translate(100, 50)
self.box1.width = 40
self.box1.height = 40
self.box1.request_update()
self.box2 = Box()
self.canvas.add(self.box2)
self.box2.matrix.translate(100, 150)
self.box2.width = 50
self.box2.height = 50
self.box2.request_update()
self.line = Line()
self.head = self.line.handles()[0]
self.tail = self.line.handles()[-1]
self.tail.pos = 100, 100
self.canvas.add(self.line)
self.canvas.update_now()
self.view = GtkView()
self.view.canvas = self.canvas
import gtk
win = gtk.Window()
win.add(self.view)
self.view.show()
self.view.update()
win.show()
self.tool = ConnectHandleTool()
class TestCaseBase(unittest.TestCase):
"""
Abstract test case class with undo support.
"""
def setUp(self):
state.observers.add(state.revert_handler)
state.subscribers.add(undo_handler)
simple_canvas(self)
def tearDown(self):
state.observers.remove(state.revert_handler)
state.subscribers.remove(undo_handler)
class ConnectHandleToolGlueTestCase(unittest.TestCase):
"""
Test handle connection tool glue method.
"""
def setUp(self):
simple_canvas(self)
def test_item_and_port_glue(self):
"""Test glue operation to an item and its ports"""
ports = self.box1.ports()
# glue to port nw-ne
item, port = self.tool.glue(self.view, self.line, self.head, (120, 50))
self.assertEquals(item, self.box1)
self.assertEquals(ports[0], port)
# glue to port ne-se
item, port = self.tool.glue(self.view, self.line, self.head, (140, 70))
self.assertEquals(item, self.box1)
self.assertEquals(ports[1], port)
# glue to port se-sw
item, port = self.tool.glue(self.view, self.line, self.head, (120, 90))
self.assertEquals(item, self.box1)
self.assertEquals(ports[2], port)
# glue to port sw-nw
item, port = self.tool.glue(self.view, self.line, self.head, (100, 70))
self.assertEquals(item, self.box1)
self.assertEquals(ports[3], port)
def test_failed_glue(self):
"""Test glue from too far distance"""
item, port = self.tool.glue(self.view, self.line, self.head, (90, 50))
self.assertTrue(item is None)
self.assertTrue(port is None)
def test_glue_call_can_glue_once(self):
"""Test if glue method calls can glue once only
Box has 4 ports. Every port is examined once per
ConnectHandleTool.glue method call. The purpose of this test is to
assure that ConnectHandleTool.can_glue is called once (for the
found port), it cannot be called four times (once for every port).
"""
# count ConnectHandleTool.can_glue calls
class Tool(ConnectHandleTool):
def __init__(self, *args):
super(Tool, self).__init__(*args)
self._calls = 0
def can_glue(self, *args):
self._calls += 1
return True
tool = Tool()
item, port = tool.glue(self.view, self.line, self.head, (120, 50))
assert item and port
self.assertEquals(1, tool._calls)
def test_glue_cannot_glue(self):
"""Test if glue method respects ConnectHandleTool.can_glue method"""
class Tool(ConnectHandleTool):
def can_glue(self, *args):
return False
tool = Tool()
item, port = tool.glue(self.view, self.line, self.head, (120, 50))
self.assertTrue(item is None)
self.assertTrue(port is None)
def test_glue_no_port_no_can_glue(self):
"""Test if glue method does not call ConnectHandleTool.can_glue method when port is not found"""
class Tool(ConnectHandleTool):
def __init__(self, *args):
super(Tool, self).__init__(*args)
self._calls = 0
def can_glue(self, *args):
self._calls += 1
tool = Tool()
# at 300, 50 there should be no item
item, port = tool.glue(self.view, self.line, self.head, (300, 50))
assert item is None and port is None
self.assertEquals(0, tool._calls)
class ConnectHandleToolConnectTestCase(unittest.TestCase):
def setUp(self):
simple_canvas(self)
def _get_line(self):
line = Line()
head = self.line.handles()[0]
self.canvas.add(line)
return line, head
def test_connect(self):
"""Test connection to an item"""
line, head = self._get_line()
self.tool.connect(self.view, line, head, (120, 50))
self.assertEquals(self.box1, head.connected_to)
self.assertTrue(head.connection_data is not None)
self.assertTrue(isinstance(head.connection_data, LineConstraint))
self.assertTrue(head.disconnect is not None)
line, head = self._get_line()
self.tool.connect(self.view, line, head, (90, 50))
self.assertTrue(head.connected_to is None)
self.assertTrue(head.connection_data is None)
def test_disconnect(self):
"""Test disconnection from an item"""
line, head = self._get_line()
self.tool.connect(self.view, line, head, (120, 50))
assert head.connected_to is not None
self.tool.disconnect(self.view, line, head)
self.assertTrue(head.connected_to is None)
self.assertTrue(head.connection_data is None)
def test_reconnect_another(self):
"""Test reconnection to another item"""
line, head = self._get_line()
self.tool.connect(self.view, line, head, (120, 50))
assert head.connected_to is not None
item = head.connected_to
constraint = head.connection_data
assert item == self.box1
assert item != self.box2
# connect to box2, handle's connected item and connection data
# should differ
self.tool.connect(self.view, line, head, (120, 150))
assert head.connected_to is not None
self.assertEqual(self.box2, head.connected_to)
self.assertNotEqual(item, head.connected_to)
self.assertNotEqual(constraint, head.connection_data)
def test_reconnect_same(self):
"""Test reconnection to same item"""
line, head = self._get_line()
self.tool.connect(self.view, line, head, (120, 50))
assert head.connected_to is not None
item = head.connected_to
constraint = head.connection_data
assert item == self.box1
assert item != self.box2
# connect to box1 again, handle's connected item should be the same
# but connection constraint will differ
connected = self.tool.connect(self.view, line, head, (120, 50))
assert head.connected_to is not None
self.assertEqual(self.box1, head.connected_to)
self.assertNotEqual(constraint, head.connection_data)
def test_find_port(self):
"""Test finding a port
"""
line, head = self._get_line()
p1, p2, p3, p4 = self.box1.ports()
head.pos = 110, 50
port = self.tool.find_port(line, head, self.box1)
self.assertEquals(p1, port)
head.pos = 140, 60
port = self.tool.find_port(line, head, self.box1)
self.assertEquals(p2, port)
head.pos = 110, 95
port = self.tool.find_port(line, head, self.box1)
self.assertEquals(p3, port)
head.pos = 100, 55
port = self.tool.find_port(line, head, self.box1)
self.assertEquals(p4, port)
class LineSegmentToolTestCase(unittest.TestCase):
"""
Line segment tool tests.
"""
def setUp(self):
simple_canvas(self)
def test_split(self):
"""Test if line is splitted while pressing it in the middle
"""
tool = LineSegmentTool()
def dummy_grab(): pass
context = Context(view=self.view,
grab=dummy_grab,
ungrab=dummy_grab)
head, tail = self.line.handles()
self.view.hovered_item = self.line
self.view.focused_item = self.line
tool.on_button_press(context, Event(x=50, y=50, state=0))
self.assertEquals(3, len(self.line.handles()))
self.assertEquals(self.head, head)
self.assertEquals(self.tail, tail)
def test_merge(self):
"""Test if line is merged by moving handle onto adjacent handle
"""
tool = LineSegmentTool()
def dummy_grab(): pass
context = Context(view=self.view,
grab=dummy_grab,
ungrab=dummy_grab)
self.view.hovered_item = self.line
self.view.focused_item = self.line
tool.on_button_press(context, Event(x=50, y=50, state=0))
# start with 2 segments
assert len(self.line.handles()) == 3
# try to merge, now
tool.on_button_release(context, Event(x=0, y=0, state=0))
self.assertEquals(2, len(self.line.handles()))
def test_merged_segment(self):
"""Test if proper segment is merged
"""
tool = LineSegmentTool()
def dummy_grab(): pass
context = Context(view=self.view,
grab=dummy_grab,
ungrab=dummy_grab)
self.view.hovered_item = self.line
self.view.focused_item = self.line
tool.on_button_press(context, Event(x=50, y=50, state=0))
tool.on_button_press(context, Event(x=75, y=75, state=0))
# start with 3 segments
assert len(self.line.handles()) == 4
# ports to be removed
port1 = self.line.ports()[0]
port2 = self.line.ports()[1]
# try to merge, now
tool.grab_handle(self.line, self.line.handles()[1])
tool.on_button_release(context, Event(x=0, y=0, state=0))
# check if line merging was performed
assert len(self.line.handles()) == 3
# check if proper segments were merged
self.assertFalse(port1 in self.line.ports())
self.assertFalse(port2 in self.line.ports())
class LineSplitTestCase(TestCaseBase):
"""
Tests for line splitting.
"""
def test_split_single(self):
"""Test single line splitting
"""
# we start with two handles and one port, after split 3 handles are
# expected and 2 ports
assert len(self.line.handles()) == 2
assert len(self.line.ports()) == 1
old_port = self.line.ports()[0]
tool = LineSegmentTool()
handles, ports = tool.split_segment(self.line, 0)
handle = handles[0]
self.assertEquals(1, len(handles))
self.assertEquals((50, 50), handle.pos)
self.assertEquals(3, len(self.line.handles()))
self.assertEquals(2, len(self.line.ports()))
# new handle is between old handles
self.assertEquals(handle, self.line.handles()[1])
# and old port is deleted
self.assertTrue(old_port not in self.line.ports())
# check ports order
p1, p2 = self.line.ports()
h1, h2, h3 = self.line.handles()
self.assertEquals(h1.pos, p1.start)
self.assertEquals(h2.pos, p1.end)
self.assertEquals(h2.pos, p2.start)
self.assertEquals(h3.pos, p2.end)
def test_split_multiple(self):
"""Test multiple line splitting
"""
self.line.handles()[1].pos = (20, 16)
handles = self.line.handles()
old_ports = self.line.ports()[:]
# start with two handles, split into 4 segments - 3 new handles to
# be expected
assert len(handles) == 2
assert len(old_ports) == 1
tool = LineSegmentTool()
handles, ports = tool.split_segment(self.line, 0, count=4)
self.assertEquals(3, len(handles))
h1, h2, h3 = handles
self.assertEquals((5, 4), h1.pos)
self.assertEquals((10, 8), h2.pos)
self.assertEquals((15, 12), h3.pos)
# new handles between old handles
self.assertEquals(5, len(self.line.handles()))
self.assertEquals(h1, self.line.handles()[1])
self.assertEquals(h2, self.line.handles()[2])
self.assertEquals(h3, self.line.handles()[3])
self.assertEquals(4, len(self.line.ports()))
# and old port is deleted
self.assertTrue(old_ports[0] not in self.line.ports())
# check ports order
p1, p2, p3, p4 = self.line.ports()
h1, h2, h3, h4, h5 = self.line.handles()
self.assertEquals(h1.pos, p1.start)
self.assertEquals(h2.pos, p1.end)
self.assertEquals(h2.pos, p2.start)
self.assertEquals(h3.pos, p2.end)
self.assertEquals(h3.pos, p3.start)
self.assertEquals(h4.pos, p3.end)
self.assertEquals(h4.pos, p4.start)
self.assertEquals(h5.pos, p4.end)
def test_ports_after_split(self):
"""Test ports removal after split
"""
self.line.handles()[1].pos = (20, 16)
tool = LineSegmentTool()
tool.split_segment(self.line, 0)
handles = self.line.handles()
old_ports = self.line.ports()[:]
# start with 3 handles and two ports
assert len(handles) == 3
assert len(old_ports) == 2
# do split of first segment again
# first port should be deleted, but 2nd one should remain untouched
tool.split_segment(self.line, 0)
self.assertFalse(old_ports[0] in self.line.ports())
self.assertEquals(old_ports[1], self.line.ports()[2])
def test_constraints_after_split(self):
"""Test if constraints are recreated after line split
"""
tool = LineSegmentTool()
# connect line2 to self.line
line2 = Line()
self.canvas.add(line2)
head = line2.handles()[0]
self.tool.connect(self.view, line2, head, (25, 25))
self.assertEquals(self.line, head.connected_to)
tool.split_segment(self.line, 0)
assert len(self.line.handles()) == 3
h1, h2, h3 = self.line.handles()
# connection shall be reconstrained between 1st and 2nd handle
c1 = head.connection_data
self.assertEquals(h1.pos, c1._line[0]._point)
self.assertEquals(h2.pos, c1._line[1]._point)
def test_split_undo(self):
"""Test line splitting undo
"""
self.line.handles()[1].pos = (20, 0)
# we start with two handles and one port, after split 3 handles and
# 2 ports are expected
assert len(self.line.handles()) == 2
assert len(self.line.ports()) == 1
tool = LineSegmentTool()
tool.split_segment(self.line, 0)
assert len(self.line.handles()) == 3
assert len(self.line.ports()) == 2
# after undo, 2 handles and 1 port are expected again
undo()
self.assertEquals(2, len(self.line.handles()))
self.assertEquals(1, len(self.line.ports()))
def test_orthogonal_line_split(self):
"""Test orthogonal line splitting
"""
# start with no orthogonal constraints
assert len(self.line._orthogonal_constraints) == 0
self.line.orthogonal = True
# check orthogonal constraints
self.assertEquals(1, len(self.line._orthogonal_constraints))
self.assertEquals(2, len(self.line.handles()))
LineSegmentTool().split_segment(self.line, 0)
# 3 handles and 2 ports are expected
# 2 constraints keep the self.line orthogonal
self.assertEquals(2, len(self.line._orthogonal_constraints))
self.assertEquals(3, len(self.line.handles()))
self.assertEquals(2, len(self.line.ports()))
def test_params_errors(self):
"""Test parameter error exceptions
"""
tool = LineSegmentTool()
# there is only 1 segment
line = Line()
self.assertRaises(ValueError, tool.split_segment, line, -1)
line = Line()
self.assertRaises(ValueError, tool.split_segment, line, 1)
line = Line()
# can't split into one or less segment :)
self.assertRaises(ValueError, tool.split_segment, line, 0, 1)
class LineMergeTestCase(TestCaseBase):
"""
Tests for line merging.
"""
def test_merge_first_single(self):
"""Test single line merging starting from 1st segment
"""
tool = LineSegmentTool()
self.line.handles()[1].pos = (20, 0)
tool.split_segment(self.line, 0)
# we start with 3 handles and 2 ports, after merging 2 handles and
# 1 port are expected
assert len(self.line.handles()) == 3
assert len(self.line.ports()) == 2
old_ports = self.line.ports()[:]
handles, ports = tool.merge_segment(self.line, 0)
# deleted handles and ports
self.assertEquals(1, len(handles))
self.assertEquals(2, len(ports))
# handles and ports left after segment merging
self.assertEquals(2, len(self.line.handles()))
self.assertEquals(1, len(self.line.ports()))
self.assertTrue(handles[0] not in self.line.handles())
self.assertTrue(ports[0] not in self.line.ports())
self.assertTrue(ports[1] not in self.line.ports())
# old ports are completely removed as they are replaced by new one
# port
self.assertEquals(old_ports, ports)
# finally, created port shall span between first and last handle
port = self.line.ports()[0]
self.assertEquals((0, 0), port.start)
self.assertEquals((20, 0), port.end)
def test_constraints_after_merge(self):
"""Test if constraints are recreated after line merge
"""
tool = LineSegmentTool()
def dummy_grab(): pass
context = Context(view=self.view,
grab=dummy_grab,
ungrab=dummy_grab)
# connect line2 to self.line
line2 = Line()
self.canvas.add(line2)
head = line2.handles()[0]
self.tool.connect(self.view, line2, head, (25, 25))
self.assertEquals(self.line, head.connected_to)
tool.split_segment(self.line, 0)
assert len(self.line.handles()) == 3
c1 = head.connection_data
tool.merge_segment(self.line, 0)
assert len(self.line.handles()) == 2
h1, h2 = self.line.handles()
# connection shall be reconstrained between 1st and 2nd handle
c2 = head.connection_data
self.assertEquals(c2._line[0]._point, h1.pos)
self.assertEquals(c2._line[1]._point, h2.pos)
self.assertFalse(c1 == c2)
def test_merge_multiple(self):
"""Test multiple line merge
"""
tool = LineSegmentTool()
self.line.handles()[1].pos = (20, 16)
tool.split_segment(self.line, 0, count=3)
# start with 4 handles and 3 ports, merge 3 segments
assert len(self.line.handles()) == 4
assert len(self.line.ports()) == 3
print self.line.handles()
handles, ports = tool.merge_segment(self.line, 0, count=3)
self.assertEquals(2, len(handles))
self.assertEquals(3, len(ports))
self.assertEquals(2, len(self.line.handles()))
self.assertEquals(1, len(self.line.ports()))
self.assertTrue(not set(handles).intersection(set(self.line.handles())))
self.assertTrue(not set(ports).intersection(set(self.line.ports())))
# finally, created port shall span between first and last handle
port = self.line.ports()[0]
self.assertEquals((0, 0), port.start)
self.assertEquals((20, 16), port.end)
def test_merge_undo(self):
"""Test line merging undo
"""
tool = LineSegmentTool()
self.line.handles()[1].pos = (20, 0)
# split for merging
tool.split_segment(self.line, 0)
assert len(self.line.handles()) == 3
assert len(self.line.ports()) == 2
# clear undo stack before merging
del undo_list[:]
# merge with empty undo stack
tool.merge_segment(self.line, 0)
assert len(self.line.handles()) == 2
assert len(self.line.ports()) == 1
# after merge undo, 3 handles and 2 ports are expected again
undo()
self.assertEquals(3, len(self.line.handles()))
self.assertEquals(2, len(self.line.ports()))
def test_orthogonal_line_merge(self):
"""Test orthogonal line merging
"""
self.assertEquals(12, len(self.canvas.solver._constraints))
tool = LineSegmentTool()
self.line.handles()[-1].pos = 100, 100
# prepare the self.line for merging
tool.split_segment(self.line, 0)
tool.split_segment(self.line, 0)
self.line.orthogonal = True
self.assertEquals(12 + 3, len(self.canvas.solver._constraints))
self.assertEquals(4, len(self.line.handles()))
self.assertEquals(3, len(self.line.ports()))
# test the merging
tool.merge_segment(self.line, 0)
self.assertEquals(12 + 2, len(self.canvas.solver._constraints))
self.assertEquals(3, len(self.line.handles()))
self.assertEquals(2, len(self.line.ports()))
def test_params_errors(self):
"""Test parameter error exceptions
"""
tool = LineSegmentTool()
line = Line()
tool.split_segment(line, 0)
# no segment -1
self.assertRaises(ValueError, tool.merge_segment, line, -1)
line = Line()
tool.split_segment(line, 0)
# no segment no 2
self.assertRaises(ValueError, tool.merge_segment, line, 2)
line = Line()
tool.split_segment(line, 0)
# can't merge one or less segments :)
self.assertRaises(ValueError, tool.merge_segment, line, 0, 1)
line = Line()
# can't merge line with one segment
self.assertRaises(ValueError, tool.merge_segment, line, 0)
line = Line()
tool.split_segment(line, 0)
# 2 segments: no 0 and 1. cannot merge as there are no segments
# after segment no 1
self.assertRaises(ValueError, tool.merge_segment, line, 1)
line = Line()
tool.split_segment(line, 0)
# 2 segments: no 0 and 1. cannot merge 3 segments as there are no 3
# segments
self.assertRaises(ValueError, tool.merge_segment, line, 0, 3)
# vim: sw=4:et:ai

View File

@ -13,30 +13,6 @@ from gaphas.tool import ToolContext, HoverTool
class ViewTestCase(unittest.TestCase):
def test_selecting_items(self):
"""
Test selection of items
"""
canvas = Canvas()
view = GtkView(canvas)
window = gtk.Window(gtk.WINDOW_TOPLEVEL)
window.add(view)
window.show_all()
box = Box()
canvas.add(box)
view.focused_item = box
assert view.focused_item is box
assert len(view.selected_items) == 1
view.select_item(box)
assert len(view.selected_items) == 1
view.unselect_all()
assert len(view.selected_items) == 0
assert view.focused_item is None
def test_bounding_box_calculations(self):
"""
A view created before and after the canvas is populated should contain
@ -57,7 +33,7 @@ class ViewTestCase(unittest.TestCase):
line = Line()
line.fyzzyness = 1
line.handles()[1].pos = (30, 30)
line.split_segment(0, 3)
#line.split_segment(0, 3)
line.matrix.translate(30, 60)
canvas.add(line)
@ -103,8 +79,8 @@ class ViewTestCase(unittest.TestCase):
assert len(view._qtree._ids) == 1
assert not view._qtree._bucket.bounds == (0, 0, 0, 0), view._qtree._bucket.bounds
assert view.get_item_at_point(10, 10) is box
assert view.get_item_at_point(60, 10) is None
assert view.get_item_at_point((10, 10)) is box
assert view.get_item_at_point((60, 10)) is None
window.destroy()
@ -210,35 +186,7 @@ class ViewTestCase(unittest.TestCase):
assert not box._matrix_v2i.has_key(view)
def test_setting_canvas(self):
"""
Test if everything is reset properly after a new canvas is set on
view.
"""
canvas = Canvas()
view = View(canvas)
box = Box()
canvas.add(box)
view.focused_item = box
view.hovered_item = box
view.dropzone_item = box
assert len(view.selected_items) > 0
# check registered_views
# check _qtree data
view.canvas = Canvas()
assert view.selected_items == []
assert view.focused_item is None
assert view.hovered_item is None
assert view.dropzone_item is None
if __name__ == '__main__':
unittest.main()

View File

@ -13,6 +13,7 @@ Current tools:
ItemTool - handle selection and movement of items
HandleTool - handle selection and movement of handles
RubberbandTool - for Rubber band selection
PanTool - for easily moving the canvas around
PlacementTool - for placing items on the canvas
Maybe even:
@ -24,11 +25,14 @@ Maybe even:
__version__ = "$Revision$"
# $HeadURL$
import sys
import cairo
import gtk
from canvas import Context
from item import Element
from geometry import Rectangle
from gaphas.canvas import Context
from gaphas.geometry import Rectangle
from gaphas.geometry import distance_point_point_fast, distance_line_point
from gaphas.item import Line
DEBUG_TOOL = False
DEBUG_TOOL_CHAIN = False
@ -131,6 +135,13 @@ class Tool(object):
if DEBUG_TOOL: print 'on_key_release', context, event
pass
def on_scroll(self, context, event):
"""
Scroll wheel was turned.
"""
if DEBUG_TOOL: print 'on_scroll', context, event, event.direction
pass
def draw(self, context):
"""
Some tools (such as Rubberband selection) may need to draw something
@ -275,6 +286,9 @@ class ToolChain(Tool):
def on_key_release(self, context, event):
self._handle('on_key_release', context, event)
def on_scroll(self, context, event):
self._handle('on_scroll', context, event)
def draw(self, context):
if self._grabbed_tool:
self._grabbed_tool.draw(context)
@ -291,7 +305,7 @@ class HoverTool(Tool):
def on_motion_notify(self, context, event):
view = context.view
old_hovered = view.hovered_item
view.hovered_item = view.get_item_at_point(event.x, event.y)
view.hovered_item = view.get_item_at_point((event.x, event.y))
return None
@ -377,8 +391,7 @@ class ItemTool(Tool):
class HandleTool(Tool):
"""
Tool for moving handles around. By default this tool does not provide
connecting handles to another item (see examples.ConnectingHandleTool for
an example).
connecting handles to another item (see `ConnectHandleTool`).
"""
def __init__(self):
@ -445,30 +458,29 @@ class HandleTool(Tool):
return None, None
def move(self, view, item, handle, x, y):
def move(self, view, item, handle, pos):
"""
Move the handle to position ``(x,y)``. ``x`` and ``y`` are in
item coordnates. ``item`` is the item whose ``handle`` is moved.
"""
handle.x = x
handle.y = y
handle.x, handle.y = pos
def glue(self, view, item, handle, vx, vy):
def glue(self, view, item, handle, vpos):
"""
Find an item that ``handle`` can connect to. ``item`` is the ``Item``
owing the handle.
``vx`` and ``vy`` are the pointer (view) coordinates.
``vpos`` is the point in the pointer (view) coordinates.
The ``glue()`` code should take care of moving ``handle`` to the
correct position, creating a glue effect.
"""
def connect(self, view, item, handle, vx, vy):
def connect(self, view, item, handle, vpos):
"""
Find an item that ``handle`` can connect to and create a connection.
``item`` is the ``Item`` owning the handle.
``vx`` and ``vy`` are the pointer (view) coordinates.
``vpos`` is the point in the pointer (view) coordinates.
A typical connect action may involve the following:
@ -529,7 +541,7 @@ class HandleTool(Tool):
try:
view = context.view
if grabbed_handle and grabbed_handle.connectable:
self.connect(view, grabbed_item, grabbed_handle, event.x, event.y)
self.connect(view, grabbed_item, grabbed_handle, (event.x, event.y))
finally:
context.ungrab()
self.ungrab_handle()
@ -554,14 +566,14 @@ class HandleTool(Tool):
x, y = v2i.transform_point(event.x, event.y)
# Do the actual move:
self.move(view, item, handle, x, y)
self.move(view, item, handle, (x, y))
# do not request matrix update as matrix recalculation will be
# performed due to item normalization if required
item.request_update(matrix=False)
try:
if self._grabbed_handle.connectable:
self.glue(view, item, handle, event.x, event.y)
self.glue(view, item, handle, (event.x, event.y))
# TODO: elif isinstance(item, Element):
# schedule (item, handle) to be handled by some "guides" tool
# that tries to align the handle with some other Element's
@ -617,6 +629,67 @@ class RubberbandTool(Tool):
cr.fill()
class PanTool(Tool):
"""
Captures drag events with the middle mouse button and uses them to
translate the canvas within the view. Trumps the ZoomTool, so should be
placed later in the ToolChain.
"""
def __init__(self):
self.x0, self.y0 = 0, 0
self.speed = 10
def on_button_press(self,context,event):
if event.button == 2:
context.grab()
self.x0, self.y0 = event.x, event.y
return True
def on_button_release(self, context, event):
context.ungrab()
self.x0, self.y0 = event.x, event.y
return True
def on_motion_notify(self, context, event):
if event.state & gtk.gdk.BUTTON2_MASK:
view = context.view
dx = self.x0 - event.x
dy = self.y0 - event.y
if dx:
adj = context.view.hadjustment
adj.value = adj.value + dx
adj.value_changed()
self.x0 = event.x
if dy:
adj = context.view.vadjustment
adj.value = adj.value + dy
adj.value_changed()
self.y0 = event.y
return True
def on_scroll(self, context, event):
direction = event.direction
gdk = gtk.gdk
adj = None
if direction == gdk.SCROLL_LEFT:
adj = context.view.hadjustment
adj.value = adj.value - self.speed
elif direction == gdk.SCROLL_RIGHT:
adj = context.view.hadjustment
adj.value = adj.value + self.speed
elif direction == gdk.SCROLL_UP:
adj = context.view.vadjustment
adj.value = adj.value - self.speed
elif direction == gdk.SCROLL_DOWN:
adj = context.view.vadjustment
adj.value = adj.value + self.speed
if adj:
adj.value_changed()
return True
class PlacementTool(Tool):
def __init__(self, factory, handle_tool, handle_index):
@ -635,7 +708,7 @@ class PlacementTool(Tool):
def on_button_press(self, context, event):
view = context.view
canvas = view.canvas
new_item = self._create_item(context, event.x, event.y)
new_item = self._create_item(context, (event.x, event.y))
# Enforce matrix update, as a good matrix is required for the handle
# positioning:
canvas.get_matrix_i2c(new_item, calculate=True)
@ -651,11 +724,11 @@ class PlacementTool(Tool):
return True
def _create_item(self, context, x, y):
def _create_item(self, context, pos):
view = context.view
canvas = view.canvas
item = self._factory()
x, y = view.get_matrix_v2i(item).transform_point(x, y)
x, y = view.get_matrix_v2i(item).transform_point(*pos)
item.matrix.translate(x, y)
return item
@ -725,15 +798,515 @@ class TextEditTool(Tool):
widget.destroy()
class ConnectHandleTool(HandleTool):
"""
This is a handle tool which allows to connect item's handle to another
item's port.
"""
GLUE_DISTANCE = 10
def glue(self, view, item, handle, vpos):
"""
Glue to an item.
Look for items in glue rectangle (which is defined by ``vpos`` (vx, vy)
and glue distance) and find the closest port.
Glue position is found for closest port as well. Handle of
connecting item is moved to glue point.
Return found item and its connection port or `(None, None)` if
not found.
:Parameters:
view
View used by user.
item
Connecting item.
handle
Handle of connecting item.
"""
if not handle.connectable:
return None
dist = self.GLUE_DISTANCE
max_dist = dist
port = None
glue_pos = None
glue_item = None
v2i = view.get_matrix_v2i
vx, vy = vpos
rect = (vx - dist, vy - dist, dist * 2, dist * 2)
items = view.get_items_in_rectangle(rect, reverse=True)
for i in items:
if i is item:
continue
for p in i.ports():
if not p.connectable:
continue
ix, iy = v2i(i).transform_point(vx, vy)
pg, d = p.glue((ix, iy))
if d >= max_dist:
continue
glue_item = i
port = p
# transform coordinates from connectable item space to view
# space
i2v = view.get_matrix_i2v(i).transform_point
glue_pos = i2v(*pg)
# check if item and glue item can be connected on closest port
if port is not None \
and not self.can_glue(view, item, handle, glue_item, port):
glue_item, port = None, None
if port is not None:
# transport coordinates from view space to connecting item
# space and update position connecting item's handle
v2i = view.get_matrix_v2i(item).transform_point
handle.pos = v2i(*glue_pos)
return glue_item, port
def can_glue(self, view, item, handle, glue_item, port):
"""
Determine if item's handle can connect to glue item's port.
`True` is returned by default. Override this method to disallow
glueing on higher level (i.e. because classes of item and glue item
does not much, etc.).
:Parameters:
view
View used by user.
item
Item connecting to glue item.
handle
Connecting handle of the item.
glue_item
Connectable item.
port
Port of connectable item.
"""
return True
def pre_connect(self, view, item, handle, glue_item, port):
"""
The method is invoked just before connection is performed by
`ConnectHandleTool.connect` method. It can be overriden by deriving
tools to perform higher level connection.
`True` is returned to indicate that higher level connection is
performed.
"""
return True
def connect(self, view, item, handle, vpos):
"""
Connect a handle of connecting item to connectable item.
Connectable item is found by `glue` method.
Return `True` if connection is performed.
:Parameters:
view
View used by user.
item
Connectable item.
handle
Handle of connecting item.
"""
glue_item, port = self.glue(view, item, handle, vpos)
# disconnect when
# - no glued item
# - currently connected item is not glue item
if not glue_item \
or glue_item and handle.connected_to is not glue_item:
handle.disconnect()
# no glue item, no connection
if not glue_item:
return
# connection on higher level
self.pre_connect(view, item, handle, glue_item, port)
# low-level connection
self.post_connect(view.canvas, item, handle, glue_item, port)
def post_connect(self, canvas, item, handle, glue_item, port):
"""
Create constraint between item's handle and port of glue item.
"""
ConnectHandleTool.create_constraint(item, handle, glue_item, port)
handle.connected_to = glue_item
handle.disconnect = DisconnectHandle(canvas, item, handle)
def disconnect(self, view, item, handle):
if handle.disconnect:
handle.disconnect()
@staticmethod
def find_port(line, handle, item):
"""
Find port of an item at position of line's handle.
:Parameters:
line
Line supposed to connect to an item.
handle
Handle of a line connecting to an item.
item
Item to be connected to a line.
"""
port = None
max_dist = sys.maxint
canvas = item.canvas
# line's handle position to canvas coordinates
i2c = canvas.get_matrix_i2c(line)
hx, hy = i2c.transform_point(*handle.pos)
# from canvas to item coordinates
c2i = canvas.get_matrix_c2i(item)
ix, iy = c2i.transform_point(hx, hy)
# find the port using item's coordinates
for p in item.ports():
pg, d = p.glue((ix, iy))
if d >= max_dist:
continue
port = p
max_dist = d
return port
@staticmethod
def create_constraint(line, handle, item, port):
"""
Create connection constraint between line's handle and item's port.
If constraint already exists, then it is removed and new constraint
is created instead.
:Parameters:
line
Line connecting to an item.
handle
Handle of a line connecting to an item.
item
Item to be connected to a line.
port
Item's port used for connection with a line.
"""
canvas = line.canvas
solver = canvas.solver
if handle.connection_data:
solver.remove_constraint(handle.connection_data)
constraint = port.constraint(canvas, line, handle, item)
handle.connection_data = constraint
solver.add_constraint(constraint)
@staticmethod
def remove_constraint(line, handle):
"""
Remove connection constraint created between line's handle and
connected item's port.
:Parameters:
line
Line connecting to an item.
handle
Handle of a line connecting to an item.
"""
if handle.connection_data:
line.canvas.solver.remove_constraint(handle.connection_data)
handle.connection_data = None
class DisconnectHandle(object):
def __init__(self, canvas, item, handle):
self.canvas = canvas
self.item = item
self.handle = handle
def __call__(self):
self.handle_disconnect()
def handle_disconnect(self):
canvas = self.canvas
item = self.item
handle = self.handle
try:
canvas.solver.remove_constraint(handle.connection_data)
except KeyError:
print 'constraint was already removed for', item, handle
pass # constraint was alreasy removed
else:
print 'constraint removed for', item, handle
handle.connection_data = None
handle.connected_to = None
# Remove disconnect handler:
handle.disconnect = None
class LineSegmentTool(ConnectHandleTool):
"""
Line segment tool provides functionality for splitting and merging line
segments.
Line segment is defined by two adjacent handles.
Line splitting is performed by clicking a line in the middle of
a segment.
Line merging is performed by moving a handle onto adjacent handle.
Please note, that this tool provides functionality without involving
context menu. Any further research into line spliting/merging
functionality should take into account this assumption and new
improvements (or even this tool replacement) shall be behavior based.
It is possible to use this tool from a menu by using
`LineSegmentTool.split_segment` and `LineSegmentTool.merge_segment`
methods.
"""
def split_segment(self, line, segment, count=2):
"""
Split one line segment into ``count`` equal pieces.
Two lists are returned
- list of created handles
- list of created ports
:Parameters:
line
Line item, which segment shall be split.
segment
Segment number to split (starting from zero).
count
Amount of new segments to be created (minimum 2).
"""
if segment < 0 or segment >= len(line.ports()):
raise ValueError('Incorrect segment')
if count < 2:
raise ValueError('Incorrect count of segments')
def do_split(segment, count):
handles = line.handles()
h0 = handles[segment]
h1 = handles[segment + 1]
dx, dy = h1.x - h0.x, h1.y - h0.y
new_h = line._create_handle((h0.x + dx / count, h0.y + dy / count))
line._reversible_insert_handle(segment + 1, new_h)
p0 = line._create_port(h0, new_h)
p1 = line._create_port(new_h, h1)
line._reversible_remove_port(line.ports()[segment])
line._reversible_insert_port(segment, p0)
line._reversible_insert_port(segment + 1, p1)
if count > 2:
do_split(segment + 1, count - 1)
# get rid of connection constraints (to be recreated later)
citems, chandles = self._remove_constraints(line)
do_split(segment, count)
# force orthogonal constraints to be recreated
line._update_orthogonal_constraints(line.orthogonal)
# recreate connection constraints
self._recreate_constraints(citems, chandles, line)
handles = line.handles()[segment + 1:segment + count]
ports = line.ports()[segment:segment + count - 1]
return handles, ports
def merge_segment(self, line, segment, count=2):
"""
Merge two (or more) line segments.
Tuple of two lists is returned, list of deleted handles and list of
deleted ports.
:Parameters:
line
Line item, which segments shall be merged.
segment
Segment number to start merging from (starting from zero).
count
Amount of segments to be merged (minimum 2).
"""
if len(line.ports()) < 2:
raise ValueError('Cannot merge line with one segment')
if segment < 0 or segment >= len(line.ports()):
raise ValueError('Incorrect segment')
if count < 2 or segment + count > len(line.ports()):
raise ValueError('Incorrect count of segments')
# get rid of connection constraints (to be recreated later)
citems, chandles = self._remove_constraints(line)
# remove handle and ports which share position with handle
deleted_handles = line.handles()[segment + 1:segment + count]
deleted_ports = line.ports()[segment:segment + count]
for h in deleted_handles:
line._reversible_remove_handle(h)
for p in deleted_ports:
line._reversible_remove_port(p)
# create new port, which replaces old ports destroyed due to
# deleted handle
h1 = line.handles()[segment]
h2 = line.handles()[segment + 1]
port = line._create_port(h1, h2)
line._reversible_insert_port(segment, port)
# force orthogonal constraints to be recreated
line._update_orthogonal_constraints(line.orthogonal)
# recreate connection constraints
self._recreate_constraints(citems, chandles, line)
return deleted_handles, deleted_ports
def _recreate_constraints(self, lines, handles, item):
"""
Create connection constraints between connecting lines and an item.
:Parameters:
lines
Lines connecting to an item.
handles
Handles connecting to an item.
item
Item connected to lines.
"""
for line, h in zip(lines, handles):
port = ConnectHandleTool.find_port(line, h, item)
ConnectHandleTool.create_constraint(line, h, item, port)
def _remove_constraints(self, item):
"""
Remove connection constraints established between an item and all
connecting items.
List of connecting items and list of connecting handles are
returned.
:Parameters:
item
Item, to which connections shall be removed.
"""
lines = []
handles = []
if item.canvas: # no canvas, no connections
data = item.canvas.get_connected_items(item)
for line, h in data:
ConnectHandleTool.remove_constraint(line, h)
if data:
lines, handles = zip(*data)
return lines, handles
def on_button_press(self, context, event):
"""
In addition to the normal behaviour, the button press event creates
new handles if it is activated on the middle of a line segment.
"""
if super(LineSegmentTool, self).on_button_press(context, event):
return True
view = context.view
item = view.hovered_item
if item and item is view.focused_item and isinstance(item, Line):
handles = item.handles()
x, y = view.get_matrix_v2i(item).transform_point(event.x, event.y)
for h1, h2 in zip(handles[:-1], handles[1:]):
xp = (h1.x + h2.x) / 2
yp = (h1.y + h2.y) / 2
if distance_point_point_fast((x,y), (xp, yp)) <= 4:
segment = handles.index(h1)
self.split_segment(item, segment)
self.grab_handle(item, item.handles()[segment + 1])
context.grab()
return True
def on_button_release(self, context, event):
"""
In addition to the normal behavior, the button release event
removes line segment if grabbed handle is close enough to an
adjacent handle.
"""
grabbed_handle = self._grabbed_handle
grabbed_item = self._grabbed_item
if super(LineSegmentTool, self).on_button_release(context, event):
if grabbed_handle and grabbed_item:
handles = grabbed_item.handles()
# don't merge using first or last handle
if handles[0] is grabbed_handle or handles[-1] is grabbed_handle:
return True
handle_index = handles.index(grabbed_handle)
segment = handle_index - 1
# cannot merge starting from last segment
if segment == len(grabbed_item.ports()) - 1:
segment =- 1
assert segment >= 0 and segment < len(grabbed_item.ports()) - 1
before = handles[handle_index - 1]
after = handles[handle_index + 1]
d, p = distance_line_point(before.pos, after.pos, grabbed_handle.pos)
if d < 2:
assert len(context.view.canvas.solver._marked_cons) == 0
self.merge_segment(grabbed_item, segment)
return True
def DefaultTool():
"""
The default tool chain build from HoverTool, ItemTool and HandleTool.
"""
chain = ToolChain().\
append(HoverTool()).\
append(HandleTool()).\
append(ItemTool()).\
append(TextEditTool()).\
#append(ConnectHandleTool()). \
chain = ToolChain(). \
append(HoverTool()). \
append(LineSegmentTool()). \
append(PanTool()). \
append(ItemTool()). \
append(TextEditTool()). \
append(RubberbandTool())
return chain

View File

@ -158,6 +158,59 @@ class Tree(object):
yield parent
parent = self.get_parent(parent)
def index_nodes(self, index_key):
"""
Provide each item in the tree with an index attribute. This makes
for fast sorting of items.
>>> class A(object):
... def __init__(self, n):
... self.n = n
... def __repr__(self):
... return self.n
>>> t = Tree()
>>> a = A('a')
>>> t.add(a)
>>> t.add(A('b'))
>>> t.add(A('c'), parent=a)
>>> t.nodes
[a, c, b]
>>> t.index_nodes('my_key')
>>> t.nodes[0].my_key, t.nodes[1].my_key, t.nodes[2].my_key
(0, 1, 2)
For sorting, see ``sort()``.
"""
nodes = self.nodes
lnodes = len(nodes)
map(setattr, nodes, [index_key] * lnodes, xrange(lnodes))
def sort(self, nodes, index_key, reverse=False):
"""
Sort a set (or list) of nodes.
>>> class A(object):
... def __init__(self, n):
... self.n = n
... def __repr__(self):
... return self.n
>>> t = Tree()
>>> a = A('a')
>>> t.add(a)
>>> t.add(A('b'))
>>> t.add(A('c'), parent=a)
>>> t.nodes # the series from Tree.index_nodes
[a, c, b]
>>> t.index_nodes('my_key')
>>> selection = (t.nodes[2], t.nodes[1])
>>> t.sort(selection, index_key='my_key')
[c, b]
"""
if index_key:
return sorted(nodes, key=attrgetter(index_key), reverse=reverse)
else:
raise NotImplemented('index_key should be provided.')
def _add_to_nodes(self, node, parent):
"""
Called only from add()
@ -281,84 +334,6 @@ class Tree(object):
disable_dispatching(_remove)
class TreeIndexer(object):
"""
The ``TreeIndexer`` is a straight-forward indexer that adds an
index-attribute to each object in the tree. This attribute can be used
to do fast sorting.
"""
def __init__(self, tree, index_key):
self._tree = tree
self._index_key = index_key
def index_tree(self):
"""
Provide each item in the tree with an index attribute. This makes
for fast sorting of items.
>>> class A(object):
... def __init__(self, n):
... self.n = n
... def __repr__(self):
... return self.n
>>> t = Tree()
>>> a = A('a')
>>> t.add(a)
>>> t.add(A('b'))
>>> t.add(A('c'), parent=a)
>>> t.nodes
[a, c, b]
>>> idx = TreeIndexer(t, 'my_key')
>>> idx.index_tree()
>>> t.nodes[0].my_key, t.nodes[1].my_key, t.nodes[2].my_key
(0, 1, 2)
For sorting, see ``sort()``.
"""
nodes = self._tree.nodes
lnodes = len(nodes)
map(setattr, nodes, [self._index_key] * lnodes, xrange(lnodes))
def sort(self, nodes, reverse=False):
"""
Sort a set (or list) of nodes to the order defined by the ``Tree``.
>>> class A(object):
... def __init__(self, n):
... self.n = n
... def __repr__(self):
... return self.n
>>> t = Tree()
>>> a = A('a')
>>> t.add(a)
>>> t.add(A('b'))
>>> t.add(A('c'), parent=a)
>>> t.nodes # the series from Tree.index_nodes
[a, c, b]
>>> idx = TreeIndexer(t, 'my_key')
>>> idx.index_tree()
>>> selection = (t.nodes[2], t.nodes[1])
>>> idx.sort(selection)
[c, b]
If the tree is not yet indexed, it is indexed on the fly.
>>> idx = TreeIndexer(t, 'my_key')
>>> selection = (t.nodes[2], t.nodes[1])
>>> idx.sort(selection)
[c, b]
"""
try:
return sorted(nodes, key=attrgetter(self._index_key), reverse=reverse)
except AttributeError:
# Looks like not all elements are indexed, so let's do it instantly
self.index_tree()
return sorted(nodes, key=attrgetter(self._index_key), reverse=reverse)
__test__ = {
'Tree.add': Tree.add,
'Tree.remove': Tree.remove,

View File

@ -34,7 +34,7 @@ class View(object):
self._painter = DefaultPainter()
# Handling selections.
self._selected_items = list()
self._selected_items = set()
self._focused_item = None
self._hovered_item = None
self._dropzone_item = None
@ -58,17 +58,18 @@ class View(object):
if self._canvas:
self._qtree = Quadtree()
# Handling selections.
del self._selected_items[:]
self._focused_item = None
self._hovered_item = None
self._dropzone_item = None
self._canvas = canvas
canvas = property(lambda s: s._canvas, _set_canvas)
def emit(self, args, **kwargs):
"""
Placeholder method for signal emission functionality.
"""
pass
def select_item(self, item):
"""
Select an item. This adds @item to the set of selected items. Do::
@ -79,8 +80,7 @@ class View(object):
"""
self.queue_draw_item(item)
if item not in self._selected_items:
self._selected_items.append(item)
self._selected_items = self._canvas.sort(self._selected_items)
self._selected_items.add(item)
self.emit('selection-changed', self._selected_items)
@ -90,7 +90,7 @@ class View(object):
"""
self.queue_draw_item(item)
if item in self._selected_items:
self._selected_items.remove(item)
self._selected_items.discard(item)
self.emit('selection-changed', self._selected_items)
@ -104,7 +104,7 @@ class View(object):
Clearing the selected_item also clears the focused_item.
"""
self.queue_draw_item(*self._selected_items)
del self._selected_items[:]
self._selected_items.clear()
self.focused_item = None
self.emit('selection-changed', self._selected_items)
@ -197,22 +197,21 @@ class View(object):
painter = property(lambda s: s._painter, _set_painter)
def get_item_at_point(self, x, y, selected=True):
def get_item_at_point(self, pos, selected=True):
"""
Return the topmost item located at (x, y).
Return the topmost item located at ``pos`` (x, y).
Parameters:
- selected: if False returns first non-selected item
"""
point = (x, y)
items = self._qtree.find_intersect((x, y, 1, 1))
items = self._qtree.find_intersect((pos[0], pos[1], 1, 1))
for item in self._canvas.sort(items, reverse=True):
if not selected and item in self.selected_items:
continue # skip selected items
v2i = self.get_matrix_v2i(item)
ix, iy = v2i.transform_point(x, y)
if item.point(ix, iy) < 0.5:
ix, iy = v2i.transform_point(*pos)
if item.point((ix, iy)) < 0.5:
return item
return None
@ -338,21 +337,6 @@ class View(object):
pass
def emit(self, *args, **kwargs):
"""
Placeholder method for signal emission functionality.
"""
pass
def queue_draw_item(self, *items):
"""
Placeholder method. Items that should be redrawn should be queued
by this method.
"""
pass
# Map GDK events to tool methods
EVENT_HANDLERS = {
gtk.gdk.BUTTON_PRESS: 'on_button_press',
@ -361,7 +345,8 @@ EVENT_HANDLERS = {
gtk.gdk._3BUTTON_PRESS: 'on_triple_click',
gtk.gdk.MOTION_NOTIFY: 'on_motion_notify',
gtk.gdk.KEY_PRESS: 'on_key_press',
gtk.gdk.KEY_RELEASE: 'on_key_release'
gtk.gdk.KEY_RELEASE: 'on_key_release',
gtk.gdk.SCROLL: 'on_scroll'
}
@ -412,7 +397,8 @@ class GtkView(gtk.DrawingArea, View):
| gtk.gdk.BUTTON_RELEASE_MASK
| gtk.gdk.POINTER_MOTION_MASK
| gtk.gdk.KEY_PRESS_MASK
| gtk.gdk.KEY_RELEASE_MASK)
| gtk.gdk.KEY_RELEASE_MASK
| gtk.gdk.SCROLL_MASK)
self._hadjustment = hadjustment or gtk.Adjustment()
self._vadjustment = vadjustment or gtk.Adjustment()
@ -500,10 +486,13 @@ class GtkView(gtk.DrawingArea, View):
adjustment.page_increment = viewport_size
adjustment.step_increment = viewport_size/10
adjustment.upper = adjustment.value + canvas_offset + canvas_size
adjustment.lower = adjustment.value + canvas_offset
adjustment.lower = 0 #adjustment.value + canvas_offset
if adjustment.value > adjustment.upper - viewport_size:
adjustment.value = adjustment.upper - viewport_size
elif adjustment.value < 0:
adjustment.value = 0
@async(single=True)
def update_adjustments(self, allocation=None):
@ -580,7 +569,7 @@ class GtkView(gtk.DrawingArea, View):
for item in removed_items:
self._qtree.remove(item)
self.selected_items.remove(item)
self.selected_items.discard(item)
if self.focused_item in removed_items:
self.focused_item = None

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
VERSION = '0.3.7'
VERSION = '0.4.0'
from ez_setup import use_setuptools