0

I'm trying to paint my own border (user selectable size and paint color) on a .net winform control. In this case, an inherited OwnerDraw ListBox. I am so close - but stumped by a painting issue that is highlighted in the gif below.

As you can see, I am successfully drawing the border in the NonClient area. The control is successfully appending the scrollbars. But there is an area in the bottom right (the spacer area between the vertical and horizontal scrollbar) where havoc occurs. Although not shown in the the animation, the red border in that bottom right area also disappears sometimes.

My steps are (relevant source below gif):

  1. Set relevant base control properties (such as DrawMode = OwnerDraw.Fixed and HorizontalScrollbar = True)
  2. Override CreateParams, switching off WS_BORDER and WS_EX_CLIENTEDGE
  3. Intercept WM_NCCALCSIZE, setting the shrunken client area.
  4. Intercept WM_NCPAINT, and painting the borders.
  5. Override OnResize and repaint the NonClient areas.

It seems that after I resize the client area (WM_NCCALCSIZE), the underlying control then resizes it again to accommodate the scrollbars...but for some reason doesn't consistently paint the 'End Spacer' (the square area at the end of the horz and vert scrollbar). Or possibly is copying pixels from the wrong place, which is why I sometimes lose the bottom right edge of the border too.

enter image description here

enter image description here

I hope the above makes some sense! The code below has a couple of calls to wrapper functions that just then call the relevant winapi function. Also, you will note that this class inherits from ExListBoxBase, which in turn inherits from ListBox. In ExListBoxBase, I add support for Checkboxes and Icons and make it owner draw.

Finally, please note a silly hack that deals with most of the issue: A call to OnNonClientPreFill at the start of handling WM_NCCALCSIZE. OnNonClientPrefill merely paints the entire control area. This should be completely unnecessary - but is the only hack that seems to partially work...and even then, the missing bottom right border shown in the pic above, still pops up from time to time.

So the question is how to clean up those artifacts, the right way, and avoid using my silly hack which forces a totally unnecessary repaint...and isn't very robust.

Public Class ExListBox
    Inherits ExListBoxBase
    Private _ClientRect As Rectangle

    '// Turn off Windows drawn borders
    Protected Overrides ReadOnly Property CreateParams As CreateParams
        Get
            Dim cp As CreateParams = MyBase.CreateParams
            cp.ExStyle = cp.ExStyle And (Not WS_EX_CLIENTEDGE)
            cp.Style = cp.Style And Not WS_BORDER
            'cp.Style = cp.Style Or WS_BORDER
            Return cp
        End Get
    End Property

    Protected Overrides Sub WndProc(ByRef m As Message)

        Select Case m.Msg
            '// resize the Client Area (excluding Scrollbar area)
            Case WM_NCCALCSIZE

                If m.WParam.ToInt32() = 0 Then
                    Dim rc As RECT = DirectCast(m.GetLParam(GetType(RECT)), RECT)
                    ResizeClientArea(rc)
                    Marshal.StructureToPtr(rc, m.LParam, True)
                    m.Result = IntPtr.Zero
                Else
                    Dim ncc As NCCALCSIZE_PARAMS = DirectCast(m.GetLParam(GetType(NCCALCSIZE_PARAMS)), NCCALCSIZE_PARAMS)
                    '// HACK: fill the entire control Area before resizing it
                    'OnNonClientPreFill(m.HWnd)
                    ResizeClientArea(ncc.rgrc0)
                    Marshal.StructureToPtr(ncc, m.LParam, True)
                    '// have tried all combos!
                    m.Result = WVR_VALIDRECTS
                End If
                '// needed after the above, for windows to create room for the scrollbars
                MyBase.WndProc(m)

            Case WM_NCPAINT
                DebugMessage(m)
                OnNonClientPaint(m.HWnd)
                m.Result = IntPtr.Zero
                '// needed after the above, for windows to reliably draw the scrollbars
                MyBase.WndProc(m)

                Return
            Case Else
                '// note: MyBase has its own WndProc that handles OwnerDraw painting amongst other things
                MyBase.WndProc(m)

        End Select

    End Sub

    '// shrink the client area for the border width.
    Public Overridable Sub ResizeClientArea(ByRef rect As RECT)

        Dim thickness = Me.Style.Border.Width
        rect.Left = rect.Left + thickness
        rect.Top = rect.Top + thickness
        rect.Right = rect.Right - thickness
        rect.Bottom = rect.Bottom - thickness
        '// helper rectangle
        _ClientRect = New Rectangle(thickness, thickness, rect.Right - rect.Left, rect.Bottom - rect.Top)
    End Sub

    '// paint the border
    Private Sub OnNonClientPaint(handle As IntPtr)
        Dim hDC As IntPtr = GetWindowDC(handle)

        Using g As Graphics = Graphics.FromHdc(hDC)
            g.ExcludeClip(_ClientRect)
            DrawBorder(g, New Rectangle(0, 0, Me.Width, Me.Height), Color.Red, ButtonBorderStyle.Solid, Me.Style.Border.Width)
        End Using

        ReleaseDC(handle, hDC)
    End Sub

    '// silly hack to prepaint the entire area (not sure if that excludeclip works as written)
    Private Sub OnNonClientPreFill(handle As IntPtr)

        Dim hDC As IntPtr = GetWindowDC(handle)
        Dim rect As New Rectangle(0, 0, Me.Width, Me.Height)
        Using g As Graphics = Graphics.FromHdc(hDC)
            g.ExcludeClip(_ClientRect)
            FillRectangle(g, rect, Color.YellowGreen)
        End Using

        ReleaseDC(handle, hDC)
    End Sub

    '// deal with resizing. RedrawWindow didn't work properly
    Protected Overrides Sub OnResize(e As EventArgs)
        MyBase.OnResize(e)
        'RedrawWindow(Me.Handle, IntPtr.Zero, IntPtr.Zero, RedrawWindowFlags.Frame Or RedrawWindowFlags.UpdateNow Or RedrawWindowFlags.Invalidate)
        OnNonClientPaint(Me.Handle)
    End Sub

    '// force a recalc of the non client areas. Not used.
    Public Sub RecalcNCArea()
        SetWindowPos(Me.Handle, 0, 0, 0, 0, 0, SWP_NOMOVE Or SWP_NOSIZE Or SWP_NOZORDER Or SWP_FRAMECHANGED)
    End Sub

    '// force a repaint of the non client areas. Not used.
    Public Sub RedrawNCAreas()
        SendMessageW(Me.Handle, WM_NCPAINT, 0&, 0&)
    End Sub

    Private Sub DebugMessage(m As Message)
        Debug.Print("MSG: {0} : &H{1}", GetConstantName(m.Msg), Hex(m.Msg))
    End Sub

End Class


Finally I should note a bunch of articles that helped get me to where I am...but none deals with the Scrollbar phenomenon. Here are a couple:

https://stackoverflow.com/a/38405319/1700453

How do I paint custom borders on .Net WinForms controls

skavan
  • 417
  • 1
  • 9
  • 17
  • Specifically what’s the question – Daniel A. White May 24 '22 at 22:12
  • I updated the post to be more specific. Hope it is. – skavan May 24 '22 at 22:46
  • 1
    In my programs I also set `ncc->rgrc[ 1 ]` and `ncc->rgrc[ 2 ]` to empty rect (0,0,0,0) so it doesn't copy anything. And Windows forgets to paint this square often, fortunatelly you can paint it yourself. Finding its rectangle is easy by using diference of width and height betwen `GetWindowRect` and `GetClientRect` (in your case adjusted by custom border). – Daniel Sęk May 25 '22 at 05:29
  • Thanks. That was helpful. I think the issue is two fold: 1. As you say, windows paints that 'end spacer' rectangle somewhat randomly. 2. Worse, when it does paint it, it doesn't seem align it to the box left empty by the vscroll and hscroll, but places it in the corner of window, making its position wrong and writing over the bottom right corner of my border. So, the 'fill the square myself approach' works for 90% of cases - but fails when windows decides to actually paint the square. I need to figure out how to detect this event (or turn it off) so I can repaint that border! Ideas? – skavan May 25 '22 at 17:11
  • I figured it out - at least as best as I think is possible. See Answer. – skavan May 27 '22 at 01:01

1 Answers1

0

Update: A Solution

First it is important to understand the problem.

  • A Windows Scrollbar is 26 pixels thick (at 144dpi).
  • It's actually 25 pixels + 1 outer pixel of Non Client Background.
  • Windows draws the scrollbar without firing any messages or allowing any control over the process.
  • When both Vertical and Horizontal Scrollbars are visible, there is a 26x26 square that is painted at the bottom of the Vertical and right of the horizontal Scrollbar. I shall call this the 'End Spacer'. Windows has a bug in its drawing of the End Spacer.

Let's take a look. To create this Listbox I shrunk the Client Area by 27 pixels on all sides (26 pixels for the Scrollbar, 1 for the border). The border is red, the remaining 26 pixels were painted pink. Here's what windows does: enter image description here

  • It starts by painting the Horizontal Scrollbar across the entire client area.
  • Then it realizes there is a vertical scroll bar, so it resizes the Horizontal Scrollbar to allow for the End Spacer. It doesn't clear the vacated pixels because it knows the End Spacer will overwrite it.
  • But (and here's the bug), while it correctly draws the scrollbars on the inside edge of the non-client area (abutting the client area), it incorrectly draws the end spacer, working backwards from the bottom right. This works fine when there's a standard nonclient area (0 pixels or WS_BORDER), but not when there is an expanded nonclient area.

enter image description here

For illustration, I have colored the End Spacer that windows draws, in a faded Blue, and where the thing should go, in Green. BTW - to make matters worse, windows redraws the End Spacer on resize, but also immediately after an OnPaint cycle.

After finally understanding the problem, the solution is annoying but not awful.

First, we overhaul OnNonClientPaint (original code above). The updated code is below, but basically:

  • we create a clipping region that is the size of the client area PLUS the size of any visible Scrollbars. Then in the case of both Scrollbars being visible, we clip the the clipping region by the area (see green area above) where the End Spacer should go.
  • We then fill the area between the end of the Scrollbars and the start of the border, with the background color.
  • We then paint the End Spacer where it should have been (SystemColors.ButtonFace).
  • Finally we paint our custom border.
  • Importantly, since windows repaints the End Spacer in the wrong position immediately after a WM_PAINT, we move our OnNonClientPaint routine, out of WM_NCPAINT and into WM_PAINT, but after the base WM_PAINT has been processed.
  • Because we do this, we can eliminate all code in the WM_NCPAINT hander and the OnResize Override.

The end result is exactly what it should be. A custom size, custom color border (with optional padding) and without artifacts.

There is one gotcha. There is a noticeable flicker in the area where windows keeps drawing the misplaced end spacer. It only occurs if both Scrollbars are visible and one is resizing. It's not terrible, but would love to find a way to conquer that niggle. Here's an example of the end result:

enter image description here

Very hard to explain all this stuff. Hope it makes sense and is useful to someone. Took me forever. Special thanks to @Daniel Sek for the kick in the right direction.

Here's the OnNonClientPaint code:

Private Sub OnNonClientPaint(handle As IntPtr)
        '// quick hack to see what scrollbars are visible.
        Dim vScrollShowing As Boolean = Not (_ClientRect.Width = ClientRectangle.Width)
        Dim hScrollShowing As Boolean = Not (_ClientRect.Height = ClientRectangle.Height)

        '// has to be created here and not in ResizeClientArea, because windows has changed ClientRectangle.Width and ClientRectangle.Height to accomodate the scrollbars.
        '// Me.ClientRectangle is set to the same as GetClientRect(handle, CRect) so we can save ourselves a call
        '// clipRect1 is the client area + the scrollbar area (if visible). This is to ensure the scrollbars are not overwritten by my non-client painting.
        Dim clipRect1 = New Rectangle(_ClientRect.Left, _ClientRect.Top,
                                      Me.ClientRectangle.Width + If(vScrollShowing, _VScrollBarWidth, 0),
                                      Me.ClientRectangle.Height + If(hScrollShowing, _HScrollBarHeight, 0))

        '// Get the size of the area between the end of the (client area + scrollbars), and the border, because this
        '// is painted in a different color than the end spacer.
        Dim inFillSize = Me.Width - clipRect1.Right - Me.Style.Border.Width


        Dim region = New Region(clipRect1)
        'if both scrollbars are showing we need to "clip" out the 'EndSpacer' from the region (the correctly positioned one) so our fill, fills it in.
        Dim endSpacer As Rectangle
        If vScrollShowing And hScrollShowing Then
            endSpacer = New Rectangle(clipRect1.Right - 26, clipRect1.Bottom - 26, 26, 26)
            region.Exclude(endSpacer)
        End If

        '// inner clip lets us draw a border around the list box and scroll bars.
        '// if we want a border just around the list items, we should do that in OnPaint
        Dim innerClip = clipRect1
        innerClip.Inflate(1, 1)

        Dim hDC As IntPtr = GetWindowDC(handle)
        Using g As Graphics = Graphics.FromHdc(hDC)
            '// we will exclude the region (client + scrollbars rectangle - endspacer rectangle) from the fill.
            g.ExcludeClip(region)

            '// draw the 'padding area', that is the area between the border and the scrollbars.
            DrawBorder(g, New Rectangle(Me.Style.Border.Width, Me.Style.Border.Width, Me.Width - (2 * Me.Style.Border.Width), Me.Height - (2 * Me.Style.Border.Width)), Me.BackColor, ButtonBorderStyle.Solid, inFillSize)
            '// if both scrolls are showing, draw the end spacer in the correct position.
            If vScrollShowing And hScrollShowing Then
                FillRectangle(g, endSpacer, SystemColors.ButtonFace)
            End If
            '// finally draw the border
            DrawBorder(g, New Rectangle(0, 0, Me.Width, Me.Height), Me.Style.Border.BorderColor, ButtonBorderStyle.Solid, Me.Style.Border.Width)
        End Using

        ReleaseDC(Me.Handle, hDC)
    End Sub
skavan
  • 417
  • 1
  • 9
  • 17