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. ![hello](./interface/hello.apng) -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 ⏎ ![capitalized](./interface/hello_cap.apng) -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 ![quit](./interface/quit_editor.apng) 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, }