luajitos

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

render.lua (30488B)


      1 -- Simple HTML Renderer for Moonbrowser
      2 -- Uses Application.lua draw operations
      3 
      4 local Renderer = {}
      5 Renderer.__index = Renderer
      6 
      7 -- Default styles for HTML elements
      8 local defaultStyles = {
      9     h1 = { fontSize = 32, fontWeight = "bold", marginTop = 21, marginBottom = 21, display = "block" },
     10     h2 = { fontSize = 24, fontWeight = "bold", marginTop = 19, marginBottom = 19, display = "block" },
     11     h3 = { fontSize = 18, fontWeight = "bold", marginTop = 18, marginBottom = 18, display = "block" },
     12     h4 = { fontSize = 16, fontWeight = "bold", marginTop = 21, marginBottom = 21, display = "block" },
     13     h5 = { fontSize = 13, fontWeight = "bold", marginTop = 22, marginBottom = 22, display = "block" },
     14     h6 = { fontSize = 10, fontWeight = "bold", marginTop = 24, marginBottom = 24, display = "block" },
     15     p = { fontSize = 16, marginTop = 16, marginBottom = 16, display = "block" },
     16     div = { fontSize = 16, display = "block" },
     17     span = { fontSize = 16, display = "inline" },
     18     body = { fontSize = 16, display = "block" },
     19     html = { fontSize = 16, display = "block" },
     20     a = { fontSize = 16, color = 0x0000FF, display = "inline" },
     21     strong = { fontSize = 16, fontWeight = "bold", display = "inline" },
     22     b = { fontSize = 16, fontWeight = "bold", display = "inline" },
     23     em = { fontSize = 16, fontStyle = "italic", display = "inline" },
     24     i = { fontSize = 16, fontStyle = "italic", display = "inline" },
     25     ul = { fontSize = 16, marginTop = 16, marginBottom = 16, paddingLeft = 40, display = "block" },
     26     ol = { fontSize = 16, marginTop = 16, marginBottom = 16, paddingLeft = 40, display = "block" },
     27     li = { fontSize = 16, marginTop = 0, marginBottom = 0, display = "list-item" },
     28     br = { display = "block" },
     29     hr = { marginTop = 8, marginBottom = 8, display = "block" },
     30     pre = { fontSize = 13, fontFamily = "monospace", display = "block", marginTop = 16, marginBottom = 16 },
     31     code = { fontSize = 13, fontFamily = "monospace", display = "inline" },
     32     blockquote = { fontSize = 16, marginTop = 16, marginBottom = 16, paddingLeft = 40, display = "block" },
     33     button = { fontSize = 14, display = "inline", marginLeft = 4, marginRight = 4 },
     34     input = { fontSize = 14, display = "inline", marginLeft = 4, marginRight = 4 },
     35     form = { fontSize = 16, display = "block", marginTop = 8, marginBottom = 8 },
     36     label = { fontSize = 14, display = "inline", marginRight = 8 },
     37     textarea = { fontSize = 14, display = "block", marginTop = 4, marginBottom = 4 },
     38     select = { fontSize = 14, display = "inline", marginLeft = 4, marginRight = 4 },
     39 }
     40 
     41 -- Get style property for an element
     42 local function getStyle(elem, prop)
     43     local tag = elem.tag and elem.tag:lower() or "div"
     44     local styles = defaultStyles[tag] or defaultStyles.div
     45     return styles[prop]
     46 end
     47 
     48 -- Get font size for element (with inheritance from parent)
     49 local function getFontSize(elem)
     50     local tag = elem.tag and elem.tag:lower() or "div"
     51     local styles = defaultStyles[tag]
     52     if styles and styles.fontSize then
     53         return styles.fontSize
     54     end
     55     -- Default font size
     56     return 16
     57 end
     58 
     59 -- Get font scale from fontSize (base font is 8 pixels)
     60 local function getFontScale(fontSize)
     61     local scale = math.floor(fontSize / 8)
     62     if scale < 1 then scale = 1 end
     63     if scale > 8 then scale = 8 end
     64     return scale
     65 end
     66 
     67 -- Get line height based on font size
     68 local function getLineHeight(fontSize)
     69     -- Line height is scale * 8 * 1.25 for reasonable spacing
     70     local scale = getFontScale(fontSize)
     71     return math.floor(scale * 8 * 1.25)
     72 end
     73 
     74 -- Get character width based on font size
     75 local function getCharWidth(fontSize)
     76     -- Character width is scale * 8
     77     local scale = getFontScale(fontSize)
     78     return scale * 8
     79 end
     80 
     81 function Renderer.new(app, width, height)
     82     local self = setmetatable({}, Renderer)
     83     self.app = app
     84     self.width = width or 800
     85     self.height = height or 600
     86     self.scrollY = 0
     87     self.offsetY = 0  -- Vertical offset for toolbar etc.
     88     self.contentHeight = 0
     89     self.interactiveElements = {}  -- Store buttons/inputs for click handling
     90     self.inputValues = {}  -- Store input field values
     91     self.focusedInput = nil  -- Currently focused input
     92     self.inlineX = 0  -- Track X position for inline elements
     93     self.inlineY = 0  -- Track Y position for current inline row
     94     self.inlineHeight = 0  -- Track max height of current inline row
     95     return self
     96 end
     97 
     98 -- Get input value
     99 function Renderer:getInputValue(name)
    100     return self.inputValues[name] or ""
    101 end
    102 
    103 -- Set input value
    104 function Renderer:setInputValue(name, value)
    105     self.inputValues[name] = value
    106 end
    107 
    108 function Renderer:render(dom, gfx)
    109     if not dom then return end
    110 
    111     -- Clear interactive elements for this frame
    112     self.interactiveElements = {}
    113 
    114     -- Clear background to white (with offset)
    115     gfx:fillRect(0, self.offsetY, self.width, self.height, 0xFFFFFF)
    116 
    117     -- Start rendering from root (with offset)
    118     local y = 10 - self.scrollY + self.offsetY
    119     y = self:renderElement(dom, gfx, 10, y, self.width - 20, 16)
    120 
    121     -- Store content height for scrolling
    122     self.contentHeight = y + self.scrollY
    123 
    124     -- Debug: show how many interactive elements we found
    125     if #self.interactiveElements > 0 and not self._debugPrinted then
    126         print("Moonbrowser: Found " .. #self.interactiveElements .. " interactive elements")
    127         for i, elem in ipairs(self.interactiveElements) do
    128             print("  [" .. i .. "] " .. elem.type .. " at (" .. elem.x .. "," .. elem.y .. ") size " .. elem.width .. "x" .. elem.height)
    129         end
    130         self._debugPrinted = true
    131     end
    132 end
    133 
    134 function Renderer:renderElement(elem, gfx, x, y, maxWidth, inheritedFontSize)
    135     if not elem then return y end
    136 
    137     local tag = elem.tag and elem.tag:lower() or ""
    138 
    139     -- Skip certain tags
    140     if tag == "head" or tag == "script" or tag == "style" or tag == "title" then
    141         return y
    142     end
    143 
    144     -- Get font size for this element
    145     local fontSize = getFontSize(elem)
    146     -- If element doesn't specify size, inherit from parent
    147     if not (defaultStyles[tag] and defaultStyles[tag].fontSize) then
    148         fontSize = inheritedFontSize or 16
    149     end
    150 
    151     local fontScale = getFontScale(fontSize)
    152     local lineHeight = getLineHeight(fontSize)
    153     local charWidth = getCharWidth(fontSize)
    154 
    155     -- Get display type
    156     local display = getStyle(elem, "display") or "block"
    157 
    158     -- Reset inline flow when hitting a block element
    159     if display == "block" and self.inlineX > 0 then
    160         y = y + self.inlineHeight + 4
    161         self.inlineX = 0
    162         self.inlineHeight = 0
    163     end
    164 
    165     -- Apply top margin for block elements
    166     local marginTop = getStyle(elem, "marginTop") or 0
    167     if display == "block" and marginTop > 0 then
    168         y = y + marginTop
    169     end
    170 
    171     -- Handle padding
    172     local paddingLeft = getStyle(elem, "paddingLeft") or 0
    173     local currentX = x + paddingLeft
    174     local currentMaxWidth = maxWidth - paddingLeft
    175 
    176     -- Render text content
    177     if elem.text and #elem.text > 0 then
    178         local text = elem.text:gsub("%s+", " ")  -- Normalize whitespace
    179         if #text > 0 then
    180             -- Get text color
    181             local color = getStyle(elem, "color") or 0x000000
    182 
    183             -- Word wrap
    184             local words = {}
    185             for word in text:gmatch("%S+") do
    186                 table.insert(words, word)
    187             end
    188 
    189             local lineX = currentX
    190             local lineWords = {}
    191 
    192             for _, word in ipairs(words) do
    193                 local wordWidth = #word * charWidth
    194                 local spaceWidth = charWidth
    195 
    196                 -- Check if word fits on current line
    197                 local lineWidth = 0
    198                 for _, w in ipairs(lineWords) do
    199                     lineWidth = lineWidth + #w * charWidth + spaceWidth
    200                 end
    201 
    202                 if lineWidth + wordWidth > currentMaxWidth and #lineWords > 0 then
    203                     -- Draw current line
    204                     local lineText = table.concat(lineWords, " ")
    205                     if y >= -lineHeight and y < self.height then
    206                         gfx:drawText(lineX, y, lineText, color, fontScale)
    207                     end
    208                     y = y + lineHeight
    209                     lineWords = {word}
    210                 else
    211                     table.insert(lineWords, word)
    212                 end
    213             end
    214 
    215             -- Draw remaining words
    216             if #lineWords > 0 then
    217                 local lineText = table.concat(lineWords, " ")
    218                 if y >= -lineHeight and y < self.height then
    219                     gfx:drawText(currentX, y, lineText, color, fontScale)
    220                 end
    221                 y = y + lineHeight
    222             end
    223         end
    224     end
    225 
    226     -- Handle special elements
    227     if tag == "br" then
    228         y = y + lineHeight
    229     elseif tag == "hr" then
    230         if y >= 0 and y < self.height then
    231             gfx:fillRect(currentX, y + 4, currentMaxWidth, 1, 0x888888)
    232         end
    233         y = y + 10
    234     elseif tag == "li" then
    235         -- Draw bullet point (use same scale as text)
    236         if y >= 0 and y < self.height then
    237             gfx:drawText(x - charWidth * 2, y, "*", 0x000000, fontScale)
    238         end
    239     elseif tag == "a" then
    240         -- Get link text from children or text
    241         local linkText = elem.text or ""
    242         if elem.children then
    243             for _, child in ipairs(elem.children) do
    244                 if child.text then
    245                     linkText = linkText .. child.text
    246                 end
    247             end
    248         end
    249         linkText = linkText:gsub("^%s+", ""):gsub("%s+$", "")
    250         if linkText == "" then linkText = "Link" end
    251 
    252         local href = elem.attributes and elem.attributes.href
    253 
    254         local linkWidth = #linkText * charWidth
    255         local linkHeight = lineHeight
    256 
    257         if y >= -linkHeight and y < self.height then
    258             -- Draw link text in blue with underline
    259             gfx:drawText(currentX, y, linkText, 0x0000FF, fontScale)
    260             -- Draw underline
    261             gfx:fillRect(currentX, y + linkHeight - 2, linkWidth, 1, 0x0000FF)
    262 
    263             -- Store for click handling (subtract offsetY since y includes it but clicks are relative to content)
    264             table.insert(self.interactiveElements, {
    265                 type = "link",
    266                 x = currentX,
    267                 y = y + self.scrollY - self.offsetY,
    268                 width = linkWidth,
    269                 height = linkHeight,
    270                 text = linkText,
    271                 href = href,
    272                 elem = elem
    273             })
    274         end
    275 
    276         y = y + linkHeight
    277         return y  -- Don't render children for links (already got text)
    278 
    279     elseif tag == "button" then
    280         -- Get button text from children or text
    281         local buttonText = elem.text or ""
    282         if elem.children then
    283             for _, child in ipairs(elem.children) do
    284                 if child.text then
    285                     buttonText = buttonText .. child.text
    286                 end
    287             end
    288         end
    289         buttonText = buttonText:gsub("^%s+", ""):gsub("%s+$", "")
    290         if buttonText == "" then buttonText = "Button" end
    291 
    292         local btnWidth = #buttonText * 8 + 16
    293         local btnHeight = 20
    294         local btnMargin = 4
    295 
    296         -- Use inline flow - check if we need to wrap to new line
    297         if self.inlineX > 0 and self.inlineX + btnWidth + btnMargin > maxWidth then
    298             -- Wrap to new line
    299             y = y + self.inlineHeight + 4
    300             self.inlineX = 0
    301             self.inlineHeight = 0
    302         end
    303 
    304         local drawX = x + self.inlineX
    305         local drawY = y
    306 
    307         if drawY >= -btnHeight and drawY < self.height then
    308             -- Button background (gradient effect)
    309             gfx:fillRect(drawX, drawY, btnWidth, btnHeight, 0xE0E0E0)
    310             -- Top/left highlight
    311             gfx:fillRect(drawX, drawY, btnWidth, 1, 0xFFFFFF)
    312             gfx:fillRect(drawX, drawY, 1, btnHeight, 0xFFFFFF)
    313             -- Bottom/right shadow
    314             gfx:fillRect(drawX, drawY + btnHeight - 1, btnWidth, 1, 0x808080)
    315             gfx:fillRect(drawX + btnWidth - 1, drawY, 1, btnHeight, 0x808080)
    316             -- Button text (centered)
    317             gfx:drawText(drawX + 8, drawY + 6, buttonText, 0x000000)
    318 
    319             -- Store for click handling (subtract offsetY since drawY includes it)
    320             table.insert(self.interactiveElements, {
    321                 type = "button",
    322                 x = drawX,
    323                 y = drawY + self.scrollY - self.offsetY,
    324                 width = btnWidth,
    325                 height = btnHeight,
    326                 text = buttonText,
    327                 onclick = elem.attributes and elem.attributes.onclick,
    328                 elem = elem
    329             })
    330         end
    331 
    332         -- Advance inline position
    333         self.inlineX = self.inlineX + btnWidth + btnMargin
    334         if btnHeight > self.inlineHeight then
    335             self.inlineHeight = btnHeight
    336         end
    337 
    338         return y  -- Don't render children for button (already got text)
    339 
    340     elseif tag == "input" then
    341         local inputType = (elem.attributes and elem.attributes.type) or "text"
    342         local inputName = (elem.attributes and elem.attributes.name) or ("input_" .. #self.interactiveElements)
    343         local placeholder = (elem.attributes and elem.attributes.placeholder) or ""
    344         local inputValue = (elem.attributes and elem.attributes.value) or self.inputValues[inputName] or ""
    345 
    346         -- Initialize input value if not set
    347         if not self.inputValues[inputName] then
    348             self.inputValues[inputName] = inputValue
    349         end
    350 
    351         if inputType == "text" or inputType == "password" or inputType == "" then
    352             local inputWidth = 150
    353             local inputHeight = 20
    354 
    355             if y >= -inputHeight and y < self.height then
    356                 local isFocused = (self.focusedInput == inputName)
    357 
    358                 -- Input background
    359                 gfx:fillRect(currentX, y, inputWidth, inputHeight, 0xFFFFFF)
    360                 -- Border (blue if focused)
    361                 local borderColor = isFocused and 0x0066CC or 0x888888
    362                 gfx:fillRect(currentX, y, inputWidth, 1, borderColor)
    363                 gfx:fillRect(currentX, y, 1, inputHeight, borderColor)
    364                 gfx:fillRect(currentX + inputWidth - 1, y, 1, inputHeight, borderColor)
    365                 gfx:fillRect(currentX, y + inputHeight - 1, inputWidth, 1, borderColor)
    366 
    367                 -- Display value or placeholder
    368                 local displayText = self.inputValues[inputName] or ""
    369                 if inputType == "password" then
    370                     displayText = string.rep("*", #displayText)
    371                 end
    372                 local textColor = 0x000000
    373                 if displayText == "" and placeholder ~= "" then
    374                     displayText = placeholder
    375                     textColor = 0x888888
    376                 end
    377                 -- Truncate if too long
    378                 local maxChars = math.floor((inputWidth - 8) / 8)
    379                 if #displayText > maxChars then
    380                     displayText = displayText:sub(-maxChars)
    381                 end
    382                 gfx:drawText(currentX + 4, y + 6, displayText, textColor)
    383 
    384                 -- Cursor if focused
    385                 if isFocused then
    386                     local cursorX = currentX + 4 + #(self.inputValues[inputName] or "") * 8
    387                     if cursorX < currentX + inputWidth - 4 then
    388                         gfx:fillRect(cursorX, y + 4, 1, 12, 0x000000)
    389                     end
    390                 end
    391 
    392                 -- Store for click handling (subtract offsetY)
    393                 table.insert(self.interactiveElements, {
    394                     type = "input",
    395                     inputType = inputType,
    396                     x = currentX,
    397                     y = y + self.scrollY - self.offsetY,
    398                     width = inputWidth,
    399                     height = inputHeight,
    400                     name = inputName,
    401                     elem = elem
    402                 })
    403             end
    404 
    405             y = y + inputHeight + 4
    406 
    407         elseif inputType == "submit" then
    408             -- Submit button
    409             local buttonText = (elem.attributes and elem.attributes.value) or "Submit"
    410             local btnWidth = #buttonText * 8 + 16
    411             local btnHeight = 20
    412 
    413             if y >= -btnHeight and y < self.height then
    414                 -- Button background
    415                 gfx:fillRect(currentX, y, btnWidth, btnHeight, 0x4080FF)
    416                 -- Highlight
    417                 gfx:fillRect(currentX, y, btnWidth, 1, 0x60A0FF)
    418                 gfx:fillRect(currentX, y, 1, btnHeight, 0x60A0FF)
    419                 -- Shadow
    420                 gfx:fillRect(currentX, y + btnHeight - 1, btnWidth, 1, 0x2060CC)
    421                 gfx:fillRect(currentX + btnWidth - 1, y, 1, btnHeight, 0x2060CC)
    422                 -- Text
    423                 gfx:drawText(currentX + 8, y + 6, buttonText, 0xFFFFFF)
    424 
    425                 table.insert(self.interactiveElements, {
    426                     type = "submit",
    427                     x = currentX,
    428                     y = y + self.scrollY - self.offsetY,
    429                     width = btnWidth,
    430                     height = btnHeight,
    431                     text = buttonText,
    432                     elem = elem
    433                 })
    434             end
    435 
    436             y = y + btnHeight + 4
    437 
    438         elseif inputType == "checkbox" then
    439             local checkSize = 14
    440 
    441             -- Initialize checkbox if not already set
    442             if self.inputValues[inputName] == nil then
    443                 -- Check for 'checked' attribute (boolean or string)
    444                 if elem.attributes and (elem.attributes.checked == true or elem.attributes.checked == "checked") then
    445                     self.inputValues[inputName] = "checked"
    446                 else
    447                     self.inputValues[inputName] = ""
    448                 end
    449             end
    450 
    451             if y >= -checkSize and y < self.height then
    452                 local isChecked = self.inputValues[inputName] == "checked"
    453 
    454                 -- Checkbox background
    455                 gfx:fillRect(currentX, y + 2, checkSize, checkSize, 0xFFFFFF)
    456                 -- Border
    457                 gfx:fillRect(currentX, y + 2, checkSize, 1, 0x888888)
    458                 gfx:fillRect(currentX, y + 2, 1, checkSize, 0x888888)
    459                 gfx:fillRect(currentX + checkSize - 1, y + 2, 1, checkSize, 0x888888)
    460                 gfx:fillRect(currentX, y + 2 + checkSize - 1, checkSize, 1, 0x888888)
    461 
    462                 -- Checkmark if checked
    463                 if isChecked then
    464                     gfx:drawText(currentX + 3, y + 2, "X", 0x000000)
    465                 end
    466 
    467                 table.insert(self.interactiveElements, {
    468                     type = "checkbox",
    469                     x = currentX,
    470                     y = y + 2 + self.scrollY - self.offsetY,
    471                     width = checkSize,
    472                     height = checkSize,
    473                     name = inputName,
    474                     elem = elem
    475                 })
    476             end
    477 
    478             -- Don't add full line height, checkbox is inline
    479             currentX = currentX + checkSize + 8
    480         end
    481 
    482         return y
    483 
    484     elseif tag == "textarea" then
    485         local inputName = (elem.attributes and elem.attributes.name) or ("textarea_" .. #self.interactiveElements)
    486         local rows = tonumber(elem.attributes and elem.attributes.rows) or 4
    487         local cols = tonumber(elem.attributes and elem.attributes.cols) or 40
    488 
    489         local textareaWidth = cols * 8 + 8
    490         local textareaHeight = rows * 12 + 8
    491 
    492         -- Initialize value
    493         if not self.inputValues[inputName] then
    494             self.inputValues[inputName] = elem.text or ""
    495         end
    496 
    497         if y >= -textareaHeight and y < self.height then
    498             local isFocused = (self.focusedInput == inputName)
    499 
    500             -- Background
    501             gfx:fillRect(currentX, y, textareaWidth, textareaHeight, 0xFFFFFF)
    502             -- Border
    503             local borderColor = isFocused and 0x0066CC or 0x888888
    504             gfx:fillRect(currentX, y, textareaWidth, 1, borderColor)
    505             gfx:fillRect(currentX, y, 1, textareaHeight, borderColor)
    506             gfx:fillRect(currentX + textareaWidth - 1, y, 1, textareaHeight, borderColor)
    507             gfx:fillRect(currentX, y + textareaHeight - 1, textareaWidth, 1, borderColor)
    508 
    509             -- Text content
    510             local textContent = self.inputValues[inputName] or ""
    511             local textY = y + 4
    512             for line in (textContent .. "\n"):gmatch("([^\n]*)\n") do
    513                 if textY < y + textareaHeight - 4 then
    514                     gfx:drawText(currentX + 4, textY, line:sub(1, cols), 0x000000)
    515                     textY = textY + 12
    516                 end
    517             end
    518 
    519             table.insert(self.interactiveElements, {
    520                 type = "textarea",
    521                 x = currentX,
    522                 y = y + self.scrollY - self.offsetY,
    523                 width = textareaWidth,
    524                 height = textareaHeight,
    525                 name = inputName,
    526                 elem = elem
    527             })
    528         end
    529 
    530         y = y + textareaHeight + 4
    531         return y
    532     end
    533 
    534     -- Render children
    535     if elem.children then
    536         for _, child in ipairs(elem.children) do
    537             y = self:renderElement(child, gfx, currentX, y, currentMaxWidth, fontSize)
    538         end
    539     end
    540 
    541     -- Apply bottom margin for block elements
    542     local marginBottom = getStyle(elem, "marginBottom") or 0
    543     if display == "block" and marginBottom > 0 then
    544         y = y + marginBottom
    545     end
    546 
    547     return y
    548 end
    549 
    550 function Renderer:scroll(delta)
    551     self.scrollY = self.scrollY + delta
    552     if self.scrollY < 0 then
    553         self.scrollY = 0
    554     end
    555     local maxScroll = math.max(0, self.contentHeight - self.height + 50)
    556     if self.scrollY > maxScroll then
    557         self.scrollY = maxScroll
    558     end
    559 end
    560 
    561 function Renderer:resetScroll()
    562     self.scrollY = 0
    563     self.focusedInput = nil
    564     self.interactiveElements = {}
    565     self.inputValues = {}
    566 end
    567 
    568 -- Handle click at position (x, y relative to content area)
    569 function Renderer:handleClick(x, y)
    570     -- Adjust y for scroll position
    571     local absY = y + self.scrollY
    572 
    573     for _, elem in ipairs(self.interactiveElements) do
    574         if x >= elem.x and x < elem.x + elem.width and
    575            absY >= elem.y and absY < elem.y + elem.height then
    576 
    577             if elem.type == "input" or elem.type == "textarea" then
    578                 -- Focus this input
    579                 self.focusedInput = elem.name
    580                 return { type = "focus", name = elem.name }
    581 
    582             elseif elem.type == "checkbox" then
    583                 -- Toggle checkbox
    584                 if self.inputValues[elem.name] == "checked" then
    585                     self.inputValues[elem.name] = ""
    586                 else
    587                     self.inputValues[elem.name] = "checked"
    588                 end
    589                 return { type = "toggle", name = elem.name, value = self.inputValues[elem.name] }
    590 
    591             elseif elem.type == "button" then
    592                 return { type = "button", text = elem.text, onclick = elem.onclick, elem = elem.elem }
    593 
    594             elseif elem.type == "submit" then
    595                 -- Collect form data
    596                 local formData = {}
    597                 for name, value in pairs(self.inputValues) do
    598                     formData[name] = value
    599                 end
    600                 return { type = "submit", text = elem.text, formData = formData }
    601 
    602             elseif elem.type == "link" then
    603                 return { type = "link", text = elem.text, href = elem.href, elem = elem.elem }
    604             end
    605         end
    606     end
    607 
    608     -- Click outside any element - unfocus
    609     if self.focusedInput then
    610         self.focusedInput = nil
    611         return { type = "unfocus" }
    612     end
    613 
    614     return nil
    615 end
    616 
    617 -- Handle keyboard input
    618 function Renderer:handleKey(key, scancode)
    619     if not self.focusedInput then
    620         return false
    621     end
    622 
    623     local name = self.focusedInput
    624 
    625     if key == "\b" then
    626         -- Backspace
    627         local val = self.inputValues[name] or ""
    628         if #val > 0 then
    629             self.inputValues[name] = val:sub(1, -2)
    630         end
    631         return true
    632     elseif key == "\n" then
    633         -- Enter - unfocus (or submit form)
    634         self.focusedInput = nil
    635         return true
    636     elseif key == "\t" then
    637         -- Tab - unfocus
    638         self.focusedInput = nil
    639         return true
    640     elseif scancode == 1 then
    641         -- Escape - unfocus
    642         self.focusedInput = nil
    643         return true
    644     elseif key and #key == 1 and key:byte() >= 32 then
    645         -- Printable character
    646         self.inputValues[name] = (self.inputValues[name] or "") .. key
    647         return true
    648     end
    649 
    650     return false
    651 end
    652 
    653 -- Check if an input is focused
    654 function Renderer:hasFocus()
    655     return self.focusedInput ~= nil
    656 end
    657 
    658 -- Create a proxy object for a DOM element that allows .value access
    659 local function createElementProxy(renderer, elem)
    660     if not elem then return nil end
    661 
    662     local proxy = {}
    663     local mt = {
    664         __index = function(t, key)
    665             if key == "value" then
    666                 -- For input/textarea, get from inputValues
    667                 local name = elem.attributes and elem.attributes.name
    668                 if name and renderer.inputValues[name] ~= nil then
    669                     return renderer.inputValues[name]
    670                 end
    671                 -- Fallback to attribute value or text content
    672                 if elem.attributes and elem.attributes.value then
    673                     return elem.attributes.value
    674                 end
    675                 return elem.text or ""
    676 
    677             elseif key == "checked" then
    678                 local name = elem.attributes and elem.attributes.name
    679                 if name then
    680                     return renderer.inputValues[name] == "checked"
    681                 end
    682                 return elem.attributes and (elem.attributes.checked == true or elem.attributes.checked == "checked")
    683 
    684             elseif key == "id" then
    685                 return elem.id
    686 
    687             elseif key == "className" or key == "class" then
    688                 return elem.className
    689 
    690             elseif key == "tagName" or key == "tag" then
    691                 return elem.tag
    692 
    693             elseif key == "name" then
    694                 return elem.attributes and elem.attributes.name
    695 
    696             elseif key == "text" or key == "textContent" or key == "innerText" then
    697                 return elem.text or ""
    698 
    699             elseif key == "innerHTML" then
    700                 -- Simple innerHTML - just return text for now
    701                 return elem.text or ""
    702 
    703             elseif key == "children" then
    704                 -- Return proxied children
    705                 local proxiedChildren = {}
    706                 for i, child in ipairs(elem.children or {}) do
    707                     proxiedChildren[i] = createElementProxy(renderer, child)
    708                 end
    709                 return proxiedChildren
    710 
    711             elseif key == "parentNode" or key == "parent" then
    712                 return createElementProxy(renderer, elem.parent)
    713 
    714             elseif key == "getAttribute" then
    715                 return function(_, attrName)
    716                     return elem.attributes and elem.attributes[attrName]
    717                 end
    718 
    719             elseif key == "setAttribute" then
    720                 return function(_, attrName, attrValue)
    721                     if elem.attributes then
    722                         elem.attributes[attrName] = attrValue
    723                     end
    724                 end
    725 
    726             elseif key == "querySelector" then
    727                 return function(_, selector)
    728                     local found = elem:querySelector(selector)
    729                     return createElementProxy(renderer, found)
    730                 end
    731 
    732             elseif key == "querySelectorAll" then
    733                 return function(_, selector)
    734                     local found = elem:querySelectorAll(selector)
    735                     local proxied = {}
    736                     for i, e in ipairs(found) do
    737                         proxied[i] = createElementProxy(renderer, e)
    738                     end
    739                     return proxied
    740                 end
    741 
    742             elseif key == "_element" then
    743                 -- Access to raw element (for debugging)
    744                 return elem
    745 
    746             else
    747                 -- Try attributes
    748                 if elem.attributes and elem.attributes[key] ~= nil then
    749                     return elem.attributes[key]
    750                 end
    751             end
    752 
    753             return nil
    754         end,
    755 
    756         __newindex = function(t, key, value)
    757             if key == "value" then
    758                 -- For input/textarea, set in inputValues
    759                 local name = elem.attributes and elem.attributes.name
    760                 if name then
    761                     renderer.inputValues[name] = tostring(value)
    762                 else
    763                     -- Set attribute if no name
    764                     if elem.attributes then
    765                         elem.attributes.value = tostring(value)
    766                     end
    767                 end
    768 
    769             elseif key == "checked" then
    770                 local name = elem.attributes and elem.attributes.name
    771                 if name then
    772                     renderer.inputValues[name] = value and "checked" or ""
    773                 end
    774 
    775             elseif key == "text" or key == "textContent" or key == "innerText" then
    776                 elem.text = tostring(value)
    777 
    778             elseif key == "innerHTML" then
    779                 -- Simple innerHTML - just set text
    780                 elem.text = tostring(value)
    781 
    782             elseif key == "className" or key == "class" then
    783                 elem.className = tostring(value)
    784 
    785             else
    786                 -- Set as attribute
    787                 if elem.attributes then
    788                     elem.attributes[key] = value
    789                 end
    790             end
    791         end
    792     }
    793 
    794     setmetatable(proxy, mt)
    795     return proxy
    796 end
    797 
    798 -- Query the DOM like document.querySelector
    799 -- Supports: #id, .class, tagname, tag#id, tag.class, [attr], [attr=value], [name=value]
    800 function Renderer:query(selector)
    801     if not self.dom then return nil end
    802     local elem = self.dom:querySelector(selector)
    803     return createElementProxy(self, elem)
    804 end
    805 
    806 -- Query all matching elements
    807 function Renderer:queryAll(selector)
    808     if not self.dom then return {} end
    809     local elems = self.dom:querySelectorAll(selector)
    810     local proxied = {}
    811     for i, elem in ipairs(elems) do
    812         proxied[i] = createElementProxy(self, elem)
    813     end
    814     return proxied
    815 end
    816 
    817 -- Store DOM reference for queries
    818 function Renderer:setDOM(dom)
    819     self.dom = dom
    820 end
    821 
    822 return Renderer