brillo-examples-2.0.0: picture/CanvasEditor/Main.hs
{-# LANGUAGE OverloadedStrings #-}
{-| Interactive canvas editor with draggable shapes and SVG export.
This example demonstrates:
- Creating graphical elements (shapes) on a canvas
- Dragging shapes with the mouse
- A clickable "Export as SVG" button
- Using the file save dialog to export the canvas
-}
module Main where
import Brillo
import Brillo.Export.SVG (exportPictureToSVG)
import Brillo.Interface.Environment (saveFileDialog)
import Brillo.Interface.IO.Game
import Control.Monad (foldM)
import Data.Maybe (listToMaybe)
import System.Directory (doesFileExist)
import System.Environment (getArgs)
-- | A shape on the canvas
data Shape = Shape
{ shapeType :: ShapeType
, shapePos :: Point
, shapeColor :: Color
}
data ShapeType
= -- | Circle with radius
CircleShape Float
| -- | Rectangle with width and height
RectShape Float Float
| -- | Equilateral triangle with side length
TriangleShape Float
-- | Application state
data AppState = AppState
{ shapes :: [Shape]
-- ^ All shapes on the canvas
, dragging :: Maybe Int
-- ^ Index of shape being dragged (if any)
, dragOffset :: Point
-- ^ Offset from shape center to mouse when drag started
, mousePos :: Point
-- ^ Current mouse position
, fontPath :: FilePath
-- ^ Path to TrueType font
}
-- | Layout dimensions
headerHeight :: Float
headerHeight = 50
canvasWidth, canvasHeight :: Int
canvasWidth = 800
canvasHeight = 450
buttonHeight :: Float
buttonHeight = 50
-- Total window height: header + canvas + button area
windowWidth, windowHeight :: Int
windowWidth = canvasWidth
windowHeight = round headerHeight + canvasHeight + round buttonHeight + 20
-- Y positions (origin at center of window)
headerY :: Float
headerY = fromIntegral windowHeight / 2 - headerHeight / 2
canvasY :: Float
canvasY = headerY - headerHeight / 2 - fromIntegral canvasHeight / 2
buttonY :: Float
buttonY = canvasY - fromIntegral canvasHeight / 2 - buttonHeight / 2 - 10
-- | Initial shapes on the canvas (positions relative to canvas center)
initialShapes :: [Shape]
initialShapes =
[ Shape (CircleShape 50) (-150, 100) red
, Shape (RectShape 80 60) (100, 50) blue
, Shape (TriangleShape 70) (0, -100) green
, Shape (CircleShape 30) (200, 150) orange
, Shape (RectShape 50 80) (-200, -50) violet
]
-- | Initial application state
initialState :: FilePath -> AppState
initialState font =
AppState
{ shapes = initialShapes
, dragging = Nothing
, dragOffset = (0, 0)
, mousePos = (0, 0)
, fontPath = font
}
-- | Convert window coordinates to canvas-local coordinates
windowToCanvas :: Point -> Point
windowToCanvas (wx, wy) = (wx, wy - canvasY)
-- | Convert canvas-local coordinates to window coordinates
canvasToWindow :: Point -> Point
canvasToWindow (cx, cy) = (cx, cy + canvasY)
-- | Check if a point (in window coords) is inside the canvas
pointInCanvas :: Point -> Bool
pointInCanvas (px, py) =
let halfW = fromIntegral canvasWidth / 2
halfH = fromIntegral canvasHeight / 2
cy = py - canvasY
in px >= -halfW
&& px <= halfW
&& cy >= -halfH
&& cy <= halfH
-- | Check if a point (in canvas-local coords) is inside a shape
pointInShape :: Point -> Shape -> Bool
pointInShape (px, py) (Shape shapeT (sx, sy) _) =
case shapeT of
CircleShape r ->
let dx = px - sx
dy = py - sy
in dx * dx + dy * dy <= r * r
RectShape w h ->
let halfW = w / 2
halfH = h / 2
in px >= sx - halfW
&& px <= sx + halfW
&& py >= sy - halfH
&& py <= sy + halfH
TriangleShape side ->
-- Approximate with bounding circle
let r = side * 0.6
dx = px - sx
dy = py - sy
in dx * dx + dy * dy <= r * r
-- | Check if a point (in window coords) is inside the export button
pointInButton :: Point -> Bool
pointInButton (px, py) =
let btnWidth = 180
halfW = btnWidth / 2
halfH = buttonHeight / 2
in px >= -halfW
&& px <= halfW
&& py >= buttonY - halfH
&& py <= buttonY + halfH
-- | Find the topmost shape at a given point (last in list = topmost)
findShapeAt :: Point -> [Shape] -> Maybe Int
findShapeAt pt shps =
case [i | (i, s) <- zip [0 ..] shps, pointInShape pt s] of
[] -> Nothing
is -> Just (last is)
-- | Render a single shape
renderShape :: Shape -> Picture
renderShape (Shape shapeT (x, y) c) =
Translate x y $
Color c $
case shapeT of
CircleShape r -> circleSolid r
RectShape w h -> rectangleSolid w h
TriangleShape side ->
let h = side * sqrt 3 / 2
pts =
[ (0, h * 2 / 3)
, (-side / 2, -h / 3)
, (side / 2, -h / 3)
]
in Polygon pts
-- | Render the header with instructions
renderHeader :: FilePath -> Picture
renderHeader font =
Translate 0 headerY $
Pictures
[ -- Header background (dark gray like the window background)
Color (greyN 0.8) $
rectangleSolid (fromIntegral canvasWidth) headerHeight
, -- Instructions text
Translate (-380) (-8) $
Color (greyN 0.3) $
TrueTypeText
font
20
"Drag shapes to move them. Click the button below to export as SVG."
]
-- | Render the export button
renderButton :: FilePath -> Bool -> Picture
renderButton font isHovered =
Translate 0 buttonY $
Pictures
[ -- Button background
Color
(if isHovered then makeColor 0.3 0.6 0.9 1.0 else makeColor 0.2 0.5 0.8 1.0)
$ rectangleSolid 180 buttonHeight
, -- Button border
Color (greyN 0.2) $ rectangleWire 180 buttonHeight
, -- Button text
Translate (-70) (-8) $
Color white $
TrueTypeText font 22 "Export as SVG"
]
-- | Render the canvas background
renderCanvas :: Picture
renderCanvas =
Translate 0 canvasY $
Pictures
[ Color (greyN 0.95) $
rectangleSolid (fromIntegral canvasWidth) (fromIntegral canvasHeight)
, Color (greyN 0.7) $
rectangleWire (fromIntegral canvasWidth) (fromIntegral canvasHeight)
]
-- | Render the canvas content (shapes only, for export)
renderCanvasContent :: AppState -> Picture
renderCanvasContent state =
Pictures $ map renderShape (shapes state)
-- | Render the entire scene
renderScene :: AppState -> IO Picture
renderScene state = do
let isHovered = pointInButton (mousePos state)
pure $
Pictures
[ renderHeader (fontPath state)
, renderCanvas
, Translate 0 canvasY $ renderCanvasContent state
, renderButton (fontPath state) isHovered
]
-- | Handle input events
handleEvent :: Event -> AppState -> IO AppState
handleEvent event state =
case event of
-- Track mouse position
EventMotion pos ->
case dragging state of
-- If dragging, update the shape position (in canvas-local coords)
Just idx ->
let (ox, oy) = dragOffset state
canvasPos = windowToCanvas pos
(mx, my) = canvasPos
newPos = (mx - ox, my - oy)
shps = shapes state
updatedShapes = updateAt idx (\s -> s{shapePos = newPos}) shps
in pure state{shapes = updatedShapes, mousePos = pos}
-- Otherwise just track mouse position
Nothing ->
pure state{mousePos = pos}
-- Mouse button down
EventKey (MouseButton LeftButton) Down _ pos -> do
-- Check if clicking the export button
if pointInButton pos
then do
exportCanvas state
pure state{mousePos = pos}
-- Check if clicking in the canvas
else
if pointInCanvas pos
then do
let canvasPos = windowToCanvas pos
case findShapeAt canvasPos (shapes state) of
Just idx ->
let (sx, sy) = shapePos (shapes state !! idx)
(mx, my) = canvasPos
offset = (mx - sx, my - sy)
in pure state{dragging = Just idx, dragOffset = offset, mousePos = pos}
Nothing ->
pure state{mousePos = pos}
else
pure state{mousePos = pos}
-- Mouse button up - stop dragging
EventKey (MouseButton LeftButton) Up _ pos ->
pure state{dragging = Nothing, mousePos = pos}
-- Ignore other events
_ -> pure state
-- | Update an element at a specific index in a list
updateAt :: Int -> (a -> a) -> [a] -> [a]
updateAt idx f xs =
[if i == idx then f x else x | (i, x) <- zip [0 ..] xs]
-- | Export the canvas to SVG
exportCanvas :: AppState -> IO ()
exportCanvas state = do
result <-
saveFileDialog
"Export as SVG"
"canvas.svg"
["*.svg"]
"SVG files"
case result of
Nothing -> pure () -- User cancelled
Just filePath -> do
let canvasPicture = renderCanvasContent state
size = (canvasWidth, canvasHeight)
bgColor = greyN 0.95
exportPictureToSVG size bgColor filePath canvasPicture
putStrLn $ "Exported to: " ++ filePath
-- | Step function (not used, but required by playIO)
stepWorld :: Float -> AppState -> IO AppState
stepWorld _ state = pure state
-- | Main entry point
main :: IO ()
main = do
putStrLn "Canvas Editor - Drag shapes and export as SVG"
putStrLn ""
args <- getArgs
let fontArg = listToMaybe args
font <- resolveFont fontArg
playIO
(InWindow "Canvas Editor" (windowWidth, windowHeight) (100, 100))
(greyN 0.8)
60 -- 60 FPS
(initialState font)
renderScene
handleEvent
stepWorld
-- | Font resolution logic
resolveFont :: Maybe FilePath -> IO FilePath
resolveFont (Just fp) = pure fp
resolveFont Nothing = do
found <- foldM pick Nothing candidateFonts
case found of
Just fp -> pure fp
Nothing -> do
putStrLn
"Warning: Could not find a TrueType font. Text may not render correctly."
putStrLn "You can specify a font path as a command line argument."
pure ""
where
pick acc candidate =
case acc of
Just _ -> pure acc
Nothing -> do
exists <- doesFileExist candidate
pure $ if exists then Just candidate else Nothing
candidateFonts :: [FilePath]
candidateFonts =
[ "/System/Library/Fonts/Supplemental/Arial.ttf"
, "/Library/Fonts/Arial.ttf"
, "/System/Library/Fonts/Supplemental/Helvetica.ttc"
, "/System/Library/Fonts/Supplemental/Tahoma.ttf"
, "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
, "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
, "/usr/share/fonts/truetype/freefont/FreeSans.ttf"
, "C:\\Windows\\Fonts\\arial.ttf"
, "C:\\Windows\\Fonts\\segoeui.ttf"
]