luajitos

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

init.lua (24333B)


      1 -- LuaJIT OS Spreadsheet Application
      2 -- Infinite spreadsheet with CSV support and formula evaluation
      3 
      4 local toolbarHeight = 30
      5 local headerHeight = 20
      6 local rowHeight = 22
      7 local defaultColWidth = 80
      8 local rowHeaderWidth = 40
      9 
     10 local initialWidth = 600
     11 local initialHeight = 400
     12 
     13 local window = app:newWindow("Spreadsheet", initialWidth, initialHeight)
     14 window.resizable = true
     15 
     16 -- Spreadsheet data: rows[y] = {cells...}, grows as needed
     17 local rows = {}
     18 local colWidths = {}  -- Custom column widths
     19 
     20 -- Selection
     21 local selectedX = 1
     22 local selectedY = 1
     23 local editing = false
     24 local editBuffer = ""
     25 
     26 -- Formula bar
     27 local formulaBarActive = false
     28 local formulaBuffer = ""
     29 
     30 -- Scroll position
     31 local scrollX = 0
     32 local scrollY = 0
     33 
     34 -- Current file
     35 local currentFile = nil
     36 
     37 -- Forward declaration
     38 local ensureSelectionVisible
     39 
     40 -- Get or create a row
     41 local function getRow(y)
     42     if not rows[y] then
     43         rows[y] = {}
     44     end
     45     return rows[y]
     46 end
     47 
     48 -- Get cell value
     49 local function getCell(x, y)
     50     local row = rows[y]
     51     if row then
     52         return row[x] or ""
     53     end
     54     return ""
     55 end
     56 
     57 -- Set cell value
     58 local function setCell(x, y, value)
     59     local row = getRow(y)
     60     row[x] = value
     61 end
     62 
     63 -- Get column width
     64 local function getColWidth(x)
     65     return colWidths[x] or defaultColWidth
     66 end
     67 
     68 -- Ensure selected cell is visible (scroll if needed)
     69 ensureSelectionVisible = function(winW, winH)
     70     -- Calculate cell position
     71     local cellX = rowHeaderWidth
     72     for col = 1, selectedX - 1 do
     73         cellX = cellX + getColWidth(col)
     74     end
     75     local cellW = getColWidth(selectedX)
     76 
     77     -- Horizontal scrolling
     78     local visibleLeft = scrollX
     79     local visibleRight = scrollX + (winW - rowHeaderWidth)
     80 
     81     if cellX - rowHeaderWidth < visibleLeft then
     82         -- Cell is too far left, scroll left
     83         scrollX = cellX - rowHeaderWidth
     84     elseif cellX - rowHeaderWidth + cellW > visibleRight then
     85         -- Cell is too far right, scroll right
     86         scrollX = cellX - rowHeaderWidth + cellW - (winW - rowHeaderWidth)
     87     end
     88 
     89     -- Vertical scrolling
     90     local visibleTop = scrollY
     91     local visibleBottom = scrollY + (winH - toolbarHeight - headerHeight)
     92     local cellTop = (selectedY - 1) * rowHeight
     93     local cellBottom = cellTop + rowHeight
     94 
     95     if cellTop < visibleTop then
     96         -- Cell is above view, scroll up
     97         scrollY = cellTop
     98     elseif cellBottom > visibleBottom then
     99         -- Cell is below view, scroll down
    100         scrollY = cellBottom - (winH - toolbarHeight - headerHeight)
    101     end
    102 
    103     -- Clamp scroll values
    104     if scrollX < 0 then scrollX = 0 end
    105     if scrollY < 0 then scrollY = 0 end
    106 end
    107 
    108 -- Convert column number to letter (1=A, 2=B, ..., 27=AA)
    109 local function colToLetter(n)
    110     local result = ""
    111     while n > 0 do
    112         n = n - 1
    113         result = string.char(65 + (n % 26)) .. result
    114         n = math.floor(n / 26)
    115     end
    116     return result
    117 end
    118 
    119 -- Convert column letter to number (A=1, B=2, ..., AA=27)
    120 local function letterToCol(letters)
    121     local result = 0
    122     for i = 1, #letters do
    123         result = result * 26 + (letters:byte(i) - 64)
    124     end
    125     return result
    126 end
    127 
    128 -- Expand range notation [A1-C4] to list of IN() calls
    129 local function expandRange(formula)
    130     return formula:gsub("%[([A-Z]+)(%d+)%-([A-Z]+)(%d+)%]", function(col1Str, row1Str, col2Str, row2Str)
    131         local col1 = letterToCol(col1Str)
    132         local row1 = tonumber(row1Str)
    133         local col2 = letterToCol(col2Str)
    134         local row2 = tonumber(row2Str)
    135         local cells = {}
    136         -- Iterate rows first, then columns
    137         for row = row1, row2 do
    138             for col = col1, col2 do
    139                 table.insert(cells, "IN(" .. col .. "," .. row .. ")")
    140             end
    141         end
    142         return table.concat(cells, ", ")
    143     end)
    144 end
    145 
    146 -- Convert cell references like A4, BC12 to IN(col, row)
    147 local function preprocessFormula(formula)
    148     -- First expand range notation [A1-C4]
    149     formula = expandRange(formula)
    150 
    151     -- Then replace cell references A1, BC23, etc. with IN(col, row)
    152     -- Use frontier pattern %f to ensure we don't match inside words like SUM, AVG, etc.
    153     -- Match cell refs that are preceded by non-letter or start of string
    154     local result = ""
    155     local i = 1
    156     while i <= #formula do
    157         -- Check if we're at a potential cell reference
    158         local col, row, endPos = formula:match("^([A-Z]+)(%d+)()", i)
    159         if col then
    160             -- Check if preceded by a letter (would be part of function name)
    161             local prevChar = i > 1 and formula:sub(i-1, i-1) or ""
    162             if prevChar:match("[A-Za-z]") then
    163                 -- Part of a function name, don't convert
    164                 result = result .. col
    165                 i = i + #col
    166             else
    167                 -- Standalone cell reference, convert it
    168                 local colNum = letterToCol(col)
    169                 result = result .. "IN(" .. colNum .. "," .. row .. ")"
    170                 i = endPos
    171             end
    172         else
    173             result = result .. formula:sub(i, i)
    174             i = i + 1
    175         end
    176     end
    177     return result
    178 end
    179 
    180 -- Formula sandbox environment
    181 local function createFormulaSandbox()
    182     local sandbox = {}
    183 
    184     -- IN(x, y) - get cell value at position
    185     sandbox.IN = function(x, y)
    186         local val = getCell(x, y)
    187         -- Try to convert to number
    188         local num = tonumber(val)
    189         if num then return num end
    190         -- Check if it's a formula result
    191         if type(val) == "string" and val:sub(1, 2) == "%=" then
    192             -- Evaluate nested formula
    193             local result = evaluateFormula(val)
    194             return result
    195         end
    196         return val
    197     end
    198 
    199     -- SELECT(x, y, direction, length) - get multiple cells
    200     sandbox.SELECT = function(x, y, direction, length)
    201         local results = {}
    202         local dx, dy = 0, 0
    203         if direction == "up" then dy = -1
    204         elseif direction == "down" then dy = 1
    205         elseif direction == "left" then dx = -1
    206         elseif direction == "right" then dx = 1
    207         end
    208 
    209         for i = 0, length - 1 do
    210             local cx = x + dx * i
    211             local cy = y + dy * i
    212             local val = sandbox.IN(cx, cy)
    213             table.insert(results, val)
    214         end
    215         return unpack(results)
    216     end
    217 
    218     -- SUM(...) - sum all numeric arguments
    219     sandbox.SUM = function(...)
    220         local args = {...}
    221         local total = 0
    222         for _, v in ipairs(args) do
    223             local num = tonumber(v)
    224             if num then
    225                 total = total + num
    226             end
    227         end
    228         return total
    229     end
    230 
    231     -- CONCAT(...) - concatenate strings
    232     sandbox.CONCAT = function(...)
    233         local args = {...}
    234         local result = ""
    235         for _, v in ipairs(args) do
    236             result = result .. tostring(v)
    237         end
    238         return result
    239     end
    240 
    241     -- REPEAT(str, count, separator)
    242     sandbox.REPEAT = function(str, count, separator)
    243         separator = separator or ""
    244         local parts = {}
    245         for i = 1, count do
    246             table.insert(parts, str)
    247         end
    248         return table.concat(parts, separator)
    249     end
    250 
    251     -- AVG(...) - average of numeric arguments
    252     sandbox.AVG = function(...)
    253         local args = {...}
    254         local total = 0
    255         local count = 0
    256         for _, v in ipairs(args) do
    257             local num = tonumber(v)
    258             if num then
    259                 total = total + num
    260                 count = count + 1
    261             end
    262         end
    263         if count == 0 then return 0 end
    264         return total / count
    265     end
    266 
    267     -- MIN/MAX
    268     sandbox.MIN = function(...)
    269         local args = {...}
    270         local result = nil
    271         for _, v in ipairs(args) do
    272             local num = tonumber(v)
    273             if num then
    274                 if result == nil or num < result then
    275                     result = num
    276                 end
    277             end
    278         end
    279         return result or 0
    280     end
    281 
    282     sandbox.MAX = function(...)
    283         local args = {...}
    284         local result = nil
    285         for _, v in ipairs(args) do
    286             local num = tonumber(v)
    287             if num then
    288                 if result == nil or num > result then
    289                     result = num
    290                 end
    291             end
    292         end
    293         return result or 0
    294     end
    295 
    296     -- Basic math
    297     sandbox.math = math
    298     sandbox.tostring = tostring
    299     sandbox.tonumber = tonumber
    300     sandbox.type = type
    301 
    302     return sandbox
    303 end
    304 
    305 -- Evaluate a formula
    306 local function evaluateFormula(formula)
    307     if type(formula) ~= "string" or formula:sub(1, 2) ~= "%=" then
    308         return formula
    309     end
    310 
    311     local code = formula:sub(3)  -- Remove %=
    312 
    313     -- Preprocess: convert cell references (A1, BC23) to IN(col, row)
    314     code = preprocessFormula(code)
    315 
    316     local sandbox = createFormulaSandbox()
    317 
    318     -- Try to compile the formula
    319     local fn, err = loadstring("return " .. code)
    320     if not fn then
    321         return "#ERR: " .. tostring(err)
    322     end
    323 
    324     -- Set environment to sandbox
    325     setfenv(fn, sandbox)
    326 
    327     local ok, result = pcall(fn)
    328     if not ok then
    329         return "#ERR: " .. tostring(result)
    330     end
    331 
    332     return result
    333 end
    334 
    335 -- Get display value for a cell (evaluates formulas)
    336 local function getDisplayValue(x, y)
    337     local val = getCell(x, y)
    338     if type(val) == "string" and val:sub(1, 2) == "%=" then
    339         return tostring(evaluateFormula(val))
    340     end
    341     return tostring(val)
    342 end
    343 
    344 -- Parse CSV line
    345 local function parseCSVLine(line)
    346     local cells = {}
    347     local current = ""
    348     local inQuotes = false
    349 
    350     for i = 1, #line do
    351         local c = line:sub(i, i)
    352         if c == '"' then
    353             inQuotes = not inQuotes
    354         elseif c == ',' and not inQuotes then
    355             table.insert(cells, current)
    356             current = ""
    357         else
    358             current = current .. c
    359         end
    360     end
    361     table.insert(cells, current)
    362 
    363     return cells
    364 end
    365 
    366 -- Load CSV file
    367 local function loadCSV(path)
    368     local data = fs:read(path)
    369     if not data then return false end
    370 
    371     rows = {}
    372     colWidths = {}
    373 
    374     local y = 1
    375     for line in data:gmatch("[^\r\n]+") do
    376         local cells = parseCSVLine(line)
    377         rows[y] = {}
    378         for x, val in ipairs(cells) do
    379             rows[y][x] = val
    380         end
    381         y = y + 1
    382     end
    383 
    384     currentFile = path
    385     selectedX = 1
    386     selectedY = 1
    387     scrollX = 0
    388     scrollY = 0
    389     window:markDirty()
    390     return true
    391 end
    392 
    393 -- Generate CSV content
    394 local function generateCSV()
    395     local maxX = 0
    396     local maxY = 0
    397 
    398     -- Find bounds
    399     for y, row in pairs(rows) do
    400         if y > maxY then maxY = y end
    401         for x, _ in pairs(row) do
    402             if x > maxX then maxX = x end
    403         end
    404     end
    405 
    406     local lines = {}
    407     for y = 1, maxY do
    408         local cells = {}
    409         for x = 1, maxX do
    410             local val = tostring(getCell(x, y) or "")
    411             -- Escape commas and quotes
    412             if val:find('[,"\n]') then
    413                 val = '"' .. val:gsub('"', '""') .. '"'
    414             end
    415             table.insert(cells, val)
    416         end
    417         table.insert(lines, table.concat(cells, ","))
    418     end
    419 
    420     return table.concat(lines, "\n")
    421 end
    422 
    423 -- Save CSV file
    424 local function saveCSV(path)
    425     local content = generateCSV()
    426     local ok = fs:write(path, content)
    427     if ok then
    428         currentFile = path
    429     end
    430     return ok
    431 end
    432 
    433 -- Clear spreadsheet
    434 local function newSpreadsheet()
    435     rows = {}
    436     colWidths = {}
    437     currentFile = nil
    438     selectedX = 1
    439     selectedY = 1
    440     scrollX = 0
    441     scrollY = 0
    442     editing = false
    443     editBuffer = ""
    444     window:markDirty()
    445 end
    446 
    447 -- Formula bar dimensions
    448 local formulaBarX = 145
    449 local formulaBarY = 5
    450 local formulaBarH = 20
    451 
    452 -- Button definitions
    453 local buttons = {
    454     {x = 5, y = 5, w = 40, h = 20, label = "New", action = newSpreadsheet},
    455     {x = 50, y = 5, w = 45, h = 20, label = "Open", action = function()
    456         local dlg = Dialog.fileOpen("/", {
    457             app = app,
    458             fs = fs,
    459             title = "Open CSV",
    460             filter = {"csv"}
    461         })
    462         dlg:openDialog(function(path)
    463             if path then
    464                 loadCSV(path)
    465             end
    466         end)
    467     end},
    468     {x = 100, y = 5, w = 40, h = 20, label = "Save", action = function()
    469         local defaultName = "spreadsheet.csv"
    470         if currentFile then
    471             defaultName = currentFile:match("([^/]+)$") or defaultName
    472         end
    473         local dlg = Dialog.fileSave("/home", defaultName, {
    474             app = app,
    475             fs = fs,
    476             title = "Save CSV"
    477         })
    478         dlg:openDialog(function(path)
    479             if path then
    480                 if not path:match("%.csv$") then
    481                     path = path .. ".csv"
    482                 end
    483                 saveCSV(path)
    484             end
    485         end)
    486     end}
    487 }
    488 
    489 -- Helper: check if point is inside rect
    490 local function isInside(px, py, x, y, w, h)
    491     return px >= x and px < x + w and py >= y and py < y + h
    492 end
    493 
    494 -- Get column at screen X position
    495 local function getColumnAtX(screenX)
    496     local x = rowHeaderWidth - scrollX
    497     local col = 1
    498     while x < screenX do
    499         x = x + getColWidth(col)
    500         if x >= screenX then
    501             return col
    502         end
    503         col = col + 1
    504         if col > 1000 then break end  -- Safety limit
    505     end
    506     return col
    507 end
    508 
    509 -- Get row at screen Y position
    510 local function getRowAtY(screenY)
    511     local contentY = screenY - toolbarHeight - headerHeight
    512     if contentY < 0 then return nil end
    513     local row = math.floor((contentY + scrollY) / rowHeight) + 1
    514     return row
    515 end
    516 
    517 -- Get X position for column
    518 local function getColumnX(col)
    519     local x = rowHeaderWidth
    520     for c = 1, col - 1 do
    521         x = x + getColWidth(c)
    522     end
    523     return x - scrollX
    524 end
    525 
    526 -- Key handler (onInput for keyboard input)
    527 window.onInput = function(key, scancode)
    528     -- Scancodes: up=72, down=80, left=75, right=77, escape=1
    529 
    530     if formulaBarActive then
    531         -- Formula bar input
    532         if key == "\n" then
    533             -- Confirm formula - prepend %= and set cell
    534             if formulaBuffer ~= "" then
    535                 setCell(selectedX, selectedY, "%=" .. formulaBuffer)
    536             end
    537             formulaBarActive = false
    538             formulaBuffer = ""
    539         elseif key == "\b" then
    540             if #formulaBuffer > 0 then
    541                 formulaBuffer = formulaBuffer:sub(1, -2)
    542             end
    543         elseif scancode == 1 then  -- Escape
    544             formulaBarActive = false
    545             formulaBuffer = ""
    546         elseif key and #key == 1 and key:byte() >= 32 then
    547             formulaBuffer = formulaBuffer .. key
    548         end
    549     elseif editing then
    550         if key == "\n" then
    551             -- Confirm edit
    552             setCell(selectedX, selectedY, editBuffer)
    553             editing = false
    554             editBuffer = ""
    555             selectedY = selectedY + 1
    556         elseif key == "\b" then
    557             if #editBuffer > 0 then
    558                 editBuffer = editBuffer:sub(1, -2)
    559             end
    560         elseif scancode == 1 then  -- Escape
    561             editing = false
    562             editBuffer = ""
    563         elseif key and #key == 1 and key:byte() >= 32 then
    564             editBuffer = editBuffer .. key
    565         end
    566     else
    567         -- Navigation
    568         local moved = false
    569         if key == "\n" then
    570             -- Start editing
    571             editing = true
    572             editBuffer = getCell(selectedX, selectedY)
    573         elseif key == "\t" then
    574             selectedX = selectedX + 1
    575             moved = true
    576         elseif scancode == 72 then  -- Up arrow
    577             if selectedY > 1 then selectedY = selectedY - 1 end
    578             moved = true
    579         elseif scancode == 80 then  -- Down arrow
    580             selectedY = selectedY + 1
    581             moved = true
    582         elseif scancode == 75 then  -- Left arrow
    583             if selectedX > 1 then selectedX = selectedX - 1 end
    584             moved = true
    585         elseif scancode == 77 then  -- Right arrow
    586             selectedX = selectedX + 1
    587             moved = true
    588         elseif key == "\b" then
    589             -- Delete cell content
    590             setCell(selectedX, selectedY, "")
    591         elseif key and #key == 1 and key:byte() >= 32 then
    592             -- Start typing immediately
    593             editing = true
    594             editBuffer = key
    595         end
    596 
    597         -- Scroll to keep selection visible
    598         if moved then
    599             ensureSelectionVisible(window.width, window.height)
    600         end
    601     end
    602     window:markDirty()
    603 end
    604 
    605 -- Click handler
    606 window.onClick = function(mx, my)
    607     -- Save current edit before doing anything else
    608     if editing then
    609         setCell(selectedX, selectedY, editBuffer)
    610         editing = false
    611         editBuffer = ""
    612     end
    613 
    614     -- Save formula bar edit
    615     if formulaBarActive then
    616         if formulaBuffer ~= "" then
    617             setCell(selectedX, selectedY, "%=" .. formulaBuffer)
    618         end
    619         formulaBarActive = false
    620         formulaBuffer = ""
    621     end
    622 
    623     -- Check toolbar buttons
    624     if my < toolbarHeight then
    625         for _, btn in ipairs(buttons) do
    626             if isInside(mx, my, btn.x, btn.y, btn.w, btn.h) then
    627                 btn.action()
    628                 return
    629             end
    630         end
    631 
    632         -- Check formula bar click (from formulaBarX to end of window)
    633         local formulaBarW = window.width - formulaBarX - 5
    634         if isInside(mx, my, formulaBarX, formulaBarY, formulaBarW, formulaBarH) then
    635             formulaBarActive = true
    636             -- Load existing formula if cell has one
    637             local cellVal = getCell(selectedX, selectedY)
    638             if type(cellVal) == "string" and cellVal:sub(1, 2) == "%=" then
    639                 formulaBuffer = cellVal:sub(3)
    640             else
    641                 formulaBuffer = ""
    642             end
    643             window:markDirty()
    644             return
    645         end
    646 
    647         window:markDirty()
    648         return
    649     end
    650 
    651     -- Check column headers (for future: resize)
    652     if my < toolbarHeight + headerHeight then
    653         window:markDirty()
    654         return
    655     end
    656 
    657     -- Check row headers
    658     if mx < rowHeaderWidth then
    659         local row = getRowAtY(my)
    660         if row then
    661             selectedY = row
    662             selectedX = 1
    663             window:markDirty()
    664         end
    665         return
    666     end
    667 
    668     -- Click on cell
    669     local col = getColumnAtX(mx)
    670     local row = getRowAtY(my)
    671 
    672     if col and row then
    673         if selectedX == col and selectedY == row then
    674             -- Double-click to edit (single click already selected)
    675             editing = true
    676             editBuffer = getCell(selectedX, selectedY)
    677         else
    678             selectedX = col
    679             selectedY = row
    680         end
    681         window:markDirty()
    682     end
    683 end
    684 
    685 -- Scroll handler
    686 window.onScroll = function(delta)
    687     scrollY = math.max(0, scrollY - delta * rowHeight * 2)
    688     window:markDirty()
    689 end
    690 
    691 -- Draw handler
    692 window.onDraw = function(gfx)
    693     local winW = window.width
    694     local winH = window.height
    695 
    696     -- Background
    697     gfx:fillRect(0, 0, winW, winH, 0xFFFFFF)
    698 
    699     -- Toolbar
    700     gfx:fillRect(0, 0, winW, toolbarHeight, 0xE0E0E0)
    701     for _, btn in ipairs(buttons) do
    702         gfx:fillRect(btn.x, btn.y, btn.w, btn.h, 0xD0D0D0)
    703         gfx:drawRect(btn.x, btn.y, btn.w, btn.h, 0x888888)
    704         gfx:drawText(btn.x + 4, btn.y + 4, btn.label, 0x000000)
    705     end
    706 
    707     -- Formula bar
    708     local formulaBarW = winW - formulaBarX - 5
    709     local fBarBgColor = formulaBarActive and 0xFFFFFF or 0xFAFAFA
    710     local fBarBorderColor = formulaBarActive and 0x0066CC or 0x888888
    711     gfx:fillRect(formulaBarX, formulaBarY, formulaBarW, formulaBarH, fBarBgColor)
    712     gfx:drawRect(formulaBarX, formulaBarY, formulaBarW, formulaBarH, fBarBorderColor)
    713 
    714     -- Formula bar content
    715     local fBarText = "%="
    716     if formulaBarActive then
    717         fBarText = fBarText .. formulaBuffer .. "|"
    718     else
    719         -- Show existing formula if cell has one
    720         local cellVal = getCell(selectedX, selectedY)
    721         if type(cellVal) == "string" and cellVal:sub(1, 2) == "%=" then
    722             fBarText = cellVal
    723         else
    724             fBarText = "%="
    725         end
    726     end
    727     gfx:drawText(formulaBarX + 3, formulaBarY + 4, fBarText, 0x000000)
    728 
    729     -- Column headers background
    730     gfx:fillRect(0, toolbarHeight, winW, headerHeight, 0xD0D0D0)
    731 
    732     -- Row headers background
    733     gfx:fillRect(0, toolbarHeight + headerHeight, rowHeaderWidth, winH, 0xD0D0D0)
    734 
    735     -- Corner
    736     gfx:fillRect(0, toolbarHeight, rowHeaderWidth, headerHeight, 0xC0C0C0)
    737     gfx:drawRect(0, toolbarHeight, rowHeaderWidth, headerHeight, 0x888888)
    738 
    739     -- Calculate visible range
    740     local startRow = math.floor(scrollY / rowHeight) + 1
    741     local visibleRows = math.ceil((winH - toolbarHeight - headerHeight) / rowHeight) + 1
    742     local endRow = startRow + visibleRows
    743 
    744     -- Draw column headers
    745     local x = rowHeaderWidth - scrollX
    746     local col = 1
    747     while x < winW do
    748         local colW = getColWidth(col)
    749         if x + colW > rowHeaderWidth then
    750             local drawX = math.max(rowHeaderWidth, x)
    751             local drawW = math.min(colW, x + colW - drawX)
    752             if x >= rowHeaderWidth then
    753                 gfx:drawRect(x, toolbarHeight, colW, headerHeight, 0x888888)
    754                 gfx:drawText(x + 4, toolbarHeight + 4, colToLetter(col), 0x000000)
    755             end
    756         end
    757         x = x + colW
    758         col = col + 1
    759         if col > 100 then break end  -- Limit columns drawn
    760     end
    761 
    762     -- Draw rows
    763     for row = startRow, endRow do
    764         local y = toolbarHeight + headerHeight + (row - 1) * rowHeight - scrollY
    765 
    766         if y + rowHeight > toolbarHeight + headerHeight and y < winH then
    767             -- Row header
    768             gfx:drawRect(0, y, rowHeaderWidth, rowHeight, 0x888888)
    769             gfx:drawText(4, y + 4, tostring(row), 0x000000)
    770 
    771             -- Cells
    772             x = rowHeaderWidth - scrollX
    773             col = 1
    774             while x < winW do
    775                 local colW = getColWidth(col)
    776 
    777                 if x + colW > rowHeaderWidth then
    778                     -- Cell background
    779                     local isSelected = (col == selectedX and row == selectedY)
    780                     local bgColor = isSelected and 0xCCE5FF or 0xFFFFFF
    781 
    782                     local cellX = math.max(rowHeaderWidth, x)
    783                     local cellW = colW - (cellX - x)
    784                     if cellX + cellW > winW then cellW = winW - cellX end
    785 
    786                     if cellW > 0 then
    787                         gfx:fillRect(cellX, y, cellW, rowHeight, bgColor)
    788                         gfx:drawRect(cellX, y, cellW, rowHeight, 0xCCCCCC)
    789 
    790                         -- Check if cell has a formula
    791                         local rawVal = getCell(col, row)
    792                         local isFormula = type(rawVal) == "string" and rawVal:sub(1, 2) == "%="
    793 
    794                         -- Cell content
    795                         if isSelected and editing then
    796                             -- Show edit buffer with cursor
    797                             local displayText = editBuffer .. "|"
    798                             gfx:drawText(cellX + 2, y + 4, displayText, 0x000000)
    799                         else
    800                             local displayVal = getDisplayValue(col, row)
    801                             if displayVal ~= "" then
    802                                 -- Truncate if too long
    803                                 local maxChars = math.floor((colW - 4) / 7)
    804                                 if #displayVal > maxChars then
    805                                     displayVal = displayVal:sub(1, maxChars - 1) .. "..."
    806                                 end
    807                                 gfx:drawText(cellX + 2, y + 4, displayVal, 0x000000)
    808                             end
    809                         end
    810 
    811                         -- Formula cell border (light blue)
    812                         if isFormula and not isSelected then
    813                             gfx:drawRect(cellX, y, cellW, rowHeight, 0x66AAFF)
    814                         end
    815 
    816                         -- Selection border
    817                         if isSelected then
    818                             gfx:drawRect(cellX, y, cellW, rowHeight, 0x0066CC)
    819                             gfx:drawRect(cellX + 1, y + 1, cellW - 2, rowHeight - 2, 0x0066CC)
    820                         end
    821                     end
    822                 end
    823 
    824                 x = x + colW
    825                 col = col + 1
    826                 if col > 100 then break end
    827             end
    828         end
    829     end
    830 
    831     -- Grid border
    832     gfx:drawRect(0, toolbarHeight, winW, winH - toolbarHeight, 0x888888)
    833 end
    834 
    835 print("Spreadsheet loaded")