[Concept] Platform for Interative AHK Tutorials

Post your working scripts, libraries and tools for AHK v1.1 and older
geek
Posts: 1052
Joined: 02 Oct 2013, 22:13
Location: GeekDude
Contact:

[Concept] Platform for Interative AHK Tutorials

20 Jan 2019, 12:10

I've been working on this script concept for a while now and, while it's not anywhere near ready for a full release, I have decided that I need some feedback on its operations.

If anyone could give it a try and give some constructive criticisms and suggestions for improvement that would be fantastic! Please note that I am looking for commentary on the technical aspects, not necessarily the tutorial content itself.

To run this code you will need Lexikos's DBGp library in your library folder.

Code: Select all

#SingleInstance force
#NoEnv
SetBatchLines, -1

global debugUIs := {}

; Settings
Book.Title := "Interactive AHK Tutorials"
Book.AcePath := "https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.2/ace.js"
page := "https://p.ahkscript.org/book/index.html"
startPort := 9000

; Start debugger
DBGp_OnBegin("DebugBegin")
DBGp_OnBreak("DebugBreak")
DBGp_OnEnd("DebugEnd")
while (!StartListening(, startPort))
{
	if (ErrorLevel == "WSAE:10048") ; WSAEADDRINUSE
		startPort++
	else
		throw Exception("Failed to start debugger",, ErrorLevel)
}

; Create a book instance
(new Book(page, startPort)).AfterClose := Func("CleanupAndExit")
return

CleanupAndExit(book)
{
	for i, debugUI in book.debugUIs
		debugUI.Close()
	ExitApp
}

DebugBegin(dbg)
{
	global debugUIs
	debugUIs[StrSplit(dbg.File, "_").Pop()].DebugBegin(dbg)
}

DebugBreak(dbg, ByRef response)
{
	global debugUIs
	debugUIs[StrSplit(dbg.File, "_").Pop()].DebugBreak(dbg, response)
}

DebugEnd(dbg)
{
	global debugUIs
	debugUIs[StrSplit(dbg.File, "_").Pop()].DebugEnd(dbg)
}

; Start listening for debugger connections. Must be called before any debugger may connect.
StartListening(localAddress="127.0.0.1", localPort=9000)
{
	static AF_INET:=2, SOCK_STREAM:=1, IPPROTO_TCP:=6
	, FD_ACCEPT:=8, FD_READ:=1, FD_CLOSE:=0x20
	static wsaData := ""
	if !VarSetCapacity(wsaData)
	{   ; Initialize Winsock to version 2.2.
		VarSetCapacity(wsaData, 402)
		wsaError := DllCall("ws2_32\WSAStartup", "ushort", 0x202, "ptr", &wsaData)
		if wsaError
			return DBGp_WSAE(wsaError)
	}
	; Create socket to be used to listen for connections.
	s := DllCall("ws2_32\socket", "int", AF_INET, "int", SOCK_STREAM, "int", IPPROTO_TCP, "ptr")
	if s = -1
		return DBGp_WSAE()
	; Bind to specific local interface, or any/all.
	VarSetCapacity(sockaddr_in, 16, 0)
	NumPut(AF_INET, sockaddr_in, 0, "ushort")
	NumPut(DllCall("ws2_32\htons", "ushort", localPort, "ushort"), sockaddr_in, 2, "ushort")
	NumPut(DllCall("ws2_32\inet_addr", "astr", localAddress), sockaddr_in, 4)
	if DllCall("ws2_32\bind", "ptr", s, "ptr", &sockaddr_in, "int", 16) = 0 ; no error
		; Request window message-based notification of network events.
	&& DllCall("ws2_32\WSAAsyncSelect", "ptr", s, "ptr", DBGp_hwnd(), "uint", 0x8000, "int", FD_ACCEPT|FD_READ|FD_CLOSE) = 0 ; no error
	&& DllCall("ws2_32\listen", "ptr", s, "int", 4) = 0 ; no error
	return s
	; An error occurred.
	n := DllCall("ws2_32\WSAGetLastError")
	DllCall("ws2_32\closesocket", "ptr", s)
	return DBGp_WSAE(n)
}

class Book
{
	static Title := "AHK Book"
	static AcePath := "../ace/ace.js"
	
	__New(URL, port:=9000)
	{
		this.debugUIs := []
		this.port := port
		
		; BoundFuncs with circular references
		this.bound := {"KeyDown": this.KeyDown.Bind(this)}
		
		; Handle KeyDown messages for ActiveX workarounds
		OnMessage(0x100, this.bound.KeyDown, 2)
		
		; Create a GUI
		Gui, New, +hWndhWnd +Resize
		this.hWnd := hWnd
		Gui, Margin, 0, 0
		WinEvents.Register(this.hWnd, this)
		
		; Add the ActiveX control
		Gui, Add, ActiveX, w1024 h768 hWndhWB, Shell.Explorer
		this.hWB := hWB
		
		; Retrieve the web browser object
		GuiControlGet, wb,, %hWB%
		this.wb := wb
		
		; Connect the web browser's event stream to a new event handler object
		ComObjConnect(this.wb, new this.WBEvents(this))
		
		; Navigate the browser to the provided page
		; TODO: before or after showing the GUI?
		wb.Navigate(URL)
		
		; Show the GUI
		Gui, Show,, % this.Title
	}
	
	AttachDocument()
	{
		; Inject styles
		; TODO: inject a full stylesheet
		style := this.wb.document.createElement("style")
		style.innerText := ".ace_gutter-cell.ace_breakpoint {"
		.   "border-radius: 20px 0px 0px 20px;"
		.   "box-shadow: 0px 0px 1px 1px blue inset;"
		. "}"
		this.wb.document.head.appendChild(style)
		
		; Inject Ace editor with callback
		scr := this.wb.document.createElement("script")
		scr.src := this.AcePath
		scr.onload := this.AceLoaded.Bind(this)
		this.wb.document.head.appendChild(scr)
	}
	
	AceLoaded()
	{
		; Replace code boxes with Ace editors/debugger UIs
		boxes := this.wb.document.querySelectorAll(".ahk")
		loop, % boxes.length
			this.debugUIs.Push(new this.DebugUI(this, boxes[A_Index - 1]))
	}
	
	DetachDocument()
	{
		for i, debugUI in this.debugUIs
			debugUI.Close()
		this.debugUIs := []
	}
	
	KeyDown(wParam, lParam, nMsg, hWnd) {
		if (Chr(wParam) ~= "[OLN]" || wParam = 0x74) ; Disable Ctrl+O/L/N and F5.
			return
		Gui +OwnDialogs ; For threadless callbacks which interrupt this.
		pipa := ComObjQuery(this.wb, "{00000117-0000-0000-C000-000000000046}")
		VarSetCapacity(kMsg, 48), NumPut(A_GuiY, NumPut(A_GuiX
		, NumPut(A_EventInfo, NumPut(lParam, NumPut(wParam
		, NumPut(nMsg, NumPut(hWnd, kMsg)))), "uint"), "int"), "int")
		r := DllCall(NumGet(NumGet(1*pipa)+5*A_PtrSize), "ptr", pipa, "ptr", &kMsg)
		ObjRelease(pipa)
		if r = 0 ; S_OK: the message was translated to an accelerator.
			return 0
	}
	
	; --- Window Event Handlers ---
	
	GuiSize(hWnd, eventInfo, width, height)
	{
		GuiControl, Move, % this.hWB, x0 y0 w%width% h%height%
	}
	
	GuiClose()
	{
		ComObjConnect(this.wb)               ; Disconnect ActiveX event stream
		WinEvents.Unregister(this.hWnd)      ; Disconnect GUI event stream
		OnMessage(0x100, this.bound.KeyDown) ; Disconnect message handler
		this.Delete("bound")                 ; Break BoundFunc circular references
		
		Gui, Destroy
		
		; Call post-close handler if provided
		this.AfterClose()
	}
	
	class WBEvents
	{
		__New(parent)
		{
			this.parent := parent
		}
		
		BeforeNavigate2()
		{
			this.parent.DetachDocument()
		}
		
		DocumentComplete()
		{
			this.parent.AttachDocument()
		}
		
		__Delete()
		{
			this.Delete("parent")
		}
	}
	class DebugUI
	{
		__New(parent, targetElement)
		{
			this.id := &this
			this.port := parent.port
			
			doc := targetElement.document
			opts := targetElement.classList
			this.readOnly := opts.contains("readonly")
			
			this.container := doc.createElement("div")
			this.container.style.cssText := "padding: 0 0; margin: 1em 0;"
			
			; Add buttons
			if (opts.contains("run"))
			{
				buttons := doc.createElement("div")
				buttons.style.marginBottom := "0.25em"
				
				; Add run button
				buttons.appendChild(this.CreateButton("run", "Run", this.Run))
				
				; Add step button
				if (opts.contains("step"))
					buttons.appendChild(this.CreateButton("step disabled", "Step", this.Step))
				
				; Add continue button
				if (opts.contains("continue"))
					buttons.appendChild(this.CreateButton("continue disabled", "Continue Until End", this.Continue))
				
				; Always add a stop button
				buttons.appendChild(this.CreateButton("stop disabled", "Stop", this.Stop))
				
				; Add them to the page element
				this.container.appendChild(buttons)
			}
			
			; Side-by-side columns for variables and code
			cols := doc.createElement("div")
			cols.style.cssText := "padding: 0; display: flex; flex-direction: row;"
			
			if (opts.contains("vars"))
			{
				vars := doc.createElement("pre")
				vars.className := "vars"
				vars.style.cssText := "margin: 0; padding: 0 0.5em; font-size: 10pt; line-height: 1.2em;"
				vars.innerText := "Variables:"
				cols.appendChild(vars)
			}
			
			; Create a new box for the code
			box := doc.createElement("div")
			box.id := "code" this.id
			box.style.cssText := "margin: 0 0.25em; flex-grow: 1;"
			box.className := "ahk-code"
			
			; Load the elements onto the page
			cols.appendChild(box)
			this.container.appendChild(cols)
			targetElement.parentNode.replaceChild(this.container, targetElement)
			
			; Load the Ace editor
			this.Eval("(function(id, text, ro){"
			.   "document.getElementById('code' + id).style.fontSize='10pt';"
			.   "let e = ace.edit('code' + id);"
			.   "e.setOptions({maxLines: 20});"
			.   "e.session.setMode('ace/mode/autohotkey');"
			.   "e.setHighlightActiveLine(false);"
			.   "e.setReadOnly(ro);"
			.   "e.setValue(text, 1);"
			.   "window['ace' + id] = e;"
			. "})").call(, this.id, targetElement.innerText, !opts.contains("edit"))
		}
		
		CreateButton(class, text, onclick)
		{
			btn := this.container.document.createElement("button")
			btn.className := class
			btn.innerText := text
			btn.onclick := onclick.bind(this)
			if (btn.classList.contains("disabled"))
				btn.disabled := True
			return btn
		}
		
		Eval(js)
		{
			return this.container.document.parentWindow.eval(js)
		}
		
		Close()
		{
			this.debugSession.stop()
			this.exec.Terminate()
		}
		
		; --- Button Event Handlers ---
		
		Run()
		{
			global debugUIs
			debugUIs[this.id] := this
			
			this.container.querySelector(".run").disabled := True
			this.code := this.Eval("ace" this.id ".getValue();")
			
			this.exec := ExecScript(this.code,, A_AhkPath, this.id, this.port)
		}
		
		Step()
		{
			this.debugSession.step_into()
		}
		
		Continue()
		{
			try
				this.Eval("ace" this.id ".session.clearBreakpoints()")
			this.debugSession.run()
		}
		
		Stop()
		{
			this.debugSession.stop()
		}
		
		; --- Debugger Event Handlers ---
		
		DebugBegin(dbg)
		{
			this.debugSession := dbg
			
			; Disable enable buttons
			buttons := this.container.querySelectorAll("button:not(.disabled)")
			loop, % buttons.length
				buttons[A_Index - 1].disabled := True
			
			; Enable disable buttons
			buttons := this.container.querySelectorAll("button.disabled")
			loop, % buttons.length
				buttons[A_Index - 1].disabled := False
			
			; Set the editor read-only
			this.Eval("ace" this.id ".setReadOnly(true);")
			
			; Run or step depending on presence of step button
			if (this.container.querySelector(".step"))
				dbg.step_into()
			else
				dbg.run()
		}
		
		DebugBreak(dbg, ByRef response)
		{
			if (!InStr(response, "status=""break"""))
				return
			
			; Find the active line
			dbg.stack_get("-d 0", response)
			RegExMatch(response, "lineno=""\K\d+", lineno)
			
			; Skip over the "line" at the end of the script
			if (lineno == StrSplit(this.code, "`n", "`r").Length() + 1)
			{
				this.UpdateVars() ; TODO: Should I just do this before finding the line?
				dbg.step_into()
				return
			}
			
			; Highlight the active line
			this.Eval("(function(id, l){"
			.   "let e = window['ace' + id];"
			.   "e.session.clearBreakpoints();"
			.   "e.session.setBreakpoint(l);"
			.   "e.scrollToLine(l, true);"
			. "})").call(, this.id, lineno - 1)
			
			this.UpdateVars()
		}
		
		UpdateVars()
		{
			doc := ComObjCreate("MSXML2.DOMDocument")
			doc.async := False
			doc.setProperty("SelectionLanguage", "XPath")
			
			text := "Variables:"
			work := []
			
			; Query the debugger for variables
			for i, cid in [0, 1]
			{
				this.debugSession.context_get("-c " cid, response)
				doc.loadXML(response)
				nodes := doc.selectNodes("/response/property")
				loop, % nodes.length
					work.Push(nodes.item[nodes.length - A_Index])
			}
			
			; Process that data
			while (node := work.Pop())
			{
				; Retrieve relevant properties
				name := node.getAttribute("fullname")
				type := node.getAttribute("type")
				class := node.getAttribute("classname")
				facet := node.getAttribute("facet")
				
				; Ignore certain types
				if (name ~= "AD)Clipboard|A_Args|0|ErrorLevel|this"
					|| facet == "Builtin"
					|| (type == "object" && class == "Class"))
					continue
				
				text .= "`n" name ": "
				if (type = "undefined")
					text .= "N/A"
				else if (type = "object")
				{
					text .= class "()" ; node.getAttribute("address")
					loop, % node.childNodes.length
						work.Push(node.childNodes[node.childNodes.length - A_Index])
				}
				else
				{
					value := DBGp_Base64UTF8Decode(node.text)
					text .= StrLen(value) == node.getAttribute("size") ? value : value "..."
				}
			}
			
			this.container.querySelector(".vars").innerText := text
		}
		
		DebugEnd(dbg)
		{
			global debugUIs
			debugUIs.Delete(this.id)
			
			try
			{
				; Disable disable buttons
				buttons := this.container.querySelectorAll("button:not(.disabled)")
				loop, % buttons.length
					buttons[A_Index - 1].disabled := False
				
				; Enable enable buttons
				buttons := this.container.querySelectorAll("button.disabled")
				loop, % buttons.length
					buttons[A_Index - 1].disabled := True
				
				; Restore editor
				this.Eval("ace" this.id ".session.clearBreakpoints();"
				. "ace" this.id ".setReadOnly(" this.readOnly ");")
			}
		}
	}
}
; Modified from https://github.com/cocobelgica/AutoHotkey-Util/blob/master/ExecScript.ahk
ExecScript(Script, Params="", AhkPath="", Name="", Port="")
{
	static Shell := ComObjCreate("WScript.Shell")
	Name := "\\.\pipe\AHK_DBG_" DllCall("GetCurrentProcessId") "_" (Name ? Name : A_TickCount)
	Pipe := []
	Loop, 3
	{
		Pipe[A_Index] := DllCall("CreateNamedPipe"
		, "Str", Name
		, "UInt", 2, "UInt", 0
		, "UInt", 255, "UInt", 0
		, "UInt", 0, "UPtr", 0
		, "UPtr", 0, "UPtr")
	}
	if !FileExist(AhkPath)
		throw Exception("AutoHotkey runtime not found: " AhkPath)
	if (A_IsCompiled && AhkPath == A_ScriptFullPath)
		AhkPath .= " /E"
	if FileExist(Name)
	{
		Exec := Shell.Exec(AhkPath " /Debug=localhost:" Port " /CP65001 " Name " " Params)
		DllCall("ConnectNamedPipe", "UPtr", Pipe[2], "UPtr", 0)
		DllCall("ConnectNamedPipe", "UPtr", Pipe[3], "UPtr", 0)
		FileOpen(Pipe[3], "h", "UTF-8").Write(Script)
	}
	Loop, 3
		DllCall("CloseHandle", "UPtr", Pipe[A_Index])
	return Exec
}
class WinEvents ; static class
{
	static _ := WinEvents.AutoInit()
	
	AutoInit()
	{
		this.Table := []
		OnMessage(2, this.Destroy.bind(this))
	}
	
	Register(ID, HandlerClass, Prefix="Gui")
	{
		Gui, %ID%: +hWndhWnd +LabelWinEvents_
		this.Table[hWnd] := {Class: HandlerClass, Prefix: Prefix}
	}
	
	Unregister(ID)
	{
		Gui, %ID%: +hWndhWnd
		this.Table.Delete(hWnd)
	}
	
	Dispatch(Type, Params*)
	{
		Info := this.Table[Params[1]]
		return (Info.Class)[Info.Prefix . Type](Params*)
	}
	
	Destroy(wParam, lParam, Msg, hWnd)
	{
		this.Table.Delete(hWnd)
	}
}

WinEvents_Close(Params*) {
	return WinEvents.Dispatch("Close", Params*)
} WinEvents_Escape(Params*) {
	return WinEvents.Dispatch("Escape", Params*)
} WinEvents_Size(Params*) {
	return WinEvents.Dispatch("Size", Params*)
} WinEvents_ContextMenu(Params*) {
	return WinEvents.Dispatch("ContextMenu", Params*)
} WinEvents_DropFiles(Params*) {
	return WinEvents.Dispatch("DropFiles", Params*)
}
burque505
Posts: 1734
Joined: 22 Jan 2017, 19:37

Re: [Concept] Platform for Interative AHK Tutorials

20 Jan 2019, 17:01

@GeekDude, it really looks great, and super promising.

I have a feeling I may need DebugView, or something similar.
I've tried running it before I start the test script, but still get the error below (DebugView is in fact active). I do have DGBp.ahk in my LIB folder.
Also, what is "Book.ahk" that's referenced?

Regards,
burque505
no_debugger.GIF
no_debugger.GIF (21.1 KiB) Viewed 3575 times
geek
Posts: 1052
Joined: 02 Oct 2013, 22:13
Location: GeekDude
Contact:

Re: [Concept] Platform for Interative AHK Tutorials

20 Jan 2019, 17:20

Book.ahk was the name of the file I was using.

Image

This script tries try to open a debug server on port 9000. You can check in Resource Monitor to see what programs are listening one ports:

Image

When the script is closed there should be no entry for 9000, then when you open it there should be an entry.
burque505
Posts: 1734
Joined: 22 Jan 2017, 19:37

Re: [Concept] Platform for Interative AHK Tutorials

20 Jan 2019, 18:20

There is an entry when I open it, as per the GIF below, but it keeps listening (or Resource Monitor says it does).
dgb-fail.gif
dgb-fail.gif (659.2 KiB) Viewed 3560 times
geek
Posts: 1052
Joined: 02 Oct 2013, 22:13
Location: GeekDude
Contact:

Re: [Concept] Platform for Interative AHK Tutorials

20 Jan 2019, 18:28

Hmm, I've had it happen before that the main script closed but then the process stuck around using the port with no tray icon and no effect on SingleInstance. You might need to close the process using the port manually with task manager.

I'll make a note to add code that looks for these hung copies and replaces them, or at least looks for a free port. I'll also add a note to figure out why this happens.
burque505
Posts: 1734
Joined: 22 Jan 2017, 19:37

Re: [Concept] Platform for Interative AHK Tutorials

20 Jan 2019, 18:47

Thanks! Any clue why that "no debugger" message is popping up? The scripts still run, as the GIF shows.
Really a great-looking app, by the way. :bravo:
geek
Posts: 1052
Joined: 02 Oct 2013, 22:13
Location: GeekDude
Contact:

Re: [Concept] Platform for Interative AHK Tutorials

20 Jan 2019, 19:05

The base script starts a debugging server so that it can run the code step by step and do variable inspection. When the code can't connect to the debugger it will still run, but the UI on the tutorial page will be disabled and the code won't run step by step.

Also, thanks for the praise!

Edit: gif from an older copy
Image
burque505
Posts: 1734
Joined: 22 Jan 2017, 19:37

Re: [Concept] Platform for Interative AHK Tutorials

20 Jan 2019, 19:14

I've got DebugView running, connected to localhost, but I keep getting the "no active debugger" error. Any idea how to make the script see the debugger?
Thanks!
EDIT: Of course I tried without DebugView also.
EDIT: I can also see the AHK_DEBUG_[9 digits] process in the tray, but it doesn't connect.
geek
Posts: 1052
Joined: 02 Oct 2013, 22:13
Location: GeekDude
Contact:

Re: [Concept] Platform for Interative AHK Tutorials

21 Jan 2019, 12:08

burque505 wrote:
20 Jan 2019, 19:14
I've got DebugView running, connected to localhost, but I keep getting the "no active debugger" error. Any idea how to make the script see the debugger?
Thanks!
EDIT: Of course I tried without DebugView also.
EDIT: I can also see the AHK_DEBUG_[9 digits] process in the tray, but it doesn't connect.
I've updated the original post with some code that should help with starting the AHK DBGp server. You don't need anything like DebugView for the script to function correctly, I'm not even sure what that is. Please try it and let me know whether it works or if it gives more useful error information.
burque505
Posts: 1734
Joined: 22 Jan 2017, 19:37

Re: [Concept] Platform for Interative AHK Tutorials

21 Jan 2019, 12:34

Thanks for the update. I'm still getting the same error popup. The debug server is starting just fine, as it did before actually. But the script does not connect to it. As you can see from the GIF, the debug server is running.
dgb-fail-2.gif
dgb-fail-2.gif (22.67 KiB) Viewed 3467 times
User avatar
TheDewd
Posts: 1510
Joined: 19 Dec 2013, 11:16
Location: USA

Re: [Concept] Platform for Interative AHK Tutorials

21 Jan 2019, 14:08

Code: Select all

---------------------------
New AutoHotkey Script.ahk
---------------------------
Error:  Failed to start debugger

Specifically: WSAE:10013

	Line#
	017: DBGp_OnBreak("DebugBreak")  
	018: DBGp_OnEnd("DebugEnd")  
	019: While,(!StartListening(, startPort))
	020: {
	021: if (ErrorLevel == "WSAE:10048")  
	022: startPort += 1
	023: Else
--->	024: Throw,Exception("Failed to start debugger",, ErrorLevel)
	025: }
	028: (new Book(page, startPort)).AfterClose := Func("CleanupAndExit")  
	029: Return
	032: {
	033: For i,debugUI in book.debugUIs
	034: debugUI.Close()  
	035: ExitApp

The thread has exited.
---------------------------
OK   
---------------------------

Return to “Scripts and Functions (v1)”

Who is online

Users browsing this forum: No registered users and 143 guests