MsgLoopHandler: an event driven wrapper for the Dopus message loop as include script

Tl;Dr
A wrapper class for the message loop, finally as an include script to handle events of dialogs with event handlers instead of switch / if else for easier coding and better to read code.


Download

Version Date Changes
v2.32 (4.2 KB) 2024-07-24 Fixed a bug in AddEventHandler() that prevented adding a hanlder for multiple controls at once (changed instanceof to typeof)
v2.31 (4.2 KB) 2024-07-17 Fixed two bugs in AddEventHandler() and DictionaryLength() (forgot to use 'var' in loops)
v2.3 (4.3 KB) 2024-07-17 AddEventHandler() now takes either a string or a string array for eventSenderName to attach one controlhandler to one event for several controls at once
v2.2 (2.2 KB) 2024-07-16 - :warning: Constructor signature changed :warning: (LoopContext)
- Introduced LoopContext variable, which is passed to every eventhandler from scope of starting the loop. Add it to the callback signature if needed
- fixed bug in ListHandlers()
v2.1 (2.2 KB) 2024-07-15 Added WatchDir() and WatchTab() for convenience
v2.0 (2.1 KB) 2024-07-13 Inital Version of the include script
Testscript (4.4 KB) 2024-07-17 Testcommand package to test the wrapper. Not regularily updated!

MsgLoopHandler is a wrapper class in an include script to handle the evaluation of the DOpus message loop to make coding custom dialogs easier and to produce better readable code.
You simply have to register an event handler for a specified control and a certain event. It also allows a catchall handler for any event fired by a certain control. The event handler then gets called with a reference to the current msg object for further processing.
This allows a simpler and easier to read and maintainable control flow than evaluating the message loop with a branching switch / if else setup.

Usage
Add this to the beginning of your script (only in DOpus 13, for older versions you need to copy the MsgLoopHandler class to every script where you need it).
@include inc_MsgLoopHandler.js

    //Setup your dialog
    var dlg = scriptCmdData.func.Dlg;
    dlg.window = source;
    dlg.template = "Dialog";
    dlg.title = "MsgLoopHandlerTest";
    dlg.want_resize = true; //do that when you want to register resize events
    dlg.detach = true;
    dlg.Create();

   	var loopContext = { myValue : 1 };

							//MsgLoopHandler(Dialog, DialogClosedCallback, LoopStepCallback, DebugLog, ListHandlersOnStart) 
	var msgLoopHandler = new MsgLoopHandler(dlg, loopContext, OnDialogClosed, null, true, true);

	msgLoopHandler.AddEventHandler("button1", "click", Button1Click);

	//Add eventhandler for check1 click event as anonymous function
	msgLoopHandler.AddEventHandler("check1", "click", function(dialog, msg){DOpus.Output("checkbox1 was clicked");});

	//Add eventhandler for listview1 selection changed event as Function Pointer (defining the function somewhere else)
	msgLoopHandler.AddEventHandler("listview1", "selchange", EventHandler);

	//Adding a catchall eventhandler for combo1, being called for any event of this control
	msgLoopHandler.AddEventHandler("combo1", "*", CatchAll);

        //add one eventhandler for one event for multiple controls at once
    msgLoopHandler.AddEventHandler(["button1", "check1", "listview1"], "click", MultiHandler);

	/*
	//Setting up a timer
	//Instead of this
	dlg.SetTimer(5000, "timer1");
	msgLoopHandler.AddEventHandler("timer1", "timer", Timer1Tick);//Event always has to be "timer"
	//do that
	*/
	msgLoopHandler.SetTimer(5000, "timer1", Timer1Tick);


	//Run the loop and react to events
	msgLoopHandler.Loop();
}

//add loopContext to your signature if you need to access it
function Button1Click(dialog, msg, loopContext)
{
	DOpus.Output("myValue = " + loopContext.myValue);
	loopContext.myValue *= 2;
	dialog.control("static1").Title = loopContext.myValue;
}

function EventHandler(dialog, msg)
{
	DOpus.Output("another eventhandler");
}

function Timer1Tick(dialog, msg)
{
	DOpus.Output("Timer ticked");
}

function CatchAll(dialog, msg)
{
	DOpus.Output("Catchall handler, event = " + msg.event);
}

function OnDialogClosed(dlg)
{
	DOpus.Output("The dialog '" + dlg.title + "' has been closed.");
}

inc_MsgLoopHandler.js Code

// MsgLoopHandler
// (c) 2024 Felix.Froemel

// This is an include file script for Directory Opus.
// See https://www.gpsoft.com.au/endpoints/redirect.php?page=scripts for development information.

// Called by Directory Opus to initialize the include file script
function OnInitIncludeFile(initData)
{
	var versionDate = "2024-07-24";
	initData.version = "2.32";
	initData.name = "MessageLoopHandler";
	initData.desc = "Wrapper class to handle the DOpus Message Loop in event driven way. v" + initData.version + " ("+ versionDate  + ")";
	initData.copyright = "(c) 2024 Felix.Froemel";
	initData.url = "https://resource.dopus.com/t/msgloophandler-an-event-driven-wrapper-for-the-dopus-message-loop-as-include-script/51781";
	initData.min_version = "13.0";
	initData.shared = true;
}

//Wrapper class to handle the DOpus Message Loop in an event driven way.
//Call with the dialog, and optional callback for each loop step, debbugging, and listing handlers on start
function MsgLoopHandler(dlg, loopContext, dialogClosedCallback, loopStepCallback, debugLog, listHandlersOnStart) 
{
    this.Dialog = dlg;
	this.EventHandlers = [];
	this.LoopContext = loopContext || { };
	this.DialogClosedCallback = dialogClosedCallback || function(dlg) { };
    this.LoopStepCallback = loopStepCallback || function (msg) { };
    this.ListHandlersOnStart = listHandlersOnStart || false;
    this.DebugLog = debugLog || false;

	//Run the Messageloop, wait for Messageloop result
	this.Loop = function() 
	{
        if (this.ListHandlersOnStart)
            this.ListHandlers();

        var msg;
        do 
		{
            msg = this.Dialog.GetMsg();

            if (!msg.result)
			{
				if (this.DebugLog)
					this.Log("Msgloop: the dialog '" + this.Dialog.title + "' has been closed.");

				this.DialogClosedCallback(this.Dialog);
				break;
			}

            if (this.DebugLog)
			{
				var evnt =  msg.event || "no event";
				var val = String(msg.value || "no value");
				var ctrl = msg.control || "no control";
				var logMsg = "Msgloop: Event = " + evnt + "\tValue = " + val + "\tControl = " + ctrl;
                this.Log(logMsg);
			}

			//Call the actual event processing
            this.LoopStep(msg);
        }
        while (msg);
    };

	//Actual Messageloop event processing
	this.LoopStep = function (dlgMsg) 
	{
	    var controlName = dlgMsg.control;
        var event = dlgMsg.event;

        var eventHandler = this.EventHandlers[controlName];
        if (eventHandler) 
		{
        	var eventCallback = eventHandler[event];
            if (eventCallback)
				eventCallback(this.Dialog, dlgMsg, this.LoopContext);
            else //check if catchall event handler is present
			{
           		var catchAllEventHandler = eventHandler["*"];
                if (catchAllEventHandler)
					catchAllEventHandler(this.Dialog, dlgMsg, this.LoopContext);
            }
        }
		
        this.LoopStepCallback(dlgMsg);
    };

    //Add event handler callback for events
	//Checks the type of eventSenderName for either being string or array to allow adding multiple controls with the same handler as array
    //Callback Signature:
    //EventHandler(dialog, msg) or
	//EventHandler(dialog, msg, loopContext)
    //set eventtype to "*" for catching all events of this control
	/*
		Structure of EventHandlerDict is controlnames are keys to a list of mappings eventname -> eventhandler
		[
			ControlA :	[Event1, EventHandler1],
						[Event2, EventHandler2]
			ControlB : 	[Event3, EventHandler3]
			...
		]
	*/
	this.AddEventHandler = function(eventSenderName, eventType, eventCallback)
	{
		if(typeof eventSenderName === "string")
			this.AddEventHandlerInternal(eventSenderName, eventType, eventCallback);
		else if(eventSenderName instanceof Array) //adding multiple controls with the same event handler
		{
			for(var senderIndex in eventSenderName)
				this.AddEventHandlerInternal(eventSenderName[senderIndex], eventType, eventCallback);
		}
	};

	//Used for actually adding the handler
	this.AddEventHandlerInternal = function(eventSenderName, eventType, eventCallback)
	{
		if (eventSenderName in this.EventHandlers) //add to already existing entry
		{
		    var handler = this.EventHandlers[eventSenderName];
	        if (!(eventType in handler))//just add once
	            handler[eventType] = eventCallback;
			else
				this.Log("Already registered event '" + eventType + "' for sender '" + eventSenderName + "'. Skipping", true);
		}
	    else //create new element in list for eventSender and register 
		{
	    	var handler = {};
	        handler[eventType] = eventCallback;
	        this.EventHandlers[eventSenderName] = handler;
		}
	};

	//Set a timer in the dialog and add to eventhandlers
	this.SetTimer = function (timeoutMs, name, elapsedCallback) 
	{
	 	this.AddEventHandler(name, "timer", elapsedCallback);
        this.Dialog.SetTimer(timeoutMs, name);
    };

	//Watch directory for changes
	this.WatchDir = function(id, path, flags, eventCallback)
	{
		this.AddEventHandler(id, "dirchange", eventCallback);
		this.Dialog.WatchDir(id, path, flags);
	};

	//Watch tab for changes
	this.WatchTab = function(tab, events, id, eventCallback)
	{
		this.AddEventHandler(id, "tab", eventCallback);
		this.Dialog.WatchTab(tab, events, id);
	};

	//List all the handlers that are registered in the scriptlog
    this.ListHandlers = function () 
	{
        this.Log(this.DictionaryLength(this.EventHandlers) + " registered Event Handlers");
        this.PrintDict(this.EventHandlers); 
    };

	//Get element count in dictionary
	this.DictionaryLength = function(dict)
	{
		var length = 0;
		for(var keys in dict)
			length++;
		return length;
	};

	//Print all keys/dubkeys from dictionary to script log
	this.PrintDict = function (dict) 
	{
    	for (var key in dict)
			for (var subKey in dict[key])
				this.Log("\t" + key + " => " + subKey);
    };
	
	//Log events
	this.Log = function(msg, e) {
		DOpus.Output(String(msg), e || false);
	};
}


Background
I really enjoy the powerfully possibility of being able create custom dialogs for script addins/buttons. But having more complex dialogs leaded to horrible to read and maintain if else code. So I started this code some years ago in DOpus 12. I haven't had the time in the past years and wasn't very active in the forum in this time. But upgrading to v13 some months ago and finding the time scratch the surface I was very happy of the new ability of finally being able to include scripts in other scripts. I had different versions of this wrapper in many different scripts and buttons in different versions and thus it was hard to maintain and to continue coding. So now I finally had the time and success finalizing the code as an include script. I hope you enjoy using this piece of code, maybe even updating your old scripts.
I tried to keep it as simple as possible and removed a lot of code of older versions. But I didn't test every case so please let me know if you find any bug or have an idea how to improve.

Old way to achieve the same as the code above

    var msg;
    do 
    {
    	msg = dlg.GetMsg();

        if (!msg.result)
        {
              DOpus.Output("Dialog closed");
              break;
         }

		var ctrl = msg.control;
		var evnt = msg.event;

		if(control == "button1")
		{
			if(evnt = "click")
			{
				//...
			}
			//else if other event
		}
		else if(ctrl == "timer1" && evnt == "timer")
		{
			//...
		}
		else if(ctrl == "listview1")
		{
			if(evnt == "selchange")
			{
				//...
			}
		}
     }
     while (msg);
7 Likes

Wow, that looks extremely useful. I just gave it a quick spin and came up with the following comments:

  • There's a bug in line 143: controlHandlersLen should be eventHandlersLen

  • What is the difference between doing something in the DialogClosedCallback() and doing the same things after msgLoopHandler.Loop() has finished?

  • Could you allow an array of controls to be passed to the event handler? (One of my current projects has a lot of buttons which all use the same click handler; the only difference is an id I read from the button's name.) Of course I can add multiple handlers, but since you're providing a wrapper... :wink:

Thanks a lot for this script!!

1 Like

This could be done with a quick loop through your controls to add the same event handler, event if what you suggest would be even easier.

@Felix.Froemel : Very nice idea !!

I think a nice addition would be to allow for an extra parameter in the event handler signature in order to pass an object (unknown type, or more exactly type known by the coder), either on an eventhandler basis (harder) or at least for all the event handlers (e.g. attached to the whole MsgLoopHandler).
Why that ?
It is frequent that UI is designed to interact with some kind of objects behind the interface itself. Sometimes, you only need to manage interactions between controls (e.g. some checkbox enabling or disabling other controls), but more often than not, if you're in detached mode, that means your UI is interacting with at least some variables in the code where the message loop is.
As designed, your event handlers can only do things with the name of the control and some of the values associated (everything included in the msg object), nothing more.
That means : accessing other controls of the dialog or setting values on variables that are to be managed/used after the dialog is closed or during its lifetime is not possible (unless you use global variables ... which I would not recommand :slight_smile:).
One quick way I see is to add an "object" (or "tag" if you refer to what is done in C#/.NET Forms controls) property to the MsgLoopHandler and to pass it as a parameter to every Event Handler function call.
That way, one would have to design some kind of small object referencing for instance : the main dialog (to interact with other controls) and/or some variables so they can live and be modified/read by each event handler and/or other objects (if what you are "editing" is more complex), attach it to the MsgLoopHandler and access it (read/write) from each handler.

1 Like

Yes, of course it could... :wink:

Perhaps I'm misunderstanding the issue, but... In the callback functions you pass to event handlers, all your local variables are accessible. (Exception: Dealing with "this" is more complicated.) So you can always do stuff like dlg.title = "new title"; and access the dialog's controls - or any other variable. (I just tried; it works as expected.)
Or do you mean something different? Do you have an example of something that doesn't work?

Context and scope are weird in JavaScript...

I don't think so, event handlers are functions (unless the variables are global)... I will test :slight_smile:

EDIT :

The dialog is in the EventHandler signature, so no problem to use it and access other controls : OK.
But accessing variables that are in the event loop scope looks unnatural to me. But I am no expert in JScript neither, so this could be a surprise to me :slight_smile: .
As said, I will test and report back.
Thanks for taking the time to answer.

OK, just tested :

What works
If you add your event handler "inline" (as an anonymous function) in the scope of the dialog creation (see below) you can access local variables :

	var myValue = 1;
	var msgLoopHandler = new MsgLoopHandler(dlg, null, null, true, false);
	msgLoopHandler.AddEventHandler("button1", "click", function(dialog, msg) {
		DOpus.Output("button1 was clicked");
		DOpus.Output("myValue = " + myValue);
		myValue *= 2;
		dialog.control("sValue").Title = myValue;
	});

What does not work
If you add your event handler as a function name (function pointer) that is declared/coded out of this scope (as the second example given in the first post : msgLoopHandler.AddEventHandler("listview1", "selchange", EventHandler);), you can not access those local variables (code below leads to an error at exec time on the line accessing the local variable : DOpus.Output("myValue = " + myValue); when you hit button2) :

	var myValue = 1;
	var msgLoopHandler = new MsgLoopHandler(dlg, null, null, true, false);

	msgLoopHandler.AddEventHandler("button2", "click", HandlerButton2_click);
	msgLoopHandler.Loop();
}

function HandlerButton2_click(dialog, msg) {
	DOpus.Output("button2 was clicked");
	DOpus.Output("myValue = " + myValue);
	myValue *= 3;
	dialog.control("sValue").Title = myValue;
}

My point of view
I'd rather have named functions, defined outside of the main loop code part because it is more reusable (same function for different controls, as in your request) and because it forces you to properly define everything you need to access/modify so that it appears clearly in the function parameters/signature (e.g. no global variable involved in what should be a quite local action).

When coding UI parts of scripts, I try to keep the parts in the message loop as small as possible through calling outside functions that are only given the parameters they need to access (e.g. read or write). Code is then more concise, more readable (function naming can help) and easier to debug (I can see quickly if one variable has a chance to be modified by some action in the UI). Moreover, it is frequent that in the many actions a UI offers, some parts of the those actions are common/can be factorised in subfunctions.

All in all, I understand it can be a matter of preference, yet in other languages, event handlers which are defined somewhere near the way this script proposes to do, are having this limitations that, in the end, help you write better code (no global vars, separation of concerns, ...).
In that case, as this include script is built for now, this limits a bit too much what can be done in an outside/named function.

Final addition : after this, I tried declaring the named function in the scope of the main loop. It works, so helps reusability, but fails to protect from modifying variables clearly.

--
For reference if you want to reproduce, full example code :

	var dlg = scriptCmdData.func.Dlg;
    dlg.window = scriptCmdData.func.sourcetab;
    dlg.template = "mydlg";
    dlg.title = "MsgLoopHandlerTest";
    dlg.want_resize = true;
    dlg.detach = true;
    dlg.Create();

	var myValue = 1;
	var msgLoopHandler = new MsgLoopHandler(dlg, null, null, true, false);

	//Add eventhandler for button1 click event as anonymous function
	msgLoopHandler.AddEventHandler("button1", "click", function(dialog, msg) {
		DOpus.Output("button1 was clicked");
		DOpus.Output("myValue = " + myValue);
		myValue *= 2;
		dialog.control("sValue").Title = myValue;
	});

	msgLoopHandler.AddEventHandler("button2", "click", HandlerButton2_click);

	msgLoopHandler.Loop();
}

function HandlerButton2_click(dialog, msg) {
	DOpus.Output("button2 was clicked");
	DOpus.Output("myValue = " + myValue);
	myValue *= 3;
	dialog.control("sValue").Title = myValue;
}

Plus resources :

==SCRIPT RESOURCES
<resources>
	<resource name="mydlg" type="dialog">
		<dialog fontsize="9" height="114" lang="francais" width="138">
			<control height="14" name="button1" title="X2" type="button" width="50" x="44" y="36" />
			<control height="14" name="button2" title="X3" type="button" width="50" x="44" y="60" />
			<control height="14" name="button3" title="X5" type="button" width="50" x="44" y="84" />
			<control halign="left" height="8" name="static1" title="Value :" type="static" valign="top" width="30" x="36" y="12" />
			<control halign="center" height="8" name="sValue" title="1" type="static" valign="top" width="42" x="72" y="12" />
		</dialog>
	</resource>
</resources>

Which look like this :
image

You could have a standalone function which takes any extra arguments you need, then have the event call a simple one-line inline function that calls it and passes the extra arguments.

That way also means you aren’t limited to a single extra argument, which can be useful/cleaner sometimes.

1 Like

Nice way to do this, yes.

First of all, thanks guys, you're welcome :wink:

Thanks, that is what happens when you edit the code between testing and releasing :sweat_smile: fixed in v2.2

You are absolutely right, there is no difference. Yet I wanted the whole thing to be event driven / with callbacks and so you can write that code as far as possible away from the loop.

But wouldnt you then have the same handler for all your controls and ending up with the same if-else/switch structure in the callback to determine which control caused the event that this wrapper should prevent?
I instead would prefer something like

var controlCallbackMapping = 
{
	"button1" : handler1,
	"button2" : handler2,
	...
};

for (var control in controlCallbackMapping)
{
	var callback = controlCallbackMapping[control];
	msgLoopHandler.AddEventHandler(control, "click", callback);
}

or did you had something like that in your mind?

msgLoopHandler.AddMultipleEventHandlers(controlCallbackMapping, "click");

I introduced LoopContext in v2.2 for that. Its a dictionary / object that will be passed to any eventhandler.

I did that in earlier stages of development of this script but ended up mostly with messy code/long signatures (eg i passed control variables obtained by dlg.control() to that function first, then moved them to the signature but in v2.0 i removed all of that ot be as clean and minimal as possible). Yet this approach will work though.

1 Like

Oh yeah, definitely. I didn't mean that it should be mandatory. Just that it's a nice way to pass those things in if you need them, using the existing include-script without having to change it, while keeping the user-code that adds the events fairly clean.

Not necessarily. I have a use case where control naming can be converted (using regexp in my case) to some key entry in a dictionnary that provides the final object to take action on.
Something like :
Button names :

btActOnObjectA
btActOnObjectB
...
btActOnObjectZ

And a dictionnary dict of objects where

dict("A") = objA
...
dict("Z") = objZ

In the callback, you can easily extract A, B, ..., Z from the control name and then have something like dict(extractedLetter).TakeSomeAction();
The code of the callback would then be very concise, no else/switch, ... and very understandable.

Thanks for the update, @Felix.Froemel!

Yes, @PassThePeas, you hit the nail on the head: That's exactly what my dialog does (well, I use numbers to index an array). And I have another dialog with several pairs of labels, each an image and a text which do exactly the same thing when clicked - so I have to assign the same handler twice.

But it's no big deal; what I requested would just slightly clean up my own code by moving all the hard work :wink: to the included script.

I'll get to work integrating the MsgLoopHandler into my project now... :+1:

It's probably not too critical at this point but... if your update includes a breaking change (the insertion of the loopContext parameter into the constructor) a warning in the change log might be a good idea. :wink:

Thanks, good point. I just changed the history and changes into a table and added that hint.
For me it was obvious for me because i sat on it the whole time and changing the code but I i agree that it wont be obvious after simply replacing the old version and suddenly all scripts crashing. Sorry for that.

...and you added support for an array of controls as well - thank you! :slight_smile:
And your test command is great as a reference manual of sorts.

I hope you're not going to block me now :wink: because I have something else to critcize: In lines 111 and 167 the loop variables aren't declared. You probably know this, but just in case: That will create a global variable which will then be visible to all scripts that include your code and might have rather unexpected side effects. I've had to learn this the hard way: Multiple times I spent (what felt like) hours trying to debug totally inexplicable situations where my variables "key" or "n" weren't properly declared...

I tested it and strangely only "senderIndex" was visible in my code, while "keys" wasn't. :man_shrugging:

Anyway, I think I will use your MsgLoopHandler for all future dialogs and perhaps even re-work some existing code. It really is a lot cleaner!

111 for(senderIndex in eventSenderName)
167 for(keys in dict)

Do you mean loopcontext? No need to declare it there since it is declared as variable of msglooper and initialzed once either from arguments or as empty dictionary. It then will be passed from inside LoopStep() to the eventhandler. Or did i miss something/missunderstood you?

I dont think that the scripts can directly communicate with each other simply via variables declared in one script. But I agree that loopContext is visible for every eventhandler of the same msgloophandler instance but that is on purpose. Having callback only variables would be going the way Leo talked about like

var test = 0;
msgLoopHandler.AddEventHandler("check1", "click", function(dialog, msg){CallOtherFunction(dialog, msg, test);});

I did so in earlier versions of development and it worked for me (i referenced initialized controls this way).

Thanks for pointing out two more bugs :smiley: Not sure why it worked though :person_shrugging: Fixed in v2.31

I feel you, debugging in DOpus can be... intense :smiley: JScript is weird sometimes :smiley: I was close to throwing that script because the error message made no sense at all for me (i forgot to write keyword 'function') :see_no_evil:

You're welcome :wink: I never thought about this being helpful/necessary but since you guys asked for it and thus it seems to be actually helpful , why not?

That makes me happy to know that its actually used somewhere else than my code (after years) and it helps :slight_smile: Feel free to let me know :wink:

No, why should I? I asked for feedback and bugreports :wink:

1 Like

I meant senderIndex and keys, both of which don't seem to be declared anywhere. I said "loop" because they both are the loop variables for your "for" loops.

I just modified my test. Using your OnMsgLoopHandlerTest() function, I added this at the very end (after the loop, ie. after all your code has run):

msgLoopHandler.Loop();
DOpus.Output("senderIndex: " + senderIndex);
DOpus.Output("keys: " + keys);

Both variables should be unknown, but after running the test the output is "2" and "listview1"...

Then I added "var" in front of both variables in your code and - surprise! - in the test both variables are now undefined, as they should be. :slight_smile:

:rofl:

Yeah i simply didnt write var and DOpus didnt complain. Maybe i should code in IDEs with auto complete or so :man_shrugging:

The beauty of Javascript...