B:BD[
3.1746] → [
2.1718:1718]
B:BD[
5.97469] → [
6.2063:2140]
B:BD[
7.13154] → [
2.1776:1776]
B:BD[
8.7181] → [
9.9066:9114]
B:BD[
8.7636] → [
9.9115:9847]
B:BD[
8.21703] → [
2.1778:1778]
∅:D[
10.1400] → [
8.20585:21703]
B:BD[
8.20585] → [
8.20585:21703]
B:BD[
8.20551] → [
10.1364:1400]
B:BD[
8.20514] → [
8.20514:20551]
∅:D[
9.11332] → [
8.20043:20204]
B:BD[
8.20043] → [
8.20043:20204]
B:BD[
8.19839] → [
9.11273:11332]
∅:D[
9.11272] → [
8.19794:19839]
B:BD[
8.19794] → [
8.19794:19839]
∅:D[
11.1393] → [
9.11216:11272]
B:BD[
8.19707] → [
9.11216:11272]
B:BD[
8.19630] → [
11.1345:1393]
∅:D[
9.11215] → [
8.19577:19630]
B:BD[
8.19577] → [
8.19577:19630]
B:BD[
8.19577] → [
9.11163:11215]
∅:D[
9.11162] → [
8.18725:19577]
B:BD[
8.18725] → [
8.18725:19577]
B:BD[
8.18725] → [
9.10634:11162]
∅:D[
9.10633] → [
8.17856:18725]
B:BD[
8.17856] → [
8.17856:18725]
B:BD[
8.17856] → [
9.10510:10633]
∅:D[
12.959] → [
8.12989:17856]
B:BD[
8.12989] → [
8.12989:17856]
B:BD[
8.12915] → [
12.887:959]
∅:D[
9.10509] → [
8.11906:12915]
B:BD[
8.11906] → [
8.11906:12915]
B:BD[
8.11876] → [
9.10490:10509]
∅:D[
9.10489] → [
8.11835:11876]
B:BD[
8.11835] → [
8.11835:11876]
B:BD[
8.11768] → [
9.10385:10489]
∅:D[
9.10384] → [
8.11567:11768]
B:BD[
8.11567] → [
8.11567:11768]
B:BD[
8.11390] → [
9.10218:10384]
∅:D[
9.10217] → [
8.11330:11390]
B:BD[
8.11330] → [
8.11330:11390]
B:BD[
8.11204] → [
9.10065:10217]
∅:D[
10.1363] → [
8.11064:11204]
B:BD[
8.11064] → [
8.11064:11204]
B:BD[
9.10064] → [
10.1268:1363]
B:BD[
8.10809] → [
9.9955:10064]
∅:D[
9.9954] → [
8.9501:10809]
B:BD[
8.9501] → [
8.9501:10809]
B:BD[
8.9342] → [
9.9848:9954]
∅:D[
9.9847] → [
8.7636:9342]
B:BD[
8.7636] → [
8.7636:9342]
∅:D[
9.9114] → [
8.7181:7636]
B:BD[
8.7181] → [
8.7181:7636]
∅:D[
10.1267] → [
8.5984:7181]
B:BD[
8.5984] → [
8.5984:7181]
B:BD[
8.5889] → [
10.1170:1267]
∅:D[
11.1344] → [
8.3380:5889]
B:BD[
8.3380] → [
8.3380:5889]
B:BD[
8.3298] → [
11.1296:1344]
∅:D[
9.9065] → [
8.3181:3298]
B:BD[
8.3181] → [
8.3181:3298]
B:BD[
8.3024] → [
9.8961:9065]
∅:D[
9.8960] → [
8.1860:3024]
B:BD[
8.1860] → [
8.1860:3024]
B:BD[
8.1790] → [
9.8842:8960]
∅:D[
9.8841] → [
8.1460:1790]
B:BD[
8.1460] → [
8.1460:1790]
B:BD[
8.1401] → [
9.8585:8841]
∅:D[
9.8584] → [
8.755:1401]
B:BD[
8.755] → [
8.755:1401]
B:BD[
8.596] → [
9.8478:8584]
∅:D[
10.1169] → [
8.482:596]
B:BD[
8.482] → [
8.482:596]
B:BD[
8.387] → [
10.1072:1169]
B:BD[
7.13154] → [
8.8:387]
∅:D[
9.8477] → [
7.11114:13154]
B:BD[
7.11114] → [
7.11114:13154]
∅:D[
10.1071] → [
9.7711:8477]
B:BD[
7.11086] → [
9.7711:8477]
B:BD[
7.10946] → [
10.976:1071]
∅:D[
11.1295] → [
7.10862:10946]
B:BD[
7.10862] → [
7.10862:10946]
B:BD[
9.7710] → [
11.1249:1295]
B:BD[
7.10601] → [
9.7661:7710]
∅:D[
9.7660] → [
7.10566:10601]
B:BD[
7.10566] → [
7.10566:10601]
B:BD[
7.10239] → [
9.7629:7660]
∅:D[
9.7628] → [
7.10233:10239]
B:BD[
7.10233] → [
7.10233:10239]
B:BD[
7.10183] → [
9.7578:7628]
∅:D[
9.7577] → [
7.10175:10183]
B:BD[
7.10175] → [
7.10175:10183]
B:BD[
7.9999] → [
9.7427:7577]
∅:D[
9.7426] → [
7.9967:9999]
B:BD[
7.9967] → [
7.9967:9999]
B:BD[
7.9760] → [
9.7288:7426]
∅:D[
9.7287] → [
7.9684:9760]
B:BD[
7.9684] → [
7.9684:9760]
B:BD[
7.9603] → [
9.6290:7287]
∅:D[
9.6289] → [
7.9562:9603]
B:BD[
7.9562] → [
7.9562:9603]
∅:D[
10.975] → [
9.6150:6289]
B:BD[
9.6150] → [
9.6150:6289]
B:BD[
9.6057] → [
10.880:975]
∅:D[
11.1248] → [
9.5973:6057]
B:BD[
9.5973] → [
9.5973:6057]
B:BD[
9.5898] → [
11.1202:1248]
B:BD[
7.9540] → [
9.5843:5898]
∅:D[
11.1201] → [
7.8926:9540]
B:BD[
7.8926] → [
7.8926:9540]
B:BD[
7.8852] → [
11.1152:1201]
∅:D[
11.1151] → [
7.8744:8852]
B:BD[
7.8744] → [
7.8744:8852]
B:BD[
7.8670] → [
11.1102:1151]
∅:D[
11.1101] → [
7.8400:8670]
B:BD[
7.8400] → [
7.8400:8670]
B:BD[
7.8326] → [
11.1052:1101]
∅:D[
11.1051] → [
7.8226:8326]
B:BD[
7.8226] → [
7.8226:8326]
B:BD[
7.8152] → [
11.1002:1051]
∅:D[
11.1001] → [
7.7895:8152]
B:BD[
7.7895] → [
7.7895:8152]
B:BD[
7.7821] → [
11.952:1001]
∅:D[
11.951] → [
7.7711:7821]
B:BD[
7.7711] → [
7.7711:7821]
B:BD[
7.7637] → [
11.902:951]
∅:D[
11.901] → [
7.7360:7637]
B:BD[
7.7360] → [
7.7360:7637]
B:BD[
7.7286] → [
11.852:901]
∅:D[
11.851] → [
7.7176:7286]
B:BD[
7.7176] → [
7.7176:7286]
B:BD[
7.7102] → [
11.802:851]
∅:D[
11.801] → [
7.6772:7102]
B:BD[
7.6772] → [
7.6772:7102]
B:BD[
7.6698] → [
11.752:801]
∅:D[
11.751] → [
7.6595:6698]
B:BD[
7.6595] → [
7.6595:6698]
B:BD[
7.6521] → [
11.702:751]
B:BD[
7.6254] → [
7.6254:6521]
∅:D[
13.694] → [
7.6140:6165]
B:BD[
7.6140] → [
7.6140:6165]
B:BD[
7.6140] → [
13.605:694]
∅:D[
14.340] → [
7.4719:6140]
B:BD[
7.4719] → [
7.4719:6140]
B:BD[
7.4659] → [
14.283:340]
B:BD[
7.4593] → [
7.4593:4659]
∅:D[
13.604] → [
7.4479:4504]
B:BD[
7.4479] → [
7.4479:4504]
B:BD[
7.4479] → [
13.515:604]
∅:D[
10.879] → [
7.4197:4479]
B:BD[
7.4197] → [
7.4197:4479]
B:BD[
7.4100] → [
10.780:879]
B:BD[
7.3001] → [
7.3002:4100]
B:BD[
5.101399] → [
7.3001:3001]
B:BD[
5.101277] → [
5.101277:101399]
∅:D[
15.1888] → [
5.99784:100436]
B:BD[
5.99784] → [
5.99784:100436]
B:BD[
5.99728] → [
15.1854:1888]
∅:D[
16.17094] → [
5.99711:99728]
B:BD[
5.99711] → [
5.99711:99728]
∅:D[
14.282] → [
16.17046:17094]
B:BD[
16.17046] → [
16.17046:17094]
B:BD[
16.16986] → [
14.225:282]
B:BD[
5.99711] → [
16.16952:16986]
∅:D[
13.514] → [
5.99676:99711]
B:BD[
5.99676] → [
5.99676:99711]
B:BD[
5.99676] → [
13.489:514]
B:BD[
5.99587] → [
5.99587:99676]
∅:D[
9.5842] → [
5.99554:99562]
B:BD[
5.99554] → [
5.99554:99562]
B:BD[
5.99393] → [
9.5734:5842]
∅:D[
9.5733] → [
5.99140:99393]
B:BD[
5.99140] → [
5.99140:99393]
B:BD[
5.98987] → [
9.5633:5733]
B:BD[
5.98903] → [
5.98903:98987]
∅:D[
13.488] → [
5.98776:98801]
B:BD[
5.98776] → [
5.98776:98801]
B:BD[
5.98776] → [
13.386:488]
∅:D[
16.16951] → [
5.98626:98776]
B:BD[
5.98626] → [
5.98626:98776]
∅:D[
2.1774] → [
5.98477:98626]
B:BD[
5.98477] → [
5.98477:98626]
B:BD[
16.16924] → [
2.1719:1774]
∅:D[
17.149] → [
16.16851:16924]
∅:D[
18.5465] → [
16.16851:16924]
B:BD[
5.98415] → [
16.16851:16924]
B:BD[
18.5422] → [
17.80:149]
B:BD[
5.98299] → [
18.5355:5422]
∅:D[
15.1853] → [
5.98294:98299]
B:BD[
5.98294] → [
5.98294:98299]
∅:D[
19.61] → [
15.1488:1853]
∅:D[
6.2140] → [
15.1488:1853]
B:BD[
5.97469] → [
15.1488:1853]
∅:D[
9.5632] → [
5.97088:97469]
B:BD[
5.97088] → [
5.97088:97469]
B:BD[
5.96937] → [
9.5534:5632]
∅:D[
20.483] → [
5.96884:96937]
B:BD[
5.96884] → [
5.96884:96937]
∅:D[
13.385] → [
20.386:483]
B:BD[
20.386] → [
20.386:483]
B:BD[
20.309] → [
13.310:385]
B:BD[
5.96811] → [
20.247:309]
∅:D[
18.5354] → [
5.96772:96811]
B:BD[
5.96772] → [
5.96772:96811]
B:BD[
5.96738] → [
18.5319:5354]
∅:D[
21.124] → [
5.96733:96738]
∅:D[
7.2999] → [
5.96733:96738]
∅:D[
3.3036] → [
5.96733:96738]
B:BD[
5.96733] → [
5.96733:96738]
B:BD[
3.2956] → [
7.2905:2999]
B:BD[
5.96733] → [
3.2914:2956]
∅:D[
3.2913] → [
5.96727:96733]
B:BD[
5.96727] → [
5.96727:96733]
B:BD[
5.96704] → [
3.2797:2913]
∅:D[
3.2796] → [
5.96696:96704]
∅:D[
22.116410] → [
5.96696:96704]
B:BD[
5.96696] → [
5.96696:96704]
∅:D[
7.2904] → [
3.2466:2796]
B:BD[
3.2466] → [
3.2466:2796]
B:BD[
3.2394] → [
7.2826:2904]
B:BD[
5.92138] → [
3.1870:2394]
∅:D[
3.1869] → [
5.92102:92138]
B:BD[
5.92102] → [
5.92102:92138]
B:BD[
5.92051] → [
3.1822:1869]
∅:D[
3.1821] → [
5.92034:92051]
B:BD[
5.92034] → [
5.92034:92051]
B:BD[
5.92003] → [
3.1787:1821]
B:BD[
23.3966] → [
5.91953:92003]
∅:D[
3.1786] → [
23.3925:3966]
∅:D[
5.91952] → [
23.3925:3966]
∅:D[
22.116187] → [
23.3925:3966]
B:BD[
4.92382] → [
23.3925:3966]
B:BD[
24.3385] → [
3.1747:1786]
∅:D[
2.1718] → [
24.3330:3385]
∅:D[
3.1746] → [
24.3330:3385]
B:BD[
4.92211] → [
24.3330:3385]
∅:D[
2.1716] → [
3.1741:1746]
B:BD[
3.1741] → [
3.1741:1746]
B:BD[
3.1741] → [
2.1702:1716]
B:BD[
24.3329] → [
3.1735:1741]
∅:D[
25.162] → [
3.1049:1053]
B:BD[
24.2376] → [
3.1049:1053]
∅:D[
2.1780] → [
3.1042:1048]
B:BD[
24.2220] → [
3.1042:1048]
B:BD[
26.3258] → [
2.1780:1780]
∅:D[
2.1778] → [
26.2646:3258]
B:BD[
26.2646] → [
26.2646:3258]
∅:D[
2.1701] → [
26.1158:2570]
B:BD[
4.92116] → [
26.1158:2570]
B:BD[
26.1157] → [
2.1693:1701]
∅:D[
2.1692] → [
26.1145:1157]
B:BD[
4.92108] → [
26.1145:1157]
assert(State.lines[State.cursor1.line].mode == 'text', 'line is not text')
assert(State.lines[loc1.line].mode == 'text')
end
function Text.final_text_loc_on_screen(State)
local screen_bottom1 = Text.screen_bottom1(State)
if State.lines[screen_bottom1.line].mode == 'text' then
return {
line=screen_bottom1.line,
pos=Text.pos_at_end_of_screen_line(State, screen_bottom1),
}
end
local loc2 = Text.to2(State, screen_bottom1)
while true do
if State.lines[loc2.line].mode == 'text' then break end
assert(loc2.line > 1 or loc2.screen_line > 1 and loc2.screen_pos > 1) -- elsewhere we're making sure there's always at least one text line on screen
loc2 = Text.previous_screen_line(State, loc2)
end
local result = Text.to1(State, loc2)
result.pos = Text.pos_at_end_of_screen_line(State, result)
return result
-- Perform some early sanity checking here, in hopes that we correctly call
-- this whenever we change editor state.
if State.right <= State.left then
assert(false, ('Right margin %d must be to the right of the left margin %d'):format(State.right, State.left))
end
State.line_cache = {}
for i=1,#State.lines do
State.line_cache[i] = {}
end
end
function Text.clear_screen_line_cache(State, line_index)
State.line_cache[line_index].screen_line_starting_pos = nil
State.line_cache[line_index].link_offsets = nil
end
function trim(s)
return s:gsub('^%s+', ''):gsub('%s+$', '')
end
function ltrim(s)
return s:gsub('^%s+', '')
end
function rtrim(s)
return s:gsub('%s+$', '')
end
function starts_with(s, prefix)
if #s < #prefix then
return false
end
for i=1,#prefix do
if s:sub(i,i) ~= prefix:sub(i,i) then
return false
end
end
return true
end
function ends_with(s, suffix)
if #s < #suffix then
return false
end
for i=0,#suffix-1 do
if s:sub(#s-i,#s-i) ~= suffix:sub(#suffix-i,#suffix-i) then
return false
end
end
return true
end
--? print('clearing line caches')
end
function Text.redraw_all(State)
end
end
end
-- slightly expensive since it redraws the screen
function Text.cursor_out_of_screen(State)
edit.draw(State)
return State.cursor_y == nil
State.cursor1 = Text.final_text_loc_on_screen(State)
if Text.cursor_out_of_screen(State) then
elseif State.cursor1.line >= screen_bottom1.line then
State.cursor1 = deepcopy(State.screen_top1)
if Text.lt1(State.cursor1, State.screen_top1) then
local screen_bottom1 = Text.screen_bottom1(State)
-- resize helper
function Text.tweak_screen_top_and_cursor(State)
if State.screen_top1.pos == 1 then return end
Text.populate_screen_line_starting_pos(State, State.screen_top1.line)
local line = State.lines[State.screen_top1.line]
local line_cache = State.line_cache[State.screen_top1.line]
for i=2,#line_cache.screen_line_starting_pos do
local pos = line_cache.screen_line_starting_pos[i]
if pos == State.screen_top1.pos then
break
end
if pos > State.screen_top1.pos then
-- make sure screen top is at start of a screen line
local prev = line_cache.screen_line_starting_pos[i-1]
if State.screen_top1.pos - prev < pos - State.screen_top1.pos then
State.screen_top1.pos = prev
else
State.screen_top1.pos = pos
end
break
end
end
-- make sure cursor is on screen
function Text.next_screen_line(State, loc2)
if State.lines[loc2.line].mode == 'drawing' then
return {line=loc2.line+1, screen_line=1, screen_pos=1}
end
Text.populate_screen_line_starting_pos(State, loc2.line)
if loc2.screen_line >= #State.line_cache[loc2.line].screen_line_starting_pos then
if loc2.line < #State.lines then
return {line=loc2.line+1, screen_line=1, screen_pos=1}
else
return loc2
end
else
return {line=loc2.line, screen_line=loc2.screen_line+1, screen_pos=1}
end
end
function Text.offset(s, pos1)
if pos1 == 1 then return 1 end
local result = utf8.offset(s, pos1)
if result == nil then
assert(false, ('Text.offset(%d) called on a string of length %d (byte size %d); this is likely a failure to handle utf8\n\n^%s$\n'):format(pos1, utf8.len(s), #s, s))
end
return result
end
function Text.previous_screen_line(State, loc2)
if loc2.screen_line > 1 then
return {line=loc2.line, screen_line=loc2.screen_line-1, screen_pos=1}
elseif loc2.line == 1 then
return loc2
elseif State.lines[loc2.line-1].mode == 'drawing' then
return {line=loc2.line-1, screen_line=1, screen_pos=1}
else
local l = State.lines[loc2.line-1]
Text.populate_screen_line_starting_pos(State, loc2.line-1)
return {line=loc2.line-1, screen_line=#State.line_cache[loc2.line-1].screen_line_starting_pos, screen_pos=1}
end
end
function Text.eq2(a, b)
return a.line == b.line and a.screen_line == b.screen_line and a.screen_pos == b.screen_pos
end
end
local s = string.sub(line.data, screen_line_starting_byte_offset)
--? print('return', mx, Text.nearest_cursor_pos(State.font, s, mx, State.left), '=>', screen_line_starting_pos + Text.nearest_cursor_pos(State.font, s, mx, State.left) - 1)
return screen_line_starting_pos + Text.nearest_cursor_pos(State.font, s, mx, State.left) - 1
end
y = nexty
end
assert(false, 'failed to map y pixel to line')
end
function Text.screen_line_width(State, line_index, i)
local line = State.lines[line_index]
local line_cache = State.line_cache[line_index]
local start_pos = line_cache.screen_line_starting_pos[i]
local start_offset = Text.offset(line.data, start_pos)
local screen_line
if i < #line_cache.screen_line_starting_pos then
local past_end_pos = line_cache.screen_line_starting_pos[i+1]
local past_end_offset = Text.offset(line.data, past_end_pos)
screen_line = string.sub(line.data, start_offset, past_end_offset-1)
else
screen_line = string.sub(line.data, start_pos)
end
return State.font:getWidth(screen_line)
end
function Text.screen_line_index(screen_line_starting_pos, pos)
for i = #screen_line_starting_pos,1,-1 do
if screen_line_starting_pos[i] <= pos then
return i
end
end
end
-- convert x pixel coordinate to pos
-- oblivious to wrapping
-- result: 1 to len+1
function Text.nearest_cursor_pos(font, line, x, left)
if x < left then
return 1
end
local len = utf8.len(line)
local max_x = left+Text.x(font, line, len+1)
if x > max_x then
return len+1
end
local leftpos, rightpos = 1, len+1
--? print('-- nearest', x)
while true do
--? print('nearest', x, '^'..line..'$', leftpos, rightpos)
if leftpos == rightpos then
return leftpos
end
local curr = math.floor((leftpos+rightpos)/2)
local currxmin = left+Text.x(font, line, curr)
local currxmax = left+Text.x(font, line, curr+1)
--? print('nearest', x, leftpos, rightpos, curr, currxmin, currxmax)
if currxmin <= x and x < currxmax then
if x-currxmin < currxmax-x then
return curr
else
return curr+1
end
end
if leftpos >= rightpos-1 then
return rightpos
end
if currxmin > x then
rightpos = curr
else
leftpos = curr
end
end
assert(false, 'failed to map x pixel to pos')
end
-- return the nearest index of line (in utf8 code points) which lies entirely
-- within x pixels of the left margin
-- result: 0 to len+1
function Text.nearest_pos_less_than(font, line, x)
--? print('', '-- nearest_pos_less_than', line, x)
local len = utf8.len(line)
local max_x = Text.x_after(font, line, len)
if x > max_x then
return len+1
end
local left, right = 0, len+1
while true do
local curr = math.floor((left+right)/2)
local currxmin = Text.x_after(font, line, curr+1)
local currxmax = Text.x_after(font, line, curr+2)
--? print('', x, left, right, curr, currxmin, currxmax)
if currxmin <= x and x < currxmax then
return curr
end
if left >= right-1 then
return left
end
if currxmin > x then
right = curr
else
left = curr
end
end
assert(false, 'failed to map x pixel to pos')
end
function Text.x_after(font, s, pos)
local len = utf8.len(s)
local offset = Text.offset(s, math.min(pos+1, len+1))
local s_before = s:sub(1, offset-1)
--? print('^'..s_before..'$')
return font:getWidth(s_before)
end
function Text.x(font, s, pos)
local offset = Text.offset(s, pos)
local s_before = s:sub(1, offset-1)
return font:getWidth(s_before)
end
function Text.to2(State, loc1)
if State.lines[loc1.line].mode == 'drawing' then
return {line=loc1.line, screen_line=1, screen_pos=1}
end
local result = {line=loc1.line}
local line_cache = State.line_cache[loc1.line]
Text.populate_screen_line_starting_pos(State, loc1.line)
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
result.screen_line = i
result.screen_pos = loc1.pos - spos + 1
break
end
end
assert(result.screen_pos, 'failed to convert schema-1 coordinate to schema-2')
return result
end
function Text.to1(State, loc2)
local result = {line=loc2.line, pos=loc2.screen_pos}
if loc2.screen_line > 1 then
result.pos = State.line_cache[loc2.line].screen_line_starting_pos[loc2.screen_line] + loc2.screen_pos - 1
end
return result
end
function Text.eq1(a, b)
return a.line == b.line and a.pos == b.pos
end
function Text.lt1(a, b)
if a.line < b.line then
return true
end
if a.line > b.line then
return false
end
return a.pos < b.pos
end
function Text.le1(a, b)
if a.line < b.line then
return true
end
if a.line > b.line then
return false
end
return a.pos <= b.pos
end
return line_cache.screen_line_starting_pos[screen_line_index+1]
local start_screen_line_index = Text.screen_line_index(line_cache.screen_line_starting_pos, line_cache.startpos)
for screen_line_index = start_screen_line_index,#line_cache.screen_line_starting_pos do
local screen_line_starting_pos = line_cache.screen_line_starting_pos[screen_line_index]
local screen_line_starting_byte_offset = Text.offset(line.data, screen_line_starting_pos)
--? print('iter', y, screen_line_index, screen_line_starting_pos, string.sub(line.data, screen_line_starting_byte_offset))
local nexty = y + State.line_height
if my < nexty then
-- On all wrapped screen lines but the final one, clicks past end of
-- line position cursor on final character of screen line.
-- (The final screen line positions past end of screen line as always.)
if screen_line_index < #line_cache.screen_line_starting_pos and mx > State.left + Text.screen_line_width(State, line_index, screen_line_index) then
--? print('past end of non-final line; return')
local y = starty
-- duplicate some logic from Text.draw
local starty = Text.starty(State, line_index)
assert(my >= starty, 'failed to map y pixel to line')
end
-- convert mx,my in pixels to schema-1 coordinates
function Text.to_pos_on_line(State, line_index, mx, my)
local line = State.lines[line_index]
local line_cache = State.line_cache[line_index]
return y < starty + State.line_height*(#line_cache.screen_line_starting_pos - Text.screen_line_index(line_cache.screen_line_starting_pos, line_cache.startpos) + 1)
Text.populate_screen_line_starting_pos(State, line_index)
local starty = Text.starty(State, line_index)
if starty == nil then return false end -- outside current page
if y < starty then return false end
end
function Text.in_line(State, line_index, x,y)
local line = State.lines[line_index]
local line_cache = State.line_cache[line_index]
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
--? print('snap =>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
--? print('cursor pos '..tostring(State.cursor1.pos)..' is on the #'..tostring(top2.screen_line)..' screen line down')
local y = App.screen.height - State.line_height
-- duplicate some logic from love.draw
while true do
--? print(y, 'top2:', top2.line, top2.screen_line, top2.screen_pos)
if top2.line == 1 and top2.screen_line == 1 then break end
if top2.screen_line > 1 or State.lines[top2.line-1].mode == 'text' then
local h = State.line_height
if y - h < State.top then
break
end
y = y - h
else
assert(top2.line > 1, 'tried to snap cursor to buttom of screen but failed')
assert(State.lines[top2.line-1].mode == 'drawing', "expected a drawing but it's not")
-- We currently can't draw partial drawings, so either skip it entirely
-- or not at all.
local h = Drawing_padding_height + Drawing.pixels(State.lines[top2.line-1].h, State.width)
if y - h < State.top then
break
end
--? print('skipping drawing of height', h)
y = y - h
end
top2 = Text.previous_screen_line(State, top2)
end
--? print('top2 finally:', top2.line, top2.screen_line, top2.screen_pos)
State.screen_top1 = Text.to1(State, top2)
--? print('top1 finally:', State.screen_top1.line, State.screen_top1.pos)
--? print('snap', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
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
--? print(screen_lines[#screen_lines], State.cursor1.pos)
return screen_lines[#screen_lines] <= State.cursor1.pos
end
function Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
local y = State.top
while State.cursor1.line <= #State.lines do
if State.lines[State.cursor1.line].mode == 'text' then
break
end
--? print('cursor skips', State.cursor1.line)
y = y + Drawing_padding_height + Drawing.pixels(State.lines[State.cursor1.line].h, State.width)
State.cursor1.line = State.cursor1.line + 1
end
if State.cursor1.pos == nil then
State.cursor1.pos = 1
end
-- hack: insert a text line at bottom of file if necessary
if State.cursor1.line > #State.lines then
assert(State.cursor1.line == #State.lines+1, 'tried to ensure bottom line of file is text, but failed')
table.insert(State.lines, {mode='text', data=''})
table.insert(State.line_cache, {})
end
--? print(y, App.screen.height, App.screen.height-State.line_height)
if y > App.screen.height - State.line_height then
--? print('scroll up')
Text.snap_cursor_to_bottom_of_screen(State)
end
end
-- should never modify State.cursor1
function Text.snap_cursor_to_bottom_of_screen(State)
--? print('to2:', State.cursor1.line, State.cursor1.pos)
local top2 = Text.to2(State, State.cursor1)
--? print('to2: =>', top2.line, top2.screen_line, top2.screen_pos)
-- slide to start of screen line
top2.screen_pos = 1 -- start of screen line
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, ('invalid pos %d'):format(loc1.pos))
end
end
function Text.right(State)
Text.right_without_scroll(State)
if Text.cursor_out_of_screen(State) then
Text.snap_cursor_to_bottom_of_screen(State)
end
end
function Text.right_without_scroll(State)
assert(State.lines[State.cursor1.line].mode == 'text', 'line is not text')
if State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
State.cursor1.pos = State.cursor1.pos+1
else
local new_cursor_line = State.cursor1.line
while new_cursor_line <= #State.lines-1 do
new_cursor_line = new_cursor_line+1
if State.lines[new_cursor_line].mode == 'text' then
State.cursor1 = {line=new_cursor_line, pos=1}
break
end
end
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]
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 spos,i
end
end
assert(false, ('invalid pos %d'):format(loc1.pos))
end
function Text.pos_at_end_of_screen_line(State, loc1)
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
end
end
function Text.end_of_line(State)
State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data) + 1
if Text.cursor_out_of_screen(State) then
Text.snap_cursor_to_bottom_of_screen(State)
end
end
function Text.word_left(State)
-- skip some whitespace
while true do
if State.cursor1.pos == 1 then
break
end
if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos-1, '%S') then
break
end
Text.left(State)
end
-- skip some non-whitespace
while true do
Text.left(State)
if State.cursor1.pos == 1 then
break
end
assert(State.cursor1.pos > 1, 'bumped up against start of line')
if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos-1, '%s') then
break
end
end
end
function Text.word_right(State)
-- skip some whitespace
while true do
if State.cursor1.pos > utf8.len(State.lines[State.cursor1.line].data) then
break
end
if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos, '%S') then
break
end
Text.right_without_scroll(State)
end
while true do
Text.right_without_scroll(State)
if State.cursor1.pos > utf8.len(State.lines[State.cursor1.line].data) then
break
end
if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos, '%s') then
break
end
end
if Text.cursor_out_of_screen(State) then
Text.snap_cursor_to_bottom_of_screen(State)
end
end
function Text.match(s, pos, pat)
local start_offset = Text.offset(s, pos)
local end_offset = Text.offset(s, pos+1)
assert(end_offset > start_offset, ('end_offset %d not > start_offset %d'):format(end_offset, start_offset))
local curr = s:sub(start_offset, end_offset-1)
return curr:match(pat)
end
function Text.left(State)
assert(State.lines[State.cursor1.line].mode == 'text', 'line is not text')
if State.cursor1.pos > 1 then
State.cursor1.pos = State.cursor1.pos-1
else
local new_cursor_line = State.cursor1.line
while new_cursor_line > 1 do
new_cursor_line = new_cursor_line-1
if State.lines[new_cursor_line].mode == 'text' then
State.cursor1 = {
line = new_cursor_line,
pos = utf8.len(State.lines[new_cursor_line].data) + 1,
}
break
end
end
end
if Text.lt1(State.cursor1, State.screen_top1) then
State.screen_top1 = {
line=State.cursor1.line,
pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
}
State.screen_top1 = deepcopy(State.cursor1)
end
function Text.start_of_line(State)
State.cursor1.pos = 1
if Text.lt1(State.cursor1, State.screen_top1) then
--? print('=>', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
--? print('cursor is NOT at final screen line of its line')
local screen_line_starting_pos, screen_line_index = Text.pos_at_start_of_screen_line(State, State.cursor1)
Text.populate_screen_line_starting_pos(State, State.cursor1.line)
local new_screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos[screen_line_index+1]
--? print('switching pos of screen line at cursor from '..tostring(screen_line_starting_pos)..' to '..tostring(new_screen_line_starting_pos))
local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos)
local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset)
State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(State.font, s, State.cursor_x, State.left) - 1
--? print('cursor pos is now', State.cursor1.line, State.cursor1.pos)
if scroll_down then
--? print('scroll up preserving cursor')
Text.snap_cursor_to_bottom_of_screen(State)
--? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
end
end
local screen_bottom1 = Text.screen_bottom1(State)
local scroll_down = Text.le1(screen_bottom1, State.cursor1)
--? print('screen top before:', State.screen_top1.line, State.screen_top1.pos)
--? print('scroll up preserving cursor')
Text.snap_cursor_to_bottom_of_screen(State)
--? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
end
else
-- move down one screen line in current line
local screen_bottom1 = Text.screen_bottom1(State)
--? print('down 2', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, screen_bottom1.line, screen_bottom1.pos)
if State.cursor1.line > screen_bottom1.line then
assert(State.cursor1.pos, 'cursor has no pos')
if Text.cursor_at_final_screen_line(State) then
-- line is done, skip to next text line
--? print('cursor at final screen line of its line')
local new_cursor_line = State.cursor1.line
while new_cursor_line < #State.lines do
new_cursor_line = new_cursor_line+1
if State.lines[new_cursor_line].mode == 'text' then
State.cursor1 = {
line = new_cursor_line,
pos = Text.nearest_cursor_pos(State.font, State.lines[new_cursor_line].data, State.cursor_x, State.left),
}
--? print(State.cursor1.pos)
break
end
end
--? print('down', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
end
end
function Text.down(State)
assert(State.lines[State.cursor1.line].mode == 'text', 'line is not text')
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(State.font, s, State.cursor_x, State.left) - 1
--? print('cursor pos is now '..tostring(State.cursor1.pos))
end
if Text.lt1(State.cursor1, State.screen_top1) then
State.screen_top1 = {
line=State.cursor1.line,
pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
}
end
function Text.up(State)
assert(State.lines[State.cursor1.line].mode == 'text', 'line is not text')
--? print('up', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
local screen_line_starting_pos, screen_line_index = Text.pos_at_start_of_screen_line(State, State.cursor1)
if screen_line_starting_pos == 1 then
--? print('cursor is at first screen line of its line')
-- line is done; skip to previous text line
local new_cursor_line = State.cursor1.line
while new_cursor_line > 1 do
new_cursor_line = new_cursor_line-1
if State.lines[new_cursor_line].mode == 'text' then
--? print('found previous text line')
State.cursor1 = {line=new_cursor_line, pos=nil}
Text.populate_screen_line_starting_pos(State, State.cursor1.line)
-- previous text line found, pick its final screen line
--? print('has multiple screen lines')
local screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos
--? print(#screen_line_starting_pos)
screen_line_starting_pos = screen_line_starting_pos[#screen_line_starting_pos]
local screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, screen_line_starting_pos)
local s = string.sub(State.lines[State.cursor1.line].data, screen_line_starting_byte_offset)
State.cursor1.pos = screen_line_starting_pos + Text.nearest_cursor_pos(State.font, s, State.cursor_x, State.left) - 1
break
end
end
else
-- move up one screen line in current line
assert(screen_line_index > 1, 'bumped up against top screen line in line')
local new_screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos[screen_line_index-1]
local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos)
local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset)
end
-- return the location of the start of the bottom-most line on screen
function Text.screen_bottom1(State)
-- duplicate some logic from love.draw
-- does not modify State (except to populate line_cache)
local loc2 = Text.to2(State, State.screen_top1)
local y = State.top
while true do
if State.lines[loc2.line].mode == 'text' then
y = y + State.line_height
elseif State.lines[loc2.line].mode == 'drawing' then
y = y + Drawing_padding_height + Drawing.pixels(State.lines[loc2.line].h, State.width)
end
if y + State.line_height > App.screen.height then break end
local next_loc2 = Text.next_screen_line(State, loc2)
if Text.eq2(next_loc2, loc2) then break end
loc2 = next_loc2
end
return Text.to1(State, loc2)
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
State.cursor1 = deepcopy(State.screen_top1)
State.screen_top1 = Text.screen_bottom1(State)
end
function Text.pagedown(State)
return Text.to1(State, loc2)
end
loc2 = Text.previous_screen_line(State, loc2)
end
elseif State.lines[loc2.line].mode == 'drawing' then
y = y - Drawing_padding_height - Drawing.pixels(State.lines[loc2.line].h, State.width)
y = y - State.line_height
if loc2.line == 1 and loc2.screen_line == 1 and loc2.screen_pos == 1 then break end
if State.lines[loc2.line].mode == 'text' then
local y = App.screen.height - State.line_height
while y >= State.top do
-- does not modify State (except to populate line_cache)
if line_index < State.screen_top1.line then return end
local loc2 = Text.to2(State, State.screen_top1)
local y = State.top
while true do
if State.lines[loc2.line].mode == 'drawing' then
y = y + Drawing_padding_top
end
if loc2.line == line_index then return y end
if State.lines[loc2.line].mode == 'text' then
y = y + State.line_height
elseif State.lines[loc2.line].mode == 'drawing' then
y = y + Drawing.pixels(State.lines[loc2.line].h, State.width) + Drawing_padding_bottom
end
if y + State.line_height > App.screen.height then break end
local next_loc2 = Text.next_screen_line(State, loc2)
if Text.eq2(next_loc2, loc2) then break end -- end of file
loc2 = next_loc2
end
end
function Text.previous_screen_top1(State)
-- duplicate some logic from love.draw
-- does not modify State (except to populate line_cache)
local loc2 = Text.to2(State, State.screen_top1)
-- duplicate some logic from love.draw
end
-- return the top y coordinate of a given line_index,
-- or nil if no part of it is on screen
function Text.starty(State, line_index)
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
State.cursor1 = deepcopy(State.screen_top1)
State.screen_top1 = Text.previous_screen_top1(State)
end
Text.pagedown(State)
end
end
function Text.insert_return(State)
local byte_offset = Text.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
table.insert(State.lines, State.cursor1.line+1, {mode='text', data=string.sub(State.lines[State.cursor1.line].data, byte_offset)})
table.insert(State.line_cache, State.cursor1.line+1, {})
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_offset-1)
Text.clear_screen_line_cache(State, State.cursor1.line)
State.cursor1 = {line=State.cursor1.line+1, pos=1}
end
function Text.pageup(State)
State.selection1 = deepcopy(State.cursor1)
end
Text.pageup(State)
elseif chord == 'S-pagedown' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.down(State)
elseif chord == 'pageup' then
Text.pageup(State)
State.selection1 = {}
elseif chord == 'pagedown' then
Text.pagedown(State)
State.selection1 = {}
elseif chord == 'S-pageup' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.up(State)
elseif chord == 'S-down' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.end_of_line(State)
elseif chord == 'up' then
Text.up(State)
State.selection1 = {}
elseif chord == 'down' then
Text.down(State)
State.selection1 = {}
elseif chord == 'S-up' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.start_of_line(State)
elseif chord == 'S-end' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.word_right(State)
elseif chord == 'home' then
Text.start_of_line(State)
State.selection1 = {}
elseif chord == 'end' then
Text.end_of_line(State)
State.selection1 = {}
elseif chord == 'S-home' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.word_left(State)
elseif chord == 'M-S-right' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.right(State)
-- C- hotkeys reserved for drawings, so we'll use M-
elseif chord == 'M-left' then
Text.word_left(State)
State.selection1 = {}
elseif chord == 'M-right' then
Text.word_right(State)
State.selection1 = {}
elseif chord == 'M-S-left' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.left(State)
elseif chord == 'S-right' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
--== shortcuts that move the cursor
elseif chord == 'left' then
Text.left(State)
State.selection1 = {}
elseif chord == 'right' then
Text.right(State)
State.selection1 = {}
elseif chord == 'S-left' then
if State.selection1.line == nil then
schedule_save(State)
record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
schedule_save(State)
return
end
local before
if State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
before = snapshot(State, State.cursor1.line)
else
before = snapshot(State, State.cursor1.line, State.cursor1.line+1)
end
if State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
local byte_start = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
local byte_end = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos+1)
if byte_start then
if byte_end then
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].data, byte_end)
else
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)
end
-- no change to State.cursor1.pos
end
elseif State.cursor1.line < #State.lines then
if State.lines[State.cursor1.line+1].mode == 'text' then
-- join lines
State.lines[State.cursor1.line].data = State.lines[State.cursor1.line].data..State.lines[State.cursor1.line+1].data
end
table.remove(State.lines, State.cursor1.line+1)
table.remove(State.line_cache, State.cursor1.line+1)
end
Text.clear_screen_line_cache(State, State.cursor1.line)
Text.delete_selection_and_record_undo_event(State)
elseif chord == 'delete' then
if State.selection1.line then
schedule_save(State)
record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
end
Text.clear_screen_line_cache(State, State.cursor1.line)
assert(Text.le1(State.screen_top1, State.cursor1), ('screen_top (line=%d,pos=%d) is below cursor (line=%d,pos=%d)'):format(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos))
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
if State.lines[State.cursor1.line-1].mode == 'drawing' then
table.remove(State.lines, State.cursor1.line-1)
table.remove(State.line_cache, State.cursor1.line-1)
else
-- join lines
State.cursor1.pos = utf8.len(State.lines[State.cursor1.line-1].data)+1
State.lines[State.cursor1.line-1].data = State.lines[State.cursor1.line-1].data..State.lines[State.cursor1.line].data
table.remove(State.lines, State.cursor1.line)
table.remove(State.line_cache, State.cursor1.line)
end
State.cursor1.line = State.cursor1.line-1
end
if State.screen_top1.line > #State.lines then
Text.populate_screen_line_starting_pos(State, #State.lines)
local line_cache = State.line_cache[#State.line_cache]
State.screen_top1 = {line=#State.lines, pos=line_cache.screen_line_starting_pos[#line_cache.screen_line_starting_pos]}
elseif Text.lt1(State.cursor1, State.screen_top1) then
State.screen_top1 = {
line=State.cursor1.line,
pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
}
end
elseif State.cursor1.line > 1 then
before = snapshot(State, State.cursor1.line-1, State.cursor1.line)
before = snapshot(State, State.cursor1.line)
local byte_start = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos-1)
local byte_end = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
if byte_start then
if byte_end then
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].data, byte_end)
else
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)
end
State.cursor1.pos = State.cursor1.pos-1
if State.cursor1.pos > 1 then
local before
schedule_save(State)
return
end
Text.delete_selection_and_record_undo_event(State)
if State.selection1.line then
elseif chord == 'backspace' then
schedule_save(State)
record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
end
--? print('=>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
Text.insert_at_cursor(State, '\t')
if State.cursor_y > App.screen.height - State.line_height then
Text.populate_screen_line_starting_pos(State, State.cursor1.line)
Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
--? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
elseif chord == 'tab' then
local before = snapshot(State, State.cursor1.line)
schedule_save(State)
record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)})
if State.cursor_y > App.screen.height - State.line_height then
Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
end
if chord == 'return' then
local before_line = State.cursor1.line
local before = snapshot(State, before_line)
Text.insert_return(State)
--== shortcuts that mutate text (must schedule_save)
--? print('chord', chord, State.selection1.line, State.selection1.pos)
function Text.keychord_press(State, chord, key, scancode, is_repeat)
-- Don't handle any keys here that would trigger text_input above.
end
local byte_offset = Text.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_offset-1)..t..string.sub(State.lines[State.cursor1.line].data, byte_offset)
Text.clear_screen_line_cache(State, State.cursor1.line)
State.cursor1.pos = State.cursor1.pos+1
Text.insert_at_cursor(State, t)
if State.cursor_y > App.screen.height - State.line_height then
Text.populate_screen_line_starting_pos(State, State.cursor1.line)
Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
end
record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
end
function Text.insert_at_cursor(State, t)
--? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
local before = snapshot(State, State.cursor1.line)
return
else
-- Key mutated by the keyboard layout. Continue below.
end
end
-- The modifiers didn't change the key. Handle it in keychord_press.
if App.any_modifier_down() then
if App.key_down(t) then
if App.mouse_down(1) then return end
function Text.text_input(State, t)
end
return font:getWidth(line.data:sub(soff, loff-1)), font:getWidth(line.data:sub(soff, hoff))
--? print(s, e, soff, eoff, loff, hoff)
end
end
local loff = math.max(s, soff)
local hoff
if eoff then
hoff = math.min(e, eoff)
else
hoff = e
end
local spos = line_cache.screen_line_starting_pos[i]
local soff = Text.offset(line.data, spos)
if e < soff then
return
end
local eoff
if i < #line_cache.screen_line_starting_pos then
local epos = line_cache.screen_line_starting_pos[i+1]
eoff = Text.offset(line.data, epos)
if s > eoff then
return
function Text.clip_wikiword_with_screen_line(font, line, line_cache, i, s, e)
local s, e = 1, 0
while s <= #line.data do
s, e = line.data:find('%[%[%S+%]%]', s)
if s == nil then break end
local word = line.data:sub(s+2, e-2) -- strip out surrounding '[[..]]'
--? print('wikiword:', s, e, word)
table.insert(line_cache.link_offsets, {s, e, word})
s = e + 1
end
end
-- Intersect the filename between byte offsets s,e with the bounds of screen line i.
-- Return the left/right pixel coordinates of of the intersection,
-- or nil if it doesn't intersect with screen line i.
-- try to wrap at word boundaries
line_cache.link_offsets = {}
local pos = 1
return
end
if line_cache.link_offsets then
local line_cache = State.line_cache[line_index]
if line.mode ~= 'text' then return end
local line = State.lines[line_index]
function Text.populate_link_offsets(State, line_index)
end
return true
end
end
end
-- create a new iterator for s which provides the index and UTF-8 bytes corresponding to each codepoint
function utf8chars(s, startpos)
local next_pos = startpos or 1 -- in code points
local next_offset = utf8.offset(s, next_pos) -- in bytes
return function()
assert(next_offset) -- never call the iterator after it returns nil
local curr_pos = next_pos
next_pos = next_pos+1
local curr_offset = next_offset
next_offset = utf8.offset(s, 2, next_offset)
if next_offset == nil then return end
local curr_char = s:sub(curr_offset, next_offset-1)
return curr_pos, curr_char
x = x + w
end
end
-- Check whether to word-wrap line at pos which will be positioned at x.
--
-- We wrap at the start of a word (non-space just after space) if the word
-- (non-spaces followed by spaces) wouldn't fit in the rest of the line.
--
-- x lies between 0 and editor.width.
--
-- Postcondition:
-- Current line is not wider than editor.width
--
-- Desired properties in priority order:
-- Next line doesn't start with whitespace
-- Current line ends with whitespace (a.k.a. word wrap)
-- Current line is close to full
-- None of these is guaranteed. But we should never satisfy a lower priority
-- before a higher one.
function Text.should_word_wrap(editor, line, pos, char, x)
if char:match('%s') then return false end
if pos == 1 then return false end
if Text.match(line, pos-1, '%S') then return false end
local offset = Text.offset(line, pos)
-- most of the time a word is printable chars + whitespace
local s = line:match('%S+%s*', offset)
assert(s)
local w = editor.font:getWidth(s)
if x+w < editor.width then return false end
if w > editor.width then return false end -- we're going to need to truncate the next word anyway
if x < 0.8*editor.width then
local s2 = line:match('%S+', offset)
local w2 = editor.font:getWidth(s2)
if x+w2 > editor.width then
-- there'll be some non-whitespace left over for the next line
return false
end
end
x = 0
x = 0
end
x = x + w
end
end
-- Check whether to word-wrap line at pos which will be positioned at x.
--
-- We wrap at the start of a word (non-space just after space) if the word
-- (non-spaces followed by spaces) wouldn't fit in the rest of the line.
--
-- x lies between 0 and editor.width.
--
-- Postcondition:
-- Current line is not wider than editor.width
--
-- Desired properties in priority order:
-- Next line doesn't start with whitespace
-- Current line ends with whitespace (a.k.a. word wrap)
-- Current line is close to full
-- None of these is guaranteed. But we should never satisfy a lower priority
-- before a higher one.
function Text.should_word_wrap(editor, line, pos, char, x)
if char:match('%s') then return false end
if pos == 1 then return false end
if Text.match(line, pos-1, '%S') then return false end
local offset = Text.offset(line, pos)
-- most of the time a word is printable chars + whitespace
local s = line:match('%S+%s*', offset)
assert(s)
local w = editor.font:getWidth(s)
if x+w < editor.width then return false end
if w > editor.width then return false end -- we're going to need to truncate the next word anyway
if x < 0.8*editor.width then
local s2 = line:match('%S+', offset)
local w2 = editor.font:getWidth(s2)
if x+w2 > editor.width then
-- there'll be some non-whitespace left over for the next line
return false
end
end
return true
end
function Text.populate_link_offsets(State, line_index)
local line = State.lines[line_index]
if line.mode ~= 'text' then return end
local line_cache = State.line_cache[line_index]
if line_cache.link_offsets then
return
end
line_cache.link_offsets = {}
local pos = 1
-- try to wrap at word boundaries
local s, e = 1, 0
while s <= #line.data do
s, e = line.data:find('%[%[%S+%]%]', s)
if s == nil then break end
local word = line.data:sub(s+2, e-2) -- strip out surrounding '[[..]]'
--? print('wikiword:', s, e, word)
table.insert(line_cache.link_offsets, {s, e, word})
s = e + 1
end
end
-- Intersect the filename between byte offsets s,e with the bounds of screen line i.
-- Return the left/right pixel coordinates of of the intersection,
-- or nil if it doesn't intersect with screen line i.
function Text.clip_wikiword_with_screen_line(font, line, line_cache, i, s, e)
local spos = line_cache.screen_line_starting_pos[i]
local soff = Text.offset(line.data, spos)
if e < soff then
return
end
local eoff
if i < #line_cache.screen_line_starting_pos then
local epos = line_cache.screen_line_starting_pos[i+1]
eoff = Text.offset(line.data, epos)
if s > eoff then
return
end
end
local loff = math.max(s, soff)
local hoff
if eoff then
hoff = math.min(e, eoff)
else
hoff = e
end
--? print(s, e, soff, eoff, loff, hoff)
return font:getWidth(line.data:sub(soff, loff-1)), font:getWidth(line.data:sub(soff, hoff))
end
function Text.text_input(State, t)
if App.mouse_down(1) then return end
if App.any_modifier_down() then
if App.key_down(t) then
-- The modifiers didn't change the key. Handle it in keychord_press.
return
else
-- Key mutated by the keyboard layout. Continue below.
end
end
local before = snapshot(State, State.cursor1.line)
--? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
Text.insert_at_cursor(State, t)
if State.cursor_y > App.screen.height - State.line_height then
Text.populate_screen_line_starting_pos(State, State.cursor1.line)
Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
end
record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
end
function Text.insert_at_cursor(State, t)
assert(State.lines[State.cursor1.line].mode == 'text', 'line is not text')
local byte_offset = Text.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_offset-1)..t..string.sub(State.lines[State.cursor1.line].data, byte_offset)
Text.clear_screen_line_cache(State, State.cursor1.line)
State.cursor1.pos = State.cursor1.pos+1
end
-- Don't handle any keys here that would trigger text_input above.
function Text.keychord_press(State, chord, key, scancode, is_repeat)
--? print('chord', chord, State.selection1.line, State.selection1.pos)
--== shortcuts that mutate text (must schedule_save)
if chord == 'return' then
local before_line = State.cursor1.line
local before = snapshot(State, before_line)
Text.insert_return(State)
if State.cursor_y > App.screen.height - State.line_height then
Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
end
record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)})
schedule_save(State)
elseif chord == 'tab' then
local before = snapshot(State, State.cursor1.line)
--? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
Text.insert_at_cursor(State, '\t')
if State.cursor_y > App.screen.height - State.line_height then
Text.populate_screen_line_starting_pos(State, State.cursor1.line)
Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
--? print('=>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
end
record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
schedule_save(State)
elseif chord == 'backspace' then
if State.selection1.line then
Text.delete_selection_and_record_undo_event(State)
schedule_save(State)
return
end
local before
if State.cursor1.pos > 1 then
before = snapshot(State, State.cursor1.line)
local byte_start = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos-1)
local byte_end = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
if byte_start then
if byte_end then
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].data, byte_end)
else
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)
end
State.cursor1.pos = State.cursor1.pos-1
end
elseif State.cursor1.line > 1 then
before = snapshot(State, State.cursor1.line-1, State.cursor1.line)
if State.lines[State.cursor1.line-1].mode == 'drawing' then
table.remove(State.lines, State.cursor1.line-1)
table.remove(State.line_cache, State.cursor1.line-1)
else
-- join lines
State.cursor1.pos = utf8.len(State.lines[State.cursor1.line-1].data)+1
State.lines[State.cursor1.line-1].data = State.lines[State.cursor1.line-1].data..State.lines[State.cursor1.line].data
table.remove(State.lines, State.cursor1.line)
table.remove(State.line_cache, State.cursor1.line)
end
State.cursor1.line = State.cursor1.line-1
end
if State.screen_top1.line > #State.lines then
Text.populate_screen_line_starting_pos(State, #State.lines)
local line_cache = State.line_cache[#State.line_cache]
State.screen_top1 = {line=#State.lines, pos=line_cache.screen_line_starting_pos[#line_cache.screen_line_starting_pos]}
elseif Text.lt1(State.cursor1, State.screen_top1) then
State.screen_top1 = {
line=State.cursor1.line,
pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
}
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
end
Text.clear_screen_line_cache(State, State.cursor1.line)
assert(Text.le1(State.screen_top1, State.cursor1), ('screen_top (line=%d,pos=%d) is below cursor (line=%d,pos=%d)'):format(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos))
record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
schedule_save(State)
elseif chord == 'delete' then
if State.selection1.line then
Text.delete_selection_and_record_undo_event(State)
schedule_save(State)
return
end
local before
if State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
before = snapshot(State, State.cursor1.line)
else
before = snapshot(State, State.cursor1.line, State.cursor1.line+1)
end
if State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
local byte_start = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
local byte_end = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos+1)
if byte_start then
if byte_end then
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].data, byte_end)
else
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)
end
-- no change to State.cursor1.pos
end
elseif State.cursor1.line < #State.lines then
if State.lines[State.cursor1.line+1].mode == 'text' then
-- join lines
State.lines[State.cursor1.line].data = State.lines[State.cursor1.line].data..State.lines[State.cursor1.line+1].data
end
table.remove(State.lines, State.cursor1.line+1)
table.remove(State.line_cache, State.cursor1.line+1)
end
Text.clear_screen_line_cache(State, State.cursor1.line)
record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
schedule_save(State)
--== shortcuts that move the cursor
elseif chord == 'left' then
Text.left(State)
State.selection1 = {}
elseif chord == 'right' then
Text.right(State)
State.selection1 = {}
elseif chord == 'S-left' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.left(State)
elseif chord == 'S-right' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.right(State)
-- C- hotkeys reserved for drawings, so we'll use M-
elseif chord == 'M-left' then
Text.word_left(State)
State.selection1 = {}
elseif chord == 'M-right' then
Text.word_right(State)
State.selection1 = {}
elseif chord == 'M-S-left' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.word_left(State)
elseif chord == 'M-S-right' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.word_right(State)
elseif chord == 'home' then
Text.start_of_line(State)
State.selection1 = {}
elseif chord == 'end' then
Text.end_of_line(State)
State.selection1 = {}
elseif chord == 'S-home' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.start_of_line(State)
elseif chord == 'S-end' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.end_of_line(State)
elseif chord == 'up' then
Text.up(State)
State.selection1 = {}
elseif chord == 'down' then
Text.down(State)
State.selection1 = {}
elseif chord == 'S-up' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.up(State)
elseif chord == 'S-down' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.down(State)
elseif chord == 'pageup' then
Text.pageup(State)
State.selection1 = {}
elseif chord == 'pagedown' then
Text.pagedown(State)
State.selection1 = {}
elseif chord == 'S-pageup' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.pageup(State)
elseif chord == 'S-pagedown' then
if State.selection1.line == nil then
State.selection1 = deepcopy(State.cursor1)
end
Text.pagedown(State)
end
end
function Text.insert_return(State)
local byte_offset = Text.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
table.insert(State.lines, State.cursor1.line+1, {mode='text', data=string.sub(State.lines[State.cursor1.line].data, byte_offset)})
table.insert(State.line_cache, State.cursor1.line+1, {})
State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_offset-1)
Text.clear_screen_line_cache(State, State.cursor1.line)
State.cursor1 = {line=State.cursor1.line+1, pos=1}
end
function Text.pageup(State)
State.screen_top1 = Text.previous_screen_top1(State)
State.cursor1 = deepcopy(State.screen_top1)
Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
end
-- return the top y coordinate of a given line_index,
-- or nil if no part of it is on screen
function Text.starty(State, line_index)
-- duplicate some logic from love.draw
-- does not modify State (except to populate line_cache)
if line_index < State.screen_top1.line then return end
local loc2 = Text.to2(State, State.screen_top1)
local y = State.top
while true do
if State.lines[loc2.line].mode == 'drawing' then
y = y + Drawing_padding_top
end
if loc2.line == line_index then return y end
if State.lines[loc2.line].mode == 'text' then
y = y + State.line_height
elseif State.lines[loc2.line].mode == 'drawing' then
y = y + Drawing.pixels(State.lines[loc2.line].h, State.width) + Drawing_padding_bottom
end
if y + State.line_height > App.screen.height then break end
local next_loc2 = Text.next_screen_line(State, loc2)
if Text.eq2(next_loc2, loc2) then break end -- end of file
loc2 = next_loc2
end
end
function Text.previous_screen_top1(State)
-- duplicate some logic from love.draw
-- does not modify State (except to populate line_cache)
local loc2 = Text.to2(State, State.screen_top1)
local y = App.screen.height - State.line_height
while y >= State.top do
if loc2.line == 1 and loc2.screen_line == 1 and loc2.screen_pos == 1 then break end
if State.lines[loc2.line].mode == 'text' then
y = y - State.line_height
elseif State.lines[loc2.line].mode == 'drawing' then
y = y - Drawing_padding_height - Drawing.pixels(State.lines[loc2.line].h, State.width)
end
loc2 = Text.previous_screen_line(State, loc2)
end
return Text.to1(State, loc2)
end
function Text.pagedown(State)
State.screen_top1 = Text.screen_bottom1(State)
State.cursor1 = deepcopy(State.screen_top1)
Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
end
-- return the location of the start of the bottom-most line on screen
function Text.screen_bottom1(State)
-- duplicate some logic from love.draw
-- does not modify State (except to populate line_cache)
local loc2 = Text.to2(State, State.screen_top1)
local y = State.top
while true do
if State.lines[loc2.line].mode == 'text' then
y = y + State.line_height
elseif State.lines[loc2.line].mode == 'drawing' then
y = y + Drawing_padding_height + Drawing.pixels(State.lines[loc2.line].h, State.width)
end
if y + State.line_height > App.screen.height then break end
local next_loc2 = Text.next_screen_line(State, loc2)
if Text.eq2(next_loc2, loc2) then break end
loc2 = next_loc2
end
return Text.to1(State, loc2)
end
function Text.up(State)
assert(State.lines[State.cursor1.line].mode == 'text', 'line is not text')
--? print('up', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
local screen_line_starting_pos, screen_line_index = Text.pos_at_start_of_screen_line(State, State.cursor1)
if screen_line_starting_pos == 1 then
--? print('cursor is at first screen line of its line')
-- line is done; skip to previous text line
local new_cursor_line = State.cursor1.line
while new_cursor_line > 1 do
new_cursor_line = new_cursor_line-1
if State.lines[new_cursor_line].mode == 'text' then
--? print('found previous text line')
State.cursor1 = {line=new_cursor_line, pos=nil}
Text.populate_screen_line_starting_pos(State, State.cursor1.line)
-- previous text line found, pick its final screen line
--? print('has multiple screen lines')
local screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos
--? print(#screen_line_starting_pos)
screen_line_starting_pos = screen_line_starting_pos[#screen_line_starting_pos]
local screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, screen_line_starting_pos)
local s = string.sub(State.lines[State.cursor1.line].data, screen_line_starting_byte_offset)
State.cursor1.pos = screen_line_starting_pos + Text.nearest_cursor_pos(State.font, s, State.cursor_x, State.left) - 1
break
end
end
else
-- move up one screen line in current line
assert(screen_line_index > 1, 'bumped up against top screen line in line')
local new_screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos[screen_line_index-1]
local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos)
local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset)
State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(State.font, s, State.cursor_x, State.left) - 1
--? print('cursor pos is now '..tostring(State.cursor1.pos))
end
if Text.lt1(State.cursor1, State.screen_top1) then
State.screen_top1 = {
line=State.cursor1.line,
pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
}
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
end
end
function Text.down(State)
assert(State.lines[State.cursor1.line].mode == 'text', 'line is not text')
--? print('down', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
assert(State.cursor1.pos, 'cursor has no pos')
if Text.cursor_at_final_screen_line(State) then
-- line is done, skip to next text line
--? print('cursor at final screen line of its line')
local new_cursor_line = State.cursor1.line
while new_cursor_line < #State.lines do
new_cursor_line = new_cursor_line+1
if State.lines[new_cursor_line].mode == 'text' then
State.cursor1 = {
line = new_cursor_line,
pos = Text.nearest_cursor_pos(State.font, State.lines[new_cursor_line].data, State.cursor_x, State.left),
}
--? print(State.cursor1.pos)
break
end
end
local screen_bottom1 = Text.screen_bottom1(State)
--? print('down 2', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, screen_bottom1.line, screen_bottom1.pos)
if State.cursor1.line > screen_bottom1.line then
--? print('screen top before:', State.screen_top1.line, State.screen_top1.pos)
--? print('scroll up preserving cursor')
Text.snap_cursor_to_bottom_of_screen(State)
--? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
end
else
-- move down one screen line in current line
local screen_bottom1 = Text.screen_bottom1(State)
local scroll_down = Text.le1(screen_bottom1, State.cursor1)
--? print('cursor is NOT at final screen line of its line')
local screen_line_starting_pos, screen_line_index = Text.pos_at_start_of_screen_line(State, State.cursor1)
Text.populate_screen_line_starting_pos(State, State.cursor1.line)
local new_screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos[screen_line_index+1]
--? print('switching pos of screen line at cursor from '..tostring(screen_line_starting_pos)..' to '..tostring(new_screen_line_starting_pos))
local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos)
local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset)
State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(State.font, s, State.cursor_x, State.left) - 1
--? print('cursor pos is now', State.cursor1.line, State.cursor1.pos)
if scroll_down then
--? print('scroll up preserving cursor')
Text.snap_cursor_to_bottom_of_screen(State)
--? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
end
end
--? print('=>', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
end
function Text.start_of_line(State)
State.cursor1.pos = 1
if Text.lt1(State.cursor1, State.screen_top1) then
State.screen_top1 = deepcopy(State.cursor1)
end
end
function Text.end_of_line(State)
State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data) + 1
if Text.cursor_out_of_screen(State) then
Text.snap_cursor_to_bottom_of_screen(State)
end
end
function Text.word_left(State)
-- skip some whitespace
while true do
if State.cursor1.pos == 1 then
break
end
if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos-1, '%S') then
break
end
Text.left(State)
end
-- skip some non-whitespace
while true do
Text.left(State)
if State.cursor1.pos == 1 then
break
end
assert(State.cursor1.pos > 1, 'bumped up against start of line')
if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos-1, '%s') then
break
end
end
end
function Text.word_right(State)
-- skip some whitespace
while true do
if State.cursor1.pos > utf8.len(State.lines[State.cursor1.line].data) then
break
end
if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos, '%S') then
break
end
Text.right_without_scroll(State)
end
while true do
Text.right_without_scroll(State)
if State.cursor1.pos > utf8.len(State.lines[State.cursor1.line].data) then
break
end
if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos, '%s') then
break
end
end
if Text.cursor_out_of_screen(State) then
Text.snap_cursor_to_bottom_of_screen(State)
end
end
function Text.match(s, pos, pat)
local start_offset = Text.offset(s, pos)
local end_offset = Text.offset(s, pos+1)
assert(end_offset > start_offset, ('end_offset %d not > start_offset %d'):format(end_offset, start_offset))
local curr = s:sub(start_offset, end_offset-1)
return curr:match(pat)
end
function Text.left(State)
assert(State.lines[State.cursor1.line].mode == 'text', 'line is not text')
if State.cursor1.pos > 1 then
State.cursor1.pos = State.cursor1.pos-1
else
local new_cursor_line = State.cursor1.line
while new_cursor_line > 1 do
new_cursor_line = new_cursor_line-1
if State.lines[new_cursor_line].mode == 'text' then
State.cursor1 = {
line = new_cursor_line,
pos = utf8.len(State.lines[new_cursor_line].data) + 1,
}
break
end
end
end
if Text.lt1(State.cursor1, State.screen_top1) then
State.screen_top1 = {
line=State.cursor1.line,
pos=Text.pos_at_start_of_screen_line(State, State.cursor1),
}
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
end
end
function Text.right(State)
Text.right_without_scroll(State)
if Text.cursor_out_of_screen(State) then
Text.snap_cursor_to_bottom_of_screen(State)
end
end
function Text.right_without_scroll(State)
assert(State.lines[State.cursor1.line].mode == 'text', 'line is not text')
if State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
State.cursor1.pos = State.cursor1.pos+1
else
local new_cursor_line = State.cursor1.line
while new_cursor_line <= #State.lines-1 do
new_cursor_line = new_cursor_line+1
if State.lines[new_cursor_line].mode == 'text' then
State.cursor1 = {line=new_cursor_line, pos=1}
break
end
end
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]
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 spos,i
end
end
assert(false, ('invalid pos %d'):format(loc1.pos))
end
function Text.pos_at_end_of_screen_line(State, loc1)
assert(State.lines[loc1.line].mode == 'text')
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, ('invalid pos %d'):format(loc1.pos))
end
function Text.final_text_loc_on_screen(State)
local screen_bottom1 = Text.screen_bottom1(State)
if State.lines[screen_bottom1.line].mode == 'text' then
return {
line=screen_bottom1.line,
pos=Text.pos_at_end_of_screen_line(State, screen_bottom1),
}
end
local loc2 = Text.to2(State, screen_bottom1)
while true do
if State.lines[loc2.line].mode == 'text' then break end
assert(loc2.line > 1 or loc2.screen_line > 1 and loc2.screen_pos > 1) -- elsewhere we're making sure there's always at least one text line on screen
loc2 = Text.previous_screen_line(State, loc2)
end
local result = Text.to1(State, loc2)
result.pos = Text.pos_at_end_of_screen_line(State, result)
return result
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
--? print(screen_lines[#screen_lines], State.cursor1.pos)
return screen_lines[#screen_lines] <= State.cursor1.pos
end
function Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
local y = State.top
while State.cursor1.line <= #State.lines do
if State.lines[State.cursor1.line].mode == 'text' then
break
end
--? print('cursor skips', State.cursor1.line)
y = y + Drawing_padding_height + Drawing.pixels(State.lines[State.cursor1.line].h, State.width)
State.cursor1.line = State.cursor1.line + 1
end
if State.cursor1.pos == nil then
State.cursor1.pos = 1
end
-- hack: insert a text line at bottom of file if necessary
if State.cursor1.line > #State.lines then
assert(State.cursor1.line == #State.lines+1, 'tried to ensure bottom line of file is text, but failed')
table.insert(State.lines, {mode='text', data=''})
table.insert(State.line_cache, {})
end
--? print(y, App.screen.height, App.screen.height-State.line_height)
if y > App.screen.height - State.line_height then
--? print('scroll up')
Text.snap_cursor_to_bottom_of_screen(State)
end
end
-- should never modify State.cursor1
function Text.snap_cursor_to_bottom_of_screen(State)
--? print('to2:', State.cursor1.line, State.cursor1.pos)
local top2 = Text.to2(State, State.cursor1)
--? print('to2: =>', top2.line, top2.screen_line, top2.screen_pos)
-- slide to start of screen line
top2.screen_pos = 1 -- start of screen line
--? print('snap', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
--? print('cursor pos '..tostring(State.cursor1.pos)..' is on the #'..tostring(top2.screen_line)..' screen line down')
local y = App.screen.height - State.line_height
-- duplicate some logic from love.draw
while true do
--? print(y, 'top2:', top2.line, top2.screen_line, top2.screen_pos)
if top2.line == 1 and top2.screen_line == 1 then break end
if top2.screen_line > 1 or State.lines[top2.line-1].mode == 'text' then
local h = State.line_height
if y - h < State.top then
break
end
y = y - h
else
assert(top2.line > 1, 'tried to snap cursor to buttom of screen but failed')
assert(State.lines[top2.line-1].mode == 'drawing', "expected a drawing but it's not")
-- We currently can't draw partial drawings, so either skip it entirely
-- or not at all.
local h = Drawing_padding_height + Drawing.pixels(State.lines[top2.line-1].h, State.width)
if y - h < State.top then
break
end
--? print('skipping drawing of height', h)
y = y - h
end
top2 = Text.previous_screen_line(State, top2)
end
--? print('top2 finally:', top2.line, top2.screen_line, top2.screen_pos)
State.screen_top1 = Text.to1(State, top2)
--? print('top1 finally:', State.screen_top1.line, State.screen_top1.pos)
--? print('snap =>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
Text.redraw_all(State) -- if we're scrolling, reclaim all line caches to avoid memory leaks
end
function Text.in_line(State, line_index, x,y)
local line = State.lines[line_index]
local line_cache = State.line_cache[line_index]
local starty = Text.starty(State, line_index)
if starty == nil then return false end -- outside current page
if y < starty then return false end
Text.populate_screen_line_starting_pos(State, line_index)
return y < starty + State.line_height*(#line_cache.screen_line_starting_pos - Text.screen_line_index(line_cache.screen_line_starting_pos, line_cache.startpos) + 1)
end
-- convert mx,my in pixels to schema-1 coordinates
function Text.to_pos_on_line(State, line_index, mx, my)
local line = State.lines[line_index]
local line_cache = State.line_cache[line_index]
local starty = Text.starty(State, line_index)
assert(my >= starty, 'failed to map y pixel to line')
-- duplicate some logic from Text.draw
local y = starty
local start_screen_line_index = Text.screen_line_index(line_cache.screen_line_starting_pos, line_cache.startpos)
for screen_line_index = start_screen_line_index,#line_cache.screen_line_starting_pos do
local screen_line_starting_pos = line_cache.screen_line_starting_pos[screen_line_index]
local screen_line_starting_byte_offset = Text.offset(line.data, screen_line_starting_pos)
--? print('iter', y, screen_line_index, screen_line_starting_pos, string.sub(line.data, screen_line_starting_byte_offset))
local nexty = y + State.line_height
if my < nexty then
-- On all wrapped screen lines but the final one, clicks past end of
-- line position cursor on final character of screen line.
-- (The final screen line positions past end of screen line as always.)
if screen_line_index < #line_cache.screen_line_starting_pos and mx > State.left + Text.screen_line_width(State, line_index, screen_line_index) then
--? print('past end of non-final line; return')
return line_cache.screen_line_starting_pos[screen_line_index+1]
end
local s = string.sub(line.data, screen_line_starting_byte_offset)
--? print('return', mx, Text.nearest_cursor_pos(State.font, s, mx, State.left), '=>', screen_line_starting_pos + Text.nearest_cursor_pos(State.font, s, mx, State.left) - 1)
return screen_line_starting_pos + Text.nearest_cursor_pos(State.font, s, mx, State.left) - 1
end
y = nexty
end
assert(false, 'failed to map y pixel to line')
end
function Text.screen_line_width(State, line_index, i)
local line = State.lines[line_index]
local line_cache = State.line_cache[line_index]
local start_pos = line_cache.screen_line_starting_pos[i]
local start_offset = Text.offset(line.data, start_pos)
local screen_line
if i < #line_cache.screen_line_starting_pos then
local past_end_pos = line_cache.screen_line_starting_pos[i+1]
local past_end_offset = Text.offset(line.data, past_end_pos)
screen_line = string.sub(line.data, start_offset, past_end_offset-1)
else
screen_line = string.sub(line.data, start_pos)
end
return State.font:getWidth(screen_line)
end
function Text.screen_line_index(screen_line_starting_pos, pos)
for i = #screen_line_starting_pos,1,-1 do
if screen_line_starting_pos[i] <= pos then
return i
end
end
end
-- convert x pixel coordinate to pos
-- oblivious to wrapping
-- result: 1 to len+1
function Text.nearest_cursor_pos(font, line, x, left)
if x < left then
return 1
end
local len = utf8.len(line)
local max_x = left+Text.x(font, line, len+1)
if x > max_x then
return len+1
end
local leftpos, rightpos = 1, len+1
--? print('-- nearest', x)
while true do
--? print('nearest', x, '^'..line..'$', leftpos, rightpos)
if leftpos == rightpos then
return leftpos
end
local curr = math.floor((leftpos+rightpos)/2)
local currxmin = left+Text.x(font, line, curr)
local currxmax = left+Text.x(font, line, curr+1)
--? print('nearest', x, leftpos, rightpos, curr, currxmin, currxmax)
if currxmin <= x and x < currxmax then
if x-currxmin < currxmax-x then
return curr
else
return curr+1
end
end
if leftpos >= rightpos-1 then
return rightpos
end
if currxmin > x then
rightpos = curr
else
leftpos = curr
end
end
assert(false, 'failed to map x pixel to pos')
end
-- return the nearest index of line (in utf8 code points) which lies entirely
-- within x pixels of the left margin
-- result: 0 to len+1
function Text.nearest_pos_less_than(font, line, x)
--? print('', '-- nearest_pos_less_than', line, x)
local len = utf8.len(line)
local max_x = Text.x_after(font, line, len)
if x > max_x then
return len+1
end
local left, right = 0, len+1
while true do
local curr = math.floor((left+right)/2)
local currxmin = Text.x_after(font, line, curr+1)
local currxmax = Text.x_after(font, line, curr+2)
--? print('', x, left, right, curr, currxmin, currxmax)
if currxmin <= x and x < currxmax then
return curr
end
if left >= right-1 then
return left
end
if currxmin > x then
right = curr
else
left = curr
end
end
assert(false, 'failed to map x pixel to pos')
end
function Text.x_after(font, s, pos)
local len = utf8.len(s)
local offset = Text.offset(s, math.min(pos+1, len+1))
local s_before = s:sub(1, offset-1)
--? print('^'..s_before..'$')
return font:getWidth(s_before)
end
function Text.x(font, s, pos)
local offset = Text.offset(s, pos)
local s_before = s:sub(1, offset-1)
return font:getWidth(s_before)
end
function Text.to2(State, loc1)
if State.lines[loc1.line].mode == 'drawing' then
return {line=loc1.line, screen_line=1, screen_pos=1}
end
local result = {line=loc1.line}
local line_cache = State.line_cache[loc1.line]
Text.populate_screen_line_starting_pos(State, loc1.line)
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
result.screen_line = i
result.screen_pos = loc1.pos - spos + 1
break
end
end
assert(result.screen_pos, 'failed to convert schema-1 coordinate to schema-2')
return result
end
function Text.to1(State, loc2)
local result = {line=loc2.line, pos=loc2.screen_pos}
if loc2.screen_line > 1 then
result.pos = State.line_cache[loc2.line].screen_line_starting_pos[loc2.screen_line] + loc2.screen_pos - 1
end
return result
end
function Text.eq1(a, b)
return a.line == b.line and a.pos == b.pos
end
function Text.lt1(a, b)
if a.line < b.line then
return true
end
if a.line > b.line then
return false
end
return a.pos < b.pos
end
function Text.le1(a, b)
if a.line < b.line then
return true
end
if a.line > b.line then
return false
end
return a.pos <= b.pos
end
function Text.eq2(a, b)
return a.line == b.line and a.screen_line == b.screen_line and a.screen_pos == b.screen_pos
end
function Text.offset(s, pos1)
if pos1 == 1 then return 1 end
local result = utf8.offset(s, pos1)
if result == nil then
assert(false, ('Text.offset(%d) called on a string of length %d (byte size %d); this is likely a failure to handle utf8\n\n^%s$\n'):format(pos1, utf8.len(s), #s, s))
end
return result
end
function Text.previous_screen_line(State, loc2)
if loc2.screen_line > 1 then
return {line=loc2.line, screen_line=loc2.screen_line-1, screen_pos=1}
elseif loc2.line == 1 then
return loc2
elseif State.lines[loc2.line-1].mode == 'drawing' then
return {line=loc2.line-1, screen_line=1, screen_pos=1}
else
local l = State.lines[loc2.line-1]
Text.populate_screen_line_starting_pos(State, loc2.line-1)
return {line=loc2.line-1, screen_line=#State.line_cache[loc2.line-1].screen_line_starting_pos, screen_pos=1}
end
end
function Text.next_screen_line(State, loc2)
if State.lines[loc2.line].mode == 'drawing' then
return {line=loc2.line+1, screen_line=1, screen_pos=1}
end
Text.populate_screen_line_starting_pos(State, loc2.line)
if loc2.screen_line >= #State.line_cache[loc2.line].screen_line_starting_pos then
if loc2.line < #State.lines then
return {line=loc2.line+1, screen_line=1, screen_pos=1}
else
return loc2
end
else
return {line=loc2.line, screen_line=loc2.screen_line+1, screen_pos=1}
end
end
-- resize helper
function Text.tweak_screen_top_and_cursor(State)
if State.screen_top1.pos == 1 then return end
Text.populate_screen_line_starting_pos(State, State.screen_top1.line)
local line = State.lines[State.screen_top1.line]
local line_cache = State.line_cache[State.screen_top1.line]
for i=2,#line_cache.screen_line_starting_pos do
local pos = line_cache.screen_line_starting_pos[i]
if pos == State.screen_top1.pos then
break
end
if pos > State.screen_top1.pos then
-- make sure screen top is at start of a screen line
local prev = line_cache.screen_line_starting_pos[i-1]
if State.screen_top1.pos - prev < pos - State.screen_top1.pos then
State.screen_top1.pos = prev
else
State.screen_top1.pos = pos
end
break
end
end
-- make sure cursor is on screen
local screen_bottom1 = Text.screen_bottom1(State)
if Text.lt1(State.cursor1, State.screen_top1) then
State.cursor1 = deepcopy(State.screen_top1)
elseif State.cursor1.line >= screen_bottom1.line then
if Text.cursor_out_of_screen(State) then
State.cursor1 = Text.final_text_loc_on_screen(State)
end
end
end
-- slightly expensive since it redraws the screen
function Text.cursor_out_of_screen(State)
edit.draw(State)
return State.cursor_y == nil
end
function Text.redraw_all(State)
--? print('clearing line caches')
-- Perform some early sanity checking here, in hopes that we correctly call
-- this whenever we change editor state.
if State.right <= State.left then
assert(false, ('Right margin %d must be to the right of the left margin %d'):format(State.right, State.left))
end
State.line_cache = {}
for i=1,#State.lines do
State.line_cache[i] = {}
end
end
function Text.clear_screen_line_cache(State, line_index)
State.line_cache[line_index].screen_line_starting_pos = nil
State.line_cache[line_index].link_offsets = nil
end
function trim(s)
return s:gsub('^%s+', ''):gsub('%s+$', '')
end
function ltrim(s)
return s:gsub('^%s+', '')
end
function rtrim(s)
return s:gsub('%s+$', '')
end
function starts_with(s, prefix)
if #s < #prefix then
return false
end
for i=1,#prefix do
if s:sub(i,i) ~= prefix:sub(i,i) then
return false
end
end
return true
end
function ends_with(s, suffix)
if #s < #suffix then
return false
end
for i=0,#suffix-1 do
if s:sub(#s-i,#s-i) ~= suffix:sub(#suffix-i,#suffix-i) then
return false
end
end
return true
end
-- create a new iterator for s which provides the index and UTF-8 bytes corresponding to each codepoint
function utf8chars(s, startpos)
local next_pos = startpos or 1 -- in code points
local next_offset = utf8.offset(s, next_pos) -- in bytes
return function()
assert(next_offset) -- never call the iterator after it returns nil
local curr_pos = next_pos
next_pos = next_pos+1
local curr_offset = next_offset
next_offset = utf8.offset(s, 2, next_offset)
if next_offset == nil then return end
local curr_char = s:sub(curr_offset, next_offset-1)
return curr_pos, curr_char
end
end