Turn Item into a protocol

Now we do not need to inherit from Item explicitly.
This commit is contained in:
Arjan Molenaar 2020-11-08 22:52:11 +01:00
parent 4b237b8ecd
commit 3dd5d02721
9 changed files with 140 additions and 114 deletions

View File

@ -23,9 +23,9 @@ The observer simply dispatches the function called (as ``<function ..>``, not as
Let's start with creating a Canvas instance and some items:
>>> from gaphas.canvas import Canvas
>>> from gaphas.item import Item
>>> from examples.exampleitems import Circle
>>> canvas = Canvas()
>>> item1, item2 = Item(), Item()
>>> item1, item2 = Circle(), Circle()
For this demonstration let's use the Canvas class (which contains an add/remove
method pair).
@ -36,11 +36,11 @@ It works (see how the add method automatically schedules the item for update):
... print('event handled', event)
>>> state.observers.add(handler)
>>> canvas.add(item1) # doctest: +ELLIPSIS
event handled (<function Canvas.add at ...>, (<gaphas.canvas.Canvas object at ...>, <gaphas.item.Item object at ...>), {})
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 ...>, <gaphas.item.Item object at ...>), {'parent': <gaphas.item.Item object at ...>})
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
[<gaphas.item.Item object at 0x...>, <gaphas.item.Item object at 0x...>]
[<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.
@ -52,8 +52,8 @@ 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...>, <gaphas.item.Item object at 0x...>), {})
event handled (<function Canvas._remove at ...>, (<gaphas.canvas.Canvas object at 0x...>, <gaphas.item.Item object at 0x...>), {})
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())
[]
@ -126,10 +126,11 @@ Handlers for the reverse events should be registered on the subscribers list:
After that, signals can be received of undoable (reverse-)events:
>>> canvas.add(Item()) # doctest: +ELLIPSIS
event handler (<function Canvas._remove at ...>, {'self': <gaphas.canvas.Canvas object at 0x...>, 'item': <gaphas.item.Item object at 0x...>})
>>> 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
[<gaphas.item.Item object at 0x...>]
[<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
@ -139,7 +140,7 @@ 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': <gaphas.item.Item object at 0x...>, 'parent': None, 'index': 0})
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())
[]

View File

@ -111,25 +111,26 @@ Again, rotate does not result in an exact match, but it's close enough.
canvas.py: Canvas
-----------------
>>> from gaphas import Canvas, Item
>>> from gaphas import Canvas
>>> from examples.exampleitems import Circle
>>> canvas = Canvas()
>>> list(canvas.get_all_items())
[]
>>> item = Item()
>>> item = Circle()
>>> canvas.add(item)
The ``request_update()`` method is observed:
>>> len(undo_list)
1
2
>>> canvas.request_update(item)
>>> len(undo_list)
2
3
On the canvas only ``add()`` and ``remove()`` are monitored:
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<gaphas.item.Item object at 0x...>]
[<examples.exampleitems.Circle object at 0x...>]
>>> undo()
>>> list(canvas.get_all_items())
[]
@ -140,7 +141,7 @@ On the canvas only ``add()`` and ``remove()`` are monitored:
[]
>>> undo()
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<gaphas.item.Item object at 0x...>]
[<examples.exampleitems.Circle object at 0x...>]
>>> undo_list
[]
@ -149,15 +150,15 @@ Parent-child relationships are restored as well:
TODO!
>>> child = Item()
>>> child = Circle()
>>> canvas.add(child, parent=item)
>>> canvas.get_parent(child) is item
True
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<gaphas.item.Item object at 0x...>, <gaphas.item.Item object at 0x...>]
[<examples.exampleitems.Circle object at 0x...>, <examples.exampleitems.Circle object at 0x...>]
>>> undo()
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<gaphas.item.Item object at 0x...>]
[<examples.exampleitems.Circle object at 0x...>]
>>> child in canvas.get_all_items()
False
@ -168,32 +169,35 @@ Now redo the previous undo action:
>>> canvas.get_parent(child) is item
True
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<gaphas.item.Item object at 0x...>, <gaphas.item.Item object at 0x...>]
[<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 = Item()
>>> child = Circle()
>>> canvas.add(child, parent=item)
>>> list(canvas.get_all_items()) # doctest: +ELLIPSIS
[<gaphas.item.Item object at 0x...>, <gaphas.item.Item object at 0x...>]
[<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
[<gaphas.item.Item object at 0x...>, <gaphas.item.Item object at 0x...>]
[<examples.exampleitems.Circle object at 0x...>, <examples.exampleitems.Circle object at 0x...>]
>>> canvas.get_children(item) # doctest: +ELLIPSIS
[<gaphas.item.Item object at 0x...>]
[<examples.exampleitems.Circle object at 0x...>]
As well as the reparent() method:
>>> canvas = Canvas()
>>> class NameItem(Item):
>>> 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')
@ -347,9 +351,9 @@ Also creation and removal of connected lines is recorded and can be undone:
... def real_disconnect():
... pass
... canvas.connections.connect_item(hitem, handle, item, port=None, constraint=None, callback=real_disconnect)
>>> b0 = Item()
>>> b0 = Circle()
>>> canvas.add(b0)
>>> b1 = Item()
>>> b1 = Circle()
>>> canvas.add(b1)
>>> l = Line(Connections())
>>> canvas.add(l)

View File

@ -3,7 +3,7 @@
These items are used in various tests.
"""
from gaphas.connector import Handle
from gaphas.item import NW, Element, Item
from gaphas.item import NW, Element, Matrices, Updateable
from gaphas.util import path_ellipse, text_align, text_multiline
@ -29,7 +29,7 @@ class Box(Element):
c.stroke()
class Text(Item):
class Text(Matrices, Updateable):
"""Simple item showing some text on the canvas."""
def __init__(self, text=None, plain=False, multiline=False, align_x=1, align_y=-1):
@ -40,6 +40,15 @@ class Text(Item):
self.align_x = align_x
self.align_y = align_y
def handles(self):
return []
def ports(self):
return []
def point(self, x, y):
return 0
def draw(self, context):
cr = context.cairo
if self.multiline:
@ -49,14 +58,11 @@ class Text(Item):
else:
text_align(cr, 0, 0, self.text, self.align_x, self.align_y)
def point(self, x, y):
return 0
class Circle(Item):
class Circle(Matrices, Updateable):
def __init__(self):
super().__init__()
self._handles.extend((Handle(), Handle()))
self._handles = [Handle(), Handle()]
h1, h2 = self._handles
h1.movable = False
@ -72,6 +78,12 @@ class Circle(Item):
radius = property(_get_radius, _set_radius)
def handles(self):
return self._handles
def ports(self):
return []
def point(self, x, y):
h1, _ = self._handles
p1 = h1.pos

View File

@ -1,6 +1,6 @@
"""Basic items."""
from math import atan2
from typing import Sequence
from typing import Protocol, Sequence
from gaphas.canvas import Context
from gaphas.connector import Handle, LinePort, Port
@ -16,31 +16,48 @@ from gaphas.state import (
)
class Item(Protocol):
@property
def matrix(self) -> Matrix:
...
@property
def matrix_i2c(self) -> Matrix:
...
def handles(self) -> Sequence[Handle]:
"""Return a list of handles owned by the item."""
def ports(self) -> Sequence[Port]:
"""Return list of ports."""
def point(self, x: float, y: float) -> float:
"""Get the distance from a point (``x``, ``y``) to the item.
``x`` and ``y`` are in item coordinates.
"""
def draw(self, context: Context):
"""Render the item to a canvas view. Context contains the following
attributes:
- cairo: the Cairo Context use this one to draw
- selected, focused, hovered, dropzone: view state of items
(True/False)
"""
def matrix_i2i(from_item, to_item):
i2c = from_item.matrix_i2c
c2i = to_item.matrix_i2c.inverse()
return i2c.multiply(c2i)
class Item:
"""Base class (or interface) for items on a canvas.Canvas.
Attributes:
- matrix: item's transformation matrix
Private:
- _handles: list of handles owned by an item
- _ports: list of ports, connectable areas of an item
"""
class Matrices:
def __init__(self, **kwargs):
super().__init__(**kwargs) # type: ignore[call-arg]
self._matrix = Matrix()
self._matrix_i2c = Matrix()
self._handles = []
self._ports = []
@property
def matrix(self) -> Matrix:
@ -50,6 +67,8 @@ class Item:
def matrix_i2c(self) -> Matrix:
return self._matrix_i2c
class Updateable:
def pre_update(self, context: Context):
"""Perform any changes before item update here, for example:
@ -76,36 +95,11 @@ class Item:
"""
pass
def draw(self, context: Context):
"""Render the item to a canvas view. Context contains the following
attributes:
- cairo: the Cairo Context use this one to draw
- selected, focused, hovered, dropzone: view state of items
(True/False)
"""
pass
def handles(self) -> Sequence[Handle]:
"""Return a list of handles owned by the item."""
return self._handles
def ports(self) -> Sequence[Port]:
"""Return list of ports."""
return self._ports
def point(self, x: float, y: float):
"""Get the distance from a point (``x``, ``y``) to the item.
``x`` and ``y`` are in item coordinates.
"""
pass
[NW, NE, SE, SW] = list(range(4))
class Element(Item):
class Element(Matrices, Updateable):
"""An Element has 4 handles (for a start)::
NW +---+ NE | | SW +---+ SE
@ -199,6 +193,14 @@ class Element(Item):
height = property(_get_height, _set_height)
def handles(self) -> Sequence[Handle]:
"""Return a list of handles owned by the item."""
return self._handles
def ports(self) -> Sequence[Port]:
"""Return list of ports."""
return self._ports
def point(self, x, y):
"""Distance from the point (x, y) to the item.
@ -212,6 +214,9 @@ class Element(Item):
list(map(float, (pnw.x, pnw.y, pse.x, pse.y))), (x, y)
)
def draw(self, context: Context):
pass
def create_orthogonal_constraints(handles, horizontal):
rest = 1 if horizontal else 0
@ -224,7 +229,7 @@ def create_orthogonal_constraints(handles, horizontal):
yield EqualsConstraint(a=p0.y, b=p1.y)
class Line(Item):
class Line(Matrices, Updateable):
"""A Line item.
Properties:
@ -411,6 +416,14 @@ class Line(Item):
p1, p0 = h1.pos, h0.pos
self._tail_angle = atan2(p1.y - p0.y, p1.x - p0.x) # type: ignore[assignment]
def handles(self) -> Sequence[Handle]:
"""Return a list of handles owned by the item."""
return self._handles
def ports(self) -> Sequence[Port]:
"""Return list of ports."""
return self._ports
def point(self, x, y):
"""
>>> a = Line()

View File

@ -102,7 +102,7 @@ class BoundingBoxPainter:
):
self.item_painter = item_painter
def paint_item(self, item, cairo):
def paint_item(self, item: Item, cairo):
cairo = CairoBoundingBoxContext(cairo)
self.item_painter.paint_item(item, cairo)
# Bounding box is in view (cairo root) coordinates

View File

@ -86,7 +86,7 @@ class GtkView(Gtk.DrawingArea, Gtk.Scrollable):
),
}
def __init__(self, canvas: Optional[Model[Item]] = None):
def __init__(self, canvas: Optional[Model] = None):
Gtk.DrawingArea.__init__(self)
self._dirty_items: Set[Item] = set()
@ -117,7 +117,7 @@ class GtkView(Gtk.DrawingArea, Gtk.Scrollable):
self._qtree: Quadtree[Item, Tuple[float, float, float, float]] = Quadtree()
self._canvas: Optional[Model[Item]] = None
self._canvas: Optional[Model] = None
if canvas:
self._set_canvas(canvas)
@ -152,7 +152,7 @@ class GtkView(Gtk.DrawingArea, Gtk.Scrollable):
m.invert()
return m
def _set_canvas(self, canvas: Optional[Model[Item]]) -> None:
def _set_canvas(self, canvas: Optional[Model]) -> None:
"""
Use view.canvas = my_canvas to set the canvas to be rendered
in the view.

View File

@ -1,45 +1,44 @@
from __future__ import annotations
from typing import Iterable, Optional, Sequence, TypeVar
from typing import Collection, Iterable, Optional
from typing_extensions import Protocol, runtime_checkable
T = TypeVar("T")
T_ct = TypeVar("T_ct", contravariant=True)
from gaphas.item import Item
@runtime_checkable
class Model(Protocol[T]):
def get_all_items(self) -> Iterable[T]:
class View(Protocol):
def request_update(
self,
items: Collection[Item],
matrix_only_items: Collection[Item],
removed_items: Collection[Item],
) -> None:
...
def get_parent(self, item: T) -> Optional[T]:
@runtime_checkable
class Model(Protocol):
def get_all_items(self) -> Iterable[Item]:
...
def get_children(self, item: T) -> Iterable[T]:
def get_parent(self, item: Item) -> Optional[Item]:
...
def sort(self, items: Sequence[T]) -> Iterable[T]:
def get_children(self, item: Item) -> Iterable[Item]:
...
def sort(self, items: Collection[Item]) -> Iterable[Item]:
...
def update_now(
self, dirty_items: Sequence[T], dirty_matrix_items: Sequence[T]
self, dirty_items: Collection[Item], dirty_matrix_items: Collection[Item]
) -> None:
...
def register_view(self, view: View[T]) -> None:
def register_view(self, view: View) -> None:
...
def unregister_view(self, view: View[T]) -> None:
...
@runtime_checkable
class View(Protocol[T_ct]):
def request_update(
self,
items: Sequence[T_ct],
matrix_only_items: Sequence[T_ct],
removed_items: Sequence[T_ct],
) -> None:
def unregister_view(self, view: View) -> None:
...

View File

@ -3,12 +3,12 @@
import pytest
from gaphas.aspect import InMotion, Selection
from gaphas.item import Item
from gaphas.item import Element
@pytest.fixture()
def item():
return Item()
def item(connections):
return Element(connections)
def test_selection_select(canvas, view, item):

View File

@ -1,7 +1,7 @@
"""Test segment aspects for items."""
import pytest
from gaphas.item import Item
from gaphas.item import Element
from gaphas.segment import HandleFinder, Line, Segment, SegmentHandleFinder
from gaphas.tool import ConnectHandleTool
@ -11,15 +11,13 @@ def tool(view):
return ConnectHandleTool(view)
def test_segment_fails_for_item(canvas):
"""Test if Segment aspect can be applied to Item."""
item = Item()
def test_segment_fails_for_element(canvas, connections):
item = Element(connections)
with pytest.raises(TypeError):
Segment(canvas, item)
def test_segment(canvas, connections):
"""Test add a new segment to a line."""
def test_add_segment_to_line(canvas, connections):
line = Line(connections)
canvas.add(line)
segment = Segment(line, canvas)
@ -32,7 +30,6 @@ def test_segment(canvas, connections):
def test_split_single(canvas, line):
"""Test single line splitting."""
# Start with 2 handles & 1 port, after split: expect 3 handles & 2 ports
assert len(line.handles()) == 2
assert len(line.ports()) == 1