lines2.love/edit.lua

471 lines
17 KiB
Lua

-- some constants people might like to tweak
Text_color = {r=0, g=0, b=0}
Cursor_color = {r=1, g=0, b=0}
Stroke_color = {r=0, g=0, b=0}
Current_stroke_color = {r=0.7, g=0.7, b=0.7} -- in process of being drawn
Current_name_background_color = {r=1, g=0, b=0, a=0.1} -- name currently being edited
Focus_stroke_color = {r=1, g=0, b=0} -- what mouse is hovering over
Highlight_color = {r=0.7, g=0.7, b=0.9} -- selected text
Icon_color = {r=0.7, g=0.7, b=0.7} -- color of current mode icon in drawings
Help_color = {r=0, g=0.5, b=0}
Help_background_color = {r=0, g=0.5, b=0, a=0.1}
Margin_top = 15
Margin_left = 25
Margin_right = 25
Drawing_padding_top = 10
Drawing_padding_bottom = 10
Drawing_padding_height = Drawing_padding_top + Drawing_padding_bottom
Same_point_distance = 4 -- pixel distance at which two points are considered the same
edit = {}
function edit.new(top, left, right, bottom, font, font_height)
local result = {
top = math.floor(top),
left = math.floor(left),
right = math.floor(right),
bottom = math.floor(bottom),
width = right-left,
font = font,
font_height = font_height,
line_height = math.floor(font_height*1.3),
-- The editor is for editing an array of lines. A line is either text or a drawing.
-- The array of lines can never be empty; there must be at least one line for positioning a cursor at.
--
-- A text line is a table with:
-- mode = 'text',
-- string data,
-- A drawing line is a table with:
-- mode = 'drawing'
-- a (h)eight,
-- an array of points, and
-- an array of shapes
-- A shape is a table containing:
-- a mode
-- an array points for mode 'freehand' (raw x,y coords; freehand drawings don't pollute the points array of a drawing)
-- an array vertices for mode 'polygon', 'rectangle', 'square'
-- p1, p2 for mode 'line'
-- center, radius for mode 'circle'
-- center, radius, start_angle, end_angle for mode 'arc'
-- Unless otherwise specified, coord fields are normalized; a drawing is always 256 units wide
-- The field names are carefully chosen so that switching modes in midstream
-- remembers previously entered points where that makes sense.
lines = {{mode='text', data=''}}, -- array of lines
-- We need to track a couple of _locations_:
screen_top = nil, -- location at top of screen, to start drawing from
cursor = nil, -- location where editing will occur
-- Valid location values:
-- {mode='text', line=, pos=}
-- {mode='drawing', line=}
selection1 = {},
-- some extra state to compute selection between mouse press and release
old_cursor1 = nil,
old_selection1 = nil,
mousepress_shift = nil,
current_drawing_mode = 'line', -- one of the available shape modes
previous_drawing_mode = nil, -- extra state for some ephemeral modes like moving/deleting/naming points
filename = love.filesystem.getSourceBaseDirectory()..'/lines.txt', -- '/' should work even on Windows
next_save = nil,
-- undo
history = {},
next_history = 1,
drawing_before = nil, -- extra state for drawing operations
-- search
search_term = nil,
search_backup = nil, -- stuff to restore when cancelling search
}
return result
end
function edit.scroll_to_top(editor)
assert(#editor.lines > 0)
if editor.lines[1].mode == 'text' then
editor.screen_top = {mode='text', line=1, pos=1}
editor.cursor = {mode='text', line=1, pos=1}
else
editor.screen_top = {mode='drawing', line=1}
editor.cursor = {mode='drawing', line=1}
end
end
function edit.valid_loc(editor, loc)
assert(#editor.lines > 0)
if loc == nil then return end
if loc.line > #editor.lines then return end
return editor.lines[loc.line].mode == loc.mode
end
function edit.draw(editor)
editor.button_handlers = {}
love.graphics.setFont(editor.font)
local cursor_or_mouse_loc = editor.cursor
if editor.mouse_down then
local loc = edit.mouse_loc(editor)
if loc.mode == 'text' then
cursor_or_mouse_loc = loc
end
end
local y = editor.top
for line_index, line in array.each(editor.lines, editor.screen_top.line) do
local rect
if line_index == editor.screen_top.line then
rect = edit.get_rect(editor, editor.screen_top)
else
rect = edit.get_rect(editor, {mode=line.mode, line=line_index, pos=1})
end
if rect.screen_line_rects then
-- text line
if #line.data == 0 then
-- empty line; just add a button to insert a new drawing
button(editor, 'draw', {x=editor.left-Margin_left+4, y=y+4, w=12,h=12, bg={r=1,g=1,b=0},
icon = icon.insert_drawing,
onpress1 = function()
editor.drawing_before = snapshot(editor, line_index-1, line_index)
table.insert(editor.lines, line_index, {mode='drawing', y=y, h=256/2, points={}, shapes={}, pending={}})
editor.cursor = {mode='drawing', line=line_index}
if line_index == editor.screen_top.line then
editor.screen_top = {mode='drawing', line=line_index}
end
schedule_save(editor)
record_undo_event(editor, {before=editor.drawing_before, after=snapshot(editor, line_index-1, line_index+1)})
editor.drawing_before = nil
end,
})
else
for _,s in ipairs(rect.screen_line_rects) do
for _,c in ipairs(s.char_rects) do
if in_selection(editor, line_index, c.pos, cursor_or_mouse_loc)
or in_search(editor, line_index, c.pos)
then
App.color(Highlight_color)
love.graphics.rectangle('fill', editor.left+c.x, y+c.y, c.dx,c.dy)
end
if c.data then
App.color(Text_color)
love.graphics.print(c.data, editor.left+c.x, y+c.y)
end
if line_index == editor.cursor.line then
if c.pos == editor.cursor.pos then
Text.draw_cursor(editor, editor.left+c.x, y+c.y)
end
end
end
end
end
-- draw cursor if it's at end of line
if line_index == editor.cursor.line and editor.cursor.pos == utf8.len(line.data)+1 then
local s = rect.screen_line_rects
local c = s[#s].char_rects
Text.draw_cursor(editor, editor.left+c[#c].x, y+c[#c].y)
end
else
-- drawing
Drawing.draw(editor, line_index, y+Drawing_padding_top)
end
y = y + rect.dy
if y + editor.line_height > editor.bottom then
break
end
end
if editor.search_term then
Text.draw_search_bar(editor)
end
end
function edit.update(editor, dt)
Drawing.update(editor, dt)
if editor.next_save and editor.next_save < Current_time then
save_to_disk(editor)
editor.next_save = nil
end
end
function schedule_save(editor)
if editor.next_save == nil then
editor.next_save = Current_time + 3 -- short enough that you're likely to still remember what you did
end
end
function edit.quit(editor)
-- make sure to save before quitting
if editor.next_save then
save_to_disk(editor)
-- give some time for the OS to flush everything to disk
love.timer.sleep(0.1)
end
end
function edit.mouse_press(editor, mx,my, mouse_button)
if editor.search_term then return end
editor.mouse_down = mouse_button
if mouse_press_consumed_by_any_button(editor, mx,my, mouse_button) then
-- press on a button and it returned 'true' to short-circuit
return
end
local loc = edit.to_loc(editor, mx,my)
if loc == nil then
-- either the click is above the top margin and the top line is not text
-- or the click is below the bottom margin and the bottom line is not text
return
end
editor.cursor = loc
if loc.mode == 'text' then
-- prepare for a drag selecting text
-- delicate dance between cursor/selection and old cursor/selection
-- scenarios:
-- regular press+release: sets cursor, clears selection
-- shift press+release:
-- sets selection to old cursor if not set otherwise leaves it untouched
-- sets cursor
-- press and hold to start a selection: sets selection on press, cursor on release
-- press and hold, then press shift: ignore shift
-- i.e. mouse_release should never look at shift state
editor.old_cursor1 = editor.cursor
editor.old_selection1 = editor.selection1
editor.mousepress_shift = shift_down()
editor.selection1 = deepcopy(loc)
else
editor.drawing_before = snapshot(editor, loc.line)
Drawing.mouse_press(editor, loc.line, mx,my, mouse_button)
end
end
function edit.mouse_release(editor, mx,my, mouse_button)
if editor.search_term then return end
editor.mouse_down = nil
local loc = edit.to_loc(editor, mx,my)
if loc.mode == 'text' then
editor.cursor = loc
edit.clean_up_mouse_press(editor)
else
editor.selection1 = {}
Drawing.mouse_release(editor, mx,my, mouse_button)
schedule_save(editor)
if editor.drawing_before then
record_undo_event(editor, {before=editor.drawing_before, after=snapshot(editor, editor.cursor.line)})
editor.drawing_before = nil
end
end
end
function edit.clean_up_mouse_press(editor)
if editor.mousepress_shift then
if editor.old_selection1.line == nil then
editor.selection1 = editor.old_cursor1
else
editor.selection1 = editor.old_selection1
end
end
editor.old_cursor1, editor.old_selection1, editor.mousepress_shift = nil
if edit.eq(editor.cursor, editor.selection1) then
editor.selection1 = {}
end
end
function edit.mouse_wheel_move(editor, dx,dy)
if dy > 0 then
editor.cursor = deepcopy(editor.screen_top)
for i=1,math.floor(dy) do
Text.up(editor)
end
elseif dy < 0 then
editor.cursor = edit.to_loc(editor, editor.left, editor.bottom-1)
assert(editor.cursor)
for i=1,math.floor(-dy) do
Text.down(editor)
end
end
end
function edit.text_input(editor, t)
if editor.search_term then
editor.search_term = editor.search_term..t
Text.search_next(editor)
return
end
if edit.to_coord(editor, editor.cursor) == nil then return end -- cursor is off screen
-- to be precise, the top-left corner of the cursor is off screen
-- for drawings the cursor is large, which can still be a bit strange
-- large selections can also be strange, though the actual cursor would still be small
if editor.cursor.mode == 'drawing' and editor.current_drawing_mode == 'name' then
-- TODO: there's a bug here where the point being named may be off screen
-- To hit that bug you'd have to press C-n to go into point naming mode
-- while the cursor is on screen, then move the page somehow without going
-- out of naming mode.
local before = snapshot(editor, editor.cursor.line)
local drawing = editor.lines[editor.cursor.line]
local p = drawing.points[drawing.pending.target_point]
p.name = p.name..t
record_undo_event(editor, {before=before, after=snapshot(editor, editor.cursor.line)})
else
Text.text_input(editor, t)
end
schedule_save(editor)
end
function edit.keychord_press(editor, chord, key)
local cursor_on_screen = edit.to_coord(editor, editor.cursor)
if editor.selection1.line and editor.cursor.mode == 'text' and
cursor_on_screen and
-- printable character created using shift key => delete selection
-- (we're not creating any ctrl-shift- or alt-shift- combinations using regular/printable keys)
(not shift_down() or utf8.len(key) == 1) and
chord ~= 'C-a' and chord ~= 'C-c' and chord ~= 'C-x' and chord ~= 'backspace' and chord ~= 'delete' and chord ~= 'C-z' and chord ~= 'C-y' and not is_cursor_movement(key) then
Text.delete_selection_and_record_undo_event(editor)
end
if editor.search_term then
if chord == 'escape' then
editor.search_term = nil
editor.cursor = editor.search_backup.cursor
editor.screen_top = editor.search_backup.screen_top
editor.search_backup = nil
elseif chord == 'return' then
editor.search_term = nil
editor.search_backup = nil
elseif chord == 'backspace' then
local len = utf8.len(editor.search_term)
local byte_offset = Text.offset(editor.search_term, len)
editor.search_term = string.sub(editor.search_term, 1, byte_offset-1)
editor.cursor = deepcopy(editor.search_backup.cursor)
editor.screen_top = deepcopy(editor.search_backup.screen_top)
Text.search_next(editor)
elseif chord == 'down' then
Text.right(editor)
Text.search_next(editor)
elseif chord == 'up' then
Text.search_previous(editor)
end
return
elseif chord == 'C-f' then
editor.search_term = ''
editor.search_backup = {
cursor=deepcopy(editor.cursor),
screen_top=deepcopy(editor.screen_top),
}
-- zoom
elseif chord == 'C-=' then
edit.update_font_settings(editor, editor.font_height+2)
elseif chord == 'C--' then
if editor.font_height > 2 then
edit.update_font_settings(editor, editor.font_height-2)
end
elseif chord == 'C-0' then
edit.update_font_settings(editor, 20)
-- undo
elseif chord == 'C-z' then
local event = undo_event(editor)
if event then
local src = event.before
editor.screen_top = deepcopy(src.screen_top)
editor.cursor = deepcopy(src.cursor)
editor.selection1 = deepcopy(src.selection)
patch(editor.lines, event.after, event.before)
schedule_save(editor)
end
elseif chord == 'C-y' then
local event = redo_event(editor)
if event then
local src = event.after
editor.screen_top = deepcopy(src.screen_top)
editor.cursor = deepcopy(src.cursor)
editor.selection1 = deepcopy(src.selection)
patch(editor.lines, event.before, event.after)
schedule_save(editor)
end
-- clipboard
elseif chord == 'C-a' and cursor_on_screen then
editor.selection1 = {line=1, pos=1}
editor.cursor = {mode='text', line=#editor.lines, pos=utf8.len(editor.lines[#editor.lines].data)+1}
elseif chord == 'C-c' then
local s = Text.selection(editor)
if s then
love.system.setClipboardText(s)
end
elseif chord == 'C-x' and cursor_on_screen then
local s = Text.cut_selection_and_record_undo_event(editor)
if s then
love.system.setClipboardText(s)
end
schedule_save(editor)
elseif chord == 'C-v' and cursor_on_screen then
-- We don't have a good sense of when to scroll, so we'll be conservative
-- and sometimes scroll when we didn't quite need to.
local before_line = editor.cursor.line
local before = snapshot(editor, before_line)
local clipboard_data = love.system.getClipboardText()
for _,code in utf8.codes(clipboard_data) do
local c = utf8.char(code)
if c == '\n' then
Text.insert_return(editor)
else
Text.insert_at_cursor(editor, c)
end
end
maybe_snap_cursor_to_bottom_of_screen(editor)
record_undo_event(editor, {before=before, after=snapshot(editor, before_line, editor.cursor.line)})
schedule_save(editor)
-- dispatch to drawing or text
elseif love.mouse.isDown(1) or chord:sub(1,2) == 'C-' then
if editor.cursor.mode == 'drawing' then
local before = snapshot(editor, editor.cursor.line)
Drawing.keychord_press(editor, chord)
record_undo_event(editor, {before=before, after=snapshot(editor, editor.cursor.line)})
schedule_save(editor)
end
elseif chord == 'escape' and not love.mouse.isDown(1) then
for _,line in ipairs(editor.lines) do
if line.mode == 'drawing' then
line.show_help = false
end
end
elseif editor.cursor.mode == 'drawing' and editor.current_drawing_mode == 'name' then
if chord == 'return' then
editor.current_drawing_mode = editor.previous_drawing_mode
editor.previous_drawing_mode = nil
else
local before = snapshot(editor, editor.cursor.line)
local drawing = editor.lines[editor.cursor.line]
local p = drawing.points[drawing.pending.target_point]
if chord == 'escape' then
p.name = nil
record_undo_event(editor, {before=before, after=snapshot(editor, editor.cursor.line)})
elseif chord == 'backspace' then
local len = utf8.len(p.name)
if len > 0 then
local byte_offset = Text.offset(p.name, len-1)
if len == 1 then byte_offset = 0 end
p.name = string.sub(p.name, 1, byte_offset)
record_undo_event(editor, {before=before, after=snapshot(editor, editor.cursor.line)})
end
end
end
schedule_save(editor)
elseif cursor_on_screen then
Text.keychord_press(editor, chord)
end
end
function edit.key_release(editor, key, scancode)
end
function edit.update_font_settings(editor, font_height)
editor.font_height = font_height
editor.font = love.graphics.newFont(editor.font_height)
editor.line_height = math.floor(font_height*1.3)
end