HTMLWindow.lua (20713B)
1 -- HTMLWindow.lua - HTML rendering window for apps 2 -- Allows any app to create an HTML-based window using their own context 3 4 local HTMLWindow = {} 5 6 -- Load parser and renderer from moonbrowser 7 local parserModule = nil 8 local rendererModule = nil 9 10 local function loadModules() 11 if parserModule and rendererModule then 12 return true 13 end 14 15 -- Load parser 16 local parserPath = "/apps/com.luajitos.moonbrowser/src/parser.luac" 17 local parserCode = nil 18 19 if CRamdiskExists and CRamdiskExists(parserPath) then 20 local handle = CRamdiskOpen(parserPath, "r") 21 if handle then 22 parserCode = CRamdiskRead(handle) 23 CRamdiskClose(handle) 24 end 25 end 26 27 if not parserCode then 28 -- Try .lua version 29 parserPath = "/apps/com.luajitos.moonbrowser/src/parser.lua" 30 if CRamdiskExists and CRamdiskExists(parserPath) then 31 local handle = CRamdiskOpen(parserPath, "r") 32 if handle then 33 parserCode = CRamdiskRead(handle) 34 CRamdiskClose(handle) 35 end 36 end 37 end 38 39 if not parserCode then 40 return false, "Could not load HTML parser" 41 end 42 43 -- Load renderer 44 local renderPath = "/apps/com.luajitos.moonbrowser/src/render.luac" 45 local renderCode = nil 46 47 if CRamdiskExists and CRamdiskExists(renderPath) then 48 local handle = CRamdiskOpen(renderPath, "r") 49 if handle then 50 renderCode = CRamdiskRead(handle) 51 CRamdiskClose(handle) 52 end 53 end 54 55 if not renderCode then 56 -- Try .lua version 57 renderPath = "/apps/com.luajitos.moonbrowser/src/render.lua" 58 if CRamdiskExists and CRamdiskExists(renderPath) then 59 local handle = CRamdiskOpen(renderPath, "r") 60 if handle then 61 renderCode = CRamdiskRead(handle) 62 CRamdiskClose(handle) 63 end 64 end 65 end 66 67 if not renderCode then 68 return false, "Could not load HTML renderer" 69 end 70 71 -- Load DOM module (required by parser) 72 local domPath = "/apps/com.luajitos.moonbrowser/src/dom.luac" 73 local domCode = nil 74 75 if CRamdiskExists and CRamdiskExists(domPath) then 76 local handle = CRamdiskOpen(domPath, "r") 77 if handle then 78 domCode = CRamdiskRead(handle) 79 CRamdiskClose(handle) 80 end 81 end 82 83 if not domCode then 84 domPath = "/apps/com.luajitos.moonbrowser/src/dom.lua" 85 if CRamdiskExists and CRamdiskExists(domPath) then 86 local handle = CRamdiskOpen(domPath, "r") 87 if handle then 88 domCode = CRamdiskRead(handle) 89 CRamdiskClose(handle) 90 end 91 end 92 end 93 94 if not domCode then 95 return false, "Could not load DOM module" 96 end 97 98 -- Compile and execute DOM module 99 local domEnv = setmetatable({}, { __index = _G, __metatable = false }) 100 local domFunc, domErr = loadstring(domCode, "dom") 101 if not domFunc then 102 return false, "Failed to compile DOM: " .. tostring(domErr) 103 end 104 setfenv(domFunc, domEnv) 105 local domOk, domModule = pcall(domFunc) 106 if not domOk then 107 return false, "Failed to execute DOM: " .. tostring(domModule) 108 end 109 110 -- Compile and execute parser with DOM available 111 local parserEnv = setmetatable({ 112 require = function(name) 113 if name == "dom" then 114 return domModule 115 end 116 return nil 117 end 118 }, { __index = _G, __metatable = false }) 119 120 local parserFunc, parserErr = loadstring(parserCode, "parser") 121 if not parserFunc then 122 return false, "Failed to compile parser: " .. tostring(parserErr) 123 end 124 setfenv(parserFunc, parserEnv) 125 local parserOk, parserResult = pcall(parserFunc) 126 if not parserOk then 127 return false, "Failed to execute parser: " .. tostring(parserResult) 128 end 129 parserModule = parserResult 130 131 -- Compile and execute renderer 132 local renderEnv = setmetatable({}, { __index = _G, __metatable = false }) 133 local renderFunc, renderErr = loadstring(renderCode, "render") 134 if not renderFunc then 135 return false, "Failed to compile renderer: " .. tostring(renderErr) 136 end 137 setfenv(renderFunc, renderEnv) 138 local renderOk, renderResult = pcall(renderFunc) 139 if not renderOk then 140 return false, "Failed to execute renderer: " .. tostring(renderResult) 141 end 142 rendererModule = renderResult 143 144 return true 145 end 146 147 --- Create a new HTML window 148 -- @param options Table with: 149 -- - app: Application instance (required) 150 -- - html: HTML string to render (either html or file required) 151 -- - file: Path to HTML file (either html or file required) 152 -- - fs: SafeFS instance for file access (required if using file) 153 -- - width: Window width (default 800) 154 -- - height: Window height (default 600) 155 -- - title: Window title (default "HTML Window") 156 -- - Dialog: Dialog module for alert/confirm/prompt 157 -- - loadstring: loadstring function for onclick handlers 158 -- - showStatusBar: Show status bar (default false) 159 -- @return window object or nil, error 160 function HTMLWindow.new(options) 161 if not options then 162 return nil, "Options required" 163 end 164 165 local app = options.app 166 if not app then 167 return nil, "app instance required" 168 end 169 170 -- Load modules if needed 171 local loadOk, loadErr = loadModules() 172 if not loadOk then 173 return nil, loadErr 174 end 175 176 local htmlContent = options.html 177 local filePath = options.file 178 179 -- Read file if provided 180 if not htmlContent and filePath then 181 local fs = options.fs 182 if fs then 183 if fs:exists(filePath) then 184 htmlContent = fs:read(filePath) 185 else 186 return nil, "File not found: " .. filePath 187 end 188 else 189 -- Try direct ramdisk access 190 if CRamdiskExists and CRamdiskExists(filePath) then 191 local handle = CRamdiskOpen(filePath, "r") 192 if handle then 193 htmlContent = CRamdiskRead(handle) 194 CRamdiskClose(handle) 195 end 196 end 197 end 198 end 199 200 if not htmlContent then 201 return nil, "No HTML content provided" 202 end 203 204 -- Store base path for relative URLs 205 local basePath = filePath and filePath:match("(.*/)[^/]*$") or "/" 206 207 -- Helper to resolve relative paths 208 local function resolvePath(href) 209 if not href then return nil end 210 if href:sub(1, 1) == "/" then 211 return href -- Absolute path 212 elseif href:sub(1, 2) == "./" then 213 return basePath .. href:sub(3) 214 elseif href:sub(1, 3) == "../" then 215 local parent = basePath:match("(.*/).-/$") or "/" 216 return parent .. href:sub(4) 217 else 218 return basePath .. href 219 end 220 end 221 222 -- Helper to read a file 223 local function readFile(path) 224 local fs = options.fs 225 if fs then 226 if fs:exists(path) then 227 return fs:read(path) 228 end 229 end 230 -- Try direct ramdisk access 231 if CRamdiskExists and CRamdiskExists(path) then 232 local handle = CRamdiskOpen(path, "r") 233 if handle then 234 local content = CRamdiskRead(handle) 235 CRamdiskClose(handle) 236 return content 237 end 238 end 239 return nil 240 end 241 242 -- Parse HTML 243 local Parser = parserModule.Parser 244 local htmlParser = Parser.new() 245 local dom = htmlParser:parse(htmlContent) 246 247 if not dom then 248 return nil, "Failed to parse HTML" 249 end 250 251 -- Collected styles from <link> and <style> tags 252 local collectedStyles = {} 253 254 -- Collected scripts to execute after page load 255 local collectedScripts = {} 256 257 -- Process <head> for stylesheets and scripts 258 local headStart = htmlContent:find("<head") 259 local headEnd = htmlContent:find("</head>") 260 if headStart and headEnd then 261 local headContent = htmlContent:sub(headStart, headEnd) 262 263 -- Find <link rel="stylesheet"> tags 264 for href in headContent:gmatch('<link[^>]+rel%s*=%s*["\']?stylesheet["\']?[^>]+href%s*=%s*["\']([^"\']+)["\']') do 265 local stylePath = resolvePath(href) 266 if stylePath then 267 local styleContent = readFile(stylePath) 268 if styleContent then 269 table.insert(collectedStyles, styleContent) 270 end 271 end 272 end 273 274 -- Also check href before rel 275 for href in headContent:gmatch('<link[^>]+href%s*=%s*["\']([^"\']+)["\'][^>]+rel%s*=%s*["\']?stylesheet["\']?') do 276 local stylePath = resolvePath(href) 277 if stylePath then 278 local styleContent = readFile(stylePath) 279 if styleContent then 280 table.insert(collectedStyles, styleContent) 281 end 282 end 283 end 284 285 -- Find <script src="..."> tags in head 286 for src in headContent:gmatch('<script[^>]+src%s*=%s*["\']([^"\']+)["\']') do 287 local scriptPath = resolvePath(src) 288 if scriptPath then 289 local scriptContent = readFile(scriptPath) 290 if scriptContent then 291 table.insert(collectedScripts, { type = "file", path = scriptPath, content = scriptContent }) 292 end 293 end 294 end 295 end 296 297 -- Process <style> tags anywhere in the document 298 for styleContent in htmlContent:gmatch("<style[^>]*>(.-)</style>") do 299 table.insert(collectedStyles, styleContent) 300 end 301 302 -- Process <script> tags in body (both inline and src) 303 local bodyStart = htmlContent:find("<body") or 1 304 local bodyContent = htmlContent:sub(bodyStart) 305 306 -- Find <script src="..."> tags 307 for src in bodyContent:gmatch('<script[^>]+src%s*=%s*["\']([^"\']+)["\'][^>]*>') do 308 local scriptPath = resolvePath(src) 309 if scriptPath then 310 local scriptContent = readFile(scriptPath) 311 if scriptContent then 312 table.insert(collectedScripts, { type = "file", path = scriptPath, content = scriptContent }) 313 end 314 end 315 end 316 317 -- Find inline <script> tags (without src attribute) 318 for scriptContent in bodyContent:gmatch("<script>(.-)") do 319 local endTag = scriptContent:find("</script>") 320 if endTag then 321 scriptContent = scriptContent:sub(1, endTag - 1) 322 end 323 if scriptContent and scriptContent:match("%S") then 324 table.insert(collectedScripts, { type = "inline", content = scriptContent }) 325 end 326 end 327 328 -- Create window 329 local width = options.width or 800 330 local height = options.height or 600 331 local title = options.title or "HTML Window" 332 local showStatusBar = options.showStatusBar or false 333 334 local window = app:newWindow(title, width, height, true) 335 if not window then 336 return nil, "Failed to create window" 337 end 338 339 -- Create renderer 340 local renderer = rendererModule.new(nil, width, height) 341 renderer:setDOM(dom) 342 343 -- Get Dialog and loadstring from options (app's context) 344 local Dialog = options.Dialog 345 local appLoadstring = options.loadstring 346 347 -- Create page sandbox for this window 348 local function queryFunc(selector) 349 return renderer:query(selector) 350 end 351 352 local function queryAllFunc(selector) 353 return renderer:queryAll(selector) 354 end 355 356 local pageSandbox = { 357 -- DOM query functions 358 query = queryFunc, 359 queryAll = queryAllFunc, 360 document = { 361 querySelector = queryFunc, 362 querySelectorAll = queryAllFunc, 363 getElementById = function(id) 364 return renderer:query("#" .. id) 365 end, 366 }, 367 368 -- Dialog functions using app's context 369 alert = function(msg) 370 if Dialog and Dialog.alert then 371 Dialog.alert(tostring(msg), { app = app }) 372 else 373 print("ALERT: " .. tostring(msg)) 374 end 375 end, 376 confirm = function(msg) 377 if Dialog and Dialog.confirm then 378 return Dialog.confirm(tostring(msg), { app = app }) 379 else 380 print("CONFIRM: " .. tostring(msg)) 381 return true 382 end 383 end, 384 prompt = function(msg, default) 385 if Dialog and Dialog.prompt then 386 return Dialog.prompt(tostring(msg), { app = app, default = default }) 387 else 388 print("PROMPT: " .. tostring(msg)) 389 return default or "" 390 end 391 end, 392 393 -- Console 394 console = { 395 log = function(...) 396 local args = {...} 397 local parts = {} 398 for i = 1, #args do 399 parts[i] = tostring(args[i]) 400 end 401 print("[console] " .. table.concat(parts, " ")) 402 end, 403 error = function(...) 404 local args = {...} 405 local parts = {} 406 for i = 1, #args do 407 parts[i] = tostring(args[i]) 408 end 409 print("[console.error] " .. table.concat(parts, " ")) 410 end, 411 warn = function(...) 412 local args = {...} 413 local parts = {} 414 for i = 1, #args do 415 parts[i] = tostring(args[i]) 416 end 417 print("[console.warn] " .. table.concat(parts, " ")) 418 end, 419 }, 420 421 -- Basic Lua functions 422 print = print, 423 tostring = tostring, 424 tonumber = tonumber, 425 type = type, 426 pairs = pairs, 427 ipairs = ipairs, 428 string = string, 429 table = table, 430 math = math, 431 } 432 433 -- Function to execute code in page sandbox 434 local function executeInSandbox(code, chunkName) 435 if not code or code == "" then return end 436 437 if not appLoadstring then 438 print("HTMLWindow: Cannot execute script - loadstring not available") 439 return 440 end 441 442 local func, err = appLoadstring(code, chunkName or "script", pageSandbox) 443 if not func then 444 print("HTMLWindow: Script error: " .. tostring(err)) 445 return 446 end 447 448 local ok, result = pcall(func) 449 if not ok then 450 print("HTMLWindow: Script runtime error: " .. tostring(result)) 451 end 452 return result 453 end 454 455 -- Forward declaration for navigation 456 local htmlWindow 457 local currentFilePath = filePath 458 459 -- Function to navigate to a new page 460 local function navigateTo(href) 461 if not href then return false end 462 463 local targetPath = resolvePath(href) 464 if not targetPath then 465 print("HTMLWindow: Invalid href: " .. tostring(href)) 466 return false 467 end 468 469 -- Read new file 470 local newContent = readFile(targetPath) 471 if not newContent then 472 print("HTMLWindow: File not found: " .. targetPath) 473 return false 474 end 475 476 -- Update base path 477 basePath = targetPath:match("(.*/)[^/]*$") or "/" 478 currentFilePath = targetPath 479 480 -- Parse new HTML 481 local newDom = htmlParser:parse(newContent) 482 if not newDom then 483 print("HTMLWindow: Failed to parse: " .. targetPath) 484 return false 485 end 486 487 -- Process scripts from new page 488 collectedScripts = {} 489 490 -- Find inline <script> tags in new content 491 for scriptContent in newContent:gmatch("<script>(.-)") do 492 local endTag = scriptContent:find("</script>") 493 if endTag then 494 scriptContent = scriptContent:sub(1, endTag - 1) 495 end 496 if scriptContent and scriptContent:match("%S") then 497 table.insert(collectedScripts, { type = "inline", content = scriptContent }) 498 end 499 end 500 501 -- Find <script src="..."> tags 502 for src in newContent:gmatch('<script[^>]+src%s*=%s*["\']([^"\']+)["\'][^>]*>') do 503 local scriptPath = resolvePath(src) 504 if scriptPath then 505 local scriptFileContent = readFile(scriptPath) 506 if scriptFileContent then 507 table.insert(collectedScripts, { type = "file", path = scriptPath, content = scriptFileContent }) 508 end 509 end 510 end 511 512 -- Update DOM and renderer 513 dom = newDom 514 renderer:setDOM(dom) 515 renderer:scroll(-renderer.scrollY) -- Reset scroll 516 renderer.inputValues = {} -- Reset form values 517 renderer.focusedInput = nil 518 519 -- Update window title 520 window.title = title .. " - " .. targetPath 521 522 -- Execute scripts 523 for _, script in ipairs(collectedScripts) do 524 executeInSandbox(script.content, script.path or "inline") 525 end 526 527 window:markDirty() 528 return true 529 end 530 531 -- Add navigation to page sandbox 532 pageSandbox.location = { 533 href = currentFilePath, 534 navigate = navigateTo, 535 reload = function() 536 if currentFilePath then 537 navigateTo(currentFilePath:match("[^/]+$")) 538 end 539 end, 540 } 541 542 pageSandbox.window = { 543 location = pageSandbox.location, 544 navigate = navigateTo, 545 } 546 547 -- Draw callback 548 window.onDraw = function(gfx) 549 renderer:render(dom, gfx) 550 551 if showStatusBar and filePath then 552 gfx:fillRect(0, height - 20, width, 20, 0x333333) 553 gfx:drawText(5, height - 16, filePath, 0xFFFFFF) 554 end 555 end 556 557 -- Input callback 558 window.onInput = function(key, scancode) 559 if renderer:hasFocus() then 560 if renderer:handleKey(key, scancode) then 561 window:markDirty() 562 return 563 end 564 end 565 566 -- Arrow key scrolling 567 if scancode == 72 then -- Up arrow 568 renderer:scroll(-40) 569 window:markDirty() 570 elseif scancode == 80 then -- Down arrow 571 renderer:scroll(40) 572 window:markDirty() 573 end 574 end 575 576 -- Click callback 577 window.onClick = function(x, y, button) 578 local result = renderer:handleClick(x, y) 579 if result then 580 if result.type == "button" then 581 if result.onclick then 582 executeInSandbox(result.onclick) 583 end 584 elseif result.type == "submit" then 585 -- Form submitted - could trigger onSubmit callback 586 if options.onSubmit then 587 options.onSubmit(result.formData) 588 end 589 elseif result.type == "link" then 590 -- Navigate to link href 591 if result.href then 592 -- Check for javascript: links 593 if result.href:match("^javascript:") then 594 local code = result.href:sub(12) -- Remove "javascript:" 595 executeInSandbox(code) 596 else 597 navigateTo(result.href) 598 end 599 end 600 end 601 window:markDirty() 602 end 603 end 604 605 -- Execute initial scripts after page is set up 606 for _, script in ipairs(collectedScripts) do 607 executeInSandbox(script.content, script.path or "inline") 608 end 609 610 -- Return extended window object 611 htmlWindow = { 612 window = window, 613 renderer = renderer, 614 dom = dom, 615 pageSandbox = pageSandbox, 616 617 -- Query functions 618 query = queryFunc, 619 queryAll = queryAllFunc, 620 621 -- Execute script in page context 622 executeScript = executeInSandbox, 623 624 -- Navigate to a new page 625 navigate = navigateTo, 626 627 -- Close window 628 close = function(self) 629 window:close() 630 end, 631 632 -- Mark dirty 633 markDirty = function(self) 634 window:markDirty() 635 end, 636 637 -- Reload HTML 638 reload = function(self, newHtml) 639 if newHtml then 640 local newDom = htmlParser:parse(newHtml) 641 if newDom then 642 dom = newDom 643 renderer:setDOM(dom) 644 window:markDirty() 645 return true 646 end 647 end 648 return false 649 end, 650 651 -- Get current file path 652 getCurrentPath = function(self) 653 return currentFilePath 654 end, 655 } 656 657 return htmlWindow 658 end 659 660 return HTMLWindow