Merge lines.love

Pensieve.love can now edit itself. This is a large change and possibly
destabilizing.
This commit is contained in:
Kartik K. Agaram 2022-09-03 18:10:12 -07:00
commit 0e96198edb
22 changed files with 6583 additions and 1627 deletions

View File

@ -3,11 +3,22 @@ program before it ever runs. However, some things don't have tests yet, either
because I don't know how to test them or because I've been lazy. I'll at least
record those here.
* Initializing settings:
- from previous session
- Filename as absolute path
- Filename as relative path
- from defaults
Startup:
- terminal log shows unit tests running
Initializing settings:
- delete app settings, start; window opens running the note-taking app
- quit while running the note-taking app, restart; window opens running the note-taking app in same position+dimensions
- quit while editing source (color; no drawings; no selection), restart; window opens editing source in same position+dimensions
- start out running the note-taking app, move window, press ctrl+e twice; window is running note-taking app in same position+dimensions
- start out editing source, move window, press ctrl+e twice; window is editing source in same position+dimensions
- no log file; switching to source works
Code loading:
* run love with directory; note-taking app runs
* run love with zip file; note-taking app runs
* start out in the note-taking app, press ctrl+e to edit source, make a change to the source, press ctrl+e twice to return to the source editor; the change should be preserved.
* How the screen looks. Our tests use a level of indirection to check text and
graphics printed to screen, but not the precise pixels they translate to.

View File

@ -44,6 +44,13 @@ love .
pensieve.love will then create a `data/` directory in the app's save directory
as above. Confusing location, but everything will be in one place.
## Modifying the app
Hit `ctrl+u` from within the to modify its code. The infrastructure works, but
it isn't advertized within the app because this particular app is currently
too large to comfortably modify from within itself. I use more specialized
editors while I improve the editing infrastructure further.
## Known issues
* No support yet for Unicode graphemes spanning multiple codepoints.
@ -86,6 +93,10 @@ as above. Confusing location, but everything will be in one place.
* No scrollbars yet. That stuff is hard.
* There are some temporary limitations when editing sources:
- no line drawings
- no selecting text
## Mirrors and Forks
This repo is a fork of [lines.love](http://akkartik.name/lines.html). Updates

29
app.lua
View File

@ -1,4 +1,4 @@
-- main entrypoint for LÖVE
-- love.run: main entrypoint function for LÖVE
--
-- Most apps can just use the default, but we need to override it to
-- install a test harness.
@ -11,13 +11,10 @@
--
-- Scroll below this function for more details.
function love.run()
App.snapshot_love()
-- Tests always run at the start.
App.run_tests()
App.run_tests_and_initialize()
--? print('==')
App.disable_tests()
App.initialize_globals()
App.initialize(love.arg.parseGameArguments(arg), arg)
love.timer.step()
local dt = 0
@ -123,6 +120,26 @@ end
App = {screen={}}
-- save/restore various framework globals we care about -- only on very first load
function App.snapshot_love()
if Love_snapshot then return end
Love_snapshot = {}
-- save the entire initial font; it doesn't seem reliably recreated using newFont
Love_snapshot.initial_font = love.graphics.getFont()
end
function App.undo_initialize()
love.graphics.setFont(Love_snapshot.initial_font)
end
function App.run_tests_and_initialize()
App.load()
App.run_tests()
App.disable_tests()
App.initialize_globals()
App.initialize(love.arg.parseGameArguments(arg), arg)
end
function App.initialize_for_test()
App.screen.init({width=100, height=50})
App.screen.contents = {} -- clear screen

83
colorize.lua Normal file
View File

@ -0,0 +1,83 @@
-- State transitions while colorizing a single line.
-- Just for comments and strings.
-- Limitation: each fragment gets a uniform color so we can only change color
-- at word boundaries.
Next_state = {
normal={
{prefix='--', target='comment'},
{prefix='"', target='dstring'},
{prefix="'", target='sstring'},
},
dstring={
{suffix='"', target='normal'},
},
sstring={
{suffix="'", target='normal'},
},
-- comments are a sink
}
Comments_color = {r=0, g=0, b=1}
String_color = {r=0, g=0.5, b=0.5}
Divider_color = {r=0.7, g=0.7, b=0.7}
Colors = {
normal=Text_color,
comment=Comments_color,
sstring=String_color,
dstring=String_color
}
Current_state = 'normal'
function initialize_color()
--? print('new line')
Current_state = 'normal'
end
function select_color(frag)
--? print('before', '^'..frag..'$', Current_state)
switch_color_based_on_prefix(frag)
--? print('using color', Current_state, Colors[Current_state])
App.color(Colors[Current_state])
switch_color_based_on_suffix(frag)
--? print('state after suffix', Current_state)
end
function switch_color_based_on_prefix(frag)
if Next_state[Current_state] == nil then
return
end
frag = rtrim(frag)
for _,edge in pairs(Next_state[Current_state]) do
if edge.prefix and find(frag, edge.prefix, nil, --[[plain]] true) == 1 then
Current_state = edge.target
break
end
end
end
function switch_color_based_on_suffix(frag)
if Next_state[Current_state] == nil then
return
end
frag = rtrim(frag)
for _,edge in pairs(Next_state[Current_state]) do
if edge.suffix and rfind(frag, edge.suffix, nil, --[[plain]] true) == #frag then
Current_state = edge.target
break
end
end
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

View File

@ -20,15 +20,6 @@ Drawing_padding_height = Drawing_padding_top + Drawing_padding_bottom
Same_point_distance = 4 -- pixel distance at which two points are considered the same
utf8 = require 'utf8'
require 'file'
require 'text'
require 'drawing'
require 'geom'
require 'help'
require 'icons'
edit = {}
-- run in both tests and a real run

View File

@ -71,7 +71,6 @@ function save_to_disk(State)
end
end
json = require 'json'
function load_drawing(infile_next_line)
local drawing = {mode='drawing', h=256/2, points={}, shapes={}, pending={}}
while true do

View File

@ -56,9 +56,17 @@ end
array = {}
function array.find(arr, elem)
for i,x in ipairs(arr) do
if x == elem then
return i
if type(elem) == 'function' then
for i,x in ipairs(arr) do
if elem(x) then
return i
end
end
else
for i,x in ipairs(arr) do
if x == elem then
return i
end
end
end
return nil

34
log.lua Normal file
View File

@ -0,0 +1,34 @@
function log(stack_frame_index, obj)
local info = debug.getinfo(stack_frame_index, 'Sl')
local msg
if type(obj) == 'string' then
msg = obj
else
msg = json.encode(obj)
end
love.filesystem.append('log', info.short_src..':'..info.currentline..': '..msg..'\n')
end
-- for section delimiters we'll use specific Unicode box characters
function log_start(name, stack_frame_index)
if stack_frame_index == nil then
stack_frame_index = 3
end
log(stack_frame_index, '\u{250c} ' .. name)
end
function log_end(name, stack_frame_index)
if stack_frame_index == nil then
stack_frame_index = 3
end
log(stack_frame_index, '\u{2518} ' .. name)
end
function log_new(name, stack_frame_index)
if stack_frame_index == nil then
stack_frame_index = 4
end
log_end(name, stack_frame_index)
log_start(name, stack_frame_index)
end
-- vim:noexpandtab

316
log_browser.lua Normal file
View File

@ -0,0 +1,316 @@
-- environment for immutable logs
-- optionally reads extensions for rendering some types from the source codebase that generated them
--
-- We won't care too much about long, wrapped lines. If they lines get too
-- long to manage, you need a better, graphical rendering for them. Load
-- functions to render them into the log_render namespace.
function source.initialize_log_browser_side()
Log_browser_state = edit.initialize_state(Margin_top, Editor_state.right + Margin_right + Margin_left, (Editor_state.right+Margin_right)*2, Editor_state.font_height, Editor_state.line_height)
Log_browser_state.filename = 'log'
load_from_disk(Log_browser_state) -- TODO: pay no attention to Fold
log_browser.parse(Log_browser_state)
Text.redraw_all(Log_browser_state)
Log_browser_state.screen_top1 = {line=1, pos=1}
Log_browser_state.cursor1 = {line=1, pos=nil}
end
Section_stack = {}
Section_border_color = {r=0.7, g=0.7, b=0.7}
Cursor_line_background_color = {r=0.7, g=0.7, b=0, a=0.1}
Section_border_padding_horizontal = 30 -- TODO: adjust this based on font height (because we draw text vertically along the borders
Section_border_padding_vertical = 15 -- TODO: adjust this based on font height
log_browser = {}
function log_browser.parse(State)
for _,line in ipairs(State.lines) do
if line.data ~= '' then
line.filename, line.line_number, line.data = line.data:match('%[string "([^:]*)"%]:([^:]*):%s*(.*)')
line.filename = guess_source(line.filename)
line.line_number = tonumber(line.line_number)
if line.data:sub(1,1) == '{' then
local data = json.decode(line.data)
if log_render[data.name] then
line.data = data
end
line.section_stack = table.shallowcopy(Section_stack)
elseif line.data:match('\u{250c}') then
line.section_stack = table.shallowcopy(Section_stack) -- as it is at the beginning
local section_name = line.data:match('\u{250c}%s*(.*)')
table.insert(Section_stack, {name=section_name})
line.section_begin = true
line.section_name = section_name
line.data = nil
elseif line.data:match('\u{2518}') then
local section_name = line.data:match('\u{2518}%s*(.*)')
if array.find(Section_stack, function(x) return x.name == section_name end) then
while table.remove(Section_stack).name ~= section_name do
--
end
line.section_end = true
line.section_name = section_name
line.data = nil
end
line.section_stack = table.shallowcopy(Section_stack)
else
-- string
line.section_stack = table.shallowcopy(Section_stack)
end
else
line.section_stack = {}
end
end
end
function table.shallowcopy(x)
return {unpack(x)}
end
function guess_source(filename)
local possible_source = filename:gsub('%.lua$', '%.splua')
if file_exists(possible_source) then
return possible_source
else
return filename
end
end
function log_browser.draw(State)
assert(#State.lines == #State.line_cache)
local mouse_line_index = log_browser.line_index(State, App.mouse_x(), App.mouse_y())
local y = State.top
for line_index = State.screen_top1.line,#State.lines do
App.color(Text_color)
local line = State.lines[line_index]
if y + State.line_height > App.screen.height then break end
local height = State.line_height
if should_show(line) then
local xleft = render_stack_left_margin(State, line_index, line, y)
local xright = render_stack_right_margin(State, line_index, line, y)
if line.section_name then
App.color(Section_border_color)
local section_text = to_text(line.section_name)
if line.section_begin then
local sectiony = y+Section_border_padding_vertical
love.graphics.line(xleft,sectiony, xleft,y+State.line_height)
love.graphics.line(xright,sectiony, xright,y+State.line_height)
love.graphics.line(xleft,sectiony, xleft+50-2,sectiony)
love.graphics.draw(section_text, xleft+50,y)
love.graphics.line(xleft+50+App.width(section_text)+2,sectiony, xright,sectiony)
else assert(line.section_end)
local sectiony = y+State.line_height-Section_border_padding_vertical
love.graphics.line(xleft,y, xleft,sectiony)
love.graphics.line(xright,y, xright,sectiony)
love.graphics.line(xleft,sectiony, xleft+50-2,sectiony)
love.graphics.draw(section_text, xleft+50,y)
love.graphics.line(xleft+50+App.width(section_text)+2,sectiony, xright,sectiony)
end
else
if type(line.data) == 'string' then
local old_left, old_right = State.left,State.right
State.left,State.right = xleft,xright
y = Text.draw(State, line_index, y, --[[startpos]] 1)
State.left,State.right = old_left,old_right
else
height = log_render[line.data.name](line.data, xleft, y, xright-xleft)
end
end
if App.mouse_x() > Log_browser_state.left and line_index == mouse_line_index then
App.color(Cursor_line_background_color)
love.graphics.rectangle('fill', xleft,y, xright-xleft, height)
end
y = y + height
end
end
end
function render_stack_left_margin(State, line_index, line, y)
if line.section_stack == nil then
-- assertion message
for k,v in pairs(line) do
print(k)
end
end
App.color(Section_border_color)
for i=1,#line.section_stack do
local x = State.left + (i-1)*Section_border_padding_horizontal
love.graphics.line(x,y, x,y+log_browser.height(State, line_index))
if y < 30 then
love.graphics.print(line.section_stack[i].name, x+State.font_height+5, y+5, --[[vertically]] math.pi/2)
end
if y > App.screen.height-log_browser.height(State, line_index) then
love.graphics.print(line.section_stack[i].name, x+State.font_height+5, App.screen.height-App.width(to_text(line.section_stack[i].name))-5, --[[vertically]] math.pi/2)
end
end
return log_browser.left_margin(State, line)
end
function render_stack_right_margin(State, line_index, line, y)
App.color(Section_border_color)
for i=1,#line.section_stack do
local x = State.right - (i-1)*Section_border_padding_horizontal
love.graphics.line(x,y, x,y+log_browser.height(State, line_index))
if y < 30 then
love.graphics.print(line.section_stack[i].name, x, y+5, --[[vertically]] math.pi/2)
end
if y > App.screen.height-log_browser.height(State, line_index) then
love.graphics.print(line.section_stack[i].name, x, App.screen.height-App.width(to_text(line.section_stack[i].name))-5, --[[vertically]] math.pi/2)
end
end
return log_browser.right_margin(State, line)
end
function should_show(line)
-- Show a line if every single section it's in is expanded.
for i=1,#line.section_stack do
local section = line.section_stack[i]
if not section.expanded then
return false
end
end
return true
end
function log_browser.left_margin(State, line)
return State.left + #line.section_stack*Section_border_padding_horizontal
end
function log_browser.right_margin(State, line)
return State.right - #line.section_stack*Section_border_padding_horizontal
end
function log_browser.update(State, dt)
end
function log_browser.quit(State)
end
function log_browser.mouse_pressed(State, x,y, mouse_button)
local line_index = log_browser.line_index(State, x,y)
if line_index == nil then
-- below lower margin
return
end
-- leave some space to click without focusing
local line = State.lines[line_index]
local xleft = log_browser.left_margin(State, line)
local xright = log_browser.right_margin(State, line)
if x < xleft or x > xright then
return
end
-- if it's a section begin/end and the section is collapsed, expand it
-- TODO: how to collapse?
if line.section_begin or line.section_end then
-- HACK: get section reference from next/previous line
local new_section
if line.section_begin then
if line_index < #State.lines then
local next_section_stack = State.lines[line_index+1].section_stack
if next_section_stack then
new_section = next_section_stack[#next_section_stack]
end
end
elseif line.section_end then
if line_index > 1 then
local previous_section_stack = State.lines[line_index-1].section_stack
if previous_section_stack then
new_section = previous_section_stack[#previous_section_stack]
end
end
end
if new_section and new_section.expanded == nil then
new_section.expanded = true
return
end
end
-- open appropriate file in source side
if line.filename ~= Editor_state.filename then
source.switch_to_file(line.filename)
end
-- set cursor
Editor_state.cursor1 = {line=line.line_number, pos=1, posB=nil}
-- make sure it's visible
-- TODO: handle extremely long lines
Editor_state.screen_top1.line = math.max(0, Editor_state.cursor1.line-5)
-- show cursor
Focus = 'edit'
-- expand B side
Editor_state.expanded = true
end
function log_browser.line_index(State, mx,my)
-- duplicate some logic from log_browser.draw
local y = State.top
for line_index = State.screen_top1.line,#State.lines do
local line = State.lines[line_index]
if should_show(line) then
y = y + log_browser.height(State, line_index)
if my < y then
return line_index
end
if y > App.screen.height then break end
end
end
end
function log_browser.mouse_released(State, x,y, mouse_button)
end
function log_browser.textinput(State, t)
end
function log_browser.keychord_pressed(State, chord, key)
-- move
if chord == 'up' then
while State.screen_top1.line > 1 do
State.screen_top1.line = State.screen_top1.line-1
if should_show(State.lines[State.screen_top1.line]) then
break
end
end
elseif chord == 'down' then
while State.screen_top1.line < #State.lines do
State.screen_top1.line = State.screen_top1.line+1
if should_show(State.lines[State.screen_top1.line]) then
break
end
end
elseif chord == 'pageup' then
local y = 0
while State.screen_top1.line > 1 and y < App.screen.height - 100 do
State.screen_top1.line = State.screen_top1.line - 1
if should_show(State.lines[State.screen_top1.line]) then
y = y + log_browser.height(State, State.screen_top1.line)
end
end
elseif chord == 'pagedown' then
local y = 0
while State.screen_top1.line < #State.lines and y < App.screen.height - 100 do
if should_show(State.lines[State.screen_top1.line]) then
y = y + log_browser.height(State, State.screen_top1.line)
end
State.screen_top1.line = State.screen_top1.line + 1
end
end
end
function log_browser.height(State, line_index)
local line = State.lines[line_index]
if line.data == nil then
-- section header
return State.line_height
elseif type(line.data) == 'string' then
return State.line_height
else
if line.height == nil then
--? print('nil line height! rendering off screen to calculate')
line.height = log_render[line.data.name](line.data, State.left, App.screen.height, State.right-State.left)
end
return line.height
end
end
function log_browser.keyreleased(State, key, scancode)
end

1782
main.lua

File diff suppressed because it is too large Load Diff

1620
run.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@ -37,8 +37,7 @@ function Text.search_next(State)
for i=State.cursor1.line+1,#State.lines do
pos = find(State.lines[i].data, State.search_term, nil, --[[no escapes]] true)
if pos then
State.cursor1.line = i
State.cursor1.pos = pos
State.cursor1 = {line=i, pos=pos}
break
end
end
@ -48,8 +47,7 @@ function Text.search_next(State)
for i=1,State.cursor1.line-1 do
pos = find(State.lines[i].data, State.search_term, nil, --[[no escapes]] true)
if pos then
State.cursor1.line = i
State.cursor1.pos = pos
State.cursor1 = {line=i, pos=pos}
break
end
end
@ -87,8 +85,7 @@ function Text.search_previous(State)
for i=State.cursor1.line-1,1,-1 do
pos = rfind(State.lines[i].data, State.search_term, nil, --[[no escapes]] true)
if pos then
State.cursor1.line = i
State.cursor1.pos = pos
State.cursor1 = {line=i, pos=pos}
break
end
end
@ -98,8 +95,7 @@ function Text.search_previous(State)
for i=#State.lines,State.cursor1.line+1,-1 do
pos = rfind(State.lines[i].data, State.search_term, nil, --[[no escapes]] true)
if pos then
State.cursor1.line = i
State.cursor1.pos = pos
State.cursor1 = {line=i, pos=pos}
break
end
end

360
source.lua Normal file
View File

@ -0,0 +1,360 @@
source = {}
Editor_state = {}
-- called both in tests and real run
function source.initialize_globals()
-- tests currently mostly clear their own state
Show_log_browser_side = false
Focus = 'edit'
Show_file_navigator = false
File_navigation = {
candidates = {
'run',
'run_tests',
'log',
'edit',
'text',
'search',
'select',
'undo',
'text_tests',
'file',
'source',
'source_tests',
'commands',
'log_browser',
'source_edit',
'source_text',
'source_undo',
'colorize',
'source_text_tests',
'source_file',
'main',
'button',
'keychord',
'app',
'test',
'json',
},
index = 1,
}
Menu_status_bar_height = nil -- initialized below
-- a few text objects we can avoid recomputing unless the font changes
Text_cache = {}
-- blinking cursor
Cursor_time = 0
end
-- called only for real run
function source.initialize()
love.keyboard.setTextInput(true) -- bring up keyboard on touch screen
love.keyboard.setKeyRepeat(true)
love.graphics.setBackgroundColor(1,1,1)
if Settings and Settings.source then
source.load_settings()
else
source.initialize_default_settings()
end
source.initialize_edit_side{'run.lua'}
source.initialize_log_browser_side()
Menu_status_bar_height = 5 + Editor_state.line_height + 5
Editor_state.top = Editor_state.top + Menu_status_bar_height
Log_browser_state.top = Log_browser_state.top + Menu_status_bar_height
love.window.setTitle('pensieve.love - source')
end
-- environment for a mutable file of bifolded text
-- TODO: some initialization is also happening in load_settings/initialize_default_settings. Clean that up.
function source.initialize_edit_side(arg)
if #arg > 0 then
Editor_state.filename = arg[1]
load_from_disk(Editor_state)
Text.redraw_all(Editor_state)
Editor_state.screen_top1 = {line=1, pos=1}
Editor_state.cursor1 = {line=1, pos=1}
else
load_from_disk(Editor_state)
Text.redraw_all(Editor_state)
end
if #arg > 1 then
print('ignoring commandline args after '..arg[1])
end
-- We currently start out with side B collapsed.
-- Other options:
-- * save all expanded state by line
-- * expand all if any location is in side B
if Editor_state.cursor1.line > #Editor_state.lines then
Editor_state.cursor1 = {line=1, pos=1}
end
if Editor_state.screen_top1.line > #Editor_state.lines then
Editor_state.screen_top1 = {line=1, pos=1}
end
edit.eradicate_locations_after_the_fold(Editor_state)
if rawget(_G, 'jit') then
jit.off()
jit.flush()
end
end
function source.load_settings()
local settings = Settings.source
love.graphics.setFont(love.graphics.newFont(settings.font_height))
-- maximize window to determine maximum allowable dimensions
love.window.setMode(0, 0) -- maximize
Display_width, Display_height, App.screen.flags = love.window.getMode()
-- set up desired window dimensions
App.screen.flags.resizable = true
App.screen.flags.minwidth = math.min(Display_width, 200)
App.screen.flags.minheight = math.min(Display_height, 200)
App.screen.width, App.screen.height = settings.width, settings.height
--? print('setting window from settings:', App.screen.width, App.screen.height)
love.window.setMode(App.screen.width, App.screen.height, App.screen.flags)
--? print('loading source position', settings.x, settings.y, settings.displayindex)
source.set_window_position_from_settings(settings)
Show_log_browser_side = settings.show_log_browser_side
local right = App.screen.width - Margin_right
if Show_log_browser_side then
right = App.screen.width/2 - Margin_right
end
Editor_state = edit.initialize_state(Margin_top, Margin_left, right, settings.font_height, math.floor(settings.font_height*1.3))
Editor_state.filename = settings.filename
Editor_state.screen_top1 = settings.screen_top
Editor_state.cursor1 = settings.cursor
end
function source.set_window_position_from_settings(settings)
-- setPosition doesn't quite seem to do what is asked of it on Linux.
love.window.setPosition(settings.x, settings.y-37, settings.displayindex)
end
function source.initialize_default_settings()
local font_height = 20
love.graphics.setFont(love.graphics.newFont(font_height))
local em = App.newText(love.graphics.getFont(), 'm')
source.initialize_window_geometry(App.width(em))
Editor_state = edit.initialize_state(Margin_top, Margin_left, App.screen.width-Margin_right)
Editor_state.font_height = font_height
Editor_state.line_height = math.floor(font_height*1.3)
Editor_state.em = em
end
function source.initialize_window_geometry(em_width)
-- maximize window
love.window.setMode(0, 0) -- maximize
Display_width, Display_height, App.screen.flags = love.window.getMode()
-- shrink height slightly to account for window decoration
App.screen.height = Display_height-100
App.screen.width = 40*em_width
App.screen.flags.resizable = true
App.screen.flags.minwidth = math.min(App.screen.width, 200)
App.screen.flags.minheight = math.min(App.screen.width, 200)
love.window.setMode(App.screen.width, App.screen.height, App.screen.flags)
print('initializing source position')
if Settings == nil then Settings = {} end
if Settings.source == nil then Settings.source = {} end
Settings.source.x, Settings.source.y, Settings.source.displayindex = love.window.getPosition()
end
function source.resize(w, h)
--? print(("Window resized to width: %d and height: %d."):format(w, h))
App.screen.width, App.screen.height = w, h
Text.redraw_all(Editor_state)
Editor_state.selection1 = {} -- no support for shift drag while we're resizing
if Show_log_browser_side then
Editor_state.right = App.screen.width/2 - Margin_right
else
Editor_state.right = App.screen.width-Margin_right
end
Log_browser_state.left = App.screen.width/2 + Margin_right
Log_browser_state.right = App.screen.width-Margin_right
Editor_state.width = Editor_state.right-Editor_state.left
Text.tweak_screen_top_and_cursor(Editor_state, Editor_state.left, Editor_state.right)
--? print('end resize')
end
function source.filedropped(file)
-- first make sure to save edits on any existing file
if Editor_state.next_save then
save_to_disk(Editor_state)
end
-- clear the slate for the new file
Editor_state.filename = file:getFilename()
file:open('r')
Editor_state.lines = load_from_file(file)
file:close()
Text.redraw_all(Editor_state)
Editor_state.screen_top1 = {line=1, pos=1}
Editor_state.cursor1 = {line=1, pos=1}
love.window.setTitle('pensieve.love - source')
end
-- a copy of source.filedropped when given a filename
function source.switch_to_file(filename)
-- first make sure to save edits on any existing file
if Editor_state.next_save then
save_to_disk(Editor_state)
end
-- clear the slate for the new file
Editor_state.filename = filename
load_from_disk(Editor_state)
Text.redraw_all(Editor_state)
Editor_state.screen_top1 = {line=1, pos=1}
Editor_state.cursor1 = {line=1, pos=1}
end
function source.draw()
source.draw_menu_bar()
edit.draw(Editor_state)
if Show_log_browser_side then
-- divider
App.color(Divider_color)
love.graphics.rectangle('fill', App.screen.width/2-1,Menu_status_bar_height, 3,App.screen.height)
--
log_browser.draw(Log_browser_state)
end
end
function source.update(dt)
Cursor_time = Cursor_time + dt
if App.mouse_x() < Editor_state.right then
edit.update(Editor_state, dt)
elseif Show_log_browser_side then
log_browser.update(Log_browser_state, dt)
end
end
function source.quit()
edit.quit(Editor_state)
log_browser.quit(Log_browser_state)
-- convert any bifold files here
end
function source.convert_bifold_text(infilename, outfilename)
local contents = love.filesystem.read(infilename)
contents = contents:gsub('\u{1e}', ';')
love.filesystem.write(outfilename, contents)
end
function source.settings()
if Current_app == 'source' then
--? print('reading source window position')
Settings.source.x, Settings.source.y, Settings.source.displayindex = love.window.getPosition()
end
local filename = Editor_state.filename
if filename:sub(1,1) ~= '/' then
filename = love.filesystem.getWorkingDirectory()..'/'..filename -- '/' should work even on Windows
end
--? print('saving source settings', Settings.source.x, Settings.source.y, Settings.source.displayindex)
return {
x=Settings.source.x, y=Settings.source.y, displayindex=Settings.source.displayindex,
width=App.screen.width, height=App.screen.height,
font_height=Editor_state.font_height,
filename=filename,
screen_top=Editor_state.screen_top1, cursor=Editor_state.cursor1,
show_log_browser_side=Show_log_browser_side,
focus=Focus,
}
end
function source.mouse_pressed(x,y, mouse_button)
Cursor_time = 0 -- ensure cursor is visible immediately after it moves
--? print('mouse click', x, y)
--? print(Editor_state.left, Editor_state.right)
--? print(Log_browser_state.left, Log_browser_state.right)
if Editor_state.left <= x and x < Editor_state.right then
--? print('click on edit side')
if Focus ~= 'edit' then
Focus = 'edit'
end
edit.mouse_pressed(Editor_state, x,y, mouse_button)
elseif Show_log_browser_side and Log_browser_state.left <= x and x < Log_browser_state.right then
--? print('click on log_browser side')
if Focus ~= 'log_browser' then
Focus = 'log_browser'
end
log_browser.mouse_pressed(Log_browser_state, x,y, mouse_button)
for _,line_cache in ipairs(Editor_state.line_cache) do line_cache.starty = nil end -- just in case we scroll
end
end
function source.mouse_released(x,y, mouse_button)
Cursor_time = 0 -- ensure cursor is visible immediately after it moves
if Focus == 'edit' then
return edit.mouse_released(Editor_state, x,y, mouse_button)
else
return log_browser.mouse_released(Log_browser_state, x,y, mouse_button)
end
end
function source.textinput(t)
Cursor_time = 0 -- ensure cursor is visible immediately after it moves
if Focus == 'edit' then
return edit.textinput(Editor_state, t)
else
return log_browser.textinput(Log_browser_state, t)
end
end
function source.keychord_pressed(chord, key)
Cursor_time = 0 -- ensure cursor is visible immediately after it moves
--? print('source keychord')
if Show_file_navigator then
keychord_pressed_on_file_navigator(chord, key)
return
end
if chord == 'C-l' then
--? print('C-l')
Show_log_browser_side = not Show_log_browser_side
if Show_log_browser_side then
App.screen.width = Log_browser_state.right + Margin_right
else
App.screen.width = Editor_state.right + Margin_right
end
--? print('setting window:', App.screen.width, App.screen.height)
love.window.setMode(App.screen.width, App.screen.height, App.screen.flags)
--? print('done setting window')
-- try to restore position if possible
-- if the window gets wider the window manager may not respect this
source.set_window_position_from_settings(Settings.source)
return
end
if chord == 'C-g' then
Show_file_navigator = true
File_navigation.index = 1
return
end
if Focus == 'edit' then
return edit.keychord_pressed(Editor_state, chord, key)
else
return log_browser.keychord_pressed(Log_browser_state, chord, key)
end
end
function source.key_released(key, scancode)
Cursor_time = 0 -- ensure cursor is visible immediately after it moves
if Focus == 'edit' then
return edit.key_released(Editor_state, key, scancode)
else
return log_browser.keychord_pressed(Log_browser_state, chordkey, scancode)
end
end
-- use this sparingly
function to_text(s)
if Text_cache[s] == nil then
Text_cache[s] = App.newText(love.graphics.getFont(), s)
end
return Text_cache[s]
end

100
source_commands.lua Normal file
View File

@ -0,0 +1,100 @@
Menu_background_color = {r=0.6, g=0.8, b=0.6}
Menu_border_color = {r=0.6, g=0.7, b=0.6}
Menu_command_color = {r=0.2, g=0.2, b=0.2}
Menu_highlight_color = {r=0.5, g=0.7, b=0.3}
function source.draw_menu_bar()
if App.run_tests then return end -- disable in tests
App.color(Menu_background_color)
love.graphics.rectangle('fill', 0,0, App.screen.width, Menu_status_bar_height)
App.color(Menu_border_color)
love.graphics.rectangle('line', 0,0, App.screen.width, Menu_status_bar_height)
App.color(Menu_command_color)
Menu_cursor = 5
if Show_file_navigator then
source.draw_file_navigator()
return
end
add_hotkey_to_menu('ctrl+u: run')
if Focus == 'edit' then
add_hotkey_to_menu('ctrl+g: switch file')
if Show_log_browser_side then
add_hotkey_to_menu('ctrl+l: hide log browser')
else
add_hotkey_to_menu('ctrl+l: show log browser')
end
if Editor_state.expanded then
add_hotkey_to_menu('ctrl+b: collapse debug prints')
else
add_hotkey_to_menu('ctrl+b: expand debug prints')
end
add_hotkey_to_menu('ctrl+d: create/edit debug print')
add_hotkey_to_menu('ctrl+f: find in file')
add_hotkey_to_menu('alt+left alt+right: prev/next word')
elseif Focus == 'log_browser' then
-- nothing yet
else
assert(false, 'unknown focus "'..Focus..'"')
end
add_hotkey_to_menu('ctrl+z ctrl+y: undo/redo')
add_hotkey_to_menu('ctrl+x ctrl+c ctrl+v: cut/copy/paste')
add_hotkey_to_menu('ctrl+= ctrl+- ctrl+0: zoom')
end
function add_hotkey_to_menu(s)
if Text_cache[s] == nil then
Text_cache[s] = App.newText(love.graphics.getFont(), s)
end
local width = App.width(Text_cache[s])
if Menu_cursor + width > App.screen.width - 5 then
return
end
App.color(Menu_command_color)
App.screen.draw(Text_cache[s], Menu_cursor,5)
Menu_cursor = Menu_cursor + width + 30
end
function source.draw_file_navigator()
for i,file in ipairs(File_navigation.candidates) do
if file == 'source' then
App.color(Menu_border_color)
love.graphics.line(Menu_cursor-10,2, Menu_cursor-10,Menu_status_bar_height-2)
end
add_file_to_menu(file, i == File_navigation.index)
end
end
function add_file_to_menu(s, cursor_highlight)
if Text_cache[s] == nil then
Text_cache[s] = App.newText(love.graphics.getFont(), s)
end
local width = App.width(Text_cache[s])
if Menu_cursor + width > App.screen.width - 5 then
return
end
if cursor_highlight then
App.color(Menu_highlight_color)
love.graphics.rectangle('fill', Menu_cursor-5,5-2, App.width(Text_cache[s])+5*2,Editor_state.line_height+2*2)
end
App.color(Menu_command_color)
App.screen.draw(Text_cache[s], Menu_cursor,5)
Menu_cursor = Menu_cursor + width + 30
end
function keychord_pressed_on_file_navigator(chord, key)
if chord == 'escape' then
Show_file_navigator = false
elseif chord == 'return' then
local candidate = guess_source(File_navigation.candidates[File_navigation.index]..'.lua')
source.switch_to_file(candidate)
Show_file_navigator = false
elseif chord == 'left' then
if File_navigation.index > 1 then
File_navigation.index = File_navigation.index-1
end
elseif chord == 'right' then
if File_navigation.index < #File_navigation.candidates then
File_navigation.index = File_navigation.index+1
end
end
end

377
source_edit.lua Normal file
View File

@ -0,0 +1,377 @@
-- some constants people might like to tweak
Text_color = {r=0, g=0, b=0}
Cursor_color = {r=1, g=0, b=0}
Focus_stroke_color = {r=1, g=0, b=0} -- what mouse is hovering over
Highlight_color = {r=0.7, g=0.7, b=0.9} -- selected text
Fold_color = {r=0, g=0.6, b=0}
Fold_background_color = {r=0, g=0.7, b=0}
Margin_top = 15
Margin_left = 25
Margin_right = 25
edit = {}
-- run in both tests and a real run
function edit.initialize_state(top, left, right, font_height, line_height) -- currently always draws to bottom of screen
local result = {
-- a line of bifold text consists of an A side and an optional B side, each of which is a string
-- expanded: whether to show B side
lines = {{data='', dataB=nil, expanded=nil}}, -- array of lines
-- Lines can be too long to fit on screen, in which case they _wrap_ into
-- multiple _screen lines_.
-- rendering wrapped text lines needs some additional short-lived data per line:
-- startpos, the index of data the line starts rendering from, can only be >1 for topmost line on screen
-- starty, the y coord in pixels the line starts rendering from
-- fragments: snippets of rendered love.graphics.Text, guaranteed to not straddle screen lines
-- screen_line_starting_pos: optional array of grapheme indices if it wraps over more than one screen line
line_cache = {},
-- Given wrapping, any potential location for the text 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.
-- Positions (and screen line indexes) can be in either the A or the B side.
--
-- 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, posB=nil}, -- position of start of screen line at top of screen
cursor1 = {line=1, pos=1, posB=nil}, -- position of cursor
screen_bottom1 = {line=1, pos=1, posB=nil}, -- position of start of screen line at bottom of screen
-- cursor coordinates in pixels
cursor_x = 0,
cursor_y = 0,
font_height = font_height,
line_height = line_height,
em = App.newText(love.graphics.getFont(), 'm'), -- widest possible character width
top = top,
left = left,
right = right,
width = right-left,
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 result
end -- App.initialize_state
function edit.draw(State)
State.button_handlers = {}
App.color(Text_color)
assert(#State.lines == #State.line_cache)
if not Text.le1(State.screen_top1, State.cursor1) then
print(State.screen_top1.line, State.screen_top1.pos, State.screen_top1.posB, State.cursor1.line, State.cursor1.pos, State.cursor1.posB)
assert(false)
end
State.cursor_x = nil
State.cursor_y = nil
local y = State.top
--? print('== draw')
for line_index = State.screen_top1.line,#State.lines do
local line = State.lines[line_index]
--? print('draw:', y, line_index, line)
if y + State.line_height > App.screen.height then break end
State.screen_bottom1 = {line=line_index, pos=nil, posB=nil}
--? print('text.draw', y, line_index)
local startpos, startposB = 1, nil
if line_index == State.screen_top1.line then
if State.screen_top1.pos then
startpos = State.screen_top1.pos
else
startpos, startposB = nil, State.screen_top1.posB
end
end
y, State.screen_bottom1.pos, State.screen_bottom1.posB = Text.draw(State, line_index, y, startpos, startposB)
y = y + State.line_height
--? print('=> y', y)
end
if State.search_term then
Text.draw_search_bar(State)
end
end
function edit.update(State, dt)
if State.next_save and State.next_save < App.getTime() then
save_to_disk(State)
State.next_save = nil
end
end
function schedule_save(State)
if State.next_save == nil then
State.next_save = App.getTime() + 3 -- short enough that you're likely to still remember what you did
end
end
function edit.quit(State)
-- make sure to save before quitting
if State.next_save then
save_to_disk(State)
end
end
function edit.mouse_pressed(State, x,y, mouse_button)
if State.search_term then return end
--? print('press', State.selection1.line, State.selection1.pos)
if mouse_press_consumed_by_any_button_handler(State, x,y, mouse_button) then
-- press on a button and it returned 'true' to short-circuit
return
end
for line_index,line in ipairs(State.lines) do
if Text.in_line(State, line_index, x,y) then
local pos,posB = Text.to_pos_on_line(State, line_index, x, y)
--? print(x,y, 'setting cursor:', line_index, pos, posB)
State.cursor1 = {line=line_index, pos=pos, posB=posB}
break
end
end
end
function edit.mouse_released(State, x,y, mouse_button)
end
function edit.textinput(State, t)
for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end -- just in case we scroll
if State.search_term then
State.search_term = State.search_term..t
State.search_text = nil
Text.search_next(State)
else
Text.textinput(State, t)
end
schedule_save(State)
end
function edit.keychord_pressed(State, chord, key)
if State.search_term then
if chord == 'escape' then
State.search_term = nil
State.search_text = nil
State.cursor1 = State.search_backup.cursor
State.screen_top1 = State.search_backup.screen_top
State.search_backup = nil
Text.redraw_all(State) -- if we're scrolling, reclaim all fragments to avoid memory leaks
elseif chord == 'return' then
State.search_term = nil
State.search_text = nil
State.search_backup = nil
elseif chord == 'backspace' then
local len = utf8.len(State.search_term)
local byte_offset = Text.offset(State.search_term, len)
State.search_term = string.sub(State.search_term, 1, byte_offset-1)
State.search_text = nil
elseif chord == 'down' then
if State.cursor1.pos then
State.cursor1.pos = State.cursor1.pos+1
else
State.cursor1.posB = State.cursor1.posB+1
end
Text.search_next(State)
elseif chord == 'up' then
Text.search_previous(State)
end
return
elseif chord == 'C-f' then
State.search_term = ''
State.search_backup = {
cursor={line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB},
screen_top={line=State.screen_top1.line, pos=State.screen_top1.pos, posB=State.screen_top1.posB},
}
assert(State.search_text == nil)
-- bifold text
elseif chord == 'C-b' then
State.expanded = not State.expanded
Text.redraw_all(State)
if not State.expanded then
for _,line in ipairs(State.lines) do
line.expanded = nil
end
edit.eradicate_locations_after_the_fold(State)
end
elseif chord == 'C-d' then
if State.cursor1.posB == nil then
local before = snapshot(State, State.cursor1.line)
if State.lines[State.cursor1.line].dataB == nil then
State.lines[State.cursor1.line].dataB = ''
end
State.lines[State.cursor1.line].expanded = true
State.cursor1.pos = nil
State.cursor1.posB = 1
if Text.cursor_out_of_screen(State) then
Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
end
schedule_save(State)
record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
end
-- zoom
elseif chord == 'C-=' then
edit.update_font_settings(State, State.font_height+2)
Text.redraw_all(State)
elseif chord == 'C--' then
edit.update_font_settings(State, State.font_height-2)
Text.redraw_all(State)
elseif chord == 'C-0' then
edit.update_font_settings(State, 20)
Text.redraw_all(State)
-- undo
elseif chord == 'C-z' then
for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end -- just in case we scroll
local event = undo_event(State)
if event then
local src = event.before
State.screen_top1 = deepcopy(src.screen_top)
State.cursor1 = deepcopy(src.cursor)
patch(State.lines, event.after, event.before)
patch_placeholders(State.line_cache, event.after, event.before)
-- if we're scrolling, reclaim all fragments to avoid memory leaks
Text.redraw_all(State)
schedule_save(State)
end
elseif chord == 'C-y' then
for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end -- just in case we scroll
local event = redo_event(State)
if event then
local src = event.after
State.screen_top1 = deepcopy(src.screen_top)
State.cursor1 = deepcopy(src.cursor)
patch(State.lines, event.before, event.after)
-- if we're scrolling, reclaim all fragments to avoid memory leaks
Text.redraw_all(State)
schedule_save(State)
end
-- clipboard
elseif chord == 'C-c' then
local s = Text.selection(State)
if s then
App.setClipboardText(s)
end
elseif chord == 'C-x' then
for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end -- just in case we scroll
local s = Text.cut_selection(State, State.left, State.right)
if s then
App.setClipboardText(s)
end
schedule_save(State)
elseif chord == 'C-v' then
for _,line_cache in ipairs(State.line_cache) do line_cache.starty = 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 = State.cursor1.line
local before = snapshot(State, 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(State)
else
Text.insert_at_cursor(State, c)
end
end
if Text.cursor_out_of_screen(State) then
Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
end
schedule_save(State)
record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)})
-- dispatch to text
else
for _,line_cache in ipairs(State.line_cache) do line_cache.starty = nil end -- just in case we scroll
Text.keychord_pressed(State, chord)
end
end
function edit.eradicate_locations_after_the_fold(State)
-- eradicate side B from any locations we track
if State.cursor1.posB then
State.cursor1.posB = nil
State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data)
State.cursor1.pos = Text.pos_at_start_of_screen_line(State, State.cursor1)
end
if State.screen_top1.posB then
State.screen_top1.posB = nil
State.screen_top1.pos = utf8.len(State.lines[State.screen_top1.line].data)
State.screen_top1.pos = Text.pos_at_start_of_screen_line(State, State.screen_top1)
end
end
function edit.key_released(State, key, scancode)
end
function edit.update_font_settings(State, font_height)
State.font_height = font_height
love.graphics.setFont(love.graphics.newFont(Editor_state.font_height))
State.line_height = math.floor(font_height*1.3)
State.em = App.newText(love.graphics.getFont(), 'm')
Text_cache = {}
end
--== some methods for tests
Test_margin_left = 25
function edit.initialize_test_state()
-- if you change these values, tests will start failing
return edit.initialize_state(
15, -- top margin
Test_margin_left,
App.screen.width, -- right margin = 0
14, -- font height assuming default LÖVE font
15) -- line height
end
-- all textinput events are also keypresses
-- TODO: handle chords of multiple keys
function edit.run_after_textinput(State, t)
edit.keychord_pressed(State, t)
edit.textinput(State, t)
edit.key_released(State, t)
App.screen.contents = {}
edit.draw(State)
end
-- not all keys are textinput
function edit.run_after_keychord(State, chord)
edit.keychord_pressed(State, chord)
edit.key_released(State, chord)
App.screen.contents = {}
edit.draw(State)
end
function edit.run_after_mouse_click(State, x,y, mouse_button)
App.fake_mouse_press(x,y, mouse_button)
edit.mouse_pressed(State, x,y, mouse_button)
App.fake_mouse_release(x,y, mouse_button)
edit.mouse_released(State, x,y, mouse_button)
App.screen.contents = {}
edit.draw(State)
end
function edit.run_after_mouse_press(State, x,y, mouse_button)
App.fake_mouse_press(x,y, mouse_button)
edit.mouse_pressed(State, x,y, mouse_button)
App.screen.contents = {}
edit.draw(State)
end
function edit.run_after_mouse_release(State, x,y, mouse_button)
App.fake_mouse_release(x,y, mouse_button)
edit.mouse_released(State, x,y, mouse_button)
App.screen.contents = {}
edit.draw(State)
end

79
source_file.lua Normal file
View File

@ -0,0 +1,79 @@
-- primitives for saving to file and loading from file
Fold = '\x1e' -- ASCII RS (record separator)
function file_exists(filename)
local infile = App.open_for_reading(filename)
if infile then
infile:close()
return true
else
return false
end
end
function load_from_disk(State)
local infile = App.open_for_reading(State.filename)
State.lines = load_from_file(infile)
if infile then infile:close() end
end
function load_from_file(infile)
local result = {}
if infile then
local infile_next_line = infile:lines() -- works with both Lua files and LÖVE Files (https://www.love2d.org/wiki/File)
while true do
local line = infile_next_line()
if line == nil then break end
local line_info = {}
if line:find(Fold) then
_, _, line_info.data, line_info.dataB = line:find('([^'..Fold..']*)'..Fold..'([^'..Fold..']*)')
else
line_info.data = line
end
table.insert(result, line_info)
end
end
if #result == 0 then
table.insert(result, {data=''})
end
return result
end
function save_to_disk(State)
local outfile = App.open_for_writing(State.filename)
if outfile == nil then
error('failed to write to "'..State.filename..'"')
end
for _,line in ipairs(State.lines) do
outfile:write(line.data)
if line.dataB and #line.dataB > 0 then
outfile:write(Fold)
outfile:write(line.dataB)
end
outfile:write('\n')
end
outfile:close()
end
-- for tests
function load_array(a)
local result = {}
local next_line = ipairs(a)
local i,line,drawing = 0, ''
while true do
i,line = next_line(a, i)
if i == nil then break end
local line_info = {}
if line:find(Fold) then
_, _, line_info.data, line_info.dataB = line:find('([^'..Fold..']*)'..Fold..'([^'..Fold..']*)')
else
line_info.data = line
end
table.insert(result, line_info)
end
if #result == 0 then
table.insert(result, {data=''})
end
return result
end

77
source_tests.lua Normal file
View File

@ -0,0 +1,77 @@
function test_resize_window()
io.write('\ntest_resize_window')
App.screen.init{width=300, height=300}
Editor_state = edit.initialize_test_state()
Editor_state.filename = 'foo'
Log_browser_state = edit.initialize_test_state()
check_eq(App.screen.width, 300, 'F - test_resize_window/baseline/width')
check_eq(App.screen.height, 300, 'F - test_resize_window/baseline/height')
check_eq(Editor_state.left, Test_margin_left, 'F - test_resize_window/baseline/left_margin')
App.resize(200, 400)
check_eq(App.screen.width, 200, 'F - test_resize_window/width')
check_eq(App.screen.height, 400, 'F - test_resize_window/height')
check_eq(Editor_state.left, Test_margin_left, 'F - test_resize_window/left_margin')
-- ugly; right margin switches from 0 after resize
check_eq(Editor_state.right, 200-Margin_right, 'F - test_resize_window/right_margin')
check_eq(Editor_state.width, 200-Test_margin_left-Margin_right, 'F - test_resize_window/drawing_width')
-- TODO: how to make assertions about when App.update got past the early exit?
end
function test_drop_file()
io.write('\ntest_drop_file')
App.screen.init{width=Editor_state.left+300, height=300}
Editor_state = edit.initialize_test_state()
App.filesystem['foo'] = 'abc\ndef\nghi\n'
local fake_dropped_file = {
opened = false,
getFilename = function(self)
return 'foo'
end,
open = function(self)
self.opened = true
end,
lines = function(self)
assert(self.opened)
return App.filesystem['foo']:gmatch('[^\n]+')
end,
close = function(self)
self.opened = false
end,
}
App.filedropped(fake_dropped_file)
check_eq(#Editor_state.lines, 3, 'F - test_drop_file/#lines')
check_eq(Editor_state.lines[1].data, 'abc', 'F - test_drop_file/lines:1')
check_eq(Editor_state.lines[2].data, 'def', 'F - test_drop_file/lines:2')
check_eq(Editor_state.lines[3].data, 'ghi', 'F - test_drop_file/lines:3')
edit.draw(Editor_state)
end
function test_drop_file_saves_previous()
io.write('\ntest_drop_file_saves_previous')
App.screen.init{width=Editor_state.left+300, height=300}
-- initially editing a file called foo that hasn't been saved to filesystem yet
Editor_state.lines = load_array{'abc', 'def'}
Editor_state.filename = 'foo'
schedule_save(Editor_state)
-- now drag a new file bar from the filesystem
App.filesystem['bar'] = 'abc\ndef\nghi\n'
local fake_dropped_file = {
opened = false,
getFilename = function(self)
return 'bar'
end,
open = function(self)
self.opened = true
end,
lines = function(self)
assert(self.opened)
return App.filesystem['bar']:gmatch('[^\n]+')
end,
close = function(self)
self.opened = false
end,
}
App.filedropped(fake_dropped_file)
-- filesystem now contains a file called foo
check_eq(App.filesystem['foo'], 'abc\ndef\n', 'F - test_drop_file_saves_previous')
end

1561
source_text.lua Normal file

File diff suppressed because it is too large Load Diff

1609
source_text_tests.lua Normal file

File diff suppressed because it is too large Load Diff

110
source_undo.lua Normal file
View File

@ -0,0 +1,110 @@
-- undo/redo by managing the sequence of events in the current session
-- based on https://github.com/akkartik/mu1/blob/master/edit/012-editor-undo.mu
-- Incredibly inefficient; we make a copy of lines on every single keystroke.
-- The hope here is that we're either editing small files or just reading large files.
-- TODO: highlight stuff inserted by any undo/redo operation
-- TODO: coalesce multiple similar operations
function record_undo_event(State, data)
State.history[State.next_history] = data
State.next_history = State.next_history+1
for i=State.next_history,#State.history do
State.history[i] = nil
end
end
function undo_event(State)
if State.next_history > 1 then
--? print('moving to history', State.next_history-1)
State.next_history = State.next_history-1
local result = State.history[State.next_history]
return result
end
end
function redo_event(State)
if State.next_history <= #State.history then
--? print('restoring history', State.next_history+1)
local result = State.history[State.next_history]
State.next_history = State.next_history+1
return result
end
end
-- Copy all relevant global state.
-- Make copies of objects; the rest of the app may mutate them in place, but undo requires immutable histories.
function snapshot(State, s,e)
-- Snapshot everything by default, but subset if requested.
assert(s)
if e == nil then
e = s
end
assert(#State.lines > 0)
if s < 1 then s = 1 end
if s > #State.lines then s = #State.lines end
if e < 1 then e = 1 end
if e > #State.lines then e = #State.lines end
-- compare with App.initialize_globals
local event = {
screen_top=deepcopy(State.screen_top1),
selection=deepcopy(State.selection1),
cursor=deepcopy(State.cursor1),
lines={},
start_line=s,
end_line=e,
-- no filename; undo history is cleared when filename changes
}
-- deep copy lines without cached stuff like text fragments
for i=s,e do
local line = State.lines[i]
table.insert(event.lines, {data=line.data, dataB=line.dataB})
end
return event
end
function patch(lines, from, to)
--? if #from.lines == 1 and #to.lines == 1 then
--? assert(from.start_line == from.end_line)
--? assert(to.start_line == to.end_line)
--? assert(from.start_line == to.start_line)
--? lines[from.start_line] = to.lines[1]
--? return
--? end
assert(from.start_line == to.start_line)
for i=from.end_line,from.start_line,-1 do
table.remove(lines, i)
end
assert(#to.lines == to.end_line-to.start_line+1)
for i=1,#to.lines do
table.insert(lines, to.start_line+i-1, to.lines[i])
end
end
function patch_placeholders(line_cache, from, to)
assert(from.start_line == to.start_line)
for i=from.end_line,from.start_line,-1 do
table.remove(line_cache, i)
end
assert(#to.lines == to.end_line-to.start_line+1)
for i=1,#to.lines do
table.insert(line_cache, to.start_line+i-1, {})
end
end
-- https://stackoverflow.com/questions/640642/how-do-you-copy-a-lua-table-by-value/26367080#26367080
function deepcopy(obj, seen)
if type(obj) ~= 'table' then return obj end
if seen and seen[obj] then return seen[obj] end
local s = seen or {}
local result = setmetatable({}, getmetatable(obj))
s[obj] = result
for k,v in pairs(obj) do
result[deepcopy(k, s)] = deepcopy(v, s)
end
return result
end
function minmax(a, b)
return math.min(a,b), math.max(a,b)
end

View File

@ -1,11 +1,6 @@
-- text editor, particularly text drawing, horizontal wrap, vertical scrolling
Text = {}
require 'search'
require 'select'
require 'undo'
require 'text_tests'
-- draw a line starting from startpos to screen at y between State.left and State.right
-- return the final y, and position of start of final screen line drawn
function Text.draw(State, line_index, y, startpos)