packages feed

Blobs-0.1: src/PersistentDocument.hs

{-| Module      :  PersistentDocument
    Author      :  Arjan van IJzendoorn
    License     :  do whatever you like with this

    Maintainer  :  afie@cs.uu.nl

    The persistent document abstraction takes care of dealing
    with a document you want to open from and save to disk and
    that supports undo. This functionality can be used by editors
    of arbitrary documents and saves you a lot of quite subtle
    coding. You only need to initialise a record with things like
    your document, the file name and call-back functions. After
    this, the framework takes care of the hard work. The framework
    is highly parametrisable but there are defaults for many
    parameters.

    The features in detail:
    - unlimited undo & redo buffers (or limited, if you choose to)
    - undo and redo items show what will be undone / redone
        (e.g. "Undo delete node")
    - undo and redo items are disabled if there is nothing to undo or redo
    - maintains a dirty bit that tells you whether the document has
      changed with respect to the version on disk
    - the save menu item can be disabled if the document is not dirty
    - the title bar can be updated to show the program name, the file name
      and whether the document is dirty (shown as "modified")
    - when trying to close the document, the user is asked whether he/she
      wants to save the changes (if needed)
    - handles interaction between saving a document and the dirty bits
      of the document and of the documents in the history and future
    - properly handles Cancel or failure at any stage, e.g. the user
      closes a dirty document with no file name, "Do you want to save
      the changes" dialog is shown, user selects "Save", a Save as
      dialog is opened, user selects a location that happens to be
      read-only, saving fails and the closing of the document is
      cancelled.
-}

module PersistentDocument
    ( PersistentDocument, PDRecord(..)

    , PersistentDocument.dummy
    , initialise
    , resetDocument

    , setDocument, updateDocument
    , superficialSetDocument, superficialUpdateDocument

    , getDocument
    , getFileName, setFileName
    ,              setDirty

    , undo, redo
    , save, saveAs, isClosingOkay
    ) where

--import IOExts(IORef, newIORef, writeIORef, readIORef)
import Data.IORef(IORef, newIORef, writeIORef, readIORef)
import Monad(when)

-- | A persistent document is a mutable variable. This way functions
--   operating on a document do not have to return the new value but
--   simply update it.
type PersistentDocument a = IORef (PDRecord a)

-- | The persistent document record maintains all information needed
--   for undo, redo and file management
data PDRecord a = PD
    { document      :: a

    -- UNDO & REDO
    , history       :: [(String, Bool, a)]
        -- ^ A history item contains a message (what will be undone),
        --   the dirty bit and a copy of the document
    , future        :: [(String, Bool, a)]
        -- ^ See history
    , limit         :: Maybe Int
        -- ^ Maximum number of items of undo history. Or no limit
        --   in the case of Nothing

    -- FILE MANAGEMENT
    , fileName      :: Maybe String
        -- ^ Nothing means no file name yet (untitled)
    , dirty         :: Bool
        -- ^ Has the document changed since saving?

    -- CALL-BACK FUNCTIONS
    , updateUndo    :: Bool -> String -> IO ()
        -- ^ This callback is called when the undo status changes. First parameter
        --   means enable (True) or disable (False). Second parameter is the message
        --   of the first item in the history
    , updateRedo    :: Bool -> String -> IO ()
        -- ^ See updateUndo
    , updateSave    :: Bool -> IO ()
        -- ^ This call-back is called when the save status changes. The boolean
        --   indicates whether save is enabled (dirty document) or disabled (not dirty)
    , updateTitleBar :: Maybe String -> Bool -> IO ()
        -- ^ This call-back is called when the title bar information changes:
        --   file name and modified or not.
    , saveToDisk    :: String -> a -> IO Bool
        -- ^ This callback should actually save the document to disk. It should
        --   return False if saving fails (no permission, disk full...)
    , saveChangesDialog :: IO (Maybe Bool)
        -- ^ This call-back is called when the user should be prompted whether
        --   he/she wants to save the changes or not. Results:
        --   Don't Save -> Just False, Save -> Just True, Cancel -> Nothing
    , saveAsDialog :: Maybe String -> IO (Maybe String)
        -- ^ This call-back is called when the user should specify a
        --   location and a name for the file. The parameter is the current
        --   file name of the document
    }

-- | A dummy persistent document is needed because you need something to pass
--   to the command handlers of menu items BEFORE you can initialse the
--   persistent document with those menu items
dummy :: IO (PersistentDocument a)
dummy = newIORef (error $ "PersistentDocument.empty: call initialise before using "
                        ++ "the persistent document")

-- | Initialise the persistent document with menu items (undo, redo, save),
--   information needed for open & save dialogs, for saving and for updating the
--   title bar
initialise :: PersistentDocument a ->  PDRecord a -> IO ()
initialise pDocRef pDoc =
  do{ writeIORef pDocRef pDoc
    ; updateGUI pDocRef
    }

-- | Clear the document and start with a given document with given file name
--   This function is typically called when you open a new document from disk
--   or start a fresh document that should replace the current document
resetDocument :: Maybe String -> a -> PersistentDocument a -> IO ()
resetDocument theFileName doc pDocRef =
  do{ updateIORef pDocRef (\pDoc -> pDoc
            { document  = doc
            , history   = []
            , future    = []
            , fileName  = theFileName
            , dirty     = False
            })
    ; updateGUI pDocRef
    }

-- | Get the actual document stored within the persistent document
getDocument :: PersistentDocument a -> IO a
getDocument pDocRef =
  do{ pDoc <- readIORef pDocRef
    ; return (document pDoc)
    }

-- | Get the file name stored within the persistent document
getFileName :: PersistentDocument a -> IO (Maybe String)
getFileName pDocRef =
  do{ pDoc <- readIORef pDocRef
    ; return (fileName pDoc)
    }

-- | Get the file name stored within the persistent document
setFileName :: PersistentDocument a -> Maybe String -> IO ()
setFileName pDocRef maybeName =
  do{ pDoc <- readIORef pDocRef
    ; writeIORef pDocRef (pDoc { fileName = maybeName })
    ; updateGUI pDocRef
    }

setDirty :: PersistentDocument a -> Bool -> IO ()
setDirty pDocRef newDirtyBit =
  do{ pDoc <- readIORef pDocRef
    ; writeIORef pDocRef (pDoc { dirty = newDirtyBit })
    ; updateGUI pDocRef
    }

-- | Replace the document inside the persistent document. The current
--   document is remembered in the history list along with the given
--   message. The future list is cleared.
setDocument :: String -> a -> PersistentDocument a -> IO ()
setDocument message newDoc pDocRef =
  do{ pDoc <- readIORef pDocRef
    ; let applyLimit = case limit pDoc of
                        Nothing -> id
                        Just nr -> take nr
          newPDoc =
            pDoc
            { document  = newDoc
            , history   = applyLimit $ (message,dirty pDoc,document pDoc):history pDoc
            , future    = []
            , dirty     = True
            }
    ; writeIORef pDocRef newPDoc
    ; updateGUI pDocRef
    }


-- | Get document, apply function, set document
updateDocument :: String -> (a -> a) -> PersistentDocument a -> IO ()
updateDocument message fun pDocRef =
  do{ doc <- getDocument pDocRef
    ; setDocument message (fun doc) pDocRef
    }

-- | Replace the document without remembering the old document in
--   the history. Superficial updates are useful if something as
--   volatile as a selection is part of your document. If the selection
--   changes you don't want to be able to undo it or to mark
--   the document as dirty
superficialSetDocument :: a -> PersistentDocument a -> IO ()
superficialSetDocument newDoc pDocRef =
    updateIORef pDocRef (\pDoc -> pDoc { document  = newDoc })

-- | Get document, apply function, superficial set document
superficialUpdateDocument :: (a -> a) -> PersistentDocument a -> IO ()
superficialUpdateDocument fun pDocRef =
  do{ doc <- getDocument pDocRef
    ; superficialSetDocument (fun doc) pDocRef
    }

-- | Check whether closing the document is okay. If the document
--   is dirty, the user is asked whether he/she wants to save the
--   changes. Returns False if this process is cancelled or fails
--   at any point.
isClosingOkay :: PersistentDocument a -> IO Bool
isClosingOkay pDocRef =
  do{ pDoc <- readIORef pDocRef
    ; if not (dirty pDoc) then return True else
  do{ result <- saveChangesDialog pDoc
    ; case result of
        Nothing -> return False
        Just True ->
          do{ hasBeenSaved <- save pDocRef
            ; return hasBeenSaved
            }
        Just False -> return True
    }}

-- | Save should be called when "Save" is selected from the file menu.
--   If there is no file name yet, this function acts as if "Save as"
--   was called. It returns False if saving is cancelled or fails.
save :: PersistentDocument a -> IO Bool
save pDocRef =
  do{ pDoc <- readIORef pDocRef
    ; case fileName pDoc of
        Nothing -> saveAs pDocRef
        Just name -> performSave name pDocRef
    }

-- | saveAs should be called when "Save As" is selected from the file menu.
--   A dialog is shown where the user can select a location to save document.
--   This function returns False if saving is cancelled or fails.
saveAs :: PersistentDocument a -> IO Bool
saveAs pDocRef =
  do{ pDoc <- readIORef pDocRef
    ; mbfname <- saveAsDialog pDoc (fileName pDoc)
    ; case mbfname of
        Just fname -> performSave fname pDocRef
        Nothing -> return False
    }


-- | The current document is stored in the future list
--   and the first element of the history list is taken
--   as the new document
undo :: PersistentDocument a -> IO ()
undo pDocRef =
  do{ pDoc <- readIORef pDocRef
    ; when (not (null (history pDoc))) $
  do{ let (msg, newDirty, newDoc) = head (history pDoc)
          newPDoc = pDoc
            { document  = newDoc
            , dirty     = newDirty
            , history   = tail (history pDoc)
            , future    = (msg, dirty pDoc, document pDoc) : future pDoc
            }
    ; writeIORef pDocRef newPDoc
    ; updateGUI pDocRef
    }}

-- | The current document is stored in the history list
--   and the first element of the future list is taken
--   as the new document
redo :: PersistentDocument a -> IO ()
redo pDocRef =
  do{ pDoc <- readIORef pDocRef
    ; when (not (null (future pDoc))) $
  do{ let (msg, newDirty, newDoc) = head (future pDoc)
          newPDoc = pDoc
            { document  = newDoc
            , dirty     = newDirty
            , future    = tail (future pDoc)
            , history   = (msg, dirty pDoc, document pDoc) : history pDoc
            }
    ; writeIORef pDocRef newPDoc
    ; updateGUI pDocRef
    }}

-- FUNCTIONS THAT ARE NOT EXPORTED

updateIORef :: IORef a -> (a -> a) -> IO ()
updateIORef var fun = do { x <- readIORef var; writeIORef var (fun x) }

-- Perform the actual save to disk. If this fails False is returned
-- otherwise the file name is set and the dirty bit is cleared. The
-- dirty bits of history and future documents are set.
performSave :: String -> PersistentDocument a -> IO Bool
performSave name pDocRef =
  do{ pDoc <- readIORef pDocRef
    ; hasBeenSaved <- (saveToDisk pDoc) name (document pDoc)
    ; if not hasBeenSaved then return False else
  do{ writeIORef pDocRef (pDoc { fileName = Just name })
    ; updateDirtyBitsOnSave pDocRef
    ; updateGUI pDocRef
    ; return True
    }}

-- updateDirtyBitsOnSave clears the dirty bit for the
-- current document and sets the dirty bits of all
-- documents in history and future lists
updateDirtyBitsOnSave :: PersistentDocument a -> IO ()
updateDirtyBitsOnSave pDocRef =
    updateIORef pDocRef (\pDoc -> pDoc
        { history = map makeDirty (history pDoc)
        , future  = map makeDirty (future  pDoc)
        , dirty   = False
        })
 where
    makeDirty (msg, _, doc) = (msg, True, doc)

-- Shorthand to call all call-backs that update the GUI
updateGUI :: PersistentDocument a -> IO ()
updateGUI pDocRef =
  do{ pDoc <- readIORef pDocRef
    ; case history pDoc of
        []              -> updateUndo pDoc False ""
        ((msg, _, _):_) -> updateUndo pDoc True msg
    ; case future pDoc of
        []              -> updateRedo pDoc False ""
        ((msg, _, _):_) -> updateRedo pDoc True msg
    ; updateSave pDoc (dirty pDoc)
    ; updateTitleBar pDoc (fileName pDoc) (dirty pDoc)
    }