14

How can you reset the max width for a PopupMenu's items list?

Say i.e you add a few TMenuItems at runtime to a popupmenu:

item1: [xxxxxxxxxxxxxxxxxxx]
item2: [xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx]

The menu automatically adjusts the size to fit the largest item. But then you do Items.Clear and add a new item:

item1: [xxxxxxxxxxxx                    ]

It ends up like that, with a large empty space after the caption.

Is there any workaround besides re-creating the popupmenu?

Here the code for reproducing this anomaly:

procedure TForm1.Button1Click(Sender: TObject);
var
  t: TMenuItem;
begin
  t := TMenuItem.Create(PopupMenu1);
  t.Caption := 'largelargelargelargelargelarge';
  PopupMenu1.Items.Add(t);
  PopupMenu1.Popup(200, 200);
end;

procedure TForm1.Button2Click(Sender: TObject);
var 
  t: TMenuItem;
begin
  PopupMenu1.Items.Clear;
  t := TMenuItem.Create(PopupMenu1);
  t.Caption := 'short';
  PopupMenu1.Items.Add(t);
  PopupMenu1.Popup(200, 200);
end;
NGLN
  • 43,011
  • 8
  • 105
  • 200
hikari
  • 3,393
  • 1
  • 33
  • 72
  • 2
    I can duplicate this with only api calls, CreatePopupMenu, InsertMenu, TrackPopupMenu, DeleteMenu etc.. There's no 'contraction' as long as the handle is valid. As such, my opinion is that, the only solution is to free the popup menu and recreate it at run time, that's the only way to call 'DestroyMenu'. – Sertac Akyuz Oct 18 '14 at 03:36
  • 2
    @hikari: Thanks for the edit. The question is much more useful with the code available, particularly for future readers who may find it in a search. – Ken White Oct 18 '14 at 15:21

3 Answers3

9

tl,dr: Attach an ImageList.


If the menu items could get send a WM_MEASUREITEM message, then the width would be recalculated.

Setting the OwnerDraw property to True achieves that, which is the first solution. But for older Delphi versions, this will result in non-default and non-styled drawing of the menu items. That is not desirable.

Fortunately, TMenu has a extraordinary way of telling whether the menu (items) is (are) owner drawn:

function TMenu.IsOwnerDraw: Boolean;
begin
  Result := OwnerDraw or (Images <> nil);
end;

Thus setting the Images property to an existing ImageList will achieve the same. Note that there need not be images in the ImageList. And if there are images in it, you do not have to use them and let the ImageIndex be -1 for the menu items. Of course an ImageList with images will do just fine too.

NGLN
  • 43,011
  • 8
  • 105
  • 200
  • Definitely much safer than cracking the class :) but I find it curious that each of these techniques yields a slightly different appearance of the menu than either each other *or* the "plain" menu. Setting OwnerDraw TRUE results in a *much* smaller menu and the dummy ImageList in a slightly larger one (not just a margin for images - also present on a plain popup - but also extra padding to the right of the item text). Very odd. – Deltics Nov 05 '14 at 19:25
  • @Deltics The much smaller menu with `OwnerDraw=True` I noticed here too with D7, but not with XE2. I suspect it to be a bug in older versions. The margin on the right side is space for hotkeys. I suspect that once an ImageList is attached, then the menu can function fully. – NGLN Nov 05 '14 at 19:32
  • This disables the themes, and Dose not looks good. I guess most of the owner drawing should be done manually if you want the items to look native. Id better simply destroy and re-create the Popupmenu or use the cracker class. – kobik Nov 05 '14 at 20:45
  • @kobik Ok, OwnerDrawing maybe isn't the best solution, but which menu doesn't use an ImageList? Or does an ImageList break theming too? – NGLN Nov 05 '14 at 20:55
  • @NGLN, same effect with or without ImageList. tested on D7/Win7. Themes are broken (the focus gradient becomes blueish). – kobik Nov 05 '14 at 21:03
  • @kobik Then for older versions, this answer isn't satisfactory. – NGLN Nov 05 '14 at 21:13
  • @NGLN, I see your point about the image list: "but which menu doesn't use an ImageList?" on the other hand if you don't use an image list, Windows will use themes for the focus but reserve an empty place-holder (on the left side of each item) for images radio/check items which also does not look quite natural if you *don't* use image list. So it's a matter of "taste" maybe. +1 – kobik Nov 05 '14 at 21:19
3

There is workaround, but it is very, very dirty: Use a cracker class to obtain access to the FHandle private member of the TPopupMenu.Items menu item property.

A cracker class involves reproducing the private storage layout of the target class up to and including the private member of interest, and using a type-cast to "overlay" that type onto an instance of the target type in a context that then allows you to access the internal storage of the target.

In this case, the target object is the Items property of TPopupMenu which is an instance of TMenuItem. TMenuItem derives from TComponent so the cracker class to provide access to FHandle for a TMenuItem is:

type
  // Here be dragons...
  TMenuItemCracker = class(TComponent)
  private
    FCaption: string;
    FChecked: Boolean;
    FEnabled: Boolean;
    FDefault: Boolean;
    FAutoHotkeys: TMenuItemAutoFlag;
    FAutoLineReduction: TMenuItemAutoFlag;
    FRadioItem: Boolean;
    FVisible: Boolean;
    FGroupIndex: Byte;
    FImageIndex: TImageIndex;
    FActionLink: TMenuActionLink;
    FBreak: TMenuBreak;
    FBitmap: TBitmap;
    FCommand: Word;
    FHelpContext: THelpContext;
    FHint: string;
    FItems: TList;
    FShortCut: TShortCut;
    FParent: TMenuItem;
    FMerged: TMenuItem;
    FMergedWith: TMenuItem;
    FMenu: TMenu;
    FStreamedRebuild: Boolean;
    FImageChangeLink: TChangeLink;
    FSubMenuImages: TCustomImageList;
    FOnChange: TMenuChangeEvent;
    FOnClick: TNotifyEvent;
    FOnDrawItem: TMenuDrawItemEvent;
    FOnAdvancedDrawItem: TAdvancedMenuDrawItemEvent;
    FOnMeasureItem: TMenuMeasureItemEvent;
    FAutoCheck: Boolean;
    FHandle: TMenuHandle;
  end;

NOTE: Since this technique relies on an exact reproduction of the internal storage layout of the target class, the cracker declaration may need to include $IFDEF variations to cater for changes in that internal layout between different Delphi versions. The declaration above is correct for Delphi XE4 and should be checked against the TMenuItem source for correctness w.r.t other Delphi versions.

With that cracker class we can then provide a utility proc to wrap up the nasty tricks we are then going to perform using the access this provides. In this case, we can clear the menu items as usual, but also call DestroyMenu() ourselves using the cracker cast to overwrite the FHandle member variable with 0 since it is now invalid and needs to be 0 to force the TPopupMenu to recreate the menu when next needed:

  procedure ResetPopupMenu(const aMenu: TPopupMenu);
  begin
    aMenu.Items.Clear;

    // Here be dragons...

    DestroyMenu(aMenu.Items.Handle);
    TMenuItemCracker(aMenu.Items).FHandle := 0;
  end;

In your sample code simply replace your call to PopupMenu1.Items.Clear in your Button2Click handler with a call to ResetPopupMenu(PopupMenu1).

It goes without saying that this is dangerous in the extreme. Quite apart from the sheer lunacy of hacking around inside the private storage of a class, no account is taken in this specific case for unmerging merged menus, for example.

But you asked if there was a workaround, and here is at least one. :)

Whether you consider this more or less practical or desirable than simply destroying and recreating the TPopupMenu is up to you. Class cracking is a technique which can be useful for getting you out of a jam which might otherwise be impossible to resolve but should definitely be considered a "last resort" !

Deltics
  • 22,162
  • 2
  • 42
  • 70
  • +1 I don't think it's dangerous if you know what you're doing and in control of your own sources. for example, TNT Unicode suit for older Delphi versions heavily relies on this technique of cracking private fields (with proper `$IFDEF` for each version). Your code works great with my D7 (of course I have defined a suteible cracker structure that matches D7 offsets. much better than `OwnerDraw` which disables the themes also, and look very bad. BTW, you could calculate the offset of `FHandle` and simply put a `Filler[offset bytes]` before this field. – kobik Nov 05 '14 at 20:38
1

Late answer: but in 10.1 Berlin at least I find that the easiest solution is to set OwnerDraw to true, but do not provide OnDrawItem, only OnMeasureItem. This retains the styling of the menu, but allows you to set the width of the menu items after calling canvas.textextent((Sender as Tmenuitem).caption).

Since I have to set item captions to for example 'Open: somefilename.txt' this allows the menu to self-customize with minimal effort.

frogb
  • 2,040
  • 15
  • 22