# 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 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
class Shape:
"""Abstract base class for all the drawing shapes."""
def __init__(self):
pass
def draw(self, cr, highlight=False):
"""Draw this shape with the given cairo context"""
raise NotImplementedError
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
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=False):
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
if self.j == self.LEFT:
x = self.x
elif self.j == self.CENTER:
x = self.x - 0.5*width
elif self.j == self.RIGHT:
x = self.x - width
else:
assert 0
y = self.y - height + descent
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)
if self.j == self.LEFT:
x = self.x
elif self.j == self.CENTER:
x = self.x - 0.5*self.w
elif self.j == self.RIGHT:
x = self.x - self.w
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
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=False):
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()
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=False):
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()
class PolygonShape(Shape):
def __init__(self, pen, points, filled=False):
Shape.__init__(self)
self.pen = pen.copy()
self.points = points
self.filled = filled
def draw(self, cr, highlight=False):
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
def draw(self, cr, highlight=False):
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
def draw(self, cr, highlight=False):
x0, y0 = self.points[0]
cr.move_to(x0, y0)
for i in range(1, len(self.points), 3):
x1, y1 = self.points[i]
x2, y2 = self.points[i + 1]
x3, y3 = self.points[i + 2]
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
def draw(self, cr, highlight=False):
for shape in self.shapes:
shape.draw(cr, highlight=highlight)
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
def get_size(self):
return self.width, self.height
def draw(self, cr, highlight_items=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:
shape.draw(cr)
for edge in self.edges:
should_highlight = any(e in highlight_items
for e in (edge, edge.src, edge.dst))
edge.draw(cr, highlight=should_highlight)
for node in self.nodes:
node.draw(cr, highlight=(node in highlight_items))
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