buffer.lua 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. local m = {} -- platform independant text buffer
  2. local lexer = require'lua-lexer'
  3. local clipboard = '' -- shared between buffer instances
  4. --helper functions
  5. local function sanitize(text)
  6. text = text:gsub('[\192-\255][\128-\191]*', '?') -- remove non-ASCII chars which would cause crash
  7. text = text:gsub('\t', '') -- tabs are handled with buffer:insertTab()
  8. return text
  9. end
  10. local insertCharAt = function(text, c, pos)
  11. c = sanitize(c)
  12. local first = text:sub(1, pos)
  13. local last = text:sub(pos + 1)
  14. return first .. c .. last, #c
  15. end
  16. local repeatN = function(n, f, ...)
  17. for i=1,n do f(...) end
  18. end
  19. function m.new(cols, rows, drawToken, drawRectangle, initialText)
  20. local buffer = {
  21. -- all coordinates in text space (integers)
  22. cols = cols,
  23. rows = rows,
  24. name = '',
  25. cursor = {x=0, y=1}, -- x is 0-indexed, y is 1-indexed
  26. selection = {x=0, y=1}, -- x is 0-indexed, y is 1-indexed
  27. -- selected text spans between cursor and selection marker
  28. scroll = {x=5, y=0}, -- both 0-indexed
  29. lines = {}, -- text broken in lines
  30. lexed = {}, -- text lines broken into tokens
  31. drawToken = drawToken,
  32. drawRectangle = drawRectangle,
  33. -- 'public' api
  34. getText = function(self)
  35. return table.concat(self.lines, '\n')
  36. end,
  37. getCursorLine = function(self)
  38. return self.lines[self.cursor.y]
  39. end,
  40. setName = function(self, name)
  41. self.name = name:gsub('%c', '')
  42. end,
  43. setText = function(self, text)
  44. text = sanitize(text)
  45. self.lines = {}
  46. self.lexed = lexer(text)
  47. for _, line in ipairs(self.lexed) do
  48. local lineStrings = {}
  49. for _, token in ipairs(line) do
  50. table.insert(lineStrings, token.data)
  51. end
  52. table.insert(self.lines, table.concat(lineStrings, ''))
  53. end
  54. self:updateView()
  55. self:deselect()
  56. end,
  57. drawCode = function(self, pass)
  58. local linesToDraw = math.min(self.rows, #(self.lexed)-self.scroll.y)
  59. local selectionFrom, selectionTo = self:selectionSpan()
  60. local selectionWidth = (selectionTo.x + selectionTo.y * self.cols) - (selectionFrom.x + selectionFrom.y * self.cols)
  61. -- highlight cursor line
  62. if selectionWidth == 0 then
  63. self.drawRectangle(pass, -1, self.cursor.y - self.scroll.y, self.cols + 2, 'cursorline')
  64. end
  65. -- selection
  66. local x, y = selectionFrom.x, selectionFrom.y
  67. self.drawRectangle(pass, x - self.scroll.x, y - self.scroll.y, selectionWidth, 'selection')
  68. selectionWidth = selectionWidth - (self.cols - selectionFrom.x)
  69. while selectionWidth > 0 do
  70. y = y + 1
  71. self.drawRectangle(pass, 0 - self.scroll.x, y - self.scroll.y, selectionWidth, 'selection')
  72. selectionWidth = selectionWidth - self.cols
  73. end
  74. -- file content
  75. for y = 1, linesToDraw do
  76. local x = -self.scroll.x
  77. local currentLine = y + self.scroll.y
  78. -- draw cursor line and caret
  79. if currentLine == self.cursor.y then
  80. self.drawToken(pass, '|', self.cursor.x - self.scroll.x - 0.5, y, 'caret')
  81. end
  82. -- draw single line of text
  83. local lineTokens = self.lexed[currentLine]
  84. for _, token in ipairs(lineTokens) do
  85. self.drawToken(pass, token.data, x, y, token.type)
  86. x = x + #token.data
  87. end
  88. end
  89. -- status line
  90. self.drawToken(pass, self.statusLine, self.cols - #self.statusLine, 0, 'comment')
  91. end,
  92. -- cursor movement
  93. cursorUp = function(self)
  94. self.cursor.y = self.cursor.y - 1
  95. self:updateView()
  96. end,
  97. cursorDown = function(self)
  98. self.cursor.y = self.cursor.y + 1
  99. self:updateView()
  100. end,
  101. cursorJumpUp = function(self)
  102. repeatN(10, self.cursorUp, self)
  103. end,
  104. cursorJumpDown = function(self)
  105. repeatN(10, self.cursorDown, self)
  106. end,
  107. cursorLeft = function(self)
  108. if self.cursor.x == 0 then
  109. if self.cursor.y > 1 then
  110. self:cursorUp()
  111. self:cursorEnd()
  112. else
  113. return false;
  114. end
  115. else
  116. self.cursor.x = self.cursor.x - 1
  117. self:updateView()
  118. end
  119. return true
  120. end,
  121. cursorRight = function(self)
  122. local length = string.len(self.lines[self.cursor.y])
  123. if self.cursor.x == length then
  124. if self.cursor.y < #(self.lines) then
  125. self:cursorDown()
  126. self:cursorHome()
  127. else
  128. return false
  129. end
  130. else
  131. self.cursor.x = self.cursor.x + 1
  132. self:updateView()
  133. end
  134. return true
  135. end,
  136. cursorJumpLeft = function(self)
  137. self:cursorLeft()
  138. local pattern = self:charAtCursor():find('[%d%l%u]') and '[%d%l%u]' or '%s'
  139. self:repeatOverPattern(pattern, self.cursorLeft, self)
  140. end,
  141. cursorJumpRight = function(self)
  142. self:cursorRight()
  143. local pattern = self:charAtCursor():find('[%d%l%u]') and '[%d%l%u]' or '%s'
  144. self:repeatOverPattern(pattern, self.cursorRight, self)
  145. end,
  146. cursorHome = function(self)
  147. self.cursor.x = 0
  148. self:updateView()
  149. end,
  150. cursorEnd = function(self)
  151. self.cursor.x = string.len(self.lines[self.cursor.y])
  152. self:updateView()
  153. end,
  154. cursorPageUp = function(self)
  155. self.cursor.y = self.cursor.y - self.rows
  156. self:updateView()
  157. end,
  158. cursorPageDown = function(self)
  159. self.cursor.y = self.cursor.y + self.rows
  160. self:updateView()
  161. end,
  162. cursorJumpHome = function(self)
  163. self.cursor.x, self.cursor.y = 0, 1
  164. self:updateView()
  165. end,
  166. cursorJumpEnd = function(self)
  167. self.cursor.y = #self.lines
  168. self.cursor.x = #self.lines[self.cursor.y]
  169. self:updateView()
  170. end,
  171. -- inserting and removing characters
  172. insertCharacter = function(self, c)
  173. self:deleteSelection()
  174. local length
  175. self.lines[self.cursor.y], length = insertCharAt(self.lines[self.cursor.y], c, self.cursor.x)
  176. self:lexLine(self.cursor.y)
  177. self.cursor.x = self.cursor.x + length
  178. self:updateView()
  179. self:deselect()
  180. end,
  181. insertTab = function(self)
  182. self:deleteSelection()
  183. self:insertString(' ') -- tab width is adjustable here
  184. self:deselect()
  185. end,
  186. breakLine = function(self, withoutIndent)
  187. self:deleteSelection()
  188. local nl = self.lines[self.cursor.y]
  189. local bef = nl:sub(1,self.cursor.x)
  190. local aft = nl:sub(self.cursor.x + 1, #nl)
  191. local indent = #(bef:match('^%s+') or '')
  192. self.lines[self.cursor.y] = bef
  193. self:lexLine(self.cursor.y)
  194. table.insert(self.lines, self.cursor.y + 1, aft)
  195. table.insert(self.lexed, self.cursor.y + 1, {})
  196. self:lexLine(self.cursor.y + 1)
  197. self:cursorHome()
  198. self:cursorDown()
  199. self:deselect()
  200. if not withoutIndent then
  201. repeatN(indent, self.insertCharacter, self, ' ')
  202. end
  203. end,
  204. deleteRight = function(self)
  205. if self:isSelected() then
  206. self:deleteSelection()
  207. else
  208. local length = string.len(self.lines[self.cursor.y])
  209. if length == self.cursor.x then -- end of line
  210. if self.cursor.y < #self.lines then
  211. -- if we have another line, remove newline by joining lines
  212. local nl = self.lines[self.cursor.y] .. self.lines[self.cursor.y + 1]
  213. self.lines[self.cursor.y] = nl
  214. self:lexLine(self.cursor.y)
  215. table.remove(self.lines, self.cursor.y + 1)
  216. table.remove(self.lexed, self.cursor.y + 1)
  217. end
  218. else -- middle of line, remove char
  219. local nl = self.lines[self.cursor.y]
  220. local bef = nl:sub(1, self.cursor.x)
  221. local aft = nl:sub(self.cursor.x + 2, string.len(nl))
  222. self.lines[self.cursor.y] = bef..aft
  223. self:lexLine(self.cursor.y)
  224. end
  225. end
  226. end,
  227. deleteLeft = function(self)
  228. if self:isSelected() then
  229. self:deleteSelection()
  230. elseif self:cursorLeft() then
  231. self:deleteRight()
  232. end
  233. end,
  234. deleteWord = function(self)
  235. self:deleteLeft()
  236. local pattern = self:charAtCursor():find('[%d%l%u]') and '[%d%l%u]' or '%s'
  237. self:repeatOverPattern(pattern, self.deleteLeft, self)
  238. end,
  239. -- clipboard
  240. cutText = function(self)
  241. if self:isSelected() then
  242. self:copyText()
  243. self:deleteSelection()
  244. elseif #self.lines > 1 then
  245. clipboard = self.lines[self.cursor.y] .. '\n'
  246. table.remove(self.lines, self.cursor.y)
  247. table.remove(self.lexed, self.cursor.y)
  248. else
  249. self.lines[1] = ''
  250. self.lexed[1] = {}
  251. end
  252. self:updateView()
  253. end,
  254. copyText = function(self)
  255. if self:isSelected() then
  256. local selectionFrom, selectionTo = self:selectionSpan()
  257. local lines = {}
  258. for y = selectionFrom.y, selectionTo.y do
  259. local fromX = y == selectionFrom.y and selectionFrom.x or 0
  260. local toX = y == selectionTo.y and selectionTo.x or self.lines[y]:len()
  261. table.insert(lines, self.lines[y]:sub(fromX + 1, toX))
  262. end
  263. clipboard = table.concat(lines, '\n')
  264. else -- copy cursor line
  265. clipboard = self.lines[self.cursor.y] .. '\n'
  266. end
  267. end,
  268. pasteText = function(self)
  269. self:deleteSelection()
  270. self:insertString(clipboard)
  271. self:updateView()
  272. end,
  273. -- helper functions
  274. isSelected = function(self)
  275. return self.selection.x ~= self.cursor.x or self.selection.y ~= self.cursor.y
  276. end,
  277. selectionSpan = function(self)
  278. if self.selection.y * self.cols + self.selection.x < self.cursor.y * self.cols + self.cursor.x then
  279. return self.selection, self.cursor
  280. else
  281. return self.cursor, self.selection
  282. end
  283. end,
  284. deleteSelection = function(self)
  285. if not self:isSelected() then return end
  286. local selectionFrom, selectionTo = self:selectionSpan()
  287. local singleLineChange = selectionFrom.y == selectionTo.y
  288. for y = selectionTo.y, selectionFrom.y, -1 do
  289. if y > selectionFrom.y and y < selectionTo.y then
  290. table.remove(self.lines, y)
  291. table.remove(self.lexed, y)
  292. else
  293. local fromX = y == selectionFrom.y and selectionFrom.x or 0
  294. local toX = y == selectionTo.y and selectionTo.x or self.lines[y]:len()
  295. local bef = self.lines[y]:sub(0, fromX)
  296. local aft = self.lines[y]:sub(toX + 1, self.lines[y]:len())
  297. self.lines[y] = bef .. aft
  298. end
  299. end
  300. self.cursor.x, self.cursor.y = selectionFrom.x, selectionFrom.y
  301. self:deselect()
  302. if singleLineChange then
  303. self:lexLine(self.cursor.y)
  304. else
  305. self:deleteRight()
  306. self:lexAll()
  307. end
  308. self:updateView()
  309. end,
  310. deselect = function(self)
  311. self.selection.x, self.selection.y = self.cursor.x, self.cursor.y
  312. end,
  313. jumpToLine = function(self, lineNumber, columnNumber)
  314. lineNumber = math.min(lineNumber or 1, #self.lines)
  315. columnNumber = math.min(columnNumber or 0, #self.lines[lineNumber] - 1)
  316. self.cursor.x = columnNumber
  317. self.cursor.y = lineNumber
  318. self.scroll.y = math.max(lineNumber - math.floor(self.rows / 2), 0)
  319. self.scroll.x = math.max(columnNumber - math.floor(7 * self.cols / 8), 0)
  320. self:deselect()
  321. end,
  322. insertString = function(self, str)
  323. local singleLineChange = true
  324. for c in str:gmatch('.') do
  325. if c == '\n' then
  326. singleLineChange = false
  327. self:deselect()
  328. self:breakLine(true)
  329. elseif c:match('%C') then
  330. local length
  331. self.lines[self.cursor.y], length = insertCharAt(self.lines[self.cursor.y], c, self.cursor.x)
  332. self.cursor.x = self.cursor.x + length
  333. end
  334. end
  335. if singleLineChange then
  336. self:lexLine(self.cursor.y)
  337. else
  338. self:lexAll()
  339. end
  340. self:deselect()
  341. end,
  342. charAtCursor = function(self)
  343. return self.lines[self.cursor.y]:sub(self.cursor.x, self.cursor.x)
  344. end,
  345. lexLine = function(self, lineNum)
  346. self.lexed[lineNum] = lexer(self.lines[lineNum])[1]
  347. end,
  348. lexAll = function(self) -- lexing single line cannot handle multiline comments and strings
  349. self.lexed = lexer(self:getText())
  350. end,
  351. updateView = function(self)
  352. -- constrain cursor to text limits
  353. self.cursor.y = math.max(self.cursor.y, 1)
  354. self.cursor.y = math.min(self.cursor.y, #(self.lines))
  355. local lineLength = string.len(self.lines[self.cursor.y] or '')
  356. self.cursor.x = math.max(self.cursor.x, 0)
  357. self.cursor.x = math.min(self.cursor.x, lineLength)
  358. -- constrain selection to text limits
  359. self.selection.y = math.max(self.selection.y, 1)
  360. self.selection.y = math.min(self.selection.y, #(self.lines))
  361. local lineLength = string.len(self.lines[self.selection.y] or "")
  362. self.selection.x = math.max(self.selection.x, 0)
  363. self.selection.x = math.min(self.selection.x, lineLength)
  364. -- keep cursor on screen
  365. if self.cursor.y <= self.scroll.y then
  366. self.scroll.y = self.cursor.y - 1
  367. elseif self.cursor.y > self.scroll.y + self.rows then
  368. self.scroll.y = self.cursor.y - self.rows
  369. end
  370. if self.cursor.x < self.scroll.x then
  371. self.scroll.x = math.max(self.cursor.x - 10, 0)
  372. elseif self.cursor.x > self.scroll.x + self.cols then
  373. self.scroll.x = self.cursor.x + 10 - self.cols
  374. end
  375. -- update status line
  376. self.statusLine = string.format('L%d C%d %s', self.cursor.y, self.cursor.x, self.name)
  377. end,
  378. repeatOverPattern = function(self, pattern, moveF, ...)
  379. -- execute moveF() over text as long as character matches pattern and cursor moves
  380. while self:charAtCursor():match(pattern) do
  381. local oldX, oldY = self.cursor.x, self.cursor.y
  382. moveF(...)
  383. if (oldX == self.cursor.x and oldY == self.cursor.y) then break end
  384. end
  385. end,
  386. }
  387. -- generate all select_ and move_ actions
  388. for _, functionName in ipairs({'Up', 'Down', 'Left', 'Right', 'JumpUp', 'JumpDown', 'JumpLeft', 'JumpRight',
  389. 'Home', 'End', 'PageUp', 'PageDown', 'JumpHome', 'JumpEnd'}) do
  390. buffer['select' .. functionName] = function(self)
  391. --self.selection = self.selection or {x= self.cursor.x, y= self.cursor.y}
  392. self['cursor' .. functionName](self)
  393. end
  394. buffer['move' .. functionName] = function(self)
  395. self['cursor' .. functionName](self)
  396. self:deselect()
  397. end
  398. end
  399. buffer:setText(initialText or '')
  400. return buffer
  401. end
  402. return m
  403. --[[ Lua pattern matching
  404. str:find(pattern) finds the first instance of pattern in string and returns its position
  405. str:gmatch(pattern) when called repeatedly, returns each successive instance of pattern in string
  406. str:gsub(pattern, repl) returns a string where all instances of pattern in string have been replaced with repl
  407. str:match(pattern) returns the first instance of pattern in string
  408. X represents the character X itself as long as it is not a magic character
  409. . represents any single character
  410. %a represents all letters A-Z and a-z
  411. %c represents all control characters such as Null, Tab, Carr.Return, Linefeed, Delete, etc
  412. %d represents all digits 0-9
  413. %l represents all lowercase letters a-z
  414. %p represents all punctuation characters or symbols such as . , ? ! : ; @ [ ] _ { } ~
  415. %s represents all white space characters such as Tab, Carr.Return, Linefeed, Space, etc
  416. %u represents all uppercase letters A-Z
  417. %w represents all alphanumeric characters A-Z and a-z and 0-9
  418. %x represents all hexadecimal digits 0-9 and A-F and a-f
  419. %z represents the character with code \000 because embedded zeroes in a pattern do not work
  420. The upper case letter versions of the above reverses their meaning
  421. i.e. %A represents all non-letters and %D represents all non-digits
  422. + one or more repetitions
  423. * or - zero or more repetitions
  424. ? optional (zero or one occurrence)
  425. %Y represents the character Y if it is any non-alphanumeric character
  426. This is the standard way to get a magic character to match itself
  427. Any punctuation character (even a non magic one) preceded by a % represents itself
  428. e.g. %% represents % percent and %+ represents + plus
  429. [set] represents the class which is the union of all characters in the set
  430. A range of characters is specified by separating first and last character of range with a - hyphen e.g. 1-5
  431. All classes described above may also be used as components in the set
  432. e.g. [%w~] (or [~%w]) represents all alphanumeric characters plus the ~ tilde
  433. [^set] represents the complement of set, where set is interpreted as above
  434. e.g. [^A-Z] represents any character except upper case letters --]]