{- Bustle.Diagram: shapes for sequence diagrams Copyright (C) 2008–2009 Collabora Ltd. This library 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 2.1 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 Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -} module Bustle.Diagram ( Diagram -- Shapes, and smart constructors therefore , Shape(..) , memberLabel , timestampLabel , headers , headerHeight -- Attributes of shapes , Arrowhead(..) , Side(..) , Colour(..) , Rect -- Annoying constants that users of this module need. , columnWidth , timestampAndMemberWidth , firstColumnOffset , eventHeight -- Displaying diagrams , diagramDimensions , topLeftJustifyDiagram , translateDiagram , drawDiagram , drawRegion ) where import Data.List (unzip4) import Control.Arrow ((&&&)) import Control.Applicative ((<$>), (<*>)) import Control.Monad.Reader import Graphics.Rendering.Cairo import Graphics.UI.Gtk.Cairo (cairoCreateContext, showLayout) import Graphics.Rendering.Pango.Layout import Graphics.Rendering.Pango.Font import qualified Bustle.Markup as Markup import Bustle.Markup (Markup) import Bustle.Util import Bustle.Types (ObjectPath, InterfaceName, MemberName) -- Sorry Mum import System.IO.Unsafe (unsafePerformIO) type Point = (Double, Double) type Rect = (Double, Double, Double, Double) data Arrowhead = Above | Below deriving (Eq, Show, Read, Ord) above, below :: Arrowhead -> Bool above Above = True above Below = False below = not . above voffset :: Num a => Arrowhead -> (a -> a -> a) voffset Above = (-) voffset Below = (+) data Side = L | R deriving (Eq, Show, Read, Ord) offset :: Num a => Side -> (a -> a -> a) offset L = (-) offset R = (+) data Colour = Colour Double Double Double deriving (Eq, Show, Read, Ord) data Shape = Header { strs :: [String] , shapex, shapey :: Double } | MemberLabel { labelPath :: ObjectPath , labelInterface :: Maybe InterfaceName , labelMember :: MemberName , shapeIsReturn :: Bool , shapex :: Double -- The coordinates of the *centre* , shapey :: Double -- of the label } | TimestampLabel { str :: String , shapex :: Double -- The coordinates of the , shapey :: Double -- *centre* of the timestamp } | ClientLines { shapexs :: NonEmpty Double -- The x-coordinates of the lines to draw , shapey1, shapey2 :: Double } | Rule { shapex1, shapex2, shapey :: Double } | Arrow { shapecolour :: Maybe Colour , arrowhead :: Arrowhead , shapex1, shapex2, shapey :: Double } | SignalArrow { shapex1, epicentre, shapex2, shapey :: Double } | DirectedSignalArrow { epicentre, shapex, shapey :: Double } | Arc { topx, topy, bottomx, bottomy :: Double , arcside :: Side , caption :: String } | Highlight { highlightRegion :: Rect } deriving (Show, Eq) -- Smart constructors for TimestampLabel and MemberLabel that fill in the -- hardcoded (spit) x coordinates. memberLabel :: ObjectPath -> Maybe InterfaceName -> MemberName -> Bool -- ^ True if this is a return; False if it's a call -> Double -- ^ y-coordinate -> Shape memberLabel p i m isReturn y = MemberLabel p i m isReturn memberx y timestampLabel :: String -> Double -> Shape timestampLabel s y = TimestampLabel s timestampx y type Diagram = [Shape] arcControlPoints :: Shape -> (Point, Point) arcControlPoints (Arc { topx=x1, topy=y1, bottomx=x2, bottomy=y2, arcside=s }) = let (+-) = offset s cp1 = (x1 +- 60, y1 + 10) cp2 = (x2 +- 60, y2 - 10) in (cp1, cp2) arcControlPoints _ = error "i see you've played arcy-shapey before" mapX, mapY :: (Double -> Double) -> (Shape -> Shape) mapX f s = case s of Rule {} -> s { shapex1 = f (shapex1 s) , shapex2 = f (shapex2 s) } Arrow {} -> s { shapex1 = f (shapex1 s) , shapex2 = f (shapex2 s) } SignalArrow {} -> s { shapex1 = f (shapex1 s) , epicentre = f (epicentre s) , shapex2 = f (shapex2 s) } Arc {} -> s { topx = f (topx s) , bottomx = f (bottomx s) } ClientLines {} -> s { shapexs = mapNonEmpty f (shapexs s) } _ -> s { shapex = f (shapex s) } mapY f s = case s of Arc {} -> s { topy = f (topy s) , bottomy = f (bottomy s) } ClientLines {} -> s { shapey1 = f (shapey1 s) , shapey2 = f (shapey2 s) } _ -> s { shapey = f (shapey s) } -- -- Constants -- eventHeight :: Double eventHeight = 30 timestampx, timestampWidth :: Double timestampx = 0 + timestampWidth / 2 timestampWidth = 60 memberx, memberWidth :: Double memberx = timestampWidth + memberWidth / 2 memberWidth = 340 timestampAndMemberWidth :: Double timestampAndMemberWidth = timestampWidth + memberWidth columnWidth :: Double columnWidth = 90 -- Method return arcs can go outside the first column. Empirically, 20 is -- enough to stop the arc (or the duration text) overlapping the object path -- etc. firstColumnOffset :: Double firstColumnOffset = 20 + columnWidth / 2 -- -- Calculating bounds of shapes -- minMax :: Ord a => (a, a) -> (a, a) minMax = uncurry min &&& uncurry max xMinMax :: Shape -> (Double, Double) xMinMax = minMax . (shapex1 &&& shapex2) fromCentre :: Double -> Double -> Double -> Rect fromCentre x y width = (x - width / 2, y - height / 2, x + width / 2, y + height / 2) where height = eventHeight headerHeight :: [String] -> Double headerHeight = fromIntegral . (10 *) . length bounds :: Shape -> Rect bounds s = case s of ClientLines {} -> let xs = nonEmptyToList (shapexs s) in (minimum xs, shapey1 s, maximum xs, shapey2 s) Rule {} -> (shapex1 s, shapey s, shapex2 s, shapey s) Arrow {} -> let (x1, x2) = xMinMax s y1 = shapey s - (if above (arrowhead s) then 5 else 0) y2 = shapey s + (if below (arrowhead s) then 5 else 0) in (x1, y1, x2, y2) SignalArrow {} -> let (x1, x2) = xMinMax s (y1, y2) = (subtract 5) &&& (+5) $ shapey s in (x1, y1, x2, y2) DirectedSignalArrow {} -> let (x1, x2) = minMax (epicentre s, shapex s) (y1, y2) = (subtract 5) &&& (+5) $ shapey s in (x1, y1, x2, y2) Arc { topx=x1, bottomx=x2, topy=y1, bottomy=y2 } -> let ((cx, _), (dx, _)) = arcControlPoints s -- FIXME: magic 5 makes the bounding box include the text in (min x1 cx, y1, max x2 dx, y2 + 5) TimestampLabel { shapex=x, shapey=y } -> fromCentre x y timestampWidth MemberLabel { shapex=x, shapey=y } -> fromCentre x y memberWidth Header { strs = ss, shapex = x, shapey = y} -> let width = columnWidth height = headerHeight ss in (x - width / 2, y, x + width / 2, y + height) Highlight r -> r intersects :: Rect -> Rect -> Bool intersects (x,y,w,z) (x', y', w', z') = not $ or [x > w', w < x', y > z', z < y'] -- Constructs a series of headers of various-sized lists of names, -- bottom-justified. headers :: [(Double, [String])] -- list of (x-coordinate, names) -> Double -- y-coordinate of top of headers -> (Double, [Shape]) -- the headers' combined height, and shapes headers [] _ = (0, []) headers xss y = (height, shapes) where heights = map (headerHeight . snd) xss height = maximum heights adjs = map (height -) heights shapes = zipWith (\(x, ss) adj -> Header ss x (y + adj)) xss adjs -- -- Drawing -- diagramBounds :: Diagram -> ((Double, Double), (Double, Double)) diagramBounds shapes = ((minimum (0:x1s), minimum (0:y1s)) ,(maximum (0:x2s), maximum (0:y2s)) ) where (x1s, y1s, x2s, y2s) = unzip4 $ map bounds shapes diagramDimensions :: Diagram -> (Double, Double) diagramDimensions shapes = (x2 - x1, y2 - y1) where ((x1, y1), (x2, y2)) = diagramBounds shapes topLeftJustifyDiagram :: Diagram -- ^ the original diagram -> ((Double, Double), Diagram) -- ^ the diagram transformed to be in -- positive space, and the (x, y)-axis -- shifts necessary to do so topLeftJustifyDiagram shapes = (translation, shapes') where ((x1, y1), _) = diagramBounds shapes translation = (negate x1, negate y1) shapes' = translateDiagram translation shapes translateDiagram :: (Double, Double) -> (Diagram -> Diagram) translateDiagram (x, y) = map (mapX (+ x) . mapY (+ y)) drawDiagramInternal :: (Shape -> Bool) -- ^ A filter for the shapes -> Bool -- ^ True to draw canvas items' bounding boxes -- (for debugging) -> Diagram -- ^ A diagram to render -> Render () drawDiagramInternal f drawBounds shapes = do clearCanvas forM_ (filter f shapes) $ \x -> do when drawBounds (drawBoundingBox x) draw x drawDiagram :: Bool -- ^ True to draw canvas items' bounding boxes (for -- debugging) -> Diagram -- ^ A diagram to render -> Render () drawDiagram = drawDiagramInternal (const True) drawRegion :: Rect -> Bool -> Diagram -> Render () drawRegion r = drawDiagramInternal isVisible where isVisible = intersects r . bounds saved :: Render () -> Render () saved act = save >> act >> restore clearCanvas :: Render () clearCanvas = saved $ do setSourceRGB 1 1 1 setOperator OperatorSource paint drawBoundingBox :: Shape -> Render () drawBoundingBox s = saved $ do let (x,y,w,z) = bounds s setSourceRGB 0 0 1 rectangle x y (w - x) (z - y) stroke draw :: Shape -> Render () draw s = draw' s where draw' = case s of Arc {} -> let ((cx, cy), (dx, dy)) = arcControlPoints s in drawArc cx cy dx dy <$> topx <*> topy <*> bottomx <*> bottomy <*> caption SignalArrow {} -> drawSignalArrow <$> epicentre <*> (Just . shapex1) <*> (Just . shapex2) <*> shapey DirectedSignalArrow { } -> drawDirectedSignalArrow <$> epicentre <*> shapex <*> shapey Arrow {} -> drawArrow <$> shapecolour <*> arrowhead <*> shapex1 <*> shapex2 <*> shapey Header {} -> drawHeader <$> strs <*> shapex <*> shapey MemberLabel {} -> drawMember <$> labelPath <*> labelInterface <*> labelMember <*> shapeIsReturn <*> shapex <*> shapey TimestampLabel {} -> drawTimestamp <$> str <*> shapex <*> shapey ClientLines {} -> drawClientLines <$> shapexs <*> shapey1 <*> shapey2 Rule {} -> drawRule <$> shapex1 <*> shapex2 <*> shapey Highlight {} -> drawHighlight <$> highlightRegion halfArrowHead :: Arrowhead -> Bool -> Render () halfArrowHead a left = do (x,y) <- getCurrentPoint let x' = if left then x - 10 else x + 10 let y' = voffset a y 5 if left -- work around weird artifacts then moveTo x' y' >> lineTo x y else lineTo x' y' >> moveTo x y arrowHead :: Bool -> Render () arrowHead left = halfArrowHead Above left >> halfArrowHead Below left drawArrow :: Maybe Colour -> Arrowhead -> Double -> Double -> Double -> Render () drawArrow c a from to y = saved $ do maybe (return ()) (\(Colour r g b) -> setSourceRGB r g b) c moveTo from y lineTo to y halfArrowHead a (from < to) stroke drawDirectedSignalArrow :: Double -- ^ the signal emission source -> Double -- ^ signal target coordinate -> Double -- ^ vertical coordinate -> Render () drawDirectedSignalArrow e x y | x < e = drawSignalArrow e (Just x) Nothing y | otherwise = drawSignalArrow e Nothing (Just x) y drawSignalArrow :: Double -- ^ the signal emission source -> Maybe Double -- ^ left-pointing arrow coordinate -> Maybe Double -- ^ right-pointing arrow coordinate -> Double -- ^ vertical coordinate -> Render () drawSignalArrow e mleft mright y = do newPath arc e y 5 0 (2 * pi) stroke maybeM mleft $ \left -> do moveTo left y arrowHead False lineTo (e - 5) y stroke maybeM mright $ \right -> do moveTo (e + 5) y lineTo right y arrowHead True stroke drawArc :: Double -> Double -> Double -> Double -> Double -> Double -> Double -> Double -> String -> Render () drawArc cx cy dx dy x1 y1 x2 y2 cap = saved $ do setSourceRGB 0.4 0.7 0.4 setDash [3, 3] 0 moveTo x1 y1 curveTo cx cy dx dy x2 y2 stroke setSourceRGB 0 0 0 l <- mkLayout (Markup.escape cap) EllipsizeNone AlignLeft (PangoRectangle _ _ textWidth _, _) <- liftIO $ layoutGetExtents l let tx = min x2 dx + abs (x2 - dx) / 2 moveTo (if x1 > cx then tx - textWidth else tx) (y2 - 5) showLayout l font :: FontDescription font = unsafePerformIO $ do fd <- fontDescriptionNew fontDescriptionSetSize fd 7 fontDescriptionSetFamily fd "Sans" return fd {-# NOINLINE font #-} mkLayout :: (MonadIO m) => Markup -> EllipsizeMode -> LayoutAlignment -> m PangoLayout mkLayout s e a = liftIO $ do ctx <- cairoCreateContext Nothing layout <- layoutEmpty ctx layoutSetMarkup layout (Markup.unMarkup s) layoutSetFontDescription layout (Just font) layoutSetEllipsize layout e layoutSetAlignment layout a return layout withWidth :: MonadIO m => m PangoLayout -> Double -> m PangoLayout withWidth m w = do l <- m liftIO $ layoutSetWidth l (Just w) return l drawHeader :: [String] -> Double -> Double -> Render () drawHeader names x y = forM_ (zip [0..] names) $ \(i, name) -> do l <- mkLayout (Markup.escape name) EllipsizeEnd AlignCenter `withWidth` columnWidth moveTo (x - (columnWidth / 2)) (y + i * h) showLayout l where h = 10 drawMember :: ObjectPath -> Maybe InterfaceName -> MemberName -> Bool -> Double -> Double -> Render () drawMember p i m isReturn x y = do drawOne path (y - 10) drawOne fullMethod y where drawOne markup y' = do l <- mkLayout markup EllipsizeStart AlignLeft `withWidth` memberWidth moveTo (x - memberWidth / 2) y' showLayout l path = (if isReturn then id else Markup.b) $ Markup.escape p fullMethod = (if isReturn then Markup.i else id) $ Markup.formatMember i m drawTimestamp :: String -> Double -> Double -> Render () drawTimestamp ts x y = do moveTo (x - timestampWidth / 2) (y - 10) showLayout =<< mkLayout (Markup.escape ts) EllipsizeNone AlignLeft `withWidth` timestampWidth drawClientLines :: NonEmpty Double -> Double -> Double -> Render () drawClientLines xs y1 y2 = saved $ do setSourceRGB 0.7 0.7 0.7 forM_ (nonEmptyToList xs) $ \x -> do moveTo x y1 lineTo x y2 stroke drawRule :: Double -> Double -> Double -> Render () drawRule x1 x2 y = saved $ do setSourceRGB 0.9 0.9 0.9 setLineWidth 0.5 moveTo x1 y lineTo x2 y stroke drawHighlight :: Rect -> Render () drawHighlight (x1, y1, x2, y2) = saved $ do setSourceRGB 0.8 0.9 1.0 rectangle x1 y1 (x2 - x1) (y2 - y1) fill -- vim: sw=2 sts=2