Saturday, December 10, 2011

Preventing "MDI creep": how to decascade a child window

I confessed a terrible feeling of emptiness to my Occupational Psychologist (OP) during our weekly meeting yesterday: until the day before, I been concentrating almost entirely on learning Finance, and now that the exam is over, I don't need to know how to price an option nor what the theory of indifference to dividend policy is. "Don't worry", she said, "I'll find you things to fill you up". And so she did (Happy birthday, by the way!).

One side effect with using MDI applications (such as the management program which I wrote and constantly improve) can be described as follows: when the program's first child window is opened, this window will have its top left hand corner placed in the screen's top left hand corner (coordinates 0, 0). The second child window will be opened automatically at 10, 10 and the third at 20, 20. This was a good idea when screen resolution was not particularly high as it makes good use of the screen's 'real estate'. But times have changed; the OP has one of those extra wide screens and she uses it in a mode which provides a huge amount of real estate. All the child windows congregate on the left hand of the screen and the right hand side is unoccupied.

So the OP drags certain child windows to the right hand side so that she can see several complete and unhidden child windows simultaneously. All is fine until a new child window is displayed; within the program I followed each new window creation with the 'cascade' command - and then the personalised display disappears when all the child windows line up again on the left hand side. This was easy to fix by removing the 'cascade' command after new window creation.

But more insidious is that the MDI screen manager (to which we have no access) will create new child windows as if cascade is still in effect. In other words, the first child window will be created at 0,0, the second at 10,10 and the third at 20,20 - even if the second child window has been moved to a location 200, 600. Worse, this behaviour continues even after the child windows have been closed: new child windows will appear at a location which would be consistent with cascading all the previous child windows.

I researched this behaviour a little yesterday afternoon and discovered that it has been named MDI creep. Whilst naming the behaviour makes it easier to talk about, it doesn't make it any easier to solve.

I posted a question of the Stack Overflow site and master programmer David Heffernan gave an answer which pointed me in the correct direction [I often wonder how David actually gets any work done as he seems to be found almost permanently on SO, answering people's esoteric questions]. It took me about an hour of to-ing and fro-ing with his idea before I had something that worked and about another hour of polishing the entire solution.

The solution boils down to three different parts:
  1. How to enable a child window to signify that it wants to be 'uncascadable'
  2. How to universalise this ability
  3. How to prevent the main MDI window from cascading the uncascadable child windows
The answer to the first part is to use each form's system menu; this is the menu that pops up when one clicks on the form's icon sitting in the top left hand corner of the caption bar.  I used to use the system menu in the early days of Windows programming, but haven't touched it in over a decade. Each form has to add an option to the system menu whose effect will be to toggle the 'cascadability' of the form; so the form has to detect when that option has been chosen and to toggle an internal variable. At the same time, it would be good to give visual feedback on the menu what the state of that variable is at any given time.

Here's the article which I used as the basis for developing the code to add the menu option and detect its being pressed. Finding out how to give the visual feedback was a bit more difficult but eventually I found out how to do this (and more importantly, how to do this correctly!). I'll show the complete code a bit further on.

When I originally wrote this code, it was sitting in the form which needed to be decascaded, but it occurred to me that the technique would be much more valuable if I could use it in almost every form and I wasn't going to copy the code fifty times. The solution is to use form inheritance - to define an ancestor form type which contains the code and then define the actual forms to inherit from this ancestor type. This is one of the huge strengths of Delphi (the entire VCL works on this principal) but is something which I have barely touched.

unit ManageForms;

interface

uses Windows, Forms, Menus, Messages;

type
 TNoCascadeForm = class (TForm)
                   private
                    SysMenu: HMenu;
                   public
                    nocascade: boolean;
                    Procedure GetSysMenu;
                    Procedure WMSysCommand(var Msg: TWMSysCommand); message WM_SYSCOMMAND;
                    Procedure SetCheck;
                    Procedure SaveCheck; virtual; abstract;
                   end;

implementation

const
 SC_NoCascade = WM_USER + 1;

procedure TNoCascadeForm.GetSysMenu;
begin
 SysMenu:= GetSystemMenu (Handle, FALSE);
 AppendMenu (SysMenu, MF_SEPARATOR, 0, '');
 AppendMenu (SysMenu, MF_STRING, SC_NoCascade, 'Decascade');
 SetCheck;
end;

procedure TNoCascadeForm.WMSysCommand (var Msg: TWMSysCommand);
begin
 if Msg.CmdType = SC_NoCascade then
  begin
   nocascade:= not nocascade;
   SetCheck;
   SaveCheck;
  end
 else inherited;
end;

procedure TNoCascadeForm.SetCheck;
var
 iChecked: Integer;

begin
 if nocascade
  then iChecked:= MF_CHECKED
  else iChecked:= MF_UNCHECKED;
 CheckMenuItem (SysMenu, SC_NoCascade, mf_bycommand or ichecked);
end;

end.
Procedure GetSysMenu first gets a pointer to the form's system menu, then adds two options: the first is a separator bar and the second is the 'decascade' option. When this option is pressed, a system message of type 'SC_NoCascade' will be sent to the form. Of course, the form has to know how to handle this message.

The final line in this procedure (which will be called once during the inherited form's create method) is to SetCheck - this procedure draws (or erases) the check mark next to 'Decascade' on the system menu, according to the value of the variable nocascade. This is initially set in the inherited form by reading a value stored in the registry; if there is no registry value then the variable is false (no check mark).

When the system message of type 'SC_NoCascade' is sent, it is intercepted by the form's WMSysCommand method. As all system menu messages pass through this method, it is vital to check what the actual message is; if it is not the specific message, then it is passed on to the inherited message handler. Assuming that the message received is SC_NoCascade, then first the method reverses the value of the internal flag, then calls SetCheck to have the menu option updated and finally calls an abstract method called SaveCheck (this probably should have been defined as a stub in the ancestor form).

The SaveCheck procedure is located in the inherited form and stores the value of nocascade in the registry. This is necessary, for if a new inherited form were to be displayed, its nocascade variable would contain the value previously stored in the registry - which would be oblivious to the change which has just occurred. This procedure has to be located in the inherited form as it is dependent on specific registry values which the ancestor form cannot know.

Here is part of the code of a form which inherits from TNoCascadeForm:
unit Manage57;

interface

uses
  Windows, Messages,  ManageForms, ,,,;
type
  TShowCallsTree = class(TNoCascadeForm)
  .
  .
  .
  private
  public
   Procedure SaveCheck; override;
  end;

implementation

{$R *.dfm}
procedure TShowCallsTree.FormCreate(Sender: TObject);
const
 myheight = 440;
 mywidth = 816;

begin
 constraints.MinWidth:= mywidth;
 constraints.MinHeight:= myheight;
 with reg do
  begin
   height:= ReadInteger (progname, 'ShowCallsTreeH', myheight);
   width:= ReadInteger (progname, 'ShowCallsTreeW', mywidth);
   nocascade:= ReadBool (progname, 'ShowCallsTreeCas', false);
  end;
 GetSysMenu;
  .
  .
  .
end;

procedure TShowCallsTree.FormClose(Sender: TObject; var Action: TCloseAction);
begin
 with reg do
  begin
   WriteInteger (progname, 'ShowCallsTreeH', height);
   WriteInteger (progname, 'ShowCallsTreeW', width);
   WriteBool (progname, 'ShowCallsTreeCas', nocascade);
  end;
 action:= caFree
end;

procedure TShowCallsTree.SaveCheck;
begin
 reg.WriteBool (progname, 'ShowCallsTreeCas', nocascade);
end;
After this excursion into the worlds of system menus and inherited forms, we can know address the issue for which we have gathered: how to decascade a child window.

David says in his answer: looking at WM_MDICASCADE it has an option to skip disabled MDI children from cascading. So you could disable certain child windows, send a WM_MDICASCADE message yourself and then re-enable the child windows. Probably easier said that done. The program's main form has a method called cascade, but this is an encapsulation of the WM_MDICascade message and leaves no room for manoeuvre. It has to be replaced by sending the actual WM_MDICascade message, and this message has to have the parameter 2 in order to ensure that disabled windows are ignored.

The main form iterates over the child windows, looking for a form which is decended from TNoCascadeForm; if its nocascade variable is set to true, then the form is disabled. Once this has been done, the form can send the above message to its MDI container window, which handles the cascade. Then the form iterates once more over the child windows, re-enabling those which had previously been disabled.

procedure TMainForm.mnCascadeClick(Sender: TObject);
var
 i: integer;

begin
 for i:= 0 to MDIChildCount - 1 do
  if MDIChildren[i] is TNoCascadeForm
   then if TNoCascadeForm (MDIChildren[i]).nocascade
    then TNoCascadeForm (MDIChildren[i]).enabled:= false;

 sendmessage (mainform.clienthandle, WM_MDICASCADE, 2, 0);

 for i:= 0 to MDIChildCount - 1 do
  if MDIChildren[i] is TNoCascadeForm
   then if TNoCascadeForm (MDIChildren[i]).nocascade
    then TNoCascadeForm (MDIChildren[i]).enabled:= true;
end;
The proof is in the pudding - this really works! Thank you, David, for the help in pointing the way.

No comments: