Template repo for tiny cross-platform apps that can be modified on phone, tablet or computer.
at main 1441 lines 51 kB view raw
1local utf8 = require 'utf8' 2 3local array = require 'array' 4local my_utf8 = require 'my_utf8' 5local t = require 'utils' 6local rects = require 'rects' 7local Loc = require 'loc' 8local move = require 'move' 9local keychord = require 'keychord' 10 11local edit = {} 12 13local I = {} -- for file-local helpers, so I can refer to them before defining them 14edit.internal = I 15local _ -- idiom for unused variables 16 17-- some constants people might like to tweak 18Cursor_color = {1, 0, 0} -- available to app; indexed by position rather than r/g/b to work with select_rgb 19local Highlight_color = {0.7, 0.7, 0.9, 0.4} -- selected text 20 21Line_number_width = 3 -- in ems; available to app 22local Line_number_color = {0.4, 0.4, 0.4} 23 24local Font_height = require 'font_height' 25 26Hand_icon = love.mouse.getSystemCursor('hand') 27Arrow_icon = love.mouse.getSystemCursor('arrow') 28 29function edit.is_this_love_version_supported() 30 local supported_versions = { 31 ['11.5']=true, 32 ['11.4']=true, 33 ['11.3']=true, 34 ['11.2']=true, 35 ['11.1']=true, 36 ['11.0']=true, 37 ['12.0']=true, 38 } 39 return supported_versions[edit.love_version()] 40end 41 42function edit.preferred_love_version() 43 return '11.5' 44end 45 46function edit.love_version() 47 local major_version, minor_version = love.getVersion() 48 return major_version..'.'..minor_version 49end 50 51local Last_focus_time, Last_resize_time 52Cursor_time = 0 -- available to app 53 54-- initialize editor 55-- this should happen only once regardless of how many editors you create 56function edit.load() 57 -- for hysteresis in a few places 58 Last_focus_time = 0 -- https://love2d.org/forums/viewtopic.php?p=249700 59 Last_resize_time = 0 60 61 -- blinking cursor (I assume you never show the cursor in more than one editor) 62 Cursor_time = 0 63end 64 65function edit.new_from_defaults(top, left, right, bottom) 66 local font = love.graphics.newFont(Font_height) 67 return edit.new(top, left, right, bottom, font, Font_height) 68end 69 70function edit.new_from_settings(settings, top, left, right, bottom) 71 local font = love.graphics.newFont(settings.font_height) 72 local editor = edit.new(top, left, right, bottom, font, settings.font_height) 73 edit.load_file(editor, settings.filename) 74 editor.screen_top = settings.screen_top 75 editor.cursor = settings.cursor 76 if not I.valid_loc(editor, editor.screen_top) 77 or not I.valid_loc(editor, editor.cursor) then 78 I.scroll_to_top(editor) 79 end 80 return editor 81end 82 83function edit.resize(editor, w, h, right, bottom) 84 editor.right = right 85 editor.width = editor.right-editor.left 86 editor.bottom = bottom 87 Last_resize_time = Current_time 88end 89 90function edit.settings(editor) 91 return { 92 font_height=editor.font_height, 93 filename=editor.filename, 94 screen_top=editor.screen_top, cursor=editor.cursor 95 } 96end 97 98function edit.load_file(editor, filename) 99 editor.filename = filename 100 editor.lines = {} 101 if love.filesystem.getInfo(filename) then 102 for line in love.filesystem.lines(editor.filename) do 103 table.insert(editor.lines, {data=line}) 104 end 105 end 106 if #editor.lines == 0 then 107 table.insert(editor.lines, {data=''}) 108 end 109 I.scroll_to_top(editor) -- clear out stale locations 110 colorize.all(editor) 111end 112 113function edit.load_array(editor, lines) 114 editor.lines = {} 115 assert(#lines > 0) 116 for _, line in ipairs(lines) do 117 table.insert(editor.lines, {data=line}) 118 end 119 colorize.all(editor) 120end 121 122function edit.focus(in_focus) 123 if in_focus then 124 Last_focus_time = Current_time 125 end 126end 127 128function edit.update_all(dt) 129 Current_time = Current_time + dt 130 Cursor_time = Cursor_time + dt 131end 132 133function edit.new(top, left, right, bottom, font, font_height) 134 local result = { 135 top = math.floor(top), 136 left = math.floor(left), 137 right = math.floor(right), 138 bottom = math.floor(bottom), 139 width = right-left, 140 141 font = font, 142 font_height = font_height, 143 line_height = math.floor(font_height*1.3), 144 145 indent_wrapped_lines = nil, 146 147 -- The editor is for editing an array of lines. 148 -- The array of lines can never be empty; there must be at least one line for positioning a cursor at. 149 lines = {{data=''}}, -- array of strings 150 151 -- We need to track a couple of _locations_: 152 screen_top = {line=1, pos=1}, -- location at top of screen, to start drawing from 153 cursor = {line=1, pos=1}, -- location where editing will occur 154 155 selection = {}, 156 157 filename = love.filesystem.getSourceBaseDirectory()..'/lines.txt', -- '/' should work even on Windows 158 modified = nil, 159 160 -- undo 161 history = {}, 162 next_history = 1, 163 164 -- search 165 search_term = nil, 166 search_backup = nil, -- stuff to restore when cancelling search 167 168 -- touch features 169 current_touch = nil, 170 inertial_scroll = {}, 171 } 172 return result 173end 174 175function edit.draw(editor, fg, hide_cursor, show_line_numbers, cursor_color) 176 if fg and fg.r then fg = {fg.r, fg.g, fg.b} end -- old interface for compatibility 177 local y = editor.top 178 for line_index = editor.screen_top.line, #editor.lines do 179 local loc 180 assert(editor.lines[line_index].data, 'each line in editor should contain a field called .data containing a string (use edit.load_array instead of setting edit.lines directly)') 181 if line_index == editor.screen_top.line then 182 loc = editor.screen_top 183 else 184 loc = {line=line_index, pos=1} 185 end 186 local rect = rects.compute(editor, loc, editor.bottom - y) 187 if show_line_numbers and loc.pos == 1 then 188 local w = Line_number_width * editor.font:getWidth('m') 189 love.graphics.setColor(Line_number_color) 190 love.graphics.print(loc.line, editor.left-w+10, y) 191 end 192 for _,s in ipairs(rect.screen_line_rects) do 193--? love.graphics.setColor(0.8, 0.8, 0.8) 194--? love.graphics.rectangle('fill', editor.left+s.x,y+s.y, s.dx,s.dy) 195 for _,c in ipairs(s.char_rects) do 196--? love.graphics.setColor(0.8, 0.9, 0.8) 197--? love.graphics.rectangle('fill', editor.left+c.x,y+c.y, c.dx-2,c.dy-1) 198 if c.show_cursor then -- only the primary place where the cursor can be is available for highlight 199 if I.in_selection(editor, line_index, c.pos, editor.cursor) 200 or I.in_search(editor, line_index, c.pos) 201 then 202 love.graphics.setColor(Highlight_color) 203 love.graphics.rectangle('fill', editor.left+c.x, y+c.y, c.dx,c.dy) 204 end 205 end 206 if c.draw then 207 for _,s in ipairs(c.draw) do I.draw_shape(s, editor.left, y, fg) end 208 end 209 if c.data and not c.conceal then 210 if fg == nil then 211 if c.pos then 212 love.graphics.setColor(c.fg or editor.colors[line_index][c.pos]) 213 else 214 love.graphics.setColor(c.fg) 215 end 216 else 217 love.graphics.setColor(c.fg or fg) 218 end 219 love.graphics.print(c.data, editor.left+c.x, y+c.y) 220 end 221 if not hide_cursor and line_index == editor.cursor.line then 222 if c.pos == editor.cursor.pos and c.show_cursor then 223 I.draw_text_cursor(editor, editor.left+c.x, y+c.y, c.dy, cursor_color) 224 end 225 end 226 end 227 end 228 y = y + rect.dy 229 if y + editor.line_height > editor.bottom then 230 break 231 end 232 end 233 I.draw_selection_handles(editor) 234 if editor.search_term then 235 I.draw_search_bar(editor) 236 end 237end 238 239-- scenarios: 240-- shift down, mouse click -> select text 241-- shift up, mouse click -> move cursor 242-- shift down, mouse click, mouse click -> select text from cursor at start to cursor at end 243-- mouse down, shift down, mouse up -> select text from mouse press to mouse release 244-- drag -> select text from mouse press to mouse release 245-- It looks like all this can be handled by setting selection on press and 246-- cursor while mouse button is down. 247function edit.mouse_press(editor, mx,my, mouse_button, is_touch, presses) 248 if is_touch then return end 249 Cursor_time = 0 -- ensure cursor is visible immediately after it moves 250 if mouse_button ~= 1 then return end 251 if editor.search_term then return end 252 if keychord.shift_down() then 253 if editor.selection.line == nil then 254 -- mouse down, shift down; set selection to old cursor 255 editor.selection = editor.cursor 256 end 257 else 258 -- mouse down, shift up: set selection to current cursor 259 editor.selection = move.to_loc(editor, mx,my) 260 end 261 editor.cursor = move.to_loc(editor, mx,my) 262end 263 264function edit.mouse_move(editor, mx,my, dx,dy, is_touch) 265 if is_touch then return end 266 Cursor_time = 0 -- ensure cursor is visible immediately after it moves 267 if love.mouse.isDown(1) then 268 editor.cursor = move.to_loc(editor, mx,my) 269 end 270end 271 272function edit.mouse_release(editor, mx,my, mouse_button, is_touch, presses) 273 if is_touch then return end 274 if Loc.eq(editor.cursor, editor.selection) then 275 editor.selection = {} 276 end 277end 278 279Device_has_touch = false 280 281function edit.touch_press(editor, touch_id, tx,ty, dx,dy, pressure) 282 Device_has_touch = true 283 if editor.search_term then return end 284 if editor.current_touch then return end -- no multi-touch support so far 285 if editor.selection.line then 286 if I.on_selection_handle(editor, editor.selection, tx,ty) then 287 editor.current_touch = { 288 id=touch_id, 289 on_selection=true, 290 } 291 elseif I.on_selection_handle(editor, editor.cursor, tx,ty) then 292 editor.current_touch = { 293 id=touch_id, 294 on_cursor=true, 295 } 296 else 297 -- touch outside handles 298 editor.selection = {} 299 end 300 return 301 end 302 editor.current_touch = { 303 id=touch_id, 304 start_time=Current_time, 305 start_x=tx, 306 start_y=ty, 307 prev_x=nil, 308 prev_y=nil, 309 current_x=tx, 310 current_y=ty, 311 has_moved=false, 312 prev_move_time=nil, 313 } 314end 315 316function edit.touch_move(editor, touch_id, tx,ty, dx,dy, pressure) 317 if not editor.current_touch then return end 318 if touch_id ~= editor.current_touch.id then return end 319 local touch = editor.current_touch 320 if touch.on_selection then 321 I.move_selection_handle(editor, editor.selection, tx, ty) 322 return 323 elseif touch.on_cursor then 324 I.move_selection_handle(editor, editor.cursor, tx, ty) 325 return 326 end 327 touch.prev_x = touch.current_x or touch.start_x 328 touch.prev_y = touch.current_y or touch.start_y 329 touch.current_x = tx 330 touch.current_y = ty 331 local distance_sq = (tx-touch.start_x)^2 + (ty-touch.start_y)^2 332 if distance_sq > 10*10 then 333 touch.has_moved = true 334 local time_delta = Current_time - (touch.prev_move_time or touch.start_time) 335 if time_delta > 0 then 336 touch.velocity_y = -(ty - touch.prev_y) / time_delta 337 end 338 touch.prev_move_time = Current_time 339 -- scroll while dragging 340 local scroll_dy = -(ty - touch.prev_y) * I.inertial_scroll_sensitivity 341 I.apply_scroll(editor, scroll_dy) 342 end 343end 344 345function edit.touch_release(editor, touch_id, tx,ty, dx,dy, pressure) 346 if not editor.current_touch then return end 347 if touch_id == editor.current_touch.id then 348 local touch = editor.current_touch 349 if touch.on_selection or touch.on_cursor then 350 -- ensure selection is at start and cursor at end for next touch 351 assert(editor.selection) 352 if Loc.lt(editor.cursor, editor.selection) then 353 editor.selection, editor.cursor = editor.cursor, editor.selection 354 I.init_selection_handle(editor, editor.selection, -1) 355 I.init_selection_handle(editor, editor.cursor, 0) 356 end 357 elseif not touch.has_moved then 358 love.keyboard.setTextInput(true) 359 local touch_duration = Current_time - touch.start_time 360 if touch_duration < 0.3 then 361 editor.cursor = move.to_loc(editor, touch.start_x, touch.start_y) 362 editor.selection = {} 363 else 364 -- long touch: go into selection mode 365 I.start_text_selection(editor, touch.start_x, touch.start_y) 366 end 367 else 368 -- drag: scroll with inertial momentum 369 I.start_inertial_scroll(editor, touch.velocity_y) 370 end 371 end 372 editor.current_touch = nil 373end 374 375-- inertial scroll 376I.inertial_scroll_sensitivity = 1.0 377I.inertial_scroll_velocity_threshold = 10 378I.inertial_scroll_velocity_multiplier = 1.0 379I.inertial_scroll_deceleration = 450 380 381function I.start_inertial_scroll(editor, velocity_y) 382 if math.abs(velocity_y) > I.inertial_scroll_velocity_threshold then 383 editor.inertial_scroll = { 384 active = true, 385 velocity_y = velocity_y * I.inertial_scroll_velocity_multiplier, 386 accumulator = nil, 387 } 388 end 389end 390 391function edit.update_inertial_scroll(editor, dt) 392 local s = editor.inertial_scroll 393 if not s.active then 394 return 395 end 396 I.apply_scroll(editor, s.velocity_y*dt) 397 -- decelerate 398 if math.abs(s.velocity_y) < I.inertial_scroll_deceleration*dt then -- s.velocity_y about to switch sign 399 editor.inertial_scroll = {} 400 return 401 end 402 local velocity_sign = (s.velocity_y > 0 and 1 or -1) 403 local decel = velocity_sign * I.inertial_scroll_deceleration 404 s.velocity_y = s.velocity_y - decel*dt 405 if math.abs(s.velocity_y) < 1 then 406 editor.inertial_scroll = {} 407 return 408 end 409end 410 411function I.apply_scroll(editor, dy) 412 if dy == 0 then return end 413 local s = editor.inertial_scroll 414 if s.accumulator == nil then s.accumulator = 0 end 415 s.accumulator = s.accumulator + dy 416 local lines_to_scroll = math.floor(math.abs(s.accumulator) / editor.line_height) 417 if lines_to_scroll == 0 then return end 418 local direction = s.accumulator > 0 and 1 or -1 419 local scroll_fn 420 if direction > 0 then 421 scroll_fn = move.internal.loc_down 422 else 423 scroll_fn = move.internal.loc_up 424 end 425 local tmp = scroll_fn(editor, editor.screen_top, lines_to_scroll*editor.line_height) 426 if tmp then 427 editor.screen_top = tmp 428 editor.cursor = t.deepcopy(editor.screen_top) 429 s.accumulator = s.accumulator - direction*lines_to_scroll*editor.line_height 430 else 431 error('bug: too fast') 432 end 433end 434 435function edit.maybe_stop_inertial_scroll(editor) 436 if editor.current_touch then return false end -- current touch is in progress 437 if not editor.inertial_scroll.active then return false end 438 editor.inertial_scroll = {} 439 return true 440end 441 442-- Text selection with draggable handles 443function I.start_text_selection(editor, touch_x, touch_y) 444 local loc = move.to_loc(editor, touch_x, touch_y) 445 if not loc then return end 446 local curr_line = editor.lines[loc.line].data 447 local word_start, word_end = I.find_word_boundaries(curr_line, loc.pos) 448 editor.selection = {line = loc.line, pos = word_start} 449 editor.cursor = {line = loc.line, pos = word_end} 450 I.init_selection_handle(editor, editor.selection, -1) 451 I.init_selection_handle(editor, editor.cursor, 0) 452end 453 454function I.find_word_boundaries(s, pos) 455 assert(pos <= utf8.len(s)+1) 456 local word_start, word_end = pos, pos 457 while true do 458 assert(word_start >= 1) 459 if word_start == 1 then 460 break 461 end 462 if my_utf8.match_at(s, word_start-1, '%s') then 463 break 464 end 465 word_start = word_start-1 466 end 467 while true do 468 assert(word_end <= utf8.len(s)+1) 469 if word_end > utf8.len(s) then 470 break 471 end 472 if my_utf8.match_at(s, word_end, '%s') then 473 break 474 end 475 word_end = word_end+1 476 end 477 return word_start, word_end 478end 479 480function I.init_selection_handle(editor, loc, xoff) 481 local x,y = move.to_coord(editor, loc) 482 if not x then 483 loc.handle = nil 484 loc.area = nil 485 else 486 local em_width = editor.font:getWidth('m') 487 loc.handle = {x=x+xoff*em_width, y=y+editor.line_height, dx=em_width, dy=editor.line_height, xoff=xoff} 488 loc.area = {x=x+(xoff-1)*em_width, y=y, dx=3*em_width, dy=3*editor.line_height, xoff=xoff} 489 end 490end 491 492function I.draw_selection_handles(editor) 493 if not Device_has_touch then return end 494 if not editor.selection.line then return end 495 local r = editor.selection.handle 496 if r then 497 love.graphics.setColor(0.6, 0.6, 0.7) 498 love.graphics.rectangle('fill', r.x,r.y, r.dx,r.dy) 499 end 500 r = editor.cursor.handle 501 if r then 502 love.graphics.setColor(0.6, 0.6, 0.7) 503 love.graphics.rectangle('fill', r.x,r.y, r.dx,r.dy) 504 end 505 if editor.current_touch then 506 if editor.current_touch.on_selection then 507 I.draw_magnifier(editor, editor.selection) 508 elseif editor.current_touch.on_cursor then 509 I.draw_magnifier(editor, editor.cursor) 510 end 511 end 512end 513 514function I.on_selection_handle(editor, loc, x,y) 515 if not loc.area then return false end 516 return move.internal.within_rect(loc.area, x,y) 517end 518 519-- modify loc in place to x,y 520function I.move_selection_handle(editor, loc, x,y) 521 I.scroll_with_selection_handle(editor, loc, x,y) 522 -- Get screen coordinates for handles 523 local nloc = move.to_loc(editor, x,y) 524 if not nloc then return end 525 I.init_selection_handle(editor, nloc, loc.handle.xoff) 526 loc.line, loc.pos, loc.handle, loc.area = nloc.line, nloc.pos, nloc.handle, nloc.area 527end 528 529-- Scroll if x,y is close to top or bottom margins 530function I.scroll_with_selection_handle(editor, loc, x,y) 531 local zone1 = 30 -- px 532 local within_zone1_speed = 1 -- lines/frame 533 local outside_zone1_speed = 2 -- lines/frame 534 local scroll_speed = 0 535 assert(editor.top < editor.bottom) 536 if y < editor.top-zone1 then 537 scroll_speed = -outside_zone1_speed 538 elseif y < editor.top then 539 scroll_speed = -within_zone1_speed 540 elseif y < editor.bottom then 541 loc.scroll_accumulator = nil 542 return 543 elseif y < editor.bottom+zone1 then 544 scroll_speed = within_zone1_speed 545 else 546 assert(y >= editor.bottom+zone1) 547 scroll_speed = outside_zone1_speed 548 end 549 -- TODO: dedup this next bit with I.apply_scroll 550 if loc.scroll_accumulator == nil then 551 loc.scroll_accumulator = 0 552 end 553 loc.scroll_accumulator = loc.scroll_accumulator + scroll_speed 554 local lines_to_scroll = math.floor(math.abs(loc.scroll_accumulator) / editor.line_height) 555 if lines_to_scroll == 0 then return end 556 local direction = scroll_speed > 0 and 1 or -1 557 local scroll_fn 558 if direction > 0 then 559 scroll_fn = move.internal.loc_down 560 else 561 scroll_fn = move.internal.loc_up 562 end 563 local tmp = scroll_fn(editor, editor.screen_top, lines_to_scroll*editor.line_height) 564 if tmp then 565 editor.screen_top = tmp 566 loc.scroll_accumulator = loc.scroll_accumulator - direction*lines_to_scroll*editor.line_height 567 -- update the other handle 568 if loc == editor.cursor then 569 I.init_selection_handle(editor, editor.selection, -1) 570 elseif loc == editor.selection then 571 I.init_selection_handle(editor, editor.cursor, 0) 572 else 573 error('invalid loc') 574 end 575 else 576 error('bug: too fast') 577 end 578end 579 580-- magnify the area around loc 581-- precondition: loc.handle exists 582function I.draw_magnifier(editor, loc) 583 local zoom_factor = 2.0 584 local mag_offset = 60 585 local mag_height = editor.line_height*zoom_factor 586 local line_text = editor.lines[loc.line].data 587 local chars_around = 5 588 local start_pos = math.max(1, loc.pos - chars_around) 589 local end_pos = math.min(utf8.len(line_text), loc.pos + chars_around) 590 local mag_text = my_utf8.sub(line_text, start_pos, end_pos+1) 591 local mag_text_width = editor.font:getWidth(mag_text) 592 local mag_width = mag_text_width*zoom_factor 593 -- position magnifier well to the left, for two reasons: 594 -- - assume a right-handed touch 595 -- - avoid the scrollbar on the right 596 local mag_x = editor.left 597 -- position magnifier above if possible, or below 598 local mag_y = loc.handle.y - mag_height - mag_offset 599 if mag_y < editor.top then 600 mag_y = loc.handle.y + mag_offset 601 end 602 -- draw magnifier frame and content 603 love.graphics.setColor(0,0,0) 604 love.graphics.setLineWidth(2) 605 love.graphics.rectangle('fill', mag_x-5, mag_y-5, mag_width+10, mag_height+10, 5,5) 606 love.graphics.setLineWidth(1) 607 love.graphics.setColor(1,1,1) 608 love.graphics.rectangle('fill', mag_x-2, mag_y-2, mag_width+4, mag_height+4) 609 if Loc.lt(editor.cursor, loc) or Loc.lt(editor.selection, loc) then 610 -- loc is at end of selection 611 local x = I.draw_magnifier_text(line_text, start_pos, loc.pos, mag_x, mag_y, editor.font, Highlight_color, zoom_factor, editor.line_height) 612 I.draw_magnifier_text(line_text, loc.pos, end_pos+1, x, mag_y, editor.font, {1,1,1}, zoom_factor, editor.line_height) 613 elseif Loc.lt(loc, editor.cursor) or Loc.lt(loc, editor.selection) then 614 -- loc is at start of selection 615 local x = I.draw_magnifier_text(line_text, start_pos, loc.pos, mag_x, mag_y, editor.font, {1,1,1}, zoom_factor, editor.line_height) 616 I.draw_magnifier_text(line_text, loc.pos, end_pos+1, x, mag_y, editor.font, Highlight_color, zoom_factor, editor.line_height) 617 end 618end 619 620function I.draw_magnifier_text(s, start_pos, end_pos, x,y, font, background_color, zoom_factor, line_height) 621 local text = my_utf8.sub(s, start_pos, end_pos) 622 local w = font:getWidth(text)*zoom_factor 623 love.graphics.setColor(background_color) 624 love.graphics.rectangle('fill', x,y, w, line_height*zoom_factor) 625 love.graphics.setColor(0,0,0) 626 love.graphics.print(text, x, y, --[[rotation]] 0, zoom_factor) 627 return x+w 628end 629 630function edit.mouse_wheel_move(editor, dx,dy) 631 Cursor_time = 0 -- ensure cursor is visible immediately after it moves 632 if dy > 0 then 633 editor.cursor = t.deepcopy(editor.screen_top) 634 for i=1,math.floor(dy) do 635 move.up_arrow(editor) 636 end 637 elseif dy < 0 then 638 editor.cursor = move.to_loc(editor, editor.left, editor.bottom-1) 639 assert(editor.cursor) 640 for i=1,math.floor(-dy) do 641 move.down_arrow(editor) 642 end 643 end 644end 645 646function I.scroll_to_top(editor) 647 assert(#editor.lines > 0) 648 editor.screen_top = {line=1, pos=1} 649 editor.cursor = {line=1, pos=1} 650end 651 652function I.valid_loc(editor, loc) 653 assert(#editor.lines > 0) 654 if loc == nil then return end 655 if loc.line > #editor.lines then return end 656 if loc.pos > #editor.lines[loc.line].data then return end 657 return true 658end 659 660function I.mark_modified(editor) 661 editor.modified = true 662end 663 664I.buffer_modified_handlers = {} 665 666function edit.signal_buffer_modified(editor) 667 for _, handler in ipairs(I.buffer_modified_handlers) do 668 handler(editor) 669 end 670end 671 672table.insert(I.buffer_modified_handlers, I.mark_modified) 673table.insert(I.buffer_modified_handlers, colorize.all) 674 675---- keyboard handling 676 677function edit.text_input(editor, t, readonly) 678 -- ignore events for some time after window in focus (mostly alt-tab) 679 if Current_time < Last_focus_time + 0.01 then 680 return 681 end 682 -- 683 Cursor_time = 0 -- ensure cursor is visible immediately after it moves 684 if love.mouse.isDown(1) then return end 685 if editor.search_term then 686 editor.search_term = editor.search_term..t 687 I.search_next(editor) 688 return 689 end 690 if move.to_coord(editor, editor.cursor) == nil then return end -- cursor is off screen 691 if keychord.any_modifier_down() and key_down(t) then 692 -- The modifiers didn't change the key. Handle it in keychord_press. 693 return 694 end 695 if not readonly then 696 local before = I.snapshot(editor, editor.cursor.line) 697 -- Try to support mobile keyboard swipe/autocomplete/paste as best we 698 -- can. 699 -- 700 -- Mobile paste still works poorly. It's unreliable, often truncates 701 -- large pastes, and inserts newlines twice, first keypress events for 702 -- all characters in a paste and _then_ textinput for all characters. 703 I.insert_text(editor, t) 704 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)}) 705 edit.signal_buffer_modified(editor) 706 end 707end 708 709function edit.keychord_press(editor, chord, key, scancode, is_repeat, readonly) 710 -- ignore events for some time after window in focus (mostly alt-tab) 711 if Current_time < Last_focus_time + 0.01 then 712 return 713 end 714 -- 715 Cursor_time = 0 -- ensure cursor is visible immediately after it moves 716 local cursor_on_screen = move.to_coord(editor, editor.cursor) 717 local dm = keychord.default_modifier 718 if not readonly and editor.selection.line and 719 cursor_on_screen and 720 -- printable character created using shift key => delete selection 721 -- (we're not creating any ctrl-shift- or alt-shift- combinations using regular/printable keys) 722 (not keychord.shift_down() or utf8.len(key) == 1) and 723 chord ~= dm('a') and chord ~= dm('c') and chord ~= dm('x') and chord ~= 'backspace' and chord ~= 'delete' and chord ~= dm('z') and chord ~= dm('y') and not keychord.is_cursor_movement(key) then 724 I.delete_selection_and_record_undo_event(editor) 725 edit.signal_buffer_modified(editor) -- possibly redundant, but ensure it happens 726 end 727 if editor.search_term then 728 if chord == 'escape' then 729 editor.search_term = nil 730 editor.cursor = editor.search_backup.cursor 731 editor.screen_top = editor.search_backup.screen_top 732 editor.search_backup = nil 733 elseif chord == 'return' then 734 editor.search_term = nil 735 editor.search_backup = nil 736 elseif chord == 'backspace' then 737 local len = utf8.len(editor.search_term) 738 local byte_offset = my_utf8.offset(editor.search_term, len) 739 editor.search_term = string.sub(editor.search_term, 1, byte_offset-1) 740 editor.cursor = t.deepcopy(editor.search_backup.cursor) 741 editor.screen_top = t.deepcopy(editor.search_backup.screen_top) 742 I.search_next(editor) 743 elseif chord == 'down' then 744 if #editor.search_term > 0 then 745 move.right_arrow(editor) 746 I.search_next(editor) 747 end 748 elseif chord == 'up' then 749 I.search_previous(editor) 750 end 751 return 752 elseif chord == dm('f') then 753 editor.search_term = '' 754 editor.search_backup = { 755 cursor=t.deepcopy(editor.cursor), 756 screen_top=t.deepcopy(editor.screen_top), 757 } 758 -- zoom 759 elseif chord == dm('=') then 760 edit.update_font_settings(editor, editor.font_height+2) 761 elseif chord == dm('-') then 762 if editor.font_height > 2 then 763 edit.update_font_settings(editor, editor.font_height-2) 764 end 765 elseif chord == dm('0') then 766 edit.update_font_settings(editor, 20) 767 -- undo 768 elseif not readonly and chord == dm('z') then 769 local event = I.undo_event(editor) 770 if event then 771 local src = event.before 772 editor.screen_top = t.deepcopy(src.screen_top) 773 editor.cursor = t.deepcopy(src.cursor) 774 editor.selection = t.deepcopy(src.selection) 775 I.patch(editor.lines, event.after, event.before) 776 edit.signal_buffer_modified(editor) 777 end 778 elseif not readonly and chord == dm('y') then 779 local event = I.redo_event(editor) 780 if event then 781 local src = event.after 782 editor.screen_top = t.deepcopy(src.screen_top) 783 editor.cursor = t.deepcopy(src.cursor) 784 editor.selection = t.deepcopy(src.selection) 785 I.patch(editor.lines, event.before, event.after) 786 edit.signal_buffer_modified(editor) 787 end 788 -- clipboard 789 elseif chord == dm('a') and cursor_on_screen then 790 editor.selection = {line=1, pos=1} 791 editor.cursor = {line=#editor.lines, pos=utf8.len(editor.lines[#editor.lines].data)+1} 792 elseif chord == dm('c') then 793 local s = I.selection(editor) 794 if s then 795 love.system.setClipboardText(s) 796 end 797 elseif not readonly and chord == dm('x') and cursor_on_screen then 798 local s = I.cut_selection(editor, editor.left, editor.right) 799 if s then 800 love.system.setClipboardText(s) 801 end 802 edit.signal_buffer_modified(editor) 803 elseif not readonly and chord == dm('v') and cursor_on_screen then 804 local before_line = editor.cursor.line 805 local before = I.snapshot(editor, before_line) 806 local clipboard_data = love.system.getClipboardText() 807 I.insert_text(editor, clipboard_data) 808 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, before_line, editor.cursor.line)}) 809 edit.signal_buffer_modified(editor) 810 --== shortcuts that mutate text 811 elseif not readonly and chord == 'return' and cursor_on_screen then 812 local before_line = editor.cursor.line 813 local before = I.snapshot(editor, before_line) 814 I.insert_return_at_cursor(editor) 815 move.maybe_snap_cursor_to_bottom_of_screen(editor) 816 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, before_line, editor.cursor.line)}) 817 edit.signal_buffer_modified(editor) 818 elseif not readonly and chord == 'tab' and cursor_on_screen then 819 local before = I.snapshot(editor, editor.cursor.line) 820 I.insert_char_at_cursor(editor, '\t') 821 move.maybe_snap_cursor_to_bottom_of_screen(editor) 822 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)}) 823 edit.signal_buffer_modified(editor) 824 elseif not readonly and chord == 'backspace' and cursor_on_screen then 825 if editor.selection.line then 826 I.delete_selection_and_record_undo_event(editor) 827 edit.signal_buffer_modified(editor) 828 return 829 end 830 if editor.cursor.line == 1 and editor.cursor.pos == 1 then 831 -- no-op 832 return 833 end 834 local before 835 if editor.cursor.pos > 1 then 836 before = I.snapshot(editor, editor.cursor.line) 837 local byte_start = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos-1) 838 local byte_end = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos) 839 if byte_start then 840 if byte_end then 841 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1)..string.sub(editor.lines[editor.cursor.line].data, byte_end) 842 else 843 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1) 844 end 845 editor.cursor.pos = editor.cursor.pos-1 846 end 847 else 848 assert(editor.cursor.line > 1) 849 before = I.snapshot(editor, editor.cursor.line-1, editor.cursor.line) 850 -- join lines 851 editor.cursor.pos = utf8.len(editor.lines[editor.cursor.line-1].data)+1 852 editor.lines[editor.cursor.line-1].data = editor.lines[editor.cursor.line-1].data..editor.lines[editor.cursor.line].data 853 table.remove(editor.lines, editor.cursor.line) 854 editor.cursor.line = editor.cursor.line-1 855 end 856 if editor.screen_top.line > #editor.lines then 857 -- line no longer exists 858 editor.screen_top = move.internal.loc_hor(editor, editor.cursor, editor.left) 859 elseif Loc.eq(editor.cursor, editor.screen_top) then 860 local len = utf8.len(editor.lines[editor.cursor.line].data) 861 assert(editor.cursor.pos <= len+1) 862 if editor.cursor.pos == len+1 then 863 -- screen line may no longer exist 864 editor.screen_top = move.internal.loc_hor(editor, editor.cursor, editor.left) 865 assert(editor.screen_top) 866 end 867 elseif Loc.lt(editor.cursor, editor.screen_top) then 868 move.maybe_snap_cursor_to_top_of_screen(editor) 869 end 870 assert(Loc.le(editor.screen_top, editor.cursor), ('screen_top (line=%d,pos=%d) is below cursor (line=%d,pos=%d)'):format(editor.screen_top.line, editor.screen_top.pos or -1, editor.cursor.line, editor.cursor.pos or -1)) 871 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)}) 872 edit.signal_buffer_modified(editor) 873 elseif not readonly and chord == 'delete' and cursor_on_screen then 874 -- cursor in text line 875 if editor.selection.line then 876 I.delete_selection_and_record_undo_event(editor) 877 edit.signal_buffer_modified(editor) 878 return 879 end 880 local before 881 if editor.cursor.pos <= utf8.len(editor.lines[editor.cursor.line].data) then 882 before = I.snapshot(editor, editor.cursor.line) 883 else 884 before = I.snapshot(editor, editor.cursor.line, editor.cursor.line+1) 885 end 886 if editor.cursor.pos <= utf8.len(editor.lines[editor.cursor.line].data) then 887 local byte_start = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos) 888 local byte_end = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos+1) 889 if byte_start then 890 if byte_end then 891 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1)..string.sub(editor.lines[editor.cursor.line].data, byte_end) 892 else 893 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1) 894 end 895 -- no change to editor.cursor.pos 896 end 897 elseif editor.cursor.line < #editor.lines then 898 -- join lines 899 editor.lines[editor.cursor.line].data = editor.lines[editor.cursor.line].data..editor.lines[editor.cursor.line+1].data 900 table.remove(editor.lines, editor.cursor.line+1) 901 end 902 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)}) 903 edit.signal_buffer_modified(editor) 904 --== shortcuts that move the cursor 905 elseif chord == 'left' and cursor_on_screen then 906 move.left_arrow(editor) 907 editor.selection = {} 908 elseif chord == 'right' and cursor_on_screen then 909 move.right_arrow(editor) 910 editor.selection = {} 911 elseif chord == 'S-left' and cursor_on_screen then 912 if editor.selection.line == nil then 913 editor.selection = t.deepcopy(editor.cursor) 914 end 915 move.left_arrow(editor) 916 elseif chord == 'S-right' and cursor_on_screen then 917 if editor.selection.line == nil then 918 editor.selection = t.deepcopy(editor.cursor) 919 end 920 move.right_arrow(editor) 921 -- C- hotkeys reserved for drawings, so we'll use M- 922 elseif chord == 'M-left' and cursor_on_screen then 923 move.word_left(editor) 924 editor.selection = {} 925 elseif chord == 'M-right' and cursor_on_screen then 926 move.word_right(editor) 927 editor.selection = {} 928 elseif chord == 'M-S-left' and cursor_on_screen then 929 if editor.selection.line == nil then 930 editor.selection = t.deepcopy(editor.cursor) 931 end 932 move.word_left(editor) 933 elseif chord == 'M-S-right' and cursor_on_screen then 934 if editor.selection.line == nil then 935 editor.selection = t.deepcopy(editor.cursor) 936 end 937 move.word_right(editor) 938 elseif (chord == 'home' or (OS == 'OS X' and chord == 's-left')) and cursor_on_screen then 939 move.start_of_line(editor) 940 editor.selection = {} 941 elseif (chord == 'end' or (OS == 'OS X' and chord == 's-right')) and cursor_on_screen then 942 move.end_of_line(editor) 943 editor.selection = {} 944 elseif chord == 'S-home' and cursor_on_screen then 945 if editor.selection.line == nil then 946 editor.selection = t.deepcopy(editor.cursor) 947 end 948 move.start_of_line(editor) 949 elseif chord == 'S-end' and cursor_on_screen then 950 if editor.selection.line == nil then 951 editor.selection = t.deepcopy(editor.cursor) 952 end 953 move.end_of_line(editor) 954 elseif chord == 'up' and cursor_on_screen then 955 move.up_arrow(editor) 956 editor.selection = {} 957 elseif chord == 'down' and cursor_on_screen then 958 move.down_arrow(editor) 959 editor.selection = {} 960 elseif chord == 'S-up' and cursor_on_screen then 961 if editor.selection.line == nil then 962 editor.selection = t.deepcopy(editor.cursor) 963 end 964 move.up_arrow(editor) 965 elseif chord == 'S-down' and cursor_on_screen then 966 if editor.selection.line == nil then 967 editor.selection = t.deepcopy(editor.cursor) 968 end 969 move.down_arrow(editor) 970 elseif chord == 'pageup' and cursor_on_screen then 971 move.pageup(editor) 972 editor.selection = {} 973 elseif chord == 'pagedown' and cursor_on_screen then 974 move.pagedown(editor) 975 editor.selection = {} 976 elseif chord == 'S-pageup' and cursor_on_screen then 977 if editor.selection.line == nil then 978 editor.selection = t.deepcopy(editor.cursor) 979 end 980 move.pageup(editor) 981 elseif chord == 'S-pagedown' and cursor_on_screen then 982 if editor.selection.line == nil then 983 editor.selection = t.deepcopy(editor.cursor) 984 end 985 move.pagedown(editor) 986 end 987end 988 989function I.insert_text(editor, s) 990 for _,code in utf8.codes(s) do 991 local c = utf8.char(code) 992 if c == '\n' then 993 I.insert_return_at_cursor(editor) 994 else 995 I.insert_char_at_cursor(editor, c) 996 end 997 end 998 move.maybe_snap_cursor_to_bottom_of_screen(editor) 999end 1000 1001function edit.clear(editor) 1002 editor.lines = {{data=''}} 1003 editor.cursor = {line=1, pos=1} 1004 editor.screen_top = {line=1, pos=1} 1005 editor.selection = {} 1006 colorize.all(editor) 1007end 1008 1009function edit.key_release(editor, key, scancode) 1010 -- ignore events for some time after window in focus (mostly alt-tab) 1011 if Current_time < Last_focus_time + 0.01 then 1012 return 1013 end 1014 -- 1015 Cursor_time = 0 -- ensure cursor is visible immediately after it moves 1016end 1017 1018function edit.update_font_settings(editor, font_height) 1019 editor.font_height = font_height 1020 editor.font = love.graphics.newFont(editor.font_height) 1021 editor.line_height = math.floor(font_height*1.3) 1022end 1023 1024function I.draw_text_cursor(editor, x, y, dy, cursor_color) 1025 -- blink every 0.5s 1026 if math.floor(Cursor_time*2)%2 == 0 then 1027 love.graphics.setColor(cursor_color or Cursor_color) 1028 love.graphics.rectangle('fill', x,y, 3,dy) 1029 end 1030end 1031 1032function I.insert_char_at_cursor(editor, t) 1033 local byte_offset = my_utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos) 1034 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_offset-1)..t..string.sub(editor.lines[editor.cursor.line].data, byte_offset) 1035 editor.cursor.pos = editor.cursor.pos+1 1036end 1037 1038function I.insert_return_at_cursor(editor) 1039 local byte_offset = my_utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos) 1040 table.insert(editor.lines, editor.cursor.line+1, {data=string.sub(editor.lines[editor.cursor.line].data, byte_offset)}) 1041 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_offset-1) 1042 editor.cursor = {line=editor.cursor.line+1, pos=1} 1043end 1044 1045-- mappings only to non-printable keys; leave out mappings that textinput will handle 1046local Numlock_off = { 1047 kp0='insert', 1048 kp1='end', 1049 kp2='down', 1050 kp3='pagedown', 1051 kp4='left', 1052 -- numpad 5 translates to nothing 1053 kp6='right', 1054 kp7='home', 1055 kp8='up', 1056 kp9='pageup', 1057 ['kp.']='delete', 1058 -- LÖVE handles keypad operators in textinput 1059 -- what's with the `kp=` and `kp,` keys? None of my keyboards have one. 1060 -- Hopefully LÖVE handles them as well in textinput. 1061 kpenter='enter', 1062 kpdel='delete', 1063} 1064local Numlock_on = { 1065 kpenter='enter', 1066 kpdel='delete', 1067} 1068function I.translate_numlock(key) 1069 if love.keyboard.isModifierActive('numlock') then 1070 return Numlock_on[key] or key 1071 else 1072 return Numlock_off[key] or key 1073 end 1074 return key 1075end 1076 1077function I.draw_shape(shape, left, top, fg) 1078 love.graphics.setColor(shape.fg or fg) 1079 if shape.type == 'rect' then 1080 love.graphics.rectangle(shape.mode, left+shape.x, top+shape.y, shape.dx, shape.dy) 1081 elseif shape.type == 'line' then 1082 love.graphics.line(left+shape.x1, top+shape.y1, left+shape.x2, top+shape.y2) 1083 elseif shape.type == 'circle' then 1084 love.graphics.circle(shape.mode, left+shape.x, top+shape.y, shape.r) 1085 end 1086end 1087 1088---- helpers for file operations 1089 1090function edit.save_to_disk(editor) 1091 if editor.filename == nil then return end 1092 local lines = {} 1093 for _, line in ipairs(editor.lines) do 1094 table.insert(lines, line.data) 1095 end 1096 local contents = table.concat(lines, '\n') 1097 local success, message = love.filesystem.write(editor.filename, contents) 1098 if not success then 1099 error('failed to write to "'..editor.filename..'": '..message) 1100 end 1101 editor.modified = nil 1102end 1103 1104---- helpers for the search bar (C-f) 1105 1106function I.draw_search_bar(editor) 1107 local screen_width, screen_height = love.graphics.getDimensions() 1108 local h = editor.line_height+2 1109 local y = screen_height-h 1110 love.graphics.setColor(0.9,0.9,0.9) 1111 love.graphics.rectangle('fill', 0, y-10, screen_width-1, h+8) 1112 love.graphics.setColor(0.6,0.6,0.6) 1113 love.graphics.line(0, y-10, screen_width-1, y-10) 1114 love.graphics.setColor(1,1,1) 1115 love.graphics.rectangle('fill', 20, y-6, screen_width-40, h+2, 2,2) 1116 love.graphics.setColor(0.6,0.6,0.6) 1117 love.graphics.rectangle('line', 20, y-6, screen_width-40, h+2, 2,2) 1118 love.graphics.setColor(Foreground_color) 1119 love.graphics.print(editor.search_term, 25,y-5) 1120 I.draw_text_cursor(editor, 25+editor.font:getWidth(editor.search_term),y-5, editor.line_height) 1121end 1122 1123function I.search_next(editor) 1124 if #editor.search_term == 0 then return end 1125 local offset 1126 -- search current line from cursor 1127 local curr_pos = editor.cursor.pos 1128 local curr_line = editor.lines[editor.cursor.line].data 1129 local curr_offset = my_utf8.offset(curr_line, curr_pos) 1130 offset = I.find(curr_line, editor.search_term, curr_offset, --[[literal]] true) 1131 if offset then 1132 editor.cursor.pos = utf8.len(curr_line, 1, offset) 1133 end 1134 if offset == nil then 1135 -- search lines below cursor 1136 for i=editor.cursor.line+1,#editor.lines do 1137 local curr_line = editor.lines[i].data 1138 offset = I.find(curr_line, editor.search_term, --[[from start]] nil, --[[literal]] true) 1139 if offset then 1140 editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)} 1141 break 1142 end 1143 end 1144 end 1145 if offset == nil then 1146 -- wrap around 1147 for i=1,editor.cursor.line-1 do 1148 local curr_line = editor.lines[i].data 1149 offset = I.find(curr_line, editor.search_term, --[[from start]] nil, --[[literal]] true) 1150 if offset then 1151 editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)} 1152 break 1153 end 1154 end 1155 end 1156 if offset == nil then 1157 -- search current line until cursor 1158 local curr_line = editor.lines[editor.cursor.line].data 1159 offset = I.find(curr_line, editor.search_term, --[[from start]] nil, --[[literal]] true) 1160 local pos = utf8.len(curr_line, 1, offset) 1161 if pos and pos < editor.cursor.pos then 1162 editor.cursor.pos = pos 1163 end 1164 end 1165 if offset then 1166 move.maybe_snap_cursor_to_bottom_of_screen(editor) 1167 else 1168 -- roll back 1169 editor.cursor = t.deepcopy(editor.search_backup.cursor) 1170 editor.screen_top = t.deepcopy(editor.search_backup.screen_top) 1171 end 1172end 1173 1174function I.search_previous(editor) 1175 if #editor.search_term == 0 then return end 1176 local offset 1177 -- search current line before cursor 1178 local curr_pos = editor.cursor.pos 1179 local curr_line = editor.lines[editor.cursor.line].data 1180 local curr_offset = my_utf8.offset(curr_line, curr_pos) 1181 offset = I.rfind(curr_line, editor.search_term, curr_offset-1, --[[literal]] true) 1182 if offset then 1183 editor.cursor.pos = utf8.len(curr_line, 1, offset) 1184 end 1185 if offset == nil then 1186 -- search lines above cursor 1187 for i=editor.cursor.line-1,1,-1 do 1188 local curr_line = editor.lines[i].data 1189 offset = I.rfind(curr_line, editor.search_term, --[[from end]] nil, --[[literal]] true) 1190 if offset then 1191 editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)} 1192 break 1193 end 1194 end 1195 end 1196 if offset == nil then 1197 -- wrap around 1198 for i=#editor.lines,editor.cursor.line+1,-1 do 1199 local curr_line = editor.lines[i].data 1200 offset = I.rfind(curr_line, editor.search_term, --[[from end]] nil, --[[literal]] true) 1201 if offset then 1202 editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)} 1203 break 1204 end 1205 end 1206 end 1207 if offset == nil then 1208 -- search current line after cursor 1209 local curr_line = editor.lines[editor.cursor.line].data 1210 offset = I.rfind(curr_line, editor.search_term, --[[from end]] nil, --[[literal]] true) 1211 local pos = utf8.len(curr_line, 1, offset) 1212 if pos and pos > editor.cursor.pos then 1213 editor.cursor.pos = pos 1214 end 1215 end 1216 if offset then 1217 move.maybe_snap_cursor_to_top_of_screen(editor) 1218 else 1219 -- roll back 1220 editor.cursor = t.deepcopy(editor.search_backup.cursor) 1221 editor.screen_top = t.deepcopy(editor.search_backup.screen_top) 1222 end 1223end 1224 1225-- return true if cursor marks start of search term and line_index,pos is in that region 1226function I.in_search(editor, line_index, pos) 1227 if editor.search_term == nil then return false end 1228 if #editor.search_term == 0 then return false end 1229 if line_index ~= editor.cursor.line then return false end 1230 return I.find_at(editor.lines[line_index].data, editor.search_term, editor.cursor.pos) 1231 and editor.cursor.pos <= pos and pos <= editor.cursor.pos+utf8.len(editor.search_term)-1 1232end 1233 1234function I.find_at(data, pat, pos) 1235 local offset = utf8.offset(data, pos) 1236 return data:sub(offset, offset+#pat-1) == pat 1237end 1238 1239function I.find(s, pat, i, plain) 1240 if s == nil then return end 1241 return s:find(pat, i, plain) 1242end 1243 1244-- TODO: avoid the expensive reverse() operations 1245-- Particularly if we only care about literal matches, we don't need all of string.find 1246function I.rfind(s, pat, i, plain) 1247 if s == nil then return end 1248 if #pat == 0 then return #s end 1249 local rs = s:reverse() 1250 local rpat = pat:reverse() 1251 if i == nil then i = #s end 1252 local ri = #s - i + 1 1253 local rendpos = rs:find(rpat, ri, plain) 1254 if rendpos == nil then return nil end 1255 local endpos = #s - rendpos + 1 1256 assert (endpos >= #pat, ('rfind: endpos %d should be >= #pat %d at this point'):format(endpos, #pat)) 1257 return endpos-#pat+1 1258end 1259 1260function test_rfind() 1261 check_eq(I.rfind('abc', ''), 3, 'empty pattern') 1262 check_eq(I.rfind('abc', 'c'), 3, 'final char') 1263 check_eq(I.rfind('acbc', 'c', 3), 2, 'previous char') 1264 check_nil(I.rfind('abc', 'd'), 'missing char') 1265 check_nil(I.rfind('abc', 'c', 2), 'no more char') 1266end 1267 1268---- helpers for selecting portions of text 1269 1270function I.cut_selection(editor) 1271 if editor.selection.line == nil then return end 1272 local result = I.selection(editor) 1273 I.delete_selection_and_record_undo_event(editor) 1274 return result 1275end 1276 1277function I.delete_selection_and_record_undo_event(editor) 1278 if editor.selection.line == nil then return end 1279 local minl,maxl = t.minmax(editor.selection.line, editor.cursor.line) 1280 local before = I.snapshot(editor, minl, maxl) 1281 I.delete_selection_without_undo(editor) 1282 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)}) 1283end 1284 1285function I.delete_selection_without_undo(editor) 1286 if editor.selection.line == nil then return end 1287 -- min,max = sorted(editor.selection,editor.cursor) 1288 local minl,minp = editor.selection.line,editor.selection.pos 1289 local maxl,maxp = editor.cursor.line,editor.cursor.pos 1290 if minl > maxl then 1291 minl,maxl = maxl,minl 1292 minp,maxp = maxp,minp 1293 elseif minl == maxl then 1294 if minp > maxp then 1295 minp,maxp = maxp,minp 1296 end 1297 end 1298 -- update editor.cursor and editor.selection 1299 editor.cursor.line = minl 1300 editor.cursor.pos = minp 1301 if Loc.lt(editor.cursor, editor.screen_top) then 1302 editor.screen_top = move.internal.loc_hor(editor, editor.cursor, editor.left) 1303 end 1304 editor.selection = {} 1305 -- delete everything between min (inclusive) and max (exclusive) 1306 local min_offset = my_utf8.offset(editor.lines[minl].data, minp) 1307 local max_offset = my_utf8.offset(editor.lines[maxl].data, maxp) 1308 if minl == maxl then 1309 editor.lines[minl].data = editor.lines[minl].data:sub(1, min_offset-1)..editor.lines[minl].data:sub(max_offset) 1310 return 1311 end 1312 assert(minl < maxl, ('minl %d not < maxl %d'):format(minl, maxl)) 1313 local rhs = editor.lines[maxl].data:sub(max_offset) 1314 for i=maxl,minl+1,-1 do 1315 table.remove(editor.lines, i) 1316 end 1317 editor.lines[minl].data = editor.lines[minl].data:sub(1, min_offset-1)..rhs 1318end 1319 1320function I.selection(editor) 1321 if editor.selection.line == nil then return end 1322 -- min,max = sorted(editor.selection,editor.cursor) 1323 local minl,minp = editor.selection.line,editor.selection.pos 1324 local maxl,maxp = editor.cursor.line,editor.cursor.pos 1325 if minl > maxl then 1326 minl,maxl = maxl,minl 1327 minp,maxp = maxp,minp 1328 elseif minl == maxl then 1329 if minp > maxp then 1330 minp,maxp = maxp,minp 1331 end 1332 end 1333 local min_offset = my_utf8.offset(editor.lines[minl].data, minp) 1334 local max_offset = my_utf8.offset(editor.lines[maxl].data, maxp) 1335 if minl == maxl then 1336 return editor.lines[minl].data:sub(min_offset, max_offset-1) 1337 end 1338 assert(minl < maxl, ('minl %d not < maxl %d'):format(minl, maxl)) 1339 local result = {editor.lines[minl].data:sub(min_offset)} 1340 for i=minl+1,maxl-1 do 1341 table.insert(result, editor.lines[i].data) 1342 end 1343 table.insert(result, editor.lines[maxl].data:sub(1, max_offset-1)) 1344 return table.concat(result, '\n') 1345end 1346 1347function I.in_selection(editor, line_index, pos, cursor) 1348 if editor.selection.line == nil then return false end 1349 local curr = {line=line_index, pos=pos} 1350 if Loc.eq(cursor, editor.selection) then 1351 return false 1352 elseif Loc.lt(cursor, editor.selection) then 1353 return Loc.le(cursor, curr) and Loc.lt(curr, editor.selection) 1354 elseif Loc.lt(editor.selection, cursor) then 1355 return Loc.le(editor.selection, curr) and Loc.lt(curr, cursor) 1356 end 1357end 1358 1359---- undo/redo 1360 1361-- undo/redo by managing the sequence of events in the current session 1362-- based on https://github.com/akkartik/mu1/blob/master/edit/012-editor-undo.mu 1363 1364-- makes a copy of lines on every single keystroke; will be inefficient with really long lines. 1365-- TODO: highlight stuff inserted by any undo/redo operation 1366-- TODO: coalesce multiple similar operations 1367 1368function I.record_undo_event(editor, data) 1369 editor.history[editor.next_history] = data 1370 editor.next_history = editor.next_history+1 1371 for i=editor.next_history,#editor.history do 1372 editor.history[i] = nil 1373 end 1374end 1375 1376function I.undo_event(editor) 1377 if editor.next_history > 1 then 1378--? print('moving to history', editor.next_history-1) 1379 editor.next_history = editor.next_history-1 1380 local result = editor.history[editor.next_history] 1381 return result 1382 end 1383end 1384 1385function I.redo_event(editor) 1386 if editor.next_history <= #editor.history then 1387--? print('restoring history', editor.next_history+1) 1388 local result = editor.history[editor.next_history] 1389 editor.next_history = editor.next_history+1 1390 return result 1391 end 1392end 1393 1394-- Copy all relevant global editor. 1395-- Make copies of objects; the rest of the app may mutate them in place, but undo requires immutable histories. 1396function I.snapshot(editor, s,e) 1397 -- Snapshot everything by default, but subset if requested. 1398 assert(s, 'failed to snapshot operation for undo history') 1399 if e == nil then 1400 e = s 1401 end 1402 assert(#editor.lines > 0, 'failed to snapshot operation for undo history') 1403 if s < 1 then s = 1 end 1404 if s > #editor.lines then s = #editor.lines end 1405 if e < 1 then e = 1 end 1406 if e > #editor.lines then e = #editor.lines end 1407 -- compare with edit.new 1408 local event = { 1409 screen_top=t.deepcopy(editor.screen_top), 1410 selection=t.deepcopy(editor.selection), 1411 cursor=t.deepcopy(editor.cursor), 1412 lines={}, 1413 start_line=s, 1414 end_line=e, 1415 -- no filename; undo history is cleared when filename changes 1416 } 1417 for i=s,e do 1418 table.insert(event.lines, t.deepcopy(editor.lines[i])) 1419 end 1420 return event 1421end 1422 1423function I.patch(lines, from, to) 1424--? if #from.lines == 1 and #to.lines == 1 then 1425--? assert(from.start_line == from.end_line) 1426--? assert(to.start_line == to.end_line) 1427--? assert(from.start_line == to.start_line) 1428--? lines[from.start_line] = to.lines[1] 1429--? return 1430--? end 1431 assert(from.start_line == to.start_line, 'failed to patch undo operation') 1432 for i=from.end_line,from.start_line,-1 do 1433 table.remove(lines, i) 1434 end 1435 assert(#to.lines == to.end_line-to.start_line+1, 'failed to patch undo operation') 1436 for i=1,#to.lines do 1437 table.insert(lines, to.start_line+i-1, to.lines[i]) 1438 end 1439end 1440 1441return edit