diff --git a/README.md b/README.md
index 7bc3cf34..1ac35946 100644
--- a/README.md
+++ b/README.md
@@ -30,13 +30,13 @@ a project must be selected first.
| Command | Combination |
| :---------------------------------------------------------------- | :-------------------------------------------- |
| Clear terminal | Ctrl+L |
-| Stop project | Ctrl+Shift+S |
+| Stop project | Ctrl+S |
| Quit project (stop and close) | Ctrl+Q |
| Reset application to initial state | Ctrl+Shift+R |
-| Reset project to initial state | Ctrl+Alt+R |
+| Restart project | Ctrl+Alt+R |
| Exit application | Ctrl+Esc |
| Pause project | Ctrl+Pause |
-| Toggle edit/run | F8 |
+| Toggle edit/run | Ctrl+T |
| **Input** | |
| Move cursor horizontally | ⇦/⇨ |
| Move cursor vertically | ⇧/⇩ |
@@ -50,42 +50,52 @@ a project must be selected first.
| Insert newline | Shift+Enter ⏎ |
| Delete current line | Ctrl+Y |
| Duplicate current line | Ctrl+D |
+| Copy | Ctrl+C / Ctrl+Insert |
+| Cut | Ctrl+X / Shift+Delete |
+| Paste | Ctrl+V / Shift+Insert |
+| Select text | Shift+⇦/⇨/⇧/⇩ |
| Evaluate input | Enter ⏎ |
| **Editor** | |
| _same as Input, except for:_ | |
-| Scroll up | PageUp |
-| Scroll down | PageDown |
-| Move selection (if in first/last line) | ⇧/⇩ |
-| Move selection | Ctrl+⇧/⇩ |
+| Move the active line by one line (nav) | ⇧/⇩ |
+| Move the active line by a page (nav) | PageUp/PageDown |
+| Move the cursor through the block (editing) | ⇧/⇩ |
+| Jump block-wise (nav) / accept and jump (editing) | Ctrl+⇧/⇩ |
| Replace selection with input | Enter ⏎ |
| _additionally_ | |
+| Open selected block for editing (nav, empty input) | Enter ⏎ |
| Insert input contents before selection | Ctrl+Enter ⏎ |
-| Insert empty block before current (if input is empty) | Shift+Enter ⏎ |
+| Insert empty block (if input is empty) | Shift+Enter ⏎ |
+| Move the block (nav) | Alt+⇧/⇩ |
+| Peek-scroll one line, keep the selection (nav) | Ctrl+Alt+⇧/⇩ |
+| Peek-scroll one page (nav) | Ctrl+Alt+PageUp/PageDown or ⇦/⇨ |
+| Copy block | Ctrl+C / Ctrl+Insert |
+| Cut block | Ctrl+X / Shift+Delete |
+| Paste | Ctrl+V / Shift+Insert |
| Delete selected block | Ctrl+Delete |
-| Delete selected block (if input is empty) | Ctrl+Y |
-| Wipe input | Ctrl+W |
-| Load selected content to input (discards previous content) | Esc |
-| Insert selected content into input | Shift+Esc |
+| Checkpoint the file | Ctrl+K |
+| Restore from checkpoint | Ctrl+Shift+K |
+| Drop the edit, return to navigation | Ctrl+W |
+| Discard the edit (editing) / leave the editor (nav) | Shift+Esc |
+| Follow the require under selection | Ctrl+J |
| Scroll to start | Ctrl+PageUp |
| Scroll to end | Ctrl+PageDown |
| Scroll up by one line | Shift+PageUp |
| Scroll down by one line | Shift+PageDown |
| Move selection to start | Ctrl+Home |
-| Move selecion to end | Ctrl+End |
-| Close editor buffer | Ctrl+S |
-| Stop editor (close all buffers) | Ctrl+Shift+S |
+| Move selection to end | Ctrl+End |
+| Leave editor (close all buffers) | Ctrl+Shift+S |
| _move mode_ | |
| Switch to moving ("pick up" selection) | Ctrl+M |
| Move selection | ⇧/⇩ |
| Move selection to start | Ctrl+Home |
-| Move selecion to end | Ctrl+End |
+| Move selection to end | Ctrl+End |
| Cancel moving | Esc |
-| Move line/block to selection and return to normal mode | Enter ⏎ |
+| Place block and return to normal mode | Enter ⏎ |
| _search mode_ | |
| Search definitions | Ctrl+F |
| Exit search | Esc |
| Jump to selected definition | Enter ⏎ |
-| Edit required file under highlight | Ctrl+O |
## Projects
@@ -212,9 +222,10 @@ Paths will be searched in the following order:
## Keys
-| Command | Combination |
-| :----------------------------- | :------------------------------------------ |
-| Reset project to initial state | Ctrl+Alt+R |
+| Command | Combination |
+| :--------------- | :------------------------------------------ |
+| Restart project | Ctrl+Alt+R |
+| Exit application | Ctrl+Esc |
#
diff --git a/doc/EDITOR.md b/doc/EDITOR.md
index 3ca8f9c1..65175e2d 100644
--- a/doc/EDITOR.md
+++ b/doc/EDITOR.md
@@ -20,12 +20,13 @@
| Command | Keymap |
| :---------------------------------------------------------------- | :-------------------------------------------- |
| Clear terminal | Ctrl+L |
-| Stop project | Ctrl+Shift+S |
-| Quit project (stop and close) | Ctrl+Shift+Q |
+| Stop project | Ctrl+S |
+| Quit project (stop and close) | Ctrl+Q |
| Reset application to initial state | Ctrl+Shift+R |
+| Restart project | Ctrl+Alt+R |
| Exit application | Ctrl+Esc |
| Pause project | Ctrl+Pause |
-| Toggle edit/run | F9 |
+| Toggle edit/run | Ctrl+T |
| **Input** |
| Move cursor horizontally | ⇦/⇨ |
| Move cursor vertically | ⇧/⇩ |
@@ -38,28 +39,44 @@
| Jump to line end | Alt+End |
| Insert newline | Shift+Enter ⏎ |
| Delete current line | Ctrl+Y |
+| Duplicate current line | Ctrl+D |
+| Copy | Ctrl+C / Ctrl+Insert |
+| Cut | Ctrl+X / Shift+Delete |
+| Paste | Ctrl+V / Shift+Insert |
+| Select text | Shift+⇦/⇨/⇧/⇩ |
| Evaluate input | Enter ⏎ |
| **Editor** |
| _same as Input, except for:_ |
-| Scroll up | PageUp |
-| Scroll down | PageDown |
-| Move selection (if in first/last line) | ⇧/⇩ |
-| Move selection | Ctrl+⇧/⇩ |
+| Move the active line by one line (nav) | ⇧/⇩ |
+| Move the active line by a page (nav) | PageUp/PageDown |
+| Move the cursor through the block (editing) | ⇧/⇩ |
+| Jump block-wise (nav) / accept and jump (editing) | Ctrl+⇧/⇩ |
| Replace selection with input | Enter ⏎ |
| _additionally_ |
+| Open selected block for editing (nav, empty input) | Enter ⏎ |
+| Insert input contents before selection | Ctrl+Enter ⏎ |
+| Insert empty block (if input is empty) | Shift+Enter ⏎ |
+| Move the block (nav) | Alt+⇧/⇩ |
+| Peek-scroll one line, keep the selection (nav) | Ctrl+Alt+⇧/⇩ |
+| Peek-scroll one page (nav) | Ctrl+Alt+PageUp/PageDown or ⇦/⇨ |
+| Copy block | Ctrl+C / Ctrl+Insert |
+| Cut block | Ctrl+X / Shift+Delete |
+| Paste | Ctrl+V / Shift+Insert |
| Delete selected block | Ctrl+Delete |
-| Delete selected block (if input is empty) | Ctrl+Y |
-| Load selected content to input (discards previous content) | Esc |
-| Insert selected content into input | Shift+Esc |
+| Checkpoint the file | Ctrl+K |
+| Restore from checkpoint | Ctrl+Shift+K |
+| Drop the edit, return to navigation | Ctrl+W |
+| Discard the edit (editing) / leave the editor (nav) | Shift+Esc |
+| Follow the require under selection | Ctrl+J |
+| Block reorder mode | Ctrl+M |
+| Search definitions | Ctrl+F |
| Scroll to start | Ctrl+PageUp |
| Scroll to end | Ctrl+PageDown |
| Scroll up by one line | Shift+PageUp |
| Scroll down by one line | Shift+PageDown |
| Move selection to start | Ctrl+Home |
-| Move selecion to end | Ctrl+End |
-| Wipe input | Ctrl+W |
-| Duplicate current line | Ctrl+D |
-| Stop editor | Ctrl+Shift+S |
+| Move selection to end | Ctrl+End |
+| Leave editor (close all buffers) | Ctrl+Shift+S |
### Usage
@@ -73,14 +90,14 @@ default, and entered input will be appended to the end.

-To modify an existing line, navigate there with
-⇧/⇩. Then load the text by pressing
-Esc, make the desired changes, then send it back with
-Enter ⏎
+To modify an existing block, navigate to it with
+⇧/⇩. Open it for editing by pressing
+Enter ⏎, make the desired changes, then send it back
+with Enter ⏎

-Happy with the modifications now, we can quit by pressing
-Ctrl-Shift-Q
+Happy with the modifications now, we can leave the editor by
+pressing Shift-Esc

diff --git a/src/controller/consoleController.lua b/src/controller/consoleController.lua
index 5196e957..5995b8cb 100644
--- a/src/controller/consoleController.lua
+++ b/src/controller/consoleController.lua
@@ -136,6 +136,52 @@ end
--- @private
--- @param name string
--- @return string?
+--- @param name string
+--- @return string
+local function checkpoint_name(name)
+ return name .. '.~save'
+end
+
+--- Modification time of a project file, nil if absent
+--- @param name string
+--- @return integer? modtime
+function ConsoleController:file_modtime(name)
+ local p = self.model.projects.current
+ if not p then return end
+ local info = FS.getInfo(p:get_path(name))
+ return info and info.modtime
+end
+
+--- @param name string
+--- @return integer? modtime of the checkpoint
+function ConsoleController:checkpoint_modtime(name)
+ return self:file_modtime(checkpoint_name(name))
+end
+
+--- Copy the file to its checkpoint (spec 2.6)
+--- @param name string
+--- @return boolean ok
+function ConsoleController:write_checkpoint(name)
+ local p = self.model.projects.current
+ if not p then return false end
+ local ok = FS.cp(
+ p:get_path(name),
+ p:get_path(checkpoint_name(name)))
+ return ok and true or false
+end
+
+--- Write the checkpoint back over the file (spec 2.6)
+--- @param name string
+--- @return boolean ok
+function ConsoleController:restore_checkpoint(name)
+ local p = self.model.projects.current
+ if not p then return false end
+ local cp = p:get_path(checkpoint_name(name))
+ if not FS.exists(cp) then return false end
+ local ok = FS.cp(cp, p:get_path(name))
+ return ok and true or false
+end
+
function ConsoleController:_readfile(name)
local PS = self.model.projects
local p = PS.current
@@ -499,6 +545,14 @@ function ConsoleController.prepare_project_env(cc)
--- @param name string
--- @return string?
+ --- Restore a file from its checkpoint; no prompt
+ --- @param name string? --- default main.lua
+ --- @return boolean
+ project_env.revert = function(name)
+ name = name or ProjectService.MAIN
+ return cc:restore_checkpoint(name)
+ end
+
project_env.readfile = function(name)
--- @diagnostic disable-next-line: invisible
return cc:_readfile(name)
@@ -901,8 +955,14 @@ function ConsoleController:edit(name, state)
love.state.prev_state = love.state.app_state
love.state.app_state = 'editor'
end
+ --- Editor accept path: a save is durable before the
+ --- editor reports acceptance (spec 2.6), so a force-stop
+ --- after an accepted edit cannot lose it. fsync only
+ --- here — writefile and bulk paths stay async.
local save = function(newcontent)
- return self:_writefile(filename, newcontent)
+ local ok, err = self:_writefile(filename, newcontent)
+ if ok then FS.fsync(fpath) end
+ return ok, err
end
self.editor:open(filename, text, save)
@@ -1041,7 +1101,7 @@ function ConsoleController:mousepressed(
x, y, btn, touch, presses)
if love.state.app_state == 'editor' then
if self.cfg.editor.mouse_enabled then
- self.editor.input:mousepressed(x, y, btn, touch, presses)
+ self.editor:mousepressed(x, y, btn, touch, presses)
end
else
self.input:mousepressed(x, y, btn, touch, presses)
diff --git a/src/controller/controller.lua b/src/controller/controller.lua
index d577a472..505251e2 100644
--- a/src/controller/controller.lua
+++ b/src/controller/controller.lua
@@ -4,6 +4,7 @@ require("view.view")
require("util.string.string")
require("util.key")
local LANG = require("util.eval")
+local FS = require("util.filesystem")
local messages = {
user_break = "BREAK into program",
@@ -451,6 +452,10 @@ Controller = {
local cfg = CC.cfg
local function quit()
+ --- flush pending writes before the process can exit
+ --- (spec 2.6): a graceful quit loses nothing. One
+ --- syscall; force-stop is covered by per-accept fsync
+ FS.sync()
if love.state.app_state == 'shutdown' then
return false
end
@@ -471,6 +476,30 @@ Controller = {
love.quit = quit
end,
+ --- Background durability net (spec 2.6): a child
+ --- leaving the app flushes pending writes. One syscall
+ --- on focus loss; does not cover a force-stop mid-edit
+ --- (per-accept fsync does).
+ --- @private
+ --- @param CC ConsoleController
+ set_love_focus = function(CC)
+ local function focus(f)
+ if not f then FS.sync() end
+ end
+ love.focus = focus
+ end,
+
+ --- Companion to focus: Android reports a backgrounded
+ --- window as not visible; flush there too.
+ --- @private
+ --- @param CC ConsoleController
+ set_love_visible = function(CC)
+ local function visible(v)
+ if not v then FS.sync() end
+ end
+ love.visible = visible
+ end,
+
----------------
--- public ---
----------------
@@ -493,10 +522,11 @@ Controller = {
--- SKIPPED joystick and gamepad support
- --- intented to run as kiosk app
- --- SKIPPED focus
+ --- intented to run as kiosk app; focus/visible are
+ --- wired only to flush pending writes on background
+ Controller.set_love_focus(CC)
+ Controller.set_love_visible(CC)
--- SKIPPED mousefocus
- --- SKIPPED visible
--- SKIPPED resize
--- SKIPPED filedropped
--- SKIPPED directorydropped
@@ -563,10 +593,11 @@ Controller = {
if love.state.app_state == 'running' then
CC:stop_project_run()
elseif love.state.app_state == 'editor' then
+ --- bare Ctrl+S is reserved for the
+ --- checkpoint (rework spec 2.6); saving
+ --- is automatic, leaving is Shift+Esc
if Key.shift() then
CC:finish_edit()
- else
- CC:close_buffer()
end
end
end
diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua
index cd9b28e4..0acbb172 100644
--- a/src/controller/editorController.lua
+++ b/src/controller/editorController.lua
@@ -2,6 +2,7 @@ require("model.interpreter.eval.evaluator")
require("controller.userInputController")
require("controller.searchController")
require("view.input.customStatus")
+require("model.input.cursor")
local class = require('util.class')
@@ -17,12 +18,16 @@ local function new(M, CC)
),
console = CC,
view = nil,
- mode = 'edit',
+ mode = 'nav',
+ pos_memory = {},
+ pending_confirm = nil,
+ accepted_n = 1,
}
end
--- @alias EditorMode
--- | 'edit' --- default
+--- | 'nav' --- navigating between blocks
--- | 'reorder'
--- | 'search'
@@ -34,6 +39,10 @@ end
--- @field view EditorView?
--- @field state EditorState?
--- @field mode EditorMode
+--- @field pos_memory table
+--- @field pending_confirm string? --- 'overwrite'|'restore'
+--- @field accepted_n integer --- blocks the last
+--- acceptance produced; the leave gate steps past them
EditorController = class.create(new)
--- @param v EditorView
@@ -79,6 +88,10 @@ function EditorController:open(name, content, save)
local b = BufferModel(name, content, save, ch, hl, pp, tr)
self.model.buffers:push_front(b)
self.view:open(b)
+ self:set_mode('nav')
+ if not self:_restore_position(b) then
+ self.view:get_current_buffer():follow_selection()
+ end
self:update_status()
self:set_state()
self.input:update_view()
@@ -105,7 +118,7 @@ function EditorController:follow_require()
local name = reqsel.name
self.console:edit(name .. '.lua')
else
- self:pop_buffer()
+ self:refuse()
end
end
@@ -113,13 +126,52 @@ function EditorController:pop_buffer()
local bs = self.model.buffers
local n_buffers = bs:length()
if n_buffers < 2 then return end
+ self:_remember_position()
bs:pop_front()
local b = bs:first()
- self.view:get_current_buffer():open(b)
+ local bv = self.view:get_current_buffer()
+ bv:open(b)
+ --- the buffer keeps its position; the view must
+ --- follow it, exactly as opening a file does —
+ --- open() alone parks the view at the end
+ bv:follow_line()
self:update_status()
end
+--- store the active buffer's position by file name
+function EditorController:_remember_position()
+ local buf = self:get_active_buffer()
+ local bv = self.view:get_current_buffer()
+ self.pos_memory[buf.name] = {
+ sel = buf:get_selection(),
+ off = bv:get_offset(),
+ }
+end
+
+--- restore a remembered position if it is still in range
+--- @param buf BufferModel
+function EditorController:_restore_position(buf)
+ local saved = self.pos_memory[buf.name]
+ if saved
+ and saved.sel >= 1
+ and saved.sel <= buf:get_content_length() then
+ buf:set_selection(saved.sel)
+ self.view:get_current_buffer():scroll_to(saved.off)
+ return true
+ end
+ return false
+end
+
+--- Replace the active buffer with fresh file content
+--- @param text string
+function EditorController:reload_active(text)
+ local old = self:get_active_buffer()
+ self.model.buffers:pop_front()
+ self:open(old.name, text, old.save_file)
+end
+
function EditorController:close_buffer()
+ self:_remember_position()
local bs = self.model.buffers
local n_buffers = bs:length()
if n_buffers < 2 then
@@ -132,9 +184,17 @@ end
--- @param m EditorMode
--- @return boolean
local function is_normal(m)
- return m == 'edit'
+ return m == 'nav' or m == 'edit'
end
+--- legal mode transitions; anything absent is rejected
+local TRANSITIONS = {
+ nav = { edit = true, reorder = true, search = true },
+ edit = { nav = true },
+ reorder = { nav = true },
+ search = { nav = true },
+}
+
--- @param mode EditorMode
function EditorController:set_mode(mode)
local buf = self:get_active_buffer()
@@ -151,7 +211,7 @@ function EditorController:set_mode(mode)
end
local current = self.mode
- if is_normal(current) then
+ if current ~= mode and TRANSITIONS[current][mode] then
if mode == 'reorder' then
set_reorg()
end
@@ -159,14 +219,9 @@ function EditorController:set_mode(mode)
init_search()
end
self.mode = mode
- else
- --- currently in a special mode, only return is allowed
- if is_normal(mode) then
- self.mode = mode
- end
+ Log.info('-- ' .. string.upper(mode) .. ' --')
+ self:update_status()
end
- Log.info('-- ' .. string.upper(mode) .. ' --')
- self:update_status()
end
--- @return EditorMode
@@ -179,6 +234,24 @@ function EditorController:is_normal_mode()
return is_normal(self.mode)
end
+--- One sound for every refused action (spec 2.4.3):
+--- a knock means "no further this way"
+--- @param msg string[]? --- also shown when given
+function EditorController:refuse(msg)
+ --- required here, not at the top: util.audio builds
+ --- its sources on load and needs love.audio ready
+ require("util.audio").knock()
+ if msg then self.input:set_error(msg) end
+end
+
+--- drop the loaded block and the input, return to nav
+function EditorController:leave_edit()
+ local buf = self:get_active_buffer()
+ buf:clear_loaded()
+ self.input:clear()
+ self:set_mode('nav')
+end
+
--- @param clipboard string
function EditorController:set_clipboard(clipboard)
self.state.clipboard = clipboard
@@ -286,14 +359,31 @@ end
--- @param t string
function EditorController:textinput(t)
self.view:update_input()
- if self.mode == 'edit' then
+ if self:_dialog_textinput(t) then return end
+ if is_normal(self.mode) then
local input = self.model.input
if input:has_error() then
input:clear_error()
else
- if Key.ctrl() and Key.shift() then
+ if Key.ctrl() or Key.alt() then
+ --- modifier chords leak glyphs on the device
+ --- (compy-input-quirks, quirk 3); only Shift
+ --- composes real input
return
end
+ --- typing after a peek returns the view (2.2)
+ local bv = self.view:get_current_buffer()
+ if self.mode == 'nav' then
+ bv:follow_line()
+ else
+ bv:follow_selection()
+ end
+ --- NB: on device, textinput precedes keypressed
+ --- (see dev/docs/compy-input-quirks.md), so this
+ --- transition lands before the same key's press
+ if self.mode == 'nav' then
+ self:start_typing()
+ end
self.input:textinput(t)
end
elseif self.mode == 'search' then
@@ -307,9 +397,12 @@ function EditorController:get_input()
end
--- @param buf BufferModel
+--- @return boolean ok --- the write reached the OS
+--- @return string? err
function EditorController:save(buf)
local ok, err = buf:save()
if not ok then Log.error("can't save: ", err) end
+ return ok, err
end
---------------------------
@@ -318,6 +411,9 @@ end
--- @private
--- @param go fun(nt: string[]|Block[])
+--- @param go fun(newtext: Block[]|string[]): boolean
+--- @return boolean accepted --- go's verdict, or false
+--- when the input does not evaluate
function EditorController:_handle_submit(go)
local inter = self.input
local raw = inter:get_text()
@@ -328,7 +424,7 @@ function EditorController:_handle_submit(go)
if not string.is_non_empty_string_array(raw) then
local sel = buf:get_selection()
local block = buf:get_content():get(sel)
- if not block then return end
+ if not block then return true end
else
local _, raw_chunks = buf.chunker(raw, true)
local pretty = buf.printer(raw)
@@ -370,17 +466,27 @@ function EditorController:_handle_submit(go)
end
end
end
- go(chunks)
+ return go(chunks)
else
local eval_err = res
if eval_err then
+ self:refuse()
inter:set_error(eval_err)
+ --- spec 2.4.3: the cursor moves to the error
+ local first = Error.get_first(eval_err)
+ or eval_err
+ if type(first) == 'table' and first.l then
+ inter.model:move_cursor(first.l, first.c or 1)
+ inter:update_view()
+ end
end
+ return false
end
end
else
- go(raw)
+ return go(raw)
end
+ return true
end
--- @private
@@ -388,6 +494,424 @@ end
--- @param by integer?
--- @param warp boolean?
--- @param moved integer?
+--- Shift+Esc out of an open block (1.1). A changed
+--- block asks for confirmation — the only irreversible
+--- action in the editor gets the same repeated-press
+--- guard the checkpoints use. A parseable draft leaves
+--- a recoverable pair in the block history: one Ctrl+Z
+--- puts the discarded text into the file, another
+--- takes it back out.
+function EditorController:discard_edit()
+ if self.mode ~= 'edit' then
+ return self:leave_edit()
+ end
+ local buf = self:get_active_buffer()
+ local draft = self.input:get_text():items()
+ local orig = buf:get_selected_text()
+ local clean = string.unlines(draft)
+ == string.unlines(orig)
+
+ if clean then return self:leave_edit() end
+
+ self.pending_confirm = 'discard'
+ self.input:set_error({
+ 'discard the changes? Confirm [Enter] / Cancel [Esc]'
+ })
+end
+
+--- Execute a confirmed dialog action (the dispatch in
+--- keypressed/textinput confirms on Enter or Space and
+--- cancels on everything else, so key repeat of the
+--- invoking chord lands on the idempotent cancel)
+--- @param act string --- 'discard'|'overwrite'|'restore'
+function EditorController:_confirm(act)
+ local con = self.console
+ if act == 'overwrite' then
+ return con:write_checkpoint(
+ self:get_active_buffer().name)
+ end
+ if act == 'restore' then
+ local name = self:get_active_buffer().name
+ if con:restore_checkpoint(name) then
+ local text = con:_readfile(name)
+ self:reload_active(text)
+ end
+ return
+ end
+ local buf = self:get_active_buffer()
+ local draft = self.input:get_text():items()
+
+ --- an unparseable draft cannot go into the file
+ --- (it would turn the buffer read-only), so only a
+ --- valid one is recoverable — as agreed
+ local parses = buf.chunker == nil
+ or (buf.chunker(draft, true))
+ if parses then
+ local span = buf:get_selection_lines()
+ local before = table.clone(buf:get_text_content())
+ local after = {}
+ local drafted = {}
+ for i, l in ipairs(before) do after[i] = l end
+ for i, l in ipairs(draft) do drafted[i] = l end
+ local head = span.start - 1
+ local removed = span:len()
+ for _ = 1, removed do
+ table.remove(after, head + 1)
+ end
+ for i = #drafted, 1, -1 do
+ table.insert(after, head + 1, drafted[i])
+ end
+ local sel = buf:get_selection()
+ --- the pair: undo #1 puts the draft in, undo #2
+ --- takes it back out (net zero, like the discard)
+ buf:push_history(before, after, sel, sel)
+ buf:push_history(after, before, sel, sel)
+ end
+ self:leave_edit()
+end
+
+--- @param t string
+--- @return boolean handled --- the glyph fed a dialog
+function EditorController:_dialog_textinput(t)
+ if self._swallow_glyph then
+ self._swallow_glyph = nil
+ if t == ' ' then return true end
+ end
+ if not self.pending_confirm then return false end
+ local act = self.pending_confirm
+ self.pending_confirm = nil
+ self.input:clear_error()
+ if t == ' ' then
+ self._swallow_glyph = true
+ self:_confirm(act)
+ end
+ return true
+end
+
+--- Load the selected block into the input and open it
+--- for editing, auto-formatted (9.4), with the cursor
+--- on the active line (2.2)
+function EditorController:open_block()
+ local buf = self:get_active_buffer()
+ local input = self.input
+ local span = buf:get_selection_lines()
+ local row = buf:get_active_line() - span.start + 1
+
+ local t = buf:get_selected_text()
+ if string.is_non_empty(t) then
+ buf:set_loaded()
+ else
+ buf:clear_loaded()
+ end
+ input:set_text(t)
+ input:jump_home()
+
+ if buf.content_type == 'lua' then
+ --- auto-format on opening (spec 9.4); a block the
+ --- formatter changes is dirty from birth (2.4)
+ local raw = input:get_text()
+ if string.is_non_empty_string_array(raw) then
+ local pretty = buf.printer(raw)
+ if pretty then
+ --- the printer may append a trailing empty
+ --- line; that is noise, not formatting
+ while #pretty > 1 and pretty[#pretty] == '' do
+ table.remove(pretty)
+ end
+ input:set_text(pretty)
+ end
+ end
+ end
+ --- the active line can sit outside the block being
+ --- opened (a deletion leaves the selection on the
+ --- trailing gap); row 0 detaches the cursor
+ local n = #input:get_text()
+ if n < 1 then n = 1 end
+ if row < 1 then row = 1 end
+ if row > n then row = n end
+ input:set_cursor(Cursor(row, 1))
+ self:set_mode('edit')
+end
+
+--- Typing in navigation starts editing at the active
+--- line: its block opens and a blank line appears there
+--- to type into, pushing the rest down (spec 2.1). On a
+--- blank line the text becomes a new block instead, so
+--- the input stays empty and acceptance inserts.
+function EditorController:start_typing()
+ local buf = self:get_active_buffer()
+ if buf.content_type ~= 'lua' then
+ self:set_mode('edit')
+ return
+ end
+ local block = buf:_get_selected_block()
+ if not block or block:is_empty() then
+ --- an empty block -- including the gap a deletion
+ --- leaves behind -- still has to be opened. Setting
+ --- the mode alone anchors the input to no block, so
+ --- the typing goes into a detached widget and the
+ --- accept drops it on the floor
+ self:open_block()
+ return
+ end
+
+ local ln = buf:get_active_line()
+ local span = buf:get_selection_lines()
+ local row = ln - span.start + 1
+ self:open_block()
+
+ local t = self.input:get_text()
+ --- the format on opening may have reshaped the block
+ if row < 1 then row = 1 end
+ if row > #t then row = #t + 1 end
+ table.insert(t, row, '')
+ self.input:set_text(t)
+ self.input:set_cursor(Cursor(row, 1))
+end
+
+--- Open a fresh empty block next to the current one
+--- (spec 2.7: Ctrl+Enter below, Ctrl+Shift+Enter
+--- above). The block itself appears on acceptance —
+--- until then the editor simply composes at that spot.
+--- @param below boolean
+function EditorController:new_block(below)
+ local buf = self:get_active_buffer()
+ if buf.readonly then return self:refuse() end
+ if below then
+ buf:set_selection(buf:get_selection() + 1)
+ end
+ buf:clear_loaded()
+ self.input:clear()
+ self:set_mode('edit')
+ self.view:get_current_buffer():follow_selection()
+ self:update_status()
+end
+
+--- Run a file-writing operation and record it in the
+--- block history (1.1): the file before and after, the
+--- diff trimmed inside push_history
+--- @param buf BufferModel
+--- @param fn function --- mutates the buffer and saves
+--- @return any --- fn's return
+function EditorController:record_write(buf, fn)
+ local before = table.clone(buf:get_text_content())
+ local sel_b = buf:get_selection()
+ local ret = fn()
+ buf:push_history(
+ before,
+ table.clone(buf:get_text_content()),
+ sel_b,
+ buf:get_selection())
+ return ret
+end
+
+--- @private
+--- Apply one block-history step (spec 1.1: navigation
+--- undo). The file is written through the same save
+--- path every operation uses.
+--- @param redo boolean?
+function EditorController:_step_history(redo)
+ local buf = self:get_active_buffer()
+ if buf.readonly then return self:refuse() end
+ --- no and/or chain here: redo() legitimately
+ --- returns nil, which must not fall through to undo
+ local step
+ if redo then
+ step = buf:redo()
+ else
+ step = buf:undo()
+ end
+ if not step then return self:refuse() end
+ self:save(buf)
+ local sel = redo and step.sel_after or step.sel_before
+ local last = buf:get_content_length()
+ if sel > last then sel = last end
+ buf:set_selection(sel)
+ self.view:refresh()
+ self.view:get_current_buffer():follow_selection()
+ self:update_status()
+end
+
+--- @return integer --- the input strip's height (2.7)
+function EditorController:_size_limit()
+ return self.view:get_current_buffer():get_max_size()
+end
+
+--- @param chunks Block[]
+--- @return integer? --- index of the first block over
+--- the limit, nil when all fit
+function EditorController:_first_oversized(chunks)
+ if self.view:get_current_buffer().content_type
+ ~= 'lua' then
+ return
+ end
+ local limit = self:_size_limit()
+ return table.find_by(chunks, function(v)
+ return (v and v.pos and v.pos:len() > limit)
+ end)
+end
+
+--- Refuse an oversized block and point at it (9.6)
+--- @param chunks Block[]
+--- @param idx integer
+function EditorController:_reject_oversized(chunks, idx)
+ local block = chunks[idx]
+ if not block or not block.pos then return end
+ local n = block.pos:len()
+ --- the wording follows 1.4: say what to do, not what
+ --- the machine measured
+ self:refuse({ string.format(
+ 'Too many lines in a block. Remove %d to save,'
+ .. ' or press Shift+Esc to cancel',
+ n - self:_size_limit()
+ ) })
+ self.input.model:move_cursor(block.pos.start, 1)
+ self.input:update_view()
+end
+
+--- Accept the open block into the file: validate, size
+--- check, re-chunk, write (spec 2.4.2). Acceptance in
+--- place keeps the block and scrolls back to it.
+--- @return boolean accepted
+function EditorController:accept_block()
+ return self:_handle_submit(function(newtext)
+ local buf = self:get_active_buffer()
+ local bufv = self.view:get_current_buffer()
+ if not bufv:is_selection_visible(true) then
+ bufv:follow_selection()
+ return false
+ end
+ if not buf:loaded_is_sel(true) then
+ buf:select_loaded()
+ bufv:follow_selection()
+ return false
+ end
+ local oversized = self:_first_oversized(newtext)
+ if oversized then
+ self:_reject_oversized(newtext, oversized)
+ return false
+ end
+ local saved = self:record_write(buf, function()
+ local _, n = buf:replace_content(newtext)
+ local ok = self:save(buf)
+ self.accepted_n = n
+ return ok
+ end)
+ if not saved then
+ --- a failed write must not read as accepted (2.6);
+ --- keep the block open so the edit is not lost
+ self:refuse({
+ 'Could not save the file.'
+ .. ' Check the storage and try again.'
+ })
+ return false
+ end
+ self.view:refresh()
+ bufv:follow_selection()
+ self:leave_edit()
+ return true
+ end)
+end
+
+--- Click semantics (spec 2.9): in nav, select the
+--- clicked line's block; while editing, a click inside
+--- the open block places the cursor, a click outside
+--- it leaves when the block is untouched
+--- @param ln integer --- source line
+function EditorController:mouse_select(ln)
+ local buf = self:get_active_buffer()
+ local bi = buf:block_at_line(ln)
+ if not bi then return end
+
+ if self.mode == 'edit' then
+ local span = buf:get_selection_lines()
+ if span:inc(ln) then
+ self.input:set_cursor(Cursor(ln - span.start + 1, 1))
+ return
+ end
+ --- leaving for another block goes through the gate
+ --- (2.4): untouched leaves, changed is accepted and
+ --- written, invalid refuses and keeps the block
+ local clean = string.unlines(self.input:get_text())
+ == string.unlines(buf:get_selected_text())
+ if clean then
+ self:leave_edit()
+ elseif not self:accept_block() then
+ return
+ end
+ end
+
+ buf:set_selection(bi)
+ buf:set_active_line(ln)
+ self.view:get_current_buffer():follow_line()
+ self:update_status()
+end
+
+--- @param x number
+--- @param y number
+--- @param btn integer
+--- @param touch boolean?
+--- @param presses integer? --- 2 on a double click
+function EditorController:mousepressed(x, y, btn, touch, presses)
+ if btn == 1 then
+ local ln = self.view:get_current_buffer():line_at(y)
+ if ln then
+ self:mouse_select(ln)
+ --- spec 2.9: a double click opens the block the
+ --- first click selected
+ if presses and presses > 1
+ and self.mode == 'nav' then
+ self:open_block()
+ end
+ return
+ end
+ end
+ self.input:mousepressed(x, y, btn, touch, presses)
+end
+
+--- Block-wise movement of the active line (spec 2.2)
+--- @param dir VerticalDir
+function EditorController:_jump_block(dir)
+ local buf = self:get_active_buffer()
+ if self.input:has_error() then return end
+ if buf:jump_block(dir) then
+ self.view:get_current_buffer():follow_line()
+ self:update_status()
+ else
+ self:refuse()
+ end
+end
+
+--- Move the active line by a viewport page
+--- @param dir VerticalDir
+function EditorController:_move_line_page(dir)
+ local buf = self:get_active_buffer()
+ if self.input:has_error() then return end
+ local bv = self.view:get_current_buffer()
+ local moved = 0
+ for _ = 1, bv.LINES do
+ if not buf:move_line(dir) then break end
+ moved = moved + 1
+ end
+ if moved == 0 then return self:refuse() end
+ bv:follow_line()
+ self:update_status()
+end
+
+--- Move the active line, keep it in view
+--- @param dir VerticalDir
+function EditorController:_move_line(dir)
+ local buf = self:get_active_buffer()
+ if self.input:has_error() then return end
+ if buf:move_line(dir) then
+ self.view:get_current_buffer():follow_line()
+ self:update_status()
+ else
+ --- nowhere further to go
+ self:refuse()
+ end
+end
+
function EditorController:_move_sel(dir, by, warp, moved)
local buf = self:get_active_buffer()
if self.input:has_error() then return end
@@ -402,6 +926,8 @@ function EditorController:_move_sel(dir, by, warp, moved)
if mv then self.view:refresh(moved) end
self.view:get_current_buffer():follow_selection()
self:update_status()
+ else
+ self:refuse()
end
end
@@ -423,16 +949,18 @@ function EditorController:_reorg(save)
local buf = self:get_active_buffer()
if save then
local target = buf:get_selection()
- buf:move(moved, target)
- buf:rechunk()
- self:save(buf)
+ self:record_write(buf, function()
+ buf:move(moved, target)
+ buf:rechunk()
+ self:save(buf)
+ end)
else
buf:set_selection(moved)
self:restore_state(self:get_state())
end
self.view:refresh()
- self:set_mode('edit')
+ self:set_mode('nav')
end
--- @private
@@ -484,7 +1012,7 @@ end
function EditorController:_search_mode_keys(k)
if k == 'escape' then
- self:set_mode('edit')
+ self:set_mode('nav')
self.search:clear()
return
end
@@ -497,7 +1025,7 @@ function EditorController:_search_mode_keys(k)
local ln = jump.line - 1
buf:set_selection(bn)
self.view:get_current_buffer():scroll_to_line(ln)
- self:set_mode('edit')
+ self:set_mode('nav')
self.search:clear()
end
end
@@ -515,25 +1043,16 @@ function EditorController:_normal_mode_keys(k)
--- @type BufferModel
local buf = self:get_active_buffer()
- local function newline()
- if Key.is_enter(k) then
- --- insert empty block if input is empty
- if is_empty
- and (Key.shift() or Key.ctrl())
- and not Key.alt() then
- buf:insert_newline()
- self:save(buf)
- self.view:refresh()
- block_input()
- end
- end
- end
-
+ --- Delete removes the block without touching the
+ --- clipboard: on the device every clipboard write
+ --- pops the system share overlay, and a deletion
+ --- clobbering the copied text surprised everyone.
+ --- Cutting is Ctrl+X alone (copy + delete).
local function delete_block()
- local t = string.unlines(buf:get_selected_text())
- buf:delete_selected_text()
- love.system.setClipboardText(t)
- self:save(buf)
+ self:record_write(buf, function()
+ buf:delete_selected_text()
+ self:save(buf)
+ end)
self.view:refresh()
end
@@ -582,194 +1101,347 @@ function EditorController:_normal_mode_keys(k)
if is_empty then
copycut()
end
- newline()
-
paste_k()
- --- @param add boolean?
- local function load_selection(add)
- local t = buf:get_selected_text()
- if string.is_non_empty(t) then
- buf:set_loaded()
- else
- buf:clear_loaded()
- end
- if add then
- local c = input:get_cursor_info().cursor
- input:add_text(t)
- input:set_cursor(c)
- else
- input:set_text(t)
- input:jump_home()
- end
- end
+
--- handlers
- local function submit()
+ --- @param force_accept boolean? --- the leave gate
+ local function submit(force_accept)
local bufv = self.view:get_current_buffer()
- local is_lua = bufv.content_type == 'lua'
- local size_limit = bufv:get_max_size()
- --- @param v Block
- --- @return boolean
- local is_oversized_chunk = function(v)
- return (v and v.pos and v.pos:len() > size_limit)
- end
- --- @param chunks Block[]
- --- @return integer?
- local first_oversized_chunk = function(chunks)
- if is_lua then
- return table.find_by(chunks, is_oversized_chunk)
- end
- end
- --- @param chunks Block[]
- --- @param idx integer
- local reject_oversized = function(chunks, idx)
- local block = chunks[idx]
- if not block or not block.pos then return end
- input.model:move_cursor(block.pos.start, 1)
- input:update_view()
- end
+
+ --- Insert freshly composed text as new block(s)
--- @param newtext Block[]
- --- @return Block[]|false
- --- @return integer? first oversized chunk index
- local analyze_input = function(newtext)
- local oversized = first_oversized_chunk(newtext)
- if not oversized then
- return newtext
+ --- @return boolean accepted
+ local function add(newtext)
+ if not bufv:is_selection_visible() then
+ bufv:follow_selection()
+ return false
end
- return false, oversized
+
+ local oversized = self:_first_oversized(newtext)
+ if oversized then
+ self:_reject_oversized(newtext, oversized)
+ return false
+ end
+
+ local n = self:record_write(buf, function()
+ local sel = buf:get_selection()
+ local _, added = buf:insert_content(newtext, sel)
+ self:save(buf)
+ return added
+ end)
+ self.view:refresh()
+ self:_move_sel('down', n)
+ self:leave_edit()
+ return true
end
- --- @param newtext Block[]
- local function replace(newtext)
- if not bufv:is_selection_visible(true) then
- return bufv:follow_selection()
+ --- undo/redo (1.1): the mode picks the level —
+ --- editing works the text history of the open
+ --- block, navigation works the file history
+ if Key.ctrl() and not Key.alt() and not Key.shift()
+ and (k == 'z' or k == 'y') then
+ block_input()
+ if self.mode == 'edit' then
+ local im = self.input.model
+ local done
+ if k == 'z' then
+ done = im:undo_edit()
+ else
+ done = im:redo_edit()
+ end
+ if done then
+ self.input:update_view()
+ else
+ self:refuse()
+ end
+ else
+ self:_step_history(k == 'y')
end
+ return
+ end
- if not buf:loaded_is_sel(true) then
- buf:select_loaded()
- bufv:follow_selection()
+ --- spec 2.7: Ctrl+Enter opens a fresh block below
+ --- in navigation and accepts while editing;
+ --- Ctrl+Shift+Enter opens one above
+ if Key.ctrl() and not Key.alt() and Key.is_enter(k) then
+ block_input()
+ if self.mode == 'nav' then
+ self:new_block(not Key.shift())
return
end
-
- local approved, oversized = analyze_input(newtext)
- if not approved then
- if oversized then
- reject_oversized(newtext, oversized)
+ if not Key.shift() then
+ local accepted
+ if buf.loaded then
+ accepted = self:accept_block()
+ else
+ accepted = self:_handle_submit(add)
end
- return
+ if not accepted then return end
end
+ return
+ end
- local _, n = buf:replace_content(approved)
- self:save(buf)
- self.view:refresh()
- self:_move_sel('down', n)
+ if force_accept
+ or (not Key.ctrl()
+ and not Key.shift()
+ and not Key.alt()
+ and Key.is_enter(k)) then
+ --- replace only what was deliberately opened;
+ --- fresh text composed in navigation is inserted
+ local accepted
+ if buf.loaded then
+ accepted = self:accept_block()
+ else
+ accepted = self:_handle_submit(add)
+ end
+ if not accepted then block_input() end
+ end
+ end
+ --- open the selected block for editing (spec 2.2: Enter)
+ local function open()
+ self:open_block()
+ block_input()
+ end
+ --- Leave the open block through the gate (spec 2.4):
+ --- untouched leaves freely, changed is accepted and
+ --- written, invalid refuses and stays
+ --- @param dir VerticalDir
+ local function leave(dir)
+ local orig = buf:get_selected_text()
+ local clean = string.unlines(input:get_text())
+ == string.unlines(orig)
+ local sel0 = buf:get_selection()
+
+ if clean then
buf:clear_loaded()
input:clear()
+ self:set_mode('nav')
+ --- the cursor crossed the block's edge; sync the
+ --- model's line to it so the step leaves the block
+ local span = buf:get_selection_lines()
+ buf:set_active_line(
+ dir == 'up' and span.start or span.fin)
+ if buf:move_line(dir) then
+ self.view:get_current_buffer():follow_line()
+ open()
+ else
+ self:refuse()
+ end
+ block_input()
+ return
+ end
- load_selection()
-
- self:update_status()
+ submit(true)
+ if self.mode ~= 'nav' then
+ --- refused; the message is set, stay on the block
+ block_input()
+ return
+ end
+ --- accepted: open the neighbor, cursor on the near
+ --- line — downward its first, upward its last
+ --- (2.4.4). Acceptance may have split the block
+ --- into several, so step past all of them.
+ local target = sel0 - 1
+ if dir == 'down' then
+ target = sel0 + self.accepted_n
end
+ if target < 1 or target > buf:get_content_length() then
+ self:refuse()
+ block_input()
+ return
+ end
+ buf:set_selection(target)
+ local span = buf:get_selection_lines()
+ buf:set_active_line(
+ dir == 'down' and span.start or span.fin)
+ self.view:get_current_buffer():follow_line()
+ open()
+ block_input()
+ end
- if Key.ctrl()
- and not Key.shift()
- and not Key.alt()
- and Key.is_enter(k) then
- --- @param newtext Block[]
- local function add(newtext)
- if not bufv:is_selection_visible() then
- return bufv:follow_selection()
- end
+ --- Ctrl+K checkpoints, Ctrl+Shift+K restores (2.6);
+ --- a second press confirms, anything else cancels
+ local function checkpoint_key()
+ if not Key.ctrl() or k ~= 'k' then return end
+ local con = self.console
+ if not con then return end
+ block_input()
- local approved, oversized = analyze_input(newtext)
- if not approved then
- if oversized then
- reject_oversized(newtext, oversized)
- end
- return
- end
+ if self.mode == 'edit' then
+ --- accept the open block first, so the
+ --- checkpoint reflects the screen
+ submit(true)
+ if self.mode ~= 'nav' then return end
+ end
- local sel = buf:get_selection()
- local _, n = buf:insert_content(approved, sel)
- self:save(buf)
- self.view:refresh()
- self:_move_sel('down', n)
- buf:clear_loaded()
- input:clear()
+ local name = buf.name
+ local stamp = function(t)
+ return t and os.date('%Y-%m-%d %H:%M', t) or '?'
+ end
+ local cp_time = con:checkpoint_modtime(name)
- self:update_status()
+ if Key.shift() then
+ if not cp_time then
+ self:refuse({ 'no checkpoint to restore' })
+ return
end
-
- self:_handle_submit(add)
+ self.pending_confirm = 'restore'
+ input:set_error({ string.format(
+ 'restore from checkpoint %s over file %s?'
+ .. ' Confirm [Enter] / Cancel [Esc]',
+ stamp(cp_time), stamp(con:file_modtime(name))
+ ) })
+ return
end
- if not Key.ctrl()
- and not Key.shift()
- and not Key.alt()
- and Key.is_enter(k) then
- self:_handle_submit(replace)
+ if cp_time then
+ self.pending_confirm = 'overwrite'
+ input:set_error({ string.format(
+ 'checkpoint from %s exists.'
+ .. ' Confirm [Enter] / Cancel [Esc]',
+ stamp(cp_time)
+ ) })
+ return
end
+ con:write_checkpoint(name)
end
- local function load()
- if not Key.ctrl() and
- not Key.shift()
- and k == "escape" then
- load_selection()
- end
+
+ --- spec 2.3: Shift+Esc discards the edit; on an empty
+ --- input it leaves the buffer / editor
+ local function discard()
if not Key.ctrl() and
Key.shift() and
k == "escape" then
- load_selection(true)
+ if is_empty and self.mode == 'nav' then
+ self:close_buffer()
+ block_input()
+ return
+ end
+ self:discard_edit()
+ block_input()
end
end
+ --- spec 2.7: Ctrl+Delete drops the block in
+ --- navigation; while editing it is the widget's
+ --- delete-next-word
local function delete()
- if Key.ctrl() then
- if k == "delete"
- or (k == "y" and is_empty) then
- delete_block()
- block_input()
- end
+ if self.mode ~= 'nav' then return end
+ --- bare Delete joins in with 1.1: the deletion is
+ --- undoable now, which is what gated it (2.7)
+ if k == "delete" then
+ delete_block()
+ block_input()
end
end
local function navigate()
- -- move selection
- if Key.ctrl() then
+ -- peek: the view moves, the selection stays (2.2).
+ -- Alt-* in both modes; block moves live in the
+ -- reorder mode (Ctrl+M) only, and the line swap is
+ -- gone with them — Alt is scrolling, nothing else
+ if Key.alt() then
if k == "up" then
- self:_move_sel('up')
- block_input()
+ self:_scroll('up', false, 1)
end
if k == "down" then
- self:_move_sel('down')
- block_input()
+ self:_scroll('down', false, 1)
+ end
+ if k == "pageup" then
+ self:_scroll('up', false)
+ end
+ if k == "pagedown" then
+ self:_scroll('down', false)
+ end
+ --- left/right double the page peek: PgUp/PgDn is
+ --- a four-key chord on the device keyboard
+ if k == "left" then
+ self:_scroll('up', false)
+ end
+ if k == "right" then
+ self:_scroll('down', false)
end
+ if k == "home" then
+ self:_scroll('up', true)
+ end
+ if k == "end" then
+ self:_scroll('down', true)
+ end
+ block_input()
+ return
+ end
+
+ -- move selection
+ if Key.ctrl() then
+ if self.mode == 'edit' then
+ --- spec 2.7: accept + block-wise move
+ if k == "up" then
+ leave('up')
+ end
+ if k == "down" then
+ leave('down')
+ end
+ else
+ if k == "up" then
+ self:_jump_block('up')
+ block_input()
+ end
+ if k == "down" then
+ self:_jump_block('down')
+ block_input()
+ end
+ end
+ elseif self.mode == 'nav' then
+ --- spec 2.7: bare Home/End reach the file's first
+ --- and last line; Ctrl+Home/End belong to the
+ --- input widget while editing
if k == "home" then
self:_move_sel('up', nil, true)
+ block_input()
end
if k == "end" then
self:_move_sel('down', nil, true)
+ block_input()
end
- else
- if k == "up" and at_limit_start then
- self:_move_sel('up')
+ --- spec 2.2: bare arrows move by line, bare
+ --- pages by a page, Ctrl+arrows (above) by block
+ if k == "up" then
+ self:_move_line('up')
block_input()
end
- if k == "down" and at_limit_end then
- self:_move_sel('down')
+ if k == "down" then
+ self:_move_line('down')
block_input()
end
+ if k == "pageup" then
+ self:_move_line_page('up')
+ block_input()
+ end
+ if k == "pagedown" then
+ self:_move_line_page('down')
+ block_input()
+ end
+ elseif self.mode == 'edit' then
+ --- crossing the block's edge leaves through the
+ --- gate (2.4); inside, arrows stay in the input
+ if k == "up" and at_limit_start then
+ leave('up')
+ end
+ if k == "down" and at_limit_end then
+ leave('down')
+ end
end
-- scroll
- if not Key.shift()
+ if Key.ctrl() and not Key.shift()
and k == "pageup" then
- self:_scroll('up', Key.ctrl())
+ self:_scroll('up', true)
end
- if not Key.shift()
+ if Key.ctrl() and not Key.shift()
and k == "pagedown" then
- self:_scroll('down', Key.ctrl())
+ self:_scroll('down', true)
end
if Key.shift()
and k == "pageup" then
@@ -780,25 +1452,28 @@ function EditorController:_normal_mode_keys(k)
self:_scroll('down', false, 1)
end
- -- step into
- if Key.ctrl() then
- if k == "o" then
+ -- step into (spec 2.7: Ctrl+J "jump"; Ctrl+O is
+ -- left free for a conventional "open file")
+ if Key.ctrl() and not Key.alt() then
+ if k == "j" then
self:follow_require()
end
end
end
- local function clear()
- if Key.ctrl() and k == "w" then
- buf:clear_loaded()
- input:clear()
- end
- end
+ local plain_enter = Key.is_enter(k)
+ and not Key.ctrl()
+ and not Key.shift()
+ and not Key.alt()
- submit()
- load()
+ if is_empty and plain_enter then
+ if self.mode == 'nav' then open() end
+ else
+ submit()
+ end
+ checkpoint_key()
+ discard()
delete()
navigate()
- clear()
if passthrough then
input:keypressed(k)
@@ -808,6 +1483,31 @@ end
--- @param k string
function EditorController:keypressed(k)
self.input:update_view()
+ if self.pending_confirm then
+ --- dialogs are repeat-proof by construction: the
+ --- confirming key differs from the invoking one, so
+ --- key repeat lands on the idempotent cancel.
+ --- Enter or Space confirms, everything else cancels
+ if Key.is_enter(k) or k == 'space' then
+ local act = self.pending_confirm
+ self.pending_confirm = nil
+ self.input:clear_error()
+ self._swallow_glyph = true
+ return self:_confirm(act)
+ end
+ self.pending_confirm = nil
+ self.input:clear_error()
+ return
+ end
+ --- a plain error message closes on Enter, Esc or
+ --- Shift+Esc without re-submitting or leaving; any
+ --- printable closes it via textinput and types
+ if self.input:has_error() and is_normal(self.mode) then
+ if Key.is_enter(k) or k == 'escape' then
+ self.input:clear_error()
+ return
+ end
+ end
local mode = self.mode
if Key.ctrl() then
diff --git a/src/controller/searchController.lua b/src/controller/searchController.lua
index 5f2806b4..3907bf6c 100644
--- a/src/controller/searchController.lua
+++ b/src/controller/searchController.lua
@@ -48,7 +48,15 @@ end
function SearchController:update_results()
local kws = self.input:get_text()[1]
+ local had = #(self.model.resultset)
self.model:narrow(kws)
+ --- resultset, not get_results(): the latter is only
+ --- the visible slice
+ if #(self.model.resultset) == 0 and had > 0 then
+ --- the search text matches nothing (spec 2.4.3:
+ --- one sound for every refused action)
+ require("util.audio").knock()
+ end
end
---------------------------
diff --git a/src/controller/userInputController.lua b/src/controller/userInputController.lua
index ed087f7f..2ca48c5a 100644
--- a/src/controller/userInputController.lua
+++ b/src/controller/userInputController.lua
@@ -235,16 +235,29 @@ function UserInputController:keypressed(k)
-- action categories
local function removers()
+ local editing = input.editing
if k == "backspace" then
- input:backspace()
+ --- word-wise deletion is the editor's 2.7; the
+ --- plain widget keeps the plain backspace
+ if Key.ctrl() and editing then
+ input:backspace_word()
+ else
+ input:backspace()
+ end
end
if k == "delete" then
input:delete()
end
if Key.ctrl() then
if k == "y" then
+ --- unreachable in the editor: its controller
+ --- takes Ctrl+Y for redo before the widget
input:delete_line()
end
+ if k == "w" and editing then
+ --- readline's synonym, per the editor spec 2.7
+ input:backspace_word()
+ end
end
end
local function vertical()
@@ -273,21 +286,21 @@ function UserInputController:keypressed(k)
input:cursor_right()
end
- if not Key.alt()
- and k == "home" then
- input:jump_home()
- end
- if not Key.alt()
- and k == "end" then
- input:jump_end()
- end
- if Key.alt()
- and k == "home" then
- input:jump_line_start()
+ --- spec 2.7: bare Home/End are line-scoped; the
+ --- jump over the whole block is Ctrl+Home/End
+ if k == "home" then
+ if Key.ctrl() then
+ input:jump_home()
+ else
+ input:jump_line_start()
+ end
end
- if Key.alt()
- and k == "end" then
- input:jump_line_end()
+ if k == "end" then
+ if Key.ctrl() then
+ input:jump_end()
+ else
+ input:jump_line_end()
+ end
end
end
local function newline()
diff --git a/src/model/editor/bufferModel.lua b/src/model/editor/bufferModel.lua
index a809e0f5..52a59d18 100644
--- a/src/model/editor/bufferModel.lua
+++ b/src/model/editor/bufferModel.lua
@@ -53,7 +53,7 @@ local function new(
if _content:last() ~= '' then
_content:push('')
end
- sel = #_content
+ sel = 1
end
--- only passing this around so the linter shuts up about nil
--- @param chk function
@@ -61,8 +61,7 @@ local function new(
ct = 'lua'
local ok, blocks = chk(lines)
if ok then
- local len = #blocks
- sel = len
+ sel = 1
else
readonly = true
sel = 1
@@ -91,7 +90,10 @@ local function new(
revmap = {},
semantic = semantic,
selection = sel,
- readonly = readonly
+ active_line = 1,
+ readonly = readonly,
+ history = {},
+ redo_history = {}
}
local id = tostring(self):gsub('table: ', '')
self.id = id
@@ -109,6 +111,7 @@ end
--- @field content_type ContentType
--- @field save_file function
--- @field selection integer
+--- @field active_line integer --- source line inside the selection
--- @field loaded integer?
--- @field readonly boolean
--- @field semantic BufferSemanticInfo?
@@ -130,6 +133,108 @@ function BufferModel:get_id()
return self.id
end
+--- The block-level undo (1.1): a 32-step ring of file
+--- operations. A step is a trimmed diff — the common
+--- prefix and suffix of the file before/after are cut,
+--- so what remains is exactly the affected line range,
+--- whatever the operation was (accept, move, delete,
+--- insert, discard pair). Applying a step is a splice;
+--- the caller re-chunks and saves, the same path every
+--- write takes.
+BLOCK_HISTORY_CAP = 32
+
+--- @param before string[] --- file lines pre-operation
+--- @param after string[] --- file lines post-operation
+--- @param sel_b integer --- selection before
+--- @param sel_a integer --- selection after
+--- @return table? --- nil when nothing changed
+local function make_step(before, after, sel_b, sel_a)
+ local nb, na = #before, #after
+ local head = 0
+ while head < nb and head < na
+ and before[head + 1] == after[head + 1] do
+ head = head + 1
+ end
+ local tail = 0
+ while tail < nb - head and tail < na - head
+ and before[nb - tail] == after[na - tail] do
+ tail = tail + 1
+ end
+ if head + tail == nb and nb == na then return end
+ local removed, inserted = {}, {}
+ for i = head + 1, nb - tail do
+ table.insert(removed, before[i])
+ end
+ for i = head + 1, na - tail do
+ table.insert(inserted, after[i])
+ end
+ return {
+ start = head + 1,
+ removed = removed,
+ inserted = inserted,
+ sel_before = sel_b,
+ sel_after = sel_a,
+ }
+end
+
+--- @param before string[]
+--- @param after string[]
+--- @param sel_b integer
+--- @param sel_a integer
+function BufferModel:push_history(before, after, sel_b, sel_a)
+ local step = make_step(before, after, sel_b, sel_a)
+ if not step then return end
+ table.insert(self.history, step)
+ if #self.history > BLOCK_HISTORY_CAP then
+ table.remove(self.history, 1)
+ end
+ self.redo_history = {}
+end
+
+--- @private
+--- Splice a step's lines into the content and re-chunk
+--- @param start integer
+--- @param n_out integer --- lines to remove
+--- @param lines_in string[]
+function BufferModel:_splice(start, n_out, lines_in)
+ local lines = string.lines(
+ string.unlines(self:get_text_content()))
+ for _ = 1, n_out do
+ table.remove(lines, start)
+ end
+ for i = #lines_in, 1, -1 do
+ table.insert(lines, start, lines_in[i])
+ end
+ if self.content_type == 'lua' then
+ local _, blocks = self.chunker(lines)
+ self.content = blocks
+ else
+ self.content = Dequeue(lines)
+ end
+end
+
+--- @return table? --- the applied step, nil when empty
+function BufferModel:undo()
+ local n = #self.history
+ if n == 0 then return end
+ local step = self.history[n]
+ table.remove(self.history, n)
+ self:_splice(step.start, #step.inserted, step.removed)
+ table.insert(self.redo_history, step)
+ return step
+end
+
+--- @return table? --- the applied step, nil when empty
+function BufferModel:redo()
+ local n = #self.redo_history
+ if n == 0 then return end
+ local step = self.redo_history[n]
+ table.remove(self.redo_history, n)
+ self:_splice(step.start, #step.removed, step.inserted)
+ table.insert(self.history, step)
+ return step
+end
+
function BufferModel:analyze()
if self.content_type ~= 'lua' then return end
local lines = string.lines(self:get_text_content())
@@ -214,10 +319,12 @@ function BufferModel:move_selection(dir, by, warp, move)
if warp then
if dir == 'up' then
self.selection = 1
+ self:clamp_active_line()
return true
end
if dir == 'down' then
self.selection = last
+ self:clamp_active_line()
return true
end
return false
@@ -228,12 +335,14 @@ function BufferModel:move_selection(dir, by, warp, move)
if dir == 'up' then
if (cur - by) >= 1 then
self.selection = cur - by
+ self:clamp_active_line()
return true
end
end
if dir == 'down' then
if (cur + by) <= last + 1 then
self.selection = cur + by
+ self:clamp_active_line()
return true
end
end
@@ -246,6 +355,7 @@ function BufferModel:set_selection(sel)
if not sel or sel < 1 then sel = 1 end
if sel > max then sel = max end
self.selection = sel
+ self:clamp_active_line()
end
--- Get index of selected line/block
@@ -281,6 +391,98 @@ function BufferModel:get_selection_start_line()
return self.selection
end
+--- Source-line span of the selected block
+--- @return Range
+function BufferModel:get_selection_lines()
+ if self.content_type == 'lua' then
+ local b = self:_get_selected_block()
+ if b and b.pos then return b.pos end
+ end
+ return Range.singleton(self.selection)
+end
+
+--- @return integer
+function BufferModel:get_active_line()
+ return self.active_line
+end
+
+--- The block owning a source line
+--- @param ln integer
+--- @return integer? block index
+function BufferModel:block_at_line(ln)
+ if self.content_type ~= 'lua' then
+ if ln >= 1 and ln <= self:get_content_length() then
+ return ln
+ end
+ return nil
+ end
+ for i, b in ipairs(self.content) do
+ if b.pos and b.pos:inc(ln) then return i end
+ end
+ return nil
+end
+
+--- @param ln integer
+function BufferModel:set_active_line(ln)
+ self.active_line = ln
+ self:clamp_active_line()
+end
+
+--- Pull the active line into the selected block
+function BufferModel:clamp_active_line()
+ local span = self:get_selection_lines()
+ local ln = self.active_line
+ if ln < span.start or ln > span.fin then
+ self.active_line = span.start
+ end
+end
+
+--- Block-wise movement of the active line (spec 2.2):
+--- down lands on the next block's first line; up lands
+--- on the current block's first line, or on the
+--- previous block's when already there
+--- @param dir VerticalDir
+--- @return boolean moved
+function BufferModel:jump_block(dir)
+ local span = self:get_selection_lines()
+ if dir == 'up' and self.active_line > span.start then
+ self.active_line = span.start
+ return true
+ end
+ if not self:move_selection(dir) then return false end
+ self.active_line = self:get_selection_lines().start
+ return true
+end
+
+--- Move the active line, crossing block boundaries
+--- @param dir VerticalDir
+--- @return boolean moved
+function BufferModel:move_line(dir)
+ local span = self:get_selection_lines()
+ local ln = self.active_line
+ if dir == 'up' then
+ if ln > span.start then
+ self.active_line = ln - 1
+ return true
+ end
+ if self:move_selection('up') then
+ self.active_line = self:get_selection_lines().fin
+ return true
+ end
+ end
+ if dir == 'down' then
+ if ln < span.fin then
+ self.active_line = ln + 1
+ return true
+ end
+ if self:move_selection('down') then
+ self.active_line = self:get_selection_lines().start
+ return true
+ end
+ end
+ return false
+end
+
--- Return the selection as string array
--- @return string[]
function BufferModel:get_selected_text()
@@ -331,6 +533,11 @@ function BufferModel:delete_selected_text()
self.content:remove(sel)
end
self:_text_change()
+ --- the content shrank under the selection, so the
+ --- active line still points into the block that was
+ --- just removed; left stale it drags the cursor
+ --- outside whatever is opened next
+ self:clamp_active_line()
end
--- @param t string[]|Block[]
diff --git a/src/model/editor/editorModel.lua b/src/model/editor/editorModel.lua
index e5e80ca6..c3304089 100644
--- a/src/model/editor/editorModel.lua
+++ b/src/model/editor/editorModel.lua
@@ -11,7 +11,8 @@ local class = require('util.class')
--- @field cfg Config
EditorModel = class.create(function(cfg)
return {
- input = UserInputModel(cfg, LuaEval()),
+ input = UserInputModel(cfg, LuaEval(),
+ false, nil, true),
buffers = Dequeue.new({}, 'BufferModel'),
search = Search(cfg),
cfg = cfg,
diff --git a/src/model/input/editHistory.lua b/src/model/input/editHistory.lua
new file mode 100644
index 00000000..6faf28af
--- /dev/null
+++ b/src/model/input/editHistory.lua
@@ -0,0 +1,91 @@
+local class = require('util.class')
+
+--- The text-level undo of the open block (1.1). Born when
+--- the block opens, dies when it closes; never touches the
+--- file. Snapshots are taken before a mutation; consecutive
+--- same-kind edits at the expected cursor coalesce into one
+--- step, so undo removes a typed word, not a letter.
+--- @class EditHistory
+--- @field cap integer
+--- @field steps table[] --- snapshots {text, cursor}
+--- @field redo_steps table[]
+--- @field last_kind string?
+--- @field last_cursor table? --- {l, c} after the last edit
+EditHistory = class.create(function(cap)
+ return {
+ cap = cap,
+ steps = {},
+ redo_steps = {},
+ last_kind = nil,
+ last_cursor = nil,
+ }
+end)
+
+--- Forget everything (the block closed or was replaced)
+function EditHistory:reset()
+ self.steps = {}
+ self.redo_steps = {}
+ self.last_kind = nil
+ self.last_cursor = nil
+end
+
+--- @param l integer
+--- @param c integer
+--- @return boolean --- the edit continues the previous one
+function EditHistory:_continues(l, c)
+ local lc = self.last_cursor
+ return lc ~= nil and lc.l == l and lc.c == c
+end
+
+--- Record the state before a mutation
+--- @param snapshot table --- {text: string[], cursor: {l,c}}
+--- @param kind string --- 'insert'|'remove'|'paste'|...
+--- @param boundary boolean --- force a new step
+function EditHistory:record(snapshot, kind, boundary)
+ local c = snapshot.cursor
+ local coalesce = not boundary
+ and kind == self.last_kind
+ and self:_continues(c.l, c.c)
+ if not coalesce then
+ table.insert(self.steps, snapshot)
+ if #self.steps > self.cap then
+ table.remove(self.steps, 1)
+ end
+ end
+ self.redo_steps = {}
+ self.last_kind = kind
+end
+
+--- The cursor where the last mutation ended; the next
+--- edit coalesces only if it starts here
+--- @param l integer
+--- @param c integer
+function EditHistory:note_cursor(l, c)
+ self.last_cursor = { l = l, c = c }
+end
+
+--- @param current table --- snapshot to park for redo
+--- @return table? --- the snapshot to restore
+function EditHistory:undo(current)
+ local n = #self.steps
+ if n == 0 then return end
+ table.insert(self.redo_steps, current)
+ local snap = self.steps[n]
+ table.remove(self.steps, n)
+ self.last_kind = nil
+ self.last_cursor = nil
+ return snap
+end
+
+--- @param current table --- snapshot to park for undo
+--- @return table? --- the snapshot to restore
+function EditHistory:redo(current)
+ local n = #self.redo_steps
+ if n == 0 then return end
+ table.insert(self.steps, current)
+ local snap = self.redo_steps[n]
+ table.remove(self.redo_steps, n)
+ self.last_kind = nil
+ self.last_cursor = nil
+ return snap
+end
diff --git a/src/model/input/userInputModel.lua b/src/model/input/userInputModel.lua
index a376c7f4..37ef7752 100644
--- a/src/model/input/userInputModel.lua
+++ b/src/model/input/userInputModel.lua
@@ -1,6 +1,7 @@
require("model.input.inputText")
require("model.input.selection")
require("model.input.history")
+require("model.input.editHistory")
require("model.lang.lua.error")
require("view.editor.visibleContent")
@@ -15,6 +16,7 @@ require("util.lua")
--- @field oneshot boolean
--- @field entered InputText
--- @field history History
+--- @field edit_history EditHistory
--- @field evaluator Evaluator
--- @field cursor Cursor
--- @field error string[]?
@@ -44,11 +46,18 @@ UserInputModel = class.create()
--- @param eval Evaluator
--- @param oneshot boolean?
--- @param custom_label string?
-function UserInputModel.new(cfg, eval, oneshot, custom_label)
+--- @param editing boolean? --- the editor's rich input:
+--- word deletion (2.7) and the text-level undo (1.1).
+--- Off everywhere else — the console, project inputs and
+--- search keep the plain widget.
+function UserInputModel.new(cfg, eval, oneshot, custom_label,
+ editing)
local self = setmetatable({
oneshot = oneshot,
+ editing = editing or false,
entered = InputText(),
history = History(cfg.input_history),
+ edit_history = EditHistory(32),
evaluator = eval,
cursor = Cursor(),
selection = InputSelection(),
@@ -100,9 +109,73 @@ end
----------------
--- @param text string
+--- @private
+--- @return table --- {text, cursor} for the edit history
+function UserInputModel:_edit_snapshot()
+ local cl, cc = self:get_cursor_pos()
+ return {
+ text = table.clone(self:get_text()),
+ cursor = { l = cl, c = cc },
+ }
+end
+
+--- @private
+--- Record the pre-mutation state in the edit history
+--- @param kind string
+--- @param boundary boolean?
+function UserInputModel:_record_edit(kind, boundary)
+ if not self.editing then return end
+ self.edit_history:record(
+ self:_edit_snapshot(), kind, boundary or false)
+end
+
+--- @private
+--- Remember where the mutation left the cursor
+function UserInputModel:_note_edit()
+ if not self.editing then return end
+ local cl, cc = self:get_cursor_pos()
+ self.edit_history:note_cursor(cl, cc)
+end
+
+--- Undo one edit step inside the open block (1.1)
+--- @return boolean --- false when there is nothing to undo
+function UserInputModel:undo_edit()
+ local snap = self.edit_history:undo(self:_edit_snapshot())
+ if not snap then return false end
+ self:_apply_edit_snapshot(snap)
+ return true
+end
+
+--- Redo one edit step
+--- @return boolean --- false when there is nothing to redo
+function UserInputModel:redo_edit()
+ local snap = self.edit_history:redo(self:_edit_snapshot())
+ if not snap then return false end
+ self:_apply_edit_snapshot(snap)
+ return true
+end
+
+--- @private
+--- @param snap table
+function UserInputModel:_apply_edit_snapshot(snap)
+ self.entered = InputText(table.clone(snap.text))
+ self:text_change()
+ self:move_cursor(snap.cursor.l, snap.cursor.c)
+ self:clear_selection()
+end
+
function UserInputModel:add_text(text)
if type(text) == 'string' then
text = sanitize_utf8(text)
+ local single = string.ulen(text) == 1
+ if single then
+ --- a whitespace starts a new step, so undo eats
+ --- word by word, not letter by letter
+ self:_record_edit('insert',
+ string.match(text, '^%s$') ~= nil)
+ else
+ self:_record_edit('paste', true)
+ end
self:pop_selected_text()
local sl, cc = self:get_cursor_pos()
local cur_line = self:get_text_line(sl)
@@ -134,12 +207,16 @@ function UserInputModel:add_text(text)
self:move_cursor(last_line_i, string.ulen(ll) + 1)
end
self:text_change()
+ self:_note_edit()
end
end
--- @param text str
--- @param keep_cursor boolean
function UserInputModel:set_text(text, keep_cursor)
+ --- programmatic content is a new baseline: the text
+ --- level lives only inside one open block (1.1)
+ self.edit_history:reset()
if type(text) == 'string' then
text = sanitize_utf8(text)
local lines = string.lines(text)
@@ -196,6 +273,7 @@ end
--- @param ln integer?
function UserInputModel:delete_line(ln)
+ self:_record_edit('remove_line', true)
local n = self:get_n_text_lines()
if n == 1 then
self:clear_input()
@@ -203,6 +281,7 @@ function UserInputModel:delete_line(ln)
local l = ln or self:get_cursor_y()
self:_drop_text_line(l)
end
+ self:_note_edit()
end
--- @param text string
@@ -244,6 +323,7 @@ function UserInputModel:swap_lines(ln_that, ln_this)
end
function UserInputModel:line_feed()
+ self:_record_edit('newline', true)
local cl, cc = self:get_cursor_pos()
local cur_line = self:get_text_line(cl)
local pre, post = string.split_at(cur_line, cc)
@@ -251,6 +331,7 @@ function UserInputModel:line_feed()
self:insert_text_line(post, cl + 1)
self:move_cursor(cl + 1, 1)
self:text_change()
+ self:_note_edit()
end
--- @return InputText
@@ -305,6 +386,7 @@ function UserInputModel:paste(text)
end
function UserInputModel:backspace()
+ self:_record_edit('remove')
self:pop_selected_text()
local line = self:get_current_line()
local cl, cc = self:get_cursor_pos()
@@ -332,9 +414,48 @@ function UserInputModel:backspace()
self:cursor_left()
end
self:text_change()
+ self:_note_edit()
+end
+
+--- Start of the word ending at column cc (spec 2.7:
+--- Ctrl+Backspace / Ctrl+W). Whitespace before the
+--- cursor is eaten with the word, as readline does.
+--- @param line string
+--- @param cc integer --- cursor column
+--- @return integer --- the column the word starts at
+local function word_start(line, cc)
+ local i = cc - 1
+ while i > 1 and string.usub(line, i - 1, i - 1) == ' ' do
+ i = i - 1
+ end
+ while i > 1 do
+ local ch = string.usub(line, i - 1, i - 1)
+ if ch == ' ' then break end
+ i = i - 1
+ end
+ return i
+end
+
+--- Delete the word before the cursor; at the line's
+--- start it falls back to joining lines
+function UserInputModel:backspace_word()
+ self:_record_edit('remove_word', true)
+ self:pop_selected_text()
+ local line = self:get_current_line()
+ local cl, cc = self:get_cursor_pos()
+ if cc == 1 then return self:backspace() end
+
+ local ws = word_start(line, cc)
+ local pre = string.usub(line, 1, ws - 1)
+ local post = string.usub(line, cc)
+ self:_set_text_line(pre .. post, cl, true)
+ self:move_cursor(cl, ws)
+ self:text_change()
+ self:_note_edit()
end
function UserInputModel:delete()
+ self:_record_edit('remove')
self:pop_selected_text()
local line = self:get_current_line()
local cl, cc = self:get_cursor_pos()
@@ -359,6 +480,7 @@ function UserInputModel:delete()
local nval = (pre or '') .. (post or '')
self:_set_text_line(nval, cl, true)
self:text_change()
+ self:_note_edit()
end
function UserInputModel:clear_input()
diff --git a/src/util/filesystem.lua b/src/util/filesystem.lua
index 70f48b46..1e7e3746 100644
--- a/src/util/filesystem.lua
+++ b/src/util/filesystem.lua
@@ -202,6 +202,64 @@ if love and not TESTING then
return FS.read(path, true) or FS.read(path)
end
+ --- Durability helpers (bionic/glibc via LuaJIT FFI).
+ --- FS.write is async (see its contract below); the
+ --- editor accept path opts into durability with
+ --- FS.fsync, and lifecycle handlers use FS.sync as a
+ --- cheap whole-filesystem net.
+ local _durable = (function()
+ local ffi_ok, ffi = pcall(require, 'ffi')
+ if not ffi_ok then return nil end
+ pcall(ffi.cdef, [[
+ int open(const char* path, int flags);
+ int close(int fd);
+ int fsync(int fd);
+ void sync(void);
+ ]])
+ local O_RDONLY = 0
+ return {
+ file = function(path)
+ local fd = ffi.C.open(path, O_RDONLY)
+ if fd < 0 then return false end
+ local r = ffi.C.fsync(fd)
+ ffi.C.close(fd)
+ return r == 0
+ end,
+ all = function() ffi.C.sync() end,
+ }
+ end)()
+
+ --- Flush one file's data through to stable storage.
+ --- The editor accept path calls this after a save
+ --- (spec 2.6 "written immediately"). Do NOT add it to
+ --- FS.write: bulk deploy/clone and the user-facing
+ --- writefile must stay async. Best-effort — returns
+ --- false when the platform lacks the syscall or the
+ --- path cannot be opened.
+ --- @param path string
+ --- @return boolean durable
+ function FS.fsync(path)
+ if not _durable then return false end
+ local ok, res = pcall(_durable.file, path)
+ return (ok and res) or false
+ end
+
+ --- Flush all pending writes filesystem-wide in one
+ --- syscall. Cheap broad net for background/quit; does
+ --- not cover a force-stop mid-edit (that is FS.fsync).
+ --- @return boolean ran
+ function FS.sync()
+ if not _durable then return false end
+ return pcall(_durable.all)
+ end
+
+ --- Write data to path, overwriting. Async by default:
+ --- the bytes reach the OS but are NOT flushed to stable
+ --- storage, so a power-cut or SIGKILL can lose them
+ --- while an (exfat dirsync) directory entry persists.
+ --- Callers needing durability opt in via FS.fsync(path)
+ --- after a successful write — the editor accept path
+ --- does; bulk deploy/clone and writefile do not.
--- @param path string
--- @param data string
--- @return boolean success
diff --git a/src/view/editor/bufferView.lua b/src/view/editor/bufferView.lua
index bd134042..52f267a5 100644
--- a/src/view/editor/bufferView.lua
+++ b/src/view/editor/bufferView.lua
@@ -122,7 +122,9 @@ end
--- @return integer
function BufferView:get_max_size()
- return self.LINES
+ --- the block limit is the input view height (spec: 14),
+ --- not the buffer viewport height
+ return self.cfg.input_max
end
--- @param moved integer?
@@ -268,6 +270,35 @@ function BufferView:follow_selection()
end
end
+--- Source line at a vertical pixel position, if any
+--- @param y number
+--- @return integer? ln
+function BufferView:line_at(y)
+ local fh = self.cfg.fh
+ local row = math.floor(y / fh) + 1
+ local wrapped = self.content.offset + row
+ if not self.content.range:inc(wrapped) then
+ return nil
+ end
+ local rev = self.content.wrap_reverse
+ return rev and rev[wrapped]
+end
+
+--- Scroll just enough to keep the active line visible
+function BufferView:follow_line()
+ local al = self.buffer:get_active_line()
+ local wl = self.content.wrap_forward[al]
+ if not wl then return end
+ local r = self.content.range
+ local first = wl[1]
+ local last = wl[#wl]
+ if first < r.start then
+ self:scroll('up', r.start - first)
+ elseif last > r.fin then
+ self:scroll('down', last - r.fin)
+ end
+end
+
--------------
--- draw ---
--------------
@@ -329,6 +360,19 @@ function BufferView:draw(special)
end
end
end
+
+ --- the active line, a shade brighter inside the block
+ local al = self.buffer:get_active_line()
+ local wl = self.content.wrap_forward[al]
+ if wl then
+ gfx.setColor(Color.with_alpha(colors.fg, .125))
+ for _, v in ipairs(wl) do
+ if self.content.range:inc(v) then
+ local l_y = (v - off - 1) * fh
+ gfx.rectangle('fill', 0, l_y, width, fh)
+ end
+ end
+ end
end
local draw_text = function()
diff --git a/src/view/input/statusline.lua b/src/view/input/statusline.lua
index 71fabfd2..a57eb6fa 100644
--- a/src/view/input/statusline.lua
+++ b/src/view/input/statusline.lua
@@ -152,7 +152,6 @@ function Statusline:draw(status, start_y)
gfx.setColor(colors.fg)
gfx.print(more_b, s_mb, start_text.y)
-- filename
- gfx.setColor(Color[Color.white])
gfx.print(custom.name, s_n, start_text.y)
else
--- normal statusline
diff --git a/src/view/input/userInputView.lua b/src/view/input/userInputView.lua
index a9375317..da28f432 100644
--- a/src/view/input/userInputView.lua
+++ b/src/view/input/userInputView.lua
@@ -258,6 +258,16 @@ function UserInputView:render_error(err_text)
drawBackground()
gfx.setColor(colors.input.error)
+ if self.controller.model.editing then
+ --- the refusal frame is the editor's 2.4.3; the
+ --- console and project inputs keep their plain
+ --- error text
+ gfx.rectangle("line",
+ 1,
+ fh + 1,
+ drawableWidth - 2,
+ apparentHeight * fh - 2)
+ end
for l, str in ipairs(err_text) do
local breaks = 0 -- starting height is already calculated
diff --git a/tests/editor/buffer_spec.lua b/tests/editor/buffer_spec.lua
index 4d5d615d..a09d4fac 100644
--- a/tests/editor/buffer_spec.lua
+++ b/tests/editor/buffer_spec.lua
@@ -177,6 +177,8 @@ print(sierpinski(4))]])
''
}
it('insert newline', function()
+ --- buffers open at the top; this test works the end
+ buffer:move_selection('down', nil, true)
assert.same(#turtle_doc + 1, buffer:get_selection())
buffer:replace_content({ qed })
assert.same(#turtle_doc + 1, buffer:get_selection())
@@ -235,9 +237,109 @@ print(sierpinski(4))]])
assert.same(turtle, buffer:get_text_content())
- assert.same(n_blocks, buffer:get_selection())
+ assert.same(1, buffer:get_selection())
local ln = buffer:get_selection_start_line()
- assert.same(68, ln)
+ assert.same(1, ln)
+ buffer:move_selection('down', nil, true)
+ assert.same(n_blocks, buffer:get_selection())
+ assert.same(68, buffer:get_selection_start_line())
+ end)
+
+ describe('line navigation', function()
+ local lnbuf
+ lazy_setup(function()
+ lnbuf = BufferModel('main.lua', turtle,
+ noop, chunker, hl)
+ end)
+
+ it('starts at the top', function()
+ assert.same(1, lnbuf:get_selection())
+ assert.same(1, lnbuf:get_active_line())
+ end)
+
+ it('walks lines within a block', function()
+ local span = lnbuf:get_selection_lines()
+ for _ = span.start, span.fin - 1 do
+ assert.is_true(lnbuf:move_line('down'))
+ end
+ --- still inside block 1, on its last line
+ assert.same(1, lnbuf:get_selection())
+ assert.same(span.fin, lnbuf:get_active_line())
+ end)
+
+ it('crosses the boundary downwards', function()
+ assert.is_true(lnbuf:move_line('down'))
+ assert.same(2, lnbuf:get_selection())
+ local span = lnbuf:get_selection_lines()
+ assert.same(span.start, lnbuf:get_active_line())
+ end)
+
+ it('crosses back upwards', function()
+ assert.is_true(lnbuf:move_line('up'))
+ assert.same(1, lnbuf:get_selection())
+ local span = lnbuf:get_selection_lines()
+ assert.same(span.fin, lnbuf:get_active_line())
+ end)
+
+ it('clamps on block jumps', function()
+ lnbuf:move_selection('down', nil, true)
+ local span = lnbuf:get_selection_lines()
+ assert.same(span.start, lnbuf:get_active_line())
+ lnbuf:set_selection(3)
+ span = lnbuf:get_selection_lines()
+ assert.same(span.start, lnbuf:get_active_line())
+ end)
+
+ it('jumps blocks per 2.2', function()
+ local jb = BufferModel('main.lua', turtle,
+ noop, chunker, hl)
+ --- down: the next block's first line
+ assert.is_true(jb:jump_block('down'))
+ assert.same(2, jb:get_selection())
+ local sp = jb:get_selection_lines()
+ assert.same(sp.start, jb:get_active_line())
+
+ --- inside a multi-line block, up returns to
+ --- its first line
+ local multi
+ for i = 1, jb:get_content_length() do
+ jb:set_selection(i)
+ if jb:get_selection_lines():len() > 1 then
+ multi = i
+ break
+ end
+ end
+ assert.truthy(multi, 'fixture has a big block')
+ jb:set_selection(multi)
+ local spm = jb:get_selection_lines()
+ jb:set_active_line(spm.fin)
+ assert.is_true(jb:jump_block('up'))
+ assert.same(multi, jb:get_selection())
+ assert.same(spm.start, jb:get_active_line())
+
+ --- already there: up goes to the block before
+ assert.is_true(jb:jump_block('up'))
+ assert.same(multi - 1, jb:get_selection())
+ assert.same(jb:get_selection_lines().start,
+ jb:get_active_line())
+
+ --- walking up bottoms out at the first block
+ while jb:jump_block('up') do end
+ assert.same(1, jb:get_selection())
+ assert.same(1, jb:get_active_line())
+ end)
+
+ it('plaintext follows the selection', function()
+ local pb = BufferModel('notes.txt',
+ { 'one', 'two', 'three' }, noop)
+ assert.same(1, pb:get_active_line())
+ assert.is_true(pb:move_line('down'))
+ assert.same(2, pb:get_selection())
+ assert.same(2, pb:get_active_line())
+ assert.is_true(pb:move_line('up'))
+ assert.same(1, pb:get_selection())
+ assert.same(1, pb:get_active_line())
+ end)
end)
it('dropping blocks', function()
diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua
index 204b0096..be1b4308 100644
--- a/tests/editor/editor_spec.lua
+++ b/tests/editor/editor_spec.lua
@@ -80,10 +80,9 @@ describe('Editor #editor', function()
local sel = buffer:get_selection()
local sel_t = buffer:get_selected_text()
- --- default selection is at the end
- assert.same(#turtle_doc, sel)
- --- and it's an empty line, of course
- assert.same('', sel_t)
+ --- files open at the first line
+ assert.same(1, sel)
+ assert.same(turtle_doc[1], sel_t)
end)
end)
@@ -109,13 +108,17 @@ describe('Editor #editor', function()
local sel = buffer:get_selection()
local sel_t = buffer:get_selected_text()
- --- default selection is at the end
- assert.same(start_sel, sel)
- --- and it's an empty line, of course
- assert.same('', sel_t)
+ --- files open at the first line
+ assert.same(1, sel)
+ assert.same(turtle_doc[1], sel_t)
end)
it('interacts', function()
+ --- files open at the top; walk to the end first,
+ --- as these interactions historically assume it
+ for _ = 1, start_sel - 1 do
+ mock.keystroke('down', press)
+ end
--- select middle line
mock.keystroke('up', press)
assert.same(start_sel - 1, buffer:get_selection())
@@ -124,15 +127,18 @@ describe('Editor #editor', function()
local input = function()
return controller.input:get_text():items()
end
- mock.keystroke('escape', press)
+ mock.keystroke('return', press)
assert.same({ turtle_doc[2] }, input())
+ --- crossing the edge leaves through the gate:
+ --- the untouched block flows to the next line
mock.keystroke('end', press)
mock.keystroke('down', press)
assert.same(start_sel, buffer:get_selection())
- -- load the empty
- mock.keystroke('escape', press)
assert.same({ '' }, input())
- --- add text
+ --- drop it, compose fresh so the text inserts
+ mock.keystroke('S-escape', press)
+ assert.same({ '' }, input())
+ --- compose text (inserted before the empty)
controller:textinput('-')
controller:textinput('-')
controller:textinput(' ')
@@ -157,7 +163,7 @@ describe('Editor #editor', function()
mock.keystroke('up', press)
assert.same(start_sel, buffer:get_selection())
- --- replace
+ --- compose over it, then discard and reopen
controller:textinput('i')
controller:textinput('n')
controller:textinput('s')
@@ -165,7 +171,11 @@ describe('Editor #editor', function()
controller:textinput('r')
controller:textinput('t')
assert.same({ 'insert' }, input())
- mock.keystroke('escape', press)
+ --- the compose is dirty: the discard asks
+ mock.keystroke('S-escape', press)
+ --- Enter confirms (repeat-proof dialogs)
+ mock.keystroke('return', press)
+ mock.keystroke('return', press)
assert.same({ '-- test' }, input())
end)
end)
@@ -186,38 +196,71 @@ describe('Editor #editor', function()
local visible = bv.content
local scroll = bv.SCROLL_BY
+ --- files open at the top now; these specs assume
+ --- the historical EOF position, so walk down first
+ for _ = 1, #sierpinski do
+ controller:keypressed('down')
+ end
+
local off = #sierpinski - l + 1
+ bv:scroll_to(off)
local start_range = Range(off + 1, #sierpinski + 1)
+ local function peek(dir)
+ mock.keystroke('C-M-' .. dir, function(kk)
+ controller:keypressed(kk)
+ end)
+ end
it('loads', function()
- --- inital scroll is at EOF, meaning last l lines are visible
- --- plus the phantom line
+ --- selection is at EOF, view at the historical offset
+ assert.same(#sierpinski + 1, buf:get_selection())
assert.same(off, bv:get_offset())
assert.same(start_range, visible.range)
end)
+ it('follows the active line', function()
+ --- walk the line up out of the viewport
+ for _ = 1, l + 2 do
+ buf:move_line('up')
+ end
+ local al = buf:get_active_line()
+ assert.is_true(al < visible.range.start)
+ bv:follow_line()
+ assert.is_true(visible.range:inc(al))
+ --- and back down below it
+ for _ = 1, l + 4 do
+ buf:move_line('down')
+ end
+ bv:follow_line()
+ assert.is_true(
+ visible.range:inc(buf:get_active_line()))
+ --- restore the historical position for the
+ --- describes that follow
+ buf:move_selection('down', nil, true)
+ bv:scroll_to(off)
+ end)
local base = Range(1, l)
it('scrolls up', function()
- controller:keypressed('pageup')
+ peek('pageup')
assert.same(start_range:translate(-scroll), visible.range)
- controller:keypressed('pageup')
+ peek('pageup')
assert.same(start_range:translate(-scroll * 2), visible.range)
- controller:keypressed('pageup')
+ peek('pageup')
assert.same(start_range:translate(-scroll * 3), visible.range)
- controller:keypressed('pageup')
+ peek('pageup')
end)
it('tops out', function()
assert.same(base, visible.range)
end)
it('scrolls down', function()
- controller:keypressed('pagedown')
+ peek('pagedown')
assert.same(base:translate(scroll), visible.range)
- controller:keypressed('pagedown')
+ peek('pagedown')
assert.same(base:translate(scroll * 2), visible.range)
- controller:keypressed('pagedown')
+ peek('pagedown')
assert.same(base:translate(scroll * 3), visible.range)
- controller:keypressed('pagedown')
+ peek('pagedown')
assert.same(base:translate(scroll * 4), visible.range)
- controller:keypressed('pagedown')
+ peek('pagedown')
end)
it('bottoms out', function()
local limit = #sierpinski + visible.overscroll
@@ -245,131 +288,190 @@ describe('Editor #editor', function()
local visible = bv.content
local scroll = bv.SCROLL_BY
+ --- files open at the top now; these specs assume
+ --- the historical EOF position, so walk down first
+ for _ = 1, #sierpinski do
+ press('down')
+ end
+
local clen = visible:get_content_length()
local off = clen - l
+ bv:scroll_to(off)
local start_range = Range(off + 1, clen)
it('loads', function()
- --- inital scroll is at EOF, meaning last l lines are visible
- --- plus the phantom line
+ --- selection is at EOF, view at the historical offset
+ assert.same(#sierpinski + 1, buffer:get_selection())
assert.same(off, bv:get_offset())
assert.same(start_range, visible.range)
end)
local base = Range(1, l)
describe('scrolls', function()
it('scrolls up', function()
- mock.keystroke('pageup', press)
+ mock.keystroke('C-M-pageup', press)
assert.same(start_range:translate(-scroll), visible.range)
- mock.keystroke('pageup', press)
+ mock.keystroke('C-M-pageup', press)
assert.same(start_range:translate(-scroll * 2), visible.range)
- mock.keystroke('pageup', press)
+ mock.keystroke('C-M-pageup', press)
assert.same(start_range:translate(-scroll * 3), visible.range)
- mock.keystroke('pageup', press)
+ mock.keystroke('C-M-pageup', press)
assert.same(start_range:translate(-scroll * 4), visible.range)
end)
it('tops out', function()
- mock.keystroke('pageup', press)
+ mock.keystroke('C-M-pageup', press)
assert.same(base, visible.range)
end)
it('scrolls down', function()
- mock.keystroke('pagedown', press)
+ mock.keystroke('C-M-pagedown', press)
assert.same(base:translate(scroll), visible.range)
- mock.keystroke('pagedown', press)
+ mock.keystroke('C-M-pagedown', press)
assert.same(base:translate(scroll * 2), visible.range)
- mock.keystroke('pagedown', press)
+ mock.keystroke('C-M-pagedown', press)
assert.same(base:translate(scroll * 3), visible.range)
- mock.keystroke('pagedown', press)
+ mock.keystroke('C-M-pagedown', press)
assert.same(base:translate(scroll * 4), visible.range)
- mock.keystroke('pagedown', press)
+ mock.keystroke('C-M-pagedown', press)
assert.same(base:translate(scroll * 5), visible.range)
end)
it('bottoms out', function()
- mock.keystroke('pagedown', press)
- mock.keystroke('pagedown', press)
- mock.keystroke('pagedown', press)
+ mock.keystroke('C-M-pagedown', press)
+ mock.keystroke('C-M-pagedown', press)
+ mock.keystroke('C-M-pagedown', press)
local limit = clen + visible.overscroll
assert.same(Range(limit - l + 1, limit), visible.range)
end)
describe('moving the selection affects scrolling', function()
- local sel = buffer:get_selection()
- local sel_t = buffer:get_selected_text()
-
- --- default selection is at the end
- assert.same(#sierpinski + 1, sel)
- --- and it's an empty line, of course
- assert.same('', sel_t)
+ --- the walk left the selection at EOF
+ assert.same(#sierpinski + 1, buffer:get_selection())
+
+ local function line_visible()
+ local al = buffer:get_active_line()
+ local wl = visible.wrap_forward[al]
+ if not wl then return false end
+ for _, v in ipairs(wl) do
+ if visible.range:inc(v) then return true end
+ end
+ return false
+ end
it('from below', function()
- mock.keystroke('pageup', press)
+ --- scroll away, then a line move pulls it back
+ mock.keystroke('C-M-pageup', press)
mock.keystroke('up', press)
- --- it's now one above the starting range, the
- --- phantom line not visible
- -- assert.same(start_range:translate(-1), visible.range)
- mock.keystroke('pageup', press)
- mock.keystroke('down', press)
- --- after scrolling up and moving the sel back, we
- --- are back to the start
- --- TODO
- assert.same(Range(19, 24), visible.range)
- -- assert.same(start_range, visible.range)
+ assert.same(#sierpinski, buffer:get_selection())
+ assert.is_true(line_visible())
end)
it('to above', function()
- local srs = visible.range.start
- --- let's move up a screen's worth with the sel
+ --- walk a screenful up; the line stays in view
for _ = 1, l do
mock.keystroke('up', press)
+ assert.is_true(line_visible())
end
- local cs = bv:_get_wrapped_selection()[1][1]
- local d = cs - srs
- --- TODO
- -- assert.same(start_range:translate(d), visible.range)
- assert.same(start_range:translate(d + 3),
- visible.range)
- mock.keystroke('up', press)
- -- assert.same(start_range:translate(d - 1), visible.range)
- assert.same(start_range:translate(d + 2), visible.range)
end)
it('tops out', function()
- --- move up to the first line
for _ = 1, clen do
mock.keystroke('up', press)
end
- assert.same(base, visible.range)
+ assert.same(1, buffer:get_selection())
+ assert.same(1, buffer:get_active_line())
+ assert.same(1, visible.range.start)
end)
it('from above', function()
- mock.keystroke('pagedown', press)
- mock.keystroke('pagedown', press)
+ --- scroll away downwards, a line move follows
+ mock.keystroke('C-M-pagedown', press)
+ mock.keystroke('C-M-pagedown', press)
mock.keystroke('down', press)
- assert.same(base:translate(1), visible.range)
+ assert.same(2, buffer:get_selection())
+ assert.is_true(line_visible())
end)
it('to below', function()
- for _ = 2, l do
+ for _ = 1, l do
mock.keystroke('down', press)
+ assert.is_true(line_visible())
end
- mock.keystroke('pageup', press)
- mock.keystroke('down', press)
- local ws = bv:_get_wrapped_selection()[1]
- local cs = ws[#ws]
- --- TODO
- -- assert.same(Range(cs - l + 1, cs), visible.range)
- assert.same(Range(11, 16), visible.range)
end)
it('bottoms out', function()
- local s = buffer:get_selection()
- for _ = s, #sierpinski do
+ for _ = 1, clen do
mock.keystroke('down', press)
end
- assert.same(start_range, visible.range)
- mock.keystroke('down', press)
- mock.keystroke('down', press)
- assert.same(start_range:translate(3), visible.range)
+ --- capped at the phantom line past the end
+ local cap = buffer:get_selection()
mock.keystroke('down', press)
- mock.keystroke('down', press)
- assert.same(start_range:translate(3), visible.range)
+ assert.same(cap, buffer:get_selection())
end)
end)
end)
+ describe('peek and page moves', function()
+ it('peek scrolls, the selection stays', function()
+ mock.keystroke('end', press)
+ local sel = buffer:get_selection()
+ local r0 = visible.range.start
+ mock.keystroke('C-M-pageup', press)
+ assert.same(sel, buffer:get_selection())
+ assert.is_true(visible.range.start < r0)
+ mock.keystroke('C-M-up', press)
+ assert.same(sel, buffer:get_selection())
+ --- left/right double the page peek
+ local r1 = visible.range.start
+ mock.keystroke('C-M-right', press)
+ assert.same(sel, buffer:get_selection())
+ assert.is_true(visible.range.start > r1)
+ mock.keystroke('C-M-left', press)
+ assert.same(r1, visible.range.start)
+ end)
+ it('typing after a peek returns the view', function()
+ controller:textinput('x')
+ local al = buffer:get_active_line()
+ local wl = visible.wrap_forward[al]
+ local seen = false
+ for _, v in ipairs(wl) do
+ if visible.range:inc(v) then seen = true end
+ end
+ assert.is_true(seen)
+ --- the typed draft asks; confirm to discard
+ mock.keystroke('S-escape', press)
+ --- Enter confirms (repeat-proof dialogs)
+ mock.keystroke('return', press)
+ end)
+ it('a held chord glyph is dropped', function()
+ mock.keystroke('C-M-down', press, true)
+ controller:textinput('q')
+ assert.same({ '' }, controller.input:get_text())
+ mock.release_keys()
+ end)
+ it('bare pages move the active line', function()
+ mock.keystroke('home', press)
+ assert.same(1, buffer:get_active_line())
+ mock.keystroke('pagedown', press)
+ assert.same(1 + l, buffer:get_active_line())
+ assert.is_true(bv:is_selection_visible())
+ mock.keystroke('pageup', press)
+ assert.same(1, buffer:get_active_line())
+ --- restore the state the describes below assume
+ mock.keystroke('end', press)
+ mock.keystroke('down', press)
+ end)
+ end)
+
+ describe('Home/End reach the file edges (2.7)', function()
+ it('bare End goes to the last line', function()
+ mock.keystroke('home', press)
+ assert.same(1, buffer:get_selection())
+ mock.keystroke('end', press)
+ assert.same(#sierpinski + 1, buffer:get_selection())
+ end)
+ it('Ctrl+Home/End do not warp in nav', function()
+ mock.keystroke('home', press)
+ local sel = buffer:get_selection()
+ mock.keystroke('C-end', press)
+ assert.same(sel, buffer:get_selection())
+ --- restore what the describes below assume
+ mock.keystroke('end', press)
+ mock.keystroke('down', press)
+ end)
+ end)
+
describe('jumps', function()
local sel = table.clone(buffer:get_selection())
it('to top', function()
@@ -392,15 +494,14 @@ describe('Editor #editor', function()
mock.keystroke('up', press)
local sel = table.clone(buffer:get_selection())
it('to bottom', function()
- mock.keystroke('C-end', press)
- --- warps to bottom
- --- TODO
- -- assert.same(start_range, visible.range)
- assert.same(Range(19, 24), visible.range)
+ mock.keystroke('end', press)
+ --- warps to bottom, selection in view
+ assert.same(#sierpinski + 1, buffer:get_selection())
+ assert.is_true(bv:is_selection_visible())
-- assert.is_not.same(sel, buffer:get_selection())
end)
it('to top', function()
- mock.keystroke('C-home', press)
+ mock.keystroke('home', press)
--- warps to top
assert.same(base, visible.range)
assert.is_not.same(sel, buffer:get_selection())
@@ -409,25 +510,21 @@ describe('Editor #editor', function()
describe('input', function()
local inter = controller.input
it('loads', function()
- inter:add_text('asd')
local selected = buffer:get_selected_text()
- mock.keystroke('escape', press)
+ mock.keystroke('return', press)
assert.same(inter:get_text(), { selected })
end)
- it("doesn't clear on move", function()
- mock.keystroke('C-end', press)
- -- load the empty
- mock.keystroke('escape', press)
- assert.same({ '' }, inter:get_text())
+ it('flows to the neighbor on Ctrl+move', function()
+ --- Ctrl+arrow leaves through the gate; the
+ --- untouched block just opens the next one
+ mock.keystroke('C-down', press)
+ local now = buffer:get_selected_text()
+ assert.same({ now }, inter:get_text())
end)
- it('inserts', function()
- -- mock.keystroke('up', press)
- local prefix = 'asd '
- local selected = buffer:get_selected_text()
- inter:add_text(prefix)
+ it('discards', function()
+ --- the loaded text is unchanged, so no ask
mock.keystroke('S-escape', press)
- local res = string.join(inter:get_text())
- assert.same(prefix .. selected, res)
+ assert.same({ '' }, inter:get_text())
end)
end)
end)
@@ -435,6 +532,998 @@ describe('Editor #editor', function()
--- end plaintext
describe('structured (lua) works', function()
+ it('moves the block through the reorder mode', function()
+ --- Alt+arrows are scrolling now; blocks move on
+ --- Ctrl+M only
+ local controller, press = wire(TU.mock_view_cfg())
+ local save, savefile = TU.get_save_function(sierpinski)
+ controller:open('sierpinski.lua', sierpinski, save)
+ --- entering reorder saves the clipboard state
+ love.system = {
+ getClipboardText = function() return '' end,
+ setClipboardText = function() end,
+ }
+ local buffer = controller:get_active_buffer()
+ local first = buffer:get_selected_text()
+
+ mock.keystroke('C-m', press)
+ mock.keystroke('down', press)
+ mock.keystroke('return', press)
+ --- the block moved down, selection follows it,
+ --- the commit is written through
+ assert.same(2, buffer:get_selection())
+ assert.same(first, buffer:get_selected_text())
+ assert.same('', string.lines(savefile())[1])
+
+ mock.keystroke('C-m', press)
+ mock.keystroke('up', press)
+ mock.keystroke('return', press)
+ assert.same(1, buffer:get_selection())
+ assert.same(first, buffer:get_selected_text())
+ end)
+
+ it('Alt+arrows peek without moving', function()
+ local controller, press = wire(TU.mock_view_cfg())
+ local save = TU.get_save_function(sierpinski)
+ controller:open('sierpinski.lua', sierpinski, save)
+ local buffer = controller:get_active_buffer()
+ local bv = controller.view:get_current_buffer()
+
+ local sel0 = buffer:get_selection()
+ local r0 = bv.content:get_range().start
+ mock.keystroke('M-down', press)
+ assert.same(sel0, buffer:get_selection())
+ assert.is_true(bv.content:get_range().start > r0)
+ mock.keystroke('M-home', press)
+ assert.same(1, bv.content:get_range().start)
+ assert.same(sel0, buffer:get_selection())
+ end)
+
+ describe('checkpoints (2.6)', function()
+ require("tests.helpers.codesnippets")
+ local controller, press, buffer, inter
+ local calls, cp_time
+
+ before_each(function()
+ local f1 = mock_func_snippet('one')
+ controller, press = wire(TU.mock_view_cfg())
+ local save = TU.get_save_function(f1)
+ controller:open('main.lua', f1 .. '\n', save)
+ buffer = controller:get_active_buffer()
+ inter = controller.input
+ calls, cp_time = {}, nil
+ controller.console = {
+ checkpoint_modtime = function() return cp_time end,
+ file_modtime = function() return 1752480000 end,
+ write_checkpoint = function(_, name)
+ table.insert(calls, 'write:' .. name)
+ return true
+ end,
+ restore_checkpoint = function(_, name)
+ table.insert(calls, 'restore:' .. name)
+ return true
+ end,
+ _readfile = function() return 'x = 1' end,
+ }
+ end)
+
+ it('first checkpoint writes without asking', function()
+ mock.keystroke('C-k', press)
+ assert.same({ 'write:main.lua' }, calls)
+ assert.is_false(inter:has_error())
+ end)
+
+ it('an existing one asks, Enter confirms', function()
+ cp_time = 1752400000
+ mock.keystroke('C-k', press)
+ assert.same({}, calls)
+ assert.is_true(inter:has_error())
+ --- the invoking chord cancels (repeat-proof);
+ --- Enter confirms
+ mock.keystroke('C-k', press)
+ assert.same({}, calls)
+ mock.keystroke('C-k', press)
+ mock.keystroke('return', press)
+ assert.same({ 'write:main.lua' }, calls)
+ end)
+
+ it('any other key cancels the confirmation', function()
+ cp_time = 1752400000
+ mock.keystroke('C-k', press)
+ mock.keystroke('escape', press)
+ mock.keystroke('C-k', press)
+ --- back to asking, not writing
+ assert.same({}, calls)
+ assert.is_true(inter:has_error())
+ end)
+
+ it('restore asks and reloads the buffer', function()
+ cp_time = 1752400000
+ mock.keystroke('C-S-k', press)
+ assert.same({}, calls)
+ mock.keystroke('return', press)
+ assert.same({ 'restore:main.lua' }, calls)
+ --- buffer reloaded from the checkpoint content
+ --- (reload replaces the model; re-fetch it)
+ local fresh = controller:get_active_buffer()
+ assert.same('x = 1',
+ fresh:get_text_content()[1])
+ end)
+
+ it('restore without a checkpoint refuses', function()
+ mock.keystroke('C-S-k', press)
+ assert.same({}, calls)
+ assert.is_true(inter:has_error())
+ end)
+
+ it('in editing, accepts the block first', function()
+ mock.keystroke('return', press)
+ local changed = mock_func_snippet('changed')
+ inter:set_text(string.lines(changed))
+ mock.keystroke('C-k', press)
+ assert.same('nav', controller:get_mode())
+ assert.same({ 'write:main.lua' }, calls)
+ end)
+ end)
+
+ it('knocks when refused', function()
+ require("tests.helpers.codesnippets")
+ local controller, press = wire(TU.mock_view_cfg())
+ local f1 = mock_func_snippet('one')
+ local save = TU.get_save_function(f1)
+ controller:open('knock.lua', f1 .. '\n', save)
+ local inter = controller.input
+
+ --- walking off the end of the file
+ mock.keystroke('end', press)
+ local n0 = #mock.played_sounds()
+ mock.keystroke('down', press)
+ mock.keystroke('down', press)
+ local played = mock.played_sounds()
+ assert.is_true(#played > n0)
+ assert.same('assets/sounds/knock.ogg', played[#played])
+
+ --- and a refused block
+ mock.keystroke('home', press)
+ mock.keystroke('return', press)
+ inter:set_text({ 'function broken(' })
+ local n1 = #mock.played_sounds()
+ mock.keystroke('C-down', press)
+ played = mock.played_sounds()
+ assert.is_true(#played > n1)
+ assert.same('assets/sounds/knock.ogg', played[#played])
+ end)
+
+ describe('Ctrl+Enter blocks (2.7)', function()
+ require("tests.helpers.codesnippets")
+ local controller, press, buffer, inter
+
+ before_each(function()
+ local f1 = mock_func_snippet('one')
+ local f2 = mock_func_snippet('two')
+ local text = f1 .. '\n\n' .. f2 .. '\n'
+ controller, press = wire(TU.mock_view_cfg())
+ local save = TU.get_save_function(text)
+ controller:open('ce.lua', text, save)
+ buffer = controller:get_active_buffer()
+ inter = controller.input
+ end)
+
+ it('does not touch the file in nav', function()
+ local text0 = string.unlines(
+ buffer:get_text_content())
+ local n0 = buffer:get_content_length()
+ mock.keystroke('C-return', press)
+ --- the block appears on acceptance, not now
+ assert.same(n0, buffer:get_content_length())
+ assert.same(text0, string.unlines(
+ buffer:get_text_content()))
+ end)
+
+ it('opens a fresh block below in nav', function()
+ assert.same(1, buffer:get_selection())
+ mock.keystroke('C-return', press)
+ assert.same('edit', controller:get_mode())
+ assert.is_true(inter:is_empty())
+ --- composing lands after the first block
+ assert.same(2, buffer:get_selection())
+ local n = buffer:get_content_length()
+ inter:set_text({ 'x = 1' })
+ mock.keystroke('return', press)
+ assert.same(n + 1, buffer:get_content_length())
+ assert.same({ 'x = 1' },
+ buffer:get_content():get(2):to_lines())
+ end)
+
+ it('opens a fresh block above with Shift', function()
+ mock.keystroke('C-down', press)
+ local sel = buffer:get_selection()
+ mock.keystroke('C-S-return', press)
+ assert.same('edit', controller:get_mode())
+ assert.is_true(inter:is_empty())
+ --- composing lands at the block's own place
+ assert.same(sel, buffer:get_selection())
+ end)
+
+ it('accepts the open block in edit', function()
+ mock.keystroke('return', press)
+ local changed = mock_func_snippet('renamed')
+ inter:set_text(string.lines(changed))
+ mock.keystroke('C-return', press)
+ assert.same('nav', controller:get_mode())
+ assert.same(1, buffer:get_selection())
+ assert.truthy(string.find(
+ string.unlines(buffer:get_text_content()),
+ 'renamed', 1, true))
+ end)
+ end)
+
+ describe('typing in navigation (2.1)', function()
+ require("tests.helpers.codesnippets")
+ local controller, press, buffer, inter
+
+ before_each(function()
+ local f1 = mock_func_snippet('one')
+ local text = f1 .. '\n\n'
+ controller, press = wire(TU.mock_view_cfg())
+ local save = TU.get_save_function(text)
+ controller:open('typing.lua', text, save)
+ buffer = controller:get_active_buffer()
+ inter = controller.input
+ end)
+
+ it('opens the block and makes room on the line',
+ function()
+ --- stand on the middle line of the function
+ mock.keystroke('down', press)
+ assert.same(2, buffer:get_active_line())
+ local before = buffer:get_selected_text()
+
+ controller:textinput('x')
+ assert.same('edit', controller:get_mode())
+ --- the block is open, one line longer, and the
+ --- character sits on a fresh line 2
+ local t = inter:get_text()
+ assert.same(#before + 1, #t)
+ assert.same('x', t[2])
+ assert.same(before[1], t[1])
+ assert.same(before[2], t[3])
+ end)
+
+ it('a blank line becomes a new block', function()
+ --- the trailing empty block
+ mock.keystroke('end', press)
+ assert.is_true(
+ buffer:_get_selected_block():is_empty())
+
+ controller:textinput('y')
+ assert.same('edit', controller:get_mode())
+ --- nothing was loaded: the text composes fresh
+ assert.same({ 'y' }, inter:get_text())
+ end)
+
+ it('never overwrites the block typed on', function()
+ mock.keystroke('down', press)
+ controller:textinput('-')
+ controller:textinput('-')
+ mock.keystroke('return', press)
+ --- the function survives, with the comment in it
+ local all = string.unlines(
+ buffer:get_text_content())
+ assert.truthy(
+ string.find(all, 'function one()', 1, true))
+ assert.truthy(string.find(all, '--', 1, true))
+ end)
+ end)
+
+ describe('block undo (1.1)', function()
+ require("tests.helpers.codesnippets")
+ local controller, press, buffer, savefile
+
+ before_each(function()
+ local f1 = mock_func_snippet('one')
+ local f2 = mock_func_snippet('two')
+ local text = f1 .. '\n\n' .. f2 .. '\n'
+ controller, press = wire(TU.mock_view_cfg())
+ local save
+ save, savefile = TU.get_save_function(text)
+ controller:open('bu.lua', text, save)
+ love.system = {
+ getClipboardText = function() return '' end,
+ setClipboardText = function() end,
+ }
+ buffer = controller:get_active_buffer()
+ end)
+
+ it('undoes an acceptance, file steps back', function()
+ local orig = string.unlines(
+ buffer:get_text_content())
+ mock.keystroke('return', press)
+ controller.input:set_text(
+ string.lines(mock_func_snippet('renamed')))
+ mock.keystroke('return', press)
+ assert.truthy(string.find(savefile(), 'renamed',
+ 1, true))
+
+ mock.keystroke('C-z', press)
+ --- back in the file, block not reopened
+ assert.same('nav', controller:get_mode())
+ assert.same(orig, string.unlines(
+ buffer:get_text_content()))
+ assert.same(orig, savefile())
+ --- redo returns the accepted state
+ mock.keystroke('C-y', press)
+ assert.truthy(string.find(
+ string.unlines(buffer:get_text_content()),
+ 'renamed', 1, true))
+ end)
+
+ it('undoes a block deletion', function()
+ local orig = string.unlines(
+ buffer:get_text_content())
+ mock.keystroke('C-delete', press)
+ assert.falsy(string.find(
+ string.unlines(buffer:get_text_content()),
+ 'function one()', 1, true))
+ mock.keystroke('C-z', press)
+ assert.same(orig, string.unlines(
+ buffer:get_text_content()))
+ end)
+
+ it('undoes a reorder block move', function()
+ local orig = string.unlines(
+ buffer:get_text_content())
+ mock.keystroke('C-m', press)
+ mock.keystroke('down', press)
+ mock.keystroke('return', press)
+ assert.is_not.same(orig, string.unlines(
+ buffer:get_text_content()))
+ mock.keystroke('C-z', press)
+ assert.same(orig, string.unlines(
+ buffer:get_text_content()))
+ end)
+
+ it('discard asks, and undo brings the draft back',
+ function()
+ local orig = string.unlines(
+ buffer:get_text_content())
+ mock.keystroke('return', press)
+ controller.input:set_text(
+ string.lines(mock_func_snippet('draft')))
+
+ --- first press asks, still editing
+ mock.keystroke('S-escape', press)
+ assert.same('edit', controller:get_mode())
+ assert.is_true(controller.input:has_error())
+
+ --- Enter confirms; the file untouched
+ mock.keystroke('return', press)
+ assert.same('nav', controller:get_mode())
+ assert.same(orig, string.unlines(
+ buffer:get_text_content()))
+
+ --- one undo: the parseable draft lands in
+ --- the file; another: gone again (the pair)
+ mock.keystroke('C-z', press)
+ assert.truthy(string.find(
+ string.unlines(buffer:get_text_content()),
+ 'draft', 1, true))
+ mock.keystroke('C-z', press)
+ assert.same(orig, string.unlines(
+ buffer:get_text_content()))
+ end)
+
+ it('any other key cancels the discard ask',
+ function()
+ mock.keystroke('return', press)
+ controller.input:set_text({ 'x = 1' })
+ mock.keystroke('S-escape', press)
+ mock.keystroke('down', press)
+ --- still editing, the ask is gone
+ assert.same('edit', controller:get_mode())
+ assert.is_nil(controller.pending_confirm)
+ end)
+
+ it('a broken draft discards without the pair',
+ function()
+ local orig = string.unlines(
+ buffer:get_text_content())
+ local n0 = #buffer.history
+ mock.keystroke('return', press)
+ controller.input:set_text(
+ { 'function broken(' })
+ mock.keystroke('S-escape', press)
+ --- Enter confirms (repeat-proof dialogs)
+ mock.keystroke('return', press)
+ assert.same('nav', controller:get_mode())
+ --- nothing recoverable was recorded
+ assert.same(n0, #buffer.history)
+ assert.same(orig, string.unlines(
+ buffer:get_text_content()))
+ end)
+
+ it('bare Delete drops the block, undoably',
+ function()
+ local orig = string.unlines(
+ buffer:get_text_content())
+ local n0 = buffer:get_content_length()
+ mock.keystroke('delete', press)
+ assert.same(n0 - 1,
+ buffer:get_content_length())
+ mock.keystroke('C-z', press)
+ assert.same(orig, string.unlines(
+ buffer:get_text_content()))
+ end)
+
+ it('checkpoint restore clears the history',
+ function()
+ mock.keystroke('C-m', press)
+ mock.keystroke('down', press)
+ mock.keystroke('return', press)
+ assert.is_true(#buffer.history > 0)
+ --- a restore rebuilds the buffer: reload
+ controller:reload_active(string.unlines(
+ buffer:get_text_content()))
+ local fresh = controller:get_active_buffer()
+ assert.same(0, #fresh.history)
+ end)
+
+ it('knocks on empty history', function()
+ local n0 = #mock.played_sounds()
+ mock.keystroke('C-z', press)
+ assert.is_true(#mock.played_sounds() > n0)
+ end)
+
+ it('a new write kills the redo tail', function()
+ mock.keystroke('C-m', press)
+ mock.keystroke('down', press)
+ mock.keystroke('return', press)
+ mock.keystroke('C-z', press)
+ mock.keystroke('C-m', press)
+ mock.keystroke('down', press)
+ mock.keystroke('return', press)
+ local n0 = #mock.played_sounds()
+ mock.keystroke('C-z', press)
+ assert.same(n0, #mock.played_sounds())
+ mock.keystroke('C-y', press)
+ mock.keystroke('C-y', press)
+ --- the second redo has nothing: the tail died
+ assert.is_true(#mock.played_sounds() > n0)
+ end)
+ end)
+
+ it('Ctrl+Z undoes typing word by word', function()
+ require("tests.helpers.codesnippets")
+ local controller, press = wire(TU.mock_view_cfg())
+ local src = 'x = 1'
+ local save = TU.get_save_function(src)
+ controller:open('undo.lua', src .. '\n', save)
+ local inter = controller.input
+
+ mock.keystroke('return', press)
+ local base = table.clone(inter:get_text():items())
+ for ch in string.gmatch('ab cd', '.') do
+ controller:textinput(ch)
+ end
+ local typed = table.clone(inter:get_text():items())
+ assert.same('ab cd' .. base[1], typed[1])
+
+ --- first undo eats the last word (with the space
+ --- that started it), not one letter
+ mock.keystroke('C-z', press)
+ assert.same('ab' .. base[1],
+ inter:get_text():items()[1])
+ --- and again, back to the baseline
+ mock.keystroke('C-z', press)
+ assert.same(base, inter:get_text():items())
+ --- empty history knocks
+ local n0 = #mock.played_sounds()
+ mock.keystroke('C-z', press)
+ assert.is_true(#mock.played_sounds() > n0)
+
+ --- redo returns everything
+ mock.keystroke('C-y', press)
+ mock.keystroke('C-y', press)
+ assert.same(typed, inter:get_text():items())
+
+ --- still in edit: the keys never left the block
+ assert.same('edit', controller:get_mode())
+ end)
+
+ it('Delete leaves the clipboard alone, Ctrl+X cuts',
+ function()
+ require("tests.helpers.codesnippets")
+ local controller, press = wire(TU.mock_view_cfg())
+ local f1 = mock_func_snippet('one')
+ local f2 = mock_func_snippet('two')
+ local text = f1 .. '\n\n' .. f2 .. '\n'
+ local save = TU.get_save_function(text)
+ controller:open('clip.lua', text, save)
+ local clip = 'precious'
+ love.system = {
+ getClipboardText = function() return clip end,
+ setClipboardText = function(t) clip = t end,
+ }
+ local buffer = controller:get_active_buffer()
+ local n0 = buffer:get_content_length()
+
+ mock.keystroke('delete', press)
+ assert.same(n0 - 1, buffer:get_content_length())
+ --- the copied text survived the deletion
+ assert.same('precious', clip)
+
+ mock.keystroke('C-x', press)
+ assert.same(n0 - 2, buffer:get_content_length())
+ assert.is_not.same('precious', clip)
+ end)
+
+ it('returning from a require restores the view',
+ function()
+ require("tests.helpers.codesnippets")
+ local controller, press = wire(TU.mock_view_cfg())
+ local blocks = {}
+ for i = 1, 30 do
+ blocks[#blocks + 1] = mock_func_snippet('f' .. i)
+ end
+ blocks[16] = "local m = require('other')"
+ local text = table.concat(blocks, '\n\n')
+ local save = TU.get_save_function(text)
+ controller.console = {
+ edit = function() end,
+ }
+ controller:open('main.lua', text .. '\n', save)
+
+ --- to the middle of the file, into the require
+ mock.keystroke('home', press)
+ for _ = 1, 15 do
+ mock.keystroke('C-down', press)
+ end
+ local line0 = controller:get_active_buffer()
+ :get_active_line()
+ controller:open('other.lua', 'x = 1\n',
+ TU.get_save_function('x = 1\n'))
+
+ --- and back: the stored line is visible again
+ --- (open() alone parks the view at the end)
+ controller:close_buffer()
+ assert.same(line0, controller:get_active_buffer()
+ :get_active_line())
+ local bv = controller.view:get_current_buffer()
+ local r = bv.content:get_range()
+ local wl = bv.content.wrap_forward[line0]
+ assert.is_true(wl[1] >= r.start
+ and wl[#wl] <= r.fin)
+ end)
+
+ it('dialogs confirm on Enter or Space only', function()
+ require("tests.helpers.codesnippets")
+ local controller, press = wire(TU.mock_view_cfg())
+ local f1 = mock_func_snippet('one')
+ local save, savefile =
+ TU.get_save_function(f1 .. '\n')
+ controller:open('dlg.lua', f1 .. '\n', save)
+ local inter = controller.input
+
+ --- a held Shift+Esc: every repeat lands on the
+ --- idempotent cancel, nothing is lost
+ mock.keystroke('return', press)
+ inter:set_text({ 'x = 9' })
+ mock.keystroke('S-escape', press)
+ assert.is_true(inter:has_error())
+ --- repeat cancels; the next press asks again —
+ --- held, it oscillates and never discards
+ mock.keystroke('S-escape', press)
+ assert.is_false(inter:has_error())
+ mock.keystroke('S-escape', press)
+ assert.is_true(inter:has_error())
+ assert.same('edit', controller:get_mode())
+ assert.same({ 'x = 9' }, inter:get_text():items())
+
+ --- Space confirms, via textinput as the device
+ --- delivers it, and the glyph is swallowed
+ controller:textinput(' ')
+ controller:keypressed('space')
+ assert.same('nav', controller:get_mode())
+
+ --- a printable cancels without typing
+ mock.keystroke('return', press)
+ inter:set_text({ 'y = 1' })
+ mock.keystroke('S-escape', press)
+ controller:textinput('q')
+ assert.same('edit', controller:get_mode())
+ assert.same({ 'y = 1' }, inter:get_text():items())
+ mock.keystroke('S-escape', press)
+ mock.keystroke('return', press)
+ assert.same('nav', controller:get_mode())
+ end)
+
+ it('an error message closes on Enter or Esc', function()
+ require("tests.helpers.codesnippets")
+ local controller, press = wire(TU.mock_view_cfg())
+ local f1 = mock_func_snippet('one')
+ local save = TU.get_save_function(f1 .. '\n')
+ controller:open('err.lua', f1 .. '\n', save)
+ local inter = controller.input
+
+ mock.keystroke('return', press)
+ inter:set_text({ 'function broken(' })
+ mock.keystroke('return', press)
+ assert.is_true(inter:has_error())
+ --- Enter closes the message without re-submitting
+ mock.keystroke('return', press)
+ assert.is_false(inter:has_error())
+ assert.same('edit', controller:get_mode())
+
+ mock.keystroke('return', press)
+ assert.is_true(inter:has_error())
+ --- Esc closes it too, staying in the block
+ mock.keystroke('escape', press)
+ assert.is_false(inter:has_error())
+ assert.same('edit', controller:get_mode())
+ end)
+
+ it('the console widget keeps plain keys', function()
+ --- a console-style input: no editing flag
+ local model = UserInputModel(
+ TU.mock_view_cfg(), LuaEval(), false, 'console')
+ local con = UserInputController(model)
+ --- keypressed refreshes the view first; a stub
+ --- is enough, the spec is about the keys
+ con.view = { refresh = function() end }
+ con.update_view = function() end
+ local press = function(k) con:keypressed(k) end
+
+ model:add_text('one two')
+ mock.keystroke('C-backspace', press)
+ --- plain backspace, one character, not a word
+ assert.same({ 'one tw' }, model:get_text():items())
+
+ mock.keystroke('C-w', press)
+ assert.same({ 'one tw' }, model:get_text():items())
+
+ mock.keystroke('C-y', press)
+ --- delete-line is still the console's Ctrl+Y
+ assert.same({ '' }, model:get_text():items())
+ end)
+
+ it('Ctrl+W and Ctrl+Backspace eat a word', function()
+ require("tests.helpers.codesnippets")
+ local controller, press = wire(TU.mock_view_cfg())
+ local src = 'x = 1'
+ local save = TU.get_save_function(src)
+ controller:open('w.lua', src .. '\n', save)
+ local inter = controller.input
+
+ mock.keystroke('return', press)
+ inter:set_text({ 'local one two three' })
+ inter.model:move_cursor(1, 20)
+
+ mock.keystroke('C-w', press)
+ assert.same({ 'local one two ' }, inter:get_text())
+ --- still editing: the key must not leave the block
+ assert.same('edit', controller:get_mode())
+
+ mock.keystroke('C-backspace', press)
+ assert.same({ 'local one ' }, inter:get_text())
+
+ --- trailing spaces go with the word
+ mock.keystroke('C-w', press)
+ assert.same({ 'local ' }, inter:get_text())
+ end)
+
+ it('Ctrl+Delete drops a block only in nav', function()
+ require("tests.helpers.codesnippets")
+ local controller, press = wire(TU.mock_view_cfg())
+ local f1 = mock_func_snippet('one')
+ local f2 = mock_func_snippet('two')
+ local save = TU.get_save_function(
+ f1 .. '\n\n' .. f2 .. '\n')
+ controller:open('del.lua',
+ f1 .. '\n\n' .. f2 .. '\n', save)
+ --- dropping a block copies it to the clipboard
+ love.system = {
+ getClipboardText = function() return '' end,
+ setClipboardText = function() end,
+ }
+ local buffer = controller:get_active_buffer()
+ local n0 = buffer:get_content_length()
+
+ --- editing: the block survives, the key is the
+ --- widget's delete-next-word
+ mock.keystroke('return', press)
+ mock.keystroke('C-delete', press)
+ assert.same(n0, buffer:get_content_length())
+ --- the word deletion made the draft dirty
+ mock.keystroke('S-escape', press)
+ --- Enter confirms (repeat-proof dialogs)
+ mock.keystroke('return', press)
+
+ --- navigation: it drops the block
+ mock.keystroke('C-delete', press)
+ assert.same(n0 - 1, buffer:get_content_length())
+ end)
+
+ it('knocks on every refused action', function()
+ require("tests.helpers.codesnippets")
+ local controller, press = wire(TU.mock_view_cfg())
+ local f1 = mock_func_snippet('one')
+ local f2 = mock_func_snippet('two')
+ local text = f1 .. '\n\n' .. f2 .. '\n'
+ local save = TU.get_save_function(text)
+ controller.console = { edit = function() end }
+ controller:open('knock.lua', text, save)
+ local buffer = controller:get_active_buffer()
+
+ local function knocked(fn)
+ local before = #mock.played_sounds()
+ fn()
+ local played = mock.played_sounds()
+ if #played == before then return false end
+ return played[#played] == 'assets/sounds/knock.ogg'
+ end
+
+ --- walk down until the file ends: the last real
+ --- block, then the phantom line past it, both
+ --- legitimate moves
+ mock.keystroke('end', press)
+ assert.is_false(knocked(function()
+ mock.keystroke('down', press)
+ end), 'stepping onto the phantom line is a move')
+
+ --- now there is nowhere further
+ assert.is_true(knocked(function()
+ mock.keystroke('down', press)
+ end), 'bare arrow at the end')
+
+ --- Ctrl+arrow past the end
+ assert.is_true(knocked(function()
+ mock.keystroke('C-down', press)
+ end), 'block jump at the end')
+
+ --- PageDown at the end
+ assert.is_true(knocked(function()
+ mock.keystroke('pagedown', press)
+ end), 'page move at the end')
+
+ --- reorder move past the edge
+ mock.keystroke('home', press)
+ mock.keystroke('C-m', press)
+ assert.is_true(knocked(function()
+ mock.keystroke('up', press)
+ end), 'block move at the edge')
+ mock.keystroke('escape', press)
+
+ --- Ctrl+J with no require in the block
+ assert.is_true(knocked(function()
+ mock.keystroke('C-j', press)
+ end), 'nothing to follow')
+
+ --- and it stays quiet when the move works
+ assert.is_false(knocked(function()
+ mock.keystroke('down', press)
+ end), 'a working move is silent')
+ assert.same(2, buffer:get_active_line())
+ end)
+
+ it('knocks when the search finds nothing', function()
+ local controller, press = wire(TU.mock_view_cfg())
+ local src = "local function findme() end"
+ local save = TU.get_save_function(src)
+ controller:open('search.lua', src .. '\n', save)
+ --- entering search saves the clipboard state
+ love.system = {
+ getClipboardText = function() return '' end,
+ setClipboardText = function() end,
+ }
+
+ mock.keystroke('C-f', press)
+ assert.same('search', controller:get_mode())
+ local before = #mock.played_sounds()
+ --- a match: quiet
+ controller:textinput('f')
+ assert.same(before, #mock.played_sounds())
+ --- no match: knock
+ controller:textinput('zzz')
+ local played = mock.played_sounds()
+ assert.is_true(#played > before)
+ assert.same('assets/sounds/knock.ogg', played[#played])
+ end)
+
+ it('follows the require on Ctrl+J', function()
+ require("tests.helpers.codesnippets")
+ local controller, press = wire(TU.mock_view_cfg())
+ local src = "local m = require('other')"
+ local save = TU.get_save_function(src)
+ local edited = {}
+ controller.console = {
+ edit = function(_, name)
+ table.insert(edited, name)
+ end,
+ }
+ controller:open('main.lua', src, save)
+
+ --- Ctrl+O is free now, it must do nothing
+ mock.keystroke('C-o', press)
+ assert.same({}, edited)
+
+ mock.keystroke('C-j', press)
+ assert.same({ 'other.lua' }, edited)
+ end)
+
+ describe('mouse (2.9)', function()
+ require("tests.helpers.codesnippets")
+ local controller, press, buffer, inter
+ local f1, f2
+
+ before_each(function()
+ f1 = mock_func_snippet('one')
+ f2 = mock_func_snippet('two')
+ local text = f1 .. '\n\n' .. f2 .. '\n'
+ controller, press = wire(TU.mock_view_cfg())
+ local save = TU.get_save_function(text)
+ controller:open('mouse.lua', text, save)
+ buffer = controller:get_active_buffer()
+ inter = controller.input
+ end)
+
+ it('maps lines to their blocks', function()
+ assert.same(1, buffer:block_at_line(2))
+ assert.same(2, buffer:block_at_line(4))
+ assert.same(3, buffer:block_at_line(6))
+ assert.is_nil(buffer:block_at_line(99))
+ end)
+
+ it('a double click opens the block', function()
+ controller:mousepressed(0, 0, 1, false, 2)
+ assert.same('edit', controller:get_mode())
+ assert.same(1, buffer:get_selection())
+ assert.same(buffer:get_selected_text(),
+ inter:get_text():items())
+ end)
+
+ it('a single click does not open', function()
+ controller:mousepressed(0, 0, 1, false, 1)
+ assert.same('nav', controller:get_mode())
+ end)
+
+ it('a click in nav selects block and line', function()
+ controller:mouse_select(6)
+ assert.same('nav', controller:get_mode())
+ assert.same(3, buffer:get_selection())
+ assert.same(6, buffer:get_active_line())
+ end)
+
+ it('a click inside the open block sets the cursor',
+ function()
+ mock.keystroke('return', press)
+ controller:mouse_select(2)
+ assert.same('edit', controller:get_mode())
+ assert.same(1, buffer:get_selection())
+ assert.same(2,
+ inter.model:get_cursor_info().cursor.l)
+ end)
+
+ it('a clean click outside flows out', function()
+ mock.keystroke('return', press)
+ controller:mouse_select(6)
+ assert.same('nav', controller:get_mode())
+ assert.same(3, buffer:get_selection())
+ assert.same(6, buffer:get_active_line())
+ assert.same({ '' }, inter:get_text())
+ end)
+
+ it('a changed click outside is accepted', function()
+ mock.keystroke('return', press)
+ inter:set_text({ 'function renamed()', 'end' })
+ controller:mouse_select(6)
+ --- 2.4.2 via 2.9: written through, then the
+ --- clicked block takes the selection
+ assert.same('nav', controller:get_mode())
+ assert.same(3, buffer:get_selection())
+ assert.same(6, buffer:get_active_line())
+ assert.truthy(string.find(
+ string.unlines(buffer:get_text_content()),
+ 'renamed', 1, true))
+ end)
+
+ it('an invalid click outside refuses', function()
+ mock.keystroke('return', press)
+ inter:set_text({ 'function broken(' })
+ controller:mouse_select(6)
+ --- 2.4.3: the block keeps the editor
+ assert.same('edit', controller:get_mode())
+ assert.same(1, buffer:get_selection())
+ assert.is_true(inter:has_error())
+ end)
+ end)
+
+ describe('leave gate (2.4)', function()
+ require("tests.helpers.codesnippets")
+ local controller, press, buffer, inter, savefile
+ local f1, f2, text
+
+ before_each(function()
+ f1 = mock_func_snippet('one')
+ f2 = mock_func_snippet('two')
+ text = f1 .. '\n\n' .. f2 .. '\n'
+ local save
+ controller, press = wire(TU.mock_view_cfg())
+ save, savefile = TU.get_save_function(text)
+ controller:open('gate.lua', text, save)
+ buffer = controller:get_active_buffer()
+ inter = controller.input
+ end)
+
+ it('untouched block flows out freely', function()
+ mock.keystroke('return', press)
+ local span = buffer:get_selection_lines()
+ for _ = 1, span:len() do
+ mock.keystroke('down', press)
+ end
+ --- crossed the edge: neighbor open, no write
+ assert.same(2, buffer:get_selection())
+ assert.same('edit', controller:get_mode())
+ local saved = savefile()
+ assert.same(text, saved)
+ --- and upward lands on the previous last line
+ mock.keystroke('up', press)
+ assert.same(1, buffer:get_selection())
+ local sp = buffer:get_selection_lines()
+ assert.same(sp.fin, buffer:get_active_line())
+ end)
+
+ it('acceptance in place stays on the block', function()
+ mock.keystroke('return', press)
+ local changed = mock_func_snippet('changed')
+ inter:set_text(string.lines(changed))
+ mock.keystroke('return', press)
+ --- 2.4.4: in place, so the block keeps the
+ --- selection and the editor returns to nav
+ assert.same('nav', controller:get_mode())
+ assert.same(1, buffer:get_selection())
+ assert.truthy(
+ string.find(savefile(), 'changed', 1, true))
+ end)
+
+ it('changed block is accepted on the way out', function()
+ mock.keystroke('return', press)
+ local changed = mock_func_snippet('changed')
+ inter:set_text(string.lines(changed))
+ mock.keystroke('C-down', press)
+ --- written through, editing flows on
+ assert.same('edit', controller:get_mode())
+ --- NB savefile() reads destructively
+ local saved = savefile()
+ assert.truthy(
+ string.find(saved, 'changed', 1, true))
+ assert.is_nil(
+ string.find(saved, 'one', 1, true))
+ end)
+
+ it('invalid block refuses to leave', function()
+ mock.keystroke('return', press)
+ inter:set_text({
+ 'function broken()', ' x = = 2', 'end'
+ })
+ mock.keystroke('C-down', press)
+ assert.same('edit', controller:get_mode())
+ assert.same(1, buffer:get_selection())
+ assert.is_true(inter:has_error())
+ --- and the cursor sits on the error's line
+ assert.same(2,
+ inter.model:get_cursor_info().cursor.l)
+ --- Shift+Esc still gets out, writing nothing:
+ --- one press closes the message, the next asks,
+ --- Enter confirms
+ mock.keystroke('S-escape', press)
+ assert.is_false(inter:has_error())
+ mock.keystroke('S-escape', press)
+ mock.keystroke('return', press)
+ assert.same('nav', controller:get_mode())
+ assert.same(text, savefile())
+ end)
+ end)
+
it('changing single line', function()
local controller, press = wire(TU.mock_view_cfg())
local save, savefile = TU.get_save_function(sierpinski)
@@ -451,13 +1540,16 @@ describe('Editor #editor', function()
assert.same(4, buffer:get_content_length())
local modified = table.clone(sierpinski)
local new_print = 'print(sierpinski(3))'
- mock.keystroke('up', press)
+ mock.keystroke('C-down', press)
+ mock.keystroke('C-down', press)
assert.same(3, buffer:get_selection())
assert.same({ print_result }, buffer:get_selected_text())
+ mock.keystroke('return', press)
input:clear()
input:add_text(new_print)
mock.keystroke('return', press)
- assert.same(4, buffer:get_selection())
+ --- acceptance in place stays on the block (2.4.4)
+ assert.same(3, buffer:get_selection())
local after = savefile()
modified[#modified] = new_print
modified[#modified + 1] = ''
@@ -492,11 +1584,9 @@ describe('Editor #editor', function()
session:submit(f_modified)
assert.is_true(input:is_empty(), "input cleared")
- assert.same(2, buffer.selection, "selection moved")
- assert.same({}, buffer:get_selected_text(),
- "next (empty) block is selected")
+ --- acceptance in place stays (2.4.4)
+ assert.same(1, buffer.selection, "selection stays")
- session:select_block(1)
assert.same(string.lines(f_modified),
buffer:get_selected_text(),
"selection replaced with modified block")
@@ -518,9 +1608,8 @@ describe('Editor #editor', function()
session:submit(new_code)
assert.is_true(input:is_empty(), "input cleared")
- assert.same(4, buffer.selection, "selection moved")
- assert.same({}, buffer:get_selected_text(),
- "next (empty) block is selected")
+ --- acceptance in place stays (2.4.4)
+ assert.same(1, buffer.selection, "selection stays")
session:select_block(1)
assert.same( string.lines(f1),
@@ -566,9 +1655,9 @@ describe('Editor #editor', function()
session:select_and_open_block(1, f_oversized)
session:submit(f_simple)
- assert.same(2, buffer.selection, "selection moved")
+ --- acceptance in place stays (2.4.4)
+ assert.same(1, buffer.selection, "selection stays")
assert.is_true(input:is_empty(), "input cleared")
- session:select_block(1)
assert.same(string.lines(f_simple),
buffer:get_selected_text(),
"previous block content replaced")
@@ -683,6 +1772,8 @@ describe('Editor #editor', function()
before_each(function()
input, buffer = session:open(existing_src, n_blocks)
+ --- files open at the top; these insert at the end
+ session:select_block(n_blocks)
end)
it("single normal block", function()
@@ -735,6 +1826,37 @@ describe('Editor #editor', function()
"saved file contains updates")
end)
+ it('fourteen lines pass, fifteen are refused', function()
+ local ok14 = mock_func_snippet('ok14', 14)
+ session:submit(ok14, true)
+ assert.is_true(input:is_empty(), '14 lines accepted')
+ assert.same(n_blocks + 1, buffer:get_content_length())
+
+ local over15 = mock_func_snippet('over15', 15)
+ session:submit(over15, true)
+ assert.is_false(input:is_empty(), '15 lines refused')
+ --- with a visible message naming the excess (9.6)
+ assert.is_true(controller.input:has_error())
+ local err = controller.input.model.error
+ assert.truthy(
+ string.find(err[1], 'Remove 1', 1, true))
+ mock.keystroke('S-escape', press)
+ end)
+
+ it('opening auto-formats a sloppy block', function()
+ local sloppy = 'function fmt() print( "x" ) end'
+ local _, b2 = session:open(sloppy, 1)
+ mock.keystroke('return', press)
+ --- the formatter reshaped the input on open (9.4)
+ local t = controller.input:get_text()
+ assert.is_true(#t > 1)
+ assert.same('function fmt()', t[1])
+ --- the file is untouched until acceptance
+ assert.same(sloppy, table.concat(
+ b2:get_text_content(), '\n'):gsub('\n+$', ''))
+ mock.keystroke('S-escape', press)
+ end)
+
it('single oversized block is rejected', function()
local f_oversized = mock_func_snippet("oversized",20)
session:submit(f_oversized, true)
diff --git a/tests/helpers/editor_session.lua b/tests/helpers/editor_session.lua
index 1d033dee..ddeec0f4 100644
--- a/tests/helpers/editor_session.lua
+++ b/tests/helpers/editor_session.lua
@@ -82,8 +82,9 @@ function EditorSession:select_block(n, target_content)
local jumpkey = dir == "up" and "home" or "end"
self.mock.keystroke(jumpkey, self.press)
end
+ --- blocks move with Ctrl (bare arrows are line-wise)
for i = 1, steps do
- self.mock.keystroke(dir, self.press)
+ self.mock.keystroke('C-' .. dir, self.press)
end
assert.same(
@@ -105,7 +106,8 @@ end
--- @param target_content string?
function EditorSession:select_and_open_block(n, target_content)
self:select_block(n, target_content)
- self.mock.keystroke("escape", self.press)
+ --- Enter on an empty input opens the block (Esc is inert)
+ self.mock.keystroke("return", self.press)
assert.same(n, self.buffer.loaded, fmt("loaded block #%s", n))
if target_content then
@@ -118,9 +120,16 @@ function EditorSession:select_and_open_block(n, target_content)
end
end
+--- @param newtext string
+--- Compose text as if it had been typed: the editor is
+--- editing whenever the input holds anything (2.1), so
+--- putting text in without the mode is not a real state
--- @param newtext string
function EditorSession:alter_input(newtext)
local newlines = string.lines(newtext)
+ if self.controller:get_mode() == 'nav' then
+ self.controller:set_mode('edit')
+ end
self.input:set_text(newlines)
assert.same(newlines, self.input:get_text(), "input altered")
end
diff --git a/tests/input/user_input_model_spec.lua b/tests/input/user_input_model_spec.lua
index 1e3a264d..73b7c3fc 100644
--- a/tests/input/user_input_model_spec.lua
+++ b/tests/input/user_input_model_spec.lua
@@ -27,6 +27,26 @@ describe("input model spec #input", function()
}
mock.mock_love(love)
+ describe('the plain widget stays plain', function()
+ --- the console, project inputs and search construct
+ --- the model without the editing flag; the editor's
+ --- 1.1 extras must not leak into them
+ it('records no edit history', function()
+ local model = UserInputModel(mockConf, luaEval)
+ model:add_text('one two three')
+ model:add_text(' four')
+ assert.same({}, model.edit_history.steps)
+ end)
+
+ it('records with the editing flag on', function()
+ local model = UserInputModel(
+ mockConf, luaEval, false, nil, true)
+ model:add_text('one ')
+ model:add_text('two')
+ assert.is_true(#model.edit_history.steps > 0)
+ end)
+ end)
+
-----------------
-- ASCII --
-----------------
diff --git a/tests/mock.lua b/tests/mock.lua
index 360369f7..286b97fe 100644
--- a/tests/mock.lua
+++ b/tests/mock.lua
@@ -24,7 +24,11 @@ local W = 1024
local H = 600
--- @param t love
+--- sounds played since the last mock_love()
+local played = {}
+
local function mock_love(t)
+ played = {}
local love = {
keyboard = {
isDown = function(k) return held[k] end
@@ -38,6 +42,17 @@ local function mock_love(t)
setCanvas = function() end,
clear = function() end,
},
+ audio = {
+ mock = true,
+ --- util.audio builds its sources on require
+ newSource = function(name)
+ return { name = name }
+ end,
+ stop = function() end,
+ play = function(source)
+ table.insert(played, source and source.name)
+ end,
+ },
}
for k, v in pairs(t) do
love[k] = v
@@ -73,6 +88,7 @@ end
return {
mock_love = mock_love,
+ played_sounds = function() return played end,
keystroke = keystroke,
release_keys = release_keys,
}