diff --git a/docs/state.rst b/docs/state.rst index a9421d5..b2494ab 100644 --- a/docs/state.rst +++ b/docs/state.rst @@ -23,9 +23,9 @@ The observer simply dispatches the function called (as ````, 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 (, (, ), {}) + event handled (, (, ), {}) >>> canvas.add(item2, parent=item1) # doctest: +ELLIPSIS - event handled (, (, ), {'parent': }) + event handled (, (, ), {'parent': }) >>> list(canvas.get_all_items()) # doctest: +ELLIPSIS - [, ] + [, ] 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 (, (, ), {}) - event handled (, (, ), {}) + event handled (, (, ), {}) + event handled (, (, ), {}) >>> 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 (, {'self': , 'item': }) + >>> canvas.add(Circle()) # doctest: +ELLIPSIS + event handler (, {'self': , 'movable': True}) + event handler (, {'self': , 'item': }) >>> list(canvas.get_all_items()) # doctest: +ELLIPSIS - [] + [] 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 (, {'self': , 'item': , 'parent': None, 'index': 0}) + event handler (, {'self': , 'item': , 'parent': None, 'index': 0}) >>> list(canvas.get_all_items()) [] diff --git a/docs/undo.rst b/docs/undo.rst index 4dda9f1..c24afc2 100644 --- a/docs/undo.rst +++ b/docs/undo.rst @@ -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 - [] + [] >>> 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 - [] + [] >>> 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 - [, ] + [, ] >>> undo() >>> list(canvas.get_all_items()) # doctest: +ELLIPSIS - [] + [] >>> 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 - [, ] + [, ] 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 - [, ] + [, ] >>> del undo_list[:] >>> canvas.remove(item) >>> list(canvas.get_all_items()) [] >>> undo() >>> list(canvas.get_all_items()) # doctest: +ELLIPSIS - [, ] + [, ] >>> canvas.get_children(item) # doctest: +ELLIPSIS - [] + [] 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) diff --git a/examples/exampleitems.py b/examples/exampleitems.py index a3696d2..9f76a07 100644 --- a/examples/exampleitems.py +++ b/examples/exampleitems.py @@ -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 diff --git a/gaphas/item.py b/gaphas/item.py index 7a5df72..7e34374 100644 --- a/gaphas/item.py +++ b/gaphas/item.py @@ -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() diff --git a/gaphas/painter/boundingboxpainter.py b/gaphas/painter/boundingboxpainter.py index 27e65b6..0f0cf95 100644 --- a/gaphas/painter/boundingboxpainter.py +++ b/gaphas/painter/boundingboxpainter.py @@ -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 diff --git a/gaphas/view/gtkview.py b/gaphas/view/gtkview.py index 79b6cb7..b623cae 100644 --- a/gaphas/view/gtkview.py +++ b/gaphas/view/gtkview.py @@ -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. diff --git a/gaphas/view/model.py b/gaphas/view/model.py index 7ad1dee..34f9340 100644 --- a/gaphas/view/model.py +++ b/gaphas/view/model.py @@ -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: ... diff --git a/tests/test_aspect.py b/tests/test_aspect.py index 9a218b1..c40010e 100644 --- a/tests/test_aspect.py +++ b/tests/test_aspect.py @@ -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): diff --git a/tests/test_segment.py b/tests/test_segment.py index b8d999f..74a80de 100644 --- a/tests/test_segment.py +++ b/tests/test_segment.py @@ -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