editors.lua 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. -- editor is a floating pane of editable code that accepts keyboard input
  2. local m
  3. m = {} -- stores functions under keys and also all editors in a list
  4. m.__index = m
  5. m.active = nil -- reference to the editor that receives text input
  6. m.font = lovr.graphics.newFont('ubuntu-mono.ttf', 20)
  7. m.font:setPixelDensity(1)
  8. m.font_width = m.font:getWidth(' ')
  9. m.font_height = m.font:getHeight()
  10. local buffer = require'buffer'
  11. local keymapping = {
  12. buffer = {
  13. ['up'] = 'moveUp',
  14. ['down'] = 'moveDown',
  15. ['volume_down'] = 'moveLeft',
  16. ['volume_up'] = 'moveRight',
  17. ['left'] = 'moveLeft',
  18. ['ctrl+up'] = 'moveJumpUp',
  19. ['ctrl+down'] = 'moveJumpDown',
  20. ['ctrl+left'] = 'moveJumpLeft',
  21. ['ctrl+right'] = 'moveJumpRight',
  22. ['right'] = 'moveRight',
  23. ['home'] = 'moveHome',
  24. ['end'] = 'moveEnd',
  25. ['pageup'] = 'movePageUp',
  26. ['pagedown'] = 'movePageDown',
  27. ['ctrl+home'] = 'moveJumpHome',
  28. ['ctrl+end'] = 'moveJumpEnd',
  29. ['shift+up'] = 'selectUp',
  30. ['alt+shift+up'] = 'selectJumpUp',
  31. ['shift+down'] = 'selectDown',
  32. ['alt+shift+down'] = 'selectJumpDown',
  33. ['shift+left'] = 'selectLeft',
  34. ['ctrl+shift+left'] = 'selectJumpLeft',
  35. ['ctrl+shift+right'] = 'selectJumpRight',
  36. ['shift+right'] = 'selectRight',
  37. ['shift+home'] = 'selectHome',
  38. ['shift+end'] = 'selectEnd',
  39. ['shift+pageup'] = 'selectPageUp',
  40. ['shift+pagedown'] = 'selectPageDown',
  41. ['tab'] = 'insertTab',
  42. ['return'] = 'breakLine',
  43. ['enter'] = 'breakLine',
  44. ['delete'] = 'deleteRight',
  45. ['backspace'] = 'deleteLeft',
  46. ['ctrl+backspace'] = 'deleteWord',
  47. ['ctrl+x'] = 'cutText',
  48. ['ctrl+c'] = 'copyText',
  49. ['ctrl+v'] = 'pasteText',
  50. },
  51. macros = {
  52. ['ctrl+o'] = function(self) self:listFiles() end,
  53. ['ctrl+s'] = function(self) self:saveFile(self.path) end,
  54. ['ctrl+h'] = function(self) m.new(120, 60):openHelp() end,
  55. ['ctrl+shift+enter'] = function(self) self:execLine() end,
  56. ['ctrl+shift+return'] = function(self) self:execLine() end,
  57. ['ctrl+shift+home'] = function(self) self:center() end,
  58. },
  59. }
  60. local palette = { -- mariana color palette
  61. background = 0x1f212b, --editor background
  62. cursorline = 0x4c5863, --cursor background
  63. caret = 0xeda550, --cursor
  64. whitespace = 0x111111, --spaces, newlines, tabs, and carriage returns
  65. comment = 0xa6acb9, --either multi-line or single-line comments
  66. string_start = 0x5fb4b4, --starts and ends of a string. There will be no non-string tokens between these two.
  67. string_end = 0x5fb4b4,
  68. string = 0x99c794, --part of a string that isn't an escape
  69. escape = 0xc695c6, --a string escape, like \n, only found inside strings
  70. keyword = 0xc695c6, --keywords. Like "while", "end", "do", etc
  71. value = 0xec5f66, --special values. Only true, false, and nil
  72. ident = 0x6699cc, --identifier. Variables, function names, etc
  73. number = 0xf9ae58, --numbers, including both base 10 (and scientific notation) and hexadecimal
  74. symbol = 0xa6acb9, --symbols, like brackets, parenthesis, ., .., etc
  75. vararg = 0xec5f66, --...
  76. operator = 0xf97b58, --operators, like +, -, %, =, ==, >=, <=, ~=, etc
  77. label_start = 0x5fb4b4, --the starts and ends of labels. Always equal to '::'. Between them there can only be whitespace and label tokens.
  78. label_end = 0x5fb4b4,
  79. label = 0xc695c6, --basically an ident between a label_start and label_end.
  80. unidentified = 0xec5f66, --anything that isn't one of the above tokens. Consider them errors. Invalid escapes are also unidentified.
  81. selection = 0x4c5863,
  82. active_bar = 0xf97b58, -- editor sidebar
  83. disabled_bar = 0x4c5863,
  84. }
  85. local function drawRectangle(pass, col, row, columns, tokenType)
  86. local color = palette[tokenType] or tonumber(tokenType)
  87. pass:setColor(color)
  88. -- rectangle in text-coordinates
  89. local width = columns * m.font_width
  90. local height = m.font_height
  91. local x = col * m.font_width
  92. local y = -row * m.font_height
  93. pass:plane(x + width / 2, y - height / 2, -2, width, height)
  94. end
  95. local function drawToken(pass, text, col, row, tokenType)
  96. local color = palette[tokenType] or 0xFFFFFF
  97. -- in-editor preview of colors in hex format 0xRRGGBBAA
  98. if tokenType == 'number' and text:match('0x%x+') then
  99. drawRectangle(pass, col, row, 2, text)
  100. end
  101. pass:setColor(color)
  102. pass:setFont(m.font)
  103. local x = col * m.font_width
  104. local y = -row * m.font_height
  105. pass:text(text, x,y,0, 1, 0, 1,0,0, 0, 'left','top')
  106. end
  107. function m.new(cols, rows, switchToProjectFn)
  108. local self = setmetatable({}, m)
  109. cols, rows = math.floor(cols), math.floor(rows)
  110. self.width = 1
  111. self.height = 1
  112. self.texture_size = 1024
  113. self.transform = lovr.math.newMat4(0, 1, -1, 1,1,1, 0, 0,1,0)
  114. self.ortho = lovr.math.newMat4()
  115. self.font:setPixelDensity(1)
  116. self.path = ''
  117. self.is_dirty = true
  118. self.switchToProject = switchToProjectFn or function() end
  119. self.buffer = buffer.new(cols, rows, drawToken, drawRectangle)
  120. self.pass = lovr.graphics.newPass()
  121. self:resize(cols, rows)
  122. self:center()
  123. table.insert(m, self)
  124. m.active = self
  125. return self
  126. end
  127. function m:resize(cols, rows)
  128. self.buffer.cols = cols
  129. self.buffer.rows = rows
  130. local status_lines = 1
  131. local texture_width = self.buffer.cols * m.font_width
  132. local texture_height = (self.buffer.rows + status_lines) * m.font_height
  133. self.width = texture_width / 1000
  134. self.height = texture_height / 1000
  135. self.ortho:orthographic(0, texture_width, 0, -texture_height, -10, 10)
  136. self.texture = lovr.graphics.newTexture(texture_width, texture_height, {mipmaps=false})
  137. self.pass:setCanvas(self.texture)
  138. self:refresh()
  139. end
  140. function m:setText(content)
  141. self.buffer:setText(content)
  142. end
  143. function m:center()
  144. local x,y,z, angle, ax,ay,az = 0,0,0, 0, 1,0,0
  145. if lovr.headset then
  146. x,y,z, angle, ax,ay,az = lovr.headset.getPose('head')
  147. end
  148. local headTransform = mat4(x,y,z, angle, ax,ay,az)
  149. local headPosition = vec3(headTransform)
  150. local panePosition = vec3(headTransform:mul(0,0,-1.0))
  151. self.transform:target(panePosition, headPosition)
  152. end
  153. function m:openFile(filename_line)
  154. -- file path can optionally include line number to jump to, like 'main.lua:50'
  155. local filename, linenumber = filename_line:match('([^:]+):([%d]+)')
  156. filename = filename or filename_line
  157. local content = ' '
  158. if lovr.filesystem.isFile(filename) then
  159. content = lovr.filesystem.read(filename)
  160. end
  161. print('file open', filename, 'size', #content)
  162. self.buffer:setText(content)
  163. local coreFile = lovr.filesystem.getRealDirectory(filename) == lovr.filesystem.getSource()
  164. self.buffer:setName((coreFile and 'core! ' or '').. filename)
  165. self.path = filename
  166. self.buffer:jumpToLine(1, 0)
  167. self:refresh()
  168. end
  169. function m:removeFile(file_path)
  170. if lovr.filesystem.isFile(file_path) then
  171. print('deleting', file_path)
  172. lovr.filesystem.remove(file_path)
  173. else
  174. local err = file_path .. ' does not exist'
  175. print('! ' .. err)
  176. error(err)
  177. end
  178. end
  179. function m:openHelp(path)
  180. self:listFiles('help')
  181. end
  182. function m:listFiles(path)
  183. if not path then -- try to use directory path of currently open file
  184. path = m.active and m.active.path:sub(1, (m.active.path:find('/[^/]+$') or 1) - 1) or ''
  185. end
  186. self.buffer:setText('')
  187. self.buffer:setName('FILES')
  188. self.path = ''
  189. --determine parent dir
  190. local previous = ''
  191. local parent = ''
  192. for subpath in path:gmatch('[^/]+/') do
  193. previous = subpath
  194. parent = parent .. previous
  195. end
  196. self.buffer:insertString('-- directory listing\n')
  197. if path ~= '' then
  198. parent = parent:sub(1, #parent - 1)
  199. self.buffer:insertString(string.format('self:listFiles(\'%s\') -- parent dir\n', parent))
  200. end
  201. --list directory items, first subdirectories then files
  202. local last_line = 2
  203. local files = {}
  204. local prefix = path == '' and '.' or path
  205. for _,filename in ipairs(lovr.filesystem.getDirectoryItems(path)) do
  206. local fullpath = prefix .. '/' .. filename
  207. if lovr.filesystem.isDirectory(fullpath) then
  208. self.buffer:insertString(string.format('self:listFiles(\'%s\')\n', fullpath))
  209. last_line = last_line + 1
  210. else
  211. table.insert(files, fullpath)
  212. end
  213. end
  214. if path == '' then -- root dir should only list directories and project-switching
  215. self.buffer:insertString('\n-- run project\n')
  216. for _,projname in ipairs(lovr.filesystem.getDirectoryItems('projects')) do
  217. self.buffer:insertString(string.format("self.switchToProject('%s')\n", projname))
  218. end
  219. else -- non-root dir shall list source/asset files and helpful commands
  220. for _, fullpath in ipairs(files) do
  221. self.buffer:insertString(string.format('self:openFile(\'%s\')\n', fullpath))
  222. end
  223. self.buffer:insertString('\n-- useful commands\n')
  224. self.buffer:insertString('self:openFile(\''..path..'/'..'new_source.lua\') -- new file\n')
  225. self.buffer:insertString('self:removeFile(\''..path..'/'..'unwanted.ext\') -- remove file\n')
  226. end
  227. self.buffer:insertString('\nctrl+shift+enter confirm selection')
  228. self.buffer:jumpToLine(last_line, 0)
  229. self:refresh()
  230. end
  231. function m:saveFile(filename)
  232. local bytes
  233. filename = filename or self.path
  234. bytes = lovr.filesystem.write(filename, self.buffer:getText())
  235. self.path = filename
  236. self.buffer:setName(filename)
  237. print('file save', filename, 'size', bytes)
  238. return bytes
  239. end
  240. function m.storeSession(name)
  241. local bytes
  242. local scriptList = {}
  243. table.insert(scriptList, 'return {')
  244. for _, editor in ipairs(m) do
  245. if editor.path then
  246. table.insert(scriptList, ' {')
  247. table.insert(scriptList, string.format(" path = '%s:%d',", tostring(editor.path), editor.buffer.cursor.y))
  248. table.insert(scriptList, string.format(' pose = {%s}', table.concat({editor.transform:unpack()}, ', ')))
  249. table.insert(scriptList, ' },\n')
  250. end
  251. end
  252. table.insert(scriptList, '}\n')
  253. local content = table.concat(scriptList, '\n')
  254. bytes = lovr.filesystem.write(name .. '.lua', content)
  255. end
  256. function m.restoreSession(name)
  257. -- close editors, discarding potentially unsaved changes!
  258. for i = #m, 1, -1 do
  259. table.remove(m, i)
  260. end
  261. -- load in the stored session
  262. m.active = nil
  263. package.loaded[name] = nil
  264. local ok, session = pcall(require, name)
  265. if ok then
  266. for _, e in ipairs(session) do
  267. local editor
  268. editor = m.new(1, 1)
  269. editor:openFile(e.path)
  270. editor.transform:set(unpack(e.pose))
  271. end
  272. else
  273. print(session)
  274. end
  275. end
  276. function m:draw(pass)
  277. -- oriented towards -z so that mat4.target() works as expected
  278. pass:push()
  279. pass:transform(self.transform)
  280. -- background and side handle
  281. pass:setColor(palette.background)
  282. local margin = 0.02
  283. pass:plane(0,0, margin / 4, self.width + margin, self.height + margin, math.pi, 0,1,0)
  284. pass:setColor(self == m.active and palette.active_bar or palette.disabled_bar)
  285. local thickness = 0.02
  286. local handleX = self.width/2 + thickness / 2 + margin
  287. local handleY = 0
  288. pass:box(handleX, handleY, 0, thickness, self.height * 0.8, thickness)
  289. -- code rendered from texture
  290. pass:setColor(1,1,1)
  291. pass:setMaterial(self.texture)
  292. pass:plane(0,0,0, -self.width, self.height)
  293. pass:setMaterial()
  294. pass:pop()
  295. end
  296. function m:drawToTexture()
  297. if not self.texture then
  298. self.texture = lovr.graphics.newTexture(self.texture_size, self.texture_size, {mipmaps=false})
  299. self.pass:setCanvas(self.texture)
  300. end
  301. self.pass:reset()
  302. self.pass:setDepthWrite(false)
  303. self.pass:setViewPose(1, mat4())
  304. self.pass:setProjection(1, self.ortho)
  305. self.pass:setColor(palette.background)
  306. self.pass:fill()
  307. self.buffer:drawCode(self.pass)
  308. return self.pass
  309. end
  310. function m:refresh()
  311. self.is_dirty = true
  312. end
  313. -- key handling
  314. function m.keypressed(k)
  315. if m.active then
  316. -- execute buffer-mapped action for keypress
  317. if keymapping.buffer[k] then
  318. m.active.buffer[keymapping.buffer[k]](m.active.buffer)
  319. end
  320. -- execute macros
  321. if keymapping.macros[k] then
  322. print('executing macro for', k)
  323. keymapping.macros[k](m.active)
  324. end
  325. m.active:refresh()
  326. end
  327. if k == 'ctrl+tab' then -- select next editor
  328. local lastEditor = m[#m]
  329. for _, editor in ipairs(m) do
  330. if editor == m.active then break end
  331. lastEditor = editor
  332. end
  333. m.active = lastEditor
  334. elseif k == 'ctrl+w' then -- close current editor
  335. local lastEditor
  336. for i = #m, 1, -1 do
  337. if m[i] == m.active then
  338. table.remove(m, i)
  339. else
  340. lastEditor = lastEditor or m[i]
  341. end
  342. end
  343. m.active = lastEditor
  344. elseif k == 'ctrl+shift+s' then
  345. m.storeSession('indeck-session')
  346. elseif k == 'ctrl+shift+l' then
  347. m.restoreSession('indeck-session')
  348. end
  349. end
  350. function m.textinput(k)
  351. if m.active then
  352. m.active.buffer:insertCharacter(k)
  353. m.active:refresh()
  354. end
  355. end
  356. -- code execution environment
  357. function m:execLine()
  358. local line = self.buffer:getCursorLine()
  359. local commentPos = line:find("%s+%-%-")
  360. if commentPos then
  361. line = line:sub(1, commentPos - 1)
  362. end
  363. local success, result = self:execUnsafely(line)
  364. if success then
  365. self.buffer.statusLine = 'ok' .. ' > ' .. (result or "")
  366. else
  367. print('! line exec error:', result)
  368. self.buffer.statusLine = 'fail' .. ' > ' .. (result or "")
  369. end
  370. end
  371. function m:execUnsafely(code)
  372. local userCode, err = loadstring(code)
  373. if not userCode then
  374. print('code error:', err)
  375. return false, err
  376. end
  377. -- set up current scope environment for user code execution
  378. local environment = {self=self, print=print, require=require, lovr=lovr}
  379. setfenv(userCode, environment)
  380. -- timber!
  381. return pcall(userCode)
  382. end
  383. return m