0

I have a project (Delphi 10 Seattle, win32) with a many menus and many items in those menus. Some of the menu items are created at design time, some of them at run time.

What I'm looking to do is log some information about the TMenuItem, such as the name/caption, timestamp, etc. when the OnClick event is triggered.

I could simply add a procedure call to the start of every function which is assigned to the TMenuItem OnClick event but I was wondering if there was a more elegant solution.

Also to note, I have tried Embarcadero's AppAnalytics but I found it didn't give me the information or flexibility I wanted and was rather pricey.

Edit: I'll add some more information detailing what options I have considered (which I probably should've done to start with).

The simple adding a function to every menuitem click I want to log, which would mean doing this for a lot of functions and would have to add it to every new menu item added.

procedure TSomeForm.SomeMenuItem1Click(Sender: TObject);
var
    item : TMenuItem;
begin
    item := Sender as TMenuItem;
    LogMenuItem(item);  // Simple log function added to the start of each menuitem click
end;

By 'more elegant solution' I mean would it be possible to add a 'hook' so that all TMenuItem OnClick events triggered another procedure (which would do the logging) before calling the procedure assigned to the OnClick Event.

Or another option I considered was creating a class which inherited from TMenuItem which would override TMenuItem.Click and do the logging before generating the OnClick event. But then I didn't know how that would work for the design time menu items without a lot of work remaking the menus.

Clayton Johnson
  • 279
  • 4
  • 16
  • Small tip: stop using onlick events and use Actions instead. – whosrdaddy Dec 04 '15 at 07:37
  • funnel all clicks to the same event, use Sender to evaluate and log each one, then route each to their own event. – John Easley Dec 04 '15 at 07:37
  • @whosrdaddy could you outline the benefits of usings Actions over OnClick – Clayton Johnson Dec 04 '15 at 07:40
  • @JohnEasley how would you determine which event would be routed to? A simple if or case statement seems like it would be difficult to read and be a pain from a maintainability standpoint – Clayton Johnson Dec 04 '15 at 07:42
  • You check the name of the sender, and call an procedure with the same name.. that's not hard to maintain at all... – John Easley Dec 04 '15 at 07:43
  • @JohnEasley OK sounds like a possible solution, would you mind writing up a full answer with an example because I'm not sure how you would go about doing that. – Clayton Johnson Dec 04 '15 at 07:45
  • In more complex GUI scenarios where one can have toolbar items, menu items, popup menus and so on, that do the same thing, it is easier to maintain actions than assign onclick handlers to each individual GUI item. – whosrdaddy Dec 04 '15 at 07:50
  • Here is a [nice article](http://www.blong.com/Articles/Actions/Actions.htm) about actions and their usage... – whosrdaddy Dec 04 '15 at 07:55
  • BTW You can route AppAnalytics to your own server and you can also enhance it to log more – Sir Rufo Dec 04 '15 at 09:35

2 Answers2

2

This is much easier to achieve using actions. That has the benefit that you'll pick up actions invoked by UI elements other than menus, for instance toolbars, buttons etc.

Use an action list, or an action manager, as you prefer. For example, with an action list the action list object has an OnExecute event that fires when any action is executed. You can listen for that event and there log the details of the action being executed.

David Heffernan
  • 601,492
  • 42
  • 1,072
  • 1,490
  • Thanks @David, making use of the action list's OnExecute event is just the kind of thing I was looking for, simple, easy and doesn't require a lot of work to change the code to make use of – Clayton Johnson Dec 04 '15 at 09:04
2

I absolutely agree that actions is the way to go, but for completeness sake and those cases where you quickly want to debug an application using old-style menus, here's a unit that you can use with menu items. It will even work if the menu items have actions linked to them, but it won't work for any other controls with actions like a TActionMainMenuBar. All the debugging code is in this unit to keep your normal code clutter-free. Just add the unit to the uses clause and call StartMenuLogging with any applicable component, e.g. a menu component, form component or even Application! Any menu items in the tree under it will be hooked. So you can potentially debug all menu-clicks in all forms with just those two lines in your production code. You can use StopMenuLogging to stop, but it's optional. Warning: This unit was not tested properly - I took an old debug unit I wrote and cleaned it up for this purpose and just tested it superficially.

unit LogMenuClicks;


interface

uses
  Classes;

procedure StartMenuLogging(AComponent: TComponent);
procedure StopMenuLogging(AComponent: TComponent);
procedure StopAllMenuLogging;


implementation

uses
  SysUtils,
  Menus;


type
  PLoggedItem = ^TLoggedItem;
  TLoggedItem = record
    Item: TMenuItem;
    OldClickEvent: TNotifyEvent;
  end;

  TLogManager = class(TComponent)
  private
    FList: TList;
    FLog: TFileStream;

    procedure Delete(Index: Integer);
    function FindControl(AItem: TMenuItem): Integer;
    procedure LogClick(Sender: TObject);
  protected
    procedure Notification(AComponent: TComponent; Operation: TOperation); override;
  public
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;

    procedure AddControl(AItem: TMenuItem);
    procedure RemoveControl(AItem: TMenuItem);
  end;

  var
    LogMan: TLogManager = nil;

{ TLogManager }

constructor TLogManager.Create(AOwner: TComponent);
begin
  inherited;

  FLog := TFileStream.Create(ChangeFileExt(ParamStr(0), '.log'), fmCreate or fmShareDenyWrite);
  FList := TList.Create;
end;

destructor TLogManager.Destroy;
var
  i: Integer;
begin
  i := FList.Count - 1;
  while i >= 0 do
    Delete(i);
  FList.Free;

  FLog.Free;

  inherited;
end;

procedure TLogManager.Notification(AComponent: TComponent; Operation: TOperation);
begin
  if Operation = opRemove then
    RemoveControl(TMenuItem(AComponent));

  inherited;
end;

procedure TLogManager.Delete(Index: Integer);
var
  li: PLoggedItem;
begin
  li := FList[Index];

  with li^ do
  begin
    Item.RemoveFreeNotification(Self);
    Item.OnClick := OldClickEvent;
  end;

  Dispose(li);
  FList.Delete(Index);
end;

function TLogManager.FindControl(AItem: TMenuItem): Integer;
begin
  Result := FList.Count - 1;
  while (Result >= 0) and (PLoggedItem(FList[Result]).Item <> AItem) do
    Dec(Result);
end;

procedure TLogManager.AddControl(AItem: TMenuItem);
var
  li: PLoggedItem;
begin
  if not Assigned(AItem) then
    Exit;

  if FindControl(AItem) >= 0 then
    Exit;

  New(li);
  li.Item := AItem;
  li.OldClickEvent := AItem.OnClick;
  AItem.OnClick := LogClick;
  FList.Add(li);

  AItem.FreeNotification(Self);
end;

procedure TLogManager.RemoveControl(AItem: TMenuItem);
var
  i: Integer;
begin
  if Assigned(AItem) then
  begin
    i := FindControl(AItem);
    if i >= 0 then
      Delete(i);
  end;
end;

procedure TLogManager.LogClick(Sender: TObject);
var
  s: string;
begin
  s := Format('%s: %s' + sLineBreak, [TComponent(Sender).Name, FormatDateTime('', Now)]);
  FLog.WriteBuffer(s[1], Length(s));
  PLoggedItem(FList[FindControl(TMenuItem(Sender))]).OldClickEvent(Sender);
end;


procedure StartMenuLogging(AComponent: TComponent);

  procedure CheckControls(Comp: TComponent);
  var
    i: Integer;
  begin
    if Comp is TMenuItem then
      LogMan.AddControl(TMenuItem(Comp))
    else
      for i := 0 to Comp.ComponentCount - 1 do
        CheckControls(Comp.Components[i]);
  end;

begin
  if not Assigned(LogMan) then
    LogMan := TLogManager.Create(nil);

  CheckControls(AComponent);
end;

procedure StopMenuLogging(AComponent: TComponent);

  procedure CheckControls(Comp: TComponent);
  var
    i: Integer;
  begin
    if Comp is TMenuItem then
      LogMan.RemoveControl(TMenuItem(Comp))
    else
      for i := 0 to Comp.ComponentCount - 1 do
        CheckControls(Comp.Components[i]);
  end;

begin
  if Assigned(LogMan) then
    CheckControls(AComponent);
end;

procedure StopAllMenuLogging;
begin
  LogMan.Free;
end;


initialization

finalization
  if Assigned(LogMan) then
     LogMan.Free;

end.
Jannie Gerber
  • 659
  • 1
  • 7
  • 15
  • Thanks Jannie, that would definitely let me do what I wanted without changing all the menuitems to use actions, but I'm going to spend the time and change them all to use actions as it is the general consensus that actions are better – Clayton Johnson Dec 06 '15 at 00:35
  • @Clayton, yes, do the action thing if possible and keep my unit around for those emergencies when you don't have time to change a large project when hunting down a bug! – Jannie Gerber Dec 06 '15 at 21:55
  • Jannie, die is 'n goeie idee en dit werk. I've spent some hours trying to find a generic way to hook any menu and popup item (on a very large project) to determine what the user clicked on. I use FindDragTarget to capture most controls on the GUI for any form but it won't capture Menu and Popup Menu items. To get the menu items you have to intercept WMMENUSELECT but then you have to do that for every form. Dankie Jannie! – Frank Pedro May 31 '21 at 20:54