"""Basic items."""
from __future__ import annotations
from dataclasses import dataclass
from math import atan2
from typing import TYPE_CHECKING, Iterable, Protocol, Sequence, runtime_checkable
from cairo import Context as CairoContext
from gaphas.constraint import Constraint, EqualsConstraint, constraint
from gaphas.geometry import distance_line_point, distance_rectangle_point
from gaphas.handle import Handle
from gaphas.matrix import Matrix
from gaphas.port import LinePort, Port
from gaphas.solver import REQUIRED, VERY_STRONG, variable
if TYPE_CHECKING:
from gaphas.connections import Connections
@dataclass(frozen=True)
class DrawContext:
cairo: CairoContext
selected: bool
focused: bool
hovered: bool
[docs]@runtime_checkable
class Item(Protocol):
"""This protocol should be implemented by model items.
All items that are rendered on a view.
"""
@property
def matrix(self) -> Matrix:
"""The "local", item-to-parent matrix."""
@property
def matrix_i2c(self) -> Matrix:
"""Matrix from item to toplevel."""
[docs] def handles(self) -> Sequence[Handle]:
"""Return a list of handles owned by the item."""
[docs] def ports(self) -> Sequence[Port]:
"""Return list of ports owned by the item."""
[docs] 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.
A distance of 0 means the point is on the item.
"""
[docs] def draw(self, context: DrawContext) -> None:
"""Render the item to a canvas view. Context contains the following
attributes:
* `cairo`: the CairoContext use this one to draw
* `selected`, `focused`, `hovered`: view state of items
(True/False)
"""
def matrix_i2i(from_item: Item, to_item: Item) -> Matrix:
i2c = from_item.matrix_i2c
c2i = to_item.matrix_i2c.inverse()
return i2c.multiply(c2i)
class Matrices:
def __init__(self, **kwargs: object) -> None:
super().__init__(**kwargs) # type: ignore[call-arg]
self._matrix = Matrix()
self._matrix_i2c = Matrix()
@property
def matrix(self) -> Matrix:
return self._matrix
@property
def matrix_i2c(self) -> Matrix:
return self._matrix_i2c
[NW, NE, SE, SW] = list(range(4))
[docs]class Element(Matrices):
"""An Element has 4 handles (for a start)::
NW +---+ NE | | SW +---+ SE
"""
min_width = variable(strength=REQUIRED, varname="_min_width")
min_height = variable(strength=REQUIRED, varname="_min_height")
def __init__(
self,
connections: Connections,
width: float = 10,
height: float = 10,
**kwargs: object,
) -> None:
super().__init__(**kwargs)
self._handles = [h(strength=VERY_STRONG) for h in [Handle] * 4]
handles = self._handles
h_nw = handles[NW]
h_ne = handles[NE]
h_sw = handles[SW]
h_se = handles[SE]
# 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),
]
# initialize min_x variables
self.min_width, self.min_height = 10, 10
add = connections.add_constraint
add(self, constraint(horizontal=(h_nw.pos, h_ne.pos)))
add(self, constraint(horizontal=(h_sw.pos, h_se.pos)))
add(self, constraint(vertical=(h_nw.pos, h_sw.pos)))
add(self, constraint(vertical=(h_ne.pos, h_se.pos)))
# create minimal size constraints
add(self, constraint(left_of=(h_nw.pos, h_se.pos), delta=self.min_width))
add(self, constraint(above=(h_nw.pos, h_se.pos), delta=self.min_height))
self.width = width
self.height = height
# Trigger solver to honour width/height by SE handle pos
self._handles[SE].pos.x.dirty()
self._handles[SE].pos.y.dirty()
@property
def width(self) -> float:
"""Width of the box, calculated as the distance from the left and right
handle."""
h = self._handles
return float(h[SE].pos.x) - float(h[NW].pos.x)
@width.setter
def width(self, width: float) -> None:
"""
>>> b=Element()
>>> b.width = 20
>>> b.width
20.0
>>> b._handles[NW].pos.x
Variable(0, 40)
>>> b._handles[SE].pos.x
Variable(20, 40)
"""
h = self._handles
h[SE].pos.x = h[NE].pos.x = h[NW].pos.x + width
@property
def height(self) -> float:
"""Height."""
h = self._handles
return float(h[SE].pos.y) - float(h[NW].pos.y)
@height.setter
def height(self, height: float) -> None:
"""
>>> b=Element()
>>> b.height = 20
>>> b.height
20.0
>>> b.height = 2
>>> b.height
2.0
>>> b._handles[NW].pos.y
Variable(0, 40)
>>> b._handles[SE].pos.y
Variable(2, 40)
"""
h = self._handles
h[SE].pos.y = h[SW].pos.y = h[NW].pos.y + height
[docs] def handles(self) -> Sequence[Handle]:
"""Return a list of handles owned by the item."""
return self._handles
[docs] def ports(self) -> Sequence[Port]:
"""Return list of ports."""
return self._ports
[docs] def point(self, x: float, y: float) -> float:
"""Distance from the point (x, y) to the item.
>>> e = Element()
>>> e.point(20, 10)
10.0
"""
h = self._handles
x0, y0 = h[NW].pos
x1, y1 = h[SE].pos
return distance_rectangle_point((x0, y0, x1 - x0, y1 - y0), (x, y))
def draw(self, context: DrawContext) -> None:
pass
def create_orthogonal_constraints(
handles: Sequence[Handle], horizontal: bool
) -> Iterable[Constraint]:
rest = 1 if horizontal else 0
for pos, (h0, h1) in enumerate(zip(handles, handles[1:])):
p0 = h0.pos
p1 = h1.pos
if pos % 2 == rest:
yield EqualsConstraint(a=p0.x, b=p1.x)
else:
yield EqualsConstraint(a=p0.y, b=p1.y)
[docs]class Line(Matrices):
"""A Line item.
Properties:
- fuzziness (0.0..n): an extra margin that should be taken into
account when calculating the distance from the line (using
point()).
- orthogonal (bool): whether or not the line should be
orthogonal (only straight angles)
- horizontal: first line segment is horizontal
- line_width: width of the line to be drawn
This line also supports arrow heads on both the begin and end of
the line. These are drawn with the methods draw_head(context) and
draw_tail(context). The coordinate system is altered so the
methods do not have to know about the angle of the line segment
(e.g. drawing a line from (10, 10) via (0, 0) to (10, -10) will
draw an arrow point).
"""
def __init__(self, connections: Connections, **kwargs: object) -> None:
super().__init__(**kwargs)
self._connections = connections
self._handles = [Handle(connectable=True), Handle((10, 10), connectable=True)]
self._ports: list[Port] = []
self._update_ports()
self._line_width = 2.0
self._fuzziness = 0.0
self._orthogonal_constraints: list[Constraint] = []
self._horizontal = False
@property
def head(self) -> Handle:
return self._handles[0]
@property
def tail(self) -> Handle:
return self._handles[-1]
@property
def line_width(self) -> float:
return self._line_width
@line_width.setter
def line_width(self, line_width: float) -> None:
self._line_width = line_width
@property
def fuzziness(self) -> float:
return self._fuzziness
@fuzziness.setter
def fuzziness(self, fuzziness: float) -> None:
self._fuzziness = fuzziness
[docs] def update_orthogonal_constraints(self, orthogonal: bool) -> None:
"""Update the constraints required to maintain the orthogonal line.
The actual constraints attribute (``_orthogonal_constraints``)
is observed, so the undo system will update the contents
properly
"""
for c in self._orthogonal_constraints:
self._connections.remove_constraint(self, c)
del self._orthogonal_constraints[:]
if not orthogonal:
return
add = self._connections.add_constraint
# Use public `horizontal` field, so that property can be overwritten
cons = [
add(self, c)
for c in create_orthogonal_constraints(self._handles, self.horizontal)
]
self._set_orthogonal_constraints(cons)
def _set_orthogonal_constraints(
self, orthogonal_constraints: list[Constraint]
) -> None:
"""Setter for the constraints maintained.
Required for the undo system.
"""
self._orthogonal_constraints = orthogonal_constraints
@property
def orthogonal(self) -> bool:
return bool(self._orthogonal_constraints)
@orthogonal.setter
def orthogonal(self, orthogonal: bool) -> None:
"""
>>> a = Line()
>>> a.orthogonal
False
"""
if orthogonal and len(self.handles()) < 3:
raise ValueError("Can't set orthogonal line with less than 3 handles")
self.update_orthogonal_constraints(orthogonal)
@property
def horizontal(self) -> bool:
return self._horizontal
@horizontal.setter
def horizontal(self, horizontal: bool) -> None:
"""
>>> line = Line()
>>> line.horizontal
False
>>> line.horizontal = False
>>> line.horizontal
False
"""
self._horizontal = horizontal
self.update_orthogonal_constraints(self.orthogonal)
def insert_handle(self, index: int, handle: Handle) -> None:
self._handles.insert(index, handle)
def remove_handle(self, handle: Handle) -> None:
self._handles.remove(handle)
def insert_port(self, index: int, port: Port) -> None:
self._ports.insert(index, port)
def remove_port(self, port: Port) -> None:
self._ports.remove(port)
def _update_ports(self) -> None:
"""Update line ports.
This destroys all previously created ports and should only be
used when initializing the line.
"""
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))
[docs] def opposite(self, handle: Handle) -> Handle:
"""Given the handle of one end of the line, return the other end."""
handles = self._handles
if handle is handles[0]:
return handles[-1]
elif handle is handles[-1]:
return handles[0]
else:
raise KeyError("Handle is not an end handle")
[docs] def handles(self) -> Sequence[Handle]:
"""Return a list of handles owned by the item."""
return self._handles
[docs] def ports(self) -> Sequence[Port]:
"""Return list of ports."""
return self._ports
[docs] def point(self, x: float, y: float) -> float:
"""
>>> a = Line()
>>> a.handles()[1].pos = 25, 5
>>> a._handles.append(a._create_handle((30, 30)))
>>> a.point(-1, 0)
1.0
>>> f"{a.point(5, 4):.3f}"
'2.942'
>>> f"{a.point(29, 29):.3f}"
'0.784'
"""
hpos = [h.pos for h in self._handles]
p = (x, y)
distance, _point = min(
distance_line_point(start, end, p) # type: ignore[arg-type]
for start, end in zip(hpos[:-1], hpos[1:])
)
return max(0.0, distance - self.fuzziness)
[docs] def draw_head(self, context: DrawContext) -> None:
"""Default head drawer: move cursor to the first handle."""
context.cairo.move_to(0, 0)
[docs] def draw_tail(self, context: DrawContext) -> None:
"""Default tail drawer: draw line to the last handle."""
context.cairo.line_to(0, 0)
[docs] def draw(self, context: DrawContext) -> None:
"""Draw the line itself.
See Item.draw(context).
"""
def draw_line_end(pos, angle, draw): # type: ignore[no-untyped-def]
cr = context.cairo
cr.save()
try:
cr.translate(*pos)
cr.rotate(angle)
draw(context)
finally:
cr.restore()
cr = context.cairo
cr.set_line_width(self.line_width)
h0, h1 = self._handles[:2]
p0, p1 = h0.pos, h1.pos
head_angle = atan2(p1.y - p0.y, p1.x - p0.x) # type: ignore[assignment]
draw_line_end(self._handles[0].pos, head_angle, self.draw_head)
for h in self._handles[1:-1]:
cr.line_to(*h.pos)
h1, h0 = self._handles[-2:]
p1, p0 = h1.pos, h0.pos
tail_angle = atan2(p1.y - p0.y, p1.x - p0.x) # type: ignore[assignment]
draw_line_end(self._handles[-1].pos, tail_angle, self.draw_tail)
cr.stroke()