Building a text editor (Part 3)

(Update: The original version of this installment included code which caused a memory leak. Fernando has fixed the code, and I recommend that you look at the latest version of wxhnotepad (cabal fetch wxhnotepad). I have updated the tutorial text below.)

This is the third installment of a series looking at wxhNotepad, a wxHaskell application written by Fernando Benavides, and this is where things start to become more interesting as we are adding some real functionality – in this case undo and redo functionality.

The wxHaskell TextCtrl () does, in fact, contain undo / redo support. It is not really ideal for a text editor, however, since it allows ‘undo’ on a new text file, for example.

GUI top level

The first thing to do is to extend the GUIContext type to maintain undo and redo history, each of which we express as a list of strings. There is also a TimerEx (), and this requires a little more explanation. The details will become clearer as we work through the code, but basically we use the timer as a means to help us to group ‘undoable’ and ‘redoable’ actions into larger units than characters.

As in the previous post, new code is highlighted in red.

> data GUIContext = GUICtx { guiWin    :: Frame (),
> guiEditor :: TextCtrl (),
> guiFile   :: Var (Maybe FilePath),
> guiTimer  :: TimerEx (),       -- ^ A timer to detect user actions
> guiPast   :: Var [String],     -- ^ For Undo history
> guiFuture :: Var [String]      -- ^ For Redo history
> }

Because we are not using the built-in undo/redo functionality in the TextCtrl, we need to define some unique menu identifiers. These must not conflict with identifiers in use elsewhere in the application. The numbers used are ‘magic’ – there is currently no convenient way to ensure that there is no such conflict – something which should probably be fixed in a future release of wxHaskell.

> wxID_MYUNDO = 5107
> wxID_MYREDO = 5108

Most of the changes to the top level GUI function require little comment as we have discussed the details in earlier installments.

> step3 :: IO ()
> step3 =
>   do
>     win <- frame [text := "wxhNotepad - Step 3", visible := False]
>     editor <- textCtrl win [font := fontFixed,
>                             text := "Undo / Redo support is ready by " ++
>                                     "default on text controls..."]
>     filePath <- varCreate Nothing

We create a timer instance in much the same way as any wxHaskell control. The timer interval is set to 1000 seconds (10^6ms).

The timer is used in an interesting way. Each time a keyboard event is received, restartTimer is called before sending the keyboard event on to the TextCtrl (we do this with the call to propagateEvent, which passes any interecpted event on to other controls which might wish to respond to it). In restartTimer a shorter (1 sec) timer interval is used to group input into chunks a little longer than a single character, so that undo and redo work on more meaningful chunks of text.

Once the timer has been created, we call updatePast to begin recording the undo/redo stream.

>     refreshTimer <- timer win []
>     past <- varCreate []
>     future <- varCreate []
>     let guiCtx = GUICtx win editor filePath refreshTimer past future
>     timerOnCommand refreshTimer $ updatePast guiCtx
>     set editor [on keyboard := \_ -> restartTimer guiCtx >> propagateEvent]
>     updatePast guiCtx

The remainder of the changes in the GUI top level relate to the extra menu entries to support undo and redo. These follow the pattern described in the previous post.

>     mnuFile <- menuPane [text := "File"]
>     mnuEdit <- menuPane [text := "Edit"]
>     menuAppend mnuFile wxID_OPEN "&Open...\tCtrl-o" "Open Page" False
>     menuAppend mnuFile wxID_SAVE "&Save\tCtrl-s" "Save Page" False
>     menuAppend mnuFile wxID_SAVEAS "Save &as...\tCtrl-Shift-s" "Save Page as" False
>     menuAppend mnuFile wxID_CLOSE "&Close\tCtrl-W" "Close Page" False
>     menuAppend mnuEdit wxID_MYUNDO "&Undo\tCtrl-z" "Undo last action" False
>     menuAppend mnuEdit wxID_MYREDO "&Redo\tCtrl-Shift-z" "Redo last undone action" False
>     evtHandlerOnMenuCommand win wxID_OPEN $ openPage guiCtx
>     evtHandlerOnMenuCommand win wxID_SAVE $ savePage guiCtx
>     evtHandlerOnMenuCommand win wxID_SAVEAS $ savePageAs guiCtx
>     evtHandlerOnMenuCommand win wxID_CLOSE $ windowClose win False >> return ()
>     evtHandlerOnMenuCommand win wxID_MYUNDO $ undo guiCtx
>     evtHandlerOnMenuCommand win wxID_MYREDO $ redo guiCtx
>     set win [menuBar := [mnuFile, mnuEdit]]
>     set win [layout := fill $ widget editor,
>              clientSize := sz 640 480]
>     focusOn editor
>     set win [visible := True]

File Handling Events

The changes to the file handling are very minimal – just a couple of lines in openPage so that the undo history is cleared when a new file is opened, and then populated with the initial file text (since this represents the first state of the ‘undo’ action list).

> openPage guiCtx@GUICtx{guiWin = win, guiEditor = editor, guiFile = filePath} =
>   do
>   maybePath <- fileOpenDialog win True True "Open file..."
>                               [("Haskells (*.hs)",["*.hs"]),
>                                ("Texts (*.txt)", ["*.txt"]),
>                                ("Any file (*.*)",["*.*"])]
>                               "" ""
>   case maybePath of
>     Nothing   -> return ()
>     Just path -> do
>                  clearPast guiCtx
>                  textCtrlLoadFile editor path
>                  updatePast guiCtx
>                  set win [text := "wxhnotepad - " ++ path]
>                  varSet filePath $ Just path

There is a new event handler for the ‘undo’ menu item.

> undo guiCtx@GUICtx{guiEditor = editor, guiPast = past, guiFuture = future} =
>   do
>     -- Now we try to detect if there's something new and kill the timer
>     updatePast guiCtx
>     history <- varGet past
>     case history of
>       []            -> return () -- Nothing to be undone
>       [t]           -> return () -- Just the initial state, nothing to be undone
>       tnow:tlast:ts -> do
>                          -- We add an action to be done on redo
>                          varUpdate future (tnow:)
>                          -- we remove it from the history
>                          varSet past $ tlast:ts
>                          -- and set the editor accordingly
>                          set editor [text := tlast]

The ‘redo’ menu item is handled similarly…

> redo guiCtx@GUICtx{guiEditor = editor, guiPast = past, guiFuture = future} =
>   do
>     -- First of all, we try to detect if there's something new
>     updatePast guiCtx
>     coming <- varGet future
>     case coming of
>       []   -> return () -- Nothing to be redone
>       t:ts -> do
>         -- remove it from the coming actions
>         varSet future ts
>         -- move it to the history actions
>         varUpdate past (t:)
>         -- and set the editor accordingly
>         set editor [text := t]

The updatePast function adds a new event to the undo history list. When updatePast is called, both the history and current editor text (tnow) are fetched and compared. If the head of the history list is not the same as the editor text, record the change.

> updatePast guiCtx@GUICtx{guiEditor = editor, guiPast = past, guiFuture = future} =
>  do
>    tnow    <- get editor text
>    history <- varGet past
>    case history of
>      []  -> varSet past [tnow]
>      t:_ -> if t /= tnow -- if there was a real change, we recorded it
>             then do
>               varUpdate past (tnow:)
>               varSet future [] -- we clean the future because the user is writing a new one
>             else     -- otherwise we ignore it
>               return ()
>               -- Now that history is up to date, we let the timer rest until new activity
>               -- is detected
>               killTimer guiCtx

The clearPast function clears the undo (past) and redo(future) lists.

> clearPast GUICtx{guiPast = past, guiFuture = future} =
>  do
>    varSet past []
>    varSet future []

The restartTimer function is used as part of a mechanism to try to separate undo/redo information into useful chunks instead of individual characters. This is done by starting a short duration (1 sec) timer which gets reset each time there is input from the user. The result is that we have a ‘timer’ which times out whenever the user pauses typing for more than a second. This is called from the textCtrl keyboard event handler, and allows us to group keyboard input according to natural pauses in typing.

> restartTimer guiCtx@GUICtx{guiWin = win, guiTimer = refreshTimer} =
>  do
>    started <- timerStart refreshTimer 1000 True
>    if started
>        then return ()
>        else do
>           errorDialog win "Error" "Can't start more timers"
>           wxcAppExit

The killTimer function starts a new timer of very long duration (something around 20 minutes). Should the timer expire, no action is taken, as the purpose here is simply to wait for some keyboard activity.

> killTimer GUICtx{guiTimer = refreshTimer} = timerStop refreshTimer

Note: there is a memory leak in this code


3 thoughts on “Building a text editor (Part 3)”

  1. Hi Jeremy… I’ve seen the memory leak with the timers and I saw it in (the project from where wxhnotepad was born) too. I’m currently trying a different approach there (where I use WXCore functions like timerCreate, timerStop, timerStart, etc. but every once in a while it fails with a GHC error and brings the whole app down.
    If I finally master the TimerEx management, I’ll update wxhnotepad and so you can update this post 😉
    Anyway… as usual… excellent article, you understand what I did even better than I do 😛

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )


Connecting to %s