├── .gitignore ├── LICENSE ├── README.md ├── compiler.lua ├── luaide.lua └── src ├── application.lua ├── controller.lua ├── editor ├── content.lua ├── editor.lua ├── file.lua └── highlighter.lua ├── main.lua ├── theme.lua └── ui ├── dialogue.lua ├── menu.lua ├── panel.lua ├── responder.lua ├── tab.lua └── textfield.lua /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | design/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 GravityScore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Lua IDE 3 | ======= 4 | 5 | An in-game IDE for ComputerCraft, complete with syntax highlighting, automatic indentation, live compiler errors, and support for coding and running Lua and Brainf**k code. 6 | -------------------------------------------------------------------------------- /compiler.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Compiler 4 | -- By Oeed 5 | -- 6 | 7 | 8 | local mainFile = "main.lua" 9 | 10 | local file = [[ 11 | 12 | -- 13 | -- Firewolf 14 | -- By GravityScore and 1lann 15 | -- 16 | -- Credits: 17 | -- * Files compiled together using Compilr by Oeed 18 | -- * RC4 Implementation by AgentE382 19 | -- * Base64 Implementation by KillaVanilla 20 | -- 21 | 22 | 23 | ]] 24 | 25 | local files = {} 26 | 27 | local function addFolder(path, tree) 28 | for i, v in ipairs(fs.list(path)) do 29 | local subPath = path .. "/" .. v 30 | 31 | local isProtectedFile = subPath == "/rom" or subPath == "/build" 32 | local isRunningFile = subPath == "/" .. shell.getRunningProgram() 33 | if v == ".DS_Store" or v == ".git" or isProtectedFile or isRunningFile then 34 | -- Ignore 35 | elseif fs.isDir(subPath) then 36 | tree[v] = {} 37 | addFolder(subPath, tree[v]) 38 | else 39 | local h = fs.open(subPath, "r") 40 | if h then 41 | tree[v] = h.readAll() 42 | h.close() 43 | end 44 | end 45 | end 46 | end 47 | 48 | addFolder("", files) 49 | 50 | if not files[mainFile] then 51 | error("You must have a file called " .. mainFile .. " to be executed at runtime.") 52 | end 53 | 54 | file = file .. "local files = " .. textutils.serialize(files) .. "\n" 55 | 56 | file = file .. [[ 57 | 58 | local function run(args) 59 | local fnFile, err = loadstring(files[ ]] .. mainFile .. [[ ], "]] .. mainFile .. [[") 60 | if err then 61 | error(err) 62 | end 63 | 64 | local function split(str, pat) 65 | local t = {} 66 | local fpat = "(.-)" .. pat 67 | local last_end = 1 68 | local s, e, cap = str:find(fpat, 1) 69 | while s do 70 | if s ~= 1 or cap ~= "" then 71 | table.insert(t, cap) 72 | end 73 | last_end = e + 1 74 | s, e, cap = str:find(fpat, last_end) 75 | end 76 | 77 | if last_end <= #str then 78 | cap = str:sub(last_end) 79 | table.insert(t, cap) 80 | end 81 | return t 82 | end 83 | 84 | local function resolveTreeForPath(path, single) 85 | local _files = files 86 | local parts = split(path, "/") 87 | if parts then 88 | for i, v in ipairs(parts) do 89 | if #v > 0 then 90 | if _files[v] then 91 | _files = _files[v] 92 | else 93 | _files = nil 94 | break 95 | end 96 | end 97 | end 98 | elseif #path > 0 and path ~= "/" then 99 | _files = _files[path] 100 | end 101 | if not single or type(_files) == "string" then 102 | return _files 103 | end 104 | end 105 | 106 | local oldFs = fs 107 | local env 108 | env = { 109 | fs = { 110 | list = function(path) 111 | local list = {} 112 | if fs.exists(path) then 113 | list = fs.list(path) 114 | end 115 | for k, v in pairs(resolveTreeForPath(path)) do 116 | if not fs.exists(path .. "/" ..k) then 117 | table.insert(list, k) 118 | end 119 | end 120 | return list 121 | end, 122 | 123 | exists = function(path) 124 | if fs.exists(path) then 125 | return true 126 | elseif resolveTreeForPath(path) then 127 | return true 128 | else 129 | return false 130 | end 131 | end, 132 | 133 | isDir = function(path) 134 | if fs.isDir(path) then 135 | return true 136 | else 137 | local tree = resolveTreeForPath(path) 138 | if tree and type(tree) == "table" then 139 | return true 140 | else 141 | return false 142 | end 143 | end 144 | end, 145 | 146 | isReadOnly = function(path) 147 | if not fs.isReadOnly(path) then 148 | return false 149 | else 150 | return true 151 | end 152 | end, 153 | 154 | getName = fs.getName, 155 | getSize = fs.getSize, 156 | getFreespace = fs.getFreespace, 157 | makeDir = fs.makeDir, 158 | move = fs.move, 159 | copy = fs.copy, 160 | delete = fs.delete, 161 | combine = fs.combine, 162 | 163 | open = function(path, mode) 164 | if fs.exists(path) then 165 | return fs.open(path, mode) 166 | elseif type(resolveTreeForPath(path)) == "string" then 167 | local handle = {close = function()end} 168 | if mode == "r" then 169 | local content = resolveTreeForPath(path) 170 | handle.readAll = function() 171 | return content 172 | end 173 | 174 | local line = 1 175 | local lines = split(content, "\n") 176 | handle.readLine = function() 177 | if line > #lines then 178 | return nil 179 | else 180 | return lines[line] 181 | end 182 | line = line + 1 183 | end 184 | return handle 185 | else 186 | error("Cannot write to read-only file (compilr archived).") 187 | end 188 | else 189 | return fs.open(path, mode) 190 | end 191 | end 192 | }, 193 | 194 | io = { 195 | input = io.input, 196 | output = io.output, 197 | type = io.type, 198 | close = io.close, 199 | write = io.write, 200 | flush = io.flush, 201 | lines = io.lines, 202 | read = io.read, 203 | open = function(path, mode) 204 | if fs.exists(path) then 205 | return io.open(path, mode) 206 | elseif type(resolveTreeForPath(path)) == "string" then 207 | local content = resolveTreeForPath(path) 208 | local f = fs.open(path, "w") 209 | f.write(content) 210 | f.close() 211 | if mode == "r" then 212 | return io.open(path, mode) 213 | else 214 | error("Cannot write to read-only file (compilr archived).") 215 | end 216 | else 217 | return io.open(path, mode) 218 | end 219 | end 220 | }, 221 | 222 | loadfile = function(_sFile) 223 | local file = env.fs.open(_sFile, "r") 224 | if file then 225 | local func, err = loadstring(file.readAll(), fs.getName(_sFile)) 226 | file.close() 227 | return func, err 228 | end 229 | return nil, "File not found: ".._sFile 230 | end, 231 | 232 | dofile = function(_sFile) 233 | local fnFile, e = env.loadfile(_sFile) 234 | if fnFile then 235 | setfenv(fnFile, getfenv(2)) 236 | return fnFile() 237 | else 238 | error(e, 2) 239 | end 240 | end 241 | } 242 | 243 | setmetatable(env, { __index = _G }) 244 | 245 | local tAPIsLoading = {} 246 | env.os.loadAPI = function(_sPath) 247 | local sName = fs.getName(_sPath) 248 | if tAPIsLoading[sName] == true then 249 | printError("API "..sName.." is already being loaded") 250 | return false 251 | end 252 | tAPIsLoading[sName] = true 253 | 254 | local tEnv = {} 255 | setmetatable(tEnv, { __index = env }) 256 | local fnAPI, err = env.loadfile(_sPath) 257 | if fnAPI then 258 | setfenv(fnAPI, tEnv) 259 | fnAPI() 260 | else 261 | printError(err) 262 | tAPIsLoading[sName] = nil 263 | return false 264 | end 265 | 266 | local tAPI = {} 267 | for k,v in pairs(tEnv) do 268 | tAPI[k] = v 269 | end 270 | 271 | env[sName] = tAPI 272 | tAPIsLoading[sName] = nil 273 | return true 274 | end 275 | 276 | env.shell = shell 277 | 278 | setfenv(fnFile, env) 279 | fnFile(unpack(args)) 280 | end 281 | 282 | local function extract() 283 | local function node(path, tree) 284 | if type(tree) == "table" then 285 | fs.makeDir(path) 286 | for k, v in pairs(tree) do 287 | node(path .. "/" .. k, v) 288 | end 289 | else 290 | local f = fs.open(path, "w") 291 | if f then 292 | f.write(tree) 293 | f.close() 294 | end 295 | end 296 | end 297 | node("", files) 298 | end 299 | 300 | local args = {...} 301 | if #args == 1 and args[1] == "--extract" then 302 | extract() 303 | else 304 | run(args) 305 | end 306 | ]] 307 | 308 | 309 | fs.delete("/build") 310 | 311 | local f = fs.open("/build", "w") 312 | f.write(file) 313 | f.close() 314 | -------------------------------------------------------------------------------- /luaide.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Lua IDE 4 | -- Made by GravityScore 5 | -- 6 | 7 | 8 | 9 | 10 | -- Variables 11 | 12 | local version = "1.1" 13 | local arguments = {...} 14 | 15 | 16 | local w, h = term.getSize() 17 | local tabWidth = 2 18 | 19 | 20 | local autosaveInterval = 20 21 | local allowEditorEvent = true 22 | local keyboardShortcutTimeout = 0.4 23 | 24 | 25 | local clipboard = nil 26 | 27 | 28 | local theme = { 29 | background = colors.gray, 30 | titleBar = colors.lightGray, 31 | 32 | top = colors.lightBlue, 33 | bottom = colors.cyan, 34 | 35 | button = colors.cyan, 36 | buttonHighlighted = colors.lightBlue, 37 | 38 | dangerButton = colors.red, 39 | dangerButtonHighlighted = colors.pink, 40 | 41 | text = colors.white, 42 | folder = colors.lime, 43 | readOnly = colors.red, 44 | } 45 | 46 | 47 | local languages = {} 48 | local currentLanguage = {} 49 | 50 | 51 | local updateURL = "https://raw.github.com/GravityScore/LuaIDE/master/computercraft/ide.lua" 52 | local ideLocation = "/" .. shell.getRunningProgram() 53 | local themeLocation = "/.luaide_theme" 54 | 55 | local function isAdvanced() 56 | return term.isColor and term.isColor() 57 | end 58 | 59 | 60 | 61 | 62 | -- -------- Utilities 63 | 64 | local function modRead(properties) 65 | local w, h = term.getSize() 66 | local defaults = {replaceChar = nil, history = nil, visibleLength = nil, textLength = nil, 67 | liveUpdates = nil, exitOnKey = nil} 68 | if not properties then properties = {} end 69 | for k, v in pairs(defaults) do if not properties[k] then properties[k] = v end end 70 | if properties.replaceChar then properties.replaceChar = properties.replaceChar:sub(1, 1) end 71 | if not properties.visibleLength then properties.visibleLength = w end 72 | 73 | local sx, sy = term.getCursorPos() 74 | local line = "" 75 | local pos = 0 76 | local historyPos = nil 77 | 78 | local function redraw(repl) 79 | local scroll = 0 80 | if properties.visibleLength and sx + pos > properties.visibleLength + 1 then 81 | scroll = (sx + pos) - (properties.visibleLength + 1) 82 | end 83 | 84 | term.setCursorPos(sx, sy) 85 | local a = repl or properties.replaceChar 86 | if a then term.write(string.rep(a, line:len() - scroll)) 87 | else term.write(line:sub(scroll + 1, -1)) end 88 | term.setCursorPos(sx + pos - scroll, sy) 89 | end 90 | 91 | local function sendLiveUpdates(event, ...) 92 | if type(properties.liveUpdates) == "function" then 93 | local ox, oy = term.getCursorPos() 94 | local a, data = properties.liveUpdates(line, event, ...) 95 | if a == true and data == nil then 96 | term.setCursorBlink(false) 97 | return line 98 | elseif a == true and data ~= nil then 99 | term.setCursorBlink(false) 100 | return data 101 | end 102 | term.setCursorPos(ox, oy) 103 | end 104 | end 105 | 106 | term.setCursorBlink(true) 107 | while true do 108 | local e, but, x, y, p4, p5 = os.pullEvent() 109 | 110 | if e == "char" then 111 | local s = false 112 | if properties.textLength and line:len() < properties.textLength then s = true 113 | elseif not properties.textLength then s = true end 114 | 115 | local canType = true 116 | if not properties.grantPrint and properties.refusePrint then 117 | local canTypeKeys = {} 118 | if type(properties.refusePrint) == "table" then 119 | for _, v in pairs(properties.refusePrint) do 120 | table.insert(canTypeKeys, tostring(v):sub(1, 1)) 121 | end 122 | elseif type(properties.refusePrint) == "string" then 123 | for char in properties.refusePrint:gmatch(".") do 124 | table.insert(canTypeKeys, char) 125 | end 126 | end 127 | for _, v in pairs(canTypeKeys) do if but == v then canType = false end end 128 | elseif properties.grantPrint then 129 | canType = false 130 | local canTypeKeys = {} 131 | if type(properties.grantPrint) == "table" then 132 | for _, v in pairs(properties.grantPrint) do 133 | table.insert(canTypeKeys, tostring(v):sub(1, 1)) 134 | end 135 | elseif type(properties.grantPrint) == "string" then 136 | for char in properties.grantPrint:gmatch(".") do 137 | table.insert(canTypeKeys, char) 138 | end 139 | end 140 | for _, v in pairs(canTypeKeys) do if but == v then canType = true end end 141 | end 142 | 143 | if s and canType then 144 | line = line:sub(1, pos) .. but .. line:sub(pos + 1, -1) 145 | pos = pos + 1 146 | redraw() 147 | end 148 | elseif e == "key" then 149 | if but == keys.enter then break 150 | elseif but == keys.left then if pos > 0 then pos = pos - 1 redraw() end 151 | elseif but == keys.right then if pos < line:len() then pos = pos + 1 redraw() end 152 | elseif (but == keys.up or but == keys.down) and properties.history then 153 | redraw(" ") 154 | if but == keys.up then 155 | if historyPos == nil and #properties.history > 0 then 156 | historyPos = #properties.history 157 | elseif historyPos > 1 then 158 | historyPos = historyPos - 1 159 | end 160 | elseif but == keys.down then 161 | if historyPos == #properties.history then historyPos = nil 162 | elseif historyPos ~= nil then historyPos = historyPos + 1 end 163 | end 164 | 165 | if properties.history and historyPos then 166 | line = properties.history[historyPos] 167 | pos = line:len() 168 | else 169 | line = "" 170 | pos = 0 171 | end 172 | 173 | redraw() 174 | local a = sendLiveUpdates("history") 175 | if a then return a end 176 | elseif but == keys.backspace and pos > 0 then 177 | redraw(" ") 178 | line = line:sub(1, pos - 1) .. line:sub(pos + 1, -1) 179 | pos = pos - 1 180 | redraw() 181 | local a = sendLiveUpdates("delete") 182 | if a then return a end 183 | elseif but == keys.home then 184 | pos = 0 185 | redraw() 186 | elseif but == keys.delete and pos < line:len() then 187 | redraw(" ") 188 | line = line:sub(1, pos) .. line:sub(pos + 2, -1) 189 | redraw() 190 | local a = sendLiveUpdates("delete") 191 | if a then return a end 192 | elseif but == keys["end"] then 193 | pos = line:len() 194 | redraw() 195 | elseif properties.exitOnKey then 196 | if but == properties.exitOnKey or (properties.exitOnKey == "control" and 197 | (but == 29 or but == 157)) then 198 | term.setCursorBlink(false) 199 | return nil 200 | end 201 | end 202 | end 203 | local a = sendLiveUpdates(e, but, x, y, p4, p5) 204 | if a then return a end 205 | end 206 | 207 | term.setCursorBlink(false) 208 | if line ~= nil then line = line:gsub("^%s*(.-)%s*$", "%1") end 209 | return line 210 | end 211 | 212 | 213 | -- -------- Themes 214 | 215 | local defaultTheme = { 216 | background = "gray", 217 | backgroundHighlight = "lightGray", 218 | prompt = "cyan", 219 | promptHighlight = "lightBlue", 220 | err = "red", 221 | errHighlight = "pink", 222 | 223 | editorBackground = "gray", 224 | editorLineHightlight = "lightBlue", 225 | editorLineNumbers = "gray", 226 | editorLineNumbersHighlight = "lightGray", 227 | editorError = "pink", 228 | editorErrorHighlight = "red", 229 | 230 | textColor = "white", 231 | conditional = "yellow", 232 | constant = "orange", 233 | ["function"] = "magenta", 234 | string = "red", 235 | comment = "lime" 236 | } 237 | 238 | local normalTheme = { 239 | background = "black", 240 | backgroundHighlight = "black", 241 | prompt = "black", 242 | promptHighlight = "black", 243 | err = "black", 244 | errHighlight = "black", 245 | 246 | editorBackground = "black", 247 | editorLineHightlight = "black", 248 | editorLineNumbers = "black", 249 | editorLineNumbersHighlight = "white", 250 | editorError = "black", 251 | editorErrorHighlight = "black", 252 | 253 | textColor = "white", 254 | conditional = "white", 255 | constant = "white", 256 | ["function"] = "white", 257 | string = "white", 258 | comment = "white" 259 | } 260 | 261 | local availableThemes = { 262 | {"Water (Default)", "https://raw.github.com/GravityScore/LuaIDE/master/themes/default.txt"}, 263 | {"Fire", "https://raw.github.com/GravityScore/LuaIDE/master/themes/fire.txt"}, 264 | {"Sublime Text 2", "https://raw.github.com/GravityScore/LuaIDE/master/themes/st2.txt"}, 265 | {"Midnight", "https://raw.github.com/GravityScore/LuaIDE/master/themes/midnight.txt"}, 266 | {"TheOriginalBIT", "https://raw.github.com/GravityScore/LuaIDE/master/themes/bit.txt"}, 267 | {"Superaxander", "https://raw.github.com/GravityScore/LuaIDE/master/themes/superaxander.txt"}, 268 | {"Forest", "https://raw.github.com/GravityScore/LuaIDE/master/themes/forest.txt"}, 269 | {"Night", "https://raw.github.com/GravityScore/LuaIDE/master/themes/night.txt"}, 270 | {"Original", "https://raw.github.com/GravityScore/LuaIDE/master/themes/original.txt"}, 271 | } 272 | 273 | local function loadTheme(path) 274 | local f = io.open(path) 275 | local l = f:read("*l") 276 | local config = {} 277 | while l ~= nil do 278 | local k, v = string.match(l, "^(%a+)=(%a+)") 279 | if k and v then config[k] = v end 280 | l = f:read("*l") 281 | end 282 | f:close() 283 | return config 284 | end 285 | 286 | -- Load Theme 287 | if isAdvanced() then theme = defaultTheme 288 | else theme = normalTheme end 289 | 290 | 291 | -- -------- Drawing 292 | 293 | local function centerPrint(text, ny) 294 | if type(text) == "table" then for _, v in pairs(text) do centerPrint(v) end 295 | else 296 | local x, y = term.getCursorPos() 297 | local w, h = term.getSize() 298 | term.setCursorPos(w/2 - text:len()/2 + (#text % 2 == 0 and 1 or 0), ny or y) 299 | print(text) 300 | end 301 | end 302 | 303 | local function title(t) 304 | term.setTextColor(colors[theme.textColor]) 305 | term.setBackgroundColor(colors[theme.background]) 306 | term.clear() 307 | 308 | term.setBackgroundColor(colors[theme.backgroundHighlight]) 309 | for i = 2, 4 do term.setCursorPos(1, i) term.clearLine() end 310 | term.setCursorPos(3, 3) 311 | term.write(t) 312 | end 313 | 314 | local function centerRead(wid, begt) 315 | local function liveUpdate(line, e, but, x, y, p4, p5) 316 | if isAdvanced() and e == "mouse_click" and x >= w/2 - wid/2 and x <= w/2 - wid/2 + 10 317 | and y >= 13 and y <= 15 then 318 | return true, "" 319 | end 320 | end 321 | 322 | if not begt then begt = "" end 323 | term.setTextColor(colors[theme.textColor]) 324 | term.setBackgroundColor(colors[theme.promptHighlight]) 325 | for i = 8, 10 do 326 | term.setCursorPos(w/2 - wid/2, i) 327 | term.write(string.rep(" ", wid)) 328 | end 329 | 330 | if isAdvanced() then 331 | term.setBackgroundColor(colors[theme.errHighlight]) 332 | for i = 13, 15 do 333 | term.setCursorPos(w/2 - wid/2 + 1, i) 334 | term.write(string.rep(" ", 10)) 335 | end 336 | term.setCursorPos(w/2 - wid/2 + 2, 14) 337 | term.write("> Cancel") 338 | end 339 | 340 | term.setBackgroundColor(colors[theme.promptHighlight]) 341 | term.setCursorPos(w/2 - wid/2 + 1, 9) 342 | term.write("> " .. begt) 343 | return modRead({visibleLength = w/2 + wid/2, liveUpdates = liveUpdate}) 344 | end 345 | 346 | 347 | -- -------- Prompt 348 | 349 | local function prompt(list, dir, isGrid) 350 | local function draw(sel) 351 | for i, v in ipairs(list) do 352 | if i == sel then term.setBackgroundColor(v.highlight or colors[theme.promptHighlight]) 353 | else term.setBackgroundColor(v.bg or colors[theme.prompt]) end 354 | term.setTextColor(v.tc or colors[theme.textColor]) 355 | for i = -1, 1 do 356 | term.setCursorPos(v[2], v[3] + i) 357 | term.write(string.rep(" ", v[1]:len() + 4)) 358 | end 359 | 360 | term.setCursorPos(v[2], v[3]) 361 | if i == sel then 362 | term.setBackgroundColor(v.highlight or colors[theme.promptHighlight]) 363 | term.write(" > ") 364 | else term.write(" - ") end 365 | term.write(v[1] .. " ") 366 | end 367 | end 368 | 369 | local key1 = dir == "horizontal" and 203 or 200 370 | local key2 = dir == "horizontal" and 205 or 208 371 | local sel = 1 372 | draw(sel) 373 | 374 | while true do 375 | local e, but, x, y = os.pullEvent() 376 | if e == "key" and but == 28 then 377 | return list[sel][1] 378 | elseif e == "key" and but == key1 and sel > 1 then 379 | sel = sel - 1 380 | draw(sel) 381 | elseif e == "key" and but == key2 and ((err == true and sel < #list - 1) or (sel < #list)) then 382 | sel = sel + 1 383 | draw(sel) 384 | elseif isGrid and e == "key" and but == 203 and sel > 2 then 385 | sel = sel - 2 386 | draw(sel) 387 | elseif isGrid and e == "key" and but == 205 and sel < 3 then 388 | sel = sel + 2 389 | draw(sel) 390 | elseif e == "mouse_click" then 391 | for i, v in ipairs(list) do 392 | if x >= v[2] - 1 and x <= v[2] + v[1]:len() + 3 and y >= v[3] - 1 and y <= v[3] + 1 then 393 | return list[i][1] 394 | end 395 | end 396 | end 397 | end 398 | end 399 | 400 | local function scrollingPrompt(list) 401 | local function draw(items, sel, loc) 402 | for i, v in ipairs(items) do 403 | local bg = colors[theme.prompt] 404 | local bghigh = colors[theme.promptHighlight] 405 | if v:find("Back") or v:find("Return") then 406 | bg = colors[theme.err] 407 | bghigh = colors[theme.errHighlight] 408 | end 409 | 410 | if i == sel then term.setBackgroundColor(bghigh) 411 | else term.setBackgroundColor(bg) end 412 | term.setTextColor(colors[theme.textColor]) 413 | for x = -1, 1 do 414 | term.setCursorPos(3, (i * 4) + x + 4) 415 | term.write(string.rep(" ", w - 13)) 416 | end 417 | 418 | term.setCursorPos(3, i * 4 + 4) 419 | if i == sel then 420 | term.setBackgroundColor(bghigh) 421 | term.write(" > ") 422 | else term.write(" - ") end 423 | term.write(v .. " ") 424 | end 425 | end 426 | 427 | local function updateDisplayList(items, loc, len) 428 | local ret = {} 429 | for i = 1, len do 430 | local item = items[i + loc - 1] 431 | if item then table.insert(ret, item) end 432 | end 433 | return ret 434 | end 435 | 436 | -- Variables 437 | local sel = 1 438 | local loc = 1 439 | local len = 3 440 | local disList = updateDisplayList(list, loc, len) 441 | draw(disList, sel, loc) 442 | 443 | -- Loop 444 | while true do 445 | local e, key, x, y = os.pullEvent() 446 | 447 | if e == "mouse_click" then 448 | for i, v in ipairs(disList) do 449 | if x >= 3 and x <= w - 11 and y >= i * 4 + 3 and y <= i * 4 + 5 then return v end 450 | end 451 | elseif e == "key" and key == 200 then 452 | if sel > 1 then 453 | sel = sel - 1 454 | draw(disList, sel, loc) 455 | elseif loc > 1 then 456 | loc = loc - 1 457 | disList = updateDisplayList(list, loc, len) 458 | draw(disList, sel, loc) 459 | end 460 | elseif e == "key" and key == 208 then 461 | if sel < len then 462 | sel = sel + 1 463 | draw(disList, sel, loc) 464 | elseif loc + len - 1 < #list then 465 | loc = loc + 1 466 | disList = updateDisplayList(list, loc, len) 467 | draw(disList, sel, loc) 468 | end 469 | elseif e == "mouse_scroll" then 470 | os.queueEvent("key", key == -1 and 200 or 208) 471 | elseif e == "key" and key == 28 then 472 | return disList[sel] 473 | end 474 | end 475 | end 476 | 477 | function monitorKeyboardShortcuts() 478 | local ta, tb = nil, nil 479 | local allowChar = false 480 | local shiftPressed = false 481 | while true do 482 | local event, char = os.pullEvent() 483 | if event == "key" and (char == 42 or char == 52) then 484 | shiftPressed = true 485 | tb = os.startTimer(keyboardShortcutTimeout) 486 | elseif event == "key" and (char == 29 or char == 157 or char == 219 or char == 220) then 487 | allowEditorEvent = false 488 | allowChar = true 489 | ta = os.startTimer(keyboardShortcutTimeout) 490 | elseif event == "key" and allowChar then 491 | local name = nil 492 | for k, v in pairs(keys) do 493 | if v == char then 494 | if shiftPressed then os.queueEvent("shortcut", "ctrl shift", k:lower()) 495 | else os.queueEvent("shortcut", "ctrl", k:lower()) end 496 | sleep(0.005) 497 | allowEditorEvent = true 498 | end 499 | end 500 | if shiftPressed then os.queueEvent("shortcut", "ctrl shift", char) 501 | else os.queueEvent("shortcut", "ctrl", char) end 502 | elseif event == "timer" and char == ta then 503 | allowEditorEvent = true 504 | allowChar = false 505 | elseif event == "timer" and char == tb then 506 | shiftPressed = false 507 | end 508 | end 509 | end 510 | 511 | 512 | -- -------- Saving and Loading 513 | 514 | local function download(url, path) 515 | for i = 1, 3 do 516 | local response = http.get(url) 517 | if response then 518 | local data = response.readAll() 519 | response.close() 520 | if path then 521 | local f = io.open(path, "w") 522 | f:write(data) 523 | f:close() 524 | end 525 | return true 526 | end 527 | end 528 | 529 | return false 530 | end 531 | 532 | local function saveFile(path, lines) 533 | local dir = path:sub(1, path:len() - fs.getName(path):len()) 534 | if not fs.exists(dir) then fs.makeDir(dir) end 535 | if not fs.isDir(path) and not fs.isReadOnly(path) then 536 | local a = "" 537 | for _, v in pairs(lines) do a = a .. v .. "\n" end 538 | 539 | local f = io.open(path, "w") 540 | f:write(a) 541 | f:close() 542 | return true 543 | else return false end 544 | end 545 | 546 | local function loadFile(path) 547 | if not fs.exists(path) then 548 | local dir = path:sub(1, path:len() - fs.getName(path):len()) 549 | if not fs.exists(dir) then fs.makeDir(dir) end 550 | local f = io.open(path, "w") 551 | f:write("") 552 | f:close() 553 | end 554 | 555 | local l = {} 556 | if fs.exists(path) and not fs.isDir(path) then 557 | local f = io.open(path, "r") 558 | if f then 559 | local a = f:read("*l") 560 | while a do 561 | table.insert(l, a) 562 | a = f:read("*l") 563 | end 564 | f:close() 565 | end 566 | else return nil end 567 | 568 | if #l < 1 then table.insert(l, "") end 569 | return l 570 | end 571 | 572 | 573 | -- -------- Languages 574 | 575 | languages.lua = {} 576 | languages.brainfuck = {} 577 | languages.none = {} 578 | 579 | -- Lua 580 | 581 | languages.lua.helpTips = { 582 | "A function you tried to call doesn't exist.", 583 | "You made a typo.", 584 | "The index of an array is nil.", 585 | "The wrong variable type was passed.", 586 | "A function/variable doesn't exist.", 587 | "You missed an 'end'.", 588 | "You missed a 'then'.", 589 | "You declared a variable incorrectly.", 590 | "One of your variables is mysteriously nil." 591 | } 592 | 593 | languages.lua.defaultHelpTips = { 594 | 2, 5 595 | } 596 | 597 | languages.lua.errors = { 598 | ["Attempt to call nil."] = {1, 2}, 599 | ["Attempt to index nil."] = {3, 2}, 600 | [".+ expected, got .+"] = {4, 2, 9}, 601 | ["'end' expected"] = {6, 2}, 602 | ["'then' expected"] = {7, 2}, 603 | ["'=' expected"] = {8, 2} 604 | } 605 | 606 | languages.lua.keywords = { 607 | ["and"] = "conditional", 608 | ["break"] = "conditional", 609 | ["do"] = "conditional", 610 | ["else"] = "conditional", 611 | ["elseif"] = "conditional", 612 | ["end"] = "conditional", 613 | ["for"] = "conditional", 614 | ["function"] = "conditional", 615 | ["if"] = "conditional", 616 | ["in"] = "conditional", 617 | ["local"] = "conditional", 618 | ["not"] = "conditional", 619 | ["or"] = "conditional", 620 | ["repeat"] = "conditional", 621 | ["return"] = "conditional", 622 | ["then"] = "conditional", 623 | ["until"] = "conditional", 624 | ["while"] = "conditional", 625 | 626 | ["true"] = "constant", 627 | ["false"] = "constant", 628 | ["nil"] = "constant", 629 | 630 | ["print"] = "function", 631 | ["write"] = "function", 632 | ["sleep"] = "function", 633 | ["pairs"] = "function", 634 | ["ipairs"] = "function", 635 | ["loadstring"] = "function", 636 | ["loadfile"] = "function", 637 | ["dofile"] = "function", 638 | ["rawset"] = "function", 639 | ["rawget"] = "function", 640 | ["setfenv"] = "function", 641 | ["getfenv"] = "function", 642 | } 643 | 644 | languages.lua.parseError = function(e) 645 | local ret = {filename = "unknown", line = -1, display = "Unknown!", err = ""} 646 | if e and e ~= "" then 647 | ret.err = e 648 | if e:find(":") then 649 | ret.filename = e:sub(1, e:find(":") - 1):gsub("^%s*(.-)%s*$", "%1") 650 | -- The "" is needed to circumvent a CC bug 651 | e = (e:sub(e:find(":") + 1) .. ""):gsub("^%s*(.-)%s*$", "%1") 652 | if e:find(":") then 653 | ret.line = e:sub(1, e:find(":") - 1) 654 | e = e:sub(e:find(":") + 2):gsub("^%s*(.-)%s*$", "%1") .. "" 655 | end 656 | end 657 | ret.display = e:sub(1, 1):upper() .. e:sub(2, -1) .. "." 658 | end 659 | 660 | return ret 661 | end 662 | 663 | languages.lua.getCompilerErrors = function(code) 664 | code = "local function ee65da6af1cb6f63fee9a081246f2fd92b36ef2(...)\n\n" .. code .. "\n\nend" 665 | local fn, err = loadstring(code) 666 | if not err then 667 | local _, e = pcall(fn) 668 | if e then err = e end 669 | end 670 | 671 | if err then 672 | local a = err:find("]", 1, true) 673 | if a then err = "string" .. err:sub(a + 1, -1) end 674 | local ret = languages.lua.parseError(err) 675 | if tonumber(ret.line) then ret.line = tonumber(ret.line) end 676 | return ret 677 | else return languages.lua.parseError(nil) end 678 | end 679 | 680 | languages.lua.run = function(path, ar) 681 | local fn, err = loadfile(path) 682 | setfenv(fn, getfenv()) 683 | if not err then 684 | _, err = pcall(function() fn(unpack(ar)) end) 685 | end 686 | return err 687 | end 688 | 689 | 690 | -- Brainfuck 691 | 692 | languages.brainfuck.helpTips = { 693 | "Well idk...", 694 | "Isn't this the whole point of the language?", 695 | "Ya know... Not being able to debug it?", 696 | "You made a typo." 697 | } 698 | 699 | languages.brainfuck.defaultHelpTips = { 700 | 1, 2, 3 701 | } 702 | 703 | languages.brainfuck.errors = { 704 | ["No matching '['"] = {1, 2, 3, 4} 705 | } 706 | 707 | languages.brainfuck.keywords = {} 708 | 709 | languages.brainfuck.parseError = function(e) 710 | local ret = {filename = "unknown", line = -1, display = "Unknown!", err = ""} 711 | if e and e ~= "" then 712 | ret.err = e 713 | ret.line = e:sub(1, e:find(":") - 1) 714 | e = e:sub(e:find(":") + 2):gsub("^%s*(.-)%s*$", "%1") .. "" 715 | ret.display = e:sub(1, 1):upper() .. e:sub(2, -1) .. "." 716 | end 717 | 718 | return ret 719 | end 720 | 721 | languages.brainfuck.mapLoops = function(code) 722 | -- Map loops 723 | local loopLocations = {} 724 | local loc = 1 725 | local line = 1 726 | for let in string.gmatch(code, ".") do 727 | if let == "[" then 728 | loopLocations[loc] = true 729 | elseif let == "]" then 730 | local found = false 731 | for i = loc, 1, -1 do 732 | if loopLocations[i] == true then 733 | loopLocations[i] = loc 734 | found = true 735 | end 736 | end 737 | 738 | if not found then 739 | return line .. ": No matching '['" 740 | end 741 | end 742 | 743 | if let == "\n" then line = line + 1 end 744 | loc = loc + 1 745 | end 746 | return loopLocations 747 | end 748 | 749 | languages.brainfuck.getCompilerErrors = function(code) 750 | local a = languages.brainfuck.mapLoops(code) 751 | if type(a) == "string" then return languages.brainfuck.parseError(a) 752 | else return languages.brainfuck.parseError(nil) end 753 | end 754 | 755 | languages.brainfuck.run = function(path) 756 | -- Read from file 757 | local f = io.open(path, "r") 758 | local content = f:read("*a") 759 | f:close() 760 | 761 | -- Define environment 762 | local dataCells = {} 763 | local dataPointer = 1 764 | local instructionPointer = 1 765 | 766 | -- Map loops 767 | local loopLocations = languages.brainfuck.mapLoops(content) 768 | if type(loopLocations) == "string" then return loopLocations end 769 | 770 | -- Execute code 771 | while true do 772 | local let = content:sub(instructionPointer, instructionPointer) 773 | 774 | if let == ">" then 775 | dataPointer = dataPointer + 1 776 | if not dataCells[tostring(dataPointer)] then dataCells[tostring(dataPointer)] = 0 end 777 | elseif let == "<" then 778 | if not dataCells[tostring(dataPointer)] then dataCells[tostring(dataPointer)] = 0 end 779 | dataPointer = dataPointer - 1 780 | if not dataCells[tostring(dataPointer)] then dataCells[tostring(dataPointer)] = 0 end 781 | elseif let == "+" then 782 | if not dataCells[tostring(dataPointer)] then dataCells[tostring(dataPointer)] = 0 end 783 | dataCells[tostring(dataPointer)] = dataCells[tostring(dataPointer)] + 1 784 | elseif let == "-" then 785 | if not dataCells[tostring(dataPointer)] then dataCells[tostring(dataPointer)] = 0 end 786 | dataCells[tostring(dataPointer)] = dataCells[tostring(dataPointer)] - 1 787 | elseif let == "." then 788 | if not dataCells[tostring(dataPointer)] then dataCells[tostring(dataPointer)] = 0 end 789 | if term.getCursorPos() >= w then print("") end 790 | write(string.char(math.max(1, dataCells[tostring(dataPointer)]))) 791 | elseif let == "," then 792 | if not dataCells[tostring(dataPointer)] then dataCells[tostring(dataPointer)] = 0 end 793 | term.setCursorBlink(true) 794 | local e, but = os.pullEvent("char") 795 | term.setCursorBlink(false) 796 | dataCells[tostring(dataPointer)] = string.byte(but) 797 | if term.getCursorPos() >= w then print("") end 798 | write(but) 799 | elseif let == "/" then 800 | if not dataCells[tostring(dataPointer)] then dataCells[tostring(dataPointer)] = 0 end 801 | if term.getCursorPos() >= w then print("") end 802 | write(dataCells[tostring(dataPointer)]) 803 | elseif let == "[" then 804 | if dataCells[tostring(dataPointer)] == 0 then 805 | for k, v in pairs(loopLocations) do 806 | if k == instructionPointer then instructionPointer = v end 807 | end 808 | end 809 | elseif let == "]" then 810 | for k, v in pairs(loopLocations) do 811 | if v == instructionPointer then instructionPointer = k - 1 end 812 | end 813 | end 814 | 815 | instructionPointer = instructionPointer + 1 816 | if instructionPointer > content:len() then print("") break end 817 | end 818 | end 819 | 820 | -- None 821 | 822 | languages.none.helpTips = {} 823 | languages.none.defaultHelpTips = {} 824 | languages.none.errors = {} 825 | languages.none.keywords = {} 826 | 827 | languages.none.parseError = function(err) 828 | return {filename = "", line = -1, display = "", err = ""} 829 | end 830 | 831 | languages.none.getCompilerErrors = function(code) 832 | return languages.none.parseError(nil) 833 | end 834 | 835 | languages.none.run = function(path) end 836 | 837 | 838 | -- Load language 839 | currentLanguage = languages.lua 840 | 841 | 842 | -- -------- Run GUI 843 | 844 | local function viewErrorHelp(e) 845 | title("LuaIDE - Error Help") 846 | 847 | local tips = nil 848 | for k, v in pairs(currentLanguage.errors) do 849 | if e.display:find(k) then tips = v break end 850 | end 851 | 852 | term.setBackgroundColor(colors[theme.err]) 853 | for i = 6, 8 do 854 | term.setCursorPos(5, i) 855 | term.write(string.rep(" ", 35)) 856 | end 857 | 858 | term.setBackgroundColor(colors[theme.prompt]) 859 | for i = 10, 18 do 860 | term.setCursorPos(5, i) 861 | term.write(string.rep(" ", 46)) 862 | end 863 | 864 | if tips then 865 | term.setBackgroundColor(colors[theme.err]) 866 | term.setCursorPos(6, 7) 867 | term.write("Error Help") 868 | 869 | term.setBackgroundColor(colors[theme.prompt]) 870 | for i, v in ipairs(tips) do 871 | term.setCursorPos(7, i + 10) 872 | term.write("- " .. currentLanguage.helpTips[v]) 873 | end 874 | else 875 | term.setBackgroundColor(colors[theme.err]) 876 | term.setCursorPos(6, 7) 877 | term.write("No Error Tips Available!") 878 | 879 | term.setBackgroundColor(colors[theme.prompt]) 880 | term.setCursorPos(6, 11) 881 | term.write("There are no error tips available, but") 882 | term.setCursorPos(6, 12) 883 | term.write("you could see if it was any of these:") 884 | 885 | for i, v in ipairs(currentLanguage.defaultHelpTips) do 886 | term.setCursorPos(7, i + 12) 887 | term.write("- " .. currentLanguage.helpTips[v]) 888 | end 889 | end 890 | 891 | prompt({{"Back", w - 8, 7}}, "horizontal") 892 | end 893 | 894 | local function run(path, lines, useArgs) 895 | local ar = {} 896 | if useArgs then 897 | title("LuaIDE - Run " .. fs.getName(path)) 898 | local s = centerRead(w - 13, fs.getName(path) .. " ") 899 | for m in string.gmatch(s, "[^ \t]+") do ar[#ar + 1] = m:gsub("^%s*(.-)%s*$", "%1") end 900 | end 901 | 902 | saveFile(path, lines) 903 | term.setCursorBlink(false) 904 | term.setBackgroundColor(colors.black) 905 | term.setTextColor(colors.white) 906 | term.clear() 907 | term.setCursorPos(1, 1) 908 | local err = currentLanguage.run(path, ar) 909 | 910 | term.setBackgroundColor(colors.black) 911 | print("\n") 912 | if err then 913 | if isAdvanced() then term.setTextColor(colors.red) end 914 | centerPrint("The program has crashed!") 915 | end 916 | term.setTextColor(colors.white) 917 | centerPrint("Press any key to return to LuaIDE...") 918 | while true do 919 | local e = os.pullEvent() 920 | if e == "key" then break end 921 | end 922 | 923 | -- To prevent key from showing up in editor 924 | os.queueEvent("") 925 | os.pullEvent() 926 | 927 | if err then 928 | if currentLanguage == languages.lua and err:find("]") then 929 | err = fs.getName(path) .. err:sub(err:find("]", 1, true) + 1, -1) 930 | end 931 | 932 | while true do 933 | title("LuaIDE - Error!") 934 | 935 | term.setBackgroundColor(colors[theme.err]) 936 | for i = 6, 8 do 937 | term.setCursorPos(3, i) 938 | term.write(string.rep(" ", w - 5)) 939 | end 940 | term.setCursorPos(4, 7) 941 | term.write("The program has crashed!") 942 | 943 | term.setBackgroundColor(colors[theme.prompt]) 944 | for i = 10, 14 do 945 | term.setCursorPos(3, i) 946 | term.write(string.rep(" ", w - 5)) 947 | end 948 | 949 | local formattedErr = currentLanguage.parseError(err) 950 | term.setCursorPos(4, 11) 951 | term.write("Line: " .. formattedErr.line) 952 | term.setCursorPos(4, 12) 953 | term.write("Error:") 954 | term.setCursorPos(5, 13) 955 | 956 | local a = formattedErr.display 957 | local b = nil 958 | if a:len() > w - 8 then 959 | for i = a:len(), 1, -1 do 960 | if a:sub(i, i) == " " then 961 | b = a:sub(i + 1, -1) 962 | a = a:sub(1, i) 963 | break 964 | end 965 | end 966 | end 967 | 968 | term.write(a) 969 | if b then 970 | term.setCursorPos(5, 14) 971 | term.write(b) 972 | end 973 | 974 | local opt = prompt({{"Error Help", w/2 - 15, 17}, {"Go To Line", w/2 + 2, 17}}, 975 | "horizontal") 976 | if opt == "Error Help" then 977 | viewErrorHelp(formattedErr) 978 | elseif opt == "Go To Line" then 979 | -- To prevent key from showing up in editor 980 | os.queueEvent("") 981 | os.pullEvent() 982 | 983 | return "go to", tonumber(formattedErr.line) 984 | end 985 | end 986 | end 987 | end 988 | 989 | 990 | -- -------- Functions 991 | 992 | local function goto() 993 | term.setBackgroundColor(colors[theme.backgroundHighlight]) 994 | term.setCursorPos(2, 1) 995 | term.clearLine() 996 | term.write("Line: ") 997 | local line = modRead({visibleLength = w - 2}) 998 | 999 | local num = tonumber(line) 1000 | if num and num > 0 then return num 1001 | else 1002 | term.setCursorPos(2, 1) 1003 | term.clearLine() 1004 | term.write("Not a line number!") 1005 | sleep(1.6) 1006 | return nil 1007 | end 1008 | end 1009 | 1010 | local function setsyntax() 1011 | local opts = { 1012 | "[Lua] Brainfuck None ", 1013 | " Lua [Brainfuck] None ", 1014 | " Lua Brainfuck [None]" 1015 | } 1016 | local sel = 1 1017 | 1018 | term.setCursorBlink(false) 1019 | term.setBackgroundColor(colors[theme.backgroundHighlight]) 1020 | term.setCursorPos(2, 1) 1021 | term.clearLine() 1022 | term.write(opts[sel]) 1023 | while true do 1024 | local e, but, x, y = os.pullEvent("key") 1025 | if but == 203 then 1026 | sel = math.max(1, sel - 1) 1027 | term.setCursorPos(2, 1) 1028 | term.clearLine() 1029 | term.write(opts[sel]) 1030 | elseif but == 205 then 1031 | sel = math.min(#opts, sel + 1) 1032 | term.setCursorPos(2, 1) 1033 | term.clearLine() 1034 | term.write(opts[sel]) 1035 | elseif but == 28 then 1036 | if sel == 1 then currentLanguage = languages.lua 1037 | elseif sel == 2 then currentLanguage = languages.brainfuck 1038 | elseif sel == 3 then currentLanguage = languages.none end 1039 | term.setCursorBlink(true) 1040 | return 1041 | end 1042 | end 1043 | end 1044 | 1045 | 1046 | -- -------- Re-Indenting 1047 | 1048 | local tabWidth = 2 1049 | 1050 | local comments = {} 1051 | local strings = {} 1052 | 1053 | local increment = { 1054 | "if%s+.+%s+then%s*$", 1055 | "for%s+.+%s+do%s*$", 1056 | "while%s+.+%s+do%s*$", 1057 | "repeat%s*$", 1058 | "function%s+[a-zA-Z_0-9]\(.*\)%s*$" 1059 | } 1060 | 1061 | local decrement = { 1062 | "end", 1063 | "until%s+.+" 1064 | } 1065 | 1066 | local special = { 1067 | "else%s*$", 1068 | "elseif%s+.+%s+then%s*$" 1069 | } 1070 | 1071 | local function check(func) 1072 | for _, v in pairs(func) do 1073 | local cLineStart = v["lineStart"] 1074 | local cLineEnd = v["lineEnd"] 1075 | local cCharStart = v["charStart"] 1076 | local cCharEnd = v["charEnd"] 1077 | 1078 | if line >= cLineStart and line <= cLineEnd then 1079 | if line == cLineStart then return cCharStart < charNumb 1080 | elseif line == cLineEnd then return cCharEnd > charNumb 1081 | else return true end 1082 | end 1083 | end 1084 | end 1085 | 1086 | local function isIn(line, loc) 1087 | if check(comments) then return true end 1088 | if check(strings) then return true end 1089 | return false 1090 | end 1091 | 1092 | local function setComment(ls, le, cs, ce) 1093 | comments[#comments + 1] = {} 1094 | comments[#comments].lineStart = ls 1095 | comments[#comments].lineEnd = le 1096 | comments[#comments].charStart = cs 1097 | comments[#comments].charEnd = ce 1098 | end 1099 | 1100 | local function setString(ls, le, cs, ce) 1101 | strings[#strings + 1] = {} 1102 | strings[#strings].lineStart = ls 1103 | strings[#strings].lineEnd = le 1104 | strings[#strings].charStart = cs 1105 | strings[#strings].charEnd = ce 1106 | end 1107 | 1108 | local function map(contents) 1109 | local inCom = false 1110 | local inStr = false 1111 | 1112 | for i = 1, #contents do 1113 | if content[i]:find("%-%-%[%[") and not inStr and not inCom then 1114 | local cStart = content[i]:find("%-%-%[%[") 1115 | setComment(i, nil, cStart, nil) 1116 | inCom = true 1117 | elseif content[i]:find("%-%-%[=%[") and not inStr and not inCom then 1118 | local cStart = content[i]:find("%-%-%[=%[") 1119 | setComment(i, nil, cStart, nil) 1120 | inCom = true 1121 | elseif content[i]:find("%[%[") and not inStr and not inCom then 1122 | local cStart = content[i]:find("%[%[") 1123 | setString(i, nil, cStart, nil) 1124 | inStr = true 1125 | elseif content[i]:find("%[=%[") and not inStr and not inCom then 1126 | local cStart = content[i]:find("%[=%[") 1127 | setString(i, nil, cStart, nil) 1128 | inStr = true 1129 | end 1130 | 1131 | if content[i]:find("%]%]") and inStr and not inCom then 1132 | local cStart, cEnd = content[i]:find("%]%]") 1133 | strings[#strings].lineEnd = i 1134 | strings[#strings].charEnd = cEnd 1135 | inStr = false 1136 | elseif content[i]:find("%]=%]") and inStr and not inCom then 1137 | local cStart, cEnd = content[i]:find("%]=%]") 1138 | strings[#strings].lineEnd = i 1139 | strings[#strings].charEnd = cEnd 1140 | inStr = false 1141 | end 1142 | 1143 | if content[i]:find("%]%]") and not inStr and inCom then 1144 | local cStart, cEnd = content[i]:find("%]%]") 1145 | comments[#comments].lineEnd = i 1146 | comments[#comments].charEnd = cEnd 1147 | inCom = false 1148 | elseif content[i]:find("%]=%]") and not inStr and inCom then 1149 | local cStart, cEnd = content[i]:find("%]=%]") 1150 | comments[#comments].lineEnd = i 1151 | comments[#comments].charEnd = cEnd 1152 | inCom = false 1153 | end 1154 | 1155 | if content[i]:find("%-%-") and not inStr and not inCom then 1156 | local cStart = content[i]:find("%-%-") 1157 | setComment(i, i, cStart, -1) 1158 | elseif content[i]:find("'") and not inStr and not inCom then 1159 | local cStart, cEnd = content[i]:find("'") 1160 | local nextChar = content[i]:sub(cEnd + 1, string.len(content[i])) 1161 | local _, cEnd = nextChar:find("'") 1162 | setString(i, i, cStart, cEnd) 1163 | elseif content[i]:find('"') and not inStr and not inCom then 1164 | local cStart, cEnd = content[i]:find('"') 1165 | local nextChar = content[i]:sub(cEnd + 1, string.len(content[i])) 1166 | local _, cEnd = nextChar:find('"') 1167 | setString(i, i, cStart, cEnd) 1168 | end 1169 | end 1170 | end 1171 | 1172 | local function reindent(contents) 1173 | local err = nil 1174 | if currentLanguage ~= languages.lua then 1175 | err = "Cannot indent languages other than Lua!" 1176 | elseif currentLanguage.getCompilerErrors(table.concat(contents, "\n")).line ~= -1 then 1177 | err = "Cannot indent a program with errors!" 1178 | end 1179 | 1180 | if err then 1181 | term.setCursorBlink(false) 1182 | term.setCursorPos(2, 1) 1183 | term.setBackgroundColor(colors[theme.backgroundHighlight]) 1184 | term.clearLine() 1185 | term.write(err) 1186 | sleep(1.6) 1187 | return contents 1188 | end 1189 | 1190 | local new = {} 1191 | local level = 0 1192 | for k, v in pairs(contents) do 1193 | local incrLevel = false 1194 | local foundIncr = false 1195 | for _, incr in pairs(increment) do 1196 | if v:find(incr) and not isIn(k, v:find(incr)) then 1197 | incrLevel = true 1198 | end 1199 | if v:find(incr:sub(1, -2)) and not isIn(k, v:find(incr)) then 1200 | foundIncr = true 1201 | end 1202 | end 1203 | 1204 | local decrLevel = false 1205 | if not incrLevel then 1206 | for _, decr in pairs(decrement) do 1207 | if v:find(decr) and not isIn(k, v:find(decr)) and not foundIncr then 1208 | level = math.max(0, level - 1) 1209 | decrLevel = true 1210 | end 1211 | end 1212 | end 1213 | 1214 | if not decrLevel then 1215 | for _, sp in pairs(special) do 1216 | if v:find(sp) and not isIn(k, v:find(sp)) then 1217 | incrLevel = true 1218 | level = math.max(0, level - 1) 1219 | end 1220 | end 1221 | end 1222 | 1223 | new[k] = string.rep(" ", level * tabWidth) .. v 1224 | if incrLevel then level = level + 1 end 1225 | end 1226 | 1227 | return new 1228 | end 1229 | 1230 | 1231 | -- -------- Menu 1232 | 1233 | local menu = { 1234 | [1] = {"File", 1235 | -- "About", 1236 | -- "Settings", 1237 | -- "", 1238 | "New File ^+N", 1239 | "Open File ^+O", 1240 | "Save File ^+S", 1241 | "Close ^+W", 1242 | "Print ^+P", 1243 | "Quit ^+Q" 1244 | }, [2] = {"Edit", 1245 | "Cut Line ^+X", 1246 | "Copy Line ^+C", 1247 | "Paste Line ^+V", 1248 | "Delete Line", 1249 | "Clear Line" 1250 | }, [3] = {"Functions", 1251 | "Go To Line ^+G", 1252 | "Re-Indent ^+I", 1253 | "Set Syntax ^+E", 1254 | "Start of Line ^+<", 1255 | "End of Line ^+>" 1256 | }, [4] = {"Run", 1257 | "Run Program ^+R", 1258 | "Run w/ Args ^+Shift+R" 1259 | } 1260 | } 1261 | 1262 | local shortcuts = { 1263 | -- File 1264 | ["ctrl n"] = "New File ^+N", 1265 | ["ctrl o"] = "Open File ^+O", 1266 | ["ctrl s"] = "Save File ^+S", 1267 | ["ctrl w"] = "Close ^+W", 1268 | ["ctrl p"] = "Print ^+P", 1269 | ["ctrl q"] = "Quit ^+Q", 1270 | 1271 | -- Edit 1272 | ["ctrl x"] = "Cut Line ^+X", 1273 | ["ctrl c"] = "Copy Line ^+C", 1274 | ["ctrl v"] = "Paste Line ^+V", 1275 | 1276 | -- Functions 1277 | ["ctrl g"] = "Go To Line ^+G", 1278 | ["ctrl i"] = "Re-Indent ^+I", 1279 | ["ctrl e"] = "Set Syntax ^+E", 1280 | ["ctrl 203"] = "Start of Line ^+<", 1281 | ["ctrl 205"] = "End of Line ^+>", 1282 | 1283 | -- Run 1284 | ["ctrl r"] = "Run Program ^+R", 1285 | ["ctrl shift r"] = "Run w/ Args ^+Shift+R" 1286 | } 1287 | 1288 | local menuFunctions = { 1289 | -- File 1290 | -- ["About"] = function() end, 1291 | -- ["Settings"] = function() end, 1292 | ["New File ^+N"] = function(path, lines) saveFile(path, lines) return "new" end, 1293 | ["Open File ^+O"] = function(path, lines) saveFile(path, lines) return "open" end, 1294 | ["Save File ^+S"] = function(path, lines) saveFile(path, lines) end, 1295 | ["Close ^+W"] = function(path, lines) saveFile(path, lines) return "menu" end, 1296 | ["Print ^+P"] = function(path, lines) saveFile(path, lines) return nil end, 1297 | ["Quit ^+Q"] = function(path, lines) saveFile(path, lines) return "exit" end, 1298 | 1299 | -- Edit 1300 | ["Cut Line ^+X"] = function(path, lines, y) 1301 | clipboard = lines[y] table.remove(lines, y) return nil, lines end, 1302 | ["Copy Line ^+C"] = function(path, lines, y) clipboard = lines[y] end, 1303 | ["Paste Line ^+V"] = function(path, lines, y) 1304 | if clipboard then table.insert(lines, y, clipboard) end return nil, lines end, 1305 | ["Delete Line"] = function(path, lines, y) table.remove(lines, y) return nil, lines end, 1306 | ["Clear Line"] = function(path, lines, y) lines[y] = "" return nil, lines, "cursor" end, 1307 | 1308 | -- Functions 1309 | ["Go To Line ^+G"] = function() return nil, "go to", goto() end, 1310 | ["Re-Indent ^+I"] = function(path, lines) 1311 | local a = reindent(lines) saveFile(path, lines) return nil, a 1312 | end, 1313 | ["Set Syntax ^+E"] = function(path, lines) 1314 | setsyntax() 1315 | if currentLanguage == languages.brainfuck and lines[1] ~= "-- Syntax: Brainfuck" then 1316 | table.insert(lines, 1, "-- Syntax: Brainfuck") 1317 | return nil, lines 1318 | end 1319 | end, 1320 | ["Start of Line ^+<"] = function() os.queueEvent("key", 199) end, 1321 | ["End of Line ^+>"] = function() os.queueEvent("key", 207) end, 1322 | 1323 | -- Run 1324 | ["Run Program ^+R"] = function(path, lines) 1325 | saveFile(path, lines) 1326 | return nil, run(path, lines, false) 1327 | end, 1328 | ["Run w/ Args ^+Shift+R"] = function(path, lines) 1329 | saveFile(path, lines) 1330 | return nil, run(path, lines, true) 1331 | end, 1332 | } 1333 | 1334 | local function drawMenu(open) 1335 | term.setCursorPos(1, 1) 1336 | term.setTextColor(colors[theme.textColor]) 1337 | term.setBackgroundColor(colors[theme.backgroundHighlight]) 1338 | term.clearLine() 1339 | local curX = 0 1340 | for _, v in pairs(menu) do 1341 | term.setCursorPos(3 + curX, 1) 1342 | term.write(v[1]) 1343 | curX = curX + v[1]:len() + 3 1344 | end 1345 | 1346 | if open then 1347 | local it = {} 1348 | local x = 1 1349 | for _, v in pairs(menu) do 1350 | if open == v[1] then 1351 | it = v 1352 | break 1353 | end 1354 | x = x + v[1]:len() + 3 1355 | end 1356 | x = x + 1 1357 | 1358 | local items = {} 1359 | for i = 2, #it do 1360 | table.insert(items, it[i]) 1361 | end 1362 | 1363 | local len = 1 1364 | for _, v in pairs(items) do if v:len() + 2 > len then len = v:len() + 2 end end 1365 | 1366 | for i, v in ipairs(items) do 1367 | term.setCursorPos(x, i + 1) 1368 | term.write(string.rep(" ", len)) 1369 | term.setCursorPos(x + 1, i + 1) 1370 | term.write(v) 1371 | end 1372 | term.setCursorPos(x, #items + 2) 1373 | term.write(string.rep(" ", len)) 1374 | return items, len 1375 | end 1376 | end 1377 | 1378 | local function triggerMenu(cx, cy) 1379 | -- Determine clicked menu 1380 | local curX = 0 1381 | local open = nil 1382 | for _, v in pairs(menu) do 1383 | if cx >= curX + 3 and cx <= curX + v[1]:len() + 2 then 1384 | open = v[1] 1385 | break 1386 | end 1387 | curX = curX + v[1]:len() + 3 1388 | end 1389 | local menux = curX + 2 1390 | if not open then return false end 1391 | 1392 | -- Flash menu item 1393 | term.setCursorBlink(false) 1394 | term.setCursorPos(menux, 1) 1395 | term.setBackgroundColor(colors[theme.background]) 1396 | term.write(string.rep(" ", open:len() + 2)) 1397 | term.setCursorPos(menux + 1, 1) 1398 | term.write(open) 1399 | sleep(0.1) 1400 | local items, len = drawMenu(open) 1401 | 1402 | local ret = true 1403 | 1404 | -- Pull events on menu 1405 | local ox, oy = term.getCursorPos() 1406 | while type(ret) ~= "string" do 1407 | local e, but, x, y = os.pullEvent() 1408 | if e == "mouse_click" then 1409 | -- If clicked outside menu 1410 | if x < menux - 1 or x > menux + len - 1 then break 1411 | elseif y > #items + 2 then break 1412 | elseif y == 1 then break end 1413 | 1414 | for i, v in ipairs(items) do 1415 | if y == i + 1 and x >= menux and x <= menux + len - 2 then 1416 | -- Flash when clicked 1417 | term.setCursorPos(menux, y) 1418 | term.setBackgroundColor(colors[theme.background]) 1419 | term.write(string.rep(" ", len)) 1420 | term.setCursorPos(menux + 1, y) 1421 | term.write(v) 1422 | sleep(0.1) 1423 | drawMenu(open) 1424 | 1425 | -- Return item 1426 | ret = v 1427 | break 1428 | end 1429 | end 1430 | end 1431 | end 1432 | 1433 | term.setCursorPos(ox, oy) 1434 | term.setCursorBlink(true) 1435 | return ret 1436 | end 1437 | 1438 | 1439 | -- -------- Editing 1440 | 1441 | local standardsCompletions = { 1442 | "if%s+.+%s+then%s*$", 1443 | "for%s+.+%s+do%s*$", 1444 | "while%s+.+%s+do%s*$", 1445 | "repeat%s*$", 1446 | "function%s+[a-zA-Z_0-9]?\(.*\)%s*$", 1447 | "=%s*function%s*\(.*\)%s*$", 1448 | "else%s*$", 1449 | "elseif%s+.+%s+then%s*$" 1450 | } 1451 | 1452 | local liveCompletions = { 1453 | ["("] = ")", 1454 | ["{"] = "}", 1455 | ["["] = "]", 1456 | ["\""] = "\"", 1457 | ["'"] = "'", 1458 | } 1459 | 1460 | local x, y = 0, 0 1461 | local edw, edh = 0, h - 1 1462 | local offx, offy = 0, 1 1463 | local scrollx, scrolly = 0, 0 1464 | local lines = {} 1465 | local liveErr = currentLanguage.parseError(nil) 1466 | local displayCode = true 1467 | local lastEventClock = os.clock() 1468 | 1469 | local function attemptToHighlight(line, regex, col) 1470 | local match = string.match(line, regex) 1471 | if match then 1472 | if type(col) == "number" then term.setTextColor(col) 1473 | elseif type(col) == "function" then term.setTextColor(col(match)) end 1474 | term.write(match) 1475 | term.setTextColor(colors[theme.textColor]) 1476 | return line:sub(match:len() + 1, -1) 1477 | end 1478 | return nil 1479 | end 1480 | 1481 | local function writeHighlighted(line) 1482 | if currentLanguage == languages.lua then 1483 | while line:len() > 0 do 1484 | line = attemptToHighlight(line, "^%-%-%[%[.-%]%]", colors[theme.comment]) or 1485 | attemptToHighlight(line, "^%-%-.*", colors[theme.comment]) or 1486 | attemptToHighlight(line, "^\".*[^\\]\"", colors[theme.string]) or 1487 | attemptToHighlight(line, "^\'.*[^\\]\'", colors[theme.string]) or 1488 | attemptToHighlight(line, "^%[%[.-%]%]", colors[theme.string]) or 1489 | attemptToHighlight(line, "^[%w_]+", function(match) 1490 | if currentLanguage.keywords[match] then 1491 | return colors[theme[currentLanguage.keywords[match]]] 1492 | end 1493 | return colors[theme.textColor] 1494 | end) or 1495 | attemptToHighlight(line, "^[^%w_]", colors[theme.textColor]) 1496 | end 1497 | else term.write(line) end 1498 | end 1499 | 1500 | local function draw() 1501 | -- Menu 1502 | term.setTextColor(colors[theme.textColor]) 1503 | term.setBackgroundColor(colors[theme.editorBackground]) 1504 | term.clear() 1505 | drawMenu() 1506 | 1507 | -- Line numbers 1508 | offx, offy = tostring(#lines):len() + 1, 1 1509 | edw, edh = w - offx, h - 1 1510 | 1511 | -- Draw text 1512 | for i = 1, edh do 1513 | local a = lines[scrolly + i] 1514 | if a then 1515 | local ln = string.rep(" ", offx - 1 - tostring(scrolly + i):len()) .. tostring(scrolly + i) 1516 | local l = a:sub(scrollx + 1, edw + scrollx + 1) 1517 | ln = ln .. ":" 1518 | 1519 | if liveErr.line == scrolly + i then ln = string.rep(" ", offx - 2) .. "!:" end 1520 | 1521 | term.setCursorPos(1, i + offy) 1522 | term.setBackgroundColor(colors[theme.editorBackground]) 1523 | if scrolly + i == y then 1524 | if scrolly + i == liveErr.line and os.clock() - lastEventClock > 3 then 1525 | term.setBackgroundColor(colors[theme.editorErrorHighlight]) 1526 | else term.setBackgroundColor(colors[theme.editorLineHightlight]) end 1527 | term.clearLine() 1528 | elseif scrolly + i == liveErr.line then 1529 | term.setBackgroundColor(colors[theme.editorError]) 1530 | term.clearLine() 1531 | end 1532 | 1533 | term.setCursorPos(1 - scrollx + offx, i + offy) 1534 | if scrolly + i == y then 1535 | if scrolly + i == liveErr.line and os.clock() - lastEventClock > 3 then 1536 | term.setBackgroundColor(colors[theme.editorErrorHighlight]) 1537 | else term.setBackgroundColor(colors[theme.editorLineHightlight]) end 1538 | elseif scrolly + i == liveErr.line then term.setBackgroundColor(colors[theme.editorError]) 1539 | else term.setBackgroundColor(colors[theme.editorBackground]) end 1540 | if scrolly + i == liveErr.line then 1541 | if displayCode then term.write(a) 1542 | else term.write(liveErr.display) end 1543 | else writeHighlighted(a) end 1544 | 1545 | term.setCursorPos(1, i + offy) 1546 | if scrolly + i == y then 1547 | if scrolly + i == liveErr.line and os.clock() - lastEventClock > 3 then 1548 | term.setBackgroundColor(colors[theme.editorError]) 1549 | else term.setBackgroundColor(colors[theme.editorLineNumbersHighlight]) end 1550 | elseif scrolly + i == liveErr.line then 1551 | term.setBackgroundColor(colors[theme.editorErrorHighlight]) 1552 | else term.setBackgroundColor(colors[theme.editorLineNumbers]) end 1553 | term.write(ln) 1554 | end 1555 | end 1556 | term.setCursorPos(x - scrollx + offx, y - scrolly + offy) 1557 | end 1558 | 1559 | local function drawLine(...) 1560 | local ls = {...} 1561 | offx = tostring(#lines):len() + 1 1562 | for _, ly in pairs(ls) do 1563 | local a = lines[ly] 1564 | if a then 1565 | local ln = string.rep(" ", offx - 1 - tostring(ly):len()) .. tostring(ly) 1566 | local l = a:sub(scrollx + 1, edw + scrollx + 1) 1567 | ln = ln .. ":" 1568 | 1569 | if liveErr.line == ly then ln = string.rep(" ", offx - 2) .. "!:" end 1570 | 1571 | term.setCursorPos(1, (ly - scrolly) + offy) 1572 | term.setBackgroundColor(colors[theme.editorBackground]) 1573 | if ly == y then 1574 | if ly == liveErr.line and os.clock() - lastEventClock > 3 then 1575 | term.setBackgroundColor(colors[theme.editorErrorHighlight]) 1576 | else term.setBackgroundColor(colors[theme.editorLineHightlight]) end 1577 | elseif ly == liveErr.line then 1578 | term.setBackgroundColor(colors[theme.editorError]) 1579 | end 1580 | term.clearLine() 1581 | 1582 | term.setCursorPos(1 - scrollx + offx, (ly - scrolly) + offy) 1583 | if ly == y then 1584 | if ly == liveErr.line and os.clock() - lastEventClock > 3 then 1585 | term.setBackgroundColor(colors[theme.editorErrorHighlight]) 1586 | else term.setBackgroundColor(colors[theme.editorLineHightlight]) end 1587 | elseif ly == liveErr.line then term.setBackgroundColor(colors[theme.editorError]) 1588 | else term.setBackgroundColor(colors[theme.editorBackground]) end 1589 | if ly == liveErr.line then 1590 | if displayCode then term.write(a) 1591 | else term.write(liveErr.display) end 1592 | else writeHighlighted(a) end 1593 | 1594 | term.setCursorPos(1, (ly - scrolly) + offy) 1595 | if ly == y then 1596 | if ly == liveErr.line and os.clock() - lastEventClock > 3 then 1597 | term.setBackgroundColor(colors[theme.editorError]) 1598 | else term.setBackgroundColor(colors[theme.editorLineNumbersHighlight]) end 1599 | elseif ly == liveErr.line then 1600 | term.setBackgroundColor(colors[theme.editorErrorHighlight]) 1601 | else term.setBackgroundColor(colors[theme.editorLineNumbers]) end 1602 | term.write(ln) 1603 | end 1604 | end 1605 | term.setCursorPos(x - scrollx + offx, y - scrolly + offy) 1606 | end 1607 | 1608 | local function cursorLoc(x, y, force) 1609 | local sx, sy = x - scrollx, y - scrolly 1610 | local redraw = false 1611 | if sx < 1 then 1612 | scrollx = x - 1 1613 | sx = 1 1614 | redraw = true 1615 | elseif sx > edw then 1616 | scrollx = x - edw 1617 | sx = edw 1618 | redraw = true 1619 | end if sy < 1 then 1620 | scrolly = y - 1 1621 | sy = 1 1622 | redraw = true 1623 | elseif sy > edh then 1624 | scrolly = y - edh 1625 | sy = edh 1626 | redraw = true 1627 | end if redraw or force then draw() end 1628 | term.setCursorPos(sx + offx, sy + offy) 1629 | end 1630 | 1631 | local function executeMenuItem(a, path) 1632 | if type(a) == "string" and menuFunctions[a] then 1633 | local opt, nl, gtln = menuFunctions[a](path, lines, y) 1634 | if type(opt) == "string" then term.setCursorBlink(false) return opt end 1635 | if type(nl) == "table" then 1636 | if #lines < 1 then table.insert(lines, "") end 1637 | y = math.min(y, #lines) 1638 | x = math.min(x, lines[y]:len() + 1) 1639 | lines = nl 1640 | elseif type(nl) == "string" then 1641 | if nl == "go to" and gtln then 1642 | x, y = 1, math.min(#lines, gtln) 1643 | cursorLoc(x, y) 1644 | end 1645 | end 1646 | end 1647 | term.setCursorBlink(true) 1648 | draw() 1649 | term.setCursorPos(x - scrollx + offx, y - scrolly + offy) 1650 | end 1651 | 1652 | local function edit(path) 1653 | -- Variables 1654 | x, y = 1, 1 1655 | offx, offy = 0, 1 1656 | scrollx, scrolly = 0, 0 1657 | lines = loadFile(path) 1658 | if not lines then return "menu" end 1659 | 1660 | -- Enable brainfuck 1661 | if lines[1] == "-- Syntax: Brainfuck" then 1662 | currentLanguage = languages.brainfuck 1663 | end 1664 | 1665 | -- Clocks 1666 | local autosaveClock = os.clock() 1667 | local scrollClock = os.clock() -- To prevent redraw flicker 1668 | local liveErrorClock = os.clock() 1669 | local hasScrolled = false 1670 | 1671 | -- Draw 1672 | draw() 1673 | term.setCursorPos(x + offx, y + offy) 1674 | term.setCursorBlink(true) 1675 | 1676 | -- Main loop 1677 | local tid = os.startTimer(3) 1678 | while true do 1679 | local e, key, cx, cy = os.pullEvent() 1680 | if e == "key" and allowEditorEvent then 1681 | if key == 200 and y > 1 then 1682 | -- Up 1683 | x, y = math.min(x, lines[y - 1]:len() + 1), y - 1 1684 | drawLine(y, y + 1) 1685 | cursorLoc(x, y) 1686 | elseif key == 208 and y < #lines then 1687 | -- Down 1688 | x, y = math.min(x, lines[y + 1]:len() + 1), y + 1 1689 | drawLine(y, y - 1) 1690 | cursorLoc(x, y) 1691 | elseif key == 203 and x > 1 then 1692 | -- Left 1693 | x = x - 1 1694 | local force = false 1695 | if y - scrolly + offy < offy + 1 then force = true end 1696 | cursorLoc(x, y, force) 1697 | elseif key == 205 and x < lines[y]:len() + 1 then 1698 | -- Right 1699 | x = x + 1 1700 | local force = false 1701 | if y - scrolly + offy < offy + 1 then force = true end 1702 | cursorLoc(x, y, force) 1703 | elseif (key == 28 or key == 156) and (displayCode and true or y + scrolly - 1 == 1704 | liveErr.line) then 1705 | -- Enter 1706 | local f = nil 1707 | for _, v in pairs(standardsCompletions) do 1708 | if lines[y]:find(v) and x == #lines[y] + 1 then f = v end 1709 | end 1710 | 1711 | local _, spaces = lines[y]:find("^[ ]+") 1712 | if not spaces then spaces = 0 end 1713 | if f then 1714 | table.insert(lines, y + 1, string.rep(" ", spaces + 2)) 1715 | if not f:find("else", 1, true) and not f:find("elseif", 1, true) then 1716 | table.insert(lines, y + 2, string.rep(" ", spaces) .. 1717 | (f:find("repeat", 1, true) and "until " or f:find("{", 1, true) and "}" or 1718 | "end")) 1719 | end 1720 | x, y = spaces + 3, y + 1 1721 | cursorLoc(x, y, true) 1722 | else 1723 | local oldLine = lines[y] 1724 | 1725 | lines[y] = lines[y]:sub(1, x - 1) 1726 | table.insert(lines, y + 1, string.rep(" ", spaces) .. oldLine:sub(x, -1)) 1727 | 1728 | x, y = spaces + 1, y + 1 1729 | cursorLoc(x, y, true) 1730 | end 1731 | elseif key == 14 and (displayCode and true or y + scrolly - 1 == liveErr.line) then 1732 | -- Backspace 1733 | if x > 1 then 1734 | local f = false 1735 | for k, v in pairs(liveCompletions) do 1736 | if lines[y]:sub(x - 1, x - 1) == k then f = true end 1737 | end 1738 | 1739 | lines[y] = lines[y]:sub(1, x - 2) .. lines[y]:sub(x + (f and 1 or 0), -1) 1740 | drawLine(y) 1741 | x = x - 1 1742 | cursorLoc(x, y) 1743 | elseif y > 1 then 1744 | local prevLen = lines[y - 1]:len() + 1 1745 | lines[y - 1] = lines[y - 1] .. lines[y] 1746 | table.remove(lines, y) 1747 | x, y = prevLen, y - 1 1748 | cursorLoc(x, y, true) 1749 | end 1750 | elseif key == 199 then 1751 | -- Home 1752 | x = 1 1753 | local force = false 1754 | if y - scrolly + offy < offy + 1 then force = true end 1755 | cursorLoc(x, y, force) 1756 | elseif key == 207 then 1757 | -- End 1758 | x = lines[y]:len() + 1 1759 | local force = false 1760 | if y - scrolly + offy < offy + 1 then force = true end 1761 | cursorLoc(x, y, force) 1762 | elseif key == 211 and (displayCode and true or y + scrolly - 1 == liveErr.line) then 1763 | -- Forward Delete 1764 | if x < lines[y]:len() + 1 then 1765 | lines[y] = lines[y]:sub(1, x - 1) .. lines[y]:sub(x + 1) 1766 | local force = false 1767 | if y - scrolly + offy < offy + 1 then force = true end 1768 | drawLine(y) 1769 | cursorLoc(x, y, force) 1770 | elseif y < #lines then 1771 | lines[y] = lines[y] .. lines[y + 1] 1772 | table.remove(lines, y + 1) 1773 | draw() 1774 | cursorLoc(x, y) 1775 | end 1776 | elseif key == 15 and (displayCode and true or y + scrolly - 1 == liveErr.line) then 1777 | -- Tab 1778 | lines[y] = string.rep(" ", tabWidth) .. lines[y] 1779 | x = x + 2 1780 | local force = false 1781 | if y - scrolly + offy < offy + 1 then force = true end 1782 | drawLine(y) 1783 | cursorLoc(x, y, force) 1784 | elseif key == 201 then 1785 | -- Page up 1786 | y = math.min(math.max(y - edh, 1), #lines) 1787 | x = math.min(lines[y]:len() + 1, x) 1788 | cursorLoc(x, y, true) 1789 | elseif key == 209 then 1790 | -- Page down 1791 | y = math.min(math.max(y + edh, 1), #lines) 1792 | x = math.min(lines[y]:len() + 1, x) 1793 | cursorLoc(x, y, true) 1794 | end 1795 | elseif e == "char" and allowEditorEvent and (displayCode and true or 1796 | y + scrolly - 1 == liveErr.line) then 1797 | local shouldIgnore = false 1798 | for k, v in pairs(liveCompletions) do 1799 | if key == v and lines[y]:find(k, 1, true) and lines[y]:sub(x, x) == v then 1800 | shouldIgnore = true 1801 | end 1802 | end 1803 | 1804 | local addOne = false 1805 | if not shouldIgnore then 1806 | for k, v in pairs(liveCompletions) do 1807 | if key == k and lines[y]:sub(x, x) ~= k then key = key .. v addOne = true end 1808 | end 1809 | lines[y] = lines[y]:sub(1, x - 1) .. key .. lines[y]:sub(x, -1) 1810 | end 1811 | 1812 | x = x + (addOne and 1 or key:len()) 1813 | local force = false 1814 | if y - scrolly + offy < offy + 1 then force = true end 1815 | drawLine(y) 1816 | cursorLoc(x, y, force) 1817 | elseif e == "mouse_click" and key == 1 then 1818 | if cy > 1 then 1819 | if cx <= offx and cy - offy == liveErr.line - scrolly then 1820 | displayCode = not displayCode 1821 | drawLine(liveErr.line) 1822 | else 1823 | local oldy = y 1824 | y = math.min(math.max(scrolly + cy - offy, 1), #lines) 1825 | x = math.min(math.max(scrollx + cx - offx, 1), lines[y]:len() + 1) 1826 | if oldy ~= y then drawLine(oldy, y) end 1827 | cursorLoc(x, y) 1828 | end 1829 | else 1830 | local a = triggerMenu(cx, cy) 1831 | if a then 1832 | local opt = executeMenuItem(a, path) 1833 | if opt then return opt end 1834 | end 1835 | end 1836 | elseif e == "shortcut" then 1837 | local a = shortcuts[key .. " " .. cx] 1838 | if a then 1839 | local parent = nil 1840 | local curx = 0 1841 | for i, mv in ipairs(menu) do 1842 | for _, iv in pairs(mv) do 1843 | if iv == a then 1844 | parent = menu[i][1] 1845 | break 1846 | end 1847 | end 1848 | if parent then break end 1849 | curx = curx + mv[1]:len() + 3 1850 | end 1851 | local menux = curx + 2 1852 | 1853 | -- Flash menu item 1854 | term.setCursorBlink(false) 1855 | term.setCursorPos(menux, 1) 1856 | term.setBackgroundColor(colors[theme.background]) 1857 | term.write(string.rep(" ", parent:len() + 2)) 1858 | term.setCursorPos(menux + 1, 1) 1859 | term.write(parent) 1860 | sleep(0.1) 1861 | drawMenu() 1862 | 1863 | -- Execute item 1864 | local opt = executeMenuItem(a, path) 1865 | if opt then return opt end 1866 | end 1867 | elseif e == "mouse_scroll" then 1868 | if key == -1 and scrolly > 0 then 1869 | scrolly = scrolly - 1 1870 | if os.clock() - scrollClock > 0.0005 then 1871 | draw() 1872 | term.setCursorPos(x - scrollx + offx, y - scrolly + offy) 1873 | end 1874 | scrollClock = os.clock() 1875 | hasScrolled = true 1876 | elseif key == 1 and scrolly < #lines - edh then 1877 | scrolly = scrolly + 1 1878 | if os.clock() - scrollClock > 0.0005 then 1879 | draw() 1880 | term.setCursorPos(x - scrollx + offx, y - scrolly + offy) 1881 | end 1882 | scrollClock = os.clock() 1883 | hasScrolled = true 1884 | end 1885 | elseif e == "timer" and key == tid then 1886 | drawLine(y) 1887 | tid = os.startTimer(3) 1888 | end 1889 | 1890 | -- Draw 1891 | if hasScrolled and os.clock() - scrollClock > 0.1 then 1892 | draw() 1893 | term.setCursorPos(x - scrollx + offx, y - scrolly + offy) 1894 | hasScrolled = false 1895 | end 1896 | 1897 | -- Autosave 1898 | if os.clock() - autosaveClock > autosaveInterval then 1899 | saveFile(path, lines) 1900 | autosaveClock = os.clock() 1901 | end 1902 | 1903 | -- Errors 1904 | if os.clock() - liveErrorClock > 1 then 1905 | local prevLiveErr = liveErr 1906 | liveErr = currentLanguage.parseError(nil) 1907 | local code = "" 1908 | for _, v in pairs(lines) do code = code .. v .. "\n" end 1909 | 1910 | liveErr = currentLanguage.getCompilerErrors(code) 1911 | liveErr.line = math.min(liveErr.line - 2, #lines) 1912 | if liveErr ~= prevLiveErr then draw() end 1913 | liveErrorClock = os.clock() 1914 | end 1915 | end 1916 | 1917 | return "menu" 1918 | end 1919 | 1920 | 1921 | -- -------- Open File 1922 | 1923 | local function newFile() 1924 | local wid = w - 13 1925 | 1926 | -- Get name 1927 | title("Lua IDE - New File") 1928 | local name = centerRead(wid, "/") 1929 | if not name or name == "" then return "menu" end 1930 | name = "/" .. name 1931 | 1932 | -- Clear 1933 | title("Lua IDE - New File") 1934 | term.setTextColor(colors[theme.textColor]) 1935 | term.setBackgroundColor(colors[theme.promptHighlight]) 1936 | for i = 8, 10 do 1937 | term.setCursorPos(w/2 - wid/2, i) 1938 | term.write(string.rep(" ", wid)) 1939 | end 1940 | term.setCursorPos(1, 9) 1941 | if fs.isDir(name) then 1942 | centerPrint("Cannot Edit a Directory!") 1943 | sleep(1.6) 1944 | return "menu" 1945 | elseif fs.exists(name) then 1946 | centerPrint("File Already Exists!") 1947 | local opt = prompt({{"Open", w/2 - 9, 14}, {"Cancel", w/2 + 2, 14}}, "horizontal") 1948 | if opt == "Open" then return "edit", name 1949 | elseif opt == "Cancel" then return "menu" end 1950 | else return "edit", name end 1951 | end 1952 | 1953 | local function openFile() 1954 | local wid = w - 13 1955 | 1956 | -- Get name 1957 | title("Lua IDE - Open File") 1958 | local name = centerRead(wid, "/") 1959 | if not name or name == "" then return "menu" end 1960 | name = "/" .. name 1961 | 1962 | -- Clear 1963 | title("Lua IDE - New File") 1964 | term.setTextColor(colors[theme.textColor]) 1965 | term.setBackgroundColor(colors[theme.promptHighlight]) 1966 | for i = 8, 10 do 1967 | term.setCursorPos(w/2 - wid/2, i) 1968 | term.write(string.rep(" ", wid)) 1969 | end 1970 | term.setCursorPos(1, 9) 1971 | if fs.isDir(name) then 1972 | centerPrint("Cannot Open a Directory!") 1973 | sleep(1.6) 1974 | return "menu" 1975 | elseif not fs.exists(name) then 1976 | centerPrint("File Doesn't Exist!") 1977 | local opt = prompt({{"Create", w/2 - 11, 14}, {"Cancel", w/2 + 2, 14}}, "horizontal") 1978 | if opt == "Create" then return "edit", name 1979 | elseif opt == "Cancel" then return "menu" end 1980 | else return "edit", name end 1981 | end 1982 | 1983 | 1984 | -- -------- Settings 1985 | 1986 | local function update() 1987 | local function draw(status) 1988 | title("LuaIDE - Update") 1989 | term.setBackgroundColor(colors[theme.prompt]) 1990 | term.setTextColor(colors[theme.textColor]) 1991 | for i = 8, 10 do 1992 | term.setCursorPos(w/2 - (status:len() + 4), i) 1993 | write(string.rep(" ", status:len() + 4)) 1994 | end 1995 | term.setCursorPos(w/2 - (status:len() + 4), 9) 1996 | term.write(" - " .. status .. " ") 1997 | 1998 | term.setBackgroundColor(colors[theme.errHighlight]) 1999 | for i = 8, 10 do 2000 | term.setCursorPos(w/2 + 2, i) 2001 | term.write(string.rep(" ", 10)) 2002 | end 2003 | term.setCursorPos(w/2 + 2, 9) 2004 | term.write(" > Cancel ") 2005 | end 2006 | 2007 | if not http then 2008 | draw("HTTP API Disabled!") 2009 | sleep(1.6) 2010 | return "settings" 2011 | end 2012 | 2013 | draw("Updating...") 2014 | local tID = os.startTimer(10) 2015 | http.request(updateURL) 2016 | while true do 2017 | local e, but, x, y = os.pullEvent() 2018 | if (e == "key" and but == 28) or 2019 | (e == "mouse_click" and x >= w/2 + 2 and x <= w/2 + 12 and y == 9) then 2020 | draw("Cancelled") 2021 | sleep(1.6) 2022 | break 2023 | elseif e == "http_success" and but == updateURL then 2024 | local new = x.readAll() 2025 | local curf = io.open(ideLocation, "r") 2026 | local cur = curf:read("*a") 2027 | curf:close() 2028 | 2029 | if cur ~= new then 2030 | draw("Update Found") 2031 | sleep(1.6) 2032 | local f = io.open(ideLocation, "w") 2033 | f:write(new) 2034 | f:close() 2035 | 2036 | draw("Click to Exit") 2037 | while true do 2038 | local e = os.pullEvent() 2039 | if e == "mouse_click" or (not isAdvanced() and e == "key") then break end 2040 | end 2041 | return "exit" 2042 | else 2043 | draw("No Updates Found!") 2044 | sleep(1.6) 2045 | break 2046 | end 2047 | elseif e == "http_failure" or (e == "timer" and but == tID) then 2048 | draw("Update Failed!") 2049 | sleep(1.6) 2050 | break 2051 | end 2052 | end 2053 | 2054 | return "settings" 2055 | end 2056 | 2057 | local function changeTheme() 2058 | title("LuaIDE - Theme") 2059 | 2060 | if isAdvanced() then 2061 | local disThemes = {"Back"} 2062 | for _, v in pairs(availableThemes) do table.insert(disThemes, v[1]) end 2063 | local t = scrollingPrompt(disThemes) 2064 | local url = nil 2065 | for _, v in pairs(availableThemes) do if v[1] == t then url = v[2] end end 2066 | 2067 | if not url then return "settings" end 2068 | if t == "Dawn (Default)" then 2069 | term.setBackgroundColor(colors[theme.backgroundHighlight]) 2070 | term.setCursorPos(3, 3) 2071 | term.clearLine() 2072 | term.write("LuaIDE - Loaded Theme!") 2073 | sleep(1.6) 2074 | 2075 | fs.delete(themeLocation) 2076 | theme = defaultTheme 2077 | return "menu" 2078 | end 2079 | 2080 | term.setBackgroundColor(colors[theme.backgroundHighlight]) 2081 | term.setCursorPos(3, 3) 2082 | term.clearLine() 2083 | term.write("LuaIDE - Downloading...") 2084 | 2085 | fs.delete("/.LuaIDE_temp_theme_file") 2086 | download(url, "/.LuaIDE_temp_theme_file") 2087 | local a = loadTheme("/.LuaIDE_temp_theme_file") 2088 | 2089 | term.setCursorPos(3, 3) 2090 | term.clearLine() 2091 | if a then 2092 | term.write("LuaIDE - Loaded Theme!") 2093 | fs.delete(themeLocation) 2094 | fs.move("/.LuaIDE_temp_theme_file", themeLocation) 2095 | theme = a 2096 | sleep(1.6) 2097 | return "menu" 2098 | end 2099 | 2100 | term.write("LuaIDE - Could Not Load Theme!") 2101 | fs.delete("/.LuaIDE_temp_theme_file") 2102 | sleep(1.6) 2103 | return "settings" 2104 | else 2105 | term.setCursorPos(1, 8) 2106 | centerPrint("Themes are not available on") 2107 | centerPrint("normal computers!") 2108 | end 2109 | end 2110 | 2111 | local function settings() 2112 | title("LuaIDE - Settings") 2113 | 2114 | local opt = prompt({{"Change Theme", w/2 - 17, 8}, {"Return to Menu", w/2 - 22, 13}, 2115 | {"Check for Updates", w/2 + 2, 8}, {"Exit IDE", w/2 + 2, 13, bg = colors[theme.err], 2116 | highlight = colors[theme.errHighlight]}}, "vertical", true) 2117 | if opt == "Change Theme" then return changeTheme() 2118 | elseif opt == "Check for Updates" then return update() 2119 | elseif opt == "Return to Menu" then return "menu" 2120 | elseif opt == "Exit IDE" then return "exit" end 2121 | end 2122 | 2123 | 2124 | -- -------- Menu 2125 | 2126 | local function menu() 2127 | title("Welcome to LuaIDE " .. version) 2128 | 2129 | local opt = prompt({{"New File", w/2 - 13, 8}, {"Open File", w/2 - 14, 13}, 2130 | {"Settings", w/2 + 2, 8}, {"Exit IDE", w/2 + 2, 13, bg = colors[theme.err], 2131 | highlight = colors[theme.errHighlight]}}, "vertical", true) 2132 | if opt == "New File" then return "new" 2133 | elseif opt == "Open File" then return "open" 2134 | elseif opt == "Settings" then return "settings" 2135 | elseif opt == "Exit IDE" then return "exit" end 2136 | end 2137 | 2138 | 2139 | -- -------- Main 2140 | 2141 | local function main(arguments) 2142 | local opt, data = "menu", nil 2143 | 2144 | -- Check arguments 2145 | if type(arguments) == "table" and #arguments > 0 then 2146 | local f = "/" .. shell.resolve(arguments[1]) 2147 | if fs.isDir(f) then print("Cannot edit a directory.") end 2148 | opt, data = "edit", f 2149 | end 2150 | 2151 | -- Main run loop 2152 | while true do 2153 | -- Menu 2154 | if opt == "menu" then opt = menu() end 2155 | 2156 | -- Other 2157 | if opt == "new" then opt, data = newFile() 2158 | elseif opt == "open" then opt, data = openFile() 2159 | elseif opt == "settings" then opt = settings() 2160 | end if opt == "exit" then break end 2161 | 2162 | -- Edit 2163 | if opt == "edit" and data then opt = edit(data) end 2164 | end 2165 | end 2166 | 2167 | -- Load Theme 2168 | if fs.exists(themeLocation) then theme = loadTheme(themeLocation) end 2169 | if not theme and isAdvanced() then theme = defaultTheme 2170 | elseif not theme then theme = normalTheme end 2171 | 2172 | -- Run 2173 | local _, err = pcall(function() 2174 | parallel.waitForAny(function() main(arguments) end, monitorKeyboardShortcuts) 2175 | end) 2176 | 2177 | -- Catch errors 2178 | if err and not err:find("Terminated") then 2179 | term.setCursorBlink(false) 2180 | title("LuaIDE - Crash! D:") 2181 | 2182 | term.setBackgroundColor(colors[theme.err]) 2183 | for i = 6, 8 do 2184 | term.setCursorPos(5, i) 2185 | term.write(string.rep(" ", 36)) 2186 | end 2187 | term.setCursorPos(6, 7) 2188 | term.write("LuaIDE Has Crashed! D:") 2189 | 2190 | term.setBackgroundColor(colors[theme.background]) 2191 | term.setCursorPos(2, 10) 2192 | print(err) 2193 | 2194 | term.setBackgroundColor(colors[theme.prompt]) 2195 | local _, cy = term.getCursorPos() 2196 | for i = cy + 1, cy + 4 do 2197 | term.setCursorPos(5, i) 2198 | term.write(string.rep(" ", 36)) 2199 | end 2200 | term.setCursorPos(6, cy + 2) 2201 | term.write("Please report this error to") 2202 | term.setCursorPos(6, cy + 3) 2203 | term.write("GravityScore! ") 2204 | 2205 | term.setBackgroundColor(colors[theme.background]) 2206 | if isAdvanced() then centerPrint("Click to Exit...", h - 1) 2207 | else centerPrint("Press Any Key to Exit...", h - 1) end 2208 | while true do 2209 | local e = os.pullEvent() 2210 | if e == "mouse_click" or (not isAdvanced() and e == "key") then break end 2211 | end 2212 | 2213 | -- Prevent key from being shown 2214 | os.queueEvent("") 2215 | os.pullEvent() 2216 | end 2217 | 2218 | -- Exit 2219 | term.setBackgroundColor(colors.black) 2220 | term.setTextColor(colors.white) 2221 | term.clear() 2222 | term.setCursorPos(1, 1) 2223 | centerPrint("Thank You for Using Lua IDE " .. version) 2224 | centerPrint("Made by GravityScore") 2225 | -------------------------------------------------------------------------------- /src/application.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Globals 4 | -- 5 | 6 | Global = {} 7 | 8 | Global.version = "2.0" 9 | Global.debug = true 10 | 11 | 12 | 13 | -- 14 | -- Libraries 15 | -- 16 | 17 | local function loadLibrary(path) 18 | local fn, err = loadfile(path) 19 | if err then 20 | error(err .. "\nFor file: " .. path) 21 | end 22 | 23 | local currentEnvironment = getfenv(1) 24 | setfenv(fn, currentEnvironment) 25 | fn() 26 | end 27 | 28 | loadLibrary(rootDirectory .. "/controller.lua") 29 | loadLibrary(rootDirectory .. "/theme.lua") 30 | 31 | loadLibrary(rootDirectory .. "/editor/content.lua") 32 | loadLibrary(rootDirectory .. "/editor/editor.lua") 33 | loadLibrary(rootDirectory .. "/editor/highlighter.lua") 34 | loadLibrary(rootDirectory .. "/editor/file.lua") 35 | 36 | loadLibrary(rootDirectory .. "/ui/menu.lua") 37 | loadLibrary(rootDirectory .. "/ui/tab.lua") 38 | loadLibrary(rootDirectory .. "/ui/responder.lua") 39 | loadLibrary(rootDirectory .. "/ui/dialogue.lua") 40 | loadLibrary(rootDirectory .. "/ui/panel.lua") 41 | loadLibrary(rootDirectory .. "/ui/textfield.lua") 42 | 43 | 44 | 45 | -- 46 | -- Main 47 | -- 48 | 49 | --- Print `text` centered on the current cursor line. 50 | --- Moves the cursor to the line below it after writing. 51 | local function center(text) 52 | local w = term.getSize() 53 | local _, y = term.getCursorPos() 54 | if text:len() <= w then 55 | term.setCursorPos(math.floor(w / 2 - text:len() / 2) + 1, y) 56 | term.write(text) 57 | term.setCursorPos(1, y + 1) 58 | else 59 | term.setCursorPos(1, y) 60 | print(text) 61 | end 62 | end 63 | 64 | local displayEnding = false 65 | 66 | local function main(args) 67 | local controller = Controller.new(args) 68 | if controller then 69 | displayEnding = true 70 | controller:run() 71 | end 72 | end 73 | 74 | local args = {...} 75 | local originalTerminal = term.current() 76 | local _, err = pcall(main, args) 77 | term.redirect(originalTerminal) 78 | 79 | if err then 80 | printError(err) 81 | os.pullEvent("key") 82 | os.queueEvent("") 83 | os.pullEvent() 84 | end 85 | 86 | if displayEnding then 87 | term.setBackgroundColor(colors.black) 88 | term.setTextColor(colors.white) 89 | term.clear() 90 | term.setCursorPos(1, 1) 91 | 92 | center("Thanks for using LuaIDE " .. Global.version) 93 | center("By GravityScore") 94 | print() 95 | end 96 | -------------------------------------------------------------------------------- /src/controller.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Controller 4 | -- 5 | 6 | Controller = {} 7 | Controller.__index = Controller 8 | 9 | 10 | --- Create a new controller object. 11 | --- Returns nil if the program should exit due to invalid arguments. 12 | function Controller.new(...) 13 | local self = setmetatable({}, Controller) 14 | if not self:setup(...) then 15 | return nil 16 | else 17 | return self 18 | end 19 | end 20 | 21 | 22 | function Controller:setup(args) 23 | local path = nil 24 | if #args >= 1 then 25 | local file = shell.dir() .. "/" .. args[1] 26 | file = file:gsub("/+", "/") 27 | 28 | if not fs.isDir(file) then 29 | path = file 30 | else 31 | printError("Cannot edit a directory") 32 | return false 33 | end 34 | end 35 | 36 | term.setBackgroundColor(colors.black) 37 | term.clear() 38 | 39 | Theme.load() 40 | 41 | self.menuBar = MenuBar.new() 42 | self.tabBar = ContentTabLink.new() 43 | self.responder = Responder.new(self) 44 | 45 | if path then 46 | self.tabBar:current():edit(path) 47 | end 48 | 49 | return true 50 | end 51 | 52 | 53 | --- Draw the whole IDE. 54 | function Controller:draw() 55 | self.menuBar:draw() 56 | self.tabBar:draw() 57 | end 58 | 59 | 60 | --- Run the main loop. 61 | function Controller:run() 62 | self:draw() 63 | 64 | while true do 65 | local event = {os.pullEventRaw()} 66 | local cancel = false 67 | 68 | if event[1] == "terminate" or event[1] == "exit" then 69 | break 70 | end 71 | 72 | -- Trigger a redraw so we can close the menu before displaying any menu items, etc. 73 | if event[1] == "menu item close" or event[1] == "menu item trigger" then 74 | self:draw() 75 | end 76 | 77 | if event[1] == "menu item trigger" and not cancel then 78 | cancel = self.responder:trigger(event[2]) 79 | 80 | -- If some event was triggered, then redraw fully 81 | if cancel then 82 | self:draw() 83 | end 84 | end 85 | 86 | if not cancel then 87 | cancel = self.menuBar:event(event) 88 | end 89 | 90 | if not cancel then 91 | cancel = self.tabBar:event(event) 92 | end 93 | 94 | self.tabBar:current():restoreCursor() 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /src/editor/content.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Content 4 | -- 5 | 6 | --- A content window for each tab. 7 | --- Responsible for rendering the editor. 8 | Content = {} 9 | Content.__index = Content 10 | 11 | --- The starting y position of the tab. 12 | Content.startY = 3 13 | 14 | --- The width of a tab in spaces 15 | Content.tabWidth = 2 16 | 17 | 18 | --- Create a new content window. 19 | function Content.new(...) 20 | local self = setmetatable({}, Content) 21 | self:setup(...) 22 | return self 23 | end 24 | 25 | 26 | function Content:setup() 27 | local w, h = term.native().getSize() 28 | self.height = h - Content.startY + 1 29 | self.width = w 30 | self.win = window.create(term.native(), 1, Content.startY, self.width, self.height, false) 31 | self.editor = Editor.new({""}, self.width, self.height) 32 | self.path = nil 33 | self.highlighter = SyntaxHighlighter.new() 34 | self:updateSyntaxHighlighting("") 35 | end 36 | 37 | 38 | --- Open a set of lines 39 | function Content:open(path, lines) 40 | self.path = path 41 | self.editor = Editor.new(lines, self.width, self.height) 42 | self:updateSyntaxHighlighting("") 43 | end 44 | 45 | 46 | --- Set the file currently being edited in this tab. 47 | --- Discards the current file and all changes and replaces it. 48 | --- Returns nil on success and a string error message on failure. 49 | function Content:edit(path) 50 | if fs.isDir(path) then 51 | Panel.error("Couldn't open file.", "", "Cannot edit a", "directory.") 52 | elseif fs.exists(path) then 53 | local lines, err = File.load(path) 54 | 55 | if lines then 56 | self:open(path, lines) 57 | else 58 | Popup.errorPopup("Failed to open file", err) 59 | end 60 | else 61 | self:open(path, {""}) 62 | end 63 | end 64 | 65 | 66 | --- Save the contents of the editor. 67 | function Content:save(path) 68 | if not self.path then 69 | self.path = path 70 | end 71 | if not path then 72 | path = self.path 73 | end 74 | 75 | if not path then 76 | Popup.errorPopup("No path specified to save to!") 77 | end 78 | 79 | local err = File.save(self.editor.lines, path) 80 | if err then 81 | Popup.errorPopup("Failed to save file", err) 82 | end 83 | end 84 | 85 | 86 | --- Returns the name of the file being edited. 87 | function Content:name() 88 | if not self.path then 89 | return "untitled" 90 | else 91 | return fs.getName(self.path) 92 | end 93 | end 94 | 95 | 96 | --- Returns true if the content is unedited 97 | function Content:isUnedited() 98 | return self.path == nil and #self.editor.lines == 1 and self.editor.lines[1]:len() == 0 99 | end 100 | 101 | 102 | --- Shows the content window's window, redrawing it over the existing screen space 103 | --- and restoring the cursor to its original position in the window. 104 | function Content:show() 105 | term.redirect(self.win) 106 | self.win.setVisible(true) 107 | self:draw() 108 | self:restoreCursor() 109 | end 110 | 111 | 112 | --- Hides the window. 113 | function Content:hide() 114 | self.win.setVisible(false) 115 | end 116 | 117 | 118 | --- Sets the cursor position to that defined by the editor. 119 | function Content:restoreCursor() 120 | local x, y = self.editor:cursorPosition() 121 | 122 | term.redirect(self.win) 123 | term.setCursorPos(x, y) 124 | term.setTextColor(Theme["editor text"]) 125 | term.setCursorBlink(true) 126 | end 127 | 128 | 129 | --- Renders a whole line - gutter and text. 130 | --- Does not redirect to the terminal. 131 | function Content:drawLine(y) 132 | term.setBackgroundColor(Theme["editor background"]) 133 | term.setCursorPos(1, y) 134 | term.clearLine() 135 | self:drawText(y) 136 | self:drawGutter(y) 137 | end 138 | 139 | 140 | --- Renders the gutter on a single line. 141 | function Content:drawGutter(y) 142 | if y == self.editor.cursor.y then 143 | term.setBackgroundColor(Theme["gutter background focused"]) 144 | term.setTextColor(Theme["gutter text focused"]) 145 | else 146 | term.setBackgroundColor(Theme["gutter background"]) 147 | term.setTextColor(Theme["gutter text"]) 148 | end 149 | 150 | local size = self.editor:gutterSize() 151 | local lineNumber = tostring(y + self.editor.scroll.y) 152 | local padding = string.rep(" ", size - lineNumber:len() - 1) 153 | lineNumber = padding .. lineNumber .. Theme["gutter separator"] 154 | term.setCursorPos(1, y) 155 | term.write(lineNumber) 156 | end 157 | 158 | 159 | --- Renders the text for a single line. 160 | function Content:drawText(y) 161 | local absoluteY = y + self.editor.scroll.y 162 | local data = self.highlighter:data(absoluteY, self.editor.scroll.x, self.width) 163 | 164 | term.setBackgroundColor(Theme["editor background"]) 165 | term.setTextColor(Theme["editor text"]) 166 | term.setCursorPos(self.editor:gutterSize() + 1, y) 167 | 168 | for _, item in pairs(data) do 169 | if item.kind == "text" then 170 | -- Render some text 171 | term.write(item.data) 172 | elseif item.kind == "color" then 173 | -- Set the current text color 174 | local index = item.data 175 | if index == "text" then 176 | index = "editor text" 177 | end 178 | 179 | term.setTextColor(Theme[index]) 180 | end 181 | end 182 | end 183 | 184 | 185 | --- Fully redraws the editor. 186 | function Content:draw() 187 | term.redirect(self.win) 188 | 189 | -- Clear 190 | term.setBackgroundColor(Theme["editor background"]) 191 | term.clear() 192 | 193 | -- Iterate over each line 194 | local lineCount = math.min(#self.editor.lines, self.height) 195 | for y = 1, lineCount do 196 | self:drawText(y) 197 | self:drawGutter(y) 198 | end 199 | 200 | -- Restore the cursor position 201 | self:restoreCursor() 202 | end 203 | 204 | 205 | --- Updates the screen based off what the editor says needs redrawing. 206 | function Content:updateDirty() 207 | local dirty = self.editor:dirty() 208 | if dirty then 209 | if dirty == "full" then 210 | self:draw() 211 | else 212 | term.redirect(self.win) 213 | for _, data in pairs(dirty) do 214 | if data.kind == "gutter" then 215 | self:drawGutter(data.data) 216 | elseif data.kind == "line" then 217 | self:drawLine(data.data) 218 | end 219 | end 220 | end 221 | 222 | self.editor:clearDirty() 223 | end 224 | end 225 | 226 | 227 | --- Updates the syntax highlighter. 228 | --- Triggers an update of the mapped data if character is non-nil, 229 | --- and a full redraw if character is one of the full redraw triggers. 230 | function Content:updateSyntaxHighlighting(character) 231 | if character then 232 | self.highlighter:update(self.editor.lines) 233 | 234 | -- Trigger a full redraw if a mapped character was typed (ie. affects 235 | -- the highlighting on other lines). 236 | if SyntaxHighlighter.fullRedrawTriggers:find(character, 1, true) then 237 | self.editor:setDirty("full") 238 | end 239 | end 240 | end 241 | 242 | 243 | --- Called when a key event occurs. 244 | function Content:key(key) 245 | if key == keys.up then 246 | self.editor:moveCursorUp() 247 | elseif key == keys.down then 248 | self.editor:moveCursorDown() 249 | elseif key == keys.left then 250 | self.editor:moveCursorLeft() 251 | elseif key == keys.right then 252 | self.editor:moveCursorRight() 253 | elseif key == keys.backspace then 254 | local character = self.editor:backspace() 255 | self:updateSyntaxHighlighting(character) 256 | elseif key == keys.tab then 257 | for i = 1, Content.tabWidth do 258 | self.editor:insertCharacter(" ") 259 | end 260 | self:updateSyntaxHighlighting(" ") 261 | elseif key == keys.enter then 262 | self.editor:insertNewline() 263 | self:updateSyntaxHighlighting("\n") 264 | end 265 | end 266 | 267 | 268 | --- Called when a char event occurs. 269 | function Content:char(character) 270 | self.editor:insertCharacter(character) 271 | self:updateSyntaxHighlighting(character) 272 | end 273 | 274 | 275 | --- Called when an event occurs. 276 | function Content:event(event) 277 | if event[1] == "char" then 278 | self:char(event[2]) 279 | elseif event[1] == "key" then 280 | self:key(event[2]) 281 | elseif event[1] == "mouse_click" or event[1] == "mouse_drag" then 282 | self.editor:moveCursorToRelative(event[3] - self.editor:gutterSize(), event[4]) 283 | return true 284 | end 285 | 286 | self:updateDirty() 287 | self:restoreCursor() 288 | return false 289 | end 290 | -------------------------------------------------------------------------------- /src/editor/editor.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Editor 4 | -- 5 | 6 | --- An editor. 7 | --- Controls the state of an editor for a single file. 8 | Editor = {} 9 | Editor.__index = Editor 10 | 11 | 12 | --- Create a new editor from the lines of text to edit, and its size. 13 | function Editor.new(...) 14 | local self = setmetatable({}, Editor) 15 | self:setup(...) 16 | return self 17 | end 18 | 19 | 20 | function Editor:setup(lines, width, height) 21 | self.lines = lines 22 | 23 | --- The editor size. 24 | self.width = width 25 | self.height = height 26 | 27 | --- The position of the cursor relative to the top left 28 | --- of the editor window. Starting at (1, 1). 29 | self.cursor = {} 30 | self.cursor.x = 1 31 | self.cursor.y = 1 32 | 33 | --- The scroll amount on each axis. Starting at (0, 0). 34 | self.scroll = {} 35 | self.scroll.x = 0 36 | self.scroll.y = 0 37 | 38 | --- State variables 39 | self.dirtyData = nil 40 | end 41 | 42 | 43 | --- Move the cursor to an absolute position within the document. 44 | --- Start at (1, 1). 45 | function Editor:moveCursorTo(x, y) 46 | local width = self:textWidth() 47 | 48 | -- Y axis 49 | if y > self.scroll.y and y <= self.scroll.y + self.height then 50 | -- Within our current view 51 | local previousY = self.cursor.y 52 | self.cursor.y = y - self.scroll.y 53 | self:updateGutter(previousY) 54 | elseif y <= self.scroll.y then 55 | -- Above us 56 | self.cursor.y = 1 57 | self.scroll.y = y - 1 58 | self:setDirty("full") 59 | else 60 | -- Below us 61 | self.cursor.y = self.height 62 | self.scroll.y = y - self.height 63 | self:setDirty("full") 64 | end 65 | 66 | -- X axis 67 | local length = self:currentLineLength() 68 | if x > self.scroll.x and x <= self.scroll.x + width then 69 | self.cursor.x = x - self.scroll.x 70 | 71 | -- Handle the case where the word is 1 cell to the left of the screen. 72 | if length > 0 and self.cursor.x == 1 and length - self.scroll.x <= 0 then 73 | if self.scroll.x > 0 then 74 | self.cursor.x = 2 75 | self.scroll.x = self.scroll.x - 1 76 | self:setDirty("full") 77 | end 78 | end 79 | elseif x <= self.scroll.x then 80 | -- To the left of us 81 | if length > 0 then 82 | self.cursor.x = 2 83 | self.scroll.x = x - 2 84 | else 85 | self.cursor.x = 1 86 | self.scroll.x = x - 1 87 | end 88 | 89 | self:setDirty("full") 90 | else 91 | -- To the right of us 92 | self.cursor.x = width 93 | self.scroll.x = x - width 94 | self:setDirty("full") 95 | end 96 | end 97 | 98 | 99 | --- Move the cursor to a position relative to the top left of 100 | --- the editor window. 101 | --- Start at (1, 1). 102 | function Editor:moveCursorToRelative(x, y) 103 | local y = math.min(y + self.scroll.y, #self.lines) 104 | local length = self.lines[y]:len() 105 | local x = math.min(x + self.scroll.x, length + 1) 106 | self:moveCursorTo(x, y) 107 | end 108 | 109 | 110 | --- Move the cursor to the end of the line if it is off the edge of the screen. 111 | function Editor:moveCursorToEndIfOffScreen() 112 | local length = self:currentLineLength() 113 | if length - self.scroll.x <= 0 then 114 | if length > 0 then 115 | self.cursor.x = 2 116 | self.scroll.x = length - 1 117 | else 118 | self.cursor.x = 1 119 | self.scroll.x = 0 120 | end 121 | 122 | self:setDirty("full") 123 | end 124 | end 125 | 126 | 127 | --- Move the cursor up a single line. 128 | function Editor:moveCursorUp() 129 | if self.cursor.y > 1 then 130 | -- Still on screen, no need to scroll 131 | local previousY = self.cursor.y 132 | self.cursor.y = self.cursor.y - 1 133 | self:updateGutter(previousY) 134 | else 135 | -- Need to scroll 136 | if self.scroll.y > 0 then 137 | self.scroll.y = self.scroll.y - 1 138 | self:setDirty("full") 139 | end 140 | end 141 | 142 | self:moveCursorToEndIfOffScreen() 143 | end 144 | 145 | 146 | --- Move the cursor down a single line. 147 | function Editor:moveCursorDown() 148 | if self.cursor.y < math.min(self.height, #self.lines) then 149 | -- Still on screen 150 | local previousY = self.cursor.y 151 | self.cursor.y = self.cursor.y + 1 152 | self:updateGutter(previousY) 153 | else 154 | -- Need to scroll 155 | if self:canScrollDown() then 156 | self.scroll.y = self.scroll.y + 1 157 | self:setDirty("full") 158 | end 159 | end 160 | 161 | self:moveCursorToEndIfOffScreen() 162 | end 163 | 164 | 165 | --- Move the cursor left one character. 166 | function Editor:moveCursorLeft() 167 | self:resetCursorRestoration() 168 | 169 | if self.cursor.x > 1 then 170 | -- Still on screen 171 | self.cursor.x = self.cursor.x - 1 172 | else 173 | -- Need to scroll 174 | if self.scroll.x > 0 then 175 | self.scroll.x = self.scroll.x - 1 176 | self:setDirty("full") 177 | end 178 | end 179 | end 180 | 181 | 182 | --- Move the cursor right one character. 183 | function Editor:moveCursorRight() 184 | local width = self:textWidth() 185 | local length = self:currentLineLength() 186 | 187 | if self.cursor.x < math.min(width, length - self.scroll.x + 1) then 188 | -- Still on screen 189 | self.cursor.x = self.cursor.x + 1 190 | else 191 | -- Need to scroll 192 | if length + 1 >= width and self.scroll.x + width <= length then 193 | self.scroll.x = self.scroll.x + 1 194 | self:setDirty("full") 195 | end 196 | end 197 | end 198 | 199 | 200 | --- Scroll up one line. 201 | function Editor:scrollUp() 202 | if self.scroll.y > 0 then 203 | self.scroll.y = self.scroll.y - 1 204 | self:setDirty("full") 205 | end 206 | end 207 | 208 | 209 | --- Scroll down one line. 210 | function Editor:scrollDown() 211 | if self:canScrollDown() then 212 | self.scroll.y = self.scroll.y + 1 213 | self:setDirty("full") 214 | end 215 | end 216 | 217 | 218 | --- Returns true if the editor can scroll down. 219 | function Editor:canScrollDown() 220 | return #self.lines > self.height and self.scroll.y + self.height < #self.lines 221 | end 222 | 223 | 224 | --- Move the cursor to the start of the current line. 225 | function Editor:moveCursorToStartOfLine() 226 | self.cursor.x = 1 227 | 228 | if self.scroll.x > 0 then 229 | self.scroll.x = 0 230 | self:setDirty("full") 231 | end 232 | end 233 | 234 | 235 | --- Move the cursor to the end of the current line. 236 | function Editor:moveCursorToEndOfLine() 237 | local length = self:currentLineLength() 238 | local width = self:textWidth() 239 | 240 | if length == 0 then 241 | -- Move to 0 242 | self:moveCursorToStartOfLine() 243 | elseif length - self.scroll.x > 0 then 244 | -- The end of the line can be seen 245 | self.cursor.x = length - self.scroll.x + 1 246 | else 247 | -- The end of the line is off screen 248 | self.cursor.x = width 249 | self.scroll.x = length - width + 1 250 | self:setDirty("full") 251 | end 252 | end 253 | 254 | 255 | --- Move the cursor to the start of the file. 256 | function Editor:moveCursorToStartOfFile() 257 | local previousY = self.cursor.y 258 | self.cursor.y = 1 259 | if self.scroll.y > 0 then 260 | self.scroll.y = 0 261 | self:setDirty("full") 262 | else 263 | self:updateGutter(previousY) 264 | end 265 | 266 | self:moveCursorToStartOfLine() 267 | end 268 | 269 | 270 | --- Move the cursor to the start of the last line in the file. 271 | function Editor:moveCursorToEndOfFile() 272 | local previousY = self.cursor.y 273 | self.cursor.y = self.height 274 | 275 | local scroll = #self.lines - self.height 276 | if self.scroll.y ~= scroll then 277 | self.scroll.y = scroll 278 | self:setDirty("full") 279 | else 280 | self:updateGutter(previousY) 281 | end 282 | 283 | self:moveCursorToStartOfLine() 284 | end 285 | 286 | 287 | --- Move the cursor to the start of a particular line. 288 | --- The line number is given as absolute within the file. 289 | function Editor:moveCursorToLine(y) 290 | self:moveCursorTo(1, y) 291 | end 292 | 293 | 294 | --- Move up one page. A page's height is equal to the 295 | --- height of the editor. 296 | function Editor:pageUp() 297 | local y = math.max(0, self.cursor.y + self.scroll.y - self.height) 298 | self:moveCursorTo(1, y) 299 | end 300 | 301 | 302 | --- Move down one page. A page's height is equal to the 303 | --- height of the editor. 304 | function Editor:pageDown() 305 | local y = math.max(0, self.cursor.y + self.scroll.y + self.height) 306 | self:moveCursorTo(1, y) 307 | end 308 | 309 | 310 | --- Delete the character behind the current cursor position. 311 | --- Returns the backspaced character, or nil if nothing was deleted. 312 | function Editor:backspace() 313 | self:resetCursorRestoration() 314 | 315 | -- Only continue if we are not at the first location in the file 316 | if self.cursor.x > 1 or self.scroll.x > 0 or self.cursor.y > 1 or self.scroll.y > 0 then 317 | local x, y = self:absoluteCursorPosition() 318 | 319 | if x > 1 then 320 | -- Remove a single character 321 | local character = self.lines[y]:sub(x - 1, x - 1) 322 | self.lines[y] = self.lines[y]:sub(1, x - 2) .. self.lines[y]:sub(x) 323 | self:setDirty("line", self.cursor.y) 324 | 325 | -- If the cursor is not at the end of the line 326 | local length = self.lines[y]:len() 327 | if self.cursor.x + self.scroll.x <= length + 1 then 328 | self:moveCursorLeft() 329 | end 330 | 331 | return character 332 | else 333 | -- Remove the line 334 | local length = self.lines[y - 1]:len() 335 | self.lines[y - 1] = self.lines[y - 1] .. self.lines[y] 336 | table.remove(self.lines, y) 337 | 338 | -- Update the cursor 339 | self:moveCursorTo(length + 1, y - 1) 340 | self:setDirty("full") 341 | 342 | return "\n" 343 | end 344 | end 345 | 346 | return nil 347 | end 348 | 349 | 350 | --- Delete the character in front of the current cursor position. 351 | function Editor:forwardDelete() 352 | self:resetCursorRestoration() 353 | local x, y = self:absoluteCursorPosition() 354 | local length = self:currentLineLength() 355 | 356 | if x <= length then 357 | -- Remove a single character 358 | self.lines[y] = self.lines[y]:sub(1, x - 1) .. self.lines[y]:sub(x + 1) 359 | self:setDirty("line", self.cursor.y) 360 | else 361 | -- Remove the line below 362 | self.lines[y] = self.lines[y] .. self.lines[y + 1] 363 | table.remove(self.lines, y + 1) 364 | self:setDirty("full") 365 | end 366 | end 367 | 368 | 369 | --- Insert a character at the current cursor position. 370 | function Editor:insertCharacter(character) 371 | self:resetCursorRestoration() 372 | local x, y = self:absoluteCursorPosition() 373 | self.lines[y] = self.lines[y]:sub(1, x - 1) .. character .. self.lines[y]:sub(x) 374 | self:moveCursorRight() 375 | self:setDirty("line", self.cursor.y) 376 | end 377 | 378 | 379 | --- Insert a newline at the current cursor position. 380 | function Editor:insertNewline() 381 | self:resetCursorRestoration() 382 | local x, y = self:absoluteCursorPosition() 383 | local first = self.lines[y]:sub(1, x - 1) 384 | local second = self.lines[y]:sub(x) 385 | 386 | self.lines[y] = first 387 | table.insert(self.lines, y + 1, second) 388 | 389 | self:moveCursorDown() 390 | self:moveCursorToStartOfLine() 391 | self:setDirty("full") 392 | end 393 | 394 | 395 | --- Rests the cursor restoration. 396 | function Editor:resetCursorRestoration() 397 | local length = self:currentLineLength() 398 | if self.cursor.x + self.scroll.x > length then 399 | self:moveCursorToEndOfLine() 400 | end 401 | end 402 | 403 | 404 | --- Returns the size of the gutter. 405 | function Editor:gutterSize() 406 | return tostring(#self.lines):len() + Theme["gutter separator"]:len() 407 | end 408 | 409 | 410 | --- Returns the width of the text portion of the 411 | --- window (excluding the gutter). 412 | function Editor:textWidth() 413 | return self.width - self:gutterSize() 414 | end 415 | 416 | 417 | --- Returns the length of the line the cursor is on. 418 | function Editor:currentLineLength() 419 | return self.lines[self.cursor.y + self.scroll.y]:len() 420 | end 421 | 422 | 423 | --- Returns the x and y location of the cursor on screen, 424 | --- relative to the top left of the editor's window. 425 | function Editor:cursorPosition() 426 | local length = self:currentLineLength() 427 | local x = math.min(self.cursor.x, length - self.scroll.x + 1) + self:gutterSize() 428 | local y = self.cursor.y 429 | return x, y 430 | end 431 | 432 | 433 | --- Return the absolute position of the cursor in the document. 434 | function Editor:absoluteCursorPosition() 435 | local x = self.cursor.x + self.scroll.x 436 | local y = self.cursor.y + self.scroll.y 437 | return x, y 438 | end 439 | 440 | 441 | --- Appends or sets what to render. 442 | function Editor:setDirty(kind, data) 443 | if kind == "full" then 444 | self.dirtyData = kind 445 | elseif self.dirtyData ~= "full" then 446 | if not self.dirtyData then 447 | self.dirtyData = {} 448 | end 449 | 450 | local data = { 451 | ["kind"] = kind, 452 | ["data"] = data, 453 | } 454 | 455 | -- Optimizations: 456 | -- * Remove duplicate draws 457 | -- * When a line draw is added, remove all gutter draws on that line 458 | 459 | table.insert(self.dirtyData, data) 460 | end 461 | end 462 | 463 | 464 | --- Determines whether the gutter on the current line and previous 465 | --- line should be redrawn. 466 | function Editor:updateGutter(previousY) 467 | if self.cursor.y ~= previousY then 468 | self:setDirty("gutter", previousY) 469 | self:setDirty("gutter", self.cursor.y) 470 | end 471 | end 472 | 473 | 474 | --- Returns what needs to be redrawn. 475 | --- Returns nil to indicate nothing should be drawn. 476 | --- Returns either "full" if to redraw everything, or an array of: 477 | --- * `line`, lines - A line or lines needs redrawing 478 | --- * `gutter`, lines - The gutter on a line or lines needs redrawing 479 | --- All line values are relative to the top left corner of the editor 480 | function Editor:dirty() 481 | return self.dirtyData 482 | end 483 | 484 | 485 | --- Clears the dirty data. 486 | function Editor:clearDirty() 487 | self.dirtyData = nil 488 | end 489 | -------------------------------------------------------------------------------- /src/editor/file.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- File 4 | -- 5 | 6 | File = {} 7 | 8 | 9 | --- Returns the contents of a file at path as an array of lines. 10 | --- Returns nil on failure, with an error message as the second argument. 11 | function File.load(path) 12 | if not fs.exists(path) then 13 | return nil, "File does not exist" 14 | elseif fs.isDir(path) then 15 | return nil, "Cannot edit a directory" 16 | else 17 | local f = fs.open(path, "r") 18 | 19 | if f then 20 | local lines = {} 21 | local line = f.readLine() 22 | 23 | while line do 24 | table.insert(lines, line) 25 | line = f.readLine() 26 | end 27 | 28 | f.close() 29 | 30 | if #lines == 0 then 31 | table.insert(lines, "") 32 | end 33 | 34 | return lines 35 | else 36 | return nil, "Failed to open file" 37 | end 38 | end 39 | end 40 | 41 | 42 | --- Saves a set of lines to the file at path. 43 | --- Returns nil on success, and an error message on failure. 44 | function File.save(lines, path) 45 | if fs.isDir(path) then 46 | return "Cannot save to a directory" 47 | elseif fs.isReadOnly(path) then 48 | return "Cannot save to a read only file" 49 | else 50 | local f = fs.open(path, "w") 51 | if f then 52 | f.write(table.concat(lines, "\n")) 53 | f.close() 54 | else 55 | return "Failed to open file" 56 | end 57 | end 58 | 59 | return nil 60 | end 61 | -------------------------------------------------------------------------------- /src/editor/highlighter.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Syntax Highlighter 4 | -- 5 | 6 | SyntaxHighlighter = {} 7 | SyntaxHighlighter.__index = SyntaxHighlighter 8 | 9 | 10 | --- Tags that specify the start and end of mapped data. 11 | --- Must be iterated in order. 12 | --- A nil end tag means the end of the line. 13 | SyntaxHighlighter.tags = { 14 | { 15 | ["start"] = "--[[", 16 | ["end"] = "]]", 17 | ["kind"] = "comment", 18 | }, 19 | { 20 | ["start"] = "--", 21 | ["end"] = nil, 22 | ["kind"] = "comment", 23 | }, 24 | { 25 | ["start"] = "[[", 26 | ["end"] = "]]", 27 | ["kind"] = "string", 28 | }, 29 | { 30 | ["start"] = "\"", 31 | ["end"] = "\"", 32 | ["kind"] = "string", 33 | }, 34 | { 35 | ["start"] = "'", 36 | ["end"] = "'", 37 | ["kind"] = "string", 38 | }, 39 | } 40 | 41 | --- Characters that should trigger a full redraw of the screen. 42 | SyntaxHighlighter.fullRedrawTriggers = "-[]\"'" 43 | 44 | --- Characters that are a valid identifier. 45 | SyntaxHighlighter.validIdentifiers = "0-9A-Za-z_" 46 | 47 | --- All items highlighted on a line-by-line basis. 48 | --- Can be Lua patterns. 49 | --- Automatically wrapped in word separators. 50 | SyntaxHighlighter.keywords = { 51 | ["keywords"] = { 52 | "break", 53 | "do", 54 | "else", 55 | "for", 56 | "if", 57 | "elseif", 58 | "return", 59 | "then", 60 | "repeat", 61 | "while", 62 | "until", 63 | "end", 64 | "function", 65 | "local", 66 | "in", 67 | }, 68 | ["constants"] = { 69 | "true", 70 | "false", 71 | "nil", 72 | }, 73 | ["numbers"] = { 74 | "[0-9]+", 75 | }, 76 | ["operators"] = { 77 | "%+", 78 | "%-", 79 | "%%", 80 | "#", 81 | "%*", 82 | "/", 83 | "%^", 84 | "=", 85 | "==", 86 | "~=", 87 | "<", 88 | "<=", 89 | ">", 90 | ">=", 91 | "!", 92 | "and", 93 | "or", 94 | "not", 95 | }, 96 | ["functions"] = { 97 | "print", 98 | "write", 99 | "sleep", 100 | "pairs", 101 | "ipairs", 102 | "loadstring", 103 | "loadfile", 104 | "dofile", 105 | "rawset", 106 | "rawget", 107 | "setfenv", 108 | "getfenv", 109 | "assert", 110 | "getmetatable", 111 | "setmetatable", 112 | "pcall", 113 | "xpcall", 114 | "type", 115 | "unpack", 116 | "tonumber", 117 | "tostring", 118 | "select", 119 | "next", 120 | }, 121 | } 122 | 123 | 124 | --- Create a new syntax highlighter. 125 | function SyntaxHighlighter.new(...) 126 | local self = setmetatable({}, SyntaxHighlighter) 127 | self:setup(...) 128 | return self 129 | end 130 | 131 | 132 | function SyntaxHighlighter:setup() 133 | self.lines = nil 134 | 135 | --- A list of starting and ending locations for 136 | --- coloring tags that span multiple lines, such 137 | --- as strings. 138 | --- { 139 | --- kind = "string|comment" 140 | --- startY = "absolute line y position", 141 | --- startX = "x position on starting line", 142 | --- endY = "absolute line ending y position", 143 | --- endX = "x position on ending line", 144 | --- } 145 | self.map = {} 146 | end 147 | 148 | 149 | --- Update the syntax highlighter when the contents 150 | --- of the lines being edited changes. 151 | function SyntaxHighlighter:update(lines) 152 | self.lines = lines 153 | self:recalculateMappedData() 154 | end 155 | 156 | 157 | --- Recalculate the mapped data. 158 | function SyntaxHighlighter:recalculateMappedData() 159 | self.map = {} 160 | local current = nil 161 | local currentEnding = nil 162 | 163 | for y = 1, #self.lines do 164 | local line = self.lines[y] 165 | local position = 1 166 | 167 | while true do 168 | if current then 169 | if not currentEnding then 170 | -- Tag ends at the end of this line. 171 | current["endY"] = y 172 | current["endX"] = line:len() 173 | table.insert(self.map, current) 174 | current = nil 175 | currentEnding = nil 176 | break 177 | else 178 | -- Look for an ending tag 179 | local start, finish = line:find(currentEnding, position, true) 180 | 181 | if not start or not finish then 182 | -- Ending tag not on this line 183 | break 184 | else 185 | -- Found ending tag 186 | current["endY"] = y 187 | current["endX"] = finish 188 | table.insert(self.map, current) 189 | current = nil 190 | currentEnding = nil 191 | position = finish + 1 192 | end 193 | end 194 | else 195 | local start = nil 196 | local finish = nil 197 | local tag = nil 198 | 199 | -- Attempt to find a starting tag 200 | for _, possible in pairs(SyntaxHighlighter.tags) do 201 | start, finish = line:find(possible["start"], position, true) 202 | if start and finish then 203 | tag = possible 204 | break 205 | end 206 | end 207 | 208 | if not start or not finish then 209 | -- Could not find another starting tag 210 | break 211 | else 212 | -- Found starting tag 213 | currentEnding = tag["end"] 214 | current = { 215 | ["kind"] = tag["kind"], 216 | ["startY"] = y, 217 | ["startX"] = start, 218 | } 219 | position = finish + 1 220 | end 221 | end 222 | end 223 | end 224 | 225 | if current then 226 | -- End the current tag at the end of the file 227 | current["endY"] = #self.lines 228 | current["endX"] = self.lines[#self.lines]:len() 229 | table.insert(self.map, current) 230 | end 231 | end 232 | 233 | 234 | --- Returns highlight data for a line, starting at 235 | --- a given x scroll position. 236 | --- The y position is absolute in the latest `lines` array. 237 | --- Returns an array of: 238 | --- * `text`, data - render the text 239 | --- * `kind`, kind - change the text color for all future text. 240 | --- `kind` is one of the indices in the keywords list, or "text" for plain text. 241 | function SyntaxHighlighter:data(y, horizontalScroll, width) 242 | -- Check if covers whole line 243 | -- Check if covering start of line 244 | -- Check if covering end of line 245 | -- Extract text in middle 246 | -- Apply highlighting for start of line 247 | -- While true 248 | -- Look for first mapped data in text 249 | -- If not found then break 250 | -- Else 251 | -- Apply keyword highlighting to text between start and mapped data 252 | -- Apply highlighting for mapped data 253 | -- update position for mapped data 254 | -- Apply keyword highlighting for rest of string 255 | -- Apply highlighting for end of line 256 | 257 | local lineStart = horizontalScroll + 1 258 | local lineFinish = horizontalScroll + width 259 | local line = self.lines[y]:sub(lineStart, lineFinish) 260 | 261 | local startText = nil 262 | local startKeyword = nil 263 | local endText = nil 264 | local endKeyword = nil 265 | 266 | for _, data in pairs(self.map) do 267 | -- Covering whole line 268 | local onLine = data.startY == y and data.endY == y and data.startX <= lineStart 269 | and data.endX >= lineFinish 270 | local beforeLine = data.startY < y and data.endY == y and data.endX >= lineFinish 271 | local afterLine = data.startY == y and data.endY > y and data.startX <= lineStart 272 | local encaseLine = data.startY < y and data.endY > y 273 | 274 | -- Covers start of line 275 | local onStartLine = data.startY == y and data.startX <= lineStart 276 | local beforeStartLine = data.startY < y and data.endY == y 277 | 278 | -- Covers end of line 279 | local onEndLine = data.startY == y and data.endY == y and data.endX >= lineFinish 280 | local afterEndLine = data.startY == y and data.endY > y 281 | 282 | if onLine or beforeLine or afterLine or encaseLine then 283 | -- Covers whole line 284 | return { 285 | {["kind"] = "color", ["data"] = data.kind}, 286 | {["kind"] = "text", ["data"] = line}, 287 | } 288 | elseif onStartLine or beforeStartLine then 289 | -- Covers start of line 290 | startText = line:sub(1, data.endX - lineStart + 1) 291 | startKeyword = data.kind 292 | elseif onEndLine or afterEndLine then 293 | -- Covers end of line 294 | endText = line:sub(data.startX - lineStart + 1) 295 | endKeyword = data.kind 296 | end 297 | end 298 | 299 | local result = {} 300 | 301 | -- Apply starting text 302 | if startText and startText:len() > 0 then 303 | table.insert(result, {["kind"] = "color", ["data"] = startKeyword}) 304 | table.insert(result, {["kind"] = "text", ["data"] = startText}) 305 | end 306 | 307 | -- Add dummy start and end text so we don't have to special case the 308 | -- nil whenever we call len. 309 | if not startText then 310 | startText = "" 311 | end 312 | if not endText then 313 | endText = "" 314 | end 315 | 316 | -- Extract inner text 317 | line = line:sub(startText:len() + 1, -endText:len() - 1) 318 | 319 | if line:len() > 0 then 320 | local position = 1 321 | 322 | while true do 323 | -- Find first occurrence of mapped data on this line 324 | -- Relative to the start of the extracted inner text 325 | local start = nil 326 | local finish = nil 327 | local mapped = nil 328 | 329 | for _, data in pairs(self.map) do 330 | if data.startY == y and data.endY == y then 331 | -- Only on this line 332 | potentialStart = data.startX - lineStart - startText:len() + 1 333 | potentialFinish = data.endX - lineStart - startText:len() + 1 334 | 335 | if potentialStart >= position and potentialFinish >= position then 336 | -- Found the next mapped data 337 | start = potentialStart 338 | finish = potentialFinish 339 | mapped = data 340 | break 341 | end 342 | end 343 | end 344 | 345 | if not start or not finish or not mapped then 346 | -- No more mapped data left 347 | break 348 | end 349 | 350 | -- Extract the text between the start of the line and the mapped data 351 | local text = line:sub(position, start - 1) 352 | 353 | -- Append the text to the result, highlighting while ignoring mapped data 354 | self:highlight(result, text) 355 | 356 | -- Append the mapped data 357 | local mappedText = line:sub(start, finish) 358 | table.insert(result, {["kind"] = "color", ["data"] = mapped.kind}) 359 | table.insert(result, {["kind"] = "text", ["data"] = mappedText}) 360 | 361 | -- Update the position 362 | position = finish + 1 363 | end 364 | 365 | -- Append the final text 366 | local text = line:sub(position) 367 | self:highlight(result, text) 368 | end 369 | 370 | -- Apply ending text 371 | if endText and endText:len() > 0 then 372 | table.insert(result, {["kind"] = "color", ["data"] = endKeyword}) 373 | table.insert(result, {["kind"] = "text", ["data"] = endText}) 374 | end 375 | 376 | return result 377 | end 378 | 379 | 380 | --- Returns the kind for a word. 381 | function SyntaxHighlighter:kind(word) 382 | -- Look for the word in each section of the keywords list 383 | for section, options in pairs(SyntaxHighlighter.keywords) do 384 | for _, option in pairs(options) do 385 | if word:find("^" .. option .. "$") then 386 | return section 387 | end 388 | end 389 | end 390 | 391 | return "text" 392 | end 393 | 394 | 395 | --- Highlights a piece of text, ignoring any mapped data. 396 | function SyntaxHighlighter:highlight(result, text) 397 | -- Split into identifiers (letters/numbers), operators, and whitespace. 398 | local position = 1 399 | local currentKind = nil 400 | 401 | while position <= text:len() do 402 | local char = text:sub(position, position) 403 | local index = true 404 | local after = nil 405 | 406 | if char:match("^%s$") then 407 | -- Whitespace is next 408 | after = text:find("[^%s]", position) 409 | index = false 410 | elseif char:match("^[" .. SyntaxHighlighter.validIdentifiers .. "]$") then 411 | -- A word is next 412 | after = text:find("[^" .. SyntaxHighlighter.validIdentifiers .. "]", position) 413 | else 414 | -- Some other operator 415 | after = text:find("[" .. SyntaxHighlighter.validIdentifiers .. "%s]", position) 416 | end 417 | 418 | if not after then 419 | after = text:len() + 1 420 | end 421 | 422 | local data = text:sub(position, after - 1) 423 | 424 | if index then 425 | local kind = SyntaxHighlighter:kind(data) 426 | if kind ~= currentKind then 427 | table.insert(result, {["kind"] = "color", ["data"] = kind}) 428 | currentKind = kind 429 | end 430 | end 431 | 432 | table.insert(result, {["kind"] = "text", ["data"] = data}) 433 | position = after 434 | end 435 | end 436 | -------------------------------------------------------------------------------- /src/main.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- LuaIDE 4 | -- GravityScore 5 | -- 6 | 7 | local environment = {} 8 | setmetatable(environment, {__index = _G}) 9 | 10 | local currentProgram = shell.getRunningProgram() 11 | local rootDirectory = currentProgram:sub(1, -fs.getName(currentProgram):len() - 2) 12 | environment["rootDirectory"] = rootDirectory 13 | environment["shell"] = shell 14 | 15 | local args = {...} 16 | local fn, err = loadfile(rootDirectory .. "/application.lua") 17 | 18 | if err then 19 | error(err) 20 | else 21 | setfenv(fn, environment) 22 | fn(unpack(args)) 23 | end 24 | -------------------------------------------------------------------------------- /src/theme.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Theme 4 | -- 5 | 6 | Theme = {} 7 | 8 | 9 | --- Load the theme 10 | Theme.load = function() 11 | if term.isColor() then 12 | -- Menu bar 13 | Theme["menu bar background"] = colors.white 14 | Theme["menu bar background focused"] = colors.gray 15 | Theme["menu bar text"] = colors.black 16 | Theme["menu bar text focused"] = colors.white 17 | Theme["menu bar flash text"] = colors.white 18 | Theme["menu bar flash background"] = colors.lightGray 19 | 20 | -- Menu dropdown items 21 | Theme["menu dropdown background"] = colors.gray 22 | Theme["menu dropdown text"] = colors.white 23 | Theme["menu dropdown flash text"] = colors.white 24 | Theme["menu dropdown flash background"] = colors.lightGray 25 | 26 | -- Tab bar 27 | Theme["tab bar background"] = colors.white 28 | Theme["tab bar background focused"] = colors.white 29 | Theme["tab bar background blurred"] = colors.white 30 | Theme["tab bar background close"] = colors.white 31 | Theme["tab bar text focused"] = colors.black 32 | Theme["tab bar text blurred"] = colors.lightGray 33 | Theme["tab bar text close"] = colors.red 34 | 35 | -- Editor 36 | Theme["editor background"] = colors.white 37 | Theme["editor text"] = colors.black 38 | 39 | -- Gutter 40 | Theme["gutter background"] = colors.white 41 | Theme["gutter background focused"] = colors.white 42 | Theme["gutter background error"] = colors.white 43 | Theme["gutter text"] = colors.lightGray 44 | Theme["gutter text focused"] = colors.gray 45 | Theme["gutter text error"] = colors.red 46 | Theme["gutter separator"] = " " 47 | 48 | -- Syntax Highlighting 49 | Theme["keywords"] = colors.lightBlue 50 | Theme["constants"] = colors.orange 51 | Theme["operators"] = colors.blue 52 | Theme["numbers"] = colors.black 53 | Theme["functions"] = colors.magenta 54 | Theme["string"] = colors.red 55 | Theme["comment"] = colors.lightGray 56 | 57 | -- Panel 58 | Theme["panel text"] = colors.white 59 | Theme["panel background"] = colors.gray 60 | Theme["panel close text"] = colors.red 61 | Theme["panel close background"] = colors.gray 62 | 63 | -- File dialogue 64 | Theme["file dialogue background"] = colors.gray 65 | Theme["file dialogue text"] = colors.white 66 | Theme["file dialogue text blurred"] = colors.lightGray 67 | Theme["file dialogue file"] = colors.white 68 | Theme["file dialogue folder"] = colors.lime 69 | Theme["file dialogue readonly"] = colors.red 70 | else 71 | 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /src/ui/dialogue.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- File Dialogue 4 | -- 5 | 6 | --- A file selection dialogue. 7 | FileDialogue = {} 8 | FileDialogue.__index = FileDialogue 9 | 10 | FileDialogue.y = 3 11 | FileDialogue.listStartY = 6 12 | 13 | FileDialogue.ignore = { 14 | ".git", 15 | ".gitignore", 16 | ".DS_Store", 17 | } 18 | 19 | 20 | --- Create a new dialogue 21 | function FileDialogue.new(...) 22 | self = setmetatable({}, FileDialogue) 23 | self:setup(...) 24 | return self 25 | end 26 | 27 | 28 | function FileDialogue:setup(title) 29 | self.title = title 30 | self.dir = shell.dir() 31 | self.listCache = self:list() 32 | self.height = -1 33 | self.scroll = 0 34 | end 35 | 36 | 37 | function FileDialogue:list() 38 | local files = fs.list(self.dir) 39 | local list = {} 40 | 41 | -- Add items 42 | for _, file in pairs(files) do 43 | local add = true 44 | for _, ignore in pairs(FileDialogue.ignore) do 45 | if file == ignore then 46 | add = false 47 | break 48 | end 49 | end 50 | 51 | if add then 52 | table.insert(list, file) 53 | end 54 | end 55 | 56 | -- Sort items 57 | table.sort(list, function(a, b) 58 | local aPath = fs.combine(self.dir, a) 59 | local bPath = fs.combine(self.dir, b) 60 | if fs.isDir(aPath) and fs.isDir(bPath) then 61 | return a < b 62 | elseif fs.isDir(aPath) or fs.isDir(bPath) then 63 | return fs.isDir(aPath) 64 | else 65 | return a < b 66 | end 67 | end) 68 | 69 | -- Add the back button 70 | if self.dir ~= "" then 71 | table.insert(list, 1, "..") 72 | end 73 | 74 | return list 75 | end 76 | 77 | 78 | function FileDialogue:drawFileList() 79 | term.setBackgroundColor(Theme["file dialogue background"]) 80 | 81 | for y = 1, math.min(self:listHeight()) do 82 | term.setCursorPos(2, y + FileDialogue.listStartY - 1) 83 | term.clearLine() 84 | 85 | local file = self.listCache[y + self.scroll] 86 | if file then 87 | local path = fs.combine(self.dir, file) 88 | 89 | term.setTextColor(Theme["file dialogue file"]) 90 | if fs.isDir(path) then 91 | term.setTextColor(Theme["file dialogue folder"]) 92 | elseif fs.isReadOnly(path) then 93 | term.setTextColor(Theme["file dialogue readonly"]) 94 | end 95 | 96 | term.write(file) 97 | end 98 | end 99 | 100 | term.setTextColor(Theme["file dialogue text blurred"]) 101 | if #self.listCache - self.scroll > self:listHeight() then 102 | term.setCursorPos(1, self:listHeight() + FileDialogue.listStartY - 1) 103 | term.write("v") 104 | end 105 | if self.scroll > 0 then 106 | term.setCursorPos(1, FileDialogue.listStartY) 107 | term.write("^") 108 | end 109 | end 110 | 111 | 112 | function FileDialogue:listHeight() 113 | return self.height - FileDialogue.listStartY 114 | end 115 | 116 | 117 | function FileDialogue:clickOnItem(name) 118 | local path = fs.combine(self.dir, name) 119 | if fs.isDir(path) then 120 | self:setDir(path) 121 | return "/" .. path 122 | else 123 | return "/" .. path 124 | end 125 | end 126 | 127 | 128 | function FileDialogue:setDir(dir) 129 | self.dir = shell.resolve("/" .. dir) 130 | self.listCache = self:list() 131 | self.scroll = 0 132 | self:drawFileList() 133 | end 134 | 135 | 136 | function FileDialogue:scrollUp() 137 | if self.scroll > 0 then 138 | self.scroll = self.scroll - 1 139 | self:drawFileList() 140 | end 141 | end 142 | 143 | 144 | function FileDialogue:scrollDown() 145 | if self.scroll + self:listHeight() < #self.listCache then 146 | self.scroll = self.scroll + 1 147 | self:drawFileList() 148 | end 149 | end 150 | 151 | 152 | 153 | --- Returns nil if the user canceled the operation, or the selected path as 154 | --- a string. 155 | function FileDialogue:show() 156 | local x = 1 157 | local y = FileDialogue.y 158 | local w, h = term.native().getSize() 159 | self.height = h - (FileDialogue.y - 1) * 2 160 | local win = window.create(term.native(), x, y, w, self.height) 161 | 162 | -- Create and clear the window 163 | term.setCursorBlink(false) 164 | term.redirect(win) 165 | term.setTextColor(Theme["file dialogue text"]) 166 | term.setBackgroundColor(Theme["file dialogue background"]) 167 | term.clear() 168 | 169 | -- Title text 170 | term.setCursorPos(2, 2) 171 | term.write(self.title) 172 | 173 | -- Text field 174 | local field = TextField.new(2, 4, 175 | Theme["file dialogue text"], Theme["file dialogue background"]) 176 | field:setWidth(-2) 177 | field:setPlaceholder("Path...", Theme["file dialogue text blurred"]) 178 | 179 | -- Text field's initial text 180 | field.text = "/" .. self.dir 181 | field:moveToEnd() 182 | 183 | -- Event callback 184 | local listStartY = y + FileDialogue.listStartY - 1 185 | local selfCopy = self 186 | 187 | field:setCallback(function(event) 188 | if event[1] == "mouse_click" then 189 | local cx = event[3] 190 | local cy = event[4] 191 | if cx > 1 and cy >= listStartY and cy < listStartY + selfCopy:listHeight() then 192 | local item = selfCopy.listCache[cy - listStartY + 1] 193 | if item then 194 | local result = selfCopy:clickOnItem(item) 195 | selfCopy:drawFileList() 196 | return result 197 | end 198 | elseif cx == 1 then 199 | if cy == listStartY then 200 | self:scrollUp() 201 | elseif cy == listStartY + selfCopy:listHeight() - 1 then 202 | self:scrollDown() 203 | end 204 | end 205 | elseif event[1] == "char" or (event[1] == "key" and event[2] == keys.backspace) then 206 | if fs.isDir(field.text) then 207 | selfCopy:setDir(field.text) 208 | elseif fs.isDir(fs.getDir(field.text)) then 209 | selfCopy:setDir(fs.getDir(field.text)) 210 | end 211 | elseif event[1] == "key" then 212 | if event[2] == keys.up then 213 | self:scrollUp() 214 | elseif event[2] == keys.down then 215 | self:scrollDown() 216 | end 217 | elseif event[1] == "mouse_scroll" then 218 | if event[2] == -1 then 219 | self:scrollUp() 220 | else 221 | self:scrollDown() 222 | end 223 | end 224 | end) 225 | 226 | -- Show 227 | self:drawFileList() 228 | local result = field:show() 229 | if result and result:len() == 0 then 230 | result = nil 231 | end 232 | 233 | return result 234 | end 235 | -------------------------------------------------------------------------------- /src/ui/menu.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Menu Bar 4 | -- 5 | 6 | MenuBar = {} 7 | MenuBar.__index = MenuBar 8 | 9 | --- The y location of the menu bar. 10 | MenuBar.y = 1 11 | 12 | --- The duration in seconds to wait when flashing a menu item. 13 | MenuBar.flashDuration = 0.1 14 | 15 | --- Items contained in the menu bar. 16 | MenuBar.items = { 17 | { 18 | ["name"] = "File", 19 | ["contents"] = { 20 | "About", 21 | "New", 22 | "Open", 23 | "Save", 24 | "Save as", 25 | "Settings", 26 | "Quit", 27 | }, 28 | }, 29 | { 30 | ["name"] = "Edit", 31 | ["contents"] = { 32 | "Copy line", 33 | "Cut line", 34 | "Paste line", 35 | "Delete line", 36 | "Comment line", 37 | }, 38 | }, 39 | { 40 | ["name"] = "Tools", 41 | ["contents"] = { 42 | "Go to line", 43 | "Reindent", 44 | }, 45 | }, 46 | } 47 | 48 | 49 | --- Create a new menu bar. 50 | function MenuBar.new(...) 51 | local self = setmetatable({}, MenuBar) 52 | self:setup(...) 53 | return self 54 | end 55 | 56 | 57 | function MenuBar:setup() 58 | local w = term.getSize() 59 | self.win = window.create(term.native(), 1, MenuBar.y, w, 1) 60 | self.flash = nil 61 | self.focus = nil 62 | end 63 | 64 | 65 | --- Draws a menu item. 66 | function MenuBar:drawItem(item, flash) 67 | term.setBackgroundColor(Theme["menu dropdown background"]) 68 | term.clear() 69 | 70 | -- Render all the items 71 | for i, text in pairs(item.contents) do 72 | if i == flash then 73 | term.setTextColor(Theme["menu dropdown flash text"]) 74 | term.setBackgroundColor(Theme["menu dropdown flash background"]) 75 | else 76 | term.setTextColor(Theme["menu dropdown text"]) 77 | term.setBackgroundColor(Theme["menu dropdown background"]) 78 | end 79 | 80 | term.setCursorPos(3, i + 1) 81 | term.clearLine() 82 | term.write(text) 83 | end 84 | end 85 | 86 | 87 | --- Flashes an item when clicked, then draws it as focused. 88 | function MenuBar:drawFlash(index) 89 | self.flash = index 90 | self:draw() 91 | sleep(MenuBar.flashDuration) 92 | self.flash = nil 93 | self.focus = index 94 | self:draw() 95 | end 96 | 97 | 98 | --- Returns the width of the window to create for a particular item index. 99 | function MenuBar:itemWidth(index) 100 | local item = self.items[index] 101 | local width = -1 102 | for _, text in pairs(item.contents) do 103 | if text:len() > width then 104 | width = text:len() 105 | end 106 | end 107 | width = width + 4 108 | 109 | return width 110 | end 111 | 112 | 113 | --- Opens a menu item, blocking the event loop until it's closed. 114 | function MenuBar:open(index) 115 | -- Flash the menu item 116 | term.setCursorBlink(false) 117 | self:drawFlash(index) 118 | 119 | -- Window location 120 | local item = self.items[index] 121 | local x = self:itemLocation(index) 122 | local y = MenuBar.y + 1 123 | local height = #item.contents + 2 124 | local width = self:itemWidth(index) 125 | 126 | -- Create the window 127 | local win = window.create(term.native(), x, y, width, height) 128 | term.redirect(win) 129 | self:drawItem(item) 130 | 131 | -- Wait for a click 132 | while true do 133 | local event = {os.pullEventRaw()} 134 | 135 | if event[1] == "terminate" then 136 | os.queueEvent("exit") 137 | break 138 | elseif event[1] == "mouse_click" then 139 | local cx = event[3] 140 | local cy = event[4] 141 | 142 | if cy >= y and cy < y + height and cx >= x and cx < x + width then 143 | -- Clicked on the window somewhere 144 | if cy >= y + 1 and cy < y + height - 1 then 145 | -- Clicked on an item 146 | local index = cy - y 147 | self:drawItem(item, index) 148 | sleep(MenuBar.flashDuration) 149 | 150 | local text = item.contents[index] 151 | os.queueEvent("menu item trigger", text) 152 | break 153 | end 154 | else 155 | -- Close the menu item 156 | os.queueEvent("menu item close") 157 | break 158 | end 159 | end 160 | end 161 | 162 | self.focus = nil 163 | win.setVisible(false) 164 | end 165 | 166 | 167 | --- Render the menu bar 168 | --- Redirects the terminal to the menu bar's window 169 | function MenuBar:draw() 170 | term.redirect(self.win) 171 | term.setBackgroundColor(Theme["menu bar background"]) 172 | term.setTextColor(Theme["menu bar text"]) 173 | term.clear() 174 | term.setCursorPos(2, 1) 175 | 176 | for i, item in pairs(MenuBar.items) do 177 | if i == self.focus then 178 | term.setTextColor(Theme["menu bar text focused"]) 179 | term.setBackgroundColor(Theme["menu bar background focused"]) 180 | elseif i == self.flash then 181 | term.setTextColor(Theme["menu bar flash text"]) 182 | term.setBackgroundColor(Theme["menu bar flash background"]) 183 | else 184 | term.setTextColor(Theme["menu bar text"]) 185 | term.setBackgroundColor(Theme["menu bar background"]) 186 | end 187 | 188 | term.write(" " .. item.name .. " ") 189 | end 190 | end 191 | 192 | 193 | --- Returns the min and max x location for a particular menu item. 194 | --- Returns nil if the index isn't found. 195 | function MenuBar:itemLocation(index) 196 | local minX = 2 197 | local maxX = -1 198 | 199 | for i, item in pairs(MenuBar.items) do 200 | maxX = minX + item.name:len() + 1 201 | if index == i then 202 | return minX, maxX 203 | end 204 | minX = maxX + 1 205 | end 206 | 207 | return nil 208 | end 209 | 210 | 211 | --- Called when a click event is received. 212 | function MenuBar:click(x, y) 213 | if y == 1 then 214 | -- Determine the clicked item 215 | local minX = 2 216 | local maxX = -1 217 | local index = -1 218 | 219 | for i, item in pairs(MenuBar.items) do 220 | maxX = minX + item.name:len() + 1 221 | if x >= minX and x <= maxX then 222 | index = i 223 | break 224 | end 225 | minX = maxX + 1 226 | end 227 | 228 | if index ~= -1 then 229 | self:open(index) 230 | end 231 | 232 | return true 233 | end 234 | end 235 | 236 | 237 | --- Called when an event is triggered on the menu bar 238 | function MenuBar:event(event) 239 | if event[1] == "mouse_click" then 240 | return self:click(event[3], event[4]) 241 | end 242 | 243 | return false 244 | end 245 | -------------------------------------------------------------------------------- /src/ui/panel.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Panel 4 | -- 5 | 6 | --- A simple text panel to display some text, that pops up with a close button 7 | Panel = {} 8 | Panel.__index = Panel 9 | 10 | 11 | --- Create a new panel 12 | function Panel.new(...) 13 | local self = setmetatable({}, Panel) 14 | self:setup(...) 15 | return self 16 | end 17 | 18 | 19 | --- Create an error panel. 20 | function Panel.error(...) 21 | local panel = Panel.new() 22 | panel:center(...) 23 | panel:show() 24 | end 25 | 26 | 27 | function Panel:setup() 28 | self.lines = {} 29 | self.width = 0 30 | end 31 | 32 | 33 | function Panel:line(position, ...) 34 | local items = {...} 35 | if #items > 1 then 36 | for _, item in pairs(items) do 37 | self:line(position, item) 38 | end 39 | else 40 | local line = items[1] 41 | if line:len() + 4 > self.width then 42 | self.width = line:len() + 4 43 | end 44 | 45 | table.insert(self.lines, {["text"] = line, ["position"] = position}) 46 | end 47 | end 48 | 49 | 50 | function Panel:center(...) 51 | self:line("center", ...) 52 | end 53 | 54 | 55 | function Panel:left(...) 56 | self:line("left", ...) 57 | end 58 | 59 | 60 | function Panel:right(...) 61 | self:line("right", ...) 62 | end 63 | 64 | 65 | function Panel:empty() 66 | self:line("left", "") 67 | end 68 | 69 | 70 | function Panel:show() 71 | self.height = #self.lines + 2 72 | 73 | local w, h = term.native().getSize() 74 | local x = math.floor(w / 2 - self.width / 2) + 1 75 | local y = math.floor(h / 2 - self.height / 2) 76 | local win = window.create(term.native(), x, y, self.width, self.height) 77 | 78 | term.redirect(win) 79 | term.setCursorBlink(false) 80 | term.setBackgroundColor(Theme["panel background"]) 81 | term.clear() 82 | 83 | -- Close button 84 | term.setCursorPos(1, 1) 85 | term.setTextColor(Theme["panel close text"]) 86 | term.setBackgroundColor(Theme["panel close background"]) 87 | term.write("x") 88 | 89 | -- Lines 90 | term.setTextColor(Theme["panel text"]) 91 | term.setBackgroundColor(Theme["panel background"]) 92 | 93 | for i, line in pairs(self.lines) do 94 | local x = 3 95 | if line.position == "center" then 96 | x = math.floor(self.width / 2 - line.text:len() / 2) + 1 97 | elseif line.position == "right" then 98 | x = self.width - line.text:len() - 1 99 | end 100 | 101 | term.setCursorPos(x, i + 1) 102 | term.write(line.text) 103 | end 104 | 105 | -- Wait for a click on the close button or outside the panel 106 | while true do 107 | local event = {os.pullEventRaw()} 108 | 109 | if event[1] == "terminate" then 110 | os.queueEvent("exit") 111 | break 112 | elseif event[1] == "mouse_click" then 113 | local cx = event[3] 114 | local cy = event[4] 115 | if cx == x and cy == y then 116 | break 117 | else 118 | local horizontal = cx < x or cx >= x + self.width 119 | local vertical = cy < y or cy >= y + self.height 120 | if horizontal or vertical then 121 | break 122 | end 123 | end 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /src/ui/responder.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Menu Bar Responder 4 | -- 5 | 6 | --- Triggers an appropriate response to different menu item trigger events. 7 | Responder = {} 8 | Responder.__index = Responder 9 | 10 | 11 | --- Create a new responder 12 | function Responder.new(...) 13 | local self = setmetatable({}, Responder) 14 | self:setup(...) 15 | return self 16 | end 17 | 18 | 19 | function Responder:setup(controller) 20 | self.controller = controller 21 | end 22 | 23 | 24 | function Responder.toCamelCase(identifier) 25 | identifier = identifier:lower() 26 | 27 | local first = true 28 | local result = "" 29 | for word in identifier:gmatch("[^%s]+") do 30 | if first then 31 | result = result .. word:lower() 32 | first = false 33 | else 34 | result = result .. word:sub(1, 1):upper() .. word:sub(2):lower() 35 | end 36 | end 37 | 38 | -- Special case the new function 39 | if result == "new" then 40 | result = "newFile" 41 | end 42 | 43 | return result 44 | end 45 | 46 | 47 | function Responder:trigger(itemName) 48 | local name = Responder.toCamelCase(itemName) 49 | if self[name] then 50 | self[name](self) 51 | return true 52 | end 53 | 54 | return false 55 | end 56 | 57 | 58 | function Responder:about() 59 | local panel = Panel.new() 60 | panel:center("Mimic " .. Global.version) 61 | panel:empty() 62 | panel:center("Made by GravityScore") 63 | panel:show() 64 | end 65 | 66 | 67 | function Responder:newFile() 68 | -- Open a new tab 69 | self.controller.tabBar:create() 70 | self.controller.tabBar:switch(self.controller.tabBar:openCount()) 71 | end 72 | 73 | 74 | function Responder:open() 75 | local dialogue = FileDialogue.new("Select a file to open...") 76 | local path = dialogue:show() 77 | if path and not fs.isDir(path) then 78 | if not self.controller.tabBar:current():isUnedited() then 79 | self:newFile() 80 | end 81 | 82 | self.controller.tabBar:current():edit(path) 83 | end 84 | end 85 | 86 | 87 | function Responder:quit() 88 | os.queueEvent("exit") 89 | end 90 | 91 | 92 | -- --- Called when the save item is triggered. 93 | -- function Responder.save(controller) 94 | -- local editor = controller.tabBar:current() 95 | -- if not editor.path then 96 | -- local path = Popup.filePath("Specify a save path...") 97 | -- if not path then 98 | -- -- User canceled, abort saving 99 | -- return 100 | -- end 101 | 102 | -- editor.path = path 103 | -- end 104 | 105 | -- editor:save() 106 | -- end 107 | 108 | 109 | -- --- Called when the save as item is triggered. 110 | -- function Responder.saveAs(controller) 111 | -- local path = Popup.filePath("Specify a save path...") 112 | -- if not path then 113 | -- -- User canceled, abort saving 114 | -- return 115 | -- end 116 | 117 | -- editor:save(path) 118 | -- end 119 | 120 | 121 | -- --- Called when the open item is triggered. 122 | -- function Responder.open(controller) 123 | -- local path = Popup.filePath("Specify a file to open...") 124 | -- if not path then 125 | -- -- Canceled 126 | -- return 127 | -- end 128 | 129 | -- controller.tabBar:create() 130 | -- controller.tabBar:switch(controller.tabBar:openCount()) 131 | -- controller.tabBar:current():edit(path) 132 | -- end 133 | 134 | 135 | -- --- Called when a menu item trigger event occurs. 136 | -- function Responder.trigger(controller, item) 137 | -- -- File menu 138 | -- if item == "New" then 139 | -- controller.tabBar:create() 140 | -- controller.tabBar:switch(controller.tabBar:openCount()) 141 | -- elseif item == "Open" then 142 | -- Responder.open(controller) 143 | -- elseif item == "Save" then 144 | -- Responder.save(controller) 145 | -- elseif item == "Save as" then 146 | -- Responder.saveAs(controller) 147 | -- elseif item == "Quit" then 148 | -- os.queueEvent("exit") 149 | -- end 150 | 151 | -- return true 152 | -- end 153 | 154 | 155 | -- --- Called when an event occurs. 156 | -- function Responder.event(controller, event) 157 | -- if event[1] == "menu item trigger" then 158 | -- return Responder.trigger(controller, event[2]) 159 | -- end 160 | 161 | -- return false 162 | -- end 163 | -------------------------------------------------------------------------------- /src/ui/tab.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Content Manager 4 | -- 5 | 6 | --- Manages a collection of content windows, one for each tab. 7 | ContentManager = {} 8 | ContentManager.__index = ContentManager 9 | 10 | 11 | --- Creates a new content manager. 12 | function ContentManager.new(...) 13 | local self = setmetatable({}, ContentManager) 14 | self:setup(...) 15 | return self 16 | end 17 | 18 | 19 | function ContentManager:setup() 20 | self.contents = {} 21 | self.current = 1 22 | end 23 | 24 | 25 | --- Creates a new content window at the given index. 26 | function ContentManager:create(index) 27 | if not index then 28 | index = #self.contents + 1 29 | end 30 | 31 | local content = Content.new() 32 | table.insert(self.contents, index, content) 33 | end 34 | 35 | 36 | --- Switches to the content window at `index`. 37 | function ContentManager:switch(index) 38 | if self.contents[index] then 39 | self:hideAll() 40 | self.current = index 41 | self.contents[self.current]:show() 42 | end 43 | end 44 | 45 | 46 | --- Deletes the content window at the given index. 47 | function ContentManager:close(index) 48 | if not index then 49 | index = self.current 50 | end 51 | 52 | if index <= #self.contents then 53 | table.remove(self.contents, index) 54 | 55 | if index <= self.current then 56 | local newIndex = math.max(1, self.current - 1) 57 | self.current = newIndex 58 | 59 | local name = self.contents[self.current]:name() 60 | os.queueEvent("tab_bar_switch", newIndex, name) 61 | end 62 | end 63 | end 64 | 65 | 66 | --- Returns a list of names for each content window, in order. 67 | function ContentManager:getTabNames() 68 | local names = {} 69 | for _, content in pairs(self.contents) do 70 | table.insert(names, content:name()) 71 | end 72 | 73 | return names 74 | end 75 | 76 | 77 | --- Shows the current content window. 78 | function ContentManager:show() 79 | self.contents[self.current]:show() 80 | end 81 | 82 | 83 | --- Hides all content windows. 84 | function ContentManager:hideAll() 85 | for i, _ in pairs(self.contents) do 86 | self.contents[i]:hide() 87 | end 88 | end 89 | 90 | 91 | --- Triggers the given event on the current content window. 92 | function ContentManager:event(event) 93 | self.contents[self.current]:event(event) 94 | end 95 | 96 | 97 | 98 | -- 99 | -- Tab Bar 100 | -- 101 | 102 | -- Delegate must respond to: 103 | -- getTabNames() 104 | 105 | --- The Tab Bar GUI element. 106 | TabBar = {} 107 | TabBar.__index = TabBar 108 | 109 | --- The y location of the tab bar. 110 | TabBar.y = 2 111 | 112 | --- The maximum number of tabs. 113 | TabBar.maxTabs = 4 114 | 115 | --- The maximum length of a tab's name. 116 | local w, h = term.getSize() 117 | TabBar.maxTabWidth = math.floor(w / TabBar.maxTabs) 118 | 119 | 120 | --- Creates a new tab bar. 121 | function TabBar.new(...) 122 | local self = setmetatable({}, TabBar) 123 | self:setup(...) 124 | return self 125 | end 126 | 127 | 128 | --- Returns a sanitised tab bar name. 129 | function TabBar.sanitiseName(name) 130 | local new = name:gsub("^%s*(.-)%s*$", "%1") 131 | if new:len() > TabBar.maxTabWidth then 132 | new = new:sub(1, TabBar.maxTabWidth):gsub("^%s*(.-)%s*$", "%1") 133 | end 134 | 135 | if new:sub(-1, -1) == "." then 136 | new = new:sub(1, -2):gsub("^%s*(.-)%s*$", "%1") 137 | end 138 | 139 | return new:gsub("^%s*(.-)%s*$", "%1") 140 | end 141 | 142 | 143 | function TabBar:setup(delegate) 144 | local w = term.getSize() 145 | 146 | self.delegate = delegate 147 | self.win = window.create(term.native(), 1, TabBar.y, w, 1, false) 148 | self.current = 1 149 | end 150 | 151 | 152 | --- Renders the tab bar. 153 | function TabBar:draw() 154 | local names = self.delegate:getTabNames() 155 | 156 | term.redirect(self.win) 157 | self.win.setVisible(true) 158 | 159 | term.setBackgroundColor(Theme["tab bar background"]) 160 | term.setTextColor(Theme["tab bar text focused"]) 161 | term.clear() 162 | term.setCursorPos(1, 1) 163 | 164 | for i, name in pairs(names) do 165 | local actualName = TabBar.sanitiseName(name) 166 | 167 | term.setBackgroundColor(Theme["tab bar background blurred"]) 168 | term.setTextColor(Theme["tab bar text blurred"]) 169 | term.write(" ") 170 | 171 | if i == self.current then 172 | term.setBackgroundColor(Theme["tab bar background focused"]) 173 | term.setTextColor(Theme["tab bar text focused"]) 174 | end 175 | 176 | term.write(actualName) 177 | 178 | if i == self.current and #names > 1 then 179 | term.setBackgroundColor(Theme["tab bar background close"]) 180 | term.setTextColor(Theme["tab bar text close"]) 181 | term.write("x") 182 | else 183 | term.write(" ") 184 | end 185 | end 186 | 187 | if #names < TabBar.maxTabs then 188 | term.setBackgroundColor(Theme["tab bar background blurred"]) 189 | term.setTextColor(Theme["tab bar text blurred"]) 190 | term.write(" + ") 191 | end 192 | end 193 | 194 | 195 | --- Makes the tab at the given index active. 196 | function TabBar:switch(index) 197 | self.current = index 198 | end 199 | 200 | 201 | --- Returns the appropriate action for the tab bar if 202 | --- clicked at the given location. 203 | --- The first argument `action` is either "switch", "close", or "create" 204 | --- Switch and close return an index that indicates which tab to switch 205 | --- to or close. 206 | function TabBar:determineClickedTab(x, y) 207 | local index, action = nil, nil 208 | 209 | if y == 1 then 210 | local names = self.delegate:getTabNames() 211 | local currentX = 2 212 | 213 | for i, name in pairs(names) do 214 | local actualName = TabBar.sanitiseName(name) 215 | local endX = currentX + actualName:len() - 1 216 | 217 | if x >= currentX and x <= endX then 218 | index = i 219 | action = "switch" 220 | elseif x == endX + 1 and i == self.current and #names > 1 then 221 | index = i 222 | action = "close" 223 | end 224 | 225 | currentX = endX + 3 226 | end 227 | 228 | if x == currentX and #names < TabBar.maxTabs then 229 | action = "create" 230 | end 231 | end 232 | 233 | return action, index 234 | end 235 | 236 | 237 | --- Called when the mouse is clicked. 238 | function TabBar:click(button, x, y) 239 | local action, index = self:determineClickedTab(x, y) 240 | 241 | local cancel = false 242 | if y == 1 then 243 | cancel = true 244 | end 245 | 246 | if action then 247 | local names = self.delegate:getTabNames() 248 | 249 | if action == "switch" then 250 | os.queueEvent("tab_bar_switch", index, names[index]) 251 | elseif action == "create" then 252 | os.queueEvent("tab_bar_create") 253 | elseif action == "close" and #names > 1 then 254 | os.queueEvent("tab_bar_close", index) 255 | end 256 | end 257 | 258 | return cancel 259 | end 260 | 261 | 262 | --- Called when an event occurs. 263 | function TabBar:event(event) 264 | local cancel = false 265 | 266 | if event[1] == "mouse_click" then 267 | cancel = self:click(event[2], event[3], event[4]) 268 | end 269 | 270 | return cancel 271 | end 272 | 273 | 274 | 275 | -- 276 | -- Content Manager and Tab Bar Link 277 | -- 278 | 279 | --- Synchronises the tab bar and content manager to maintain 280 | --- a common current tab. 281 | ContentTabLink = {} 282 | ContentTabLink.__index = ContentTabLink 283 | 284 | 285 | --- Copies from source into destination. 286 | local function copyTable(source, destination) 287 | for key, value in pairs(source) do 288 | destination[key] = value 289 | end 290 | end 291 | 292 | 293 | --- Converts a mouse event's y coordinate to be relative to the 294 | --- given starting y. 295 | local function localiseEvent(event, startY) 296 | local localised = {} 297 | copyTable(event, localised) 298 | 299 | local isMouseClick = localised[1] == "mouse_click" 300 | local isMouseDrag = localised[1] == "mouse_drag" 301 | local isMouseScroll = localised[1] == "mouse_scroll" 302 | if isMouseClick or isMouseDrag or isMouseScroll then 303 | localised[4] = localised[4] - startY + 1 304 | end 305 | 306 | return localised 307 | end 308 | 309 | 310 | --- Creates a new tab bar and content manager link. 311 | function ContentTabLink.new(...) 312 | local self = setmetatable({}, ContentTabLink) 313 | self:setup(...) 314 | return self 315 | end 316 | 317 | 318 | function ContentTabLink:setup() 319 | self.contentManager = ContentManager.new() 320 | self.tabBar = TabBar.new(self.contentManager) 321 | 322 | local index = #self.contentManager.contents + 1 323 | self.contentManager:create(index) 324 | 325 | local name = self.contentManager.contents[index]:name() 326 | os.queueEvent("tab_bar_switch", index, name) 327 | end 328 | 329 | 330 | --- Returns the current tab. 331 | function ContentTabLink:current() 332 | return self.contentManager.contents[self.contentManager.current] 333 | end 334 | 335 | 336 | --- Returns the number of tabs currently open. 337 | function ContentTabLink:openCount() 338 | return #self.contentManager.contents 339 | end 340 | 341 | 342 | --- Renders both the tab bar and current content window. 343 | function ContentTabLink:draw() 344 | self.tabBar:draw() 345 | self.contentManager.contents[self.contentManager.current]:draw() 346 | end 347 | 348 | 349 | --- Switches the current tab to the given index. 350 | function ContentTabLink:switch(index) 351 | self.contentManager:switch(index) 352 | self.tabBar:switch(index) 353 | self.tabBar:draw() 354 | end 355 | 356 | 357 | --- Closes the tab at the given index. 358 | function ContentTabLink:close(index) 359 | self.contentManager:close() 360 | self.tabBar:draw() 361 | end 362 | 363 | 364 | --- Creates a new tab at the end of the tab list. 365 | function ContentTabLink:create() 366 | self.contentManager:create() 367 | self.tabBar:draw() 368 | end 369 | 370 | 371 | --- Returns true if the event should be passed to the content window. 372 | function ContentTabLink:isEventValid(event) 373 | local isMouseClick = event[1] == "mouse_click" 374 | local isMouseDrag = event[1] == "mouse_drag" 375 | local isMouseScroll = event[1] == "mouse_scroll" 376 | if isMouseClick or isMouseDrag or isMouseScroll then 377 | if event[3] < 1 or event[4] < 1 then 378 | return false 379 | end 380 | end 381 | 382 | return true 383 | end 384 | 385 | 386 | --- Called when an event occurs. 387 | function ContentTabLink:event(event) 388 | if event[1] == "tab_bar_switch" then 389 | self:switch(event[2]) 390 | elseif event[1] == "tab_bar_close" then 391 | self:close(event[2]) 392 | elseif event[1] == "tab_bar_create" then 393 | self:create() 394 | else 395 | -- Trigger an event on the current content window 396 | local cancel = false 397 | 398 | local tabEvent = localiseEvent(event, TabBar.y) 399 | if not cancel and self:isEventValid(tabEvent) then 400 | cancel = self.tabBar:event(tabEvent) 401 | end 402 | 403 | local contentEvent = localiseEvent(event, Content.startY) 404 | if not cancel and self:isEventValid(contentEvent) then 405 | cancel = self.contentManager:event(contentEvent) 406 | end 407 | 408 | return cancel 409 | end 410 | 411 | return false 412 | end 413 | -------------------------------------------------------------------------------- /src/ui/textfield.lua: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | -- Text Field 4 | -- 5 | 6 | --- An editable text field. 7 | TextField = {} 8 | TextField.__index = TextField 9 | 10 | 11 | --- Create a new text field. 12 | function TextField.new(...) 13 | local self = setmetatable({}, TextField) 14 | self:setup(...) 15 | return self 16 | end 17 | 18 | 19 | function TextField:setup(x, y, textColor, backgroundColor) 20 | self.x = x 21 | self.y = y 22 | self.backgroundColor = backgroundColor 23 | self.textColor = textColor 24 | self.text = "" 25 | 26 | self.scroll = 0 27 | self.cursor = 1 28 | 29 | local w, h = term.native().getSize() 30 | self.width = w - self.x 31 | self.maximumLength = -1 32 | self.placeholderText = nil 33 | self.placeholderColor = colors.black 34 | self.eventCallback = nil 35 | end 36 | 37 | 38 | --- Specify negative values as an offset from the right computer edge. 39 | function TextField:setWidth(w) 40 | if w < 1 then 41 | local width = term.native().getSize() 42 | w = width + w 43 | end 44 | 45 | self.width = w 46 | end 47 | 48 | 49 | function TextField:setLength(length) 50 | self.maximumLength = length 51 | end 52 | 53 | 54 | function TextField:setPlaceholder(text, color) 55 | self.placeholderText = text 56 | self.placeholderColor = color 57 | end 58 | 59 | 60 | function TextField:setCallback(callback) 61 | self.eventCallback = callback 62 | end 63 | 64 | 65 | function TextField:draw() 66 | term.setBackgroundColor(self.backgroundColor) 67 | term.setTextColor(self.textColor) 68 | 69 | term.setCursorPos(self.x, self.y) 70 | term.write(string.rep(" ", self.width)) 71 | term.setCursorPos(self.x, self.y) 72 | term.write(self.text:sub(self.scroll + 1, self.scroll + self.width)) 73 | 74 | if self.text:len() == 0 and self.placeholderText then 75 | term.setTextColor(self.placeholderColor) 76 | term.write(self.placeholderText:sub(1, self.width)) 77 | end 78 | end 79 | 80 | 81 | function TextField:left() 82 | if self.cursor > 1 then 83 | self.cursor = self.cursor - 1 84 | elseif self.scroll > 0 then 85 | self.scroll = self.scroll - 1 86 | end 87 | end 88 | 89 | 90 | function TextField:right() 91 | if self.text:len() < self.width then 92 | if self.cursor <= self.text:len() then 93 | self.cursor = self.cursor + 1 94 | end 95 | else 96 | if self.cursor < self.width then 97 | self.cursor = self.cursor + 1 98 | elseif self.scroll + self.cursor < self.text:len() + 1 then 99 | self.scroll = self.scroll + 1 100 | end 101 | end 102 | end 103 | 104 | 105 | function TextField:backspace() 106 | if self.cursor + self.scroll > 1 then 107 | local before = self.text:sub(1, self.cursor + self.scroll - 2) 108 | local after = self.text:sub(self.cursor + self.scroll) 109 | self.text = before .. after 110 | end 111 | 112 | self:left() 113 | if self.cursor == 1 and self.scroll > 0 then 114 | self.cursor = 2 115 | self.scroll = self.scroll - 1 116 | end 117 | end 118 | 119 | 120 | function TextField:key(key) 121 | if key == keys.enter then 122 | return true 123 | elseif key == keys.left then 124 | self:left() 125 | elseif key == keys.right then 126 | self:right() 127 | elseif key == keys.backspace then 128 | self:backspace() 129 | end 130 | end 131 | 132 | 133 | function TextField:char(character) 134 | local before = self.text:sub(1, self.cursor + self.scroll) 135 | local after = self.text:sub(self.cursor + self.scroll + 1) 136 | self.text = before .. character .. after 137 | self:right() 138 | end 139 | 140 | 141 | function TextField:click(x, y) 142 | if y == self.y and x >= self.x and x < self.x + self.width then 143 | local click = x - self.x + 1 144 | local textWidth = self.text:len() - self.scroll + 1 145 | self.cursor = math.min(click, textWidth) 146 | end 147 | end 148 | 149 | 150 | function TextField:moveToEnd() 151 | if self.text:len() < self.width then 152 | -- End on screen 153 | self.scroll = 0 154 | self.cursor = self.text:len() + 1 155 | else 156 | -- Off screen 157 | self.scroll = self.text:len() - self.width + 1 158 | self.cursor = self.width 159 | end 160 | end 161 | 162 | 163 | function TextField:show() 164 | local path = nil 165 | 166 | while true do 167 | term.setCursorBlink(true) 168 | self:draw() 169 | 170 | term.setTextColor(self.textColor) 171 | term.setCursorPos(self.x + self.cursor - 1, self.y) 172 | 173 | local event = {os.pullEventRaw()} 174 | 175 | if event[1] == "terminate" then 176 | os.queueEvent("exit") 177 | break 178 | elseif event[1] == "key" then 179 | if self:key(event[2]) then 180 | path = self.text 181 | break 182 | end 183 | elseif event[1] == "char" then 184 | self:char(event[2]) 185 | elseif event[1] == "mouse_click" or event[1] == "mouse_drag" then 186 | self:click(event[3], event[4] - self.y + 2) 187 | end 188 | 189 | if self.eventCallback then 190 | local newText = self.eventCallback(event) 191 | if newText then 192 | self.text = newText 193 | self:moveToEnd() 194 | end 195 | end 196 | end 197 | 198 | term.setCursorBlink(false) 199 | return path 200 | end 201 | --------------------------------------------------------------------------------