# 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 <http://www.gnu.org/licenses/>. # 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 = '<b>' + markup + '</b>' if self.pen.italic: markup = '<i>' + markup + '</i>' if self.pen.underline: markup = '<span underline="single">' + markup + '</span>' if self.pen.strikethrough: markup = '<s>' + markup + '</s>' if self.pen.superscript: markup = '<sup><small>' + markup + '</small></sup>' if self.pen.subscript: markup = '<sub><small>' + markup + '</small></sub>' 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 "<Node %s>" % 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 "<Edge %s -> %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