# Copyright 2008-2015 Jose Fonseca
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see .
#
import math
import operator
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('PangoCairo', '1.0')
from gi.repository import GObject
from gi.repository import Gdk
from gi.repository import GdkPixbuf
from gi.repository import Pango
from gi.repository import PangoCairo
import cairo
_inf = float('inf')
_get_bounding = operator.attrgetter('bounding')
class Shape:
"""Abstract base class for all the drawing shapes."""
bounding = (-_inf, -_inf, _inf, _inf)
def __init__(self):
pass
def _intersects(self, bounding):
x0, y0, x1, y1 = bounding
x2, y2, x3, y3 = self.bounding
return x2 <= x1 and x0 <= x3 and y2 <= y1 and y0 <= y3
def _fully_in(self, bounding):
x0, y0, x1, y1 = bounding
x2, y2, x3, y3 = self.bounding
return x0 <= x2 and x3 <= x1 and y0 <= y2 and y3 <= y1
def _draw(self, cr, highlight, bounding):
"""Actual draw implementation"""
raise NotImplementedError
def draw(self, cr, highlight=False, bounding=None):
"""Draw this shape with the given cairo context"""
if bounding is None or self._intersects(bounding):
self._draw(cr, highlight, bounding)
def select_pen(self, highlight):
if highlight:
if not hasattr(self, 'highlight_pen'):
self.highlight_pen = self.pen.highlighted()
return self.highlight_pen
else:
return self.pen
def search_text(self, regexp):
return False
@staticmethod
def _bounds_from_points(points):
x0, y0 = points[0]
x1, y1 = x0, y0
for i in range(1, len(points)):
x, y = points[i]
x0, x1 = min(x0, x), max(x1, x)
y0, y1 = min(y0, y), max(y1, y)
return x0, y0, x1, y1
@staticmethod
def _envelope_bounds(*args):
xa = ya = _inf
xb = yb = -_inf
for bounds in args:
for x0, y0, x1, y1 in bounds:
xa, xb = min(xa, x0), max(xb, x1)
ya, yb = min(ya, y0), max(yb, y1)
return xa, ya, xb, yb
class TextShape(Shape):
LEFT, CENTER, RIGHT = -1, 0, 1
def __init__(self, pen, x, y, j, w, t):
Shape.__init__(self)
self.pen = pen.copy()
self.x = x
self.y = y
self.j = j # Centering
self.w = w # width
self.t = t # text
def _draw(self, cr, highlight, bounding):
try:
layout = self.layout
except AttributeError:
layout = PangoCairo.create_layout(cr)
# set font options
# see http://lists.freedesktop.org/archives/cairo/2007-February/009688.html
context = layout.get_context()
fo = cairo.FontOptions()
fo.set_antialias(cairo.ANTIALIAS_DEFAULT)
fo.set_hint_style(cairo.HINT_STYLE_NONE)
fo.set_hint_metrics(cairo.HINT_METRICS_OFF)
try:
PangoCairo.context_set_font_options(context, fo)
except TypeError:
# XXX: Some broken pangocairo bindings show the error
# 'TypeError: font_options must be a cairo.FontOptions or None'
pass
except KeyError:
# cairo.FontOptions is not registered as a foreign
# struct in older PyGObject versions.
# https://git.gnome.org/browse/pygobject/commit/?id=b21f66d2a399b8c9a36a1758107b7bdff0ec8eaa
pass
# set font
font = Pango.FontDescription()
# https://developer.gnome.org/pango/stable/PangoMarkupFormat.html
markup = GObject.markup_escape_text(self.t)
if self.pen.bold:
markup = '' + markup + ''
if self.pen.italic:
markup = '' + markup + ''
if self.pen.underline:
markup = '' + markup + ''
if self.pen.strikethrough:
markup = '' + markup + ''
if self.pen.superscript:
markup = '' + markup + ''
if self.pen.subscript:
markup = '' + markup + ''
success, attrs, text, accel_char = Pango.parse_markup(markup, -1, '\x00')
assert success
layout.set_attributes(attrs)
font.set_family(self.pen.fontname)
font.set_absolute_size(self.pen.fontsize*Pango.SCALE)
layout.set_font_description(font)
# set text
layout.set_text(text, -1)
# cache it
self.layout = layout
else:
PangoCairo.update_layout(cr, layout)
descent = 2 # XXX get descender from font metrics
width, height = layout.get_size()
width = float(width)/Pango.SCALE
height = float(height)/Pango.SCALE
# we know the width that dot thinks this text should have
# we do not necessarily have a font with the same metrics
# scale it so that the text fits inside its box
if width > self.w:
f = self.w / width
width = self.w # equivalent to width *= f
height *= f
descent *= f
else:
f = 1.0
y = self.y - height + descent
if bounding is None or (y <= bounding[3] and bounding[1] <= y + height):
x = self.x - 0.5 * (1 + self.j) * width
cr.move_to(x, y)
cr.save()
cr.scale(f, f)
cr.set_source_rgba(*self.select_pen(highlight).color)
PangoCairo.show_layout(cr, layout)
cr.restore()
if 0: # DEBUG
# show where dot thinks the text should appear
cr.set_source_rgba(1, 0, 0, .9)
x = self.x - 0.5 * (1 + self.j) * width
cr.move_to(x, self.y)
cr.line_to(x+self.w, self.y)
cr.stroke()
def search_text(self, regexp):
return regexp.search(self.t) is not None
@property
def bounding(self):
x, w, j = self.x, self.w, self.j
return x - 0.5 * (1 + j) * w, -_inf, x + 0.5 * (1 - j) * w, _inf
class ImageShape(Shape):
def __init__(self, pen, x0, y0, w, h, path):
Shape.__init__(self)
self.pen = pen.copy()
self.x0 = x0
self.y0 = y0
self.w = w
self.h = h
self.path = path
def _draw(self, cr, highlight, bounding):
pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.path)
sx = float(self.w)/float(pixbuf.get_width())
sy = float(self.h)/float(pixbuf.get_height())
cr.save()
cr.translate(self.x0, self.y0 - self.h)
cr.scale(sx, sy)
Gdk.cairo_set_source_pixbuf(cr, pixbuf, 0, 0)
cr.paint()
cr.restore()
@property
def bounding(self):
x0, y0 = self.x0, self.y0
return x0, y0 - self.h, x0 + self.w, y0
class EllipseShape(Shape):
def __init__(self, pen, x0, y0, w, h, filled=False):
Shape.__init__(self)
self.pen = pen.copy()
self.x0 = x0
self.y0 = y0
self.w = w
self.h = h
self.filled = filled
def _draw(self, cr, highlight, bounding):
cr.save()
cr.translate(self.x0, self.y0)
cr.scale(self.w, self.h)
cr.move_to(1.0, 0.0)
cr.arc(0.0, 0.0, 1.0, 0, 2.0*math.pi)
cr.restore()
pen = self.select_pen(highlight)
if self.filled:
cr.set_source_rgba(*pen.fillcolor)
cr.fill()
else:
cr.set_dash(pen.dash)
cr.set_line_width(pen.linewidth)
cr.set_source_rgba(*pen.color)
cr.stroke()
@property
def bounding(self):
x0, y0, w, h = self.x0, self.y0, self.w, self.h
bt = 0 if self.filled else self.pen.linewidth / 2.
w, h = w + bt, h + bt
return x0 - w, y0 - h, x0 + w, y0 + h
class PolygonShape(Shape):
def __init__(self, pen, points, filled=False):
Shape.__init__(self)
self.pen = pen.copy()
self.points = points
self.filled = filled
x0, y0, x1, y1 = Shape._bounds_from_points(self.points)
bt = 0 if self.filled else self.pen.linewidth / 2.
self.bounding = x0 - bt, y0 - bt, x1 + bt, y1 + bt
def _draw(self, cr, highlight, bounding):
x0, y0 = self.points[-1]
cr.move_to(x0, y0)
for x, y in self.points:
cr.line_to(x, y)
cr.close_path()
pen = self.select_pen(highlight)
if self.filled:
cr.set_source_rgba(*pen.fillcolor)
cr.fill_preserve()
cr.fill()
else:
cr.set_dash(pen.dash)
cr.set_line_width(pen.linewidth)
cr.set_source_rgba(*pen.color)
cr.stroke()
class LineShape(Shape):
def __init__(self, pen, points):
Shape.__init__(self)
self.pen = pen.copy()
self.points = points
x0, y0, x1, y1 = Shape._bounds_from_points(self.points)
bt = self.pen.linewidth / 2.
self.bounding = x0 - bt, y0 - bt, x1 + bt, y1 + bt
def _draw(self, cr, highlight, bounding):
x0, y0 = self.points[0]
cr.move_to(x0, y0)
for x1, y1 in self.points[1:]:
cr.line_to(x1, y1)
pen = self.select_pen(highlight)
cr.set_dash(pen.dash)
cr.set_line_width(pen.linewidth)
cr.set_source_rgba(*pen.color)
cr.stroke()
class BezierShape(Shape):
def __init__(self, pen, points, filled=False):
Shape.__init__(self)
self.pen = pen.copy()
self.points = points
self.filled = filled
x0, y0 = self.points[0]
xa = xb = x0
ya = yb = y0
for i in range(1, len(self.points), 3):
(x1, y1), (x2, y2), (x3, y3) = self.points[i:i+3]
for t in self._cubic_bernstein_extrema(x0, x1, x2, x3):
if 0 < t < 1: # We're dealing only with Bezier curves
v = self._cubic_bernstein(x0, x1, x2, x3, t)
xa, xb = min(xa, v), max(xb, v)
xa, xb = min(xa, x3), max(xb, x3) # t=0 / t=1
for t in self._cubic_bernstein_extrema(y0, y1, y2, y3):
if 0 < t < 1: # We're dealing only with Bezier curves
v = self._cubic_bernstein(y0, y1, y2, y3, t)
ya, yb = min(ya, v), max(yb, v)
ya, yb = min(ya, y3), max(yb, y3) # t=0 / t=1
x0, y0 = x3, y3
bt = 0 if self.filled else self.pen.linewidth / 2.
self.bounding = xa - bt, ya - bt, xb + bt, yb + bt
@staticmethod
def _cubic_bernstein_extrema(p0, p1, p2, p3):
"""
Find extremas of a function of real domain defined by evaluating
a cubic bernstein polynomial of given bernstein coefficients.
"""
# compute coefficients of derivative
a = 3.*(p3-p0+3.*(p1-p2))
b = 6.*(p0+p2-2.*p1)
c = 3.*(p1-p0)
if a == 0:
if b == 0:
return () # constant
return (-c / b,) # linear
# quadratic
# compute discriminant
d = b*b - 4.*a*c
if d < 0:
return ()
k = -2. * a
if d == 0:
return (b / k,)
r = math.sqrt(d)
return ((b + r) / k, (b - r) / k)
@staticmethod
def _cubic_bernstein(p0, p1, p2, p3, t):
"""
Evaluate polynomial of given bernstein coefficients
using de Casteljau's algorithm.
"""
u = 1 - t
return p0*(u**3) + 3*t*u*(p1*u + p2*t) + p3*(t**3)
def _draw(self, cr, highlight, bounding):
x0, y0 = self.points[0]
cr.move_to(x0, y0)
for i in range(1, len(self.points), 3):
(x1, y1), (x2, y2), (x3, y3) = self.points[i:i+3]
cr.curve_to(x1, y1, x2, y2, x3, y3)
pen = self.select_pen(highlight)
if self.filled:
cr.set_source_rgba(*pen.fillcolor)
cr.fill_preserve()
cr.fill()
else:
cr.set_dash(pen.dash)
cr.set_line_width(pen.linewidth)
cr.set_source_rgba(*pen.color)
cr.stroke()
class CompoundShape(Shape):
def __init__(self, shapes):
Shape.__init__(self)
self.shapes = shapes
self.bounding = Shape._envelope_bounds(map(_get_bounding, self.shapes))
def _draw(self, cr, highlight, bounding):
if bounding is not None and self._fully_in(bounding):
bounding = None
for shape in self.shapes:
if bounding is None or shape._intersects(bounding):
shape._draw(cr, highlight, bounding)
def search_text(self, regexp):
for shape in self.shapes:
if shape.search_text(regexp):
return True
return False
class Url(object):
def __init__(self, item, url, highlight=None):
self.item = item
self.url = url
if highlight is None:
highlight = set([item])
self.highlight = highlight
class Jump(object):
def __init__(self, item, x, y, highlight=None):
self.item = item
self.x = x
self.y = y
if highlight is None:
highlight = set([item])
self.highlight = highlight
class Element(CompoundShape):
"""Base class for graph nodes and edges."""
def __init__(self, shapes):
CompoundShape.__init__(self, shapes)
def is_inside(self, x, y):
return False
def get_url(self, x, y):
return None
def get_jump(self, x, y):
return None
class Node(Element):
def __init__(self, id, x, y, w, h, shapes, url):
Element.__init__(self, shapes)
self.id = id
self.x = x
self.y = y
self.x1 = x - 0.5*w
self.y1 = y - 0.5*h
self.x2 = x + 0.5*w
self.y2 = y + 0.5*h
self.url = url
def is_inside(self, x, y):
return self.x1 <= x and x <= self.x2 and self.y1 <= y and y <= self.y2
def get_url(self, x, y):
if self.url is None:
return None
if self.is_inside(x, y):
return Url(self, self.url)
return None
def get_jump(self, x, y):
if self.is_inside(x, y):
return Jump(self, self.x, self.y)
return None
def __repr__(self):
return "" % self.id
def square_distance(x1, y1, x2, y2):
deltax = x2 - x1
deltay = y2 - y1
return deltax*deltax + deltay*deltay
class Edge(Element):
def __init__(self, src, dst, points, shapes):
Element.__init__(self, shapes)
self.src = src
self.dst = dst
self.points = points
RADIUS = 10
def is_inside_begin(self, x, y):
return square_distance(x, y, *self.points[0]) <= self.RADIUS*self.RADIUS
def is_inside_end(self, x, y):
return square_distance(x, y, *self.points[-1]) <= self.RADIUS*self.RADIUS
def is_inside(self, x, y):
if self.is_inside_begin(x, y):
return True
if self.is_inside_end(x, y):
return True
return False
def get_jump(self, x, y):
if self.is_inside_begin(x, y):
return Jump(self, self.dst.x, self.dst.y, highlight=set([self, self.dst]))
if self.is_inside_end(x, y):
return Jump(self, self.src.x, self.src.y, highlight=set([self, self.src]))
return None
def __repr__(self):
return " %s>" % (self.src, self.dst)
class Graph(Shape):
def __init__(self, width=1, height=1, shapes=(), nodes=(), edges=()):
Shape.__init__(self)
self.width = width
self.height = height
self.shapes = shapes
self.nodes = nodes
self.edges = edges
self.bounding = Shape._envelope_bounds(
map(_get_bounding, self.shapes),
map(_get_bounding, self.nodes),
map(_get_bounding, self.edges))
def get_size(self):
return self.width, self.height
def draw(self, cr, highlight_items=None, bounding=None):
if bounding is not None:
if not self._intersects(bounding):
return
if self._fully_in(bounding):
bounding = None
if highlight_items is None:
highlight_items = ()
cr.set_source_rgba(0.0, 0.0, 0.0, 1.0)
cr.set_line_cap(cairo.LINE_CAP_BUTT)
cr.set_line_join(cairo.LINE_JOIN_MITER)
for shape in self.shapes:
if bounding is None or shape._intersects(bounding):
shape._draw(cr, highlight=False, bounding=bounding)
for edge in self.edges:
if bounding is None or edge._intersects(bounding):
should_highlight = any(e in highlight_items
for e in (edge, edge.src, edge.dst))
edge._draw(cr, highlight=should_highlight, bounding=bounding)
for node in self.nodes:
if bounding is None or node._intersects(bounding):
node._draw(cr, highlight=(node in highlight_items), bounding=bounding)
def get_element(self, x, y):
for node in self.nodes:
if node.is_inside(x, y):
return node
for edge in self.edges:
if edge.is_inside(x, y):
return edge
def get_url(self, x, y):
for node in self.nodes:
url = node.get_url(x, y)
if url is not None:
return url
return None
def get_jump(self, x, y):
for edge in self.edges:
jump = edge.get_jump(x, y)
if jump is not None:
return jump
for node in self.nodes:
jump = node.get_jump(x, y)
if jump is not None:
return jump
return None