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*)
}