Still lots to do, but the eventual hope is that this will make this project's code easier to reuse from other LÖVE projects.
One gotcha: even as we start putting code more aggressively into nested tables, tests must remain at the top-level. Otherwise they won't run.
2L5MEZV344TOZLVY3432RHJFIRVXFD6O3GWLL5O4CV66BGAFTURQC
QW5KQQTDX5SRZI6EUHDZNQZMCTMEMBJHWQI4KGCLZG7SHLXMLKGAC
Q7BDB3XQPTTM3YH3HAXATV7ENUTRXTYH55X3PPSXMQA2DO5UJHFAC
SHEGBK4HP2KVIXQBVLD2E4XUEFEB3V7KFKVC7HY65TIUTL5YOH7QC
T7IWZFL4NGMHUKNBXVXMC32AO4GC63WJDAOVOL6M6HIPANPNRCMAC
TC4HQILHSPUPG6GXQCN2NRSOTLLXP3PICKPHRJXYSNSUYVP5WPFQC
S7ZZA3YEKYGLBN6UC2N7WGUS43L6MX2KQQ2LBUZT4FQ7K7V5IQGQC
3EMBUWVW6X5NELBVPEGXLKU4YGOHAQRO5US323BI3OEGVHPXND7QC
6VXO3ZL3P7NCX2SEOUDCTTRB4J46G5RXVTUYXFJBLNZA23I2ZZYQC
GK47BBCYVEZ3OEQ7ISE2WCJULAFZ35WC6EYJ5CTBYNM26RSAELOQC
4CXVIEBSQ5X62UYNJNSNMYKP24GE4IPO77T5ZWQW24QIK2BUQGWAC
K4OBZSHEBIZBAKPH3F7ASDGCPLB7D5W5QLFJQYSM5XOYDPB4BUHAC
QCQTMUZ7M3BKJFTKXTTXL4TS4CAQNIUNK3LR3WQIJDU3VVTOPS6AC
NX3DDSCZM23ONUBXATHBM2DM3RL7YO7LDPXLI2UA6GQU2G3DKOTQC
4CTZOJPCTWYUSHLIZZJ2M5W7S4JZFZVT5MUU5XNSOIBS5L4UY5UQC
AVTNUQYRBW7IX2YQ3KDLVQ23RGW3BAKTAE7P73ASBYNKOHMQMH5AC
TVCPXAAU4P3K5MFYINH2MWDK3KGTQ2GE74TUNERYOONG2G5EYKMQC
OTIBCAUJ3KDQJLVDN3A536DLZGNRYMGJLORZVR3WLCGXGO6UGO6AC
6LJZN727CRPYR34LV75CQF55YZI3E7MGESYZSFSYAE73SNEZE3FAC
VHQCNMARPMNBSIUFLJG7HVK4QGDNPCGNVFLHS3I4IGNVSV5MRLYQC
XX7G2FFJ4QCGQGD4REAW5QFHVYAKCFUPGZCK7L6DFGS5ISVBYBQQC
CE4LZV4TNXJT54CVGM3QANCBP42TMLMZWF2DBSMUYKAHILXIZEMQC
VVXVV2D2F5Y6D6N5VVPUPK3N6GMDTG2YCYPQDYTYEKVKBYHRRYEAC
AD34IX2ZSGYGU3LGY2IZOZNKD4HRQOYJVG5UWMWLXJZJSM62FFOAC
TNHZZYWPVWKA3OEBRH3QQ64GW3D2BQL5JJAVIUKQM5WBTYWASMIQC
MYC7XR5QOT2AXHF6UNGSNFFD5VL6UHGUZQBP7PWWLZ5NNXE7UMTAC
JCSLDGAH2F6AIY4Z6XM6K4LOMW7EFY3E4NF5YXLMHLTYTX3A4Z3QC
BOFNXP5GZDCUMQG3LQVTSSFEQP7REQ4RIRJLDLETFSAGFTVDVEKAC
MGOQ5XAVFTWZPBG2O5ZTGSEKU6BRJKQZLDV6CM4737VD2FAEB5JQC
2RXZ3PGOTTZ6M4R372JXIKPLBQKPVBMAXNPIEO2HZDN4EMYW4GNAC
WOXIYUTL4NU7ACHQYXEXJDSXCRDLQ2X457KO6C7GEXFQZ43F3L7QC
DHI6IJCNSTHGED67T6H5X6Y636C7PIDGIJD32HBEKLT5WIMRS5MAC
DRFE3B3ZKRG4RY2R5Q3SDFD3LH4EXUX3CZCDFBNAXVI2SLDS57PAC
5FW7YOFTLKHRND6IOR4HG4X3C5BO2WV5KTEUW3PPKCRU5L5GXKXQC
RMKMPFT5L67WIFWIO4GTC6XESX6UPKNL4GPNQLOBC5CXSUZABEHQC
IRV65LZPHFLLYPTMLTDO6OJDHVZQJ6MFXZ45IRXRDSRSEQNO5DIAC
OIB2QPRCB4MAVZV5NCEKSAL45ITT6V4BYSET3Q2VCT3WBOIC4QVQC
4RUI5X52CSQODLT3WI4VBMXWZLACBYV5QANGDKRWS3VONZPVSEEQC
AVQ5MC5DWNLI6LUUIPGBLGP4LKRPGWBY4THNY25OBT2FAVHC6MCAC
A2QPFRFJNWDHBYRRLJFBK5BOTOWXDT5DYCKHRRKVBZNDA4NE3CHQC
LUNH47XXUUITDLE6NBI3J7GJQWQ45OQAGXY2HJI4HRPOR2GUULPAC
V5TP27FPD6GPATHPABRW2FKP7BIKY53KL47UVEU5DF4WXEZF7CKAC
JVRL5TWLBTWMTHJDZSDN5XQDMEIIPVAZBKUP75HMO7JHURAYWG5QC
R6GUSTBY5ZHR7E46DSIDQDNZDJI6QMZQDC7RPQMQWLGWQKXU6HVQC
HOSPP2ANSW654DYRTC6CQUQA2GUKV6T2FI7QBKXD2DZS3R32IMGAC
42LVB4DEK3ILS3O5DHFMTJO5HNMJFDYA2WRCLUIOYFPA46MJFZTAC
YPHKZVWM2FS7U3VNVDXFRJTBF4RLQ6K7ZWISLHOQJPYSKBELHFEAC
LAW2O3NWVFTPBSKIMIXPAGYBDOCHYJNKCAVWKNKH62G42DIKZCYQC
5DOTWNVMOGN75GJMXXB4A56UAJYSNYC5WXPRT7QFMAAV27NWPP3AC
ZZ2B5RPQKANSIWAZA4ATDXVBK3XLYIORJ7I4IH2WQOG5JAPJFZ4AC
YKRF5V3ZZQIQ3UGAFYTQT5PUQVHCP2VHFDX77EY2C3X543HUDYKQC
IZZVOCLB7KB4ZNQ35OL466MHWOK3XZMOS7ZPFLHUFQ47LJLQQQ3QC
PGZJ6NATSMW4XH64XEPE5Q2EEYCMAMQIIP2OZXPNJ527234QPKMQC
PX7DDEMOBGPVK3FXKK5XEPG24CJXZSVW67DLG2JZZ5E77NVEAA3AC
73OCE2MCBJJZZMN2KYPJTBOUCKBZAOQ2QIAMTGCNOOJ2AJAXFT2AC
Z4XRNDTRTGSZHNB65WNHOVUBFW4QWQABLVSK4RM3QJHGK33DMRJAC
NP7PIUBTR4K6SWJS46YZG3H2RYYNRGNEJMPV4I24TQXT5O3YT27QC
K464QQR4FTXFUMHFWAGOD5DJ6YHUBUKRHLXF2ORE74DVT7TVQ35QC
J2SVGR2EQEROXDDMYZOCELD2VDYQALGZYRSZ4WGMTACAGMRPJ7UAC
6J3NXBYGADKVHD53QKHUZNRO2B52DC66Y6GQT5KEH6YKVYNCCRTAC
4VKEE43Z7MUPNIAOCK36INVBNHRTSWRRN37TIKRPXPH3DRKGHHAQC
ZNLTRNNKAKSMWOVZKYKFPSIOSRFS73YTFADWE4N5V3BT4UX57Q4AC
2H67P75X7FSSNZMV4TU5I46XLIYAJ5VT7H6MS7GYLUSNTA4IRDFQC
YGCT2D2ORMLTBHANLGHZV3EBGGHD7ZK55UAM7HF2AVSHDXAAKK5QC
DHCLUDCWEEPSOCVMSO7M6DDWESXWZ7PIZNXKG6TOT6TT6ODAPLBAC
DLQMM2656JHXX3ONOEM6UIOXKFJFT5QT7RHWK7YS2W77PVZWHRSAC
OYXDYPGSJK2QICJ6RBA7357WT4FSNAWRUT77YLQHT3F3VYMWGNFQC
UWNHC4AAO3SPOYLPANTO4WKCTZL7KAYC73Q2YUZFFW7K26FVJ7FQC
LFMI3D7D236VAARLPOF3OKWMFVL7KSCVEUBNGJKVOE75ST3AKZSQC
7IKRRESBHMYHHKW4XHUEEKHKPOBLAGZ7A7FJMRU32MTRKIV6S7GQC
NQKFQSZEFIQTIJXEJ64KX46JXLWUUFXVRTQCPM7HF4DUHT2QHZAAC
4C375P53EXHUPXUFQSI3LA7THEP2WOKX5ZB57OQ5ZSM7LYOVW5HAC
B3IWYWSRDSZ7AG5HDS3TELNTG2IKRZYPI25B6LJGVFAJYTHVXZZAC
ESETRNLB3MIJ2SID6HJMMP52FEVUBLGK2HLWD75KDQZAKQMKSF2QC
YTSPVDZHEN5LLNMGIBUBLPWFWSFM3SOHBRGWYSDEVFKRTH24ARRQC
3CS5KKCIZQ6J4SBILINYZSOM6V3U2LE7YIXOZVKXXNBROF6Z6JWAC
BULPIBEGL7TMK6CVIE7IS7WGAHGOSUJBGJSFQK542MOWGHP2ADQQC
QU7NHFOVGFSKQ3CWG7EF2Q7GKP3Z6FHGTIDXFHHMSFL6XMUOHMEAC
CVGE3SIGJRGCLY3A2RBPGFXAEKVZXUUIZQLRHJLM4VPUM4SHEZIAC
RT6EV6OPUYCXYZOX2PHFXJ7KT77KHNEVINEGQXIQLHQVKPGTN6VQC
KVHUFUFVOSY6GB4XI2QK4T4WCLIYOV3NZR67TX6AQHAQDWJMEOBQC
VIU2FBNVHG5FV5AJLVPMGEUO5HCLJEGZTRWNY2C5XC4AKMQZZKVAC
7M7LS7I2QT6AFZ6RVK5KK2CZ6SNJAMQIWD7MX34F7MQ3MZKH72GAC
AM42E4Y6RLS7QPWBMESL6H5RPFKG5LQYM6EFNB5UYSRSUASKLISQC
J5IEBT64CTGVDGSIBE2MTXBTGOMFSSF62SVCFZL2SDDLE745MGDAC
OAHNWDYG3V6EJQXJ333FD7M44E3VBZUWSKZ45SSFXVK5Q2HJUI4AC
ZUOL7X6VIPRCMEZURYGNHTDEIP3ZCHZW4PKVKBNXVZL5V4VOE5ZQC
U76D4P367BI2LN7UWQ5R26T6FPYL3RRZS4SBH3QZB4F57C7SFRWQC
2INHXC3KRJVZTX2BQ63ZQLHIC5SWPUG4PQKCMLC7SQKN5R7LJZ6QC
CTJ3IZGSPY4DBHC6OYNL4DZE24MXYQBM3KVJZTQHM5DI5TED5ZLQC
EMRPLZPWIJOEYBBSHFBRUYG4EFROTZ5VG7N3MTXFZFFPBTZ6ODGQC
AQQQNDTL52Q2VO3XLEGYKHTU2YSRAB4ACEGVPSWYD2Z6WA6Z2YPAC
CCYSVZA2ONWXB6XJXWSIEBY4CS2LGBEVV3RB6KZ6I4XYRXQLSTXQC
QLTJG7Q33ABBTDJ55K3OPLNSYBFBIVRS3UABXEY73RHYMOOJ542QC
2ENZW7TVCS47BWCA4AIEVGKGMT4Y2TSM5IJ7O5K2VSWNXIN3SG4QC
HYEAFRZ2UEKDYTAE2GDQLHEJBPQASP2NDLMXB7F6MTVK2BKOXKEAC
FYS7TCDWKNRNOJSGRD2JMU4B2LHX5S63ZISM7YF7KZYEYLVCIKIAC
ZLJGZYQGQ2S4UFWTVF4PQDSGMP6A4IS4GDHCMBAAA5SK2N2NWR3QC
3GFQP6IRHABYMDAEXEMM2HQNEUY4LT2P72PI3KXV4M6PSQT3SFLAC
QZH3PQFUBL2ISJBJ4PBALVKUPKQ4QKGNKSFASEXWYB5J7ODL42KQC
XHLL3JQRBGO3FVFRMEFHCFEHHRTEGSYPVW5PDBFXZO3XZAU2F7JAC
BYG5CEMVXANDTBI2ORNVMEY6K3EBRIHZHS4QBK27VONJC5537COQC
7SFHSB47KC6YH737DJPYYVONCFGEHC2Z37RCVPJO6I5PHEOLK74AC
CIQN2MDEMWAASJAHOHMUZTI5PF4JV5SZSOBYYDCIIFYO2VHWULKAC
KMRJOSLYYHHPGMYXBSLUQTICP6F4LXRCGYSP55YTZQSX4SZISDEAC
OGUV4HSA7XGSQLUVWBAE3AE263Z7Z6G3BZOB4CN2AOYD2DEJMOZAC
MP2TBKU6CNDMZKENYMBV62F5KQ27ZWEVPVRFS2RESVDQQT2IRR4AC
TKFSYQ2ZTEPN27IGKGEYSUS7FQ2AUIC7DJWFF2RJ53AW6QRPMXWQC
EMHRPJ3RAVIVJEQIRXIVDGENV6QHUUGXXRWTJ3BXC7SZNC66VK5QC
AJB4LFRBMIRBEDWJ3OW7GQIMD2BZBVQ62GH4TE2FISWZKSAHRF4QC
NQWWTGXRLSBASOSP75FPOSVYP664VYRFQH7MY5LALLIP2VEBQMCQC
65XHTZEKUTGHMOIWAFRH7ZVGUP4DWBCUT2TN4Y3LHYILKWTTBLKAC
T7SJSJIH3FUWK2TK6DNVLCNYL7ROJGFVMOOCIY3L46EX5T7M7VNAC
LS55YKGWKICTQTAHR5KLMNDOL6CDI4ATT3NT5Z2YL5IM3CRQOONQC
SN2QONLI6IAOW6UBIJIPWJVIC3HIYDPVWTT24V44CT5PK2RMJ3QAC
WIDXZBNW4CCOGWHCQSICXRPSZ4MB24OJZLEC3RO3UTEZKEFUZBSQC
VC2CU2GGRIWXIFJELD5NAELDUIRY5S5LEAFJCM2A5P3CUBYF3Z3AC
IRCKL6VNSFB7TQEKPQUPJCN37N5QW7D54DSZMESVXGK7NEHGSIPAC
5ZFHMYQIX3BJ3OXWEAZHTXVZ4P6JJ7X34D372XGKBZWVYXLX75OQC
YJJ4X4JGABMVA5JBQW5UAWI543P3Y7NDVFTOHA6LIDA5KSFGUFNQC
BTKAW76LJFOXLINKJKOIK47MUDFHZKDMWX3NQODS2XUQLYGOZXUQC
JFFUF5ALUWPDM7IEDEZVAYG2SVXO334STONRGKVB3QKY2TT5QGBQC
TVM2WIHHCOTGYGJ4EL6MIUVH22Y4TU3HHHUFWAVVJQAT2ARCA2NQC
IDG26SXKRKPRUQM3G2MMBQR3UZGNU7NCWJBBDKKIM5IGUNJQ2URQC
VCMS2CWTJDOW2FYCDH4ZSMVJAP6W4ZV4VWHJPPGGNHRX47GWYAGAC
252M2QMDBMNWHBZY5MDSC7WVYO5JBLJYPVMW5W4IVJCZVYRQ5IQQC
VG75U7IM2ZQTGM2QETDT6QQ4CSLQPB4APK436POAAQJWOMINPIJAC
XNFTJHC4QSHNSIWNN7K6QZEZ37GTQYKHS4EPNSVPQCUSWREROGIQC
242L3OQXTU2TCAINRJXQEEDSXQXM7Y7USUPBK37ZNM3A7V5TUDSAC
AVLAYODPMKCDBUFJSTGNUXIK74V3NDCBH55DBBFTNVBMFY6I7BCAC
HRWN5V6J6VMXS7WNSRGI7WMUSZ2OI52JJ4IK352VVSDZI4EF5HHQC
JRLBUB6LR2JIAKVQNKF3T4BDICUIJ3HEMRRHX56YP5M5SP7ZS3WAC
R5QXEHUIZLELJGGCZAE7ATNS3CLRJ7JFRENMGH4XXH24C5WABZDQC
BJ5X5O4ACBBJ56LRBBSTCW6IBQP4HAEOOOPNH3SKTA4F66YTOIDAC
-- a line is either text or a drawing
-- a text is a table with:
-- mode = 'text',
-- string data,
-- startpos, the index of data the line starts rendering from (if currently on screen), can only be >1 for topmost line on screen
-- starty, the y coord in pixels
-- some cached data that's blown away and recomputed when data changes:
-- fragments: snippets of rendered love.graphics.Text, guaranteed to not wrap
-- screen_line_starting_pos: optional array of grapheme indices if it wraps over more than one screen line
-- a drawing is a table with:
-- mode = 'drawing'
-- a (y) coord in pixels (updated while painting screen),
-- a (h)eight,
-- an array of points, and
-- an array of shapes
-- a shape is a table containing:
-- a mode
-- an array points for mode 'freehand' (raw x,y coords; freehand drawings don't pollute the points array of a drawing)
-- an array vertices for mode 'polygon', 'rectangle', 'square'
-- p1, p2 for mode 'line'
-- center, radius for mode 'circle'
-- center, radius, start_angle, end_angle for mode 'arc'
-- Unless otherwise specified, coord fields are normalized; a drawing is always 256 units wide
-- The field names are carefully chosen so that switching modes in midstream
-- remembers previously entered points where that makes sense.
Lines = {{mode='text', data=''}}
-- Lines can be too long to fit on screen, in which case they _wrap_ into
-- multiple _screen lines_.
--
-- Therefore, any potential location for the cursor can be described in two ways:
-- * schema 1: As a combination of line index and position within a line (in utf8 codepoint units)
-- * schema 2: As a combination of line index, screen line index within the line, and a position within the screen line.
--
-- Most of the time we'll only persist positions in schema 1, translating to
-- schema 2 when that's convenient.
--
-- Make sure these coordinates are never aliased, so that changing one causes
-- action at a distance.
Screen_top1 = {line=1, pos=1} -- position of start of screen line at top of screen
Cursor1 = {line=1, pos=1} -- position of cursor
Screen_bottom1 = {line=1, pos=1} -- position of start of screen line at bottom of screen
Selection1 = {}
Old_cursor1, Old_selection1, Mousepress_shift = nil -- some extra state to compute selection between mouse press and release
Recent_mouse = {} -- when selecting text, avoid recomputing some state on every single frame
Cursor_x, Cursor_y = 0, 0 -- in pixels
Current_drawing_mode = 'line'
Previous_drawing_mode = nil
-- values for tests
Font_height = 14
Line_height = 15
-- widest possible character width
Em = App.newText(love.graphics.getFont(), 'm')
Margin_top = 15
Margin_left = 25
Margin_right = 25
Margin_width = Margin_left + Margin_right
Drawing_padding_top = 10
Drawing_padding_bottom = 10
Drawing_padding_height = Drawing_padding_top + Drawing_padding_bottom
Filename = love.filesystem.getUserDirectory()..'/lines.txt'
Next_save = nil
-- undo
History = {}
Next_history = 1
-- search
Search_term = nil
Search_text = nil
Search_backup = nil -- stuff to restore when cancelling search
return edit.initialize_globals()
end
-- resize
Last_resize_time = nil
-- blinking cursor
Cursor_time = 0
end -- App.initialize_globals
Button_handlers = {}
love.graphics.setColor(0, 0, 0)
--? print(Screen_top1.line, Screen_top1.pos, Cursor1.line, Cursor1.pos)
assert(Text.le1(Screen_top1, Cursor1))
Cursor_y = -1
local y = Margin_top
--? print('== draw')
for line_index = Screen_top1.line,#Lines do
local line = Lines[line_index]
--? print('draw:', y, line_index, line)
if y + Line_height > App.screen.height then break end
Screen_bottom1.line = line_index
if line.mode == 'text' and line.data == '' then
line.starty = y
line.startpos = 1
-- insert new drawing
button('draw', {x=4,y=y+4, w=12,h=12, color={1,1,0},
icon = icon.insert_drawing,
onpress1 = function()
Drawing.before = snapshot(line_index-1, line_index)
table.insert(Lines, line_index, {mode='drawing', y=y, h=256/2, points={}, shapes={}, pending={}})
if Cursor1.line >= line_index then
Cursor1.line = Cursor1.line+1
end
schedule_save()
record_undo_event({before=Drawing.before, after=snapshot(line_index-1, line_index+1)})
end
})
if Search_term == nil then
if line_index == Cursor1.line then
Text.draw_cursor(Margin_left, y)
end
end
Screen_bottom1.pos = Screen_top1.pos
y = y + Line_height
elseif line.mode == 'drawing' then
y = y+Drawing_padding_top
line.y = y
Drawing.draw(line)
y = y + Drawing.pixels(line.h) + Drawing_padding_bottom
else
line.starty = y
line.startpos = 1
if line_index == Screen_top1.line then
line.startpos = Screen_top1.pos
end
--? print('text.draw', y, line_index)
y, Screen_bottom1.pos = Text.draw(line, line_index, line.starty, Margin_left, App.screen.width-Margin_right)
y = y + Line_height
--? print('=> y', y)
end
end
if Cursor_y == -1 then
Cursor_y = App.screen.height
end
--? print('screen bottom: '..tostring(Screen_bottom1.pos)..' in '..tostring(Lines[Screen_bottom1.line].data))
if Search_term then
Text.draw_search_bar()
end
edit.draw()
Cursor_time = Cursor_time + dt
-- some hysteresis while resizing
if Last_resize_time then
if App.getTime() - Last_resize_time < 0.1 then
return
else
Last_resize_time = nil
end
end
Drawing.update(dt)
if Next_save and Next_save < App.getTime() then
save_to_disk(Lines, Filename)
Next_save = nil
end
edit.update(dt)
if Search_term then return end
-- ensure cursor is visible immediately after it moves
Cursor_time = 0
--? print('press', Selection1.line, Selection1.pos)
propagate_to_button_handlers(x,y, mouse_button)
for line_index,line in ipairs(Lines) do
if line.mode == 'text' then
if Text.in_line(line, x,y, Margin_left, App.screen.width-Margin_right) then
-- delicate dance between cursor, selection and old cursor/selection
-- scenarios:
-- regular press+release: sets cursor, clears selection
-- shift press+release:
-- sets selection to old cursor if not set otherwise leaves it untouched
-- sets cursor
-- press and hold to start a selection: sets selection on press, cursor on release
-- press and hold, then press shift: ignore shift
-- i.e. mousereleased should never look at shift state
Old_cursor1 = Cursor1
Old_selection1 = Selection1
Mousepress_shift = App.shift_down()
Selection1 = {
line=line_index,
pos=Text.to_pos_on_line(line, x, y, Margin_left, App.screen.width-Margin_right),
}
--? print('selection', Selection1.line, Selection1.pos)
break
end
elseif line.mode == 'drawing' then
if Drawing.in_drawing(line, x, y) then
Lines.current_drawing_index = line_index
Lines.current_drawing = line
Drawing.before = snapshot(line_index)
Drawing.mouse_pressed(line, x,y, mouse_button)
break
end
end
end
return edit.mouse_pressed(x,y, mouse_button)
if Search_term then return end
--? print('release')
-- ensure cursor is visible immediately after it moves
Cursor_time = 0
if Lines.current_drawing then
Drawing.mouse_released(x,y, mouse_button)
schedule_save()
if Drawing.before then
record_undo_event({before=Drawing.before, after=snapshot(Lines.current_drawing_index)})
Drawing.before = nil
end
else
for line_index,line in ipairs(Lines) do
if line.mode == 'text' then
if Text.in_line(line, x,y, Margin_left, App.screen.width-Margin_right) then
--? print('reset selection')
Cursor1 = {
line=line_index,
pos=Text.to_pos_on_line(line, x, y, Margin_left, App.screen.width-Margin_right),
}
--? print('cursor', Cursor1.line, Cursor1.pos)
if Mousepress_shift then
if Old_selection1.line == nil then
Selection1 = Old_cursor1
else
Selection1 = Old_selection1
end
end
Old_cursor1, Old_selection1, Mousepress_shift = nil
if eq(Cursor1, Selection1) then
Selection1 = {}
end
break
end
end
end
--? print('selection:', Selection1.line, Selection1.pos)
end
return edit.mouse_released(x,y, mouse_button)
-- ensure cursor is visible immediately after it moves
Cursor_time = 0
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
if Search_term then
Search_term = Search_term..t
Search_text = nil
Text.search_next()
elseif Current_drawing_mode == 'name' then
local before = snapshot(Lines.current_drawing_index)
local drawing = Lines.current_drawing
local p = drawing.points[drawing.pending.target_point]
p.name = p.name..t
record_undo_event({before=before, after=snapshot(Lines.current_drawing_index)})
else
Text.textinput(t)
end
schedule_save()
return edit.textinput(t)
-- ensure cursor is visible immediately after it moves
Cursor_time = 0
if Selection1.line and
not Lines.current_drawing and
-- printable character created using shift key => delete selection
-- (we're not creating any ctrl-shift- or alt-shift- combinations using regular/printable keys)
(not App.shift_down() or utf8.len(key) == 1) and
chord ~= 'C-c' and chord ~= 'C-x' and chord ~= 'backspace' and backspace ~= 'delete' and not App.is_cursor_movement(chord) then
Text.delete_selection(Margin_left, App.screen.width-Margin_right)
end
if Search_term then
if chord == 'escape' then
Search_term = nil
Search_text = nil
Cursor1 = Search_backup.cursor
Screen_top1 = Search_backup.screen_top
Search_backup = nil
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
elseif chord == 'return' then
Search_term = nil
Search_text = nil
Search_backup = nil
elseif chord == 'backspace' then
local len = utf8.len(Search_term)
local byte_offset = Text.offset(Search_term, len)
Search_term = string.sub(Search_term, 1, byte_offset-1)
Search_text = nil
elseif chord == 'down' then
Cursor1.pos = Cursor1.pos+1
Text.search_next()
elseif chord == 'up' then
Text.search_previous()
end
return
elseif chord == 'C-f' then
Search_term = ''
Search_backup = {cursor={line=Cursor1.line, pos=Cursor1.pos}, screen_top={line=Screen_top1.line, pos=Screen_top1.pos}}
assert(Search_text == nil)
elseif chord == 'C-=' then
initialize_font_settings(Font_height+2)
Text.redraw_all()
elseif chord == 'C--' then
initialize_font_settings(Font_height-2)
Text.redraw_all()
elseif chord == 'C-0' then
initialize_font_settings(20)
Text.redraw_all()
elseif chord == 'C-z' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local event = undo_event()
if event then
local src = event.before
Screen_top1 = deepcopy(src.screen_top)
Cursor1 = deepcopy(src.cursor)
Selection1 = deepcopy(src.selection)
patch(Lines, event.after, event.before)
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
schedule_save()
end
elseif chord == 'C-y' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local event = redo_event()
if event then
local src = event.after
Screen_top1 = deepcopy(src.screen_top)
Cursor1 = deepcopy(src.cursor)
Selection1 = deepcopy(src.selection)
patch(Lines, event.before, event.after)
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
schedule_save()
end
-- clipboard
elseif chord == 'C-c' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local s = Text.selection()
if s then
App.setClipboardText(s)
end
elseif chord == 'C-x' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local s = Text.cut_selection(Margin_left, App.screen.width-Margin_right)
if s then
App.setClipboardText(s)
end
schedule_save()
elseif chord == 'C-v' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
-- We don't have a good sense of when to scroll, so we'll be conservative
-- and sometimes scroll when we didn't quite need to.
local before_line = Cursor1.line
local before = snapshot(before_line)
local clipboard_data = App.getClipboardText()
for _,code in utf8.codes(clipboard_data) do
local c = utf8.char(code)
if c == '\n' then
Text.insert_return()
else
Text.insert_at_cursor(c)
end
end
if Text.cursor_past_screen_bottom() then
Text.snap_cursor_to_bottom_of_screen(Margin_left, App.screen.height-Margin_right)
end
schedule_save()
record_undo_event({before=before, after=snapshot(before_line, Cursor1.line)})
-- dispatch to drawing or text
elseif App.mouse_down(1) or chord:sub(1,2) == 'C-' then
-- DON'T reset line.y here
local drawing_index, drawing = Drawing.current_drawing()
if drawing_index then
local before = snapshot(drawing_index)
Drawing.keychord_pressed(chord)
record_undo_event({before=before, after=snapshot(drawing_index)})
schedule_save()
end
elseif chord == 'escape' and not App.mouse_down(1) then
for _,line in ipairs(Lines) do
if line.mode == 'drawing' then
line.show_help = false
end
end
elseif Current_drawing_mode == 'name' then
if chord == 'return' then
Current_drawing_mode = Previous_drawing_mode
Previous_drawing_mode = nil
else
local before = snapshot(Lines.current_drawing_index)
local drawing = Lines.current_drawing
local p = drawing.points[drawing.pending.target_point]
if chord == 'escape' then
p.name = nil
record_undo_event({before=before, after=snapshot(Lines.current_drawing_index)})
elseif chord == 'backspace' then
local len = utf8.len(p.name)
local byte_offset = Text.offset(p.name, len-1)
if len == 1 then byte_offset = 0 end
p.name = string.sub(p.name, 1, byte_offset)
record_undo_event({before=before, after=snapshot(Lines.current_drawing_index)})
end
end
schedule_save()
else
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
Text.keychord_pressed(chord)
end
return edit.keychord_pressed(chord, key)
utf8 = require 'utf8'
require 'file'
require 'button'
require 'text'
require 'drawing'
require 'geom'
require 'help'
require 'icons'
edit = {}
-- run in both tests and a real run
function edit.initialize_globals()
-- a line is either text or a drawing
-- a text is a table with:
-- mode = 'text',
-- string data,
-- startpos, the index of data the line starts rendering from (if currently on screen), can only be >1 for topmost line on screen
-- starty, the y coord in pixels
-- some cached data that's blown away and recomputed when data changes:
-- fragments: snippets of rendered love.graphics.Text, guaranteed to not wrap
-- screen_line_starting_pos: optional array of grapheme indices if it wraps over more than one screen line
-- a drawing is a table with:
-- mode = 'drawing'
-- a (y) coord in pixels (updated while painting screen),
-- a (h)eight,
-- an array of points, and
-- an array of shapes
-- a shape is a table containing:
-- a mode
-- an array points for mode 'freehand' (raw x,y coords; freehand drawings don't pollute the points array of a drawing)
-- an array vertices for mode 'polygon', 'rectangle', 'square'
-- p1, p2 for mode 'line'
-- center, radius for mode 'circle'
-- center, radius, start_angle, end_angle for mode 'arc'
-- Unless otherwise specified, coord fields are normalized; a drawing is always 256 units wide
-- The field names are carefully chosen so that switching modes in midstream
-- remembers previously entered points where that makes sense.
Lines = {{mode='text', data=''}}
-- Lines can be too long to fit on screen, in which case they _wrap_ into
-- multiple _screen lines_.
--
-- Therefore, any potential location for the cursor can be described in two ways:
-- * schema 1: As a combination of line index and position within a line (in utf8 codepoint units)
-- * schema 2: As a combination of line index, screen line index within the line, and a position within the screen line.
--
-- Most of the time we'll only persist positions in schema 1, translating to
-- schema 2 when that's convenient.
--
-- Make sure these coordinates are never aliased, so that changing one causes
-- action at a distance.
Screen_top1 = {line=1, pos=1} -- position of start of screen line at top of screen
Cursor1 = {line=1, pos=1} -- position of cursor
Screen_bottom1 = {line=1, pos=1} -- position of start of screen line at bottom of screen
Selection1 = {}
Old_cursor1, Old_selection1, Mousepress_shift = nil -- some extra state to compute selection between mouse press and release
Recent_mouse = {} -- when selecting text, avoid recomputing some state on every single frame
Cursor_x, Cursor_y = 0, 0 -- in pixels
Current_drawing_mode = 'line'
Previous_drawing_mode = nil
-- values for tests
Font_height = 14
Line_height = 15
-- widest possible character width
Em = App.newText(love.graphics.getFont(), 'm')
Margin_top = 15
Margin_left = 25
Margin_right = 25
Margin_width = Margin_left + Margin_right
Drawing_padding_top = 10
Drawing_padding_bottom = 10
Drawing_padding_height = Drawing_padding_top + Drawing_padding_bottom
Filename = love.filesystem.getUserDirectory()..'/lines.txt'
Next_save = nil
-- undo
History = {}
Next_history = 1
-- search
Search_term = nil
Search_text = nil
Search_backup = nil -- stuff to restore when cancelling search
-- resize
Last_resize_time = nil
-- blinking cursor
Cursor_time = 0
end -- App.initialize_globals
function edit.draw()
Button_handlers = {}
love.graphics.setColor(0, 0, 0)
--? print(Screen_top1.line, Screen_top1.pos, Cursor1.line, Cursor1.pos)
assert(Text.le1(Screen_top1, Cursor1))
Cursor_y = -1
local y = Margin_top
--? print('== draw')
for line_index = Screen_top1.line,#Lines do
local line = Lines[line_index]
--? print('draw:', y, line_index, line)
if y + Line_height > App.screen.height then break end
Screen_bottom1.line = line_index
if line.mode == 'text' and line.data == '' then
line.starty = y
line.startpos = 1
-- insert new drawing
button('draw', {x=4,y=y+4, w=12,h=12, color={1,1,0},
icon = icon.insert_drawing,
onpress1 = function()
Drawing.before = snapshot(line_index-1, line_index)
table.insert(Lines, line_index, {mode='drawing', y=y, h=256/2, points={}, shapes={}, pending={}})
if Cursor1.line >= line_index then
Cursor1.line = Cursor1.line+1
end
schedule_save()
record_undo_event({before=Drawing.before, after=snapshot(line_index-1, line_index+1)})
end
})
if Search_term == nil then
if line_index == Cursor1.line then
Text.draw_cursor(Margin_left, y)
end
end
Screen_bottom1.pos = Screen_top1.pos
y = y + Line_height
elseif line.mode == 'drawing' then
y = y+Drawing_padding_top
line.y = y
Drawing.draw(line)
y = y + Drawing.pixels(line.h) + Drawing_padding_bottom
else
line.starty = y
line.startpos = 1
if line_index == Screen_top1.line then
line.startpos = Screen_top1.pos
end
--? print('text.draw', y, line_index)
y, Screen_bottom1.pos = Text.draw(line, line_index, line.starty, Margin_left, App.screen.width-Margin_right)
y = y + Line_height
--? print('=> y', y)
end
end
if Cursor_y == -1 then
Cursor_y = App.screen.height
end
--? print('screen bottom: '..tostring(Screen_bottom1.pos)..' in '..tostring(Lines[Screen_bottom1.line].data))
if Search_term then
Text.draw_search_bar()
end
end
function edit.update(dt)
Cursor_time = Cursor_time + dt
-- some hysteresis while resizing
if Last_resize_time then
if App.getTime() - Last_resize_time < 0.1 then
return
else
Last_resize_time = nil
end
end
Drawing.update(dt)
if Next_save and Next_save < App.getTime() then
save_to_disk(Lines, Filename)
Next_save = nil
end
end
function schedule_save()
if Next_save == nil then
Next_save = App.getTime() + 3 -- short enough that you're likely to still remember what you did
end
end
function edit.quit()
-- make sure to save before quitting
if Next_save then
save_to_disk(Lines, Filename)
end
end
function edit.mouse_pressed(x,y, mouse_button)
if Search_term then return end
-- ensure cursor is visible immediately after it moves
Cursor_time = 0
--? print('press', Selection1.line, Selection1.pos)
propagate_to_button_handlers(x,y, mouse_button)
for line_index,line in ipairs(Lines) do
if line.mode == 'text' then
if Text.in_line(line, x,y, Margin_left, App.screen.width-Margin_right) then
-- delicate dance between cursor, selection and old cursor/selection
-- scenarios:
-- regular press+release: sets cursor, clears selection
-- shift press+release:
-- sets selection to old cursor if not set otherwise leaves it untouched
-- sets cursor
-- press and hold to start a selection: sets selection on press, cursor on release
-- press and hold, then press shift: ignore shift
-- i.e. mousereleased should never look at shift state
Old_cursor1 = Cursor1
Old_selection1 = Selection1
Mousepress_shift = App.shift_down()
Selection1 = {
line=line_index,
pos=Text.to_pos_on_line(line, x, y, Margin_left, App.screen.width-Margin_right),
}
--? print('selection', Selection1.line, Selection1.pos)
break
end
elseif line.mode == 'drawing' then
if Drawing.in_drawing(line, x, y) then
Lines.current_drawing_index = line_index
Lines.current_drawing = line
Drawing.before = snapshot(line_index)
Drawing.mouse_pressed(line, x,y, mouse_button)
break
end
end
end
end
function edit.mouse_released(x,y, mouse_button)
if Search_term then return end
--? print('release')
-- ensure cursor is visible immediately after it moves
Cursor_time = 0
if Lines.current_drawing then
Drawing.mouse_released(x,y, mouse_button)
schedule_save()
if Drawing.before then
record_undo_event({before=Drawing.before, after=snapshot(Lines.current_drawing_index)})
Drawing.before = nil
end
else
for line_index,line in ipairs(Lines) do
if line.mode == 'text' then
if Text.in_line(line, x,y, Margin_left, App.screen.width-Margin_right) then
--? print('reset selection')
Cursor1 = {
line=line_index,
pos=Text.to_pos_on_line(line, x, y, Margin_left, App.screen.width-Margin_right),
}
--? print('cursor', Cursor1.line, Cursor1.pos)
if Mousepress_shift then
if Old_selection1.line == nil then
Selection1 = Old_cursor1
else
Selection1 = Old_selection1
end
end
Old_cursor1, Old_selection1, Mousepress_shift = nil
if eq(Cursor1, Selection1) then
Selection1 = {}
end
break
end
end
end
--? print('selection:', Selection1.line, Selection1.pos)
end
end
function edit.textinput(t)
-- ensure cursor is visible immediately after it moves
Cursor_time = 0
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
if Search_term then
Search_term = Search_term..t
Search_text = nil
Text.search_next()
elseif Current_drawing_mode == 'name' then
local before = snapshot(Lines.current_drawing_index)
local drawing = Lines.current_drawing
local p = drawing.points[drawing.pending.target_point]
p.name = p.name..t
record_undo_event({before=before, after=snapshot(Lines.current_drawing_index)})
else
Text.textinput(t)
end
schedule_save()
end
function edit.keychord_pressed(chord, key)
-- ensure cursor is visible immediately after it moves
Cursor_time = 0
if Selection1.line and
not Lines.current_drawing and
-- printable character created using shift key => delete selection
-- (we're not creating any ctrl-shift- or alt-shift- combinations using regular/printable keys)
(not App.shift_down() or utf8.len(key) == 1) and
chord ~= 'C-c' and chord ~= 'C-x' and chord ~= 'backspace' and backspace ~= 'delete' and not App.is_cursor_movement(chord) then
Text.delete_selection(Margin_left, App.screen.width-Margin_right)
end
if Search_term then
if chord == 'escape' then
Search_term = nil
Search_text = nil
Cursor1 = Search_backup.cursor
Screen_top1 = Search_backup.screen_top
Search_backup = nil
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
elseif chord == 'return' then
Search_term = nil
Search_text = nil
Search_backup = nil
elseif chord == 'backspace' then
local len = utf8.len(Search_term)
local byte_offset = Text.offset(Search_term, len)
Search_term = string.sub(Search_term, 1, byte_offset-1)
Search_text = nil
elseif chord == 'down' then
Cursor1.pos = Cursor1.pos+1
Text.search_next()
elseif chord == 'up' then
Text.search_previous()
end
return
elseif chord == 'C-f' then
Search_term = ''
Search_backup = {cursor={line=Cursor1.line, pos=Cursor1.pos}, screen_top={line=Screen_top1.line, pos=Screen_top1.pos}}
assert(Search_text == nil)
elseif chord == 'C-=' then
initialize_font_settings(Font_height+2)
Text.redraw_all()
elseif chord == 'C--' then
initialize_font_settings(Font_height-2)
Text.redraw_all()
elseif chord == 'C-0' then
initialize_font_settings(20)
Text.redraw_all()
elseif chord == 'C-z' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local event = undo_event()
if event then
local src = event.before
Screen_top1 = deepcopy(src.screen_top)
Cursor1 = deepcopy(src.cursor)
Selection1 = deepcopy(src.selection)
patch(Lines, event.after, event.before)
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
schedule_save()
end
elseif chord == 'C-y' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local event = redo_event()
if event then
local src = event.after
Screen_top1 = deepcopy(src.screen_top)
Cursor1 = deepcopy(src.cursor)
Selection1 = deepcopy(src.selection)
patch(Lines, event.before, event.after)
Text.redraw_all() -- if we're scrolling, reclaim all fragments to avoid memory leaks
schedule_save()
end
-- clipboard
elseif chord == 'C-c' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local s = Text.selection()
if s then
App.setClipboardText(s)
end
elseif chord == 'C-x' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
local s = Text.cut_selection(Margin_left, App.screen.width-Margin_right)
if s then
App.setClipboardText(s)
end
schedule_save()
elseif chord == 'C-v' then
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
-- We don't have a good sense of when to scroll, so we'll be conservative
-- and sometimes scroll when we didn't quite need to.
local before_line = Cursor1.line
local before = snapshot(before_line)
local clipboard_data = App.getClipboardText()
for _,code in utf8.codes(clipboard_data) do
local c = utf8.char(code)
if c == '\n' then
Text.insert_return()
else
Text.insert_at_cursor(c)
end
end
if Text.cursor_past_screen_bottom() then
Text.snap_cursor_to_bottom_of_screen(Margin_left, App.screen.height-Margin_right)
end
schedule_save()
record_undo_event({before=before, after=snapshot(before_line, Cursor1.line)})
-- dispatch to drawing or text
elseif App.mouse_down(1) or chord:sub(1,2) == 'C-' then
-- DON'T reset line.y here
local drawing_index, drawing = Drawing.current_drawing()
if drawing_index then
local before = snapshot(drawing_index)
Drawing.keychord_pressed(chord)
record_undo_event({before=before, after=snapshot(drawing_index)})
schedule_save()
end
elseif chord == 'escape' and not App.mouse_down(1) then
for _,line in ipairs(Lines) do
if line.mode == 'drawing' then
line.show_help = false
end
end
elseif Current_drawing_mode == 'name' then
if chord == 'return' then
Current_drawing_mode = Previous_drawing_mode
Previous_drawing_mode = nil
else
local before = snapshot(Lines.current_drawing_index)
local drawing = Lines.current_drawing
local p = drawing.points[drawing.pending.target_point]
if chord == 'escape' then
p.name = nil
record_undo_event({before=before, after=snapshot(Lines.current_drawing_index)})
elseif chord == 'backspace' then
local len = utf8.len(p.name)
local byte_offset = Text.offset(p.name, len-1)
if len == 1 then byte_offset = 0 end
p.name = string.sub(p.name, 1, byte_offset)
record_undo_event({before=before, after=snapshot(Lines.current_drawing_index)})
end
end
schedule_save()
else
for _,line in ipairs(Lines) do line.y = nil end -- just in case we scroll
Text.keychord_pressed(chord)
end
end
function edit.key_released(key, scancode)
end