luajitos

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

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