luajitos

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

editor.lua (18824B)


      1 -- Lunar Editor - Simple text editor for LuajitOS
      2 -- Displays text files with a toolbar (New, Open, Save)
      3 
      4 local windowWidth = 640
      5 local windowHeight = 480
      6 local toolbarHeight = 30
      7 local statusBarHeight = 20
      8 local contentHeight = windowHeight - toolbarHeight - statusBarHeight
      9 
     10 -- Editor state
     11 local lines = {""}  -- Array of lines
     12 local cursorLine = 1
     13 local cursorCol = 1
     14 local scrollY = 0  -- Scroll offset in lines
     15 local currentFile = nil  -- Currently open file path
     16 local modified = false  -- Has the file been modified?
     17 
     18 -- Font metrics (scale 1 = 8px base font, but we use larger spacing)
     19 local fontScale = 1
     20 local charWidth = 8
     21 local lineHeight = 12
     22 local visibleLines = math.floor(contentHeight / lineHeight)
     23 
     24 -- Toolbar buttons
     25 local toolbarButtons = {
     26     { x = 5, y = 5, width = 70, height = 20, label = "New", action = "new" },
     27     { x = 80, y = 5, width = 70, height = 20, label = "Open", action = "open" },
     28     { x = 155, y = 5, width = 70, height = 20, label = "Save", action = "save" },
     29     { x = 230, y = 5, width = 75, height = 20, label = "Save As", action = "saveas" },
     30 }
     31 
     32 -- Helper: get window title
     33 local function getTitle()
     34     local title = "Lunar Editor"
     35     if currentFile then
     36         -- Get filename from path
     37         local filename = currentFile:match("([^/]+)$") or currentFile
     38         title = title .. " - " .. filename
     39     else
     40         title = title .. " - Untitled"
     41     end
     42     if modified then
     43         title = title .. " *"
     44     end
     45     return title
     46 end
     47 
     48 -- Create window
     49 local window = app:newWindow(getTitle(), windowWidth, windowHeight, true)
     50 
     51 if not window then
     52     print("Lunar Editor: Failed to create window")
     53     return
     54 end
     55 
     56 print("Lunar Editor: Starting...")
     57 
     58 -- Helper: update title
     59 local function updateTitle()
     60     window.title = getTitle()
     61 end
     62 
     63 -- Helper: ensure cursor is in valid position
     64 local function clampCursor()
     65     if cursorLine < 1 then cursorLine = 1 end
     66     if cursorLine > #lines then cursorLine = #lines end
     67     if cursorCol < 1 then cursorCol = 1 end
     68     local lineLen = #lines[cursorLine]
     69     if cursorCol > lineLen + 1 then cursorCol = lineLen + 1 end
     70 end
     71 
     72 -- Helper: ensure cursor is visible (scroll if needed)
     73 local function ensureCursorVisible()
     74     if cursorLine <= scrollY then
     75         scrollY = cursorLine - 1
     76     elseif cursorLine > scrollY + visibleLines then
     77         scrollY = cursorLine - visibleLines
     78     end
     79 end
     80 
     81 -- File operations
     82 local function newFile()
     83     lines = {""}
     84     cursorLine = 1
     85     cursorCol = 1
     86     scrollY = 0
     87     currentFile = nil
     88     modified = false
     89     updateTitle()
     90     window:markDirty()
     91     print("Lunar Editor: New file")
     92 end
     93 
     94 local function loadFile(path)
     95     if not fs then
     96         print("Lunar Editor: Filesystem not available")
     97         return false
     98     end
     99 
    100     local content, err = fs:read(path)
    101     if not content then
    102         print("Lunar Editor: Failed to read file: " .. tostring(err))
    103         return false
    104     end
    105 
    106     -- Split content into lines
    107     lines = {}
    108     for line in (content .. "\n"):gmatch("([^\n]*)\n") do
    109         table.insert(lines, line)
    110     end
    111 
    112     -- Ensure at least one line
    113     if #lines == 0 then
    114         lines = {""}
    115     end
    116 
    117     cursorLine = 1
    118     cursorCol = 1
    119     scrollY = 0
    120     currentFile = path
    121     modified = false
    122     updateTitle()
    123     window:markDirty()
    124     print("Lunar Editor: Loaded " .. path .. " (" .. #lines .. " lines)")
    125     return true
    126 end
    127 
    128 local function saveFile(path)
    129     if not fs then
    130         print("Lunar Editor: Filesystem not available")
    131         return false
    132     end
    133 
    134     -- Join lines with newlines
    135     local content = table.concat(lines, "\n")
    136 
    137     local ok, err = fs:write(path, content)
    138     if not ok then
    139         print("Lunar Editor: Failed to save file: " .. tostring(err))
    140         return false
    141     end
    142 
    143     currentFile = path
    144     modified = false
    145     updateTitle()
    146     window:markDirty()
    147     print("Lunar Editor: Saved " .. path)
    148     return true
    149 end
    150 
    151 -- Dialog functions
    152 local function openFileDialog()
    153     if not Dialog or not Dialog.fileOpen then
    154         print("Lunar Editor: Dialog.fileOpen not available")
    155         return
    156     end
    157 
    158     local startPath = "/home"
    159     if currentFile then
    160         -- Use current file's directory
    161         startPath = currentFile:match("(.*/)")
    162         if not startPath then startPath = "/home" end
    163     end
    164 
    165     local dialog = Dialog.fileOpen(startPath, {
    166         app = app,
    167         fs = fs,
    168         title = "Open File"
    169     })
    170 
    171     dialog:openDialog(function(selectedPath)
    172         if selectedPath then
    173             loadFile(selectedPath)
    174         end
    175     end)
    176 end
    177 
    178 local function saveFileDialog()
    179     if not Dialog or not Dialog.fileSave then
    180         print("Lunar Editor: Dialog.fileSave not available")
    181         return
    182     end
    183 
    184     local startPath = "/home"
    185     local defaultName = "untitled.txt"
    186 
    187     if currentFile then
    188         startPath = currentFile:match("(.*/)")
    189         defaultName = currentFile:match("([^/]+)$") or "untitled.txt"
    190         if not startPath then startPath = "/home" end
    191     end
    192 
    193     local dialog = Dialog.fileSave(startPath, defaultName, {
    194         app = app,
    195         fs = fs,
    196         title = "Save File"
    197     })
    198 
    199     dialog:openDialog(function(selectedPath)
    200         if selectedPath then
    201             saveFile(selectedPath)
    202         end
    203     end)
    204 end
    205 
    206 -- Draw callback
    207 window.onDraw = function(gfx)
    208     -- Draw toolbar background
    209     gfx:fillRect(0, 0, windowWidth, toolbarHeight, 0x404040)
    210 
    211     -- Draw toolbar buttons
    212     for _, btn in ipairs(toolbarButtons) do
    213         gfx:fillRect(btn.x, btn.y, btn.width, btn.height, 0x606060)
    214         gfx:drawRect(btn.x, btn.y, btn.width, btn.height, 0x808080)
    215         local textX = btn.x + (btn.width - #btn.label * 6) / 2
    216         local textY = btn.y + 6
    217         gfx:drawText(textX, textY, btn.label, 0xFFFFFF)
    218     end
    219 
    220     -- Draw separator line
    221     gfx:fillRect(0, toolbarHeight - 1, windowWidth, 1, 0x303030)
    222 
    223     -- Draw content area (white background)
    224     gfx:fillRect(0, toolbarHeight, windowWidth, contentHeight, 0xFFFFFF)
    225 
    226     -- Draw text content
    227     local y = toolbarHeight + 2
    228     local startLine = scrollY + 1
    229     local endLine = math.min(#lines, scrollY + visibleLines)
    230 
    231     for i = startLine, endLine do
    232         local line = lines[i] or ""
    233         gfx:drawText(5, y, line, 0x000000, fontScale)
    234 
    235         -- Draw cursor if on this line
    236         if i == cursorLine then
    237             local cursorX = 5 + (cursorCol - 1) * charWidth
    238             local cursorY = y
    239             -- Draw cursor as a vertical line
    240             gfx:fillRect(cursorX, cursorY, 2, lineHeight, 0x000000)
    241         end
    242 
    243         y = y + lineHeight
    244     end
    245 
    246     -- Draw status bar
    247     local statusY = windowHeight - statusBarHeight
    248     gfx:fillRect(0, statusY, windowWidth, statusBarHeight, 0x333333)
    249 
    250     -- Status text: line/col info
    251     local statusText = "Ln " .. cursorLine .. ", Col " .. cursorCol
    252     if currentFile then
    253         statusText = currentFile .. "  |  " .. statusText
    254     else
    255         statusText = "Untitled  |  " .. statusText
    256     end
    257     gfx:drawText(5, statusY + 4, statusText, 0xAAAAAA)
    258 
    259     -- Right side: modified indicator
    260     if modified then
    261         gfx:drawText(windowWidth - 70, statusY + 4, "Modified", 0xFFAAAA)
    262     end
    263 end
    264 
    265 -- Input callback
    266 window.onInput = function(key, scancode)
    267     local baseScancode = (scancode or 0) % 128
    268 
    269     -- Arrow keys
    270     if baseScancode == 72 then  -- Up
    271         if cursorLine > 1 then
    272             cursorLine = cursorLine - 1
    273             clampCursor()
    274             ensureCursorVisible()
    275             window:markDirty()
    276         end
    277         return
    278     elseif baseScancode == 80 then  -- Down
    279         if cursorLine < #lines then
    280             cursorLine = cursorLine + 1
    281             clampCursor()
    282             ensureCursorVisible()
    283             window:markDirty()
    284         end
    285         return
    286     elseif baseScancode == 75 then  -- Left
    287         if cursorCol > 1 then
    288             cursorCol = cursorCol - 1
    289         elseif cursorLine > 1 then
    290             cursorLine = cursorLine - 1
    291             cursorCol = #lines[cursorLine] + 1
    292         end
    293         clampCursor()
    294         ensureCursorVisible()
    295         window:markDirty()
    296         return
    297     elseif baseScancode == 77 then  -- Right
    298         if cursorCol <= #lines[cursorLine] then
    299             cursorCol = cursorCol + 1
    300         elseif cursorLine < #lines then
    301             cursorLine = cursorLine + 1
    302             cursorCol = 1
    303         end
    304         clampCursor()
    305         ensureCursorVisible()
    306         window:markDirty()
    307         return
    308     elseif baseScancode == 71 then  -- Home
    309         cursorCol = 1
    310         window:markDirty()
    311         return
    312     elseif baseScancode == 79 then  -- End
    313         cursorCol = #lines[cursorLine] + 1
    314         window:markDirty()
    315         return
    316     elseif baseScancode == 73 then  -- Page Up
    317         cursorLine = math.max(1, cursorLine - visibleLines)
    318         clampCursor()
    319         ensureCursorVisible()
    320         window:markDirty()
    321         return
    322     elseif baseScancode == 81 then  -- Page Down
    323         cursorLine = math.min(#lines, cursorLine + visibleLines)
    324         clampCursor()
    325         ensureCursorVisible()
    326         window:markDirty()
    327         return
    328     elseif baseScancode == 83 then  -- Delete
    329         local line = lines[cursorLine]
    330         if cursorCol <= #line then
    331             -- Delete character at cursor
    332             lines[cursorLine] = line:sub(1, cursorCol - 1) .. line:sub(cursorCol + 1)
    333             modified = true
    334             updateTitle()
    335         elseif cursorLine < #lines then
    336             -- Join with next line
    337             lines[cursorLine] = line .. lines[cursorLine + 1]
    338             table.remove(lines, cursorLine + 1)
    339             modified = true
    340             updateTitle()
    341         end
    342         clampCursor()
    343         window:markDirty()
    344         return
    345     end
    346 
    347     -- Text input
    348     if key == "\b" then  -- Backspace
    349         if cursorCol > 1 then
    350             local line = lines[cursorLine]
    351             lines[cursorLine] = line:sub(1, cursorCol - 2) .. line:sub(cursorCol)
    352             cursorCol = cursorCol - 1
    353             modified = true
    354             updateTitle()
    355         elseif cursorLine > 1 then
    356             -- Join with previous line
    357             local prevLine = lines[cursorLine - 1]
    358             cursorCol = #prevLine + 1
    359             lines[cursorLine - 1] = prevLine .. lines[cursorLine]
    360             table.remove(lines, cursorLine)
    361             cursorLine = cursorLine - 1
    362             modified = true
    363             updateTitle()
    364         end
    365         clampCursor()
    366         ensureCursorVisible()
    367         window:markDirty()
    368     elseif key == "\n" then  -- Enter
    369         local line = lines[cursorLine]
    370         local beforeCursor = line:sub(1, cursorCol - 1)
    371         local afterCursor = line:sub(cursorCol)
    372         lines[cursorLine] = beforeCursor
    373         table.insert(lines, cursorLine + 1, afterCursor)
    374         cursorLine = cursorLine + 1
    375         cursorCol = 1
    376         modified = true
    377         updateTitle()
    378         clampCursor()
    379         ensureCursorVisible()
    380         window:markDirty()
    381     elseif key and #key == 1 and key:byte() >= 32 then  -- Printable character
    382         local line = lines[cursorLine]
    383         lines[cursorLine] = line:sub(1, cursorCol - 1) .. key .. line:sub(cursorCol)
    384         cursorCol = cursorCol + 1
    385         modified = true
    386         updateTitle()
    387         window:markDirty()
    388     end
    389 end
    390 
    391 -- Paste callback for handling multi-line pastes properly
    392 window.onPaste = function(content, contentType)
    393     if not content or content == "" then return end
    394 
    395     -- Split content into lines
    396     local pasteLines = {}
    397     for line in (content .. "\n"):gmatch("([^\n]*)\n") do
    398         table.insert(pasteLines, line)
    399     end
    400 
    401     if #pasteLines == 0 then return end
    402 
    403     -- Get current line content
    404     local currentLine = lines[cursorLine] or ""
    405     local beforeCursor = currentLine:sub(1, cursorCol - 1)
    406     local afterCursor = currentLine:sub(cursorCol)
    407 
    408     if #pasteLines == 1 then
    409         -- Single line paste - insert inline
    410         lines[cursorLine] = beforeCursor .. pasteLines[1] .. afterCursor
    411         cursorCol = cursorCol + #pasteLines[1]
    412     else
    413         -- Multi-line paste
    414         -- First line: append to current position
    415         lines[cursorLine] = beforeCursor .. pasteLines[1]
    416 
    417         -- Middle lines: insert as new lines
    418         for i = 2, #pasteLines - 1 do
    419             table.insert(lines, cursorLine + i - 1, pasteLines[i])
    420         end
    421 
    422         -- Last line: prepend to remaining content
    423         local lastPasteLine = pasteLines[#pasteLines]
    424         table.insert(lines, cursorLine + #pasteLines - 1, lastPasteLine .. afterCursor)
    425 
    426         -- Update cursor position
    427         cursorLine = cursorLine + #pasteLines - 1
    428         cursorCol = #lastPasteLine + 1
    429     end
    430 
    431     modified = true
    432     updateTitle()
    433     clampCursor()
    434     ensureCursorVisible()
    435     window:markDirty()
    436 end
    437 
    438 -- Selection edit callback for multi-line selection editing
    439 -- point1 and point2 are {x, y} coordinates in the content area
    440 window.onSelectionEditted = function(point1, point2, newContent)
    441     -- Safety check for valid points
    442     if not point1 or not point2 or not point1.x or not point1.y or not point2.x or not point2.y then
    443         return
    444     end
    445 
    446     -- Convert y coordinates to line numbers
    447     -- Text starts at y = toolbarHeight + 2, each line is lineHeight pixels
    448     local textStartY = toolbarHeight + 2
    449 
    450     local startLine = scrollY + math.floor((point1.y - textStartY) / lineHeight) + 1
    451     local endLine = scrollY + math.floor((point2.y - textStartY) / lineHeight) + 1
    452 
    453     -- Convert x coordinates to column positions
    454     -- Text starts at x = 5, each character is charWidth pixels
    455     local startCol = math.floor((point1.x - 5) / charWidth) + 1
    456     local endCol = math.floor((point2.x - 5) / charWidth) + 1
    457 
    458     -- Normalize so start is before end
    459     if startLine > endLine or (startLine == endLine and startCol > endCol) then
    460         startLine, endLine = endLine, startLine
    461         startCol, endCol = endCol, startCol
    462     end
    463 
    464     -- Validate line numbers - if selection is completely outside valid range, abort
    465     if startLine > #lines and endLine > #lines then return end
    466     if startLine < 1 then startLine = 1 end
    467     if endLine > #lines then endLine = #lines end
    468     if startLine > #lines then startLine = #lines end
    469     if #lines == 0 then
    470         lines = {""}
    471         startLine = 1
    472         endLine = 1
    473     end
    474 
    475     -- Clamp column positions
    476     if startCol < 1 then startCol = 1 end
    477     if endCol < 1 then endCol = 1 end
    478     local startLineLen = lines[startLine] and #lines[startLine] or 0
    479     local endLineLen = lines[endLine] and #lines[endLine] or 0
    480     if startCol > startLineLen + 1 then startCol = startLineLen + 1 end
    481     if endCol > endLineLen then endCol = endLineLen end
    482 
    483     -- Get the text before and after the selection
    484     local beforeText = lines[startLine]:sub(1, startCol - 1)
    485     local afterText = lines[endLine]:sub(endCol + 1)
    486 
    487     -- Split newContent into lines
    488     local newLines = {}
    489     for line in ((newContent or "") .. "\n"):gmatch("([^\n]*)\n") do
    490         table.insert(newLines, line)
    491     end
    492     -- Remove the trailing empty line added by the pattern
    493     if #newLines > 0 and newLines[#newLines] == "" and not (newContent or ""):match("\n$") then
    494         table.remove(newLines)
    495     end
    496     if #newLines == 0 then
    497         newLines = {""}
    498     end
    499 
    500     -- Remove all lines in the selection range
    501     for i = endLine, startLine + 1, -1 do
    502         table.remove(lines, i)
    503     end
    504 
    505     -- Build the replacement
    506     if #newLines == 1 then
    507         -- Single line replacement
    508         lines[startLine] = beforeText .. newLines[1] .. afterText
    509         cursorLine = startLine
    510         cursorCol = #beforeText + #newLines[1] + 1
    511     else
    512         -- Multi-line replacement
    513         -- First line: beforeText + first new line
    514         lines[startLine] = beforeText .. newLines[1]
    515 
    516         -- Middle lines: insert as new lines
    517         for i = 2, #newLines - 1 do
    518             table.insert(lines, startLine + i - 1, newLines[i])
    519         end
    520 
    521         -- Last line: last new line + afterText
    522         local lastNewLine = newLines[#newLines]
    523         table.insert(lines, startLine + #newLines - 1, lastNewLine .. afterText)
    524 
    525         cursorLine = startLine + #newLines - 1
    526         cursorCol = #lastNewLine + 1
    527     end
    528 
    529     modified = true
    530     updateTitle()
    531     clampCursor()
    532     ensureCursorVisible()
    533     window:markDirty()
    534 end
    535 
    536 -- Click callback
    537 window.onClick = function(x, y, button)
    538     -- Check toolbar
    539     if y < toolbarHeight then
    540         for _, btn in ipairs(toolbarButtons) do
    541             if x >= btn.x and x < btn.x + btn.width and
    542                y >= btn.y and y < btn.y + btn.height then
    543                 print("Lunar Editor: Button clicked: " .. btn.action)
    544                 if btn.action == "new" then
    545                     newFile()
    546                 elseif btn.action == "open" then
    547                     openFileDialog()
    548                 elseif btn.action == "save" then
    549                     if currentFile then
    550                         saveFile(currentFile)
    551                     else
    552                         saveFileDialog()
    553                     end
    554                 elseif btn.action == "saveas" then
    555                     saveFileDialog()
    556                 end
    557                 return
    558             end
    559         end
    560         return
    561     end
    562 
    563     -- Click in content area - move cursor
    564     if y >= toolbarHeight and y < windowHeight - statusBarHeight then
    565         local contentY = y - toolbarHeight
    566         local clickedLine = scrollY + math.floor(contentY / lineHeight) + 1
    567         if clickedLine >= 1 and clickedLine <= #lines then
    568             cursorLine = clickedLine
    569             local clickedCol = math.floor((x - 5) / charWidth) + 1
    570             cursorCol = math.max(1, math.min(clickedCol, #lines[cursorLine] + 1))
    571             window:markDirty()
    572         end
    573     end
    574 end
    575 
    576 -- Resize callback
    577 window.onResize = function(newWidth, newHeight, oldWidth, oldHeight)
    578     windowWidth = newWidth
    579     windowHeight = newHeight
    580     contentHeight = windowHeight - toolbarHeight - statusBarHeight
    581     visibleLines = math.floor(contentHeight / lineHeight)
    582     if osprint then
    583         osprint("[LunarEditor] onResize: " .. oldWidth .. "x" .. oldHeight .. " -> " .. newWidth .. "x" .. newHeight .. "\n")
    584         osprint("[LunarEditor] contentHeight=" .. contentHeight .. " visibleLines=" .. visibleLines .. "\n")
    585     end
    586     -- Ensure cursor is still visible after resize
    587     ensureCursorVisible()
    588     window:markDirty()
    589 end
    590 
    591 -- Check for command line arguments to open a file
    592 if args then
    593     local argPath = args.o or args.open or args[1]
    594     if argPath and argPath ~= "" then
    595         -- Expand ~ to /home
    596         if argPath:sub(1, 1) == "~" then
    597             argPath = "/home" .. argPath:sub(2)
    598         end
    599         print("Lunar Editor: Opening file from arguments: " .. argPath)
    600         loadFile(argPath)
    601     end
    602 end
    603 
    604 print("Lunar Editor: Ready")