Building The Asynchronous Disk I/O Explorer |
|
This essay discusses the various GUI techniques I used to create the "Asynch Explorer" project. It seemed to me that in the building of this project, I used a lot of techniques which might not be obvious to beginners, or those not steeped in the details of Windows Graphics.
This program uses a number of useful techniques.
This is an "old trick" for me; my very first Windows app used this technique. However, if you've not seen it before, it contains a number of useful tricks. Note that the dialog uses a resizing border, so it can be resized. This creates two issues: how to resize/relocate the controls within the dialog, and how to prevent the dialog from being resized below some minimum, such as limiting the size to a size that does not hide any controls.
To do the size limitation, add an OnGetMinMaxInfo handler to your dialog (unfortunately, in VS6, for reasons that never made any sense, this message is not available from ClassWizard unless you go to the Class tab and change the type to Generic Window)
void CAsynchDlg::OnGetMinMaxInfo(MINMAXINFO * MMI) { CRect r; if(c_Limit.GetSafeHwnd() != NULL) { /* limit it */ c_Limit.GetWindowRect(&r); ScreenToClient(&r); CalcWindowRect(&r); MMI->ptMinTrackSize = CPoint(r.Width(), r.Height()); } /* limit it */ CDialog::OnGetMinMaxInfo(MMI); }
There are two key ideas here. First, I put down a simple picture control of type "frame", changes its ID from IDC_STATIC to something like IDC_FRAME, and create a control member variable (c_Limit) to represent it. I also mark the control as non-visible by unchecking its "Visible" property (VS6) or setting the "Visible" property to FALSE (VS.NET). This gives me a limit, in client coordinates, for my minimum window size. So first I do a GetWindowRect to get the window size, then convert it to client coordinates by using ScreenToClient. However, if you stop here, you will find that your limits are a bit too small. In fact, horizontally they are too small by the width of the borders of the dialog, and vertically they are too small by the height of the caption bar and the height of the menu (if present). The CalcWindowRect function, given a rectangle, will compute the size the window needs to be set to in order to contain that rectangle as its client area. This is the value that I use for the MINMAXINFO fields for the ptMinTrackSize value.
To resize the controls, add an OnSize handler. In that handler, resize the controls as appropriate. However, there are some idioms which can be used quite simply.
The first thing to understand is that the OnSize handler is called fairly often, and is called early (it is one of the first five messages received during window creation), so the child controls may not exist, or in the case of MFC, they have not yet been bound to their member variables. So it is not safe to assume that a child window is available. I resize three of the windows in my dialog: the plot window, the Y-axis label window, and the log window.
void CAsynchDlg::OnSize(UINT nType, int cx, int cy) { CDialog::OnSize(nType, cx, cy); //**************************************************************** // Resize Plot window //**************************************************************** if(c_Plot.GetSafeHwnd() != NULL) { /* do resize */ CRect r; c_Plot.GetWindowRect(&r); ScreenToClient(&r); c_Plot.SetWindowPos(NULL, 0,0, // no reposition cx - r.left, cy - r.top, // new size SWP_NOZORDER | SWP_NOMOVE); c_Plot.Invalidate(); } /* do resize */ //**************************************************************** // Resize Y-axis window //**************************************************************** if(c_Y_Label.GetSafeHwnd() != NULL) { /* resize Y-label */ CRect r; c_Y_Label.GetWindowRect(&r); ScreenToClient(&r); c_Y_Label.SetWindowPos(NULL, 0, 0, // no reposition r.Width(), cy - r.top, SWP_NOZORDER | SWP_NOMOVE); c_Y_Label.Invalidate(); } /* resize Y-label */ //**************************************************************** // Resize log window //**************************************************************** if(c_Log.GetSafeHwnd() != NULL) { /* resize log */ CRect r; c_Log.GetWindowRect(&r); ScreenToClient(&r); c_Log.SetWindowPos(NULL, 0,0, // no reposition cx, r.Height(), // new width, same height SWP_NOZORDER | SWP_NOMOVE); } /* resize log */ }
Note that each resize block is self-contained; it is true that I could have factored the GetWindowRect/ScreenToClient out, but then the code would not be completely self-contained, which is my preference. Note that the size of the plot window is set by computing the size from the top left corner of the window to the right edge and bottom edge. The size of the Y-label is stretched vertically only, and the size of the log box is stretched horizontally only. Note that in the case of the plot window and the Y-axis window I found it necessary to force an Invalidate to force a correct redraw of the contents.
Dynamic resizing of controls has some second-order effects that you need to be aware of. For example, in the Y-axis control I do a TextOut to write the text. Most standard controls are created with the class style CS_PARENTDC. This seems very odd to me, but it is a fact we have to live with. The problem with a DC of this type is that the clipping region is set to the clipping region of the parent window, not the clipping region of the child window. What this means is that a TextOut, for example, will overwrite the area. However, the pixels in the parent that are overwritten will not be cleared. This will produce very ugly effects. The figure to the left shows what happens if I remove the feature that limits the drawing area, the result after a resize that makes the text box smaller than the text, and then is dragged out. The text overflows the area ("draws outside the lines") and after a few resize operations the solid ugly blot (which is drawn on the dialog, not as part of the Y-axis label). This is not a desirable result.
This is solved by creating a clipping region that limits the output to the actual client area of the control. To do this, execute the code shown below. In this code, which appears in the OnPaint handler, I get the client rectangle using GetClientRect. I then create a CRgn that is a rectangular region that is exactly the client area.
Using the CSaveDC class (see my essay on CSaveDC, I set a number of parameters, one of which is to establish the clipping region, and finally do a TextOut. Note the order in which things are done. It is important that the DC not be saved until after the CRgn is created. This is because the destructor for CSaveDC is executed when its scope is exited, and I want the DC restored before the CRgn destructor is executed. Otherwise, if the CRgn is selected into the DC, then the destructor of the CRgn will fail to actually delete the region, although it will give the illusion of success. This will cause a GDI resource leak which will eventually result in the program failing. Generally, I do this by creating a new scope, rather than using the implicit destructors-execute-in-inverse-order-of-constructors
CPaintDC dc(this); // device context for painting CRect r; GetClientRect(&r); CRgn rgn; rgn.CreateRectRgn(r.left, r.top, r.right, r.bottom);
{ /* display data */ CSaveDC save(dc); ... other setup here dc.SelectClipRgn(&rgn); dc.TextOut(r.right, y, s); } /* display data */
Any time you are using a built-in control, particularly those derived from CStatic, you have to worry about this sort of "overspill", and I find it simplifies my code tremendously to let the GDI handle the clipping. Note the effect now if the window is resized to a size smaller than the text.
I could have chosen to dynamically resize the font so the text fits, but in this case, I decided to leave the font size alone.
I did not have the luxury of leaving the text size alone when rescaling the plot size. In the one form, the phrases "Early" and "Late" appear; in another, I show the times indicated by the axis. In both these cases, I do not wish to have the letters appear in different sizes when I scale the image.
Shown on the left are two snapshots of the same image; in the one case, the window had been resized to minimum size, and in the second, it had been resized to the full height of the window. Note the word "Late" appears in the same size in both cases. If I had created a font of a given height, when rendered in a given anisotropic mapping, I would get letters of various sizes based on the size of the window. The small window would have a nearly unreadable tiny red blot, and the large window would have something perhaps 3/8" high. Neither of these were desirable properties.
To create a font of the desired height, you have to take into account the desired mapping, the screen resolution, and a number of other factors. Fortunately, using MFC, this is simplified by using the CreatePointFont method. This takes three parameters, the desired point size (expressed in tenths of a point), the name of the font, and a pointer to a DC which represents the desired mapping mode for which the font is being created. So, having set the mapping modes, I simply called GetFont to get the current font set for the window, GetLogFont to get the name of the font, and CreatePointFont to create the font. Note that for debugging ease I use ASSERT and VERIFY. During the debugging process, I wanted to examine the actual size of the font created, so I added the GetLogFont under the control of the #ifdef. This was convenient for debugging.
However, it is worth observing that as you resize the window, the size of the text will "jiggle" by one pixel in height at various sizes of the display. This is because of the roundoff error as the mapping is done from logical coordinate space to device space .
CFont * f = GetFont(); ASSERT(f != NULL); if(f == NULL) return; LOGFONT lf; f->GetLogFont(&lf); VERIFY(pf.CreatePointFont(120, lf.lfFaceName, &dc)); #ifdef _DEBUG // see the properties of the actual font LOGFONT plf; pf.GetLogFont(&plf); #endif dc.SelectObject(&pf);
I decided that I did not want to draw each point in the scatter plot one at a time. Therefore I used PolyPolygon to do the drawing. This requires two arrays: an array of points to be drawn, representing the endpoints of a sequence of polygons, and an array which says how many points constitute each polygon.
It is the nature of PolyPolygon to require n+1 points for an n-sided polygon, because the documentation states that the polygons drawn by PolyPolygon are not automatically closed. So the last point must be made coincident with the first point to get a closed polygon.
Here's the comment from my code:
//================================================================ // k,k+4 k+1 // +-------+ // | | // | * | <= i, data.Sequence[i] // | | // +-------+ // k+3 k+2 //================================================================
For convenience, I defined a constant NPOINTS to be 5. My general loop was on loop index i, but I set k = i * NPOINTS and used that as an index into my point array.
#define NPOINTS 5
I made several attempts to get the behavior I wanted, and ended up with what I thought of in the first place. When I created the array of points, I simply set all points to be the computed (x,y) coordinates of the point in question, without making any attempt to create a polygon. I then applied the LPtoDP transformation to convert these points to device coordinates. When I had finished filling in the array of points, I restored the mapping mode to MM_TEXT. I then went through and computed the desired width of the polygon by computing an offset in device space (where I had defined PT_WIDTH to be 2).
points[k].x -= PT_WIDTH; points[k].y -= PT_WIDTH; points[k+1].x += PT_WIDTH + 1; // note +1 points[k+1].y = points[k].y; points[k+2].x = points[k+1].x; points[k+2].y += PT_WIDTH + 1; // note +1 points[k+3].x = points[k].x; points[k+3].y = points[k+2].y; points[k+4] = points[k];
Now, this appears to be creating a skewed rectangle, that is something like illustrated below, where the center of the point is skewed to the top and left because the bottom and right are extended. However, it is critical here to understand that Windows graphics, when drawing a line from (x0,y0) to (x1,y1) does not actually draw a line all the way to (x1,y1) but actually to (x1-1,y1-1). The endpoint itself is not actually drawn. (You may ask: why do anything this dumb? The answer is that in the graphics world there are two approaches, one of which says "inclusive of target" and one of which says "exclusive of target". Each have their own problems. When designing graphics, it is only necessary to flip a coin to decide which approach to take. On the whole, the "exclusive" approach has fewer problems than the "inclusive" approach. I've programmed graphics with both, and in the "inclusive" approach there are about as many instances of adding "-1" to computations as there are instances of "+1" in the "exclusive approach). So, by extending the coordinates by one unit, I get a perfectly square block.
//================================================================ // k,k+4 k+1 // +---------+ // | | // | * | <= i, data.Sequence[i] // | | // | | // +---------+ // k+3 k+2 //================================================================
So, where I issued the PolyPolygon I was issuing it in MM_TEXT mapping, that is, in device space.
Now you might ask, why did I do this in device coordinates instead of logical coordinates? Simple: roundoff. When converting from logical coordinates to device coordinates, there are three approaches when the mapping is not exact. A few of them are shown below, for simple 2-color (monochrome) rendering. The number of pixels in logical space result in a single pixel on the screen being drawn, but note the variety of options. And this is taking into account only the mapping to a single pixel (in photographic processing where you are reducing the image size, considerations such as anti-aliasing and pixel averaging consider many pixels at once in both source and destination)
| | | | | | | |#| | |#| | | |#|#| |#| | | |#| |#| |#|#| | |#|#|#|#| | | |#| | |#|#| |#| |#| |#|#|#|#| | |#|#| |#|#|#|#| |#|#|#|#| | |#######|#######|#######|#######|#######|#######|#######|#######|#######|#######|#######|#######|#######|#######|#######| Absolute | |#######|#######|#######| |#######|#######|#######| |#######|#######|#######| |#######|#######|#######| Right-weighted | | | |#######| |#######|#######|#######| |#######|#######|#######|#######|#######|#######|#######| Majority, right-weighted, >=50% | | | | | | | |#######| | | |#######| |#######|#######|#######| Majority, right-weighted, >50% | | | | |#######|#######|#######|#######|#######|#######|#######|#######|#######|#######|#######|#######| Left-weighted | | | | | |#######|#######|#######| |#######|#######|#######|#######|#######|#######|#######| Majority, left-weighted, >=50% | | | | | | | |#######| | | |#######| |#######|#######|#######| Majority, left-weighted, >50%
It doesn't matter much which one Windows uses (I believe it uses the "absolute", which is the hardest one to deal with in terms of desired outcome but the fastest and easiest to implement in terms of the rendering); there will always be an inaccuracy if I compute the block size in logical space and then draw it. The act of drawing it forces in into device space, and there will be random one-pixel-wide errors in width and height. The result is that the "blocks" come out looking either square, or too narrow, or too wide, depending on exactly where they appear. If I rescale the drawing by stretching the display, the little boxes would shift around and some which were square would become misshapen and others that were wrong become square. The lower the resolution of the display, the more obvious these errors are. I run 1900×1200, which is very high resolution, and the errors are quite obvious. So the only solution is to work in the device coordinates; then there is no ambiguity about the size. Note, however, that there is a chance that a center of a box will be one unit off in terms of horizontal or vertical. Human perception makes it a bit harder to see this kind of error, whereas the box shapes stand out quite badly if done wrong. So there is always a compromise; this is the more aesthetic compromise.
The array of points was NPOINTS * n where n is the number of data points to plot. The accompanying array, which I call polycounts, indicates the number of points for each polygon, is simple, because all the polygons are the same size. It is an array of size n where each value of the array is NPOINTS. So the final call was
dc.PolyPolygon(points.GetData(), polycounts.GetData(), n);
This draws all the polygons in a single operation.
In my first attempt, I got some disturbing-looking output (shown here as captured by my "Better Zoomin" utililty). Note the hollow center where two polygons overlap.
This has to do with the "polygon fill" mode, which determines how complex overlapping regions are filled in. There are two modes, ALTERNATE and WINDING. In ALTERNATE mode, an area is filled in if the number of vertices from any point inside it to the outside of the polygon is odd, and not filled if the number of vertices to the outside of the polygon is even. This can be expressed as filling in the area between odd-numbered and even-numbered polygon sides. In WINDING mode, any area that has a nonzero winding value is filled, where the winding value is the number of times a pen used to draw the polygon would go around the region. The default mode is ALTERNATE, so I added a SetPolyFillMode(WINDING) and the hollow areas disappeared.
I decided that I wanted to see the display of the scatter graph in more detail than could actually be shown in the limited space available. There are many ways to do this, and I chose to use a tooltip-like facility. Tooltips are cool, but they only show a single line, and I wanted to show multiple lines. So I chose to implement my own control. To do this, I derived a control class called CInfoDisplay directly from CWnd. I had to implement OnPaint and OnEraseBkgnd handlers.
The OnPaint handler has to compute the size of the box required to hold the text. It would appear superficially that DrawText would be sufficient for this, using the DT_CALCRECT option, but, alas, it doesn't work that way. It will take the width of the rectangle as definitive and simply compute a new height. So it isn't that simple; I had to compute the size "by hand" by extracting each line, one at a time.
To do this, I have to use GetTextExtent, so the current font must be the font used for output. This should be sufficient, but I want to include the "internal leading" as part of the height, and GetTextExtent does not provide that. So the height-computation algorithm is basically just adding the TEXTMETRIC heights. Note the SWP_NOACTIVATE option for SetWindowPos. This avoids annoying flicker in the caption bar, and it prevents the mouse from leaving the area.
CPaintDC dc(this); // device context for painting CFont * f = GetFont(); dc.SelectObject(f); // First, we compute the maximum line width, and set the rectangle wide enough to // hold this. Then we use DrawText/DT_CALCRECT to compute the height CString text; GetWindowText(text); CSize box = CSize(0,0); TEXTMETRIC tm; dc.GetTextMetrics(&tm); int inf; // inflation factor { /* compute box size */ CString s = text; while(TRUE) { /* scan string */ CString line; int p = s.Find(_T("\n")); if(p < 0) line = s; else { /* one line */ line = s.Left(p); s = s.Mid(p + 1); } /* one line */ CSize sz = dc.GetTextExtent(line); box.cx = max(box.cx, sz.cx); box.cy += tm.tmHeight + tm.tmInternalLeading; if(p < 0) break; } /* scan string */ SetWindowPos(NULL, 0, 0, r.Width(), r.Height(), SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); } /* compute box size */
The output is done simply by tearing the lines apart again and displaying them, one at a time
CRect r; GetClientRect(&r); r.InflateRect(-inf, -inf); dc.SetBkMode(TRANSPARENT); CString s = text; int y = r.top; while(TRUE) { /* scan string */ CString line; int p = s.Find(_T("\n")); if(p < 0) line = s; else { /* one line */ line = s.Left(p); s = s.Mid(p + 1); } /* one line */ dc.TextOut(r.left, y, line); y += tm.tmHeight + tm.tmInternalLeading; if(p < 0) break; } /* scan string */
The creation of the window also involves some special flags not normally encountered. What I did was add a Create method to the class: One of the important flags to add to this is the WS_EX_NOACTIVATE flag that prevents the window from activating. To register the class, I used AfxRegisterClass. This will deal nicely with the fact that if the class is already registered, there is no error; it works happily. ::RegisterClass will return an error if the class is already registered (AfxRegisterClass uses this to recognize the class has already been registered). Note that I also do not set the window as WS_VISIBLE, and my comment indicates that this flag should not be added.
BOOL CInfoDisplay::Create(int x, int y, LPCTSTR text, CWnd * parent) { ASSERT(parent != NULL); if(parent == NULL) return FALSE; LPCTSTR infoclass = AfxRegisterWndClass(0, // mo class styles NULL, // default arrow cursor (HBRUSH)(COLOR_INFOBK + 1), NULL); ASSERT(infoclass != NULL); if(infoclass == NULL) return FALSE; CRect r(0,0,10,10); // meaningless values, will be recomputed after creation BOOL result = CreateEx(WS_EX_NOACTIVATE, // extended styles infoclass, text, /* not WS_VISIBLE */ WS_POPUP | WS_BORDER, r, parent, NULL); ASSERT(result); if(!result) return FALSE; CFont * f = parent->GetFont(); SetFont(f); return TRUE; } // CInfoDisplay::Create
Now notice something: I do a SetFont call. But a generic CWnd does not support SetFont, so when I would do a GetFont, I would get a NULL reference. So I added WM_SETFONT and WM_GETFONT handlers: I added a member variable HFONT font; declaration to the class and added entries to the MESSAGE_MAP as shown, and the handlers, which are very straightforward.
ON_MESSAGE(WM_SETFONT, OnSetFont) ON_MESSAGE(WM_GETFONT, OnGetFont) LRESULT CInfoDisplay::OnSetFont(WPARAM wParam, LPARAM lParam) { font = (HFONT)wParam; if(LOWORD(lParam)) { /* force redraw */ Invalidate(); UpdateWindow(); } /* force redraw */ return 0; } // CInfoDisplay::OnSetFont LRESULT CInfoDisplay::OnGetFont(WPARAM, LPARAM) { return (LRESULT)font; } // CInfoDisplay::OnGetFont
So how to get the little box to pop up? This involves handling mouse tracking. I pop the box up when the mouse enters the window, and destroy it when the mouse leaves the window. To do this, I made sure that mouse messages will go to the plotting control by setting the SS_NOTIFY style for the static control. I then added a WM_MOUSEMOVE handler. If the mouse moves into the window, I create the window if it does not already exist. If it does exist, I move it. However, I also set a TrackMouseEvent so when the mouse leaves the window, I'll get a WM_MOUSELEAVE notification. When the WM_MOUSELEAVE occurs, I call DestroyWindow to destroy the window.
CPoint target = point; ClientToScreen(&target); target.y += ::GetSystemMetrics(SM_CYCURSOR); // height of cursor if(info != NULL) { /* move window */ CString old; info->GetWindowText(old); if(old != stats) info->SetWindowText(stats); info->SetWindowPos(NULL, target.x, target.y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); info->ShowWindow(visible ? SW_SHOWNA : SW_HIDE); } /* move window */ else { /* create window */ info = new CInfoDisplay; if(!info->Create(target.x, target.y, stats, this)) { /* failed */ delete info; return; } /* failed */ info->SetWindowPos(NULL, target.x, target.y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); info->ShowWindow(visible ? SW_SHOWNA : SW_HIDE); TRACKMOUSEEVENT tme = {sizeof(TRACKMOUSEEVENT), TME_LEAVE, m_hWnd, 0}; VERIFY(TrackMouseEvent(&tme)); } /* create window */
This is part of a subroutine that is called from the OnMouseMove handler. It is passed the mouse point (in client coordinates), a string called stats, and a Boolean value visible that says whether or not the window should be shown (more on this later). The member variable info is either NULL (no popup exists) or non-NULL, in which case it is a pointer to the little popup window.
The simpler case is what to do if the mouse moves with the pointer already in existence. In this case, I first compare the existing text to the desired text and only call SetWindowText if the text has changed. This eliminates gratuitous flickering. Next, the window is repositioned so it is below the pointer. Because the positioning of a popup is in screen coordinates, the ClientToScreen has been called to convert the mouse coordinates to screen coordinates. All cursors are the same size, but the size is platform-specific, so I position it lower on the screen by a cursor height (::GetSystemMetrics(SM_CYCURSOR)). Again, notice the SWP_NOACTIVATE on the SetWindowPos. Also, the visible flag is used to either show or hide the window (again, more on this later).
If the window does not already exist, I attempt to create the popup window. If it succeeds, I then set up a TrackMouseEvent on the plotting window. This will generate, with the TME_LEAVE option, a WM_MOUSELEAVE event if the mouse ever leaves the plotting window. There is no way to use ClassWizard to add a WM_MOUSELEAVE message to the handlers (why? Who knows?) so it has to be added manually:
ON_MESSAGE(WM_MOUSELEAVE, OnMouseLeave)
The handler is defined as
LRESULT CPlot::OnMouseLeave(WPARAM, LPARAM) { if(info != NULL) { /* get rid of it */ info->DestroyWindow(); } /* get rid of it */ info = NULL; return 0; } // CPlot::OnMouseLeave
You may notice an absence of a delete operation in this sequence. This is because I added a PostNcDestroy handler to the class CInfoDisplay
void CInfoDisplay::PostNcDestroy() { CWnd::PostNcDestroy(); delete this; }
For the scatter plot I wanted to pop up some information only when the mouse is directly over a point. The information displayed will be the information about that point; as shown here it is the seek offset, the sequence at which it is issued (shown here, this was the 144th ReadFile issued, and the time it was received; this was the 32nd event received. So how is this done?
First, I have to detect when I'm over a data point. Fortunately, all the information I need to do this has already been computed. I create an array of n CRect structures, based on the computations I had done to create the device-coordinate based PolyPolygon structure. So on each OnMouseMove event, I simply pass in the CPoint structure to a hit-test function which simply iterates over this array, and returns the index of the hit, if any, or -1 if there is no hit. I use the PtInRect method of CRect to do this. If I get a hit, the visible flag passed in to the display routine is set to TRUE, otherwise it is set to FALSE. Because of the ShowWindow calls illustrated, the window is merely shown or hidden, rather than being created and destroyed, which simplifies the programming somewhat.
The code to do this is based on an array of CRects which is HitTestArray. This is computed from the vertices used to create the polygons.
int CPlot::HitTest(CPoint pt) { for(int i = 0; i < HitTestArray.GetSize(); i++) { /* scan values */ if(HitTestArray[i].PtInRect(pt)) return i; } /* scan values */ return -1; } // CPlot::HitTest
You'd think that vertical text would be easy. Unfortunately, it isn't as easy as it looks. Now, in general, it is easy, but I wanted to use a CStatic control to do it. This may have been a bad decision, but it was an easy decision, and the consequences were not appreciated at the time I made the decision. Adding controls to a dialog can be a bit inconvenient if they are not system-defined controls, so I had some motivation to deal with this.
The first thing I did was to use a simple CStatic control. This almost worked.
The trick was to create an OnPaint handler that created the necessary rotated font.
void CVertical::OnPaint() { CPaintDC dc(this); // device context for painting CRect r; GetClientRect(&r); CRgn rgn; rgn.CreateRectRgn(r.left, r.top, r.right, r.bottom); CFont * f = GetFont(); if(f == NULL) { /* font missing */ f = GetParent()->GetFont(); if(f == NULL) return; // nothing to draw, no font has been set else SetFont(f); } /* font missing */ LOGFONT lf; f->GetLogFont(&lf); lf.lfEscapement = 900; lf.lfOrientation = 900; CFont yfont; VERIFY(yfont.CreateFontIndirect(&lf)); CString s; GetWindowText(s); { /* display data */ CSaveDC save(dc); dc.SetTextAlign(TA_BOTTOM); dc.SetBkMode(TRANSPARENT); dc.SelectClipRgn(&rgn); dc.SelectObject(&yfont); int y = r.bottom; y -= 4 * ::GetSystemMetrics(SM_CXBORDER); dc.TextOut(r.right, y, s); } /* display data */ // Do not call CStatic::OnPaint() for painting messages }
Most of this is straightforward. The reason for the CRgn and SelectClipRgn has already been discussed. The relevant actions here to draw vertical text are the creation of a rotated font, selecting the right DC modes, and using the right coordinates for the TextOut. To get a nice appearance, I used some spacing from the "left" of the text, an amount which is 4 * ::GetSystemMetrics(SM_CXBORDER) gives the spacing I want. Note that in MM_TEXT, the subtract operation moves the text upwards in the box.
The OnEraseBkgnd function is very straightforward.
BOOL CVertical::OnEraseBkgnd(CDC* pDC) { CRect r; GetClientRect(&r); pDC->FillSolidRect(&r, ::GetSysColor(COLOR_3DFACE)); return TRUE; }
And you would think that would do it! But no, CStatic has all sorts of implementation problems I never hit before. For example, due to some overly-clever programmer at Microsoft going to considerable effort to do the Wrong Thing, instead of SetWindowText simply doing an Invalidate and simply waiting for the text to redraw, instead, the Really Clever Programmer violates the basic principles of programming and calls the default drawing routine directly! Of course, this makes no sense whatsoever, but this is what happens when inexperienced people think they can be Clever and Optimize The Code. Someone, make it stop, please.
So when I did the SetWindowText to change the text, instead of my own OnPaint handler being called, instead, the text is displayed using the default OnPaint handler, which of course gets it completely wrong.
So I changed the control from a CStatic with SS_LEFT text attributes to a CStatic with SS_BITMAP. Now there was just one problem: since there was no text and no font, SetFont, GetFont, SetWindowText and GetWindowText no longer functioned!
I've already described how to implement SetFont and GetFont. Implementing SetWindowText was not that much harder. At this point, I had effectively derived solely from CWnd, but with the convenience of having a control readily settable in the dialog editor.
The only additional feature for text management is that I had to implement not just WM_SETTEXT and WM_GETTEXT, but also WM_GETTEXTLENGTH. To implement WM_GETTEXT I use the StringCchCopyEx function from the strsafe.h file. I no longer use lstrcpy, strcpy, _tcscpy, lstrcat, strcat, _tcscat, and other unsafe string functions. The declarations need to be added in the appropriate places.
CString text; HFONT font; ON_MESSAGE(WM_SETTEXT, OnSetText) ON_MESSAGE(WM_GETTEXT, OnGetText) ON_MESSAGE(WM_GETTEXTLENGTH, OnGetTextLength) LRESULT CVertical::OnSetText(WPARAM, LPARAM lParam) { text = (LPCTSTR)lParam; return TRUE; } // CVertical::OnSetText LRESULT CVertical::OnGetText(WPARAM wParam, LPARAM lParam) { WPARAM n = text.GetLength(); if(n >= wParam) n = wParam; LPTSTR end; StringCchCopyEx((LPTSTR)lParam, wParam, text, &end, NULL, 0); LRESULT len = end - (LPTSTR)lParam; return len; } // CVertical::OnGetText
LRESULT CVertical::OnGetTextLength(WPARAM, LPARAM) { return (LRESULT)text.GetLength(); } // CVertical::OnGetTextLength
The classic methods of computing mean and standard deviation involves maintaining arrays for all the points. This can get inconvenient, and when processing long streams of samples, it would mean a large in-memory structure. In fact, you only need three variables to compute these. The basic formula can be expressed as shown in the equation below
There are only three variables: n, Σx2 and Σx. maintained. The code to compute mean and standard deviation in my code looks like this (note that I have all the data in an array of doubles, but it also works well if the data is merely passing through. But I don't have to keep arrays for computing the variance).
//---------------------------------------------------------------- // _ _ // _n-1_ | _n-1_ |2 // \ | \ | // n > x[i]² - | > x[i] | // /____ | /____ | // i = 0 |_ i = 0 _| // S² = ---------------------------- // n (n - 1) //---------------------------------------------------------------- double sumx2 = 0.0; double sumx = 0.0; for(int i = 0; i < n; i++) { /* compute mean, sdt */ sumx2 += data.Times[i] * data.Times[i]; sumx += data.Times[i]; } /* compute mean, sd, sorted list */ double sq = 0.0; double mean = 0.0; if(n == 1) mean = data.Times[0]; // this should never happen if(n > 1) { /* mean & sd */ sq = (n * sumx2 - (sumx * sumx)) / (n * (n - 1)); mean = sumx / (double)n; } /* mean & sd */ double sd = sqrt(sq);
Before I created the graphing component, I played around with the kinds of displays I'd like to create. I prototyped them in Excel. One thing that was very clear was that with times running from tens of microseconds to hundreds of milliseconds, a linear graph was going to be inappropriate. So I decided to do a semi-log graph.
First, I did a simple loop that computed the minimum and maximum values. Then I computed the logarithm and took the "ceiling", the integer which is larger than the argument provided. This told me the maximum point I'd need on my Y-axis. Since Windows wants points rendered as integers, I wouldn't get very far in graphing if I chose to represent four orders of magnitude with a graph having the range of 1..4. So I chose an arbitrary scaling factor to allow finer resolution. This factor can be any value, but I decided 1000 would be a reasonable scaling
#define SCALING 1000
double maxTime = 0.0; double minTime = 1.0E50; for(int i = 0; i < n; i++) { /* compute max */ maxTime = max(maxTime, data.Times[i]); minTime = min(minTime, data.Times[i]); } /* compute max */ double maxlog = log10(maxTime * MICROSECONDS); maxlog = ceil(maxlog); int maxY = (int)maxlog * SCALING; // scaled integer value for maximum rate if(maxY == 0) return; // not interesting, no data
Using the mapping mode MM_ANISOTROPIC, I set the scaling to be in terms of the count of elements n, the newly-computed (scaled) maxY, and the dimensions of the client area in rectangle r. I also set it up so that the coordinates are increasing in the Y direction, upward (that's what the minus sign does in the SetViewportExt call), and the origin is set to the lower left corner (SetViewportOrg).
// Set up the coordinates dc.SetMapMode(MM_ANISOTROPIC); dc.SetWindowExt(n, (int)maxY); dc.SetViewportOrg(0, r.Height()); dc.SetViewportExt(r.Width(), -r.Height());
Finally, I plot all the points using PolyLine. I convert the floating-point value which is in seconds to an integer representing the number of microseconds. The array sorted is actually an array of time values which is sorted by send times, while the raw data is stored in an array kept in order of receipt. For the details of how this sorted array is generated (it is a trivial algorithm because the transmit time is already encoded in the data structures), consult the source code.
#define MICROSECONDS 1000000
CArray<CPoint, CPoint>points; points.SetSize(n); for(int i = 0; i < n; i++) { /* display times */ double L = log10(sorted[i] * MICROSECONDS); int y = (int) (L * SCALING); points[i] = CPoint(i, y); } /* display times */ dc.Polyline(points.GetData(), n);
I've omitted a lot of details to concentrate on the core algorithms; for example the drawing of the seek address plot, the drawing of the horizontal grid lines, or the plotting of the mean and ± σ area. But the result looks like this:
Note, however, the axis labels. They are given as 1µs, 10µs, and 100µs. In the other graph, the designations 1ms, 10ms and 100ms appear. This makes the data easier to read and interpret.
While scientific notation is general, can you tell me how many microseconds in 1.2×10-4 seconds? Or how long 1000000000 microseconds are? Unless you work with these numbers every day, it is not obvious. Therefore, when presenting information, it is reasonable to consider displays that conform to our natural notations. Now, if I wrote the exponential as 120µs, it would be easier to understand. If I wrote the long number with commas, as 1,000,000,000 microseconds, you might realize it is 1,000 seconds, although a description such as 16:40 might be a better way to express it. So when expressing times. you should use natural forms of expression. Since all the values I planned to express were less than 10 seconds, I implemented a subset of the notation convention. However, in the past I've converted to nnd hh:mm, hh:mm:ss, mm:ss.s, and so on. Note that as the scales get larger, the smaller units drop off. Something in terms of days doesn't need to know much about seconds, for example. This subroutine returns the axis labels to display in the logarithmic graph.
CString CPlot::units(double usec) { static const LPCTSTR scales[] = { _T("us"), _T("ms"), _T("s"), NULL }; CString result; int time = (int)usec; for(int i = 0; scales[i] != NULL; i++) { /* compute */ result.Format(_T("%d%s"), time, scales[i]); if(time < 1000) break; time /= 1000; } /* compute */ return result; } // CPlot::units
In the prehistory of C, it was developed on a 16-bit computer. The int type was defined to be the "natural word length" of the machine. This meant that when we moved to 32-bit machines, the int type became a 32-bit data type. Fortunately, having suffered from this problem in the past, the designers of the Windows 64-bit compilers simply declared that int is the size of the abstract integer of the C environment, not coupled to hardware, and is consequently 32 bits. A typical example of the failure mode of the "int is the natural word length" is illustrated by the defects of the rand function. This is supposed to return an int type, but in fact the range is still restricted to the quaint 16-bit range of positive integers, that is, rand returns a value in the range of 0..32767. So we don't get an int range, or even a UINT range; we still have the same random range of the old PDP-11. Wow.
To get a seek offset into a potentially 1GB file, I could not use something as meaninglessly trivial as the rand function. I had to have a good 32-bit random number function. Fortunately, when I posted this question on the newsgroup, I was pointed to the CryptGenRandom function. So here's my 32-bit random number generator: In the unlikely event that the crypto library fails to initialize, I fall back to a rather kludgy RAND32 macro I found somewhere on the Internet.
#define RAND32() ((int)(((DWORD)rand() << 17) ^ ((DWORD)rand() << 9) ^ rand())) /* protected: static */ HCRYPTPROV Rand32::provider = NULL; /*static */ int Rand32::rand32() { error = ERROR_SUCCESS; if(provider == NULL) { /* get provider */ if(!::CryptAcquireContext(&provider, NULL, // use default cryptographic container NULL, // use default cryptographic provider PROV_RSA_FULL, // as good a choice as any CRYPT_NEWKEYSET | CRYPT_SILENT)) { /* failed */ error = ::GetLastError(); provider = (HCRYPTPROV)INVALID_HANDLE_VALUE; } /* failed */ } /* get provider */ if(provider != (HCRYPTPROV)INVALID_HANDLE_VALUE) { /* get number */ int result; if(::CryptGenRandom(provider, sizeof(int), (LPBYTE)&result)) return result; error = ::GetLastError(); ::CryptReleaseContext(provider, 0); // failed, why? provider = (HCRYPTPROV)INVALID_HANDLE_VALUE; } /* get number */ return RAND32(); } // rand32
To create this little application, nominally a "toy" application, I called on a lot of my existing code base to put components together. I also called on a lot of my knowledge of Windows graphics. When I was done, I realized this might make an interesting tutorial on Windows programming techniques, so here it is.
The views expressed in these essays are those of the author, and in no way represent, nor are they endorsed by, Microsoft.