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")