Dialog.lua (83979B)
1 -- Dialog.lua - Dialog Library for LuajitOS 2 -- Provides common dialogs like file open/save, confirm, and prompt 3 4 local Dialog = {} 5 6 -- Capture global ramdisk functions at load time (before sandbox restricts access) 7 local _CRamdiskList = CRamdiskList or _G.CRamdiskList 8 9 -- Helper function to list directory contents 10 -- Uses global ramdisk functions to show the entire filesystem (not restricted by SafeFS) 11 -- This allows users to browse anywhere and select files, then SafeFS is updated to allow access 12 local function listDirectory(path) 13 if osprint then 14 osprint("[Dialog.listDirectory] path=" .. tostring(path) .. "\n") 15 end 16 17 -- Use global CRamdiskList to show entire filesystem 18 if _CRamdiskList then 19 local entries = {} 20 21 if osprint then 22 osprint("[Dialog.listDirectory] Using CRamdiskList\n") 23 end 24 25 local items = _CRamdiskList(path) 26 if items then 27 if osprint then 28 osprint("[Dialog.listDirectory] CRamdiskList returned " .. #items .. " items\n") 29 end 30 31 for _, item in ipairs(items) do 32 if osprint then 33 osprint("[Dialog.listDirectory] item: " .. tostring(item.name) .. " type=" .. tostring(item.type) .. "\n") 34 end 35 table.insert(entries, { 36 name = item.name, 37 type = item.type == "dir" and "directory" or item.type 38 }) 39 end 40 else 41 if osprint then 42 osprint("[Dialog.listDirectory] CRamdiskList returned nil\n") 43 end 44 end 45 46 if osprint then 47 osprint("[Dialog.listDirectory] total entries: " .. #entries .. "\n") 48 end 49 50 return entries 51 else 52 if osprint then 53 osprint("[Dialog.listDirectory] CRamdiskList not available\n") 54 end 55 end 56 57 return nil 58 end 59 60 -- File Open Dialog 61 -- Creates a file picker window in the calling application 62 -- @param startPath Optional starting directory path (default: "/") 63 -- @param options Optional table with fields: 64 -- - app: Application instance (required) 65 -- - fs: SafeFS instance (optional, for adding paths when file is selected) 66 -- - title: Dialog title (default: "Open File") 67 -- - width: Dialog width (default: 400) 68 -- - height: Dialog height (default: 300) 69 -- @return Dialog object with methods: 70 -- - openDialog(callback): Show dialog and set callback for result (callback receives path or nil) 71 -- - onSuccess(callback): Set callback for when file is selected 72 -- - onCancel(callback): Set callback for when dialog is cancelled 73 -- - show(): Show the dialog 74 -- - close(): Close the dialog 75 function Dialog.fileOpen(startPath, options) 76 if not options or not options.app then 77 error("Dialog.fileOpen requires options.app (Application instance)") 78 end 79 80 local app = app or options.app 81 local fs = options.fs or app.fs -- Optional, only used for adding allowed paths 82 local title = options.title or "Open File" 83 local width = options.width or 400 84 local height = options.height or 300 85 86 startPath = startPath or "/" 87 88 local dialog = { 89 app = app, 90 fs = fs, 91 currentPath = startPath, 92 window = nil, 93 successCallback = nil, 94 cancelCallback = nil, 95 entries = {}, 96 scrollOffset = 0, 97 _isHidden = true 98 } 99 100 -- Open dialog with callback (new pattern) 101 function dialog:openDialog(callback) 102 if type(callback) ~= "function" then 103 error("openDialog requires a function") 104 end 105 106 -- Set callbacks to invoke the single callback with result, then close 107 self.successCallback = function(path) 108 callback(path) 109 self:close() 110 end 111 self.cancelCallback = function() 112 callback(nil) 113 self:close() 114 end 115 116 -- Create and show window 117 self:_createWindow() 118 return self 119 end 120 121 -- Set success callback (old pattern) 122 function dialog:onSuccess(callback) 123 if type(callback) ~= "function" then 124 error("onSuccess requires a function") 125 end 126 self.successCallback = callback 127 return self 128 end 129 130 -- Set cancel callback (old pattern) 131 function dialog:onCancel(callback) 132 if type(callback) ~= "function" then 133 error("onCancel requires a function") 134 end 135 self.cancelCallback = callback 136 return self 137 end 138 139 -- Load directory contents 140 function dialog:loadDirectory(path) 141 if osprint then 142 osprint("[Dialog] loadDirectory called with path: " .. path .. "\n") 143 end 144 145 self.currentPath = path 146 self.entries = {} 147 self.scrollOffset = 0 148 149 -- Add parent directory entry if not at root 150 if path ~= "/" then 151 table.insert(self.entries, { 152 name = "..", 153 type = "parent", 154 path = path:match("(.*/)[^/]+/?$") or "/" 155 }) 156 if osprint then 157 osprint("[Dialog] Added parent directory entry\n") 158 end 159 end 160 161 -- Use helper to get directory contents (supports diskfs and ramdisk) 162 local entries = listDirectory(path) 163 164 if entries then 165 -- Separate directories and files 166 local dirs = {} 167 local files = {} 168 169 for _, entry in ipairs(entries) do 170 if osprint then 171 osprint("[Dialog] Entry: '" .. entry.name .. "' type=" .. entry.type .. "\n") 172 end 173 174 if entry.type == "directory" or entry.type == "dir" then 175 table.insert(dirs, entry.name) 176 elseif entry.type == "file" then 177 table.insert(files, entry.name) 178 end 179 end 180 181 -- Add directories first (sorted) 182 table.sort(dirs) 183 for _, dir in ipairs(dirs) do 184 if osprint then 185 osprint("[Dialog] Adding directory: " .. dir .. "\n") 186 end 187 table.insert(self.entries, { 188 name = dir, 189 type = "directory", 190 path = path .. (path:sub(-1) == "/" and "" or "/") .. dir 191 }) 192 end 193 194 -- Add files (sorted) 195 table.sort(files) 196 for _, file in ipairs(files) do 197 if osprint then 198 osprint("[Dialog] Adding file: " .. file .. "\n") 199 end 200 table.insert(self.entries, { 201 name = file, 202 type = "file", 203 path = path .. (path:sub(-1) == "/" and "" or "/") .. file 204 }) 205 end 206 end 207 208 if osprint then 209 osprint("[Dialog] Total entries: " .. #self.entries .. "\n") 210 end 211 212 if self.window then 213 self.window:markDirty() 214 end 215 end 216 217 -- Close dialog 218 function dialog:close() 219 if self.window then 220 self.window:close() 221 self.window = nil 222 self._isHidden = true 223 end 224 end 225 226 -- Show dialog (old pattern) 227 function dialog:show() 228 if not self._isHidden then 229 return self -- Already showing 230 end 231 self:_createWindow() 232 return self 233 end 234 235 -- Internal: Create window (used by both show() and openDialog()) 236 function dialog:_createWindow() 237 if self.window then 238 return -- Already created 239 end 240 241 self._isHidden = false 242 243 -- Create dialog window 244 local screenWidth = 1024 245 local screenHeight = 768 246 local x = math.floor((screenWidth - width) / 2) 247 local y = math.floor((screenHeight - height) / 2) 248 249 self.window = self.app:newWindow(x, y, width, height) 250 self.window.title = title 251 252 -- Load initial directory 253 self:loadDirectory(self.currentPath) 254 255 -- Draw callback 256 self.window.onDraw = function(gfx) 257 -- Background 258 gfx:fillRect(0, 0, width, height, 0x2C2C2C) 259 260 -- Title bar background (drawn by window system, but we draw path) 261 local pathY = 5 262 gfx:fillRect(0, pathY, width, 25, 0x3C3C3C) 263 264 -- Current path 265 local displayPath = self.currentPath 266 if #displayPath > 50 then 267 displayPath = "..." .. displayPath:sub(-47) 268 end 269 gfx:drawText(10, pathY + 7, displayPath, 0xCCCCCC) 270 271 -- File list area 272 local listY = 35 273 local listHeight = height - listY - 40 274 local itemHeight = 25 275 local visibleItems = math.floor(listHeight / itemHeight) 276 277 -- Draw entries 278 local y = listY 279 local startIdx = self.scrollOffset + 1 280 local endIdx = math.min(startIdx + visibleItems - 1, #self.entries) 281 282 for i = startIdx, endIdx do 283 local entry = self.entries[i] 284 local isHovered = false -- TODO: Add hover detection 285 286 -- Background (alternate colors) 287 local bgColor = (i % 2 == 0) and 0x383838 or 0x2C2C2C 288 gfx:fillRect(0, y, width, itemHeight, bgColor) 289 290 -- Icon (simple text-based) 291 local icon = "" 292 local iconColor = 0xCCCCCC 293 if entry.type == "parent" then 294 icon = "^" 295 iconColor = 0xFFCC00 296 elseif entry.type == "directory" then 297 icon = "/" 298 iconColor = 0x66CCFF 299 else 300 icon = " " 301 iconColor = 0xFFFFFF 302 end 303 304 gfx:drawText(10, y + 7, icon, iconColor) 305 306 -- Name 307 local nameColor = 0xFFFFFF 308 if entry.type == "directory" or entry.type == "parent" then 309 nameColor = 0x66CCFF 310 end 311 312 local displayName = entry.name 313 if #displayName > 45 then 314 displayName = displayName:sub(1, 42) .. "..." 315 end 316 317 gfx:drawText(30, y + 7, displayName, nameColor) 318 319 y = y + itemHeight 320 end 321 322 -- Scrollbar if needed 323 if #self.entries > visibleItems then 324 local scrollbarX = width - 15 325 local scrollbarHeight = listHeight 326 local thumbHeight = math.max(20, math.floor(scrollbarHeight * visibleItems / #self.entries)) 327 local thumbY = listY + math.floor((scrollbarHeight - thumbHeight) * self.scrollOffset / (#self.entries - visibleItems)) 328 329 -- Scrollbar track 330 gfx:fillRect(scrollbarX, listY, 10, scrollbarHeight, 0x1C1C1C) 331 332 -- Scrollbar thumb 333 gfx:fillRect(scrollbarX, thumbY, 10, thumbHeight, 0x4C4C4C) 334 end 335 336 -- Buttons area 337 local buttonY = height - 35 338 gfx:fillRect(0, buttonY - 5, width, 40, 0x3C3C3C) 339 340 -- Cancel button 341 local cancelX = width - 90 342 gfx:fillRect(cancelX, buttonY, 80, 25, 0x555555) 343 gfx:drawRect(cancelX, buttonY, 80, 25, 0x777777) 344 gfx:drawText(cancelX + 18, buttonY + 7, "Cancel", 0xFFFFFF) 345 end 346 347 -- Click handler 348 self.window.onClick = function(mx, my) 349 if osprint then 350 osprint("[Dialog.fileOpen.onClick] mx=" .. mx .. " my=" .. my .. " window.y=" .. tostring(self.window.y) .. "\n") 351 end 352 local listY = 35 353 local listHeight = height - listY - 40 354 local itemHeight = 25 355 local visibleItems = math.floor(listHeight / itemHeight) 356 357 -- Check if click is in file list area 358 if my >= listY and my < listY + listHeight then 359 local clickedIdx = math.floor((my - listY) / itemHeight) + self.scrollOffset + 1 360 361 if clickedIdx >= 1 and clickedIdx <= #self.entries then 362 local entry = self.entries[clickedIdx] 363 364 if entry.type == "directory" or entry.type == "parent" then 365 -- Navigate to directory 366 self:loadDirectory(entry.path) 367 elseif entry.type == "file" then 368 -- File selected 369 if self.successCallback then 370 -- Add the file's directory to the app's SafeFS allowedPaths 371 local fileDir = entry.path:match("(.*/)[^/]+$") or "/" 372 373 -- Use SafeFS internal addAllowedPath (stored in _G._safefsInternal) 374 if self.fs and _G._safefsInternal then 375 local internal = _G._safefsInternal[self.fs] 376 if internal and internal.addAllowedPath then 377 local success, err = internal.addAllowedPath(fileDir .. "*") 378 if osprint then 379 if success then 380 osprint("[Dialog] Added allowed path: " .. fileDir .. "*\n") 381 else 382 osprint("[Dialog] Failed to add allowed path: " .. tostring(err) .. "\n") 383 end 384 end 385 end 386 end 387 388 self.successCallback(entry.path) 389 end 390 end 391 end 392 return 393 end 394 395 -- Check if click is on cancel button 396 local buttonY = height - 35 397 local cancelX = width - 90 398 if mx >= cancelX and mx < cancelX + 80 and my >= buttonY and my < buttonY + 25 then 399 if self.cancelCallback then 400 self.cancelCallback() 401 end 402 return 403 end 404 end 405 406 return self 407 end 408 409 return dialog 410 end 411 412 -- File Save Dialog 413 -- Similar to fileOpen but with a filename input field 414 -- @param startPath Optional starting directory path (default: "/") 415 -- @param defaultName Optional default filename 416 -- @param options Optional table with same fields as fileOpen 417 -- @return Dialog object with same methods as fileOpen 418 function Dialog.fileSave(startPath, defaultName, options) 419 if not options or not options.app then 420 error("Dialog.fileSave requires options.app (Application instance)") 421 end 422 423 local app = options.app 424 local fs = options.fs or app.fs -- Optional, only used for adding allowed paths 425 local title = options.title or "Save File" 426 local width = options.width or 400 427 local height = options.height or 350 -- Taller to accommodate filename input 428 429 startPath = startPath or "/" 430 defaultName = defaultName or "untitled" 431 432 local dialog = { 433 app = app, 434 fs = fs, 435 currentPath = startPath, 436 filename = defaultName, 437 window = nil, 438 successCallback = nil, 439 cancelCallback = nil, 440 entries = {}, 441 scrollOffset = 0, 442 filenameInputActive = false, 443 _isHidden = true 444 } 445 446 -- Open dialog with callback (new pattern) 447 function dialog:openDialog(callback) 448 if type(callback) ~= "function" then 449 error("openDialog requires a function") 450 end 451 452 -- Set callbacks to invoke the single callback with result, then close 453 self.successCallback = function(path) 454 callback(path) 455 self:close() 456 end 457 self.cancelCallback = function() 458 callback(nil) 459 self:close() 460 end 461 462 -- Create and show window 463 self:_createWindow() 464 return self 465 end 466 467 -- Set success callback (old pattern) 468 function dialog:onSuccess(callback) 469 if type(callback) ~= "function" then 470 error("onSuccess requires a function") 471 end 472 self.successCallback = callback 473 return self 474 end 475 476 -- Set cancel callback (old pattern) 477 function dialog:onCancel(callback) 478 if type(callback) ~= "function" then 479 error("onCancel requires a function") 480 end 481 self.cancelCallback = callback 482 return self 483 end 484 485 -- Load directory contents 486 function dialog:loadDirectory(path) 487 if osprint then 488 osprint("[Dialog] loadDirectory called with path: " .. path .. "\n") 489 end 490 491 self.currentPath = path 492 self.entries = {} 493 self.scrollOffset = 0 494 495 -- Add parent directory entry if not at root 496 if path ~= "/" then 497 table.insert(self.entries, { 498 name = "..", 499 type = "parent", 500 path = path:match("(.*/)[^/]+/?$") or "/" 501 }) 502 if osprint then 503 osprint("[Dialog] Added parent directory entry\n") 504 end 505 end 506 507 -- Use helper to get directory contents (supports diskfs and ramdisk) 508 local entries = listDirectory(path) 509 510 if entries then 511 -- Separate directories and files 512 local dirs = {} 513 local files = {} 514 515 for _, entry in ipairs(entries) do 516 if osprint then 517 osprint("[Dialog] Entry: '" .. entry.name .. "' type=" .. entry.type .. "\n") 518 end 519 520 if entry.type == "directory" or entry.type == "dir" then 521 table.insert(dirs, entry.name) 522 elseif entry.type == "file" then 523 table.insert(files, entry.name) 524 end 525 end 526 527 -- Add directories first (sorted) 528 table.sort(dirs) 529 for _, dir in ipairs(dirs) do 530 if osprint then 531 osprint("[Dialog] Adding directory: " .. dir .. "\n") 532 end 533 table.insert(self.entries, { 534 name = dir, 535 type = "directory", 536 path = path .. (path:sub(-1) == "/" and "" or "/") .. dir 537 }) 538 end 539 540 -- Add files (sorted) 541 table.sort(files) 542 for _, file in ipairs(files) do 543 if osprint then 544 osprint("[Dialog] Adding file: " .. file .. "\n") 545 end 546 table.insert(self.entries, { 547 name = file, 548 type = "file", 549 path = path .. (path:sub(-1) == "/" and "" or "/") .. file 550 }) 551 end 552 end 553 554 if osprint then 555 osprint("[Dialog] Total entries: " .. #self.entries .. "\n") 556 end 557 558 if self.window then 559 self.window:markDirty() 560 end 561 end 562 563 -- Close dialog 564 function dialog:close() 565 if self.window then 566 self.window:close() 567 self.window = nil 568 self._isHidden = true 569 end 570 end 571 572 -- Show dialog (old pattern) 573 function dialog:show() 574 if not self._isHidden then 575 return self -- Already showing 576 end 577 self:_createWindow() 578 return self 579 end 580 581 -- Internal: Create window 582 function dialog:_createWindow() 583 if self.window then 584 return -- Already created 585 end 586 587 self._isHidden = false 588 589 -- Create dialog window 590 local screenWidth = 1024 591 local screenHeight = 768 592 local x = math.floor((screenWidth - width) / 2) 593 local y = math.floor((screenHeight - height) / 2) 594 595 self.window = self.app:newWindow(x, y, width, height) 596 self.window.title = title 597 598 -- Load initial directory 599 self:loadDirectory(self.currentPath) 600 601 -- Draw callback 602 self.window.onDraw = function(gfx) 603 -- Background 604 gfx:fillRect(0, 0, width, height, 0x2C2C2C) 605 606 -- Title bar background (drawn by window system, but we draw path) 607 local pathY = 5 608 gfx:fillRect(0, pathY, width, 25, 0x3C3C3C) 609 610 -- Current path 611 local displayPath = self.currentPath 612 if #displayPath > 50 then 613 displayPath = "..." .. displayPath:sub(-47) 614 end 615 gfx:drawText(10, pathY + 7, displayPath, 0xCCCCCC) 616 617 -- File list area 618 local listY = 35 619 local listHeight = height - listY - 90 -- Extra space for filename input 620 local itemHeight = 25 621 local visibleItems = math.floor(listHeight / itemHeight) 622 623 -- Draw entries 624 local y = listY 625 local startIdx = self.scrollOffset + 1 626 local endIdx = math.min(startIdx + visibleItems - 1, #self.entries) 627 628 for i = startIdx, endIdx do 629 local entry = self.entries[i] 630 631 -- Background (alternate colors) 632 local bgColor = (i % 2 == 0) and 0x383838 or 0x2C2C2C 633 gfx:fillRect(0, y, width, itemHeight, bgColor) 634 635 -- Icon (simple text-based) 636 local icon = "" 637 local iconColor = 0xCCCCCC 638 if entry.type == "parent" then 639 icon = "^" 640 iconColor = 0xFFCC00 641 elseif entry.type == "directory" then 642 icon = "/" 643 iconColor = 0x66CCFF 644 else 645 icon = " " 646 iconColor = 0xFFFFFF 647 end 648 649 gfx:drawText(10, y + 7, icon, iconColor) 650 651 -- Name 652 local nameColor = 0xFFFFFF 653 if entry.type == "directory" or entry.type == "parent" then 654 nameColor = 0x66CCFF 655 end 656 657 local displayName = entry.name 658 if #displayName > 45 then 659 displayName = displayName:sub(1, 42) .. "..." 660 end 661 662 gfx:drawText(30, y + 7, displayName, nameColor) 663 664 y = y + itemHeight 665 end 666 667 -- Scrollbar if needed 668 if #self.entries > visibleItems then 669 local scrollbarX = width - 15 670 local scrollbarHeight = listHeight 671 local thumbHeight = math.max(20, math.floor(scrollbarHeight * visibleItems / #self.entries)) 672 local thumbY = listY + math.floor((scrollbarHeight - thumbHeight) * self.scrollOffset / (#self.entries - visibleItems)) 673 674 -- Scrollbar track 675 gfx:fillRect(scrollbarX, listY, 10, scrollbarHeight, 0x1C1C1C) 676 677 -- Scrollbar thumb 678 gfx:fillRect(scrollbarX, thumbY, 10, thumbHeight, 0x4C4C4C) 679 end 680 681 -- Filename input area 682 local inputY = height - 85 683 gfx:fillRect(0, inputY - 5, width, 50, 0x3C3C3C) 684 685 -- Label 686 gfx:drawText(10, inputY + 2, "Filename:", 0xCCCCCC) 687 688 -- Input box 689 local inputBoxX = 90 690 local inputBoxY = inputY 691 local inputBoxWidth = width - 100 692 local inputBoxHeight = 25 693 694 local inputBgColor = self.filenameInputActive and 0x4C4C4C or 0x383838 695 gfx:fillRect(inputBoxX, inputBoxY, inputBoxWidth, inputBoxHeight, inputBgColor) 696 gfx:drawRect(inputBoxX, inputBoxY, inputBoxWidth, inputBoxHeight, 0x666666) 697 698 -- Filename text 699 local displayFilename = self.filename 700 if #displayFilename > 40 then 701 displayFilename = displayFilename:sub(-40) 702 end 703 gfx:drawText(inputBoxX + 5, inputBoxY + 7, displayFilename, 0xFFFFFF) 704 705 -- Cursor if active 706 if self.filenameInputActive then 707 local cursorX = inputBoxX + 5 + (#displayFilename * 6) -- Approximate char width 708 gfx:fillRect(cursorX, inputBoxY + 5, 3, 15, 0xFFFFFF) 709 end 710 711 -- Buttons area 712 local buttonY = height - 35 713 gfx:fillRect(0, buttonY - 5, width, 40, 0x3C3C3C) 714 715 -- Save button 716 local saveX = width - 180 717 local saveEnabled = #self.filename > 0 718 local saveBgColor = saveEnabled and 0x0066CC or 0x444444 719 gfx:fillRect(saveX, buttonY, 80, 25, saveBgColor) 720 gfx:drawRect(saveX, buttonY, 80, 25, 0x777777) 721 local saveTextColor = saveEnabled and 0xFFFFFF or 0x888888 722 gfx:drawText(saveX + 22, buttonY + 7, "Save", saveTextColor) 723 724 -- Cancel button 725 local cancelX = width - 90 726 gfx:fillRect(cancelX, buttonY, 80, 25, 0x555555) 727 gfx:drawRect(cancelX, buttonY, 80, 25, 0x777777) 728 gfx:drawText(cancelX + 18, buttonY + 7, "Cancel", 0xFFFFFF) 729 end 730 731 -- Input handler for filename typing 732 self.window.onInput = function(key, scancode) 733 if not self.filenameInputActive then 734 return 735 end 736 737 if key == "\b" then 738 -- Backspace 739 if #self.filename > 0 then 740 self.filename = self.filename:sub(1, -2) 741 self.window:markDirty() 742 end 743 elseif key == "\n" then 744 -- Enter key - save 745 if #self.filename > 0 then 746 local fullPath = self.currentPath .. (self.currentPath:sub(-1) == "/" and "" or "/") .. self.filename 747 if self.successCallback then 748 self.successCallback(fullPath) 749 end 750 end 751 elseif key == "\t" then 752 -- Tab - ignore 753 elseif #key == 1 then 754 -- Regular character 755 self.filename = self.filename .. key 756 self.window:markDirty() 757 end 758 end 759 760 -- Click handler 761 self.window.onClick = function(mx, my) 762 local listY = 35 763 local listHeight = height - listY - 90 764 local itemHeight = 25 765 local visibleItems = math.floor(listHeight / itemHeight) 766 767 -- Check if click is in file list area 768 if my >= listY and my < listY + listHeight then 769 local clickedIdx = math.floor((my - listY) / itemHeight) + self.scrollOffset + 1 770 771 if clickedIdx >= 1 and clickedIdx <= #self.entries then 772 local entry = self.entries[clickedIdx] 773 774 if entry.type == "directory" or entry.type == "parent" then 775 -- Navigate to directory 776 self:loadDirectory(entry.path) 777 elseif entry.type == "file" then 778 -- File clicked - set filename in input 779 self.filename = entry.name 780 self.filenameInputActive = true 781 self.window:markDirty() 782 783 -- Focus window for input 784 if sys and sys.setActiveWindow then 785 sys.setActiveWindow(self.window) 786 end 787 end 788 end 789 return 790 end 791 792 -- Check if click is in filename input 793 local inputY = height - 85 794 local inputBoxX = 90 795 local inputBoxWidth = width - 100 796 local inputBoxHeight = 25 797 798 if mx >= inputBoxX and mx < inputBoxX + inputBoxWidth and 799 my >= inputY and my < inputY + inputBoxHeight then 800 self.filenameInputActive = true 801 self.window:markDirty() 802 803 -- Focus window for input 804 if sys and sys.setActiveWindow then 805 sys.setActiveWindow(self.window) 806 end 807 return 808 else 809 -- Click outside input - deactivate 810 if self.filenameInputActive then 811 self.filenameInputActive = false 812 self.window:markDirty() 813 end 814 end 815 816 -- Check if click is on save button 817 local buttonY = height - 35 818 local saveX = width - 180 819 if mx >= saveX and mx < saveX + 80 and my >= buttonY and my < buttonY + 25 then 820 if #self.filename > 0 then 821 local fullPath = self.currentPath .. (self.currentPath:sub(-1) == "/" and "" or "/") .. self.filename 822 if self.successCallback then 823 self.successCallback(fullPath) 824 end 825 end 826 return 827 end 828 829 -- Check if click is on cancel button 830 local cancelX = width - 90 831 if mx >= cancelX and mx < cancelX + 80 and my >= buttonY and my < buttonY + 25 then 832 if self.cancelCallback then 833 self.cancelCallback() 834 end 835 return 836 end 837 end 838 839 return self 840 end 841 842 return dialog 843 end 844 845 -- Confirmation Dialog 846 -- Creates a simple confirmation dialog with two buttons 847 -- @param message The message to display (default: "Confirm?") 848 -- @param yesText Text for the yes/confirm button (default: "Yes") 849 -- @param noText Text for the no/cancel button (default: "No") 850 -- @param options Optional table with fields: 851 -- - app: Application instance (required) 852 -- - title: Dialog title (default: "Confirm") 853 -- - width: Dialog width (default: 300) 854 -- - height: Dialog height (default: 150) 855 -- @return Dialog object with methods: 856 -- - openDialog(callback): Show dialog and set callback for result (callback receives true or false) 857 -- - onYes(callback): Set callback for yes button 858 -- - onNo(callback): Set callback for no button 859 -- - show(): Show the dialog 860 -- - close(): Close the dialog 861 function Dialog.confirm(message, yesText, noText, options) 862 -- Handle if called with just options (no message) 863 if type(message) == "table" and options == nil then 864 options = message 865 message = nil 866 end 867 868 if not options or not options.app then 869 error("Dialog.confirm requires options.app (Application instance)") 870 end 871 872 local app = options.app 873 message = message or "Confirm?" 874 yesText = yesText or "Yes" 875 noText = noText or "No" 876 local title = options.title or "Confirm" 877 local width = options.width or 300 878 local height = options.height or 150 879 880 local dialog = { 881 app = app, 882 message = message, 883 yesText = yesText, 884 noText = noText, 885 window = nil, 886 yesCallback = nil, 887 noCallback = nil, 888 _isHidden = true 889 } 890 891 -- Open dialog with callback (new pattern) 892 function dialog:openDialog(callback) 893 if type(callback) ~= "function" then 894 error("openDialog requires a function") 895 end 896 897 -- Set callbacks to invoke the single callback with result, then close 898 self.yesCallback = function() 899 callback(true) 900 self:close() 901 end 902 self.noCallback = function() 903 callback(false) 904 self:close() 905 end 906 907 -- Create and show window 908 self:_createWindow() 909 return self 910 end 911 912 -- Set yes callback (old pattern) 913 function dialog:onYes(callback) 914 if type(callback) ~= "function" then 915 error("onYes requires a function") 916 end 917 self.yesCallback = callback 918 return self 919 end 920 921 -- Set no callback (old pattern) 922 function dialog:onNo(callback) 923 if type(callback) ~= "function" then 924 error("onNo requires a function") 925 end 926 self.noCallback = callback 927 return self 928 end 929 930 -- Close dialog 931 function dialog:close() 932 if self.window then 933 self.window:close() 934 self.window = nil 935 self._isHidden = true 936 end 937 end 938 939 -- Show dialog (old pattern) 940 function dialog:show() 941 if not self._isHidden then 942 return self -- Already showing 943 end 944 self:_createWindow() 945 return self 946 end 947 948 -- Internal: Create window 949 function dialog:_createWindow() 950 if self.window then 951 return -- Already created 952 end 953 954 self._isHidden = false 955 956 -- Create dialog window centered on screen 957 local screenWidth = 1024 958 local screenHeight = 768 959 local x = math.floor((screenWidth - width) / 2) 960 local y = math.floor((screenHeight - height) / 2) 961 962 self.window = self.app:newWindow(x, y, width, height) 963 self.window.title = title 964 965 -- Draw callback 966 self.window.onDraw = function(gfx) 967 -- Background 968 gfx:fillRect(0, 0, width, height, 0x2C2C2C) 969 970 -- Message area 971 local messageY = 30 972 local messageHeight = height - 80 973 974 -- Word wrap the message 975 local maxChars = math.floor((width - 20) / 6) -- Approximate chars per line 976 local lines = {} 977 local currentLine = "" 978 979 for word in self.message:gmatch("%S+") do 980 if #currentLine + #word + 1 > maxChars then 981 if #currentLine > 0 then 982 table.insert(lines, currentLine) 983 currentLine = word 984 else 985 -- Word is too long, just add it 986 table.insert(lines, word) 987 end 988 else 989 if #currentLine > 0 then 990 currentLine = currentLine .. " " .. word 991 else 992 currentLine = word 993 end 994 end 995 end 996 997 if #currentLine > 0 then 998 table.insert(lines, currentLine) 999 end 1000 1001 -- Draw message lines 1002 local lineHeight = 15 1003 local startY = messageY + math.floor((messageHeight - (#lines * lineHeight)) / 2) 1004 1005 for i, line in ipairs(lines) do 1006 local textX = math.floor((width - (#line * 6)) / 2) -- Center text 1007 gfx:drawText(textX, startY + (i - 1) * lineHeight, line, 0xFFFFFF) 1008 end 1009 1010 -- Buttons area 1011 local buttonY = height - 40 1012 local buttonWidth = 80 1013 local buttonHeight = 25 1014 local buttonSpacing = 20 1015 1016 -- Calculate button positions (centered) 1017 local totalWidth = buttonWidth * 2 + buttonSpacing 1018 local startX = math.floor((width - totalWidth) / 2) 1019 1020 -- No button (left) 1021 local noX = startX 1022 gfx:fillRect(noX, buttonY, buttonWidth, buttonHeight, 0x555555) 1023 gfx:drawRect(noX, buttonY, buttonWidth, buttonHeight, 0x777777) 1024 1025 local noTextWidth = #self.noText * 6 1026 local noTextX = noX + math.floor((buttonWidth - noTextWidth) / 2) 1027 gfx:drawText(noTextX, buttonY + 7, self.noText, 0xFFFFFF) 1028 1029 -- Yes button (right) 1030 local yesX = startX + buttonWidth + buttonSpacing 1031 gfx:fillRect(yesX, buttonY, buttonWidth, buttonHeight, 0x0066CC) 1032 gfx:drawRect(yesX, buttonY, buttonWidth, buttonHeight, 0x0088EE) 1033 1034 local yesTextWidth = #self.yesText * 6 1035 local yesTextX = yesX + math.floor((buttonWidth - yesTextWidth) / 2) 1036 gfx:drawText(yesTextX, buttonY + 7, self.yesText, 0xFFFFFF) 1037 end 1038 1039 -- Click handler 1040 self.window.onClick = function(mx, my) 1041 local buttonY = height - 40 1042 local buttonWidth = 80 1043 local buttonHeight = 25 1044 local buttonSpacing = 20 1045 1046 local totalWidth = buttonWidth * 2 + buttonSpacing 1047 local startX = math.floor((width - totalWidth) / 2) 1048 1049 -- No button 1050 local noX = startX 1051 if mx >= noX and mx < noX + buttonWidth and my >= buttonY and my < buttonY + buttonHeight then 1052 if self.noCallback then 1053 self.noCallback() 1054 end 1055 return 1056 end 1057 1058 -- Yes button 1059 local yesX = startX + buttonWidth + buttonSpacing 1060 if mx >= yesX and mx < yesX + buttonWidth and my >= buttonY and my < buttonY + buttonHeight then 1061 if self.yesCallback then 1062 self.yesCallback() 1063 end 1064 return 1065 end 1066 end 1067 1068 return self 1069 end 1070 1071 return dialog 1072 end 1073 1074 -- Prompt Dialog 1075 -- Creates a text input dialog with optional autocomplete values 1076 -- @param message The message/question to display (default: "Enter value:") 1077 -- @param autocompleteValues Optional array of strings for autocomplete (default: nil) 1078 -- @param options Optional table with fields: 1079 -- - app: Application instance (required) 1080 -- - title: Dialog title (default: "Prompt") 1081 -- - width: Dialog width (default: 350) 1082 -- - height: Dialog height (default: 200) 1083 -- @return Dialog object with methods: 1084 -- - openDialog(callback): Show dialog and set callback for result (callback receives text or nil) 1085 -- - onSuccess(callback): Set callback for when text is submitted 1086 -- - onCancel(callback): Set callback for when dialog is cancelled 1087 -- - show(): Show the dialog 1088 -- - close(): Close the dialog 1089 function Dialog.prompt(message, autocompleteValues, options) 1090 -- Handle if called with just options (no message) 1091 if type(message) == "table" and autocompleteValues == nil and options == nil then 1092 options = message 1093 message = nil 1094 autocompleteValues = nil 1095 end 1096 1097 if not options or not options.app then 1098 error("Dialog.prompt requires options.app (Application instance)") 1099 end 1100 1101 local app = options.app 1102 message = message or "Enter value:" 1103 autocompleteValues = autocompleteValues or {} 1104 local title = options.title or "Prompt" 1105 local width = options.width or 350 1106 local height = options.height or 200 1107 1108 local dialog = { 1109 app = app, 1110 message = message, 1111 autocompleteValues = autocompleteValues, 1112 inputText = "", 1113 window = nil, 1114 successCallback = nil, 1115 cancelCallback = nil, 1116 inputActive = false, 1117 filteredAutocomplete = {}, 1118 selectedAutocompleteIndex = 0, 1119 _isHidden = true 1120 } 1121 1122 -- Open dialog with callback (new pattern) 1123 function dialog:openDialog(callback) 1124 if type(callback) ~= "function" then 1125 error("openDialog requires a function") 1126 end 1127 1128 -- Set callbacks to invoke the single callback with result 1129 self.successCallback = function(text) 1130 callback(text) 1131 end 1132 self.cancelCallback = function() 1133 callback(nil) 1134 end 1135 1136 -- Create and show window 1137 self:_createWindow() 1138 return self 1139 end 1140 1141 -- Set success callback (old pattern) 1142 function dialog:onSuccess(callback) 1143 if type(callback) ~= "function" then 1144 error("onSuccess requires a function") 1145 end 1146 self.successCallback = callback 1147 return self 1148 end 1149 1150 -- Set cancel callback (old pattern) 1151 function dialog:onCancel(callback) 1152 if type(callback) ~= "function" then 1153 error("onCancel requires a function") 1154 end 1155 self.cancelCallback = callback 1156 return self 1157 end 1158 1159 -- Update autocomplete suggestions based on current input 1160 function dialog:updateAutocomplete() 1161 self.filteredAutocomplete = {} 1162 self.selectedAutocompleteIndex = 0 1163 1164 if #self.inputText > 0 and #self.autocompleteValues > 0 then 1165 local lowerInput = self.inputText:lower() 1166 for _, value in ipairs(self.autocompleteValues) do 1167 if value:lower():find(lowerInput, 1, true) == 1 then 1168 table.insert(self.filteredAutocomplete, value) 1169 end 1170 end 1171 end 1172 end 1173 1174 -- Close dialog 1175 function dialog:close() 1176 if self.window then 1177 self.window:close() 1178 self.window = nil 1179 self._isHidden = true 1180 end 1181 end 1182 1183 -- Show dialog (old pattern) 1184 function dialog:show() 1185 if not self._isHidden then 1186 return self -- Already showing 1187 end 1188 self:_createWindow() 1189 return self 1190 end 1191 1192 -- Internal: Create window 1193 function dialog:_createWindow() 1194 if self.window then 1195 return -- Already created 1196 end 1197 1198 self._isHidden = false 1199 1200 -- Create dialog window centered on screen 1201 local screenWidth = 1024 1202 local screenHeight = 768 1203 local x = math.floor((screenWidth - width) / 2) 1204 local y = math.floor((screenHeight - height) / 2) 1205 1206 self.window = self.app:newWindow(x, y, width, height) 1207 self.window.title = title 1208 self.inputActive = true -- Activate input by default 1209 1210 -- Draw callback 1211 self.window.onDraw = function(gfx) 1212 -- Background 1213 gfx:fillRect(0, 0, width, height, 0x2C2C2C) 1214 1215 -- Message area 1216 local messageY = 20 1217 gfx:drawText(10, messageY, self.message, 0xCCCCCC) 1218 1219 -- Input box 1220 local inputY = messageY + 30 1221 local inputWidth = width - 20 1222 local inputHeight = 25 1223 1224 local inputBgColor = self.inputActive and 0x4C4C4C or 0x383838 1225 gfx:fillRect(10, inputY, inputWidth, inputHeight, inputBgColor) 1226 gfx:drawRect(10, inputY, inputWidth, inputHeight, 0x666666) 1227 1228 -- Input text 1229 local displayText = self.inputText 1230 if #displayText > 50 then 1231 displayText = displayText:sub(-50) 1232 end 1233 gfx:drawText(15, inputY + 7, displayText, 0xFFFFFF) 1234 1235 -- Cursor if active 1236 if self.inputActive then 1237 local cursorX = 15 + (#displayText * 6) -- Approximate char width 1238 gfx:fillRect(cursorX, inputY + 5, 2, 15, 0xFFFFFF) 1239 end 1240 1241 -- Autocomplete suggestions 1242 local autocompleteY = inputY + 30 1243 if #self.filteredAutocomplete > 0 then 1244 local maxSuggestions = 5 1245 for i = 1, math.min(maxSuggestions, #self.filteredAutocomplete) do 1246 local value = self.filteredAutocomplete[i] 1247 local suggestionHeight = 20 1248 local y = autocompleteY + (i - 1) * suggestionHeight 1249 1250 -- Highlight selected suggestion 1251 local bgColor = (i == self.selectedAutocompleteIndex) and 0x4C4C4C or 0x383838 1252 gfx:fillRect(10, y, inputWidth, suggestionHeight, bgColor) 1253 1254 -- Truncate if too long 1255 local displayValue = value 1256 if #displayValue > 50 then 1257 displayValue = displayValue:sub(1, 47) .. "..." 1258 end 1259 1260 gfx:drawText(15, y + 4, displayValue, 0xCCCCCC) 1261 end 1262 end 1263 1264 -- Buttons area 1265 local buttonY = height - 40 1266 local buttonWidth = 80 1267 local buttonHeight = 25 1268 local buttonSpacing = 20 1269 1270 -- Calculate button positions (centered) 1271 local totalWidth = buttonWidth * 2 + buttonSpacing 1272 local startX = math.floor((width - totalWidth) / 2) 1273 1274 -- Cancel button (left) 1275 local cancelX = startX 1276 gfx:fillRect(cancelX, buttonY, buttonWidth, buttonHeight, 0x555555) 1277 gfx:drawRect(cancelX, buttonY, buttonWidth, buttonHeight, 0x777777) 1278 gfx:drawText(cancelX + 18, buttonY + 7, "Cancel", 0xFFFFFF) 1279 1280 -- OK button (right) 1281 local okX = startX + buttonWidth + buttonSpacing 1282 gfx:fillRect(okX, buttonY, buttonWidth, buttonHeight, 0x0066CC) 1283 gfx:drawRect(okX, buttonY, buttonWidth, buttonHeight, 0x0088EE) 1284 gfx:drawText(okX + 30, buttonY + 7, "OK", 0xFFFFFF) 1285 end 1286 1287 -- Input handler for text typing 1288 self.window.onInput = function(key, scancode) 1289 if not self.inputActive then 1290 return 1291 end 1292 1293 if key == "\b" then 1294 -- Backspace 1295 if #self.inputText > 0 then 1296 self.inputText = self.inputText:sub(1, -2) 1297 self:updateAutocomplete() 1298 self.window:markDirty() 1299 end 1300 elseif key == "\n" then 1301 -- Enter key - submit 1302 if self.successCallback then 1303 self.successCallback(self.inputText) 1304 end 1305 elseif key == "\t" then 1306 -- Tab - autocomplete if available 1307 if self.selectedAutocompleteIndex > 0 and self.selectedAutocompleteIndex <= #self.filteredAutocomplete then 1308 self.inputText = self.filteredAutocomplete[self.selectedAutocompleteIndex] 1309 self:updateAutocomplete() 1310 self.window:markDirty() 1311 elseif #self.filteredAutocomplete > 0 then 1312 self.inputText = self.filteredAutocomplete[1] 1313 self:updateAutocomplete() 1314 self.window:markDirty() 1315 end 1316 elseif #key == 1 then 1317 -- Regular character 1318 self.inputText = self.inputText .. key 1319 self:updateAutocomplete() 1320 self.window:markDirty() 1321 end 1322 end 1323 1324 -- Click handler 1325 self.window.onClick = function(mx, my) 1326 local messageY = 20 1327 local inputY = messageY + 30 1328 local inputWidth = width - 20 1329 local inputHeight = 25 1330 1331 -- Check if click is on input box 1332 if mx >= 10 and mx < 10 + inputWidth and my >= inputY and my < inputY + inputHeight then 1333 self.inputActive = true 1334 self.window:markDirty() 1335 1336 -- Focus window for input 1337 if sys and sys.setActiveWindow then 1338 sys.setActiveWindow(self.window) 1339 end 1340 return 1341 end 1342 1343 -- Check if click is on autocomplete suggestion 1344 local autocompleteY = inputY + 30 1345 if #self.filteredAutocomplete > 0 then 1346 local maxSuggestions = 5 1347 for i = 1, math.min(maxSuggestions, #self.filteredAutocomplete) do 1348 local suggestionHeight = 20 1349 local y = autocompleteY + (i - 1) * suggestionHeight 1350 1351 if mx >= 10 and mx < 10 + inputWidth and my >= y and my < y + suggestionHeight then 1352 -- Clicked on suggestion - fill input 1353 self.inputText = self.filteredAutocomplete[i] 1354 self:updateAutocomplete() 1355 self.window:markDirty() 1356 return 1357 end 1358 end 1359 end 1360 1361 -- Check if click is on buttons 1362 local buttonY = height - 40 1363 local buttonWidth = 80 1364 local buttonHeight = 25 1365 local buttonSpacing = 20 1366 1367 local totalWidth = buttonWidth * 2 + buttonSpacing 1368 local startX = math.floor((width - totalWidth) / 2) 1369 1370 -- Cancel button 1371 local cancelX = startX 1372 if mx >= cancelX and mx < cancelX + buttonWidth and my >= buttonY and my < buttonY + buttonHeight then 1373 if self.cancelCallback then 1374 self.cancelCallback() 1375 end 1376 return 1377 end 1378 1379 -- OK button 1380 local okX = startX + buttonWidth + buttonSpacing 1381 if mx >= okX and mx < okX + buttonWidth and my >= buttonY and my < buttonY + buttonHeight then 1382 if self.successCallback then 1383 self.successCallback(self.inputText) 1384 end 1385 return 1386 end 1387 end 1388 1389 return self 1390 end 1391 1392 return dialog 1393 end 1394 1395 -- Alert Dialog 1396 -- Creates an instant alert dialog with just an OK button 1397 -- Shows immediately without needing openDialog() call 1398 -- @param message The message to display (default: "Alert") 1399 -- @param options Optional table with fields: 1400 -- - app: Application instance (optional, uses sandbox app if not provided) 1401 -- - title: Dialog title (default: "Alert") 1402 -- - width: Dialog width (default: 300) 1403 -- - height: Dialog height (default: 150) 1404 -- @return Dialog object with close() method 1405 function Dialog.alert(message, options) 1406 -- Handle if called with just options (no message) 1407 if type(message) == "table" and options == nil then 1408 options = message 1409 message = nil 1410 end 1411 1412 options = options or {} 1413 1414 -- Use sandbox app if not provided 1415 local app = options.app or app 1416 if not app then 1417 error("Dialog.alert requires app instance (provide options.app or use from sandboxed context)") 1418 end 1419 message = message or "Alert" 1420 local title = options.title or "Alert" 1421 local width = options.width or 300 1422 local height = options.height or 150 1423 1424 local dialog = { 1425 app = app, 1426 message = message, 1427 window = nil 1428 } 1429 1430 -- Close dialog 1431 function dialog:close() 1432 if self.window then 1433 self.window:close() 1434 self.window = nil 1435 end 1436 end 1437 1438 -- Create and show window immediately 1439 local screenWidth = 1024 1440 local screenHeight = 768 1441 local x = math.floor((screenWidth - width) / 2) 1442 local y = math.floor((screenHeight - height) / 2) 1443 1444 dialog.window = app:newWindow(x, y, width, height) 1445 dialog.window.title = title 1446 1447 -- Draw callback 1448 dialog.window.onDraw = function(gfx) 1449 -- Background 1450 gfx:fillRect(0, 0, width, height, 0x2C2C2C) 1451 1452 -- Message area 1453 local messageY = 30 1454 local messageHeight = height - 80 1455 1456 -- Word wrap the message 1457 local maxChars = math.floor((width - 20) / 6) -- Approximate chars per line 1458 local lines = {} 1459 local currentLine = "" 1460 1461 for word in dialog.message:gmatch("%S+") do 1462 if #currentLine + #word + 1 > maxChars then 1463 if #currentLine > 0 then 1464 table.insert(lines, currentLine) 1465 currentLine = word 1466 else 1467 -- Word is too long, just add it 1468 table.insert(lines, word) 1469 end 1470 else 1471 if #currentLine > 0 then 1472 currentLine = currentLine .. " " .. word 1473 else 1474 currentLine = word 1475 end 1476 end 1477 end 1478 1479 if #currentLine > 0 then 1480 table.insert(lines, currentLine) 1481 end 1482 1483 -- Draw message lines 1484 local lineHeight = 15 1485 local startY = messageY + math.floor((messageHeight - (#lines * lineHeight)) / 2) 1486 1487 for i, line in ipairs(lines) do 1488 local textX = math.floor((width - (#line * 6)) / 2) -- Center text 1489 gfx:drawText(textX, startY + (i - 1) * lineHeight, line, 0xFFFFFF) 1490 end 1491 1492 -- OK button area 1493 local buttonY = height - 40 1494 local buttonWidth = 80 1495 local buttonHeight = 25 1496 1497 -- Center the OK button 1498 local okX = math.floor((width - buttonWidth) / 2) 1499 gfx:fillRect(okX, buttonY, buttonWidth, buttonHeight, 0x0066CC) 1500 gfx:drawRect(okX, buttonY, buttonWidth, buttonHeight, 0x0088EE) 1501 gfx:drawText(okX + 30, buttonY + 7, "OK", 0xFFFFFF) 1502 end 1503 1504 -- Click handler 1505 dialog.window.onClick = function(mx, my) 1506 local buttonY = height - 40 1507 local buttonWidth = 80 1508 local buttonHeight = 25 1509 local okX = math.floor((width - buttonWidth) / 2) 1510 1511 -- Check if click is on OK button 1512 if mx >= okX and mx < okX + buttonWidth and my >= buttonY and my < buttonY + buttonHeight then 1513 dialog:close() 1514 return 1515 end 1516 end 1517 1518 return dialog 1519 end 1520 1521 -- Password Prompt Dialog 1522 -- Creates a password input dialog that masks the input with asterisks 1523 -- @param message The message/question to display (default: "Enter password:") 1524 -- @param options Optional table with fields: 1525 -- - app: Application instance (optional, uses sandbox app if not provided) 1526 -- - title: Dialog title (default: "Password") 1527 -- - width: Dialog width (default: 350) 1528 -- - height: Dialog height (default: 180) 1529 -- @return Dialog object with methods: 1530 -- - openDialog(callback): Show dialog and set callback for result (callback receives password or nil) 1531 -- - onSuccess(callback): Set callback for when password is submitted 1532 -- - onCancel(callback): Set callback for when dialog is cancelled 1533 -- - show(): Show the dialog 1534 -- - close(): Close the dialog 1535 function Dialog.promptPassword(message, options) 1536 -- Handle if called with just options (no message) 1537 if type(message) == "table" and options == nil then 1538 options = message 1539 message = nil 1540 end 1541 1542 options = options or {} 1543 1544 -- Use sandbox app if not provided 1545 local app_instance = options.app or app 1546 if not app_instance then 1547 error("Dialog.promptPassword requires app instance (provide options.app or use from sandboxed context)") 1548 end 1549 1550 message = message or "Enter password:" 1551 local title = options.title or "Password" 1552 local width = options.width or 350 1553 local height = options.height or 180 1554 1555 local dialog = { 1556 app = app_instance, 1557 message = message, 1558 inputText = "", 1559 window = nil, 1560 successCallback = nil, 1561 cancelCallback = nil, 1562 inputActive = false, 1563 _isHidden = true 1564 } 1565 1566 -- Open dialog with callback (new pattern) 1567 function dialog:openDialog(callback) 1568 if type(callback) ~= "function" then 1569 error("openDialog requires a function") 1570 end 1571 1572 -- Set callbacks to invoke the single callback with result, then close 1573 self.successCallback = function(password) 1574 callback(password) 1575 self:close() 1576 end 1577 self.cancelCallback = function() 1578 callback(nil) 1579 self:close() 1580 end 1581 1582 -- Create and show window 1583 self:_createWindow() 1584 return self 1585 end 1586 1587 -- Set success callback (old pattern) 1588 function dialog:onSuccess(callback) 1589 if type(callback) ~= "function" then 1590 error("onSuccess requires a function") 1591 end 1592 self.successCallback = callback 1593 return self 1594 end 1595 1596 -- Set cancel callback (old pattern) 1597 function dialog:onCancel(callback) 1598 if type(callback) ~= "function" then 1599 error("onCancel requires a function") 1600 end 1601 self.cancelCallback = callback 1602 return self 1603 end 1604 1605 -- Close dialog 1606 function dialog:close() 1607 if self.window then 1608 self.window:close() 1609 self.window = nil 1610 self._isHidden = true 1611 end 1612 end 1613 1614 -- Show dialog (old pattern) 1615 function dialog:show() 1616 if not self._isHidden then 1617 return self -- Already showing 1618 end 1619 self:_createWindow() 1620 return self 1621 end 1622 1623 -- Internal: Create window 1624 function dialog:_createWindow() 1625 if self.window then 1626 return -- Already created 1627 end 1628 1629 self._isHidden = false 1630 1631 -- Create dialog window centered on screen 1632 local screenWidth = 1024 1633 local screenHeight = 768 1634 local x = math.floor((screenWidth - width) / 2) 1635 local y = math.floor((screenHeight - height) / 2) 1636 1637 self.window = self.app:newWindow(x, y, width, height) 1638 self.window.title = title 1639 self.inputActive = true -- Activate input by default 1640 1641 -- Draw callback 1642 self.window.onDraw = function(gfx) 1643 -- Background 1644 gfx:fillRect(0, 0, width, height, 0x2C2C2C) 1645 1646 -- Message area 1647 local messageY = 20 1648 gfx:drawText(10, messageY, self.message, 0xCCCCCC) 1649 1650 -- Input box 1651 local inputY = messageY + 30 1652 local inputWidth = width - 20 1653 local inputHeight = 25 1654 1655 local inputBgColor = self.inputActive and 0x4C4C4C or 0x383838 1656 gfx:fillRect(10, inputY, inputWidth, inputHeight, inputBgColor) 1657 gfx:drawRect(10, inputY, inputWidth, inputHeight, 0x666666) 1658 1659 -- Display asterisks instead of actual password 1660 local maskedText = string.rep("*", #self.inputText) 1661 if #maskedText > 50 then 1662 maskedText = maskedText:sub(-50) 1663 end 1664 gfx:drawText(15, inputY + 7, maskedText, 0xFFFFFF) 1665 1666 -- Cursor if active 1667 if self.inputActive then 1668 local cursorX = 15 + (#maskedText * 6) -- Approximate char width 1669 gfx:fillRect(cursorX, inputY + 5, 2, 15, 0xFFFFFF) 1670 end 1671 1672 -- Buttons area 1673 local buttonY = height - 40 1674 local buttonWidth = 80 1675 local buttonHeight = 25 1676 local buttonSpacing = 20 1677 1678 -- Calculate button positions (centered) 1679 local totalWidth = buttonWidth * 2 + buttonSpacing 1680 local startX = math.floor((width - totalWidth) / 2) 1681 1682 -- Cancel button (left) 1683 local cancelX = startX 1684 gfx:fillRect(cancelX, buttonY, buttonWidth, buttonHeight, 0x555555) 1685 gfx:drawRect(cancelX, buttonY, buttonWidth, buttonHeight, 0x777777) 1686 gfx:drawText(cancelX + 18, buttonY + 7, "Cancel", 0xFFFFFF) 1687 1688 -- OK button (right) 1689 local okX = startX + buttonWidth + buttonSpacing 1690 gfx:fillRect(okX, buttonY, buttonWidth, buttonHeight, 0x0066CC) 1691 gfx:drawRect(okX, buttonY, buttonWidth, buttonHeight, 0x0088EE) 1692 gfx:drawText(okX + 30, buttonY + 7, "OK", 0xFFFFFF) 1693 end 1694 1695 -- Input handler for password typing 1696 self.window.onInput = function(key, scancode) 1697 if not self.inputActive then 1698 return 1699 end 1700 1701 if key == "\b" then 1702 -- Backspace 1703 if #self.inputText > 0 then 1704 self.inputText = self.inputText:sub(1, -2) 1705 self.window:markDirty() 1706 end 1707 elseif key == "\n" then 1708 -- Enter key - submit 1709 if self.successCallback then 1710 self.successCallback(self.inputText) 1711 end 1712 elseif key == "\t" then 1713 -- Tab - ignore 1714 elseif #key == 1 then 1715 -- Regular character 1716 self.inputText = self.inputText .. key 1717 self.window:markDirty() 1718 end 1719 end 1720 1721 -- Click handler 1722 self.window.onClick = function(mx, my) 1723 local messageY = 20 1724 local inputY = messageY + 30 1725 local inputWidth = width - 20 1726 local inputHeight = 25 1727 1728 -- Check if click is on input box 1729 if mx >= 10 and mx < 10 + inputWidth and my >= inputY and my < inputY + inputHeight then 1730 self.inputActive = true 1731 self.window:markDirty() 1732 1733 -- Focus window for input 1734 if sys and sys.setActiveWindow then 1735 sys.setActiveWindow(self.window) 1736 end 1737 return 1738 end 1739 1740 -- Check if click is on buttons 1741 local buttonY = height - 40 1742 local buttonWidth = 80 1743 local buttonHeight = 25 1744 local buttonSpacing = 20 1745 1746 local totalWidth = buttonWidth * 2 + buttonSpacing 1747 local startX = math.floor((width - totalWidth) / 2) 1748 1749 -- Cancel button 1750 local cancelX = startX 1751 if mx >= cancelX and mx < cancelX + buttonWidth and my >= buttonY and my < buttonY + buttonHeight then 1752 if self.cancelCallback then 1753 self.cancelCallback() 1754 end 1755 return 1756 end 1757 1758 -- OK button 1759 local okX = startX + buttonWidth + buttonSpacing 1760 if mx >= okX and mx < okX + buttonWidth and my >= buttonY and my < buttonY + buttonHeight then 1761 if self.successCallback then 1762 self.successCallback(self.inputText) 1763 end 1764 return 1765 end 1766 end 1767 1768 return self 1769 end 1770 1771 return dialog 1772 end 1773 1774 -- HTML Dialog 1775 -- Creates a dialog window that renders HTML content using the moon browser 1776 -- The HTML has access to the calling process's environment via window.env 1777 -- @param html The HTML string to render 1778 -- @param options Optional table with fields: 1779 -- - app: Application instance (required) 1780 -- - title: Dialog title (default: "HTML Dialog") 1781 -- - width: Dialog width (default: 400) 1782 -- - height: Dialog height (default: 200) 1783 -- - env: Environment table to expose to HTML scripts (default: calling sandbox) 1784 -- - onClose: Callback when dialog is closed 1785 -- @return Dialog object with close() method 1786 function Dialog.html(html, options) 1787 if not html or type(html) ~= "string" then 1788 error("Dialog.html requires an HTML string") 1789 end 1790 1791 options = options or {} 1792 1793 local app_instance = options.app or app 1794 if not app_instance then 1795 error("Dialog.html requires app instance (provide options.app or use from sandboxed context)") 1796 end 1797 1798 local title = options.title or "HTML Dialog" 1799 local width = options.width or 400 1800 local height = options.height or 200 1801 local env = options.env or {} 1802 local onCloseCallback = options.onClose 1803 1804 local dialog = { 1805 app = app_instance, 1806 html = html, 1807 window = nil, 1808 dom = nil, 1809 renderer = nil, 1810 scrollY = 0, 1811 env = env 1812 } 1813 1814 -- Close dialog 1815 function dialog:close() 1816 if self.window then 1817 self.window:close() 1818 self.window = nil 1819 if onCloseCallback then 1820 onCloseCallback() 1821 end 1822 end 1823 end 1824 1825 -- Load moonbrowser modules 1826 local function loadModule(path) 1827 local code = nil 1828 -- Try .luac first 1829 if _G.CRamdiskExists and _G.CRamdiskExists(path .. "c") then 1830 local handle = _G.CRamdiskOpen(path .. "c", "r") 1831 if handle then 1832 code = _G.CRamdiskRead(handle) 1833 _G.CRamdiskClose(handle) 1834 end 1835 end 1836 -- Fall back to .lua 1837 if not code and _G.CRamdiskExists and _G.CRamdiskExists(path) then 1838 local handle = _G.CRamdiskOpen(path, "r") 1839 if handle then 1840 code = _G.CRamdiskRead(handle) 1841 _G.CRamdiskClose(handle) 1842 end 1843 end 1844 return code 1845 end 1846 1847 -- Load DOM module 1848 local domCode = loadModule("/apps/com.luajitos.moonbrowser/src/dom.lua") 1849 if not domCode then 1850 error("Dialog.html: Could not load DOM module") 1851 end 1852 1853 local domEnv = setmetatable({}, { __index = _G, __metatable = false }) 1854 local domFunc, domErr = loadstring(domCode, "dom") 1855 if not domFunc then 1856 error("Dialog.html: Failed to compile DOM: " .. tostring(domErr)) 1857 end 1858 setfenv(domFunc, domEnv) 1859 local domOk, domModule = pcall(domFunc) 1860 if not domOk then 1861 error("Dialog.html: Failed to execute DOM: " .. tostring(domModule)) 1862 end 1863 1864 -- Load parser module 1865 local parserCode = loadModule("/apps/com.luajitos.moonbrowser/src/parser.lua") 1866 if not parserCode then 1867 error("Dialog.html: Could not load parser module") 1868 end 1869 1870 local parserEnv = setmetatable({ 1871 require = function(name) 1872 if name == "dom" then return domModule end 1873 return nil 1874 end 1875 }, { __index = _G, __metatable = false }) 1876 local parserFunc, parserErr = loadstring(parserCode, "parser") 1877 if not parserFunc then 1878 error("Dialog.html: Failed to compile parser: " .. tostring(parserErr)) 1879 end 1880 setfenv(parserFunc, parserEnv) 1881 local parserOk, parserModule = pcall(parserFunc) 1882 if not parserOk then 1883 error("Dialog.html: Failed to execute parser: " .. tostring(parserModule)) 1884 end 1885 1886 -- Load renderer module 1887 local renderCode = loadModule("/apps/com.luajitos.moonbrowser/src/render.lua") 1888 if not renderCode then 1889 error("Dialog.html: Could not load renderer module") 1890 end 1891 1892 local renderEnv = setmetatable({}, { __index = _G, __metatable = false }) 1893 local renderFunc, renderErr = loadstring(renderCode, "render") 1894 if not renderFunc then 1895 error("Dialog.html: Failed to compile renderer: " .. tostring(renderErr)) 1896 end 1897 setfenv(renderFunc, renderEnv) 1898 local renderOk, rendererModule = pcall(renderFunc) 1899 if not renderOk then 1900 error("Dialog.html: Failed to execute renderer: " .. tostring(rendererModule)) 1901 end 1902 1903 -- Parse HTML 1904 local parseOk, dom = pcall(function() 1905 return parserModule.parse(html) 1906 end) 1907 if not parseOk or not dom then 1908 error("Dialog.html: Failed to parse HTML: " .. tostring(dom)) 1909 end 1910 dialog.dom = dom 1911 1912 -- Create renderer instance 1913 local renderer = rendererModule.new(width, height) 1914 dialog.renderer = renderer 1915 1916 -- Create window centered on screen 1917 local screenWidth = 1024 1918 local screenHeight = 768 1919 local x = math.floor((screenWidth - width) / 2) 1920 local y = math.floor((screenHeight - height) / 2) 1921 1922 dialog.window = app_instance:newWindow(x, y, width, height) 1923 dialog.window.title = title 1924 1925 -- Create script execution environment with access to caller's env 1926 local scriptEnv = setmetatable({ 1927 -- Standard globals 1928 print = print, 1929 tostring = tostring, 1930 tonumber = tonumber, 1931 type = type, 1932 pairs = pairs, 1933 ipairs = ipairs, 1934 string = string, 1935 table = table, 1936 math = math, 1937 1938 -- Window object for HTML scripts 1939 window = { 1940 env = env, -- Expose caller's environment 1941 close = function() dialog:close() end, 1942 alert = function(msg) 1943 if Dialog.alert then 1944 Dialog.alert(tostring(msg), { app = app_instance }) 1945 end 1946 end, 1947 width = width, 1948 height = height 1949 }, 1950 1951 -- Document object 1952 document = { 1953 title = title, 1954 close = function() dialog:close() end 1955 } 1956 }, { __index = _G, __metatable = false }) -- Prevent metatable access 1957 1958 -- Draw callback 1959 dialog.window.onDraw = function(gfx) 1960 -- Clear background 1961 gfx:fillRect(0, 0, width, height, 0xFFFFFF) 1962 1963 -- Render HTML 1964 if renderer and dom then 1965 local renderOk2, renderErr2 = pcall(function() 1966 renderer:render(dom, gfx, 0, -dialog.scrollY) 1967 end) 1968 if not renderOk2 and osprint then 1969 osprint("Dialog.html render error: " .. tostring(renderErr2) .. "\n") 1970 end 1971 end 1972 end 1973 1974 -- Click handler 1975 dialog.window.onClick = function(mx, my) 1976 if renderer and dom then 1977 -- Check for clickable elements 1978 local clickY = my + dialog.scrollY 1979 local element = renderer:getElementAt(mx, clickY) 1980 1981 if element then 1982 -- Handle onclick attribute 1983 if element.attributes and element.attributes.onclick then 1984 local onclick = element.attributes.onclick 1985 local clickFunc, clickErr = loadstring(onclick, "onclick") 1986 if clickFunc then 1987 setfenv(clickFunc, scriptEnv) 1988 local ok, err = pcall(clickFunc) 1989 if not ok and osprint then 1990 osprint("Dialog.html onclick error: " .. tostring(err) .. "\n") 1991 end 1992 end 1993 end 1994 1995 -- Handle link clicks 1996 if element.tag == "a" and element.attributes and element.attributes.href then 1997 local href = element.attributes.href 1998 if href:sub(1, 11) == "javascript:" then 1999 local code = href:sub(12) 2000 local jsFunc, jsErr = loadstring(code, "href") 2001 if jsFunc then 2002 setfenv(jsFunc, scriptEnv) 2003 pcall(jsFunc) 2004 end 2005 end 2006 end 2007 2008 dialog.window:markDirty() 2009 end 2010 end 2011 end 2012 2013 -- Scroll handler 2014 dialog.window.onScroll = function(delta) 2015 local contentHeight = renderer and renderer:getContentHeight() or height 2016 local maxScroll = math.max(0, contentHeight - height) 2017 dialog.scrollY = math.max(0, math.min(maxScroll, dialog.scrollY - delta * 20)) 2018 dialog.window:markDirty() 2019 end 2020 2021 return dialog 2022 end 2023 2024 -- Custom Dialog 2025 -- Creates a dialog with dynamic content based on arguments 2026 -- Usage: Dialog.customDialog("Label", "STRING", "\n", "Age:", "NUMBER", "\n", "Agree?", "BOOLEAN", "\n", "BUTTON=Cancel", "BUTTON=Ok", callback) 2027 -- @param ... Variable arguments: strings are labels, "STRING"/"NUMBER"/"BOOLEAN" are input types, 2028 -- "\n" is a line break, "BUTTON=Label" creates a button 2029 -- @param callback The last argument must be a callback function that receives all input values followed by button label 2030 -- @param options Optional table with app, title, width (can be passed as second-to-last arg before callback) 2031 function Dialog.customDialog(...) 2032 local args = {...} 2033 local callback = nil 2034 local options = {} 2035 2036 -- Last arg must be callback 2037 if type(args[#args]) == "function" then 2038 callback = table.remove(args) 2039 else 2040 error("Dialog.customDialog: last argument must be a callback function") 2041 end 2042 2043 -- Check if second-to-last is options table 2044 if type(args[#args]) == "table" and args[#args].app then 2045 options = table.remove(args) 2046 end 2047 2048 local app_instance = options.app or app 2049 if not app_instance then 2050 error("Dialog.customDialog requires app instance (provide options.app or use from sandboxed context)") 2051 end 2052 2053 local title = options.title or "Dialog" 2054 2055 -- Parse arguments to build UI elements 2056 local elements = {} -- {type, label, value, x, y, w, h} 2057 local inputs = {} -- Track input elements for collecting values 2058 local buttons = {} -- Track button elements 2059 2060 local cursorX = 10 2061 local cursorY = 20 2062 local lineHeight = 30 2063 local inputHeight = 22 2064 local checkboxSize = 18 2065 local buttonHeight = 25 2066 local buttonWidth = 80 2067 local padding = 10 2068 2069 local maxWidth = 300 2070 local maxY = cursorY 2071 2072 for i, arg in ipairs(args) do 2073 if arg == "\n" then 2074 -- Line break 2075 cursorX = 10 2076 cursorY = cursorY + lineHeight 2077 elseif arg == "STRING" then 2078 -- String input field 2079 local inputWidth = 150 2080 local input = { 2081 type = "string", 2082 x = cursorX, 2083 y = cursorY, 2084 w = inputWidth, 2085 h = inputHeight, 2086 value = "", 2087 active = false 2088 } 2089 table.insert(elements, input) 2090 table.insert(inputs, input) 2091 cursorX = cursorX + inputWidth + padding 2092 if cursorX > maxWidth then maxWidth = cursorX end 2093 elseif arg == "NUMBER" then 2094 -- Number input field 2095 local inputWidth = 80 2096 local input = { 2097 type = "number", 2098 x = cursorX, 2099 y = cursorY, 2100 w = inputWidth, 2101 h = inputHeight, 2102 value = "", 2103 active = false 2104 } 2105 table.insert(elements, input) 2106 table.insert(inputs, input) 2107 cursorX = cursorX + inputWidth + padding 2108 if cursorX > maxWidth then maxWidth = cursorX end 2109 elseif arg == "PASSWORD" then 2110 -- Password input field (masked with asterisks) 2111 local inputWidth = 150 2112 local input = { 2113 type = "password", 2114 x = cursorX, 2115 y = cursorY, 2116 w = inputWidth, 2117 h = inputHeight, 2118 value = "", 2119 active = false 2120 } 2121 table.insert(elements, input) 2122 table.insert(inputs, input) 2123 cursorX = cursorX + inputWidth + padding 2124 if cursorX > maxWidth then maxWidth = cursorX end 2125 elseif arg == "BOOLEAN" then 2126 -- Checkbox 2127 local input = { 2128 type = "boolean", 2129 x = cursorX, 2130 y = cursorY + 2, 2131 w = checkboxSize, 2132 h = checkboxSize, 2133 value = false 2134 } 2135 table.insert(elements, input) 2136 table.insert(inputs, input) 2137 cursorX = cursorX + checkboxSize + padding 2138 if cursorX > maxWidth then maxWidth = cursorX end 2139 elseif type(arg) == "string" and arg:sub(1, 7) == "BUTTON=" then 2140 -- Button 2141 local label = arg:sub(8) 2142 local btn = { 2143 type = "button", 2144 label = label, 2145 x = 0, -- Will be positioned later 2146 y = 0, 2147 w = buttonWidth, 2148 h = buttonHeight 2149 } 2150 table.insert(buttons, btn) 2151 elseif type(arg) == "string" then 2152 -- Text label 2153 local textWidth = #arg * 7 + 5 -- Approximate width 2154 local label = { 2155 type = "label", 2156 text = arg, 2157 x = cursorX, 2158 y = cursorY + 3, 2159 w = textWidth, 2160 h = lineHeight 2161 } 2162 table.insert(elements, label) 2163 cursorX = cursorX + textWidth + 5 2164 if cursorX > maxWidth then maxWidth = cursorX end 2165 end 2166 2167 if cursorY + lineHeight > maxY then 2168 maxY = cursorY + lineHeight 2169 end 2170 end 2171 2172 -- Position buttons at the bottom 2173 cursorY = maxY + 10 2174 local totalButtonWidth = #buttons * buttonWidth + (#buttons - 1) * padding 2175 local buttonStartX = math.floor((maxWidth - totalButtonWidth) / 2) 2176 if buttonStartX < 10 then buttonStartX = 10 end 2177 2178 for i, btn in ipairs(buttons) do 2179 btn.x = buttonStartX + (i - 1) * (buttonWidth + padding) 2180 btn.y = cursorY 2181 table.insert(elements, btn) 2182 end 2183 2184 -- Calculate dialog size 2185 local width = options.width or math.max(maxWidth + 20, 200) 2186 local height = cursorY + buttonHeight + 20 2187 2188 -- Create dialog object 2189 local dialog = { 2190 app = app_instance, 2191 window = nil, 2192 elements = elements, 2193 inputs = inputs, 2194 buttons = buttons, 2195 activeInput = nil, 2196 callback = callback 2197 } 2198 2199 -- Collect input values and call callback 2200 -- If callback returns true, keep dialog open 2201 -- If callback returns a number, clear that input index and focus it 2202 function dialog:submit(buttonLabel) 2203 local values = {} 2204 for _, input in ipairs(self.inputs) do 2205 if input.type == "string" or input.type == "password" then 2206 table.insert(values, input.value) 2207 elseif input.type == "number" then 2208 table.insert(values, tonumber(input.value) or 0) 2209 elseif input.type == "boolean" then 2210 table.insert(values, input.value) 2211 end 2212 end 2213 table.insert(values, buttonLabel) 2214 2215 local result = nil 2216 if self.callback then 2217 result = self.callback(unpack(values)) 2218 end 2219 2220 if result == true then 2221 -- Keep dialog open, do nothing 2222 return 2223 elseif type(result) == "number" then 2224 -- Clear the input at that index and focus it 2225 local inputIndex = result 2226 if inputIndex >= 1 and inputIndex <= #self.inputs then 2227 local input = self.inputs[inputIndex] 2228 -- Clear value 2229 if input.type == "boolean" then 2230 input.value = false 2231 else 2232 input.value = "" 2233 end 2234 -- Deactivate current input 2235 if self.activeInput then 2236 self.activeInput.active = false 2237 end 2238 -- Focus the specified input 2239 input.active = true 2240 self.activeInput = input 2241 if self.window then 2242 self.window:markDirty() 2243 end 2244 end 2245 return 2246 end 2247 2248 -- Default: close the dialog 2249 if self.window then 2250 self.window:close() 2251 end 2252 end 2253 2254 -- Close method 2255 function dialog:close() 2256 if self.window then 2257 self.window:close() 2258 self.window = nil 2259 end 2260 end 2261 2262 -- Show method 2263 function dialog:show() 2264 if self.window then return self end 2265 2266 -- Create window 2267 self.window = self.app:newWindow(title, width, height) 2268 2269 -- Draw callback 2270 self.window.onDraw = function(gfx) 2271 -- Background 2272 gfx:fillRect(0, 0, width, height, 0xF0F0F0) 2273 2274 -- Draw all elements 2275 for _, elem in ipairs(self.elements) do 2276 if elem.type == "label" then 2277 gfx:drawText(elem.x, elem.y, elem.text, 0x000000) 2278 2279 elseif elem.type == "string" or elem.type == "number" or elem.type == "password" then 2280 -- Input field background 2281 local bgColor = elem.active and 0xFFFFFF or 0xFAFAFA 2282 local borderColor = elem.active and 0x0066CC or 0x888888 2283 gfx:fillRect(elem.x, elem.y, elem.w, elem.h, bgColor) 2284 gfx:drawRect(elem.x, elem.y, elem.w, elem.h, borderColor) 2285 -- Text (show asterisks for password) 2286 local displayText 2287 if elem.type == "password" then 2288 displayText = string.rep("*", #elem.value) 2289 else 2290 displayText = elem.value 2291 end 2292 if elem.active then 2293 displayText = displayText .. "|" 2294 end 2295 gfx:drawText(elem.x + 3, elem.y + 4, displayText, 0x000000) 2296 2297 elseif elem.type == "boolean" then 2298 -- Checkbox 2299 gfx:fillRect(elem.x, elem.y, elem.w, elem.h, 0xFFFFFF) 2300 gfx:drawRect(elem.x, elem.y, elem.w, elem.h, 0x666666) 2301 if elem.value then 2302 -- Draw checkmark 2303 local cx, cy = elem.x + elem.w/2, elem.y + elem.h/2 2304 gfx:fillRect(elem.x + 4, elem.y + 4, elem.w - 8, elem.h - 8, 0x0066CC) 2305 end 2306 2307 elseif elem.type == "button" then 2308 -- Button 2309 gfx:fillRect(elem.x, elem.y, elem.w, elem.h, 0xDDDDDD) 2310 gfx:drawRect(elem.x, elem.y, elem.w, elem.h, 0x888888) 2311 local textX = elem.x + math.floor((elem.w - #elem.label * 7) / 2) 2312 local textY = elem.y + 6 2313 gfx:drawText(textX, textY, elem.label, 0x000000) 2314 end 2315 end 2316 end 2317 2318 -- Key handler 2319 self.window.onKey = function(key) 2320 if self.activeInput then 2321 local input = self.activeInput 2322 if key == "\b" then 2323 -- Backspace 2324 if #input.value > 0 then 2325 input.value = input.value:sub(1, -2) 2326 self.window:markDirty() 2327 end 2328 elseif key == "\n" then 2329 -- Enter - deactivate input 2330 input.active = false 2331 self.activeInput = nil 2332 self.window:markDirty() 2333 elseif key == "\t" then 2334 -- Tab - move to next input 2335 input.active = false 2336 local found = false 2337 for i, inp in ipairs(self.inputs) do 2338 if found and (inp.type == "string" or inp.type == "number" or inp.type == "password") then 2339 inp.active = true 2340 self.activeInput = inp 2341 self.window:markDirty() 2342 break 2343 end 2344 if inp == input then 2345 found = true 2346 end 2347 end 2348 if self.activeInput == input then 2349 -- Wrap around 2350 for i, inp in ipairs(self.inputs) do 2351 if inp.type == "string" or inp.type == "number" or inp.type == "password" then 2352 inp.active = true 2353 self.activeInput = inp 2354 self.window:markDirty() 2355 break 2356 end 2357 end 2358 end 2359 elseif #key == 1 then 2360 -- Regular character 2361 if input.type == "number" then 2362 -- Only allow digits, minus, dot 2363 if key:match("[%d%.%-]") then 2364 input.value = input.value .. key 2365 self.window:markDirty() 2366 end 2367 else 2368 input.value = input.value .. key 2369 self.window:markDirty() 2370 end 2371 end 2372 end 2373 end 2374 2375 -- Click handler 2376 self.window.onClick = function(mx, my) 2377 -- Deactivate current input 2378 if self.activeInput then 2379 self.activeInput.active = false 2380 self.activeInput = nil 2381 end 2382 2383 -- Check all elements 2384 for _, elem in ipairs(self.elements) do 2385 if mx >= elem.x and mx < elem.x + elem.w and 2386 my >= elem.y and my < elem.y + elem.h then 2387 2388 if elem.type == "string" or elem.type == "number" or elem.type == "password" then 2389 -- Activate input 2390 elem.active = true 2391 self.activeInput = elem 2392 self.window:markDirty() 2393 return 2394 2395 elseif elem.type == "boolean" then 2396 -- Toggle checkbox 2397 elem.value = not elem.value 2398 self.window:markDirty() 2399 return 2400 2401 elseif elem.type == "button" then 2402 -- Button clicked - submit with button label 2403 self:submit(elem.label) 2404 return 2405 end 2406 end 2407 end 2408 2409 self.window:markDirty() 2410 end 2411 2412 return self 2413 end 2414 2415 -- openDialog method (convenience) 2416 function dialog:openDialog(cb) 2417 if cb then 2418 self.callback = cb 2419 end 2420 return self:show() 2421 end 2422 2423 return dialog 2424 end 2425 2426 return Dialog