# Sketch - A Python-based interactive drawing program
# Copyright (C) 1997, 1998, 1999, 2001 by Bernhard Herzog
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Library General Public
# License as published by the Free Software Foundation; either
# version 2 of the License, or (at your option) any later version.
#
# This library 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
# Library General Public License for more details.
#
# You should have received a copy of the GNU Library General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# This file contains the root of the Sketch graphics class hierarchy.
#
from traceback import print_stack
from Sketch.warn import warn,INTERNAL
from Sketch.const import CHANGED,SelectSet,Button1Mask,ConstraintMask,\
SCRIPT_GET, SCRIPT_OBJECT, SCRIPT_UNDO
from Sketch import NullUndo,CreateMultiUndo,Undo,UndoAfter
from Sketch import Point,NullPoint,UnionRects,Identity,Translation,Trafo
from blend import Blend,MismatchError,BlendTrafo
from properties import PropertyStack
import properties
# Class Draggable
#
# This class maintains some instance variables for a click and drag
# operation on a graphics object.
#
# The scenario is this: The user has selected a graphics object, say a
# straight line between the points A and B, for editing. As a hint for
# the user where to click, the application shows two inverted rectangles
# at the endpoints. These rectangles are called handles. The user clicks
# on one of the handles, and, with the mouse button still pressed, drags
# the mouse to the new location of the selected endpoint. As feedback to
# the user, the application shows a `rubber-band' line during the drag
# to indicate what the line would look like if the user released the
# button.
#
# Two aspects of this operation are handled by the classes Draggable and
# EditSelect: Keeping track of the start point, the current point, the
# amount dragged, drawing the object during the drag and, in the case of
# Selectable, which parts of the object the user selected.
#
# Keeping track of where the drag started and how far in which direction
# the user has moved the mouse so far, is important, because, in the
# above example the endpoint should be moved not simply to the point the
# user dragged to, but by the amount the user dragged.
#
# To make this a little clearer: the handle is usually a few pixels
# wide, so the user may not click exactly on the pixel the endpoint lies
# on, but some pixels away. In that case, releasing the button without
# moving the mouse would still move the endpoint which is not what the
# user expected.
#
# Using only the offset of the drag is even more important when the
# entire object is being moved. In the above example, clicking on the
# middle of the line should select the entire line, i.e. both endpoints,
# for the drag. During the drag and at the end of the drag we can't move
# one or both endpoints to the current location of the mouse pointer, we
# have to move both endpoints by the same offset.
#
# To achieve this, an instance of Draggable has the following instance
# variables:
#
# dragging True, while being dragged
# drag_start start point
# drag_cur current point
# off offset by which the pointer was moved,
# i.e. drag_cur - drag_start
# drawn true, if the object is visible on the screen in its
# dragged form (see Hide() and Show())
#
# These variables only have meaningful values during the drag, that is,
# between the calls to DragStart() and DragStop(), see below.
# drag_start, drag_cur and off are of type Point. (See the developer's guide)
class Draggable:
drawn = 0
dragging = 0
drag_start = NullPoint
drag_cur = NullPoint
off = NullPoint
drag_mask = Button1Mask # XXX move this to some other class ?
def __init__(self):
# not needed here, but if some derived class wants to call the
# base class constructor...
pass
def DragStart(self, p):
# Start the drag at P. Initialize the instance variables. Set
# dragging to true.
# XXX: document the meaning of the return value
self.drawn = 0 # the object is not visible yet
self.dragging = 1
self.drag_start = p
self.drag_cur = p
self.off = NullPoint
return self.off
def DragMove(self, p):
# The pointer has moved to p. Compute the new offset.
self.off = p - self.drag_start
self.drag_cur = p
def MouseMove(self, p, state):
# XXX add documentation for this
if state & self.drag_mask:
self.off = p - self.drag_start
self.drag_cur = p
def DragStop(self, p):
# The drag stopped at p. Update drag_cur and off for the last
# time, and set dragging to false.
self.dragging = 0
self.off = p - self.drag_start
self.drag_cur = p
def DragCancel(self):
self.dragging = 0
# The rest of Draggable's methods deal with drawing the object in
# `dragged' form (usually an outline) on the screen. The output
# device is assumed to be set up in such a way that drawing the same
# object twice removes it again (usually using GCxor). Currently,
# this will be an instance of InvertingDevice (graphics.py)
#
# Show() and Hide() use this assumption and the instance variable
# drawn, to make certain that the object is visible or invisible,
# respectively. If drawn is false Show() calls DrawDragged() to draw
# the object and then sets drawn to true. This way Show() may be
# called multiple times by the canvas widget if it thinks the
# outline of the object should be visible, without removing the
# outline accidentally.
#
# DrawDragged(), which obviously has to be implemented by some
# derived class, has to draw the outline of the object on the output
# device, using drag_cur or off to compute coordinates. The internal
# state of the object, for example the endpoints of lines, should
# only be changed temporarily during DrawDragged; the state of the
# object should only change if the drag is completed successfully.
#
# The boolean parameter PARTIALLY indicates whether the object has
# to be drawn completely or if it is sufficient to draw only the
# parts that are changed by the drag. For instance, if a vertex of a
# polygon is dragged, it might suffice to draw the two edges sharing
# this vertex. It is safe to ignore this parameter and always draw
# the whole object. It is especially useful for complex objects like
# polygons or poly beziers, where it improves performance and
# reduces flickering on the screen
#
# Implementation Note: Show and Hide are the methods normally used
# by the canvas to show or hide the object while dragging. An
# exception is the RedrawMethod of the canvas object where
# DrawDragged is called directly.
def DrawDragged(self, device, partially):
pass
def Show(self, device, partially = 0):
if not self.drawn:
self.DrawDragged(device, partially)
self.drawn = 1
def Hide(self, device, partially = 0):
if self.drawn:
self.DrawDragged(device, partially)
self.drawn = 0
#
# Class Selectable
#
# This class defines the interface and default implementation for
# objects that can be selected by the user with a mouse click.
#
class Selectable:
def __init__(self):
# only needed for derived classes.
pass
def Hit(self, p, rect, device):
return None
def SelectSubobject(self, p, rect, device, path = None, *rest):
return self
def GetObjectHandle(self, multiple):
# Return a single point marking an important point of the
# object. This point is highlighted by a small rectangle in the
# canvas to indicate that the object is selected. Alternatively,
# a list of such points can be returned to mark several points,
# but that feature should only be used by compound objects.
#
# If multiple is false, self is the only object selected. If
# it's true, there may be more than one selected object.
return []
class EditSelect(Selectable):
def SelectPoint(self, p, rect, device, mode = SelectSet):
# Select (sub)object at P. If something is selected, return
# true, false otherwise.
return 0
def SelectHandle(self, handle, mode = SelectSet):
pass
def SelectRect(self, rect, mode = SelectSet):
# select (sub-)object(s) in RECT
pass
def GetHandles(self):
# In edit mode, this method will be called to get a list of
# handles. A handle should be shown at every `hot' spot of the
# object (e.g. the nodes of a PolyBezier). Handles are described
# by tuples which can be easily created by the functions in
# handle.py
return []
class SelectAndDrag(Draggable, EditSelect):
def __init__(self):
Draggable.__init__(self)
Selectable.__init__(self)
def CurrentInfoText(self):
# return a string describing the current state of the object
# during a drag
return ''
#
# Class Protocols
#
# Some boolean flags that describe the object's capabilities
#
class Protocols:
is_GraphicsObject = 0
is_Primitive = 0
is_Editor = 0
is_Creator = 0
has_edit_mode = 0 # true if object has an edit mode. If true, the
# Editor() method must be implemented
is_curve = 0 # true, if object can be represented by and
# converted to a PolyBezier object. If true, the
# AsBezier() and Paths() methods must be
# implemented
is_clip = 0
has_fill = 0 # True, iff object can have fill properties
has_line = 0 # True, iff object can have line properties
has_font = 0 # True, iff object can have a font
has_properties = 0
is_Bezier = 0
is_Rectangle = 0
is_Ellipse = 0
is_Text = 0 # Text objects must have a Font() method
# returning a font.
is_SimpleText = 0
is_PathTextGroup = 0
is_PathTextText = 0 # The text part of a path text group
is_Image = 0
is_Eps = 0
is_Group = 0
is_Compound = 0
is_Layer = 0
is_Blend = 0 # The blendgroup
is_BlendInterpolation = 0 # The interpolation child of a blend group
is_Clone = 0
is_MaskGroup = 0
is_GuideLine = 0
is_Plugin = 0
#
# Class Bounded
#
# Instances of this class have various kinds of bounding rectangles
# These rectangles are accessible via instance variables to increase
# performance (important for bounding_rect, which is used when testing
# which object is selected by a click). All rectangles are given in
# document coords and are aligned with the axes. The variables are:
#
# coord_rect
#
# The smallest rectangle that contains all points of the outline.
# The line width, if applicable, is NOT taken into account here.
# This rectangle is used to arrange objects (AlignSelected,
# AbutHorizonal, ...)
#
# bounding_rect
#
# Like coord rect but takes the line width into account. It is
# meant to be useful as a PostScript BoundingBox.
#
# Method:
#
# LayoutPoint()
#
# Return the point which should be snapped to a grid point.
#
class Bounded:
_lazy_attrs = {'coord_rect' : 'update_rects',
'bounding_rect' : 'update_rects'}
def __init__(self):
pass
def del_lazy_attrs(self):
for key in self._lazy_attrs.keys():
try:
delattr(self, key)
except:
pass
def update_rects(self):
# compute the various bounding rects and other attributes that
# use `lazy evaluation'. This method MUST be implemented by
# derived classes. It MUST set self.bounding_rect and
# self.coord_rect and other attributes where appropriate.
pass
def __getattr__(self, attr):
# if a lazy attribute is accessed, compute it.
method = self._lazy_attrs.get(attr)
if method:
getattr(self, method)()
# now it should work... use self.__dict__ directly to avoid
# recursion if the method is buggy
try:
return self.__dict__[attr]
except KeyError, msg:
warn(INTERNAL, '%s did not compute %s for %s.',
method, attr, self)
if attr[:2] == attr[-2:] == '__':
#if attr in ('__nonzero__', '__len__'):
# print_stack()
pass
else:
warn(INTERNAL, "%s instance doesn't have an attribute %s",
self.__class__, attr)
raise AttributeError, attr
def LayoutPoint(self):
return Point(self.coord_rect.left, self.coord_rect.bottom)
def GetSnapPoints(self):
return []
#
# Class HierarchyNode
#
# This is base class for all objects that are part of the object
# hierarchy of a document. It manages the parent child relationship and
# the references to the document and other methods (and standard
# behavior) that every object needs. No object derived from this class
# should override the methods defined here except as documented.
#
class HierarchyNode:
def __init__(self, duplicate = None):
if duplicate is not None:
self.document = duplicate.document
if duplicate.was_untied:
self.was_untied = duplicate.was_untied
def __del__(self):
if self.document:
self.document.connector.RemovePublisher(self)
def Destroy(self):
# remove all circular references here...
# May be extended by derived classes.
self.parent = None
parent = None
def SetParent(self, parent):
self.parent = parent
def depth(self):
if self.parent is not None:
return self.parent.depth() + 1
return 1
def SelectionInfo(self):
if self.parent is not None:
return self.parent.SelectionInfo(self)
document = None # the document self belongs to
def SetDocument(self, doc):
self.document = doc
if doc is not None and self.was_untied:
self.TieToDocument()
del self.was_untied
def UntieFromDocument(self):
# this will be called when self is being stored in the clipboard
# (CopyForClipboard/CutForClipboard), but before self.document
# becomes None. Disconnect will not be called in this case.
# May be extended by derived classes.
self.was_untied = 1
def TieToDocument(self):
# this will be called when self is being inserted into the
# document from the clipboard, after self.document has been set.
# Connect will not be called in this case.
# May be extended by derived classes.
pass
def Subscribe(self, channel, func, *args):
# XXX: what do we do if document has not been set (yet)
if self.document is not None:
self.document.connector.Connect(self, channel, func, args)
def Unsubscribe(self, channel, func, *args):
if self.document is not None:
self.document.connector.Disconnect(self, channel, func, args)
def Issue(self, channel, *args):
if self.document is not None:
apply(self.document.connector.Issue, (self, channel,) + args)
def issue_changed(self):
self.Issue(CHANGED, self)
if self.parent is not None:
self.parent.ChildChanged(self)
def Connect(self):
# May be extended by derived classes.
pass
def Disconnect(self):
# May be extended by derived classes.
pass
def Duplicate(self):
# return a duplicate of self
return self.__class__(duplicate = self)
#
# Class GraphicsObject
#
# The base class for all `normal' objects that are part of the drawing
# itself, like rectangles or groups (the experimental clone objects are
# derived from HierarchyNode (Sep98))
#
class GraphicsObject(Bounded, HierarchyNode, Selectable, Protocols):
is_GraphicsObject = 1
keymap = None
commands = []
context_commands = ()
was_untied = 0
script_access = {}
def __init__(self, duplicate = None):
Selectable.__init__(self)
HierarchyNode.__init__(self, duplicate = duplicate)
def ChildChanged(self, child):
# in compound objects, this method is called by the child
# whenever it changes (normally via the issue_changed method)
pass
def __cmp__(self, other):
return cmp(id(self), id(other))
def _changed(self):
self.del_lazy_attrs()
self.issue_changed()
return (self._changed,)
def SetLowerLeftCorner(self, corner):
# move self so that self's lower left corner is at CORNER. This
# used when interactively placing an object
rect = self.coord_rect
ll = Point(rect.left, rect.bottom)
return self.Translate(corner - ll)
def RemoveTransformation(self):
# Some objects accumulate the transformation applied by
# Transform() and apply them every time the object is displayed
# Restore this transformation to Identity.
return NullUndo
script_access['RemoveTransformation'] = SCRIPT_UNDO
def AsBezier(self):
# Return self as bezier if possible. See is_curve above.
return None
script_access['AsBezier'] = SCRIPT_OBJECT
def Paths(self):
# Return a tuple of curve objects describing the outline of self
# if possible. The curve objects can be the ones used internally
# by self. The calling code is expected not to modify the curve
# objects in place.
# See is_curve above.
return None
script_access['Paths'] = SCRIPT_GET
def Blend(self, other, frac1, frac2):
# Return the weighted average of SELF and OTHER. FRAC1 and FRAC2
# are the weights (if SELF and OTHER were numbers this should be
# FRAC1 * SELF + FRAC2 * OTHER).
#
# This method is used by the function Blend() in blend.py. If
# SELF and OTHER can't be blended, raise the blend.MismatchError
# exception. This is also the default behaviour.
raise MismatchError
script_access['Blend'] = SCRIPT_OBJECT
def Snap(self, p):
# Determine the point Q on self's outline closest to P and
# return a tuple (abs(Q - P), Q)
return (1e100, p)
script_access['Snap'] = SCRIPT_GET
def ObjectChanged(self, obj):
return 0
def ObjectRemoved(self, obj):
return NullUndo
# Add some inherited method's script access flags
script_access['coord_rect'] = SCRIPT_GET
script_access['bounding_rect'] = SCRIPT_GET
script_access['LayoutPoint'] = SCRIPT_GET
script_access['Duplicate'] = SCRIPT_OBJECT
# and flags for standard methods
script_access['Transform'] = SCRIPT_UNDO
script_access['Translate'] = SCRIPT_UNDO
#
#
#
class Creator(SelectAndDrag, Protocols):
is_Creator = 1
creation_text = 'Create Object'
def __init__(self, start):
self.start = start
def EndCreation(self):
# This method will be called when the object was being created
# interactively using more than one click-drag-release cycle,
# and the user has finished. This method is needed by the
# PolyBezier primitive for instance.
#
# Return true if creation was successful, false otherwise.
return 1
def ContinueCreation(self):
# called during interactive creation when the user releases the
# mouse button. Return true, if the object may need another
# click-drag-release cycle, false for objects that are always
# complete after one cycle. (XXX the `true' return value is
# interpreted in a special way, see the PolyBezier primitive)
#
# XXX: Should we distinguish more cases? A rectangle for example
# is always complete after one click-drag-release cycle. A
# PolyBezier object needs at least two cycles but accepts any
# number of additional cycles. A polygon (with straight lines)
# needs at least one. We might return a value that indicates
# whether the user *must* supply additional points, whether it's
# optional or whether the object is complete and the user
# *cannot* add points.
return None
class Editor(SelectAndDrag):
is_Editor = 1
EditedClass = GraphicsObject
context_commands = ()
def __init__(self, object):
self.object = object
def __getattr__(self, attr):
return getattr(self.object, attr)
def Destroy(self):
# called by the edit mode selection when the editor it not
# needed anymore.
pass
def ChangeRect(self):
# ChangeRect indicates the area that is going to change during
# the current click-drag-release cycle. It is safe to make this
# equal to bounding_rect. This rectangle is used to determine
# which parts of the window have to be redrawn.
return self.bounding_rect
#
# Class Primitive
#
# The baseclass for all graphics primitives like Polygon, Rectangle, ...
# but not for composite objects. Basically, this adds the management of
# properties and styles to GraphicsObject
class Primitive(GraphicsObject):
has_fill = 1
has_line = 1
has_properties = 1
is_Primitive = 1
tie_info = None
script_access = GraphicsObject.script_access.copy()
def __init__(self, properties = None, duplicate = None):
GraphicsObject.__init__(self, duplicate = duplicate)
if duplicate is not None:
self.properties = duplicate.properties.Duplicate()
if duplicate.tie_info:
self.tie_info = duplicate.tie_info
else:
if properties is not None:
self.properties = properties
else:
self.properties = PropertyStack()
def Destroy(self):
GraphicsObject.Destroy(self)
def UntieFromDocument(self):
info = self.properties.Untie()
if info:
self.tie_info = info
GraphicsObject.UntieFromDocument(self)
def TieToDocument(self):
if self.tie_info:
self.properties.Tie(self.document, self.tie_info)
del self.tie_info
def Transform(self, trafo, rects = None):
# Apply the affine transformation trafo to all coordinates and
# the properties.
undo = self.properties.Transform(trafo, rects)
if undo is not NullUndo:
return self.properties_changed(undo)
return undo
def Translate(self, offset):
# Move all points by OFFSET. OFFSET is an SKPoint instance.
return NullUndo
def DrawShape(self, device):
# Draw the object on device. Here we just set the properties.
device.SetProperties(self.properties, self.bounding_rect)
# The following functions manage the properties
def set_property_stack(self, properties):
self.properties = properties
load_SetProperties = set_property_stack
def properties_changed(self, undo):
if undo is not NullUndo:
return (UndoAfter, undo, self._changed())
return undo
def AddStyle(self, style):
return self.properties_changed(self.properties.AddStyle(style))
script_access['AddStyle'] = SCRIPT_UNDO
def Filled(self):
return self.properties.HasFill()
script_access['Filled'] = SCRIPT_GET
def Properties(self):
return self.properties
script_access['Properties'] = SCRIPT_OBJECT
def SetProperties(self, if_type_present = 0, **kw):
if if_type_present:
# change properties of that type if properties of that are
# already present.
prop_types = properties.property_types
LineProperty = properties.LineProperty
FillProperty = properties.FillProperty
FontProperty = properties.FontProperty
types = map(prop_types.get, kw.keys())
if LineProperty in types and not self.properties.HasLine():
for key in kw.keys():
if prop_types[key] == LineProperty:
del kw[key]
if FillProperty in types and not self.properties.HasFill():
for key in kw.keys():
if prop_types[key] == FillProperty:
del kw[key]
if FontProperty in types and not self.properties.HasFont():
for key in kw.keys():
if prop_types[key] == FontProperty:
del kw[key]
return self.properties_changed(apply(self.properties.SetProperty, (),
kw))
script_access['SetProperties'] = SCRIPT_UNDO
def LineWidth(self):
if self.properties.HasLine:
return self.properties.line_width
return 0
script_access['LineWidth'] = SCRIPT_GET
def ObjectChanged(self, obj):
if self.properties.ObjectChanged(obj):
rect = self.bounding_rect
self.del_lazy_attrs()
self.document.AddClearRect(UnionRects(rect, self.bounding_rect))
self.issue_changed()
return 1
return 0
def ObjectRemoved(self, obj):
return self.properties.ObjectRemoved(obj)
def set_blended_properties(self, blended, other, frac1, frac2):
blended.set_property_stack(Blend(self.properties, other.properties,
frac1, frac2))
def SaveToFile(self, file):
# save object to file. Must be extended by the subclasses. Here,
# we just save the properties.
self.properties.SaveToFile(file)
#
# Class RectangularObject
#
# A mix-in class for graphics objects that are more or less rectangular
# and store their position and orientation in a SKTrafoObject.
class RectangularObject:
def __init__(self, trafo = None, duplicate = None):
if duplicate is not None:
self.trafo = duplicate.trafo
else:
if not trafo:
self.trafo = Identity
else:
self.trafo = trafo
def Trafo(self):
return self.trafo
def LayoutPoint(self, *rest):
# accept arguments to use this function as GetObjectHandle
return self.trafo.offset()
GetObjectHandle = LayoutPoint
def Translate(self, offset):
return self.Transform(Translation(offset))
def set_transformation(self, trafo):
undo = (self.set_transformation, self.trafo)
self.trafo = trafo
self._changed()
return undo
def Transform(self, trafo):
trafo = trafo(self.trafo)
return self.set_transformation(trafo)
def Blend(self, other, p, q):
if other.__class__ == self.__class__:
blended = self.__class__(BlendTrafo(self.trafo, other.trafo, p, q))
self.set_blended_properties(blended, other, p, q)
return blended
raise MismatchError
class RectangularPrimitive(RectangularObject, Primitive):
def __init__(self, trafo = None, properties = None, duplicate = None):
RectangularObject.__init__(self, trafo, duplicate = duplicate)
Primitive.__init__(self, properties = properties,
duplicate = duplicate)
def Transform(self, trafo, transform_properties = 1):
undostyle = undo = NullUndo
try:
rect = self.bounding_rect
undo = RectangularObject.Transform(self, trafo)
if transform_properties:
rects = (rect, self.bounding_rect)
undostyle = Primitive.Transform(self, trafo, rects = rects)
return CreateMultiUndo(undostyle, undo)
except:
Undo(undo)
Undo(undostyle)
raise
def Translate(self, offset):
return self.Transform(Translation(offset), transform_properties = 0)
class RectangularCreator(Creator):
def __init__(self, start):
Creator.__init__(self, start)
self.trafo = Trafo(1, 0, 0, 1, start.x, start.y)
def ButtonDown(self, p, button, state):
Creator.DragStart(self, p)
def apply_constraint(self, p, state):
if state & ConstraintMask:
trafo = self.trafo
w, h = p - self.drag_start
if w == 0:
w = 0.00001
a = h / w
if a > 0:
sign = 1
else:
sign = -1
if abs(a) > 1.0:
h = sign * w
else:
w = sign * h
p = self.drag_start + Point(w, h)
return p
def MouseMove(self, p, state):
p = self.apply_constraint(p, state)
Creator.MouseMove(self, p, state)
def ButtonUp(self, p, button, state):
p = self.apply_constraint(p, state)
Creator.DragStop(self, p)
x, y = self.off
self.trafo = Trafo(x, 0, 0, y, self.trafo.v1, self.trafo.v2)
|