Remove gaphas.state module

This commit is contained in:
Arjan Molenaar 2021-01-10 12:12:59 +01:00
parent 1e0fa71449
commit dc5e026b51
17 changed files with 12 additions and 1258 deletions

View File

@ -39,7 +39,6 @@ Table of Contents
tools
connectors
solver
state
.. toctree::
:caption: Advanced

View File

@ -1,195 +0,0 @@
State management
================
.. important:: This functionality will be removed.
A special word should be mentioned about state management. Managing state is
the first step in creating an undo system.
The state system consists of two parts:
1. A basic observer (the ``@observed`` decorator)
2. A reverser
Observer
--------
The observer simply dispatches the function called (as ``<function ..>``, not as
``<unbound method..>``!) to each handler registered in an observers list.
>>> from gaphas import state
>>> state.observers.clear()
>>> state.subscribers.clear()
Let's start with creating a Canvas instance and some items:
>>> from gaphas.canvas import Canvas
>>> from examples.exampleitems import Circle
>>> canvas = Canvas()
>>> item1, item2 = Circle(), Circle()
For this demonstration let's use the Canvas class (which contains an add/remove
method pair).
It works (see how the add method automatically schedules the item for update):
>>> def handler(event):
... print('event handled', event)
>>> state.observers.add(handler)
>>> canvas.add(item1) # doctest: +ELLIPSIS
event handled (<function Canvas.add at ...>, (<gaphas.canvas.Canvas object at ...>, <examples.exampleitems.Circle object at ...>), {})
>>> canvas.add(item2, parent=item1) # doctest: +ELLIPSIS
event handled (<function Canvas.add at ...>, (<gaphas.canvas.Canvas object at ...>, <examples.exampleitems.Circle object at ...>), {'parent': <examples.exampleitems.Circle object at ...>})
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<examples.exampleitems.Circle object at 0x...>, <examples.exampleitems.Circle object at 0x...>]
Note that the handler is invoked before the actual call is made. This is
important if you want to store the (old) state for an undo mechanism.
Remember that this observer is just a simple method call notifier and knows
nothing about the internals of the ``Canvas`` class (in this case the
``remove()`` method recursively calls ``remove()`` for each of it's children).
Therefore some careful crafting of methods may be necessary in order to get the
right effect (items should be removed in the right order, child first):
>>> canvas.remove(item1) # doctest: +ELLIPSIS
event handled (<function Canvas._remove at ...>, (<gaphas.canvas.Canvas object at 0x...>, <examples.exampleitems.Circle object at 0x...>), {})
event handled (<function Canvas._remove at ...>, (<gaphas.canvas.Canvas object at 0x...>, <examples.exampleitems.Circle object at 0x...>), {})
>>> list(canvas.get_all_items())
[]
The ``@observed`` decorator can also be applied to properties, as is done in
gaphas/connector.py's Handle class:
>>> from gaphas.solver import Variable
>>> var = Variable()
>>> var.value = 10 # doctest: +ELLIPSIS
event handled (<function Variable.set_value at 0x...>, (Variable(0, 20), 10), {})
(this is simply done by observing the setter method).
Off course handlers can be removed as well (only the default revert handler
is present now):
>>> state.observers.remove(handler)
>>> state.observers # doctest: +ELLIPSIS
set()
What should you know:
1. The observer always generates events based on 'function' calls. Even for
class method invocations. This is because, when calling a method (say
Tree.add) it's the ``im_func`` field is executed, which is a function type
object.
2. It's important to know if an event came from invoking a method or a simple
function. With methods, the first argument always is an instance. This can
be handy when writing an undo management systems in case multiple calls
from the same instance do not have to be registered (e.g. if a method
``set_point()`` is called with exact coordinates (in stead of deltas), only
the first call to ``set_point()`` needs to be remembered).
Reverser
--------
The reverser requires some registration.
1. Property setters should be declared with ``reversible_property()``
2. Method (or function) pairs that implement each others reverse operation
(e.g. add and remove) should be registered as ``reversible_pair()``'s in the
reverser engine.
The reverser will construct a tuple (callable, arguments) which are send
to every handler registered in the subscribers list. Arguments is a
``dict()``.
First thing to do is to actually enable the ``revert_handler``:
>>> state.observers.add(state.revert_handler)
This handler is not enabled by default because:
1. it generates quite a bit of overhead if it isn't used anyway
2. you might want to add some additional filtering.
Point 2 may require some explanation. First of all observers have been added
to almost every method that involves a state change. As a result loads of
events are generated. In most cases you're only interested in the first event,
since that one contains the state before it started changing.
Handlers for the reverse events should be registered on the subscribers list:
>>> events = []
>>> def handler(event):
... events.append(event)
... print('event handler', event)
>>> state.subscribers.add(handler)
After that, signals can be received of undoable (reverse-)events:
>>> canvas.add(Circle()) # doctest: +ELLIPSIS
event handler (<function Handle._set_movable at ...>, {'self': <Handle object on (Variable(0, 20), Variable(0, 20))>, 'movable': True})
event handler (<function Canvas._remove at ...>, {'self': <gaphas.canvas.Canvas object at 0x...>, 'item': <examples.exampleitems.Circle object at 0x...>})
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<examples.exampleitems.Circle object at 0x...>]
As you can see this event is constructed of only two parameters: the function
that does the inverse operation of ``add()`` and the arguments that should be
applied to that function.
The inverse operation is easiest performed by the function ``saveapply()``. Of
course an inverse operation is emitting a change event too:
>>> state.saveapply(*events.pop()) # doctest: +ELLIPSIS
event handler (<function Canvas.add at 0x...>, {'self': <gaphas.canvas.Canvas object at 0x...>, 'item': <examples.exampleitems.Circle object at 0x...>, 'parent': None, 'index': 0})
>>> list(canvas.get_all_items())
[]
Just handling method pairs is one thing. Handling properties (descriptors) in
a simple fashion is another matter. First of all the original value should
be retrieved before the new value is applied (this is different from applying
the same arguments to another method in order to reverse an operation).
For this a ``reversible_property`` has been introduced. It works just like a
property (in fact it creates a plain old property descriptor), but also
registers the property as being reversible.
>>> var = Variable()
>>> var.value = 10 # doctest: +ELLIPSIS
event handler (<function Variable.set_value at 0x...>, {'self': Variable(0, 20), 'value': 0.0})
Handlers can be simply removed:
>>> state.subscribers.remove(handler)
>>> state.observers.remove(state.revert_handler)
What is Observed
----------------
As far as Gaphas is concerned, only properties and methods related to the
model (e.g. ``Canvas``, ``Item``) emit state changes. Some extra effort has
been taken to monitor the ``Matrix`` class (which is from Cairo).
canvas.py:
``Canvas``: ``add()``, ``remove()``, ``reparent()``, ``request_update()``
connector.py:
``Handle``: ``connectable``, ``movable``, ``visible``, ``connected_to`` and ``disconnect`` properties
connections.py:
``Connections``: ``connect_item()``, ``disconnect_item()``, ``reconnect_item()``
item.py:
``Element``: ``matrix``, ``min_height`` and ``min_width`` properties
``Line``: matrix``, ``line_width``, ``fuzziness``, ``orthogonal`` and ``horizontal`` properties
variable.py:
``Variable``: ``strength`` and ``value`` properties
solver.py:
``Solver``: ``add_constraint()`` and ``remove_constraint()``
matrix.py:
``Matrix``: ``invert()``, ``translate()``, ``rotate()``, ``scale()`` and ``set()``

View File

@ -1,414 +0,0 @@
Undo example
============
This document describes a basic undo system and tests Gaphas' classes with this
system.
This document contains a set of test cases that is used to prove that it really
works.
For this to work, some boilerplate has to be configured:
>>> from gaphas import state
>>> state.observers.clear()
>>> state.subscribers.clear()
>>> undo_list = []
>>> redo_list = []
>>> def undo_handler(event):
... undo_list.append(event)
>>> state.observers.add(state.revert_handler)
>>> state.subscribers.add(undo_handler)
This simple undo function will revert all states collected in the undo_list:
>>> 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[:]
Undo functionality tests
========================
The following sections contain most of the basis unit tests for undo
management.
tree.py: Tree
-------------
Tree has no observed methods.
matrix.py: Matrix
-----------------
Matrix is used by Item classes.
>>> from gaphas.matrix import Matrix
>>> m = Matrix()
>>> m
Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
translate(tx, ty):
>>> m.translate(12, 16)
>>> m
Matrix(1.0, 0.0, 0.0, 1.0, 12.0, 16.0)
>>> undo()
>>> m
Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
scale(sx, sy):
>>> m.scale(1.5, 1.5)
>>> m
Matrix(1.5, 0.0, 0.0, 1.5, 0.0, 0.0)
>>> undo()
>>> m
Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
rotate(radians):
>>> def matrix_approx(m):
... a = []
... for i in tuple(m):
... if -1e-10 < i < 1e-10: i=0
... a.append(i)
... return tuple(a)
>>> m.rotate(0.5)
>>> m
Matrix(0.8775825618903728, 0.479425538604203, -0.479425538604203, 0.8775825618903728, 0.0, 0.0)
>>> undo()
>>> matrix_approx(m)
(1.0, 0, 0, 1.0, 0, 0)
Okay, nearly, close enough IMHO...
>>> m = Matrix()
>>> m.translate(12, 10)
>>> m.scale(1.5, 1.5)
>>> m.rotate(0.5)
>>> m
Matrix(1.3163738428355591, 0.7191383079063045, -0.7191383079063045, 1.3163738428355591, 12.0, 10.0)
>>> m.invert()
>>> m
Matrix(0.5850550412602484, -0.3196170257361353, 0.3196170257361353, 0.5850550412602484, -10.216830752484334, -2.0151461037688607)
>>> undo()
>>> matrix_approx(m)
(1.0, 0, 0, 1.0, 0, 0)
Again, rotate does not result in an exact match, but it's close enough.
>>> undo_list
[]
canvas.py: Canvas
-----------------
>>> from gaphas import Canvas
>>> from examples.exampleitems import Circle
>>> canvas = Canvas()
>>> list(canvas.get_all_items())
[]
>>> item = Circle()
>>> canvas.add(item)
The ``request_update()`` method is observed:
>>> len(undo_list)
2
>>> canvas.request_update(item)
>>> len(undo_list)
3
On the canvas only ``add()`` and ``remove()`` are monitored:
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<examples.exampleitems.Circle object at 0x...>]
>>> undo()
>>> list(canvas.get_all_items())
[]
>>> canvas.add(item)
>>> del undo_list[:]
>>> canvas.remove(item)
>>> list(canvas.get_all_items())
[]
>>> undo()
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<examples.exampleitems.Circle object at 0x...>]
>>> undo_list
[]
Parent-child relationships are restored as well:
TODO!
>>> child = Circle()
>>> canvas.add(child, parent=item)
>>> canvas.get_parent(child) is item
True
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<examples.exampleitems.Circle object at 0x...>, <examples.exampleitems.Circle object at 0x...>]
>>> undo()
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<examples.exampleitems.Circle object at 0x...>]
>>> child in canvas.get_all_items()
False
Now redo the previous undo action:
>>> undo_list[:] = redo_list[:]
>>> undo()
>>> canvas.get_parent(child) is item
True
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<examples.exampleitems.Circle object at 0x...>, <examples.exampleitems.Circle object at 0x...>]
Remove also works when items are removed recursively (an item and it's
children):
>>> child = Circle()
>>> canvas.add(child, parent=item)
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<examples.exampleitems.Circle object at 0x...>, <examples.exampleitems.Circle object at 0x...>]
>>> del undo_list[:]
>>> canvas.remove(item)
>>> list(canvas.get_all_items())
[]
>>> undo()
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<examples.exampleitems.Circle object at 0x...>, <examples.exampleitems.Circle object at 0x...>]
>>> canvas.get_children(item) # doctest: +ELLIPSIS
[<examples.exampleitems.Circle object at 0x...>]
As well as the reparent() method:
>>> canvas = Canvas()
>>> class NameItem:
... def __init__(self, name):
... super(NameItem, self).__init__()
... self.name = name
... def handles(self): return []
... def ports(self): return []
... def point(self, x, y): return 0
... def __repr__(self):
... return '<%s>' % self.name
>>> ni1 = NameItem('a')
>>> canvas.add(ni1)
>>> ni2 = NameItem('b')
>>> canvas.add(ni2)
>>> ni3 = NameItem('c')
>>> canvas.add(ni3, parent=ni1)
>>> ni4 = NameItem('d')
>>> canvas.add(ni4, parent=ni3)
>>> list(canvas.get_all_items())
[<a>, <c>, <d>, <b>]
>>> del undo_list[:]
>>> canvas.reparent(ni3, parent=ni2)
>>> list(canvas.get_all_items())
[<a>, <b>, <c>, <d>]
>>> len(undo_list)
1
>>> undo()
>>> list(canvas.get_all_items())
[<a>, <c>, <d>, <b>]
Redo should work too:
>>> undo_list[:] = redo_list[:]
>>> undo()
>>> list(canvas.get_all_items())
[<a>, <b>, <c>, <d>]
Undo/redo a connection: see gaphas/tests/test_undo.py
connector.py: Handle
--------------------
Changing the Handle's position is reversible:
>>> from gaphas import Handle
>>> handle = Handle()
>>> handle.pos = 10, 12
>>> handle.pos
<Position object on (Variable(10, 20), Variable(12, 20))>
>>> undo()
>>> handle.pos
<Position object on (Variable(0, 20), Variable(0, 20))>
As are all other properties:
>>> handle.connectable, handle.movable, handle.visible
(False, True, True)
>>> handle.connectable = True
>>> handle.movable = False
>>> handle.visible = False
>>> handle.connectable, handle.movable, handle.visible
(True, False, False)
And now undo the whole lot at once:
>>> undo()
>>> handle.connectable, handle.movable, handle.visible
(False, True, True)
item.py: Item
-------------
The basic Item properties are canvas and matrix. Canvas has been tested before,
while testing the Canvas class.
The Matrix has been tested in section matrix.py: Matrix.
item.py: Element
----------------
An element has ``min_height`` and ``min_width`` properties.
>>> from gaphas import Element
>>> from gaphas.connections import Connections
>>> e = Element(Connections())
>>> e.min_height, e.min_width
(Variable(10, 100), Variable(10, 100))
>>> e.min_height, e.min_width = 30, 40
>>> e.min_height, e.min_width
(Variable(30, 100), Variable(40, 100))
>>> undo()
>>> e.min_height, e.min_width
(Variable(0, 100), Variable(0, 100))
>>> canvas = Canvas()
>>> canvas.add(e)
>>> undo()
item.py: Line
-------------
A line has the following properties: ``line_width``, ``fuzziness``,
``orthogonal`` and ``horizontal``. Each one of then is observed for changes:
>>> from gaphas import Line
>>> from gaphas.segment import Segment
>>> l = Line(Connections())
Let's first add a segment to the line, to test orthogonal lines as well.
>>> segment = Segment(l, canvas)
>>> _ = segment.split_segment(0)
>>> l.line_width, l.fuzziness, l.orthogonal, l.horizontal
(2.0, 0.0, False, False)
Now change the properties:
>>> l.line_width = 4
>>> l.fuzziness = 2
>>> l.orthogonal = True
>>> l.horizontal = True
>>> l.line_width, l.fuzziness, l.orthogonal, l.horizontal
(4, 2, True, True)
And undo the changes:
>>> undo()
>>> l.line_width, l.fuzziness, l.orthogonal, l.horizontal
(2.0, 0.0, False, False)
In addition to those properties, line segments can be split and merged.
>>> l.handles()[1].pos = 10, 10
>>> l.handles()
[<Handle object on (Variable(0, 20), Variable(0, 20))>, <Handle object on (Variable(10, 20), Variable(10, 20))>]
This is our basis for further testing.
>>> del undo_list[:]
>>> Segment(l, canvas).split_segment(0) # doctest: +ELLIPSIS
([<Handle object on (Variable(5, 10), Variable(5, 10))>], [<gaphas.connector.LinePort object at 0x...>])
>>> l.handles()
[<Handle object on (Variable(0, 20), Variable(0, 20))>, <Handle object on (Variable(5, 10), Variable(5, 10))>, <Handle object on (Variable(10, 20), Variable(10, 20))>]
The opposite operation is performed with the merge_segment() method:
>>> undo()
>>> l.handles()
[<Handle object on (Variable(0, 20), Variable(0, 20))>, <Handle object on (Variable(10, 20), Variable(10, 20))>]
Also creation and removal of connected lines is recorded and can be undone:
>>> canvas = Canvas()
>>> def real_connect(hitem, handle, item):
... def real_disconnect():
... pass
... canvas.connections.connect_item(hitem, handle, item, port=None, constraint=None, callback=real_disconnect)
>>> b0 = Circle()
>>> canvas.add(b0)
>>> b1 = Circle()
>>> canvas.add(b1)
>>> l = Line(Connections())
>>> canvas.add(l)
>>> real_connect(l, l.handles()[0], b0)
>>> real_connect(l, l.handles()[1], b1)
>>> canvas.connections.get_connection(l.handles()[0]) # doctest: +ELLIPSIS
Connection(item=<gaphas.item.Line object at 0x...>)
>>> canvas.connections.get_connection(l.handles()[1]) # doctest: +ELLIPSIS
Connection(item=<gaphas.item.Line object at 0x...>)
Clear already collected undo data:
>>> del undo_list[:]
Now remove the line from the canvas:
>>> canvas.remove(l)
The handles are disconnected:
>>> canvas.connections.get_connection(l.handles()[0])
>>> canvas.connections.get_connection(l.handles()[1])
Undoing the remove() action should put everything back in place again:
>>> undo()
>>> canvas.connections.get_connection(l.handles()[0]) # doctest: +ELLIPSIS
Connection(item=<gaphas.item.Line object at 0x...>)
>>> canvas.connections.get_connection(l.handles()[1]) # doctest: +ELLIPSIS
Connection(item=<gaphas.item.Line object at 0x...>)
solver.py: Variable
-------------------
Variable's strength and value properties are observed:
>>> from gaphas.solver import Variable
>>> v = Variable()
>>> v.value = 10
>>> v
Variable(10, 20)
>>> undo()
>>> v
Variable(0, 20)
solver.py: Solver
-----------------
Solvers ``add_constraint()`` and ``remove_constraint()`` are observed.
>>> from gaphas.solver import Solver
>>> from gaphas.constraint import EquationConstraint
>>> s = Solver()
>>> a, b = Variable(1.0), Variable(2.0)
>>> s.add_constraint(EquationConstraint(lambda a,b: a+b, a=a, b=b))
EquationConstraint(<lambda>, a=Variable(1, 20), b=Variable(2, 20))
>>> undo()
>>> undo_list[:] = redo_list[:]
>>> undo()

View File

@ -7,8 +7,6 @@ It sports a small canvas and some trivial operations:
- Zoom in/out
- Split a line segment
- Delete focused item
- Record state changes
- Play back state changes (= undo !) With visual updates
- Exports to SVG and PNG
"""
import math
@ -17,7 +15,7 @@ import cairo
import gi
from examples.exampleitems import Box, Circle, Text
from gaphas import Canvas, GtkView, state
from gaphas import Canvas, GtkView
from gaphas.guide import GuidePainter
from gaphas.item import Line
from gaphas.painter import (
@ -236,42 +234,6 @@ def create_window(canvas, title, zoom=1.0): # noqa too complex
b.connect("clicked", on_delete_focused_clicked)
v.add(b)
v.add(Gtk.Label.new("State:"))
b = Gtk.ToggleButton.new_with_label("Record")
def on_toggled(button):
global undo_list
if button.get_active():
print("start recording")
del undo_list[:]
state.subscribers.add(undo_handler)
else:
print("stop recording")
state.subscribers.remove(undo_handler)
b.connect("toggled", on_toggled)
v.add(b)
b = Gtk.Button.new_with_label("Play back")
def on_play_back_clicked(self):
global undo_list
apply_me = list(undo_list)
del undo_list[:]
print("Actions on the undo stack:", len(apply_me))
apply_me.reverse()
saveapply = state.saveapply
for event in apply_me:
print("Undo: invoking", event)
saveapply(*event)
print("New undo stack size:", len(undo_list))
# Visualize each event:
# while Gtk.events_pending():
# Gtk.main_iteration()
b.connect("clicked", on_play_back_clicked)
v.add(b)
v.add(Gtk.Label.new("Export:"))
b = Gtk.Button.new_with_label("Write demo.png")
@ -415,9 +377,6 @@ def create_canvas(c=None):
def main():
# State handling (a.k.a. undo handlers)
# First, activate the revert handler:
state.observers.add(state.revert_handler)
def print_handler(event):
print("event:", event)
@ -427,8 +386,6 @@ def main():
create_canvas(c)
# state.subscribers.add(print_handler)
# Start the main application
create_window(c, "View created after")

View File

@ -36,7 +36,6 @@ from typing_extensions import Protocol
from gaphas import matrix, tree
from gaphas.connections import Connection, Connections
from gaphas.decorators import nonrecursive
from gaphas.state import observed, reversible_method, reversible_pair
if TYPE_CHECKING:
from gaphas.item import Item
@ -67,7 +66,6 @@ class Canvas:
def connections(self) -> Connections:
return self._connections
@observed
def add(self, item, parent=None, index=None):
"""Add an item to the canvas.
@ -85,7 +83,6 @@ class Canvas:
self._tree.add(item, parent, index)
self.request_update(item)
@observed
def _remove(self, item):
"""Remove is done in a separate, @observed, method so the undo system
can restore removed items in the right order."""
@ -110,29 +107,10 @@ class Canvas:
self._connections.remove_connections_to_item(item)
self._remove(item)
reversible_pair(
add,
_remove,
bind1={
"parent": lambda self, item: self.get_parent(item),
"index": lambda self, item: self._tree.get_siblings(item).index(item),
},
)
@observed
def reparent(self, item, parent, index=None):
"""Set new parent for an item."""
self._tree.move(item, parent, index)
reversible_method(
reparent,
reverse=reparent,
bind={
"parent": lambda self, item: self.get_parent(item),
"index": lambda self, item: self._tree.get_siblings(item).index(item),
},
)
def get_all_items(self) -> Iterable[Item]:
"""Get a list of all items.
@ -236,7 +214,6 @@ class Canvas:
m = m.multiply(self.get_matrix_i2c(parent))
return m
@observed
def request_update(
self, item: Item, update: bool = True, matrix: bool = True
) -> None:
@ -261,12 +238,6 @@ class Canvas:
elif matrix:
self._update_views(dirty_matrix_items=(item,))
reversible_method(
request_update,
reverse=request_update,
bind={"update": lambda: True, "matrix": lambda: True},
)
def request_matrix_update(self, item):
"""Schedule only the matrix to be updated."""
self.request_update(item, update=False, matrix=True)

View File

@ -9,7 +9,6 @@ from gaphas.connector import Handle, Port
from gaphas.constraint import Constraint
from gaphas.item import Item
from gaphas.solver import Solver
from gaphas.state import observed, reversible_method, reversible_pair
class Connection(NamedTuple):
@ -84,7 +83,6 @@ class Connections:
self._solver.remove_constraint(constraint)
self._connections.delete(item, None, None, None, constraint, None)
@observed
def connect_item(
self,
item: Item,
@ -131,7 +129,6 @@ class Connections:
for cinfo in list(self._connections.query(item=item, handle=handle)):
self._disconnect_item(*cinfo)
@observed
def _disconnect_item(
self,
item: Item,
@ -151,8 +148,6 @@ class Connections:
self._connections.delete(item, handle, connected, port, constraint, callback)
reversible_pair(connect_item, _disconnect_item)
def remove_connections_to_item(self, item: Item) -> None:
"""Remove all connections (handles connected to and constraints) for a
specific item (to and from the item).
@ -168,7 +163,6 @@ class Connections:
for cinfo in list(self._connections.query(connected=item)):
disconnect_item(*cinfo)
@observed
def reconnect_item(
self,
item: Item,
@ -203,17 +197,6 @@ class Connections:
if constraint:
self._solver.add_constraint(constraint)
reversible_method(
reconnect_item,
reverse=reconnect_item,
bind={
"port": lambda self, item, handle: self.get_connection(handle).port,
"constraint": lambda self, item, handle: self.get_connection(
handle
).constraint,
},
)
def get_connection(self, handle: Handle) -> Optional[Connection]:
"""Get connection information for specified handle.

View File

@ -7,7 +7,6 @@ from gaphas.constraint import Constraint, LineConstraint, PositionConstraint
from gaphas.geometry import distance_line_point, distance_point_point
from gaphas.position import MatrixProjection, Position
from gaphas.solver import NORMAL, MultiConstraint
from gaphas.state import observed, reversible_property
from gaphas.types import Pos, SupportsFloatPos, TypedProperty
if TYPE_CHECKING:
@ -59,31 +58,28 @@ class Handle:
pos: TypedProperty[Position, Union[Position, SupportsFloatPos]]
pos = property(lambda s: s._pos, _set_pos, doc="The Handle's position")
@observed
def _set_connectable(self, connectable: bool) -> None:
self._connectable = connectable
connectable = reversible_property(
connectable = property(
lambda s: s._connectable,
_set_connectable,
doc="Can this handle actually connectect to a port?",
)
@observed
def _set_movable(self, movable: bool) -> None:
self._movable = movable
movable = reversible_property(
movable = property(
lambda s: s._movable,
_set_movable,
doc="Can this handle be moved by a mouse pointer?",
)
@observed
def _set_visible(self, visible: bool) -> None:
self._visible = visible
visible = reversible_property(
visible = property(
lambda s: s._visible, _set_visible, doc="Is this handle visible to the user?"
)
@ -104,11 +100,10 @@ class Port:
self._connectable = True
@observed
def _set_connectable(self, connectable: bool) -> None:
self._connectable = connectable
connectable = reversible_property(lambda s: s._connectable, _set_connectable)
connectable = property(lambda s: s._connectable, _set_connectable)
def glue(self, pos: SupportsFloatPos) -> Tuple[Pos, float]:
"""Get glue point on the port and distance to the port."""

View File

@ -12,12 +12,6 @@ from gaphas.constraint import Constraint, EqualsConstraint, constraint
from gaphas.geometry import distance_line_point, distance_rectangle_point
from gaphas.matrix import Matrix
from gaphas.solver import REQUIRED, VERY_STRONG, variable
from gaphas.state import (
observed,
reversible_method,
reversible_pair,
reversible_property,
)
from gaphas.types import CairoContext
if TYPE_CHECKING:
@ -271,17 +265,15 @@ class Line(Matrices):
def tail(self) -> Handle:
return self._handles[-1]
@observed
def _set_line_width(self, line_width: float) -> None:
self._line_width = line_width
line_width = reversible_property(lambda s: s._line_width, _set_line_width)
line_width = property(lambda s: s._line_width, _set_line_width)
@observed
def _set_fuzziness(self, fuzziness: float) -> None:
self._fuzziness = fuzziness
fuzziness = reversible_property(lambda s: s._fuzziness, _set_fuzziness)
fuzziness = property(lambda s: s._fuzziness, _set_fuzziness)
def update_orthogonal_constraints(self, orthogonal: bool) -> None:
"""Update the constraints required to maintain the orthogonal line.
@ -304,7 +296,6 @@ class Line(Matrices):
]
self._set_orthogonal_constraints(cons)
@observed
def _set_orthogonal_constraints(
self, orthogonal_constraints: List[Constraint]
) -> None:
@ -314,11 +305,6 @@ class Line(Matrices):
"""
self._orthogonal_constraints = orthogonal_constraints
reversible_property(
lambda s: s._orthogonal_constraints, _set_orthogonal_constraints
)
@observed
def _set_orthogonal(self, orthogonal: bool) -> None:
"""
>>> a = Line()
@ -329,20 +315,11 @@ class Line(Matrices):
raise ValueError("Can't set orthogonal line with less than 3 handles")
self.update_orthogonal_constraints(orthogonal)
orthogonal = reversible_property(
lambda s: bool(s._orthogonal_constraints), _set_orthogonal
)
orthogonal = property(lambda s: bool(s._orthogonal_constraints), _set_orthogonal)
@observed
def _inner_set_horizontal(self, horizontal: bool) -> None:
self._horizontal = horizontal
reversible_method(
_inner_set_horizontal,
_inner_set_horizontal,
{"horizontal": lambda horizontal: not horizontal},
)
def _set_horizontal(self, horizontal: bool) -> None:
"""
>>> line = Line()
@ -355,36 +332,20 @@ class Line(Matrices):
self._inner_set_horizontal(horizontal)
self.update_orthogonal_constraints(self.orthogonal)
horizontal = reversible_property(lambda s: s._horizontal, _set_horizontal)
horizontal = property(lambda s: s._horizontal, _set_horizontal)
@observed
def insert_handle(self, index: int, handle: Handle) -> None:
self._handles.insert(index, handle)
@observed
def remove_handle(self, handle: Handle) -> None:
self._handles.remove(handle)
reversible_pair(
insert_handle,
remove_handle,
bind1={"index": lambda self, handle: self._handles.index(handle)},
)
@observed
def insert_port(self, index: int, port: Port) -> None:
self._ports.insert(index, port)
@observed
def remove_port(self, port: Port) -> None:
self._ports.remove(port)
reversible_pair(
insert_port,
remove_port,
bind1={"index": lambda self, port: self._ports.index(port)},
)
def _update_ports(self) -> None:
"""Update line ports.

View File

@ -4,7 +4,7 @@ correct properties on gaphas' modules.
Matrix
------
Small utility class wrapping cairo.Matrix. The `Matrix` class adds
state preservation capabilities.
state notification capabilities.
"""
from __future__ import annotations
@ -13,13 +13,11 @@ from typing import Callable, Optional, Set, Tuple
import cairo
from gaphas.state import observed, reversible_method
MatrixTuple = Tuple[float, float, float, float, float, float]
class Matrix:
"""Matrix wrapper. This version sends @observed messages on state changes.
"""Matrix wrapper.
>>> Matrix()
Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
@ -54,31 +52,26 @@ class Matrix:
for handler in self._handlers:
handler(self, old)
@observed
def invert(self) -> None:
old: MatrixTuple = self.tuple()
self._matrix.invert()
self.notify(old)
@observed
def rotate(self, radians: float) -> None:
old: MatrixTuple = self.tuple()
self._matrix.rotate(radians)
self.notify(old)
@observed
def scale(self, sx: float, sy: float) -> None:
old = self.tuple()
self._matrix.scale(sx, sy)
self.notify(old)
@observed
def translate(self, tx: float, ty: float) -> None:
old: MatrixTuple = self.tuple()
self._matrix.translate(tx, ty)
self.notify(old)
@observed
def set(
self,
xx: Optional[float] = None,
@ -105,13 +98,6 @@ class Matrix:
if updated:
self.notify(old)
reversible_method(invert, invert)
reversible_method(rotate, rotate, {"radians": lambda radians: -radians})
reversible_method(scale, scale, {"sx": lambda sx: 1 / sx, "sy": lambda sy: 1 / sy})
reversible_method(
translate, translate, {"tx": lambda tx: -tx, "ty": lambda ty: -ty}
)
def multiply(self, m: Matrix) -> Matrix:
return Matrix(matrix=self._matrix.multiply(m._matrix))

View File

@ -36,7 +36,6 @@ import functools
from typing import Callable, Collection, List, Optional, Set
from gaphas.solver.constraint import Constraint, ContainsConstraints
from gaphas.state import observed, reversible_pair
class Solver:
@ -73,7 +72,6 @@ class Solver:
def constraints(self) -> Collection[Constraint]:
return self._constraints
@observed
def add_constraint(self, constraint: Constraint) -> Constraint:
"""Add a constraint. The actual constraint is returned, so the
constraint can be removed later on.
@ -100,7 +98,6 @@ class Solver:
constraint.add_handler(self.request_resolve_constraint)
return constraint
@observed
def remove_constraint(self, constraint: Constraint) -> None:
"""Remove a constraint from the solver.
@ -126,8 +123,6 @@ class Solver:
while constraint in self._marked_cons:
self._marked_cons.remove(constraint)
reversible_pair(add_constraint, remove_constraint)
def request_resolve_constraint(self, c: Constraint) -> None:
"""Request resolving a constraint."""
if not self._solving:

View File

@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Callable, Set, SupportsFloat
from gaphas.state import observed, reversible_property
from gaphas.types import TypedProperty
# epsilon for float comparison
@ -104,7 +103,6 @@ class Variable:
"""
self.notify(self._value)
@observed
def set_value(self, value: SupportsFloat) -> None:
oldval = self._value
v = float(value)
@ -113,7 +111,7 @@ class Variable:
self.notify(oldval)
value: TypedProperty[float, SupportsFloat]
value = reversible_property(lambda s: s._value, set_value)
value = property(lambda s: s._value, set_value)
def __str__(self):
return f"Variable({self._value:g}, {self._strength:d})"

View File

@ -1,254 +0,0 @@
"""This module is the central point where Gaphas' classes report their state
changes.
Invocations of method and state changing properties are emitted to all
functions (or bound methods) registered in the 'observers' set. Use
`observers.add()` and `observers.remove()` to add/remove handlers.
This module also contains a second layer: a state inverser. Instead of
emitting the invoked method, it emits a signal (callable, \\*\\*kwargs)
that can be applied to revert the state of the object to the point
before the method invocation.
For this to work the revert_handler has to be added to the observers
set::
gaphas.state.observers.add(gaphas.state.revert_handler)
"""
from functools import update_wrapper
from inspect import getfullargspec as _getargspec
from threading import Lock
from typing import Callable, Set, Tuple
# This string is added to each docstring in order to denote is's observed
# OBSERVED_DOCSTRING = \
# '\n\n This method is @observed. See gaphas.state for extra info.\n'
# Tell @observed to dispatch invocation messages by default
# May be changed (but be sure to do that right at the start of your
# application,otherwise you have no idea what's enabled and what's not!)
DISPATCH_BY_DEFAULT = True
# Add/remove methods from this subscribers list.
# Subscribers should have signature method(event) where event is a
# Event has the form: (func, keywords)
# Since most events originate from methods, it's safe to call
# saveapply(func, keywords) for those functions
subscribers: Set[Callable[[Tuple[Callable, str]], None]] = set()
# Subscribe to low-level change events:
observers: Set[Callable[[Tuple[Callable, str]], None]] = set()
# Perform locking (should be per thread?).
mutex = Lock()
def observed(func):
"""Simple observer, dispatches events to functions registered in the
observers list.
Also note that the events are dispatched *before* the function is
invoked. This is an important feature, esp. for the reverter code.
"""
def wrapper(*args, **kwargs):
acquired = mutex.acquire(False)
try:
if acquired:
dispatch((observer, args, kwargs), queue=observers)
# args[0].notify()
return func(*args, **kwargs)
finally:
if acquired:
mutex.release()
observer = update_wrapper(wrapper, func)
return observer
def dispatch(event, queue):
"""Dispatch an event to a queue of event handlers. Event handlers should
have signature: handler(event).
>>> def handler(event):
... print("event handled", event)
>>> observers.add(handler)
>>> @observed
... def callme():
... pass
>>> callme() # doctest: +ELLIPSIS
event handled (<function callme at 0x...>, (), {})
>>> class Callme(object):
... @observed
... def callme(self):
... pass
>>> Callme().callme() # doctest: +ELLIPSIS
event handled (<function Callme.callme at 0x...), {})
>>> observers.remove(handler)
>>> callme()
"""
for s in queue:
s(event)
_reverse = {}
def reversible_function(func, reverse, bind={}):
"""Straight forward reversible method, if func is invoked, reverse is
dispatched with bind as arguments."""
global _reverse
func = getfunction(func)
_reverse[func] = (reverse, getargnames(reverse), bind)
reversible_method = reversible_function
def reversible_pair(func1, func2, bind1={}, bind2={}):
"""Treat a pair of functions (func1 and func2) as each others inverse
operation. bind1 provides arguments that can overrule the default values
(or add additional values). bind2 does the same for func2.
See `revert_handler()` for doctesting.
"""
global _reverse
# We need the function, since that's what's in the events
func1 = getfunction(func1)
func2 = getfunction(func2)
_reverse[func1] = (func2, getargnames(func2), bind2)
_reverse[func2] = (func1, getargnames(func1), bind1)
def reversible_property(fget=None, fset=None, fdel=None, doc=None, bind={}):
"""Replacement for the property descriptor. In addition to creating a
property instance, the property is registered as reversible and reverse
events can be send out when changes occur.
Caveat: we can't handle both fset and fdel in the proper
way. Therefore fdel should somehow invoke fset. (personally, I
hardly use fdel)
See revert_handler() for doctesting.
"""
# given fset, read the value argument name (second arg) and create a
# bind {value: lambda self: fget(self)}
# TODO! handle fdel
if fset:
spec = getargnames(fset)
argnames = spec[0]
assert len(argnames) == 2, f"Set argument {fset} has argnames {argnames}"
argself, argvalue = argnames
func = getfunction(fset)
b = {argvalue: lambda self: fget(self)}
b.update(bind)
_reverse[func] = (func, spec, b)
return property(fget=fget, fset=fset, fdel=fdel, doc=doc)
def revert_handler(event):
"""Event handler, generates undoable statements and puts them on the
subscribers queue.
First thing to do is to actually enable the revert_handler:
>>> observers.add(revert_handler)
First let's define our simple list:
>>> class SList(object):
... def __init__(self):
... self.l = list()
... def add(self, node, before=None):
... if before: self.l.insert(self.l.index(before), node)
... else: self.l.append(node)
... add = observed(add)
... @observed
... def remove(self, node):
... self.l.remove(self.l.index(node))
>>> sl = SList()
>>> sl.add(10)
>>> sl.l
[10]
>>> sl.add(11)
>>> sl.l
[10, 11]
>>> sl.add(12, before=11)
>>> sl.l
[10, 12, 11]
It works, so let's add some reversible stuff:
>>> reversible_pair(SList.add, SList.remove, \
bind1={'before': lambda self, node: self.l[self.l.index(node)+1] })
>>> def handler(event):
... print('handle', event)
>>> subscribers.add(handler)
>>> sl.add(20) # doctest: +ELLIPSIS
handle (<function SList.remove at 0x...)
Same goes for properties (more or less):
>>> class PropTest(object):
... def __init__(self): self._a = 0
... @observed
... def _set_a(self, value): self._a = value
... a = reversible_property(lambda s: s._a, _set_a)
>>> pt = PropTest()
>>> pt.a
0
>>> pt.a = 10 # doctest: +ELLIPSIS
handle (<function PropTest._set_a at 0x...>, {'self': <gaphas.state.PropTest object at 0x...>, 'value': 0})
>>> subscribers.remove(handler)
"""
global _reverse
func, args, kwargs = event
spec = getargnames(func)
reverse, revspec, bind = _reverse.get(func, (None, None, {}))
if not reverse:
return
kw = dict(kwargs)
kw.update(dict(list(zip(spec[0], args))))
for arg, binding in list(bind.items()):
kw[arg] = saveapply(binding, kw)
argnames = list(revspec[0]) # normal args
if spec[1]: # *args
argnames.append(revspec[1])
if spec[2]: # **kwargs
argnames.append(revspec[2])
kwargs = {arg: kw.get(arg) for arg in argnames}
dispatch((reverse, kwargs), queue=subscribers)
def saveapply(func, kw):
"""Do apply a set of keywords to a method or function.
The function names should be known at meta-level, since arguments
are applied as func(\\*\\*kwargs).
"""
spec = getargnames(func)
argnames = list(spec[0])
if spec[1]:
argnames.append(spec[1])
if spec[2]:
argnames.append(spec[2])
kwargs = {arg: kw.get(arg) for arg in argnames}
return func(**kwargs)
def getargnames(func):
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
spec = _getargspec(func)
return spec[:3]
def getfunction(func):
"""Return the function associated with a class method."""
return func

View File

@ -1,7 +1,6 @@
import gi
import pytest
from gaphas import state
from gaphas.canvas import Canvas
from gaphas.item import Element as Box
from gaphas.item import Line
@ -56,37 +55,6 @@ def line(canvas, connections):
return line
@pytest.fixture(scope="module")
def undo_fixture():
undo_list = [] # type: ignore[var-annotated]
redo_list = [] # type: ignore[var-annotated]
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 undo_handler(event):
undo_list.append(event)
return undo, undo_handler, undo_list
@pytest.fixture()
def revert_undo(undo_fixture):
state.observers.clear()
state.subscribers.clear()
state.observers.add(state.revert_handler)
state.subscribers.add(undo_fixture[1])
yield
state.observers.remove(state.revert_handler)
state.subscribers.remove(undo_fixture[1])
@pytest.fixture
def handler():
events = []

View File

@ -2,7 +2,6 @@
from gaphas.canvas import Canvas
from gaphas.item import Line
from gaphas.segment import Segment
def test_initial_ports():
@ -10,59 +9,3 @@ def test_initial_ports():
canvas = Canvas()
line = Line(canvas.connections)
assert 1 == len(line.ports())
def test_orthogonal_horizontal_undo(revert_undo, undo_fixture):
"""Test orthogonal line constraints bug (#107)."""
canvas = Canvas()
line = Line(canvas.connections)
canvas.add(line)
assert not line.horizontal
assert len(canvas.solver._constraints) == 0
segment = Segment(line, canvas)
segment.split_segment(0)
line.orthogonal = True
assert 2 == len(canvas.solver._constraints)
del undo_fixture[2][:] # Clear undo_list
line.horizontal = True
assert 2 == len(canvas.solver._constraints)
undo_fixture[0]() # Call undo
assert not line.horizontal
assert 2 == len(canvas.solver._constraints)
line.horizontal = True
assert line.horizontal
assert 2 == len(canvas.solver._constraints)
def test_orthogonal_line_undo(revert_undo, undo_fixture):
"""Test orthogonal line undo."""
canvas = Canvas()
line = Line(canvas.connections)
canvas.add(line)
segment = Segment(line, canvas)
segment.split_segment(0)
# Start with no orthogonal constraints
assert len(canvas.solver._constraints) == 0
line.orthogonal = True
# Check orthogonal constraints
assert 2 == len(canvas.solver._constraints)
assert 3 == len(line.handles())
undo_fixture[0]() # Call undo
assert not line.orthogonal
assert 0 == len(canvas.solver._constraints)
assert 2 == len(line.handles())

View File

@ -140,26 +140,6 @@ def test_constraints_after_split(canvas, connections, line, view):
assert cinfo.constraint != orig_constraint
def test_split_undo(canvas, line, revert_undo, undo_fixture):
"""Test line splitting undo."""
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(line.handles()) == 2
assert len(line.ports()) == 1
segment = Segment(line, canvas)
segment.split_segment(0)
assert len(line.handles()) == 3
assert len(line.ports()) == 2
# After undo, 2 handles and 1 port are expected again
undo_fixture[0]() # Call Undo
assert 2 == len(line.handles())
assert 1 == len(line.ports())
def test_orthogonal_line_split(canvas, line):
"""Test orthogonal line splitting."""
# Start with no orthogonal constraints
@ -294,31 +274,6 @@ def test_merge_multiple(canvas, line):
assert (20, 16) == port.end.pos
def test_merge_undo(canvas, line, revert_undo, undo_fixture):
"""Test line merging undo."""
line.handles()[1].pos = (20, 0)
segment = Segment(line, canvas)
# Split for merging
segment.split_segment(0)
assert len(line.handles()) == 3
assert len(line.ports()) == 2
# Clear undo stack before merging
del undo_fixture[2][:]
# Merge with empty undo stack
segment.merge_segment(0)
assert len(line.handles()) == 2
assert len(line.ports()) == 1
# After merge undo, 3 handles and 2 ports are expected again
undo_fixture[0]() # Undo
assert 3 == len(line.handles())
assert 2 == len(line.ports())
def test_orthogonal_line_merge(canvas, connections, line):
"""Test orthogonal line merging."""
assert 0 == len(connections.solver._constraints)

View File

@ -1,36 +0,0 @@
import sys
from gaphas.state import _reverse, observed, reversible_pair
class SList:
def __init__(self):
self.list = []
def add(self, node, before=None):
if before:
self.list.insert(self.list.index(before), node)
else:
self.list.append(node)
add = observed(add)
@observed
def remove(self, node):
self.list.remove(self.list.index(node))
def test_adding_pair():
"""Test adding reversible pair."""
reversible_pair(
SList.add,
SList.remove,
bind1={"before": lambda self, node: self.list[self.list.index(node) + 1]},
)
if sys.version_info.major >= 3: # Modern Python
assert SList.add in _reverse
assert SList.remove in _reverse
else: # Legacy Python
assert SList.add.__func__ in _reverse # type: ignore[attr-defined]
assert SList.remove.__func__ in _reverse

View File

@ -1,58 +0,0 @@
from gaphas.aspect import ConnectionSink, Connector
from gaphas.canvas import Canvas
from gaphas.item import Element as Box
from gaphas.item import Line
def test_undo_on_delete_element(revert_undo, undo_fixture):
canvas = Canvas()
b1 = Box(canvas.connections)
b2 = Box(canvas.connections)
line = Line(canvas.connections)
canvas.add(b1)
assert 12 == len(canvas.solver.constraints)
canvas.add(b2)
assert 12 == len(canvas.solver.constraints)
canvas.add(line)
sink = ConnectionSink(b1, b1.ports()[0])
connector = Connector(line, line.handles()[0], canvas.connections)
connector.connect(sink)
sink = ConnectionSink(b2, b2.ports()[0])
connector = Connector(line, line.handles()[-1], canvas.connections)
connector.connect(sink)
assert 14 == len(canvas.solver.constraints)
assert 2 == len(list(canvas.connections.get_connections(item=line)))
del undo_fixture[2][:] # Clear undo_list
# Here disconnect is not invoked!
canvas.remove(b2)
assert 7 == len(canvas.solver.constraints)
assert 1 == len(list(canvas.connections.get_connections(item=line)))
cinfo = canvas.connections.get_connection(line.handles()[0])
assert cinfo
assert b1 == cinfo.connected
cinfo = canvas.connections.get_connection(line.handles()[-1])
assert cinfo is None
undo_fixture[0]() # Call undo
assert 14 == len(canvas.solver.constraints)
assert 2 == len(list(canvas.connections.get_connections(item=line)))
cinfo = canvas.connections.get_connection(line.handles()[0])
assert cinfo
assert b1 == cinfo.connected
cinfo = canvas.connections.get_connection(line.handles()[-1])
assert cinfo
assert b2 == cinfo.connected