JackTrapper / DelphiBugs

Bug tracker for Delphi
7 stars 2 forks source link

ListView column width becomes zero if you read Column.Width #2

Open JackTrapper opened 5 years ago

JackTrapper commented 5 years ago

Reading a column's Width can cause the listview to try to get the column width from the underlying Windows LISTVIEW control directly - before the Win32 control's columns have been initialized.

Because the columns have not been initialized, the listview's LVM_GETCOLUMNWIDTH message fails, returning zero. The TListView takes this to mean that the width is zero, and makes all columns zero.

This bug was introduced sometime after Delphi 5.

Steps to reproduce

Add a report style listview with three columns to a form:

image

Add an OnResize event handler to the listview:

procedure TForm1.ListView1Resize(Sender: TObject);
begin
    {
       Any column you attempt to read the width of 
       will **cause** the width to become zero
    }
    ListView1.Columns[0].Width;
//  ListView1.Columns[1].Width;
    ListView1.Columns[2].Width;
end;

Run it:

image

The Bug

The TListColumn code tries to read the column width out of the Windows listview class directly, before the columns have even been added to the Windows listview control

Formatting their code for readability:

function TListColumn.GetWidth: TWidth;
var
   IsStreaming: Boolean;
   LOwner: TCustomListView;
begin
   LOwner := TListColumns(Collection).Owner;
   IsStreaming := [csReading, csWriting, csLoading] * LOwner.ComponentState <> [];

   if (
         (FWidth = 0) 
         and (LOwner.HandleAllocated or not IsStreaming)
      ) 
      or
      (
         (not AutoSize) 
         and LOwner.HandleAllocated 
         and (LOwner.ViewStyle = vsReport) 
         and (FWidth <> LVSCW_AUTOSIZE) 
         and (LOwner.ValidHeaderHandle)
      ) then
   begin
      FWidth := ListView_GetColumnWidth(LOwner.Handle, FOrderTag);
   end;

   Result := FWidth;
end; 

The problem happens while the form is being constructed during dfm deserialization:

ComCtrls.TListColumn.GetWidth: TWidth;
TForm1.ListView1Resize(Sender: TObject);
Windows.CreateWindowEx(...)
Controls.TWinControl.CreateWindowHandle(const Params: TCreateParams);
Controls.TWinControl.CreateWnd;
ComCtrls.TCustomListView.CreateWnd;

The issue is that TCustomListView.CreatWnd the columns are added sometime after the call to CreateWnd:

procedure TCustomListView.CreateWnd;
begin
   inherited CreateWnd; //triggers a call to OnResize, trying to read the column widths
   //...
   Columns.UpdateCols; //add the columns
   //...
end;

The code in TListColumn.GetWidth doesn't realize that the columns have not yet been initialized.

Why doesn't it fail in Delphi 5?

Delphi 5 uses similar TCustomListView construction:

procedure TCustomListView.CreateWnd;
begin
   inherited CreateWnd; //triggers a call to OnResize
   //...
   Columns.UpdateCols;
   //...
end;

Except Delphi 5 doesn't try to psyche itself out, and overthink things:

function TListColumn.GetWidth: TWidth;
begin
   if FWidth = 0 then
      FWidth := ListView_GetColumnWidth(TListColumns(Collection).Owner.Handle, Index);
   Result := FWidth;
end;

If we have a width, use it.

Why does it work with themes disabled?

The bug only happens with Visual Styles enabled.

Windows has a WM_PARENTNOTIFY message that "notifies the parent of important events in the life of the control". In the case of the listview, this sends the handle of the header control that the listview uses internally. Delphi then saves this header hwnd:

procedure TCustomListView.WMParentNotify(var Message: TWMParentNotify);
begin
  with Message do
    if (Event = WM_CREATE) and (FHeaderHandle = 0) then
    begin
      FHeaderHandle := ChildWnd;

      //...
    end;
  inherited;
end;

With themes disabled, Windows does not send the WM_PARENTNOTIFY message until later in the construction cycle. This means that with themes disabled the TListColumn will fail to meet one of the criteria that allows it to talk to the listview:

   if (
         (FWidth = 0) 
         and (LOwner.HandleAllocated or not IsStreaming)
      ) 
      or
      (
         (not AutoSize) 
         and LOwner.HandleAllocated 
         and (LOwner.ViewStyle = vsReport) 
         and (FWidth <> LVSCW_AUTOSIZE) 
         and (LOwner.ValidHeaderHandle) //<--- invalid
      ) then
   begin
      FWidth := ListView_GetColumnWidth(LOwner.Handle, FOrderTag);
   end;

But when we use the new version of the Windows listview control, Windows happens to send the WM_PARENTNOTIFY message sooner in the construction:

   if (
         (FWidth = 0) 
         and (LOwner.HandleAllocated or not IsStreaming)
      ) 
      or
      (
         (not AutoSize) 
         and LOwner.HandleAllocated 
         and (LOwner.ViewStyle = vsReport) 
         and (FWidth <> LVSCW_AUTOSIZE) 
         and (LOwner.ValidHeaderHandle) //<--- Valid!
      ) then
   begin
      FWidth := ListView_GetColumnWidth(LOwner.Handle, FOrderTag);
   end;

Even through the header handle is valid, it does not mean that the columns have been added yet.

The Proposed Fix

It seems like the VCL fix is to use WM_PARENTNOTIY as the correct opportunity to add the columns to the listview:

procedure TCustomListView.WMParentNotify(var Message: TWMParentNotify);
begin
  with Message do
    if (Event = WM_CREATE) and (FHeaderHandle = 0) then
    begin
      FHeaderHandle := ChildWnd;
      UpdateCols; //20140822 Ian Boyd  Fixed QC123456 where the columns aren't usable in time
      //...
    end;
  inherited;
end;

Bonus Chatter

Looking at the Windows 2000 source code, the ListView has some comments that recognize some poor apps exist out there:

lvrept.c

BOOL_PTR NEAR ListView_CreateHeader(LV* plv)
{
   ...
   plv->hwndHdr = CreateWindowEx(0L, c_szHeaderClass, // WC_HEADER,
       NULL, dwStyle, 0, 0, 0, 0, plv->ci.hwnd, (HMENU)LVID_HEADER, GetWindowInstance(plv->ci.hwnd), NULL);

   if (plv->hwndHdr) 
   {
      NMLVHEADERCREATED nmhc;

      nmhc.hwndHdr = plv->hwndHdr;
      // some apps blow up if a notify is sent before the control is fully created.
      CCSendNotify(&plv->ci, LVN_HEADERCREATED, &nmhc.hdr);
      plv->hwndHdr = nmhc.hwndHdr;
   }
   ...
}

From: https://stackoverflow.com/questions/25449362/xe6-tlistview-column-widths-become-zero-if-you-read-column-width