Merge template-live-editor

This commit is contained in:
Kartik K. Agaram 2023-06-04 15:36:26 -07:00
commit b8d5e24834
7 changed files with 272 additions and 39 deletions

View File

@ -2,15 +2,18 @@ resync_editors = function()
edit.draw(Editors[1], Text_color) -- initialize screen_bottom1
for e=2,#Editors do
local editor = Editors[e]
editor.screen_top1 = {line=Editors[e-1].screen_bottom1.line, pos=Editors[e-1].screen_bottom1.pos}
-- scroll down one line
Text.populate_screen_line_starting_pos(editor, editor.screen_top1.line)
local _, screen_line_index = Text.pos_at_start_of_screen_line(editor, editor.screen_top1)
if screen_line_index < #editor.line_cache[editor.screen_top1.line].screen_line_starting_pos then
editor.screen_top1.pos = editor.line_cache[editor.screen_top1.line].screen_line_starting_pos[screen_line_index+1]
else
editor.screen_top1 = {line=editor.screen_top1.line+1, pos=1}
if Editors[e-1].screen_bottom1.line then
-- there's something left to draw on this column
editor.screen_top1 = {line=Editors[e-1].screen_bottom1.line, pos=Editors[e-1].screen_bottom1.pos}
-- scroll down one line
Text.populate_screen_line_starting_pos(editor, editor.screen_top1.line)
local _, screen_line_index = Text.pos_at_start_of_screen_line(editor, editor.screen_top1)
if screen_line_index < #editor.line_cache[editor.screen_top1.line].screen_line_starting_pos then
editor.screen_top1.pos = editor.line_cache[editor.screen_top1.line].screen_line_starting_pos[screen_line_index+1]
else
editor.screen_top1 = {line=editor.screen_top1.line+1, pos=1}
end
edit.draw(Editors[e], Text_color) -- initialize screen_bottom1
end
edit.draw(Editors[e], Text_color) -- initialize screen_bottom1
end
end
end

View File

@ -48,8 +48,6 @@ function edit.initialize_state(top, left, right, font_height, line_height) -- c
old_cursor1 = nil,
old_selection1 = nil,
mousepress_shift = nil,
-- when selecting text, avoid recomputing some state on every single frame
recent_mouse = {},
-- cursor coordinates in pixels (nil => no cursor drawn in viewport)
cursor_x = 0,
@ -103,21 +101,22 @@ function edit.draw(State, fg, hide_cursor)
State.cursor_x = nil
State.cursor_y = nil
local y = State.top
local screen_bottom1 = {line=nil, pos=nil}
--? print('== draw')
for line_index = State.screen_top1.line,#State.lines do
local line = State.lines[line_index]
--? print('draw:', y, line_index, line)
if y + State.line_height > App.screen.height then break end
State.screen_bottom1 = {line=line_index, pos=nil}
screen_bottom1.line = line_index
--? print('text.draw', y, line_index)
local startpos = 1
if line_index == State.screen_top1.line then
startpos = State.screen_top1.pos
end
y, State.screen_bottom1.pos = Text.draw(State, line_index, y, startpos, fg, hide_cursor)
y, screen_bottom1.pos = Text.draw(State, line_index, y, startpos, fg, hide_cursor)
--? print('=> y', y)
end
--? print('screen bottom: '..tostring(State.screen_bottom1.pos)..' in '..tostring(State.lines[State.screen_bottom1.line].data))
State.screen_bottom1 = screen_bottom1
if State.search_term then
Text.draw_search_bar(State, hide_cursor)
end
@ -147,7 +146,18 @@ end
function edit.mouse_press(State, x,y, mouse_button)
if State.search_term then return end
--? print('press', State.cursor1.line)
--? print_and_log(('edit.mouse_press: cursor at %d,%d'):format(State.cursor1.line, State.cursor1.pos))
if y < State.top then
State.old_cursor1 = State.cursor1
State.old_selection1 = State.selection1
State.mousepress_shift = App.shift_down()
State.selection1 = {
line=State.screen_top1.line,
pos=State.screen_top1.pos,
}
return
end
for line_index,line in ipairs(State.lines) do
if Text.in_line(State, line_index, x,y) then
-- delicate dance between cursor, selection and old cursor/selection
@ -158,7 +168,8 @@ function edit.mouse_press(State, x,y, mouse_button)
-- 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_released should never look at shift state
-- i.e. mouse_release should never look at shift state
--? print_and_log(('edit.mouse_press: in line %d'):format(line_index))
State.old_cursor1 = State.cursor1
State.old_selection1 = State.selection1
State.mousepress_shift = App.shift_down()
@ -166,23 +177,31 @@ function edit.mouse_press(State, x,y, mouse_button)
line=line_index,
pos=Text.to_pos_on_line(State, line_index, x, y),
}
--? print('selection', State.selection1.line, State.selection1.pos)
break
return
end
end
-- still here? click is below all screen lines
State.old_cursor1 = State.cursor1
State.old_selection1 = State.selection1
State.mousepress_shift = App.shift_down()
State.selection1 = {
line=State.screen_bottom1.line,
pos=Text.pos_at_end_of_screen_line(State, State.screen_bottom1),
}
end
function edit.mouse_release(State, x,y, mouse_button)
if State.search_term then return end
--? print('release', State.cursor1.line)
--? print_and_log(('edit.mouse_release(%d,%d): cursor at %d,%d'):format(x,y, State.cursor1.line, State.cursor1.pos))
for line_index,line in ipairs(State.lines) do
if Text.in_line(State, line_index, x,y) then
--? print('reset selection')
--? print_and_log(('edit.mouse_release: in line %d'):format(line_index))
State.cursor1 = {
line=line_index,
pos=Text.to_pos_on_line(State, line_index, x, y),
}
--? print('cursor', State.cursor1.line, State.cursor1.pos)
--? print_and_log(('edit.mouse_release: cursor now %d,%d'):format(State.cursor1.line, State.cursor1.pos))
if State.mousepress_shift then
if State.old_selection1.line == nil then
State.selection1 = State.old_cursor1
@ -197,7 +216,7 @@ function edit.mouse_release(State, x,y, mouse_button)
break
end
end
--? print('selection:', State.selection1.line, State.selection1.pos)
--? print_and_log(('edit.mouse_release: finally selection %s,%s cursor %d,%d'):format(tostring(State.selection1.line), tostring(State.selection1.pos), State.cursor1.line, State.cursor1.pos))
end
function edit.mouse_wheel_move(State, dx,dy)

View File

@ -72,6 +72,11 @@ function App.initialize(arg)
end
end
function print_and_log(s)
print(s)
log(3, s)
end
function love.quit()
if on.quit then on.quit() end
love.filesystem.write('config', json.encode(settings()))

View File

@ -29,7 +29,6 @@ function Text.clip_selection(State, line_index, apos, bpos)
-- compare bounds more carefully (start inclusive, end exclusive)
local a_ge = Text.le1({line=minl, pos=minp}, {line=line_index, pos=apos})
local b_lt = Text.lt1({line=line_index, pos=bpos}, {line=maxl, pos=maxp})
--? print(minl,line_index,maxl, '--', minp,apos,bpos,maxp, '--', a_ge,b_lt)
if a_ge and b_lt then
-- fully contained
return apos,bpos
@ -60,7 +59,6 @@ function Text.draw_highlight(State, line, x,y, pos, lo,hi)
local before = line.data:sub(pos_offset, lo_offset-1)
lo_px = App.width(before)
end
--? print(lo,pos,hi, '--', lo_offset,pos_offset,hi_offset, '--', lo_px)
local s = line.data:sub(lo_offset, hi_offset-1)
App.color(Highlight_color)
love.graphics.rectangle('fill', x+lo_px,y, App.width(s),State.line_height)
@ -69,27 +67,17 @@ function Text.draw_highlight(State, line, x,y, pos, lo,hi)
end
end
-- inefficient for some reason, so don't do it on every frame
function Text.mouse_pos(State)
local time = love.timer.getTime()
if State.recent_mouse.time and State.recent_mouse.time > time-0.1 then
return State.recent_mouse.line, State.recent_mouse.pos
local x,y = App.mouse_x(), App.mouse_y()
if y < State.line_cache[State.screen_top1.line].starty then
return State.screen_top1.line, State.screen_top1.pos
end
State.recent_mouse.time = time
local line,pos = Text.to_pos(State, App.mouse_x(), App.mouse_y())
if line then
State.recent_mouse.line = line
State.recent_mouse.pos = pos
end
return State.recent_mouse.line, State.recent_mouse.pos
end
function Text.to_pos(State, x,y)
for line_index,line in ipairs(State.lines) do
if Text.in_line(State, line_index, x,y) then
return line_index, Text.to_pos_on_line(State, line_index, x,y)
end
end
return State.screen_bottom1.line, Text.pos_at_end_of_screen_line(State, State.screen_bottom1)
end
function Text.cut_selection(State)

View File

@ -542,6 +542,7 @@ function Text.right_without_scroll(State)
end
end
-- result: pos, index of screen line
function Text.pos_at_start_of_screen_line(State, loc1)
Text.populate_screen_line_starting_pos(State, loc1.line)
local line_cache = State.line_cache[loc1.line]
@ -554,6 +555,20 @@ function Text.pos_at_start_of_screen_line(State, loc1)
assert(false)
end
function Text.pos_at_end_of_screen_line(State, loc1)
Text.populate_screen_line_starting_pos(State, loc1.line)
local line_cache = State.line_cache[loc1.line]
local most_recent_final_pos = utf8.len(State.lines[loc1.line].data)+1
for i=#line_cache.screen_line_starting_pos,1,-1 do
local spos = line_cache.screen_line_starting_pos[i]
if spos <= loc1.pos then
return most_recent_final_pos
end
most_recent_final_pos = spos-1
end
assert(false)
end
function Text.cursor_at_final_screen_line(State)
Text.populate_screen_line_starting_pos(State, State.cursor1.line)
local screen_lines = State.line_cache[State.cursor1.line].screen_line_starting_pos

118
text_tests Normal file
View File

@ -0,0 +1,118 @@
== Summary of tests for the text editor
This doesn't include all tests and might not change between forks, but it's
intended to be the "timeless core" of a text editor widget that shouldn't
change across forks.
# basic
initial state
draw text
draw wrapping text
draw word wrapping text
draw text wrapping within word
draw wrapping text containing non ascii
# mouse
click moves cursor
click to left of line
click takes margins into account
click on empty line
click on wrapping line
click on wrapping line takes margins into account
click on wrapping line
click on wrapping line rendered from partway at top of screen
click past end of wrapping line
click past end of wrapping line containing non ascii
click past end of word wrapping line
# cursor movement
move left
move left to previous line
move right
move right to next line
move to start of word
move to start of previous word
move to start of word on previous line
move past end of word
move past end of word on next line
skip to previous word
skip past tab to previous word
skip multiple spaces to previous word
skip to next word
skip past tab to next word
skip multiple spaces to next word
# mutating text
insert first character
edit wrapping text
insert newline
insert newline at start of line
insert from clipboard
backspace from start of final line
backspace past line boundary
backspace over selection
backspace over selection reverse
backspace over multiple lines
backspace to end of line
backspace to start of line
# scroll
pagedown
pagedown often shows start of wrapping line
pagedown can start from middle of long wrapping line
pagedown never moves up
down arrow moves cursor
down arrow scrolls down by one line
down arrow scrolls down by one screen line
down arrow scrolls down by one screen line after splitting within word
pagedown followed by down arrow does not scroll screen up
up arrow moves cursor
up arrow scrolls up by one line
up arrow scrolls up by one screen line
up arrow scrolls up to final screen line
up arrow scrolls up to empty line
pageup
pageup scrolls up by screen line
pageup scrolls up from middle screen line
enter on bottom line scrolls down
enter on final line avoids scrolling down when not at bottom
inserting text on final line avoids scrolling down when not at bottom
typing on bottom line scrolls down
left arrow scrolls up in wrapped line
right arrow scrolls down in wrapped line
home scrolls up in wrapped line
end scrolls down in wrapped line
position cursor on recently edited wrapping line
backspace can scroll up
backspace can scroll up screen line
# selection
select text using shift and cursor movement operations
select text using mouse
clicking to left of a line = start of line
clicking to right of a line = end of line
clicking above topmost line = top of screen
clicking below bottom-most line = bottom of screen
select text using mouse and shift
select text repeatedly using mouse and shift
cursor movement without shift resets selection
mouse click without shift resets selection
edit deletes selection
edit with shift key deletes selection
deleting selection may scroll
copy does not reset selection
cut
cut without selection
paste replaces selection
# search
search
search upwards
search wrap
search wrap upwards
# undo
undo insert text
undo delete text
undo restores selection

View File

@ -1,4 +1,6 @@
-- major tests for text editing flows
-- Arguably this should be called edit_tests.lua,
-- but that would mess up the git blame at this point.
function test_initial_state()
App.screen.init{width=120, height=60}
@ -802,6 +804,67 @@ function test_select_text_using_mouse()
check_eq(Editor_state.cursor1.pos, 4, 'cursor:pos')
end
function test_select_text_using_mouse_starting_above_text()
App.screen.init{width=50, height=60}
Editor_state = edit.initialize_test_state()
Editor_state.lines = load_array{'abc', 'def', 'xyz'}
Text.redraw_all(Editor_state)
Editor_state.cursor1 = {line=1, pos=1}
Editor_state.screen_top1 = {line=1, pos=1}
Editor_state.screen_bottom1 = {}
Editor_state.selection1 = {}
edit.draw(Editor_state, Text_color) -- populate line_cache.starty for each line Editor_state.line_cache
-- press mouse above first line of text
edit.run_after_mouse_press(Editor_state, Editor_state.left+8,5, 1)
check(Editor_state.selection1.line ~= nil, 'selection:line-not-nil')
check_eq(Editor_state.selection1.line, 1, 'selection:line')
check_eq(Editor_state.selection1.pos, 1, 'selection:pos')
end
function test_select_text_using_mouse_starting_above_text_wrapping_line()
-- first screen line starts in the middle of a line
App.screen.init{width=50, height=60}
Editor_state = edit.initialize_test_state()
Editor_state.lines = load_array{'abc', 'defgh', 'xyz'}
Text.redraw_all(Editor_state)
Editor_state.cursor1 = {line=2, pos=5}
Editor_state.screen_top1 = {line=2, pos=3}
Editor_state.screen_bottom1 = {}
-- press mouse above first line of text
edit.run_after_mouse_press(Editor_state, Editor_state.left+8,5, 1)
-- selection is at screen top
check(Editor_state.selection1.line ~= nil, 'selection:line-not-nil')
check_eq(Editor_state.selection1.line, 2, 'selection:line')
check_eq(Editor_state.selection1.pos, 3, 'selection:pos')
end
function test_select_text_using_mouse_starting_below_text()
-- I'd like to test what happens when a mouse click is below some page of
-- text, potentially even in the middle of a line.
-- However, it's brittle to set up a text line boundary just right.
-- So I'm going to just check things below the bottom of the final line of
-- text when it's in the middle of the screen.
-- final screen line ends in the middle of screen
App.screen.init{width=50, height=60}
Editor_state = edit.initialize_test_state()
Editor_state.lines = load_array{'abcde'}
Text.redraw_all(Editor_state)
Editor_state.cursor1 = {line=1, pos=1}
Editor_state.screen_top1 = {line=1, pos=1}
Editor_state.screen_bottom1 = {}
edit.draw(Editor_state, Text_color)
local y = Editor_state.top
App.screen.check(y, 'ab', 'baseline:screen:1')
y = y + Editor_state.line_height
App.screen.check(y, 'cde', 'baseline:screen:2')
-- press mouse above first line of text
edit.run_after_mouse_press(Editor_state, 5,App.screen.height-5, 1)
-- selection is past bottom-most text in screen
check(Editor_state.selection1.line ~= nil, 'selection:line-not-nil')
check_eq(Editor_state.selection1.line, 1, 'selection:line')
check_eq(Editor_state.selection1.pos, 6, 'selection:pos')
end
function test_select_text_using_mouse_and_shift()
App.screen.init{width=50, height=60}
Editor_state = edit.initialize_test_state()
@ -856,6 +919,28 @@ function test_select_text_repeatedly_using_mouse_and_shift()
check_eq(Editor_state.cursor1.pos, 2, 'cursor:pos')
end
function test_select_all_text()
-- display a single line of text
App.screen.init{width=75, height=80}
Editor_state = edit.initialize_test_state()
Editor_state.lines = load_array{'abc def'}
Text.redraw_all(Editor_state)
Editor_state.cursor1 = {line=1, pos=1}
Editor_state.screen_top1 = {line=1, pos=1}
Editor_state.screen_bottom1 = {}
edit.draw(Editor_state, Text_color)
-- select all
App.fake_key_press('lctrl')
edit.run_after_keychord(Editor_state, 'C-a')
App.fake_key_release('lctrl')
edit.key_release(Editor_state, 'lctrl')
-- selection
check_eq(Editor_state.selection1.line, 1, 'selection:line')
check_eq(Editor_state.selection1.pos, 1, 'selection:pos')
check_eq(Editor_state.cursor1.line, 1, 'cursor:line')
check_eq(Editor_state.cursor1.pos, 8, 'cursor:pos')
end
function test_cut_without_selection()
-- display a few lines
App.screen.init{width=Editor_state.left+30, height=60}