Custom Controls in wxHaskell (part 4)

Deciding what to render

Our DiffCtrl has two viewport Window instances on which we will eventually be rendering the diff text. In this section, we consider what actually needs to be rendered.

The background is that most GUI toolsets support the concept of a viewport which provides a window over all of the data in a window. There is too much information to be able to fit everything into the area available to display, so we see only a part of it. In wxWidgets (and therefore wxHaskell), the key ideas are the virtualSize which represents the size a window would need to be to fit all of the displayable information into it and the clientSize which is the size actually available for displaying information in the application.

One option available to us is to simply render all of the information into the virtual canvas. This is fine, and works OK for controls which have automatic management of scroll bars (incidentally, if you do not set the virtualSize and clientSize parameters for such controls, Layout often fails since any size control size up to the virtualSize is possible and the Layout algorithm has a tendency to decide that something like a 1 x 1 control size is a good choice – almost certainly not what you had in mind).

There is a problem with rendering everything to the virtual canvas, and that is that rendering can take a while. If you have a very large data set, and a small window on it, you are expending processing time just to throw away most of what you did (because wxWidgets only displays the part of the rendered information which can be seen on the client Window). For reasons of responsiveness, many applications therefore don’t bother to render what you cannot see, and only render what is visible.

In this application, we are managing what is displayed in Windows manually, as well as keeping manual control of the scroll bars. Therefore, we really have to take care about what is going to be rendered to the Window instances ourselves.

A digression: unified diff output

Below is a fragment of the output from a unified diff command:

--- /path/to/original ''timestamp''
+++ /path/to/new      ''timestamp''
@@ -1,3 +1,4 @@
+This is an important notice! It should therefore be located at the beginning of this document!
+
 This part of the document has stayed the same from version to
@@ -5,16 +11,10 @@
 be shown if it doesn't change.  Otherwise, that would not be helping to
-compress the size of the changes.Determine the viewable text, i.e. the text which should be displayed in the viewport
  • The — prefix indicates that the line contains the name and timestamp of the ‘original’ file in the diff.
  • The +++ prefix indicates that the line contains the name and timestamp of the ‘modified’ file in the diff.
  • The @@ prefix indicates which lines have changed.
  • The + prefix indicates a line which has been added to the original to create the modified file.
  • The – prefix indicates a line which has been deleted from the original to create the modified file.
  • Lines starting with a space character are unchanged between the original and modified files.

It is worthwhile to point out that we don’t want to render filename, timestamp or line number information in the Window. Therefore, the number of lines of information returned from the diff command is not the same as the number of lines we need to render.

We can express this with a suitable data type:

> data DiffLineType = CommonLine
>                   | AddLine
>                   | DeleteLine
>                   | RangeLine
>                   | OrigFileLine
>                   | ChangeFileLine
>                   | UnknownLine
>                     deriving (Eq, Show)

It is very straightforward to determine the type of information represented in a diff line. It is probably an error if we don’t have one of the prefixes given above. Since I’m not sure how I will deal with such an error for now, UnknownLine has been created.

> getDiffLineType :: String -> DiffLineType
> getDiffLineType ('-':'-':'-':_) = OrigFileLine
> getDiffLineType ('+':'+':'+':_) = ChangeFileLine
> getDiffLineType ('@':'@':_)     = RangeLine
> getDiffLineType ('+':_)         = AddLine
> getDiffLineType ('-':_)         = DeleteLine
> getDiffLineType (' ':_)         = CommonLine
> getDiffLineType _               = UnknownLine

Now we have everything we need to parse the raw output from the diff command into something a little easier to work with. The lines from a diff run are represented in a data type containing the line type and the remaining text.

> data DiffLine = DiffLine DiffLineType String
>                 deriving Show

Parse diff lines into DiffLine data structures.

> parseDiffLines = map parseDiffLine
>     where
>       parseDiffLine s =
>           case getDiffLineType s of
>             OrigFileLine   -> DiffLine OrigFileLine   (drop 3 s)
>             ChangeFileLine -> DiffLine ChangeFileLine (drop 3 s)
>             RangeLine      -> DiffLine RangeLine      (drop 2 s)
>             AddLine        -> DiffLine AddLine        (tail s)
>             DeleteLine     -> DiffLine DeleteLine     (tail s)
>             CommonLine     -> DiffLine CommonLine     (tail s)
>             UnknownLine    -> DiffLine UnknownLine    (tail s)

It is now straightforward to determine whether a line from the diff command should be displayed in the viewport or not. Displayable returns True if a line is displayable in a viewport.

 > displayable AddLine    = True
 > displayable DeleteLine = True
 > displayable CommonLine = True
 > displayable _          = False

We are now in a position to determine how many lines of displayable text the diff command generated for us.

> countDiffLines map = Map.fold countDiffLines' 0 map
>     where
>     countDiffLines' (DiffLine typ _) acc | displayable typ = acc + 1
>                                          | otherwise       = acc

What can I show you today?

This is basically a question of working out the row and column of the displayable lines of text at which we start display, and the row and column at which we finish display.

Working out which columns to display is the more straightforward calculation, so I chose to represent this in a RenderInfo type which contains a line of diff text, the first column to be displayed and the length of text to display. Note that when making these calculations, if the calculations as integers are inexact (they often are), just render slightly more information than your display area. The surplus will be clipped, but it will clip showing (for example) partial characters, which is what you want.

In passing, I should note that since the first column and maximum to render are the same for all lines of text, I could probably have done without this data type.

 > data RenderInfo = RI { ri_txt      :: ![DiffLine]  -- | Text to render
 >                      , ri_firstcol :: !Int         -- | First column to be used in text
 >                      , ri_length   :: !Int         -- | Length of text to render
 >                      } deriving Show

The getDisplayableText function uses calcViewportTextExtent to determine the size of a rectangle (in rows and columns of characters) on which text can be displayed, and the selectLines function to select the correct number of displayable lines.

 > getDisplayableText win =
 >     calcViewportTextExtent win >>= \r@(Rect x _ w _) ->
 >     dvGetState win >>= \(DVS _ _ _ _ _ _ _ _ _ map) ->
 >     let (_, diff_lines) = Map.fold (selectLines r) (0,[]) map
 >         disp_diff  = RI diff_lines x w in
 >     return disp_diff

The selectLines function selects those lines which will be displayed on the viewport, based on the following criteria:

  • displayable lines with line number < y are above the viewport and not displayed
  • displayable lines between line number y and line number y + h are displayed
  • other lines are not displayed

In addition, note that as not all lines are displayable, we need to keep a separate account of the displayable line number as we iterate over the map. This is why we use (Int, [DiffLine]) as our accumulator, rather than just [DiffLine].

 > selectLines :: Rect -> DiffLine -> (Int, [DiffLine]) -> (Int, [DiffLine]
 > selectLines (Rect _ y _ h) (DiffLine typ str) acc@(disp_line_no, strs)
 >                 | (disp_line_no < y)     && (displayable typ) = (disp_line_no + 1, strs)
 >                 | (disp_line_no < y + h) && (displayable typ) = (disp_line_no + 1, (DiffLine typ str) : strs)
 >                 | otherwise                                   = acc

As noted earlier, calcViewportTextExtent determines a rectangle over the complete displayable diff text which will be displayed. This is straightforward: the scroll bars give the starting row/column from their current position. We know the end row/column by calculating the viewport text size. Thus we obtain a starting row/column and an end row/column to display.

I should note that the viewport text size is the number of rows and columns of text which will fit in the viewport. This is dependent on the size of text and font used, which is why it has been calculated separately from working out which rows/columns of text to display.

 > calcViewportTextExtent w =
 >     dvGetState w >>= \(DVS _ _ _ _ _ vs hs _ _ map) ->
 >     scrollBarGetThumbPosition vs >>= \top_left_y ->
 >     scrollBarGetThumbPosition hs >>= \top_left_x ->
 >     calcViewportTextSize w >>= \(Size bot_right_raw_x bot_right_raw_y) ->
 >     let (Size max_x max_y) = calcTextSize map
 >         width  = min bot_right_raw_x (max_x - top_left_x)
 >         height = min bot_right_raw_y (max_y - top_left_y)
 >         textExtent  = Rect top_left_x top_left_y width height in
 >     putStrLn ("calcViewportTextExtent textExtent:" ++ (show textExtent)) >>
 >     return textExtent

Calculate the number of rows and columns required to completely fit the diff text (i.e. the number of lines and the length of the longest line).

 > calcTextSize :: Map.Map Int DiffLine -> Size
 > calcTextSize m = sz (longestString m) (countDiffLines m)
 >     where
 >       longestString m = Map.fold (\(DiffLine _ str) max_now -> max (length str) max_now) 0 m

Calculate how much text we can fit into the viewport. This is straightforward: on each axis, the ratio of virtual canvas extent to viewport canvas extent (i.e. in device context units) is the same as the ratio of the total number of rows/columns to the viewable rows/columns.

Notice that the virtualSize is given a minimum size of (1,1). This is to ensure that no divide by zero error is possible when calculating x_cs_txt and y_cs_txt.

 > calcViewportTextSize w =
 >     dvGetState w >>= \(DVS _ _ _ vp1 _ _ _ _ _ map) ->
 >     get vp1 clientSize  >>= \(Size x_cs y_cs) ->
 >     get vp1 virtualSize >>= \(Size x_vs y_vs) ->
 >     let (Size x_vs_txt y_vs_txt) = calcTextSize map
 >         x_vs'    = max 1 x_vs
 >         y_vs'    = max 1 y_vs
 >         x_cs_txt = min ((x_cs * x_vs_txt) `div` x_vs') x_vs_txt
 >         y_cs_txt = min ((y_cs * y_vs_txt) `div` y_vs') y_vs_txt
 >         cs_txt   = sz x_cs_txt y_cs_txt in
 >     return cs_txt

One thought on “Custom Controls in wxHaskell (part 4)”

Leave a comment