From 1a8dbb981b77a065cf7e9246cc358b4a6f350bfc Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Sat, 11 Jul 2026 23:11:06 +0000 Subject: [PATCH 01/52] fix(editor): demo-safe key semantics per rework spec 2.2/2.3 - Enter on an empty input opens the selected block for editing (was: silent no-op); submit on non-empty input is unchanged - bare Esc no longer loads the block and performs no action, which also defuses the device RMB->Esc binding silently replacing typed input - Shift+Esc discards the edit (clears input and loaded state); on an empty input it closes the buffer / leaves the editor - Ctrl+Y no longer deletes the block; deliberate deletion stays on Ctrl+Delete - Ctrl+O follows the require if present and no longer silently pops the buffer otherwise Stopgap ahead of the full editor rework (stage 1); matches the spec direction so none of it is throwaway. --- src/controller/editorController.lua | 40 +++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index cd9b28e4..74710abb 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -104,8 +104,6 @@ function EditorController:follow_require() if reqsel then local name = reqsel.name self.console:edit(name .. '.lua') - else - self:pop_buffer() end end @@ -713,22 +711,29 @@ function EditorController:_normal_mode_keys(k) self:_handle_submit(replace) end end - local function load() - if not Key.ctrl() and - not Key.shift() - and k == "escape" then - load_selection() - end + --- open the selected block for editing (spec 2.2: Enter) + local function open() + load_selection() + block_input() + 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 then + self:close_buffer() + else + buf:clear_loaded() + input:clear() + end + block_input() end end local function delete() if Key.ctrl() then - if k == "delete" - or (k == "y" and is_empty) then + if k == "delete" then delete_block() block_input() end @@ -794,8 +799,17 @@ function EditorController:_normal_mode_keys(k) end end - submit() - load() + local plain_enter = Key.is_enter(k) + and not Key.ctrl() + and not Key.shift() + and not Key.alt() + + if is_empty and plain_enter then + open() + else + submit() + end + discard() delete() navigate() clear() From 6cc1b129f09dc67d5b418b8a59e2574e9aec07be Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Sat, 11 Jul 2026 23:11:06 +0000 Subject: [PATCH 02/52] fix(controller): bare Ctrl+S no longer closes the editor Saving is automatic on block accept, so bare Ctrl+S in the editor state now does nothing; the key is reserved for the checkpoint (rework spec 2.6). Ctrl+Shift+S still finishes the edit, and leaving the editor is Shift+Esc. --- src/controller/controller.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/controller/controller.lua b/src/controller/controller.lua index d577a472..06332392 100644 --- a/src/controller/controller.lua +++ b/src/controller/controller.lua @@ -563,10 +563,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 From 8c660fdf40b4411612fa07179ae06e5faf22eafd Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Sun, 12 Jul 2026 07:09:41 +0000 Subject: [PATCH 03/52] feat(editor): explicit navigation/editing submode with indicator Adds an 'editing' flag as a submode of 'edit', without touching the mode enum or its call sites: - entering: Enter opens the selected block, or typing into an empty input starts a new block - leaving: accepting a block (Enter), discarding (Shift+Esc), or clearing (Ctrl+W) - the statusline shows [NAV] / [EDIT] before the filename; reorder and search render as before - while editing, Enter on an emptied input is a no-op instead of reloading the block, and Shift+Esc first drops back to navigation; a second Shift+Esc leaves - accepting a block no longer auto-loads the next one into the input, so accept lands back in navigation --- src/controller/editorController.lua | 24 ++++++++++++++++++++---- src/view/input/statusline.lua | 10 ++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 74710abb..246df6cd 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -18,11 +18,13 @@ local function new(M, CC) console = CC, view = nil, mode = 'edit', + editing = false, } end --- @alias EditorMode --- | 'edit' --- default +--- | 'nav' --- display-only, 'edit' with editing off --- | 'reorder' --- | 'search' @@ -34,6 +36,7 @@ end --- @field view EditorView? --- @field state EditorState? --- @field mode EditorMode +--- @field editing boolean --- submode of 'edit' EditorController = class.create(new) --- @param v EditorView @@ -264,6 +267,9 @@ function EditorController:_generate_status(sel) local more = bufview.content:get_more() local cs local m = self.mode + if m == 'edit' and not self.editing then + m = 'nav' + end local ct = bufview.content_type if ct == 'lua' then local range = bufview.content:get_block_app_pos(sel) @@ -292,6 +298,10 @@ function EditorController:textinput(t) if Key.ctrl() and Key.shift() then return end + if not self.editing then + self.editing = true + self:update_status() + end self.input:textinput(t) end elseif self.mode == 'search' then @@ -666,8 +676,7 @@ function EditorController:_normal_mode_keys(k) self:_move_sel('down', n) buf:clear_loaded() input:clear() - - load_selection() + self.editing = false self:update_status() end @@ -697,6 +706,7 @@ function EditorController:_normal_mode_keys(k) self:_move_sel('down', n) buf:clear_loaded() input:clear() + self.editing = false self:update_status() end @@ -714,6 +724,8 @@ function EditorController:_normal_mode_keys(k) --- open the selected block for editing (spec 2.2: Enter) local function open() load_selection() + self.editing = true + self:update_status() block_input() end --- spec 2.3: Shift+Esc discards the edit; on an empty @@ -722,11 +734,13 @@ function EditorController:_normal_mode_keys(k) if not Key.ctrl() and Key.shift() and k == "escape" then - if is_empty then + if is_empty and not self.editing then self:close_buffer() else buf:clear_loaded() input:clear() + self.editing = false + self:update_status() end block_input() end @@ -796,6 +810,8 @@ function EditorController:_normal_mode_keys(k) if Key.ctrl() and k == "w" then buf:clear_loaded() input:clear() + self.editing = false + self:update_status() end end @@ -805,7 +821,7 @@ function EditorController:_normal_mode_keys(k) and not Key.alt() if is_empty and plain_enter then - open() + if not self.editing then open() end else submit() end diff --git a/src/view/input/statusline.lua b/src/view/input/statusline.lua index 71fabfd2..20bdc879 100644 --- a/src/view/input/statusline.lua +++ b/src/view/input/statusline.lua @@ -102,7 +102,13 @@ function Statusline:draw(status, start_y) end local more_b = morelabel(custom.buffer_more) .. ' ' local more_i = morelabel(status.input_more) .. ' ' - local name = custom.name .. ' ' + local tag = '' + if custom.mode == 'nav' then + tag = '[NAV] ' + elseif custom.mode == 'edit' then + tag = '[EDIT] ' + end + local name = tag .. custom.name .. ' ' gfx.setColor(colors.fg) local font = gfx.getFont() @@ -153,7 +159,7 @@ function Statusline:draw(status, start_y) gfx.print(more_b, s_mb, start_text.y) -- filename gfx.setColor(Color[Color.white]) - gfx.print(custom.name, s_n, start_text.y) + gfx.print(tag .. custom.name, s_n, start_text.y) else --- normal statusline local pos_c = ':' .. c.c From 6d834e4793a57fb134b3137818e4f511201581d9 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Sun, 12 Jul 2026 14:56:13 +0000 Subject: [PATCH 04/52] =?UTF-8?q?fix(editor):=20address=20demo=20review=20?= =?UTF-8?q?=E2=80=94=20arrows,=20open=20position,=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bare Up/Down no longer move the buffer selection while editing; they stay in the input and only navigate blocks in navigation submode - files open at the first block/line instead of the last (BufferModel selection defaults to 1); saved-state restore is unchanged, leaving room for real position memory later - statusline drops the [NAV] label and shows a single yellow E while editing, before the filename --- src/controller/editorController.lua | 2 +- src/model/editor/bufferModel.lua | 5 ++--- src/view/input/statusline.lua | 17 ++++++++++------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 246df6cd..fbe9c71b 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -770,7 +770,7 @@ function EditorController:_normal_mode_keys(k) if k == "end" then self:_move_sel('down', nil, true) end - else + elseif not self.editing then if k == "up" and at_limit_start then self:_move_sel('up') block_input() diff --git a/src/model/editor/bufferModel.lua b/src/model/editor/bufferModel.lua index a809e0f5..cd1a19db 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 diff --git a/src/view/input/statusline.lua b/src/view/input/statusline.lua index 20bdc879..aeb81b3c 100644 --- a/src/view/input/statusline.lua +++ b/src/view/input/statusline.lua @@ -102,13 +102,11 @@ function Statusline:draw(status, start_y) end local more_b = morelabel(custom.buffer_more) .. ' ' local more_i = morelabel(status.input_more) .. ' ' - local tag = '' - if custom.mode == 'nav' then - tag = '[NAV] ' - elseif custom.mode == 'edit' then - tag = '[EDIT] ' + local edit_tag = '' + if custom.mode == 'edit' then + edit_tag = 'E ' end - local name = tag .. custom.name .. ' ' + local name = edit_tag .. custom.name .. ' ' gfx.setColor(colors.fg) local font = gfx.getFont() @@ -158,8 +156,13 @@ function Statusline:draw(status, start_y) gfx.setColor(colors.fg) gfx.print(more_b, s_mb, start_text.y) -- filename + local ew = gfx.getFont():getWidth(edit_tag) + if edit_tag ~= '' then + gfx.setColor(Color[Color.yellow]) + gfx.print(edit_tag, s_n, start_text.y) + end gfx.setColor(Color[Color.white]) - gfx.print(tag .. custom.name, s_n, start_text.y) + gfx.print(custom.name, s_n + ew, start_text.y) else --- normal statusline local pos_c = ':' .. c.c From 53b1bf2d23259d11a27533b4f22aeace43f02c3d Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Sun, 12 Jul 2026 15:02:00 +0000 Subject: [PATCH 05/52] feat(editor): remember cursor position per file within a session Stores the active buffer's selection and scroll offset by file name when the buffer is closed or popped, and restores them on open when still in range. In-memory for the session only; not persisted across restarts. Fresh files with no remembered position keep opening at the first block. --- src/controller/editorController.lua | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index fbe9c71b..08100f66 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -19,6 +19,7 @@ local function new(M, CC) view = nil, mode = 'edit', editing = false, + pos_memory = {}, } end @@ -37,6 +38,7 @@ end --- @field state EditorState? --- @field mode EditorMode --- @field editing boolean --- submode of 'edit' +--- @field pos_memory table EditorController = class.create(new) --- @param v EditorView @@ -82,6 +84,7 @@ 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:_restore_position(b) self:update_status() self:set_state() self.input:update_view() @@ -114,13 +117,37 @@ 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) 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) + end +end + function EditorController:close_buffer() + self:_remember_position() local bs = self.model.buffers local n_buffers = bs:length() if n_buffers < 2 then From e86bf52a208347d40e6e294e876844078bd39853 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Sun, 12 Jul 2026 15:30:46 +0000 Subject: [PATCH 06/52] fix(editor): scroll the view to the selection on open BufferView opens scrolled to the end of the content, which stopped matching the selection once files started opening at the first block: the buffer showed the end while the selection sat at the top, and the first arrow snapped the view back to the start. On open, follow the selection when no remembered position was restored, so the view and selection agree from the first frame. --- src/controller/editorController.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 08100f66..a55b2d6e 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -84,7 +84,9 @@ 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:_restore_position(b) + 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() @@ -143,7 +145,9 @@ function EditorController:_restore_position(buf) 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 function EditorController:close_buffer() From 9e6ca2c079462c884a67b2869953756b7fa4ba3b Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Mon, 13 Jul 2026 17:52:42 +0000 Subject: [PATCH 07/52] fix(editor): typing in navigation inserts a new block, never replaces Files now open with the first block selected, so typing straight into a fresh file and pressing Enter went down the replace path and destroyed the first block with no undo (compy-ide-typing-overwrites-first-block). Plain Enter now replaces only when a block was deliberately opened (buf.loaded is set by Enter / load_selection); text composed from scratch in navigation goes through the existing insert path and lands at the selection, pushing content down. The destructive replace is unreachable without opening a block first. Also pads the yellow editing marker with a leading space so it no longer collides with the filetype label (compy-ide-edit-indicator-collides-with-filetype). --- src/controller/editorController.lua | 8 +++++++- src/view/input/statusline.lua | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index a55b2d6e..3c222f8e 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -749,7 +749,13 @@ function EditorController:_normal_mode_keys(k) and not Key.shift() and not Key.alt() and Key.is_enter(k) then - self:_handle_submit(replace) + --- replace only what was deliberately opened; + --- fresh text composed in navigation is inserted + if buf.loaded then + self:_handle_submit(replace) + else + self:_handle_submit(add) + end end end --- open the selected block for editing (spec 2.2: Enter) diff --git a/src/view/input/statusline.lua b/src/view/input/statusline.lua index aeb81b3c..adaac9ef 100644 --- a/src/view/input/statusline.lua +++ b/src/view/input/statusline.lua @@ -104,7 +104,7 @@ function Statusline:draw(status, start_y) local more_i = morelabel(status.input_more) .. ' ' local edit_tag = '' if custom.mode == 'edit' then - edit_tag = 'E ' + edit_tag = ' E ' end local name = edit_tag .. custom.name .. ' ' From a83e828c9c9d99d374b9219f266604a459469849 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Mon, 13 Jul 2026 18:35:47 +0000 Subject: [PATCH 08/52] refactor(editor): promote the editing flag to real nav/edit modes The 'edit' mode splits into 'nav' and 'edit' as first- class states, replacing the boolean submode flag from the demo series. Transitions are declared in an explicit table in set_mode: nav reaches edit/reorder/search, the special modes and edit only return to nav. Rejected transitions no longer log or repaint. Behavioral deltas from the flag version: - reorder and search are enterable from nav only; while editing, Ctrl+M / Ctrl+F do nothing until the block is accepted or discarded - a freshly opened buffer always starts in nav - the statusline receives the real mode, the display mapping in _generate_status is gone is_normal() treats both nav and edit as normal-key modes, so the external is_normal_mode() contract is unchanged. --- src/controller/editorController.lua | 66 ++++++++++++----------------- 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 3c222f8e..ee5f7d84 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -17,15 +17,14 @@ local function new(M, CC) ), console = CC, view = nil, - mode = 'edit', - editing = false, + mode = 'nav', pos_memory = {}, } end --- @alias EditorMode --- | 'edit' --- default ---- | 'nav' --- display-only, 'edit' with editing off +--- | 'nav' --- navigating between blocks --- | 'reorder' --- | 'search' @@ -37,7 +36,6 @@ end --- @field view EditorView? --- @field state EditorState? --- @field mode EditorMode ---- @field editing boolean --- submode of 'edit' --- @field pos_memory table EditorController = class.create(new) @@ -84,6 +82,7 @@ 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 @@ -164,7 +163,7 @@ end --- @param m EditorMode --- @return boolean local function is_normal(m) - return m == 'edit' + return m == 'nav' or m == 'edit' end --- @param mode EditorMode @@ -182,8 +181,15 @@ function EditorController:set_mode(mode) end end + local ALLOWED = { + nav = { edit = true, reorder = true, search = true }, + edit = { nav = true }, + reorder = { nav = true }, + search = { nav = true }, + } + local current = self.mode - if is_normal(current) then + if current ~= mode and ALLOWED[current][mode] then if mode == 'reorder' then set_reorg() end @@ -191,14 +197,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 @@ -298,9 +299,6 @@ function EditorController:_generate_status(sel) local more = bufview.content:get_more() local cs local m = self.mode - if m == 'edit' and not self.editing then - m = 'nav' - end local ct = bufview.content_type if ct == 'lua' then local range = bufview.content:get_block_app_pos(sel) @@ -321,7 +319,7 @@ end --- @param t string function EditorController:textinput(t) self.view:update_input() - if self.mode == 'edit' then + if is_normal(self.mode) then local input = self.model.input if input:has_error() then input:clear_error() @@ -329,10 +327,7 @@ function EditorController:textinput(t) if Key.ctrl() and Key.shift() then return end - if not self.editing then - self.editing = true - self:update_status() - end + self:set_mode('edit') self.input:textinput(t) end elseif self.mode == 'search' then @@ -471,7 +466,7 @@ function EditorController:_reorg(save) end self.view:refresh() - self:set_mode('edit') + self:set_mode('nav') end --- @private @@ -523,7 +518,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 @@ -536,7 +531,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 @@ -707,9 +702,7 @@ function EditorController:_normal_mode_keys(k) self:_move_sel('down', n) buf:clear_loaded() input:clear() - self.editing = false - - self:update_status() + self:set_mode('nav') end if Key.ctrl() @@ -737,9 +730,7 @@ function EditorController:_normal_mode_keys(k) self:_move_sel('down', n) buf:clear_loaded() input:clear() - self.editing = false - - self:update_status() + self:set_mode('nav') end self:_handle_submit(add) @@ -761,8 +752,7 @@ function EditorController:_normal_mode_keys(k) --- open the selected block for editing (spec 2.2: Enter) local function open() load_selection() - self.editing = true - self:update_status() + self:set_mode('edit') block_input() end --- spec 2.3: Shift+Esc discards the edit; on an empty @@ -771,13 +761,12 @@ function EditorController:_normal_mode_keys(k) if not Key.ctrl() and Key.shift() and k == "escape" then - if is_empty and not self.editing then + if is_empty and self.mode == 'nav' then self:close_buffer() else buf:clear_loaded() input:clear() - self.editing = false - self:update_status() + self:set_mode('nav') end block_input() end @@ -807,7 +796,7 @@ function EditorController:_normal_mode_keys(k) if k == "end" then self:_move_sel('down', nil, true) end - elseif not self.editing then + elseif self.mode == 'nav' then if k == "up" and at_limit_start then self:_move_sel('up') block_input() @@ -847,8 +836,7 @@ function EditorController:_normal_mode_keys(k) if Key.ctrl() and k == "w" then buf:clear_loaded() input:clear() - self.editing = false - self:update_status() + self:set_mode('nav') end end @@ -858,7 +846,7 @@ function EditorController:_normal_mode_keys(k) and not Key.alt() if is_empty and plain_enter then - if not self.editing then open() end + if self.mode == 'nav' then open() end else submit() end From 2365415f8ff9e8c74f33f6ad82449b5345d2d280 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Tue, 14 Jul 2026 10:26:01 +0000 Subject: [PATCH 09/52] refactor(editor): cleanup, and green the spec suite on the new semantics Code: - load_selection loses its dead additive parameter (Shift+Esc no longer additively loads) - mode transition table is a module constant, the exit ritual is a leave_edit() method (also fixes it calling clear on the input model instead of the controller) - add() hoisted to submit scope: the plain-Enter insert dispatch referenced it out of scope, crashing on composing a fresh line in plaintext/markdown buffers - note on device textinput-before-keypressed ordering at the nav->edit transition Tests (suite was green on dev, 26 red after the editor changes; every red encoded pre-rework semantics): - open helper presses Enter, not the now-inert Esc - open-at-top expectations; scroll suites establish the historical EOF position explicitly (walk + scroll_to) - arrows staying in the input while editing, Shift+Esc as discard (additive-load spec repurposed) - insertion suites select the last block before adding 685 successes / 0 failures / 0 errors. --- src/controller/editorController.lua | 98 ++++++++++++++--------------- tests/editor/buffer_spec.lua | 9 ++- tests/editor/editor_spec.lua | 86 +++++++++++++++---------- tests/helpers/editor_session.lua | 3 +- 4 files changed, 110 insertions(+), 86 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index ee5f7d84..6d8a24c3 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -166,6 +166,14 @@ local function is_normal(m) 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() @@ -181,15 +189,8 @@ function EditorController:set_mode(mode) end end - local ALLOWED = { - nav = { edit = true, reorder = true, search = true }, - edit = { nav = true }, - reorder = { nav = true }, - search = { nav = true }, - } - local current = self.mode - if current ~= mode and ALLOWED[current][mode] then + if current ~= mode and TRANSITIONS[current][mode] then if mode == 'reorder' then set_reorg() end @@ -212,6 +213,14 @@ function EditorController:is_normal_mode() return is_normal(self.mode) 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 @@ -327,6 +336,9 @@ function EditorController:textinput(t) if Key.ctrl() and Key.shift() then return end + --- NB: on device, textinput precedes keypressed + --- (see dev/docs/compy-input-quirks.md), so this + --- transition lands before the same key's press self:set_mode('edit') self.input:textinput(t) end @@ -621,21 +633,15 @@ function EditorController:_normal_mode_keys(k) paste_k() --- @param add boolean? - local function load_selection(add) + local function load_selection() 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 + input:set_text(t) + input:jump_home() end @@ -700,39 +706,35 @@ function EditorController:_normal_mode_keys(k) self:save(buf) self.view:refresh() self:_move_sel('down', n) - buf:clear_loaded() - input:clear() - self:set_mode('nav') + self:leave_edit() 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 + --- @param newtext Block[] + local function add(newtext) + if not bufv:is_selection_visible() then + return bufv:follow_selection() + end - local approved, oversized = analyze_input(newtext) - if not approved then - if oversized then - reject_oversized(newtext, oversized) - end - return + local approved, oversized = analyze_input(newtext) + if not approved then + if oversized then + reject_oversized(newtext, oversized) 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() - self:set_mode('nav') + return end + local sel = buf:get_selection() + local _, n = buf:insert_content(approved, sel) + self:save(buf) + self.view:refresh() + self:_move_sel('down', n) + self:leave_edit() + end + + if Key.ctrl() + and not Key.shift() + and not Key.alt() + and Key.is_enter(k) then self:_handle_submit(add) end @@ -764,9 +766,7 @@ function EditorController:_normal_mode_keys(k) if is_empty and self.mode == 'nav' then self:close_buffer() else - buf:clear_loaded() - input:clear() - self:set_mode('nav') + self:leave_edit() end block_input() end @@ -834,9 +834,7 @@ function EditorController:_normal_mode_keys(k) end local function clear() if Key.ctrl() and k == "w" then - buf:clear_loaded() - input:clear() - self:set_mode('nav') + self:leave_edit() end end diff --git a/tests/editor/buffer_spec.lua b/tests/editor/buffer_spec.lua index 4d5d615d..5e5483e9 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,12 @@ 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) it('dropping blocks', function() diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 204b0096..8c779348 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()) + --- arrows stay in the input while editing mock.keystroke('end', press) mock.keystroke('down', press) - assert.same(start_sel, buffer:get_selection()) - -- load the empty - mock.keystroke('escape', press) + assert.same(start_sel - 1, buffer:get_selection()) + --- drop the edit, walk to the trailing empty + mock.keystroke('S-escape', press) assert.same({ '' }, input()) - --- add text + mock.keystroke('down', press) + assert.same(start_sel, buffer:get_selection()) + --- 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,8 @@ describe('Editor #editor', function() controller:textinput('r') controller:textinput('t') assert.same({ 'insert' }, input()) - mock.keystroke('escape', press) + mock.keystroke('S-escape', press) + mock.keystroke('return', press) assert.same({ '-- test' }, input()) end) end) @@ -186,12 +193,19 @@ 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) 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) @@ -245,12 +259,19 @@ 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) @@ -409,25 +430,20 @@ 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()) + --- ctrl-moving the selection keeps the input + local loaded = inter:get_text() + mock.keystroke('C-up', press) + assert.same(loaded, 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() + --- Shift+Esc drops the edit and returns to nav 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) @@ -451,9 +467,11 @@ 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('down', press) + mock.keystroke('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) @@ -683,6 +701,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() diff --git a/tests/helpers/editor_session.lua b/tests/helpers/editor_session.lua index 1d033dee..ef831476 100644 --- a/tests/helpers/editor_session.lua +++ b/tests/helpers/editor_session.lua @@ -105,7 +105,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 From e3c072fb4a0f61632b1758bd89f2e6530300825f Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Tue, 14 Jul 2026 10:46:54 +0000 Subject: [PATCH 10/52] feat(editor): active line in the buffer model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the line coordinate under the block selection, per the rework spec's line-wise navigation: - active_line: absolute source line, always inside the selected block; clamped on every selection mutation (set_selection, move_selection, warps) - get_selection_lines(): source-line span of the selected block; singleton for plaintext and for the phantom past-end position - move_line(dir): steps by one line, crossing block boundaries — down lands on the next block's first line, up on the previous block's last Block selection stays the source of truth for open, replace, and insert; the line is a refinement inside it. For plaintext, blocks are lines, so move_line degenerates to block movement. Covered by a new line-navigation spec group; suite at 692 green. --- src/model/editor/bufferModel.lua | 60 ++++++++++++++++++++++++++++++++ tests/editor/buffer_spec.lua | 58 ++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/src/model/editor/bufferModel.lua b/src/model/editor/bufferModel.lua index cd1a19db..5cdcf358 100644 --- a/src/model/editor/bufferModel.lua +++ b/src/model/editor/bufferModel.lua @@ -90,6 +90,7 @@ local function new( revmap = {}, semantic = semantic, selection = sel, + active_line = 1, readonly = readonly } local id = tostring(self):gsub('table: ', '') @@ -108,6 +109,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? @@ -213,10 +215,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 @@ -227,12 +231,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 @@ -245,6 +251,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 @@ -280,6 +287,59 @@ 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 + +--- 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 + +--- 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() diff --git a/tests/editor/buffer_spec.lua b/tests/editor/buffer_spec.lua index 5e5483e9..8d5f1578 100644 --- a/tests/editor/buffer_spec.lua +++ b/tests/editor/buffer_spec.lua @@ -245,6 +245,64 @@ print(sierpinski(4))]]) 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('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() local delbuf = table.clone(buffer) delbuf:move_selection('up', nil, true) From e7c667cb84343acb7af608889e4cab44733b5ec1 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Tue, 14 Jul 2026 10:51:43 +0000 Subject: [PATCH 11/52] feat(editor): draw the active line, follow it on scroll - draw_highlight paints the active line's wrapped rows a shade brighter (fg at .125 alpha) inside the block highlight; no new palette keys, tunable later - BufferView:follow_line() scrolls just enough to keep the active line's wrapped rows in range, the line-wise sibling of follow_selection - covered by a spec walking the line out of the viewport in both directions; suite at 693 green --- src/view/editor/bufferView.lua | 28 ++++++++++++++++++++++++++++ tests/editor/editor_spec.lua | 21 +++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/view/editor/bufferView.lua b/src/view/editor/bufferView.lua index bd134042..5b8de5b9 100644 --- a/src/view/editor/bufferView.lua +++ b/src/view/editor/bufferView.lua @@ -268,6 +268,21 @@ function BufferView:follow_selection() end 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 +344,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/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 8c779348..bc490d87 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -209,6 +209,27 @@ describe('Editor #editor', function() 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') From f017167d78d70ecf3e473cad17c622e63556b442 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Tue, 14 Jul 2026 10:55:48 +0000 Subject: [PATCH 12/52] feat(editor): line-wise navigation keys, open lands on the line Spec 2.2 key semantics for navigation: - bare Up/Down in nav move the active line via _move_line + follow_line; Ctrl+Up/Down stay block-wise (_move_sel), Ctrl+Home/End warp as before - open() places the input cursor on the row of the active line inside the block, not on line 1 Spec migration: - select_block helper and lua block walks use Ctrl arrows, since bare arrows are line-wise now - the selection-scroll suite asserts visibility invariants (active line in range, caps at the ends) instead of offsets hand-tuned to follow_selection; the old TODO-marked fudge constants are gone - warp-to-bottom asserts selection and visibility 693 green. --- src/controller/editorController.lua | 25 +++++++-- tests/editor/editor_spec.lua | 87 +++++++++++------------------ tests/helpers/editor_session.lua | 3 +- 3 files changed, 57 insertions(+), 58 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 6d8a24c3..6f80b471 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') @@ -434,6 +435,17 @@ end --- @param by integer? --- @param warp boolean? --- @param moved integer? +--- 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() + end +end + function EditorController:_move_sel(dir, by, warp, moved) local buf = self:get_active_buffer() if self.input:has_error() then return end @@ -753,7 +765,10 @@ function EditorController:_normal_mode_keys(k) end --- open the selected block for editing (spec 2.2: Enter) local function open() + local span = buf:get_selection_lines() + local row = buf:get_active_line() - span.start + 1 load_selection() + self.input:set_cursor(Cursor(row, 1)) self:set_mode('edit') block_input() end @@ -797,12 +812,14 @@ function EditorController:_normal_mode_keys(k) self:_move_sel('down', nil, true) end elseif self.mode == 'nav' then - if k == "up" and at_limit_start then - self:_move_sel('up') + --- spec 2.2: bare arrows move by line, + --- 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 end diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index bc490d87..01adc5a1 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -333,81 +333,63 @@ describe('Editor #editor', function() 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() + --- scroll away, then a line move pulls it back mock.keystroke('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() + --- scroll away downwards, a line move follows mock.keystroke('pagedown', press) mock.keystroke('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) @@ -435,10 +417,9 @@ describe('Editor #editor', function() 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) + --- 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() @@ -488,8 +469,8 @@ describe('Editor #editor', function() assert.same(4, buffer:get_content_length()) local modified = table.clone(sierpinski) local new_print = 'print(sierpinski(3))' - mock.keystroke('down', press) - mock.keystroke('down', 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) diff --git a/tests/helpers/editor_session.lua b/tests/helpers/editor_session.lua index ef831476..76e15236 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( From fe5d5d2c08d31d1c707e79684e6980649757c106 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Tue, 14 Jul 2026 15:25:21 +0000 Subject: [PATCH 13/52] feat(editor): peek scrolling on Ctrl+Alt, page-wise line moves, chord guard Implements the amended 2.2/2.7 navigation split: - Ctrl+Alt+Up/Down peek-scrolls one line, Ctrl+Alt+ PageUp/Down one page; the selection and any open block stay put; identical in nav and edit - bare PageUp/PageDown in nav move the active line by a viewport page (_move_line_page); in editing they do nothing; Ctrl+PageUp/Down keep the warp scroll - typing after a peek returns the view to the active line (nav) or the open block (edit), per 2.2/2.4 - the textinput guard widens from Ctrl+Shift to any Ctrl/Alt chord: device chords leak glyphs (compy-input-quirks, quirk 3); only Shift composes Specs: scroll suites drive peeking through the C-M- chord (the mock already holds lalt); new group covers peek keeping the selection, the chord-glyph drop, page moves, and the view returning on input. 697 green. --- src/controller/editorController.lua | 66 +++++++++++++++++--- tests/editor/editor_spec.lua | 97 +++++++++++++++++++++-------- 2 files changed, 131 insertions(+), 32 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 6f80b471..96e829e0 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -334,9 +334,19 @@ function EditorController:textinput(t) 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 @@ -435,6 +445,19 @@ end --- @param by integer? --- @param warp boolean? --- @param moved integer? +--- 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() + for _ = 1, bv.LINES do + if not buf:move_line(dir) then break end + end + bv:follow_line() + self:update_status() +end + --- Move the active line, keep it in view --- @param dir VerticalDir function EditorController:_move_line(dir) @@ -795,6 +818,27 @@ function EditorController:_normal_mode_keys(k) end end local function navigate() + -- peek: the view moves, the selection stays (2.2) + if Key.ctrl() and Key.alt() then + if k == "up" then + self:_scroll('up', false, 1) + block_input() + end + if k == "down" then + self:_scroll('down', false, 1) + block_input() + end + if k == "pageup" then + self:_scroll('up', false) + block_input() + end + if k == "pagedown" then + self:_scroll('down', false) + block_input() + end + return + end + -- move selection if Key.ctrl() then if k == "up" then @@ -812,8 +856,8 @@ function EditorController:_normal_mode_keys(k) self:_move_sel('down', nil, true) end elseif self.mode == 'nav' then - --- spec 2.2: bare arrows move by line, - --- Ctrl+arrows (above) by block + --- 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() @@ -822,16 +866,24 @@ function EditorController:_normal_mode_keys(k) 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 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 diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 01adc5a1..2be50da4 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -203,6 +203,11 @@ describe('Editor #editor', function() 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() --- selection is at EOF, view at the historical offset assert.same(#sierpinski + 1, buf:get_selection()) @@ -232,27 +237,27 @@ describe('Editor #editor', function() 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 @@ -299,35 +304,35 @@ describe('Editor #editor', function() 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) @@ -348,7 +353,7 @@ describe('Editor #editor', function() it('from below', function() --- scroll away, then a line move pulls it back - mock.keystroke('pageup', press) + mock.keystroke('C-M-pageup', press) mock.keystroke('up', press) assert.same(#sierpinski, buffer:get_selection()) assert.is_true(line_visible()) @@ -370,8 +375,8 @@ describe('Editor #editor', function() end) it('from above', function() --- scroll away downwards, a line move follows - mock.keystroke('pagedown', press) - mock.keystroke('pagedown', press) + mock.keystroke('C-M-pagedown', press) + mock.keystroke('C-M-pagedown', press) mock.keystroke('down', press) assert.same(2, buffer:get_selection()) assert.is_true(line_visible()) @@ -394,6 +399,48 @@ describe('Editor #editor', function() end) end) + describe('peek and page moves', function() + it('peek scrolls, the selection stays', function() + mock.keystroke('C-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()) + 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) + mock.keystroke('S-escape', 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('C-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('C-end', press) + mock.keystroke('down', press) + end) + end) + describe('jumps', function() local sel = table.clone(buffer:get_selection()) it('to top', function() From 3539007837091b806b9ab503b01bcec60e76a84c Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Tue, 14 Jul 2026 15:28:42 +0000 Subject: [PATCH 14/52] feat(editor): Alt+arrows move the block in navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-step block swap with the neighbor, reusing the reorder path (move + rechunk + save): written through immediately, the selection follows the moved block, the view follows the selection. Capped at the buffer edges, refused on readonly buffers and on the phantom past-the-end position. In editing, Alt+arrows keep passing through to the input widget's line swap, completing the amended 2.7 row: Alt moves — the block in nav, the line in edit. 698 green. --- src/controller/editorController.lua | 41 +++++++++++++++++++++++++++++ tests/editor/editor_spec.lua | 22 ++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 96e829e0..26cd3cf5 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -445,6 +445,30 @@ end --- @param by integer? --- @param warp boolean? --- @param moved integer? +--- Swap the selected block with its neighbor (spec 2.7: +--- Alt+arrows in navigation), written through like reorder +--- @param dir VerticalDir +function EditorController:_move_block(dir) + local buf = self:get_active_buffer() + if self.input:has_error() then return end + if buf.readonly then return end + + local sel = buf:get_selection() + local last = buf:get_content_length() + if sel > last then return end + local target = sel - 1 + if dir == 'down' then target = sel + 1 end + if target < 1 or target > last then return end + + buf:move(sel, target) + buf:rechunk() + self:save(buf) + buf:set_selection(target) + self.view:refresh() + self.view:get_current_buffer():follow_selection() + self:update_status() +end + --- Move the active line by a viewport page --- @param dir VerticalDir function EditorController:_move_line_page(dir) @@ -818,6 +842,23 @@ function EditorController:_normal_mode_keys(k) end end local function navigate() + -- move the block: Alt+arrows in nav (2.7); in + -- editing Alt passes through to the input widget, + -- which moves the line + if Key.alt() and not Key.ctrl() then + if self.mode == 'nav' then + if k == "up" then + self:_move_block('up') + block_input() + end + if k == "down" then + self:_move_block('down') + block_input() + end + end + return + end + -- peek: the view moves, the selection stays (2.2) if Key.ctrl() and Key.alt() then if k == "up" then diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 2be50da4..8307ce09 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -500,6 +500,28 @@ describe('Editor #editor', function() --- end plaintext describe('structured (lua) works', function() + it('moves the block with Alt+arrows', function() + local controller, press = wire(TU.mock_view_cfg()) + local save, savefile = TU.get_save_function(sierpinski) + controller:open('sierpinski.lua', sierpinski, save) + local buffer = controller:get_active_buffer() + local first = buffer:get_selected_text() + + mock.keystroke('M-down', press) + --- the block moved down, selection follows it + assert.same(2, buffer:get_selection()) + assert.same(first, buffer:get_selected_text()) + --- and the swap is written through + assert.same('', string.lines(savefile())[1]) + + mock.keystroke('M-up', press) + assert.same(1, buffer:get_selection()) + assert.same(first, buffer:get_selected_text()) + --- capped at the edge + mock.keystroke('M-up', press) + assert.same(1, buffer:get_selection()) + end) + it('changing single line', function() local controller, press = wire(TU.mock_view_cfg()) local save, savefile = TU.get_save_function(sierpinski) From d2bb212a04bc22ce7e3be967e6218f1195c0b2b0 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Tue, 14 Jul 2026 16:31:16 +0000 Subject: [PATCH 15/52] feat(editor): 14-line limit with a visible message, auto-format on open Leave-block gate, part one: - the block size limit is the input view height (cfg.input_max = 14), not the buffer viewport; a 14- line block passes, 15 is refused (spec 9.6). The refusal names the excess in the input's error line, shown until the first fixing keystroke (2.5); the refusing keypress is blocked from the widget so it does not clear its own message - opening a Lua block runs it through the pretty-printer (spec 9.4): a sloppy block is reshaped in the input and thereby dirty from birth (2.4), while the file stays untouched until acceptance; the printer's trailing-empty artifact is trimmed so a properly formatted block loads byte-identical and stays clean Specs: 14-passes/15-refused with the message, and auto-format on open leaving the file alone. 700 green. --- src/controller/editorController.lua | 23 +++++++++++++++++++++ src/view/editor/bufferView.lua | 4 +++- tests/editor/editor_spec.lua | 31 +++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 26cd3cf5..38b6fc34 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -727,8 +727,15 @@ function EditorController:_normal_mode_keys(k) local reject_oversized = function(chunks, idx) local block = chunks[idx] if not block or not block.pos then return end + local n = block.pos:len() + input:set_error({ string.format( + 'block is %d lines, the limit is %d', n, size_limit + ) }) input.model:move_cursor(block.pos.start, 1) input:update_view() + --- the refusing keypress must not reach the + --- widget, or it clears the message it caused + block_input() end --- @param newtext Block[] --- @return Block[]|false @@ -815,6 +822,22 @@ function EditorController:_normal_mode_keys(k) local span = buf:get_selection_lines() local row = buf:get_active_line() - span.start + 1 load_selection() + 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 t = input:get_text() + if string.is_non_empty_string_array(t) then + local pretty = buf.printer(t) + 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 self.input:set_cursor(Cursor(row, 1)) self:set_mode('edit') block_input() diff --git a/src/view/editor/bufferView.lua b/src/view/editor/bufferView.lua index 5b8de5b9..9915182b 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? diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 8307ce09..63b2be59 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -826,6 +826,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], '15 lines', 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) From 95e1b4bfefe0035a3347a8f7bb61fda55b897a70 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Tue, 14 Jul 2026 16:43:00 +0000 Subject: [PATCH 16/52] feat(editor): the leave-block gate (spec 2.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crossing the open block's edge with an arrow, or Ctrl+Up/Down from editing, now goes through the gate: - untouched (input equals the file's block text): leaves freely, writes nothing, and opens the neighbor — downward on its first line, upward on its last (2.4.1/2.4.4). The model's active line is synced to the crossed edge first, since during editing the cursor lives in the input, not the model - changed: accepted through the standard pipeline (validate, auto-format, re-chunk, size check) and written; editing flows on to the neighbor - invalid: the leave is refused, the parser's message shows per 2.5, the block stays; Shift+Esc remains the way out, writing nothing (2.4.3) The refusing keypress is blocked from the widget, as with the size limit. Bare arrows inside the block keep moving the input cursor; only the edge crossing leaves. Specs cover all three paths on pprint-stable fixtures (the suite caught that sierpinski is not: auto-format on open makes it dirty at birth, by design). Note: tests' savefile() reads destructively — read once. 703 green. --- src/controller/editorController.lua | 102 +++++++++++++++++++++++----- src/model/editor/bufferModel.lua | 6 ++ tests/editor/editor_spec.lua | 85 ++++++++++++++++++++--- 3 files changed, 165 insertions(+), 28 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 38b6fc34..59997070 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -706,7 +706,8 @@ function EditorController:_normal_mode_keys(k) --- 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() @@ -804,10 +805,11 @@ function EditorController:_normal_mode_keys(k) self:_handle_submit(add) end - if not Key.ctrl() - and not Key.shift() - and not Key.alt() - and Key.is_enter(k) then + 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 if buf.loaded then @@ -842,6 +844,51 @@ function EditorController:_normal_mode_keys(k) self:set_mode('edit') 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() + end + block_input() + return + end + + 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 (2.4.4); downward the pipeline already + --- left the selection on it + if dir == 'up' then + buf:set_selection(sel0 - 1) + buf:set_active_line(buf:get_selection_lines().fin) + end + self.view:get_current_buffer():follow_line() + open() + block_input() + end + --- spec 2.3: Shift+Esc discards the edit; on an empty --- input it leaves the buffer / editor local function discard() @@ -905,19 +952,29 @@ function EditorController:_normal_mode_keys(k) -- move selection if Key.ctrl() then - if k == "up" then - self:_move_sel('up') - block_input() - end - if k == "down" then - self:_move_sel('down') - block_input() - end - if k == "home" then - self:_move_sel('up', nil, true) - end - if k == "end" then - self:_move_sel('down', nil, true) + 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:_move_sel('up') + block_input() + end + if k == "down" then + self:_move_sel('down') + block_input() + end + if k == "home" then + self:_move_sel('up', nil, true) + end + if k == "end" then + self:_move_sel('down', nil, true) + end end elseif self.mode == 'nav' then --- spec 2.2: bare arrows move by line, bare @@ -938,6 +995,15 @@ function EditorController:_normal_mode_keys(k) 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 diff --git a/src/model/editor/bufferModel.lua b/src/model/editor/bufferModel.lua index 5cdcf358..32d016a1 100644 --- a/src/model/editor/bufferModel.lua +++ b/src/model/editor/bufferModel.lua @@ -302,6 +302,12 @@ function BufferModel:get_active_line() return self.active_line 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() diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 63b2be59..1b7fcf93 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -129,15 +129,15 @@ describe('Editor #editor', function() end mock.keystroke('return', press) assert.same({ turtle_doc[2] }, input()) - --- arrows stay in the input while editing + --- 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 - 1, buffer:get_selection()) - --- drop the edit, walk to the trailing empty + assert.same(start_sel, buffer:get_selection()) + assert.same({ '' }, input()) + --- drop it, compose fresh so the text inserts mock.keystroke('S-escape', press) assert.same({ '' }, input()) - mock.keystroke('down', press) - assert.same(start_sel, buffer:get_selection()) --- compose text (inserted before the empty) controller:textinput('-') controller:textinput('-') @@ -483,11 +483,12 @@ describe('Editor #editor', function() mock.keystroke('return', press) assert.same(inter:get_text(), { selected }) end) - it("doesn't clear on move", function() - --- ctrl-moving the selection keeps the input - local loaded = inter:get_text() - mock.keystroke('C-up', press) - assert.same(loaded, 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('discards', function() --- Shift+Esc drops the edit and returns to nav @@ -522,6 +523,70 @@ describe('Editor #editor', function() assert.same(1, buffer:get_selection()) 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('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(' }) + mock.keystroke('C-down', press) + assert.same('edit', controller:get_mode()) + assert.same(1, buffer:get_selection()) + assert.is_true(inter:has_error()) + --- Shift+Esc still gets out, writing nothing + mock.keystroke('S-escape', 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) From 91cf87fffc436cb6dcc80b3cc135aad697bdf400 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Tue, 14 Jul 2026 17:17:18 +0000 Subject: [PATCH 17/52] feat(editor): Ctrl+Alt+Left/Right double the page peek Ctrl+Alt+PgUp/PgDn is a four-key chord on the device keyboard (PgUp/PgDn live behind Fn), too much for a child's hand. Left/Right on the same Ctrl+Alt anchor peek a page up/down; the PgUp/PgDn variants stay for full-size keyboards. The whole peek now sits on one chord: vertical arrows by a line, horizontal by a page. --- src/controller/editorController.lua | 10 ++++++++++ tests/editor/editor_spec.lua | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 59997070..e4ca8ac9 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -947,6 +947,16 @@ function EditorController:_normal_mode_keys(k) self:_scroll('down', false) block_input() 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) + block_input() + end + if k == "right" then + self:_scroll('down', false) + block_input() + end return end diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 1b7fcf93..23961011 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -409,6 +409,13 @@ describe('Editor #editor', function() 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') From 243c9cbb1cc7155db34a2fff3672917b727c1e08 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Tue, 14 Jul 2026 17:43:33 +0000 Subject: [PATCH 18/52] feat(editor): checkpoints on Ctrl+K, restore on Ctrl+Shift+K (2.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last unimplemented spec chunk. Console side: write_checkpoint copies the file to {name}.~save via FS.cp, restore_checkpoint copies it back, modtimes via FS.getInfo; the REPL gains revert(name) (default main.lua), restoring without a prompt and returning false when no checkpoint exists. Editor side, proposal for Open item 1 — confirmation by repeated press on the 2.5 message channel (Compy has no dialog machinery, and the inert Esc finally earns a role): - Ctrl+K writes the checkpoint; if one exists, the first press shows its date and asks, the second overwrites, any other key (Esc included) cancels - Ctrl+Shift+K always asks, showing the checkpoint's and the file's dates; the second press restores and reloads the open buffer from disk - in editing, Ctrl+K accepts the open block first, so the checkpoint reflects the screen; a refused block aborts the checkpoint with its message showing Flow covered by specs with a stubbed console; the FS copy path itself needs the device/LÖVE pass. 708 green. --- src/controller/consoleController.lua | 54 ++++++++++++++++++ src/controller/editorController.lua | 73 +++++++++++++++++++++++++ tests/editor/editor_spec.lua | 82 ++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+) diff --git a/src/controller/consoleController.lua b/src/controller/consoleController.lua index 5196e957..cd81115f 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) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index e4ca8ac9..849113c2 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -20,6 +20,7 @@ local function new(M, CC) view = nil, mode = 'nav', pos_memory = {}, + pending_confirm = nil, } end @@ -38,6 +39,7 @@ end --- @field state EditorState? --- @field mode EditorMode --- @field pos_memory table +--- @field pending_confirm string? --- 'overwrite'|'restore' EditorController = class.create(new) --- @param v EditorView @@ -150,6 +152,14 @@ function EditorController:_restore_position(buf) 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 @@ -889,6 +899,62 @@ function EditorController:_normal_mode_keys(k) block_input() 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() + + 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 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) + + if Key.shift() then + if not cp_time then + input:set_error({ 'no checkpoint to restore' }) + return + end + if self.pending_confirm == 'restore' then + self.pending_confirm = nil + if con:restore_checkpoint(name) then + local text = con:_readfile(name) + self:reload_active(text) + end + return + end + self.pending_confirm = 'restore' + input:set_error({ string.format( + 'restore from checkpoint %s over file %s?' + .. ' Ctrl+Shift+K again restores, Esc cancels', + stamp(cp_time), stamp(con:file_modtime(name)) + ) }) + return + end + + if cp_time and self.pending_confirm ~= 'overwrite' then + self.pending_confirm = 'overwrite' + input:set_error({ string.format( + 'checkpoint from %s exists;' + .. ' Ctrl+K again overwrites, Esc cancels', + stamp(cp_time) + ) }) + return + end + self.pending_confirm = nil + con:write_checkpoint(name) + end + --- spec 2.3: Shift+Esc discards the edit; on an empty --- input it leaves the buffer / editor local function discard() @@ -1057,6 +1123,7 @@ function EditorController:_normal_mode_keys(k) else submit() end + checkpoint_key() discard() delete() navigate() @@ -1070,6 +1137,12 @@ end --- @param k string function EditorController:keypressed(k) self.input:update_view() + if self.pending_confirm + and not (Key.ctrl() and k == 'k') then + --- anything else cancels the confirmation (Esc + --- included); the message clears with the keypress + self.pending_confirm = nil + end local mode = self.mode if Key.ctrl() then diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 23961011..71049bdf 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -530,6 +530,88 @@ describe('Editor #editor', function() assert.same(1, 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, second press writes', function() + cp_time = 1752400000 + mock.keystroke('C-k', press) + assert.same({}, calls) + assert.is_true(inter:has_error()) + mock.keystroke('C-k', 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('C-S-k', 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) + describe('leave gate (2.4)', function() require("tests.helpers.codesnippets") local controller, press, buffer, inter, savefile From 2eed10044b6fe9f51f75ebfa208da71ff315d54d Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Tue, 14 Jul 2026 22:19:39 +0000 Subject: [PATCH 19/52] feat(editor): mouse selection, cursor-to-error, refusal frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining 2.4/2.9 tails: Mouse (2.9), geometry split from semantics: - BufferView:line_at(y) maps a pixel row through the scroll offset and wrap_reverse to a source line - BufferModel:block_at_line(ln) finds the owning block - EditorController:mouse_select(ln): in nav, selects the clicked block and line; while editing, a click inside the open block places the input cursor on that line, a click outside flows out when the block is untouched, and asks to accept or discard when dirty - buffer-area clicks route editor:mousepressed via the console; input-strip clicks keep going to the widget Cursor-to-error (2.4.3): an eval refusal moves the input cursor to the first error's line before the message shows. _handle_submit now reports acceptance, and submit() blocks the widget passthrough on refusal — the same self-clearing-message bug the size limit had. Refusal frame: render_error draws a line rectangle in the error color around the message (the message replaces the input until the first fixing keystroke, so framing it is framing the refusal). Sound stays out pending an actual sound asset. 714 green; line_at and the frame are device-check items. --- src/controller/consoleController.lua | 2 +- src/controller/editorController.lua | 65 +++++++++++++++++++++++++-- src/model/editor/bufferModel.lua | 16 +++++++ src/view/editor/bufferView.lua | 14 ++++++ src/view/input/userInputView.lua | 6 +++ tests/editor/editor_spec.lua | 66 +++++++++++++++++++++++++++- 6 files changed, 164 insertions(+), 5 deletions(-) diff --git a/src/controller/consoleController.lua b/src/controller/consoleController.lua index cd81115f..f98b079e 100644 --- a/src/controller/consoleController.lua +++ b/src/controller/consoleController.lua @@ -1095,7 +1095,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/editorController.lua b/src/controller/editorController.lua index 849113c2..ad36110b 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -385,6 +385,8 @@ end --- @private --- @param go fun(nt: string[]|Block[]) +--- @param go function +--- @return boolean accepted --- false on an eval refusal function EditorController:_handle_submit(go) local inter = self.input local raw = inter:get_text() @@ -395,7 +397,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) @@ -442,12 +444,21 @@ function EditorController:_handle_submit(go) local eval_err = res if eval_err then 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) end + return true end --- @private @@ -455,6 +466,52 @@ end --- @param by integer? --- @param warp boolean? --- @param moved integer? +--- 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 + local clean = string.unlines(self.input:get_text()) + == string.unlines(buf:get_selected_text()) + if not clean then + self.input:set_error({ + 'accept (Enter) or discard (Shift+Esc) first' + }) + return + end + self:leave_edit() + 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 +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 + return self:mouse_select(ln) + end + end + self.input:mousepressed(x, y, btn, touch, presses) +end + --- Swap the selected block with its neighbor (spec 2.7: --- Alt+arrows in navigation), written through like reorder --- @param dir VerticalDir @@ -822,11 +879,13 @@ function EditorController:_normal_mode_keys(k) 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 - self:_handle_submit(replace) + accepted = self:_handle_submit(replace) else - self:_handle_submit(add) + 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) diff --git a/src/model/editor/bufferModel.lua b/src/model/editor/bufferModel.lua index 32d016a1..c28e9e9b 100644 --- a/src/model/editor/bufferModel.lua +++ b/src/model/editor/bufferModel.lua @@ -302,6 +302,22 @@ 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 diff --git a/src/view/editor/bufferView.lua b/src/view/editor/bufferView.lua index 9915182b..52f267a5 100644 --- a/src/view/editor/bufferView.lua +++ b/src/view/editor/bufferView.lua @@ -270,6 +270,20 @@ 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() diff --git a/src/view/input/userInputView.lua b/src/view/input/userInputView.lua index a9375317..acc3fa98 100644 --- a/src/view/input/userInputView.lua +++ b/src/view/input/userInputView.lua @@ -258,6 +258,12 @@ function UserInputView:render_error(err_text) drawBackground() gfx.setColor(colors.input.error) + --- the refusal frame (spec 2.4.3) + gfx.rectangle("line", + 1, + fh + 1, + drawableWidth - 2, + apparentHeight * fh - 2) for l, str in ipairs(err_text) do local breaks = 0 -- starting height is already calculated diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 71049bdf..456ecc3f 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -612,6 +612,65 @@ describe('Editor #editor', function() end) 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 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 dirty click outside asks to resolve', function() + mock.keystroke('return', press) + inter:set_text({ 'function dirty()', 'end' }) + controller:mouse_select(6) + 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 @@ -664,11 +723,16 @@ describe('Editor #editor', function() it('invalid block refuses to leave', function() mock.keystroke('return', press) - inter:set_text({ 'function broken(' }) + 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 mock.keystroke('S-escape', press) assert.same('nav', controller:get_mode()) From 798f33f9cd9dca685b89453fb22ff482725c4f71 Mon Sep 17 00:00:00 2001 From: dsent <8774536+dsent@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:08:43 +0200 Subject: [PATCH 20/52] docs(keymap): correct README + EDITOR keymaps to 1b86c90 behavior The Keys tables in README.md and doc/EDITOR.md were stale and mutually inconsistent. Correct them to the behavior verified against the shipped editor build 1b86c90: toggle edit/run = Ctrl+T (was F8/F9), stop project = Ctrl+S (was Ctrl+Shift+S), quit project = Ctrl+Q (EDITOR had Ctrl+Shift+Q), leave editor = Shift+Esc / Ctrl+Shift+S, drop the stale editor Esc/Ctrl+S/Ctrl+Y rows, and add the input clipboard/selection keys. These tables document commit 1b86c90. The editor-stage1 (2eed100) keymap update follows as a separate change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0177PwS4Xws9YFAGqJMADY5c --- README.md | 33 ++++++++++++++++++--------------- doc/EDITOR.md | 37 ++++++++++++++++++++++--------------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 7bc3cf34..db55152a 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,6 +50,10 @@ 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:_ | | @@ -59,33 +63,31 @@ a project must be selected first. | Move selection | Ctrl+/ | | Replace selection with input | Enter ⏎ | |        _additionally_ | | +| Open selected block for editing (empty input) | Enter ⏎ | | Insert input contents before selection | Ctrl+Enter ⏎ | | Insert empty block before current (if input is empty) | Shift+Enter ⏎ | | 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 | +| Discard edit / back out one level | Shift+Esc | +| Follow the require under selection | Ctrl+O | | 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 +214,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..b4aaf8d0 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,6 +39,11 @@ | 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:_ | @@ -47,19 +53,20 @@ | Move selection | Ctrl+/ | | Replace selection with input | Enter ⏎ | |        _additionally_ | +| Open selected block for editing (empty input) | Enter ⏎ | | 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 | +| Discard edit / back out one level | Shift+Esc | +| Follow the require under selection | Ctrl+O | +| 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 | +| Move selection to end | Ctrl+End | | Wipe input | Ctrl+W | -| Duplicate current line | Ctrl+D | -| Stop editor | Ctrl+Shift+S | +| Leave editor (close all buffers) | Ctrl+Shift+S | ### Usage @@ -73,14 +80,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) From 6bade2c87054b8db25a9cf54e99845c9c0c7358b Mon Sep 17 00:00:00 2001 From: dsent <8774536+dsent@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:23:49 +0200 Subject: [PATCH 21/52] docs(keymap): update editor keymap to editor-stage1 (2eed100) Retarget the editor-mode Keys tables in README.md and doc/EDITOR.md to the editor rework at 2eed100: line-wise navigation with Ctrl+arrow block jumps, Alt+arrows to move a block, Ctrl+Alt peek scrolling, block-level Copy/Cut/Paste, and Ctrl+K / Ctrl+Shift+K checkpoint/restore. Platform, input, and search bindings are unchanged from 1b86c90 and retained. Verified: every key literal in editorController.lua at 2eed100 maps to a documented row. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0177PwS4Xws9YFAGqJMADY5c --- README.md | 24 ++++++++++++++++-------- doc/EDITOR.md | 24 +++++++++++++++++------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index db55152a..5a879a30 100644 --- a/README.md +++ b/README.md @@ -57,18 +57,26 @@ a project must be selected first. | 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 (empty input) | Enter ⏎ | +| 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 | -| Wipe input | Ctrl+W | -| Discard edit / back out one level | 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+O | | Scroll to start | Ctrl+PageUp | | Scroll to end | Ctrl+PageDown | diff --git a/doc/EDITOR.md b/doc/EDITOR.md index b4aaf8d0..8223c504 100644 --- a/doc/EDITOR.md +++ b/doc/EDITOR.md @@ -47,15 +47,26 @@ | 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 (empty input) | Enter ⏎ | +| 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 | -| Discard edit / back out one level | 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+O | | Block reorder mode | Ctrl+M | | Search definitions | Ctrl+F | @@ -65,7 +76,6 @@ | Scroll down by one line | Shift+PageDown | | Move selection to start | Ctrl+Home | | Move selection to end | Ctrl+End | -| Wipe input | Ctrl+W | | Leave editor (close all buffers) | Ctrl+Shift+S | ### Usage From 5a0cc94f6a0bd75224bdf61383eec9097eaf2dd0 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 08:04:39 +0000 Subject: [PATCH 22/52] feat(editor): follow require moves to Ctrl+J, Ctrl+O is freed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agreed 2.7 binding was never actually implemented: the spec and the amendments say Ctrl+J (F12 died with the F-row, which Android swallows without root), but the code still had follow_require on Ctrl+O. Ctrl+O now does nothing and stays reserved for a conventional 'open file', so children carry a habit that works in their next editor. The keymap tables inherited from dsent/dev are retargeted with it — they documented the code correctly, it was the code that lagged the spec. 715 green. --- README.md | 2 +- doc/EDITOR.md | 2 +- src/controller/editorController.lua | 7 ++++--- tests/editor/editor_spec.lua | 21 +++++++++++++++++++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5a879a30..1ac35946 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ a project must be selected first. | 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+O | +| Follow the require under selection | Ctrl+J | | Scroll to start | Ctrl+PageUp | | Scroll to end | Ctrl+PageDown | | Scroll up by one line | Shift+PageUp | diff --git a/doc/EDITOR.md b/doc/EDITOR.md index 8223c504..65175e2d 100644 --- a/doc/EDITOR.md +++ b/doc/EDITOR.md @@ -67,7 +67,7 @@ | 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+O | +| Follow the require under selection | Ctrl+J | | Block reorder mode | Ctrl+M | | Search definitions | Ctrl+F | | Scroll to start | Ctrl+PageUp | diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index ad36110b..a5884a82 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -1159,9 +1159,10 @@ 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 diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 456ecc3f..ee533342 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -612,6 +612,27 @@ describe('Editor #editor', function() end) 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 From 0cb93265522b76f056ea0f8a3b10fd9eb4d15a06 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 08:39:53 +0000 Subject: [PATCH 23/52] feat(editor): knock on every refused action (spec 2.4.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One sound for all refusals, as agreed: knock from the existing util.audio library — no new asset, and the IDE already ships the sound bank for projects. EditorController:refuse(msg) knocks and optionally shows the message; every refusal now goes through it: - a block the parser or the size check rejects - Ctrl+Shift+K with no checkpoint to restore - a mouse click away from a changed block - Ctrl+J with no require to follow (was silent) - an arrow at the end of the file (was silent) knock is deliberately neutral: hitting the end of a file is normal navigation, not a mistake, and it will sound dozens of times per lesson — 'wrong' would be judging the child, and anything musical would grate. util.audio is required inside refuse(), not at the top: it builds its sources on load and needs love.audio ready, while the editor controller is required before love is mocked in tests. The mock gains a love.audio stub that records what was played. 716 green; the mute-device behavior stays a device check. --- src/controller/editorController.lua | 22 +++++++++++++++++++--- tests/editor/editor_spec.lua | 28 ++++++++++++++++++++++++++++ tests/mock.lua | 16 ++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index a5884a82..ebc01f21 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -114,6 +114,8 @@ function EditorController:follow_require() if reqsel then local name = reqsel.name self.console:edit(name .. '.lua') + else + self:refuse() end end @@ -224,6 +226,16 @@ 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() @@ -443,6 +455,7 @@ function EditorController:_handle_submit(go) 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) @@ -485,7 +498,7 @@ function EditorController:mouse_select(ln) local clean = string.unlines(self.input:get_text()) == string.unlines(buf:get_selected_text()) if not clean then - self.input:set_error({ + self:refuse({ 'accept (Enter) or discard (Shift+Esc) first' }) return @@ -557,6 +570,9 @@ function EditorController:_move_line(dir) 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 @@ -796,7 +812,7 @@ function EditorController:_normal_mode_keys(k) local block = chunks[idx] if not block or not block.pos then return end local n = block.pos:len() - input:set_error({ string.format( + self:refuse({ string.format( 'block is %d lines, the limit is %d', n, size_limit ) }) input.model:move_cursor(block.pos.start, 1) @@ -981,7 +997,7 @@ function EditorController:_normal_mode_keys(k) if Key.shift() then if not cp_time then - input:set_error({ 'no checkpoint to restore' }) + self:refuse({ 'no checkpoint to restore' }) return end if self.pending_confirm == 'restore' then diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index ee533342..ebf91c9a 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -612,6 +612,34 @@ describe('Editor #editor', function() 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('C-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('C-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) + it('follows the require on Ctrl+J', function() require("tests.helpers.codesnippets") local controller, press = wire(TU.mock_view_cfg()) 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, } From c8b12faa7342e4d75a80dc9398814becef9c8f6f Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 09:03:32 +0000 Subject: [PATCH 24/52] fix(editor): knock on the refusals that were still silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditing the sound against the agreed list found four gaps — the reported coverage was wider than the code: - search text matching nothing never knocked at all, though it is on the list (SearchController checks resultset, not get_results(), which is only the visible slice, and only knocks on the transition to empty so every further keystroke stays quiet) - Ctrl+Up/Down block jumps at the file's edges - PageUp/PageDown when not a single line can move - Alt+Up/Down moving a block past an edge, and on a readonly buffer - leaving a clean block with nowhere further to go Spec drives each case through the keyboard and asserts the sound, including that a working move stays silent; the phantom line past the last block is a legitimate stop, so the first arrow there is a move, not a refusal. 718 green. --- src/controller/editorController.lua | 15 ++++-- src/controller/searchController.lua | 8 +++ tests/editor/editor_spec.lua | 84 +++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index ebc01f21..5fe8ba0c 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -531,14 +531,16 @@ end function EditorController:_move_block(dir) local buf = self:get_active_buffer() if self.input:has_error() then return end - if buf.readonly then return end + if buf.readonly then return self:refuse() end local sel = buf:get_selection() local last = buf:get_content_length() - if sel > last then return end + if sel > last then return self:refuse() end local target = sel - 1 if dir == 'down' then target = sel + 1 end - if target < 1 or target > last then return end + if target < 1 or target > last then + return self:refuse() + end buf:move(sel, target) buf:rechunk() @@ -555,9 +557,12 @@ 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 @@ -590,6 +595,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 @@ -951,6 +958,8 @@ function EditorController:_normal_mode_keys(k) if buf:move_line(dir) then self.view:get_current_buffer():follow_line() open() + else + self:refuse() end block_input() return 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/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index ebf91c9a..b080b184 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -640,6 +640,90 @@ describe('Editor #editor', function() assert.same('assets/sounds/knock.ogg', played[#played]) 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('C-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') + + --- Alt+arrow moving a block past the edge + mock.keystroke('C-home', press) + assert.is_true(knocked(function() + mock.keystroke('M-up', press) + end), 'block move at the edge') + + --- 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()) From 5bf8976dfe7a1d45645df6e705db7e17efc39221 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 10:21:01 +0000 Subject: [PATCH 25/52] fix(editor): Ctrl+Delete drops a block only in navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per 2.7 the key means 'delete the block' in navigation and 'delete the next word' while editing, but the handler was not gated on the mode: Ctrl+Delete inside an open block dropped the whole block instead of a word — destructive, with no undo. 719 green. --- src/controller/editorController.lua | 5 ++++- tests/editor/editor_spec.lua | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 5fe8ba0c..a91cecaf 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -1053,8 +1053,11 @@ function EditorController:_normal_mode_keys(k) 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 Key.ctrl() and self.mode == 'nav' then if k == "delete" then delete_block() block_input() diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index b080b184..68f257aa 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -640,6 +640,35 @@ describe('Editor #editor', function() assert.same('assets/sounds/knock.ogg', played[#played]) 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()) + mock.keystroke('S-escape', 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()) From f5de60200e8bafb949ae55d05392baafc7e75a2a Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 10:22:25 +0000 Subject: [PATCH 26/52] fix(editor): Ctrl+Up/Down follow the spec's block-jump rule Per 2.2 the paragraph-jump convention is asymmetric: Ctrl+Down goes to the next block's first line, while Ctrl+Up first returns to the current block's first line and only jumps to the previous block when the active line is already there. The controller called _move_sel, which always changed the block, so Ctrl+Up skipped past the head of a long block the reader was in the middle of. BufferModel:jump_block(dir) implements the rule and the controller gains _jump_block for the nav binding; _move_sel keeps serving reorder, the Ctrl+Home/End warps and the acceptance tails untouched. 720 green. --- src/controller/editorController.lua | 17 +++++++++++-- src/model/editor/bufferModel.lua | 17 +++++++++++++ tests/editor/buffer_spec.lua | 39 +++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index a91cecaf..8514c365 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -551,6 +551,19 @@ function EditorController:_move_block(dir) self:update_status() 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) @@ -1125,11 +1138,11 @@ function EditorController:_normal_mode_keys(k) end else if k == "up" then - self:_move_sel('up') + self:_jump_block('up') block_input() end if k == "down" then - self:_move_sel('down') + self:_jump_block('down') block_input() end if k == "home" then diff --git a/src/model/editor/bufferModel.lua b/src/model/editor/bufferModel.lua index c28e9e9b..586fb6f8 100644 --- a/src/model/editor/bufferModel.lua +++ b/src/model/editor/bufferModel.lua @@ -333,6 +333,23 @@ function BufferModel:clamp_active_line() 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 diff --git a/tests/editor/buffer_spec.lua b/tests/editor/buffer_spec.lua index 8d5f1578..a09d4fac 100644 --- a/tests/editor/buffer_spec.lua +++ b/tests/editor/buffer_spec.lua @@ -290,6 +290,45 @@ print(sierpinski(4))]]) 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) From c4cbde37e3b96d57d8e4caec7c139a8f446a0dc4 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 10:24:24 +0000 Subject: [PATCH 27/52] fix(editor): acceptance in place keeps the block (spec 2.4.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enter moved the selection down by the number of blocks the acceptance produced, a leftover of the pre-rework 'submit and walk on' flow. Per 2.4.4 only an arrow transition opens a neighbor; acceptance in place stays and scrolls back to the block. The gate depended on that drift to land on the next block, so it now computes the target itself: sel + the accepted block count downwards (an acceptance may split one block into several), sel - 1 upwards, with the active line on the near edge — first line downwards, last upwards. Nowhere to go refuses with the knock instead of silently staying open. Four specs encoded the old drift; migrated, plus one new for the in-place case. 721 green. --- src/controller/editorController.lua | 27 +++++++++++++++++++------ tests/editor/editor_spec.lua | 31 +++++++++++++++++++---------- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 8514c365..dd7d68ce 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -808,6 +808,10 @@ function EditorController:_normal_mode_keys(k) + --- blocks produced by the last acceptance, read by + --- the leave gate to find the neighbor + local accepted_n = 1 + --- handlers --- @param force_accept boolean? --- the leave gate local function submit(force_accept) @@ -875,7 +879,10 @@ function EditorController:_normal_mode_keys(k) local _, n = buf:replace_content(approved) self:save(buf) self.view:refresh() - self:_move_sel('down', n) + accepted_n = n + --- acceptance in place keeps the block and + --- scrolls back to it (2.4.4) + bufv:follow_selection() self:leave_edit() end @@ -985,12 +992,20 @@ function EditorController:_normal_mode_keys(k) return end --- accepted: open the neighbor, cursor on the near - --- line (2.4.4); downward the pipeline already - --- left the selection on it - if dir == 'up' then - buf:set_selection(sel0 - 1) - buf:set_active_line(buf:get_selection_lines().fin) + --- 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 + 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() diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 68f257aa..fbab065c 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -868,6 +868,19 @@ describe('Editor #editor', function() 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') @@ -926,7 +939,8 @@ describe('Editor #editor', function() 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] = '' @@ -961,11 +975,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") @@ -987,9 +999,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), @@ -1035,9 +1046,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") From 94a20a926737a8728c8ab7dd27d19683fcc55884 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 10:34:50 +0000 Subject: [PATCH 28/52] refactor(editor): lift the acceptance pipeline out of the key handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking away from a changed block refused with 'accept or discard first', but 2.9 says such a click follows 2.4 — the block is accepted and written, exactly as an arrow transition would. The mouse could not do that: the pipeline lived in closures inside _normal_mode_keys, reachable only from a keypress. The pipeline is now controller methods — _size_limit, _first_oversized, _reject_oversized and accept_block — so the keyboard gate and the mouse share one path, and the accepted block count moves onto the controller where the gate reads it. _handle_submit propagates the handler's verdict instead of always reporting success, which is what lets a refusal block the widget. That propagation exposed a real bug the extraction would otherwise have hidden: the Ctrl+Enter branch ignored the verdict, so an oversized-block refusal let Enter through to the widget, which cleared the message it had just raised. The size limit only looked enforced because reject_oversized reached into the closure to stop the key itself. 722 green. --- src/controller/editorController.lua | 192 ++++++++++++++-------------- tests/editor/editor_spec.lua | 19 ++- 2 files changed, 116 insertions(+), 95 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index dd7d68ce..f0cbd3fc 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -21,6 +21,7 @@ local function new(M, CC) mode = 'nav', pos_memory = {}, pending_confirm = nil, + accepted_n = 1, } end @@ -40,6 +41,8 @@ end --- @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 @@ -397,8 +400,9 @@ end --- @private --- @param go fun(nt: string[]|Block[]) ---- @param go function ---- @return boolean accepted --- false on an eval refusal +--- @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() @@ -451,7 +455,7 @@ function EditorController:_handle_submit(go) end end end - go(chunks) + return go(chunks) else local eval_err = res if eval_err then @@ -469,7 +473,7 @@ function EditorController:_handle_submit(go) end end else - go(raw) + return go(raw) end return true end @@ -479,6 +483,72 @@ end --- @param by integer? --- @param warp boolean? --- @param moved integer? +--- @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() + self:refuse({ string.format( + 'block is %d lines, the limit is %d', + 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 _, n = buf:replace_content(newtext) + self:save(buf) + self.view:refresh() + self.accepted_n = n + 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 @@ -495,15 +565,16 @@ function EditorController:mouse_select(ln) 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 not clean then - self:refuse({ - 'accept (Enter) or discard (Shift+Esc) first' - }) + if clean then + self:leave_edit() + elseif not self:accept_block() then return end - self:leave_edit() end buf:set_selection(bi) @@ -808,111 +879,44 @@ function EditorController:_normal_mode_keys(k) - --- blocks produced by the last acceptance, read by - --- the leave gate to find the neighbor - local accepted_n = 1 - --- handlers --- @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 - local n = block.pos:len() - self:refuse({ string.format( - 'block is %d lines, the limit is %d', n, size_limit - ) }) - input.model:move_cursor(block.pos.start, 1) - input:update_view() - --- the refusing keypress must not reach the - --- widget, or it clears the message it caused - block_input() - end - --- @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 - end - return false, oversized - end - - --- @param newtext Block[] - local function replace(newtext) - if not bufv:is_selection_visible(true) then - return bufv:follow_selection() - end - - if not buf:loaded_is_sel(true) then - buf:select_loaded() - bufv:follow_selection() - return - end - - local approved, oversized = analyze_input(newtext) - if not approved then - if oversized then - reject_oversized(newtext, oversized) - end - return - end - - local _, n = buf:replace_content(approved) - self:save(buf) - self.view:refresh() - accepted_n = n - --- acceptance in place keeps the block and - --- scrolls back to it (2.4.4) - bufv:follow_selection() - self:leave_edit() - end + --- Insert freshly composed text as new block(s) --- @param newtext Block[] + --- @return boolean accepted local function add(newtext) if not bufv:is_selection_visible() then - return bufv:follow_selection() + bufv:follow_selection() + return false end - local approved, oversized = analyze_input(newtext) - if not approved then - if oversized then - reject_oversized(newtext, oversized) - end - return + local oversized = self:_first_oversized(newtext) + if oversized then + self:_reject_oversized(newtext, oversized) + return false end local sel = buf:get_selection() - local _, n = buf:insert_content(approved, sel) + local _, n = buf:insert_content(newtext, sel) self:save(buf) self.view:refresh() self:_move_sel('down', n) self:leave_edit() + return true end if Key.ctrl() and not Key.shift() and not Key.alt() and Key.is_enter(k) then - self:_handle_submit(add) + --- a refusal must not let the key through, or + --- the widget clears the message it caused + if not self:_handle_submit(add) then + block_input() + end end if force_accept @@ -924,7 +928,7 @@ function EditorController:_normal_mode_keys(k) --- fresh text composed in navigation is inserted local accepted if buf.loaded then - accepted = self:_handle_submit(replace) + accepted = self:accept_block() else accepted = self:_handle_submit(add) end @@ -996,7 +1000,9 @@ function EditorController:_normal_mode_keys(k) --- (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 + accepted_n end + if dir == 'down' then + target = sel0 + self.accepted_n + end if target < 1 or target > buf:get_content_length() then self:refuse() block_input() diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index fbab065c..e7c33340 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -823,10 +823,25 @@ describe('Editor #editor', function() assert.same({ '' }, inter:get_text()) end) - it('a dirty click outside asks to resolve', function() + it('a changed click outside is accepted', function() mock.keystroke('return', press) - inter:set_text({ 'function dirty()', 'end' }) + 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()) From e81cc6ae2f067305d14267035449a7087fc3b0df Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 10:40:16 +0000 Subject: [PATCH 29/52] feat(editor): typing in navigation opens the block at the active line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per 2.1 a printable character starts editing where the reader is: the active line's block opens and a blank line appears at that line, pushing the rest down, to type into. Only on a blank line — an empty block, an empty file — does the text compose a new block instead. The editor did the latter everywhere: typing anywhere opened an empty input and acceptance inserted a new block before the selection, so a child standing inside a function and typing got a new block above it rather than a line in it. That behavior was the demo fix for typing destroying the first block; the spec reaches the same safety a better way. open() is lifted to EditorController:open_block(), since typing needs the same load-and-format path the keyboard uses; the key handler now delegates to it. Row placement inherits the format-on-open caveat already there: a reshaped block can shift the line, so the insert clamps to the input's length. 725 green. --- src/controller/editorController.lua | 102 +++++++++++++++++++--------- tests/editor/editor_spec.lua | 58 ++++++++++++++++ 2 files changed, 127 insertions(+), 33 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index f0cbd3fc..3acb15b9 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -375,7 +375,9 @@ function EditorController:textinput(t) --- NB: on device, textinput precedes keypressed --- (see dev/docs/compy-input-quirks.md), so this --- transition lands before the same key's press - self:set_mode('edit') + if self.mode == 'nav' then + self:start_typing() + end self.input:textinput(t) end elseif self.mode == 'search' then @@ -483,6 +485,70 @@ end --- @param by integer? --- @param warp boolean? --- @param moved integer? +--- 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 + 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() + local block = buf:_get_selected_block() + if not block or block:is_empty() then + self:set_mode('edit') + 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 > #t then row = #t + 1 end + table.insert(t, row, '') + self.input:set_text(t) + self.input:set_cursor(Cursor(row, 1)) +end + --- @return integer --- the input strip's height (2.7) function EditorController:_size_limit() return self.view:get_current_buffer():get_max_size() @@ -865,17 +931,7 @@ function EditorController:_normal_mode_keys(k) paste_k() - --- @param add boolean? - local function load_selection() - 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() - end + @@ -937,27 +993,7 @@ function EditorController:_normal_mode_keys(k) end --- open the selected block for editing (spec 2.2: Enter) local function open() - local span = buf:get_selection_lines() - local row = buf:get_active_line() - span.start + 1 - load_selection() - 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 t = input:get_text() - if string.is_non_empty_string_array(t) then - local pretty = buf.printer(t) - 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 - self.input:set_cursor(Cursor(row, 1)) - self:set_mode('edit') + self:open_block() block_input() end --- Leave the open block through the gate (spec 2.4): diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index e7c33340..86ce46bf 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -640,6 +640,64 @@ describe('Editor #editor', function() assert.same('assets/sounds/knock.ogg', played[#played]) 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('C-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) + it('Ctrl+Delete drops a block only in nav', function() require("tests.helpers.codesnippets") local controller, press = wire(TU.mock_view_cfg()) From 64a7c1305b5b3b3b522b6214c9060ea6ce70bd88 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 10:42:48 +0000 Subject: [PATCH 30/52] feat(editor): Ctrl+Enter opens a fresh block, accepts while editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per 2.7 Ctrl+Enter means 'a new empty block below the current one, open it' in navigation and 'accept the block, back to navigation' while editing; Ctrl+Shift+Enter opens one above. The key did neither: it inserted whatever the input held at the selection. The new block appears on acceptance rather than as a blank line in the file, since blank lines are structural (2.1) and re-chunk regenerates them — Ctrl+Enter just composes at that spot, which is what 'open an empty block' means in this model. The insertion suite composed text straight into the input while the editor stayed in navigation, a state 2.1 does not allow (text in the input means editing). The session helper now enters editing as typing would. 728 green. NB the table also puts a line break on plain Enter while editing, which would move acceptance onto Ctrl+Enter alone. Enter still accepts here — that is a product decision, not a bug, and it is raised separately. --- src/controller/editorController.lua | 44 +++++++++++++++++++----- tests/editor/editor_spec.lua | 53 +++++++++++++++++++++++++++++ tests/helpers/editor_session.lua | 7 ++++ 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 3acb15b9..74a60c5e 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -549,6 +549,24 @@ function EditorController:start_typing() 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 + --- @return integer --- the input strip's height (2.7) function EditorController:_size_limit() return self.view:get_current_buffer():get_max_size() @@ -964,15 +982,25 @@ function EditorController:_normal_mode_keys(k) return true end - if Key.ctrl() - and not Key.shift() - and not Key.alt() - and Key.is_enter(k) then - --- a refusal must not let the key through, or - --- the widget clears the message it caused - if not self:_handle_submit(add) then - block_input() + --- 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 + if not Key.shift() then + local accepted + if buf.loaded then + accepted = self:accept_block() + else + accepted = self:_handle_submit(add) + end + if not accepted then return end end + return end if force_accept diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 86ce46bf..1a73f0dc 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -640,6 +640,59 @@ describe('Editor #editor', function() 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('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 diff --git a/tests/helpers/editor_session.lua b/tests/helpers/editor_session.lua index 76e15236..ddeec0f4 100644 --- a/tests/helpers/editor_session.lua +++ b/tests/helpers/editor_session.lua @@ -120,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 From 156d4eaf1bb8b32bc658ccbb5136feb096350e33 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 10:43:56 +0000 Subject: [PATCH 31/52] fix(editor): bare Home/End reach the file edges in navigation Per 2.7 Home and End alone go to the file's first and last line while navigating, and Ctrl+Home/End belong to the input widget (block start/end) while editing. The bindings were inverted: the warp sat on Ctrl+Home/End in navigation, and bare Home/End fell through to the widget, which moved a cursor nobody was looking at. Specs that warped through Ctrl now use the bare keys, plus one covering both halves of the rule. 730 green. --- src/controller/editorController.lua | 17 +++++++++----- tests/editor/editor_spec.lua | 36 +++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 74a60c5e..9b2d354c 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -1230,14 +1230,19 @@ function EditorController:_normal_mode_keys(k) self:_jump_block('down') block_input() end - if k == "home" then - self:_move_sel('up', nil, true) - end - if k == "end" then - self:_move_sel('down', nil, true) - 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 --- spec 2.2: bare arrows move by line, bare --- pages by a page, Ctrl+arrows (above) by block if k == "up" then diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 1a73f0dc..a64e7322 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -401,7 +401,7 @@ describe('Editor #editor', function() describe('peek and page moves', function() it('peek scrolls, the selection stays', function() - mock.keystroke('C-end', press) + mock.keystroke('end', press) local sel = buffer:get_selection() local r0 = visible.range.start mock.keystroke('C-M-pageup', press) @@ -435,7 +435,7 @@ describe('Editor #editor', function() mock.release_keys() end) it('bare pages move the active line', function() - mock.keystroke('C-home', press) + mock.keystroke('home', press) assert.same(1, buffer:get_active_line()) mock.keystroke('pagedown', press) assert.same(1 + l, buffer:get_active_line()) @@ -443,7 +443,25 @@ describe('Editor #editor', function() 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) @@ -470,14 +488,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) + 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()) @@ -621,7 +639,7 @@ describe('Editor #editor', function() local inter = controller.input --- walking off the end of the file - mock.keystroke('C-end', press) + mock.keystroke('end', press) local n0 = #mock.played_sounds() mock.keystroke('down', press) mock.keystroke('down', press) @@ -630,7 +648,7 @@ describe('Editor #editor', function() assert.same('assets/sounds/knock.ogg', played[#played]) --- and a refused block - mock.keystroke('C-home', press) + mock.keystroke('home', press) mock.keystroke('return', press) inter:set_text({ 'function broken(' }) local n1 = #mock.played_sounds() @@ -727,7 +745,7 @@ describe('Editor #editor', function() it('a blank line becomes a new block', function() --- the trailing empty block - mock.keystroke('C-end', press) + mock.keystroke('end', press) assert.is_true( buffer:_get_selected_block():is_empty()) @@ -802,7 +820,7 @@ describe('Editor #editor', function() --- walk down until the file ends: the last real --- block, then the phantom line past it, both --- legitimate moves - mock.keystroke('C-end', press) + mock.keystroke('end', press) assert.is_false(knocked(function() mock.keystroke('down', press) end), 'stepping onto the phantom line is a move') @@ -823,7 +841,7 @@ describe('Editor #editor', function() end), 'page move at the end') --- Alt+arrow moving a block past the edge - mock.keystroke('C-home', press) + mock.keystroke('home', press) assert.is_true(knocked(function() mock.keystroke('M-up', press) end), 'block move at the edge') From d5bac5d02c44923a9a9c040101fb0515cd3fad1c Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 10:44:13 +0000 Subject: [PATCH 32/52] feat(editor): a double click opens the block (spec 2.9) The presses count LOVE passes was accepted and dropped; a double click now opens the block the first click selected, the mouse counterpart of Enter. 732 green. --- src/controller/editorController.lua | 11 ++++++++++- tests/editor/editor_spec.lua | 13 +++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 9b2d354c..98dcbdcb 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -670,11 +670,20 @@ 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 - return self:mouse_select(ln) + 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) diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index a64e7322..120af73a 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -926,6 +926,19 @@ describe('Editor #editor', function() 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()) From db05cfe58d1908ce30db995b7224b66b5af4bacd Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 10:44:31 +0000 Subject: [PATCH 33/52] fix(editor): the oversize message follows the spec's wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.4 asks for an actionable refusal — 'Too many lines in a block. Remove N lines to save or press Shift+Esc to cancel' — while the message reported the measurement ('block is 15 lines, the limit is 14'), leaving the child to do the subtraction. 732 green. --- src/controller/editorController.lua | 7 +++++-- tests/editor/editor_spec.lua | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 98dcbdcb..074c05a9 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -593,9 +593,12 @@ 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( - 'block is %d lines, the limit is %d', - n, self:_size_limit() + '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() diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 120af73a..916d77a4 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -1387,7 +1387,7 @@ describe('Editor #editor', function() assert.is_true(controller.input:has_error()) local err = controller.input.model.error assert.truthy( - string.find(err[1], '15 lines', 1, true)) + string.find(err[1], 'Remove 1', 1, true)) mock.keystroke('S-escape', press) end) From cbbc540065d16a529f996f1822768934845a174e Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 15 Jul 2026 13:31:29 +0000 Subject: [PATCH 34/52] fix(editor): Ctrl+Enter no longer inserts a stray block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy newline() handler put an empty block into the file whenever Enter arrived with Shift or Ctrl held and the input empty, and it ran before the submit dispatch. Once Ctrl+Enter became 'open a fresh block' (2.7), both fired: the file silently gained a blank line, saved, and only then did the editor open a block to compose in. newline() is what the new binding replaces, so it goes. Shift+Enter keeps reaching the widget, where it is the line break; the empty block now appears where 2.1 says it should — from the re-chunk on acceptance. BufferModel:insert_newline stays, still covered by the buffer specs and reachable for reorder work. 733 green. --- src/controller/editorController.lua | 16 ---------------- tests/editor/editor_spec.lua | 11 +++++++++++ 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 074c05a9..d1871ee3 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -890,20 +890,6 @@ 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 - local function delete_block() local t = string.unlines(buf:get_selected_text()) buf:delete_selected_text() @@ -957,8 +943,6 @@ function EditorController:_normal_mode_keys(k) if is_empty then copycut() end - newline() - paste_k() diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 916d77a4..9dea0612 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -673,6 +673,17 @@ describe('Editor #editor', function() 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) From 2727ef2c7ac37a20230e107b0ea21f45d1fc6178 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Thu, 16 Jul 2026 14:41:09 +0000 Subject: [PATCH 35/52] feat(input): delete the previous word on Ctrl+Backspace / Ctrl+W MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 2.7 puts 'delete the previous word' on Ctrl+Backspace with Ctrl+W as its synonym, deliberately displacing the conventional close binding. Neither existed: the editor used Ctrl+W to leave the block — the very binding the spec displaces — and word-wise deletion was absent from the widget entirely, which only had per-character backspace, delete and Ctrl+Y for a line. UserInputModel:backspace_word() eats the word before the cursor together with the whitespace in front of it, the readline behavior both bindings come from, and falls back to backspace() at a line's start so the line join still works. The widget binds both keys; the editor's Ctrl+W handler is gone, and Shift+Esc remains the way out of a block, as 2.3 says. NB this touches the input widget, which is Gleb's area: the change is additive (a new model method plus two bindings in removers()), it does not alter how the controller dispatches or how existing keys behave. Worth a heads-up before the #77 routing migration. 734 green. --- src/controller/editorController.lua | 7 ------ src/controller/userInputController.lua | 10 +++++++- src/model/input/userInputModel.lua | 35 ++++++++++++++++++++++++++ tests/editor/editor_spec.lua | 25 ++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index d1871ee3..a506a454 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -1294,12 +1294,6 @@ function EditorController:_normal_mode_keys(k) end end end - local function clear() - if Key.ctrl() and k == "w" then - self:leave_edit() - end - end - local plain_enter = Key.is_enter(k) and not Key.ctrl() and not Key.shift() @@ -1314,7 +1308,6 @@ function EditorController:_normal_mode_keys(k) discard() delete() navigate() - clear() if passthrough then input:keypressed(k) diff --git a/src/controller/userInputController.lua b/src/controller/userInputController.lua index ed087f7f..5432204b 100644 --- a/src/controller/userInputController.lua +++ b/src/controller/userInputController.lua @@ -236,7 +236,11 @@ function UserInputController:keypressed(k) -- action categories local function removers() if k == "backspace" then - input:backspace() + if Key.ctrl() then + input:backspace_word() + else + input:backspace() + end end if k == "delete" then input:delete() @@ -245,6 +249,10 @@ function UserInputController:keypressed(k) if k == "y" then input:delete_line() end + --- readline's synonym, per the editor spec 2.7 + if k == "w" then + input:backspace_word() + end end end local function vertical() diff --git a/src/model/input/userInputModel.lua b/src/model/input/userInputModel.lua index a376c7f4..18470402 100644 --- a/src/model/input/userInputModel.lua +++ b/src/model/input/userInputModel.lua @@ -334,6 +334,41 @@ function UserInputModel:backspace() self:text_change() 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: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() +end + function UserInputModel:delete() self:pop_selected_text() local line = self:get_current_line() diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 9dea0612..50c89b34 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -780,6 +780,31 @@ describe('Editor #editor', function() end) 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()) From e676e6b6c12aaecbc0b217dea1d8c02240180251 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Fri, 17 Jul 2026 11:03:56 +0000 Subject: [PATCH 36/52] feat(input): text-level undo of the open block (1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first half of the two-level model: EditHistory, a 32-step ring of {text, cursor} snapshots taken before each mutation. Consecutive same-kind edits continuing at the expected cursor coalesce, and a typed whitespace starts a new step — so Ctrl+Z removes the word just typed, not one letter, without any wall-clock timers (deterministic, hence testable). The history is born with the block: set_text (how every block opens) resets it, so it never leaks across blocks or into the file. All six mutators record: add_text (insert/paste), backspace, delete, backspace_word, delete_line, line_feed. Ctrl+Z / Ctrl+Y while editing work this history via undo_edit/redo_edit; empty history knocks. Navigation undo is the block level, coming next. 735 green. --- src/controller/editorController.lua | 20 +++++++ src/model/input/editHistory.lua | 91 +++++++++++++++++++++++++++++ src/model/input/userInputModel.lua | 79 +++++++++++++++++++++++++ tests/editor/editor_spec.lua | 38 ++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 src/model/input/editHistory.lua diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index a506a454..535ef8ca 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -978,6 +978,26 @@ function EditorController:_normal_mode_keys(k) return true end + --- undo/redo (1.1): the mode picks the level — + --- editing works the text history of the open + --- block, navigation (below) works the file + 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 = (k == 'z') + and im:undo_edit() or (k == 'y') + and im:redo_edit() + if done then + self.input:update_view() + else + self:refuse() + end + return + end + end + --- spec 2.7: Ctrl+Enter opens a fresh block below --- in navigation and accepts while editing; --- Ctrl+Shift+Enter opens one above 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 18470402..2abf40cc 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[]? @@ -49,6 +51,7 @@ function UserInputModel.new(cfg, eval, oneshot, custom_label) oneshot = oneshot, entered = InputText(), history = History(cfg.input_history), + edit_history = EditHistory(32), evaluator = eval, cursor = Cursor(), selection = InputSelection(), @@ -100,9 +103,71 @@ 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) + self.edit_history:record( + self:_edit_snapshot(), kind, boundary or false) +end + +--- @private +--- Remember where the mutation left the cursor +function UserInputModel:_note_edit() + 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 +199,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 +265,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 +273,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 +315,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 +323,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 +378,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,6 +406,7 @@ 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: @@ -356,6 +431,7 @@ 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() @@ -367,9 +443,11 @@ function UserInputModel:backspace_word() 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() @@ -394,6 +472,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/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 50c89b34..ca87a659 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -780,6 +780,44 @@ describe('Editor #editor', function() 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('Ctrl+W and Ctrl+Backspace eat a word', function() require("tests.helpers.codesnippets") local controller, press = wire(TU.mock_view_cfg()) From 42f3eef6abcb79d7100722ac3bea0637b232f4e8 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Fri, 17 Jul 2026 11:07:37 +0000 Subject: [PATCH 37/52] feat(editor): block-level undo of file operations (1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second half of the model: a 32-step ring on the buffer recording every file write as a trimmed diff — the common prefix and suffix of the file before/after are cut, leaving exactly the affected line range, whatever the operation. One recording wrapper (record_write) serves all five write sites: acceptance, insertion, deletion/cut, the Alt block move and the reorder move. Applying a step is a splice plus the same re-chunk and save every write takes; selection restores to the step's remembered side. Ctrl+Z / Ctrl+Y in navigation walk this history; the history lives on the buffer, so it survives a follow- require round trip and dies with the file. Empty history, and any attempt on a readonly buffer, knock. A new write kills the redo tail. Found and fixed by the specs on the way: the dispatch used 'redo and buf:redo() or buf:undo()', and since redo() legitimately returns nil when its stack is empty, the chain fell through to undo — a redo past the tail silently undid instead of refusing. The same trap sat in the edit-level branch. Both are honest ifs now. 740 green. --- src/controller/editorController.lua | 101 ++++++++++++++++++++----- src/model/editor/bufferModel.lua | 113 +++++++++++++++++++++++++++- tests/editor/editor_spec.lua | 85 +++++++++++++++++++++ 3 files changed, 278 insertions(+), 21 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 535ef8ca..0557a4f8 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -567,6 +567,51 @@ function EditorController:new_block(below) 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() @@ -626,10 +671,12 @@ function EditorController:accept_block() self:_reject_oversized(newtext, oversized) return false end - local _, n = buf:replace_content(newtext) - self:save(buf) + self:record_write(buf, function() + local _, n = buf:replace_content(newtext) + self:save(buf) + self.accepted_n = n + end) self.view:refresh() - self.accepted_n = n bufv:follow_selection() self:leave_edit() return true @@ -709,9 +756,11 @@ function EditorController:_move_block(dir) return self:refuse() end - buf:move(sel, target) - buf:rechunk() - self:save(buf) + self:record_write(buf, function() + buf:move(sel, target) + buf:rechunk() + self:save(buf) + end) buf:set_selection(target) self.view:refresh() self.view:get_current_buffer():follow_selection() @@ -798,9 +847,11 @@ 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()) @@ -892,9 +943,11 @@ function EditorController:_normal_mode_keys(k) 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() + love.system.setClipboardText(t) + self:save(buf) + end) self.view:refresh() end @@ -969,9 +1022,12 @@ function EditorController:_normal_mode_keys(k) return false end - local sel = buf:get_selection() - local _, n = buf:insert_content(newtext, sel) - self:save(buf) + 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() @@ -980,22 +1036,27 @@ function EditorController:_normal_mode_keys(k) --- undo/redo (1.1): the mode picks the level — --- editing works the text history of the open - --- block, navigation (below) works the file + --- 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 = (k == 'z') - and im:undo_edit() or (k == 'y') - and im:redo_edit() + 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 - return + else + self:_step_history(k == 'y') end + return end --- spec 2.7: Ctrl+Enter opens a fresh block below diff --git a/src/model/editor/bufferModel.lua b/src/model/editor/bufferModel.lua index 586fb6f8..cb17d1bb 100644 --- a/src/model/editor/bufferModel.lua +++ b/src/model/editor/bufferModel.lua @@ -91,7 +91,9 @@ local function new( semantic = semantic, selection = sel, active_line = 1, - readonly = readonly + readonly = readonly, + history = {}, + redo_history = {} } local id = tostring(self):gsub('table: ', '') self.id = id @@ -131,6 +133,115 @@ 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 + +--- A checkpoint restore or any rewrite from outside the +--- editor is a new baseline (1.1) +function BufferModel:clear_history() + self.history = {} + 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()) diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index ca87a659..d63e1690 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -780,6 +780,91 @@ describe('Editor #editor', function() 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 an Alt block move', function() + local orig = string.unlines( + buffer:get_text_content()) + mock.keystroke('M-down', 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('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('M-down', press) + mock.keystroke('C-z', press) + mock.keystroke('M-down', 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()) From cc76ac83248a45aa409d94e9105be563702bffce Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Fri, 17 Jul 2026 11:10:22 +0000 Subject: [PATCH 38/52] feat(editor): the discard asks; a valid draft is undoable (1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift+Esc was the editor's only irreversible action: a changed block discarded on one keypress, the typed text gone for good. Two agreed guards close that. The ask: discarding a changed block now takes a second Shift+Esc, the same repeated-press confirmation the checkpoints use; any other key cancels. A clean block still leaves instantly. The pair: a confirmed discard of a draft that parses records two history steps — original->draft and draft->original. One Ctrl+Z in navigation puts the discarded text into the file, another takes it back out; redo mirrors. No flags, no block reopening, just two ordinary steps. A draft that does not parse records nothing: writing it would flip the buffer read-only (the 1.0 invariant), so the ask is its only guard. Specs migrated where they discarded dirty blocks with a single press; new specs cover the ask, the cancel, the pair round-trip and the broken-draft case. 743 green. --- src/controller/editorController.lua | 68 ++++++++++++++++++++++++++-- tests/editor/editor_spec.lua | 70 ++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 0557a4f8..445837e9 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -485,6 +485,64 @@ 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 + + if self.pending_confirm ~= 'discard' then + self.pending_confirm = 'discard' + self.input:set_error({ + 'discard the changes?' + .. ' Shift+Esc again to confirm' + }) + return + end + self.pending_confirm = nil + + --- 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 + --- 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) @@ -1222,9 +1280,10 @@ function EditorController:_normal_mode_keys(k) k == "escape" then if is_empty and self.mode == 'nav' then self:close_buffer() - else - self:leave_edit() + block_input() + return end + self:discard_edit() block_input() end end @@ -1399,7 +1458,10 @@ end function EditorController:keypressed(k) self.input:update_view() if self.pending_confirm - and not (Key.ctrl() and k == 'k') then + and not (Key.ctrl() and k == 'k') + and not (self.pending_confirm == 'discard' + and Key.shift() and not Key.ctrl() + and k == 'escape') then --- anything else cancels the confirmation (Esc --- included); the message clears with the keypress self.pending_confirm = nil diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index d63e1690..3e29fde8 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -171,6 +171,8 @@ describe('Editor #editor', function() controller:textinput('r') controller:textinput('t') assert.same({ 'insert' }, input()) + --- the compose is dirty: the discard asks + mock.keystroke('S-escape', press) mock.keystroke('S-escape', press) mock.keystroke('return', press) assert.same({ '-- test' }, input()) @@ -426,6 +428,8 @@ describe('Editor #editor', function() 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) mock.keystroke('S-escape', press) end) it('a held chord glyph is dropped', function() @@ -516,7 +520,7 @@ describe('Editor #editor', function() assert.same({ now }, inter:get_text()) end) it('discards', function() - --- Shift+Esc drops the edit and returns to nav + --- the loaded text is unchanged, so no ask mock.keystroke('S-escape', press) assert.same({ '' }, inter:get_text()) end) @@ -845,6 +849,64 @@ describe('Editor #editor', function() 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()) + + --- second press discards; the file untouched + mock.keystroke('S-escape', 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) + mock.keystroke('S-escape', 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('knocks on empty history', function() local n0 = #mock.played_sounds() mock.keystroke('C-z', press) @@ -950,6 +1012,8 @@ describe('Editor #editor', function() 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) mock.keystroke('S-escape', press) --- navigation: it drops the block @@ -1224,7 +1288,9 @@ describe('Editor #editor', function() --- 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 + --- Shift+Esc still gets out (after the ask), + --- writing nothing + mock.keystroke('S-escape', press) mock.keystroke('S-escape', press) assert.same('nav', controller:get_mode()) assert.same(text, savefile()) From f78de1d77d1c3d3ec0cd2cba28ebe0443182476c Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Fri, 17 Jul 2026 11:10:58 +0000 Subject: [PATCH 39/52] feat(editor): bare Delete, Ctrl+Y freed, restore is a boundary (1.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last pieces of the layout the spec tied to undo: Bare Delete drops the block in navigation — 2.7 gated it on Undo/Redo existing, and it does now; Ctrl+Delete stays as the synonym. While editing, Delete remains the widget's character delete. Ctrl+Y in the input widget was delete-line; redo displaces it, per the agreed layout. Delete-line keeps no key for now (Home, Shift+End, Backspace covers it) until one is picked. A checkpoint restore or revert() rebuilds the buffer through reload_active -> open, so the history is born empty — the new-baseline boundary costs nothing; a spec pins it so a future in-place reload cannot silently lose the boundary. 745 green. --- src/controller/editorController.lua | 11 ++++++----- src/controller/userInputController.lua | 6 +++--- tests/editor/editor_spec.lua | 24 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 445837e9..fb7c82db 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -1291,11 +1291,12 @@ function EditorController:_normal_mode_keys(k) --- navigation; while editing it is the widget's --- delete-next-word local function delete() - if Key.ctrl() and self.mode == 'nav' then - if k == "delete" 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() diff --git a/src/controller/userInputController.lua b/src/controller/userInputController.lua index 5432204b..f7749c3e 100644 --- a/src/controller/userInputController.lua +++ b/src/controller/userInputController.lua @@ -246,9 +246,9 @@ function UserInputController:keypressed(k) input:delete() end if Key.ctrl() then - if k == "y" then - input:delete_line() - end + --- Ctrl+Y is redo since 1.1; delete-line lost + --- its key (reachable via Home, Shift+End, + --- Backspace) until a new one is picked --- readline's synonym, per the editor spec 2.7 if k == "w" then input:backspace_word() diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 3e29fde8..13afca38 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -907,6 +907,30 @@ describe('Editor #editor', function() 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('M-down', 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) From d2e0442dec6b74f5bc8d8abb69c926ff6862254f Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Fri, 17 Jul 2026 11:16:00 +0000 Subject: [PATCH 40/52] refactor(editor): drop the uncalled clear_history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boundary it was written for holds structurally on every path: Ctrl+Shift+K reloads the buffer through reload_active (fresh BufferModel, empty history) and revert() rewrites the file on disk while no buffer is open — the next edit() reads it fresh. A method nobody can call is a promise nobody keeps; the specs covering the restore boundary stay green without it. 745 green. --- src/model/editor/bufferModel.lua | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/model/editor/bufferModel.lua b/src/model/editor/bufferModel.lua index cb17d1bb..3df408f8 100644 --- a/src/model/editor/bufferModel.lua +++ b/src/model/editor/bufferModel.lua @@ -191,13 +191,6 @@ function BufferModel:push_history(before, after, sel_b, sel_a) self.redo_history = {} end ---- A checkpoint restore or any rewrite from outside the ---- editor is a new baseline (1.1) -function BufferModel:clear_history() - self.history = {} - self.redo_history = {} -end - --- @private --- Splice a step's lines into the content and re-chunk --- @param start integer From 95186e57ace36acec603ebe46a646f8db6cefea8 Mon Sep 17 00:00:00 2001 From: dsent <8774536+dsent@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:32:31 +0000 Subject: [PATCH 41/52] =?UTF-8?q?feat(fs):=20durability=20API=20=E2=80=94?= =?UTF-8?q?=20FS.fsync(path)=20and=20FS.sync()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FS.fsync(path) and FS.sync() over a pcall-guarded LuaJIT FFI cdef (open/fsync/close/sync), degrading to a no-op where the syscalls are unavailable. FS.write stays async by contract — bulk deploy/clone and the user-facing writefile must not stall on the card — with durability as an explicit opt-in for callers that promise it. Vendored nativefs is untouched. Interface only; the consumers (the editor's per-accept fsync, the app's lifecycle flushes) live in the editor series. Split from c5866b2. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011WEJighCCuSFparAVqjsWH (cherry picked from commit e5e31ff42aac70d62a2f21a43b0d4363f9384a0e) --- src/util/filesystem.lua | 58 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) 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 From 1efd2cbeafaa2b00a3d9924bb1b18b2148f0f39f Mon Sep 17 00:00:00 2001 From: dsent <8774536+dsent@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:32:39 +0000 Subject: [PATCH 42/52] fix(app): flush pending writes on quit and on background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumers of the FS durability API at the app lifecycle level: FS.sync() on love.quit, and on focus(false) / visible(false) — previously skipped handlers — so a child leaving the app flushes pending writes. One syscall per event; a force-stop mid-edit is covered by the editor's per-accept fsync, not by these nets. Split from c5866b2. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011WEJighCCuSFparAVqjsWH --- src/controller/controller.lua | 36 ++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/controller/controller.lua b/src/controller/controller.lua index 06332392..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 From cade8cf084928649d1cd630723565ce41449bd2e Mon Sep 17 00:00:00 2001 From: dsent <8774536+dsent@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:48:06 +0000 Subject: [PATCH 43/52] fix(editor): make accepted edits durable with per-accept fsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor half of the durable-writes work (the FS API it consumes is the preceding commit, split out as shared platform code): - controller/consoleController.lua (:edit): the editor's save callback fsyncs the file after a successful write — the accept path only. - controller/editorController.lua: :save returns ok and err; accept_block refuses, keeping the block open with a statusline error, when the write fails, so a failed save no longer reads as accepted. Verified on device: an FFI probe returns fsync=0 for a file on the SD card, and an accepted edit followed immediately by a force-stop and relaunch survives across repeated runs. Refs: compy-ide-edit-flush-loss Split from c5866b2. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011WEJighCCuSFparAVqjsWH --- src/controller/consoleController.lua | 8 +++++++- src/controller/editorController.lua | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/controller/consoleController.lua b/src/controller/consoleController.lua index f98b079e..5995b8cb 100644 --- a/src/controller/consoleController.lua +++ b/src/controller/consoleController.lua @@ -955,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) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index fb7c82db..ca52d950 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -391,9 +391,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 --------------------------- @@ -729,11 +732,21 @@ function EditorController:accept_block() self:_reject_oversized(newtext, oversized) return false end - self:record_write(buf, function() + local saved = self:record_write(buf, function() local _, n = buf:replace_content(newtext) - self:save(buf) + 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() From bdf89d2706766bdd1af1cd94aaacfa0ee84348cd Mon Sep 17 00:00:00 2001 From: dsent <8774536+dsent@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:17:10 +0200 Subject: [PATCH 44/52] fix(input): make bare Home/End line-scoped while editing Inside a multi-line block End jumped to the end of the whole input and Home to its start, so nothing reached the ends of the current line. The intended layout is the opposite of what was bound: bare Home/End are the line-scoped pair while editing, and the jump over the whole block belongs on Ctrl+Home/End. The line-scoped jumps were instead sitting on Alt, which is not a documented binding at all. Route bare Home/End to jump_line_start/jump_line_end and give the whole-block jump to Ctrl+Home/End. UserInputController is shared with the console, so its input line is corrected by the same change. Navigation Home/End in the editor (first/last block) is handled in the editor controller and is untouched. Verified on a 4-line block opened with the cursor on row 2: End -> 2:15, Ctrl+End -> 4:4, Home -> 4:1, Ctrl+Home -> 1:1. Refs: compy-ide-home-end-line-scope Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011WEJighCCuSFparAVqjsWH --- src/controller/userInputController.lua | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/controller/userInputController.lua b/src/controller/userInputController.lua index f7749c3e..7c7e772e 100644 --- a/src/controller/userInputController.lua +++ b/src/controller/userInputController.lua @@ -281,21 +281,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() From fee731f191e27f30df0fa8bf18e1f2d83b0f218e Mon Sep 17 00:00:00 2001 From: dsent <8774536+dsent@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:17:24 +0200 Subject: [PATCH 45/52] fix(editor): anchor the input when typing into an empty block Typing straight after Ctrl+Delete went into a detached input: the status bar showed a cursor like 0:32 with no block association, nothing appeared on screen, and Enter never accepted it into the file, so the keystrokes were lost outright. A navigation press between the delete and the typing hid it by re-anchoring the editor. Four things lined up: - delete_selected_text() never re-clamped the active line after the content shrank under the selection, leaving it stale. - Once the last block is gone the selection sits on the trailing gap, where the selected block reads as Empty. - start_typing() returned early for an empty block, setting edit mode without opening it, so the input belonged to no block. - The row it derives from the stale active line could reach 0, and set_cursor assigns straight to self.cursor without the clamping that move_cursor applies. Re-clamp the active line after a deletion, open the block for the empty and gap cases so the input is always anchored, and clamp the row in start_typing and open_block. Plain buffers keep their previous path. Verified on device: deleting the trailing block and typing at once lands the text (status B6 L9, cursor 1:8, text on screen), and the same holds when the deleted block is a non-empty one mid-file. Refs: compy-ide-delete-then-type-phantom Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011WEJighCCuSFparAVqjsWH --- src/controller/editorController.lua | 19 ++++++++++++++++++- src/model/editor/bufferModel.lua | 5 +++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index ca52d950..94e464bb 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -580,6 +580,13 @@ function EditorController:open_block() 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 @@ -591,9 +598,18 @@ end --- 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 - self:set_mode('edit') + --- 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 @@ -604,6 +620,7 @@ function EditorController:start_typing() 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) diff --git a/src/model/editor/bufferModel.lua b/src/model/editor/bufferModel.lua index 3df408f8..52a59d18 100644 --- a/src/model/editor/bufferModel.lua +++ b/src/model/editor/bufferModel.lua @@ -533,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[] From 11c103836f77ef6433b8c4277440c91d05d68058 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Sun, 19 Jul 2026 18:16:46 +0000 Subject: [PATCH 46/52] fix(input): the editor's 1.1 extras no longer leak platform-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserInputModel is the whole platform's input — the console, project inputs the games read, and search all build on it. The 1.1 work changed it as if it were the editor's own, and every surface felt it: - Ctrl+Backspace/Ctrl+W ate a word in every input - Ctrl+Y stopped deleting the line in the console - the edit history recorded every mutation of every input, cloning the text on each keystroke - the refusal frame drew around every error message, console errors included — which is what moved the screenshots in the user-guide generator An explicit editing flag on the model (off by default, the editor model opts in) now scopes all of it: word deletion and the frame gate on it, recording returns early without it, and the widget's Ctrl+Y delete-line binding is restored — in the editor it is shadowed by the controller's redo dispatch, which runs first and blocks the key. Regression specs pin the contract from the console's side: a flagless model records nothing, Ctrl+Backspace deletes one character, Ctrl+W does nothing, Ctrl+Y deletes the line. 748 green. --- src/controller/userInputController.lua | 17 +++++++++++------ src/model/editor/editorModel.lua | 3 ++- src/model/input/userInputModel.lua | 10 +++++++++- src/view/input/userInputView.lua | 16 ++++++++++------ tests/editor/editor_spec.lua | 24 ++++++++++++++++++++++++ tests/input/user_input_model_spec.lua | 20 ++++++++++++++++++++ 6 files changed, 76 insertions(+), 14 deletions(-) diff --git a/src/controller/userInputController.lua b/src/controller/userInputController.lua index 7c7e772e..2ca48c5a 100644 --- a/src/controller/userInputController.lua +++ b/src/controller/userInputController.lua @@ -235,8 +235,11 @@ function UserInputController:keypressed(k) -- action categories local function removers() + local editing = input.editing if k == "backspace" then - if Key.ctrl() then + --- 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() @@ -246,11 +249,13 @@ function UserInputController:keypressed(k) input:delete() end if Key.ctrl() then - --- Ctrl+Y is redo since 1.1; delete-line lost - --- its key (reachable via Home, Shift+End, - --- Backspace) until a new one is picked - --- readline's synonym, per the editor spec 2.7 - if k == "w" 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 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/userInputModel.lua b/src/model/input/userInputModel.lua index 2abf40cc..37ef7752 100644 --- a/src/model/input/userInputModel.lua +++ b/src/model/input/userInputModel.lua @@ -46,9 +46,15 @@ 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), @@ -118,6 +124,7 @@ end --- @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 @@ -125,6 +132,7 @@ 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 diff --git a/src/view/input/userInputView.lua b/src/view/input/userInputView.lua index acc3fa98..da28f432 100644 --- a/src/view/input/userInputView.lua +++ b/src/view/input/userInputView.lua @@ -258,12 +258,16 @@ function UserInputView:render_error(err_text) drawBackground() gfx.setColor(colors.input.error) - --- the refusal frame (spec 2.4.3) - gfx.rectangle("line", - 1, - fh + 1, - drawableWidth - 2, - apparentHeight * fh - 2) + 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/editor_spec.lua b/tests/editor/editor_spec.lua index 13afca38..8748e730 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -989,6 +989,30 @@ describe('Editor #editor', function() 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()) 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 -- ----------------- From 9e64a1933fa071bbdc3713ee4d19040d6f7a37c9 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 22 Jul 2026 18:44:48 +0000 Subject: [PATCH 47/52] fix(editor): drop the statusline mode tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mode is evident from the input strip itself: editing shows the strip with a cursor and the loaded-block highlight, navigation shows an empty strip and the active line. The tag added nothing — and collided with the left label: the filename block is right-anchored (its x is computed back from the right cluster), the 'lua' label left-anchored, and the gap between them is whatever happens to remain, so prepending ' E ' to the name pushed it left into the label on longer filenames. Spec criterion 9.9 amended accordingly. 748 green. --- src/view/input/statusline.lua | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/view/input/statusline.lua b/src/view/input/statusline.lua index adaac9ef..a57eb6fa 100644 --- a/src/view/input/statusline.lua +++ b/src/view/input/statusline.lua @@ -102,11 +102,7 @@ function Statusline:draw(status, start_y) end local more_b = morelabel(custom.buffer_more) .. ' ' local more_i = morelabel(status.input_more) .. ' ' - local edit_tag = '' - if custom.mode == 'edit' then - edit_tag = ' E ' - end - local name = edit_tag .. custom.name .. ' ' + local name = custom.name .. ' ' gfx.setColor(colors.fg) local font = gfx.getFont() @@ -156,13 +152,7 @@ function Statusline:draw(status, start_y) gfx.setColor(colors.fg) gfx.print(more_b, s_mb, start_text.y) -- filename - local ew = gfx.getFont():getWidth(edit_tag) - if edit_tag ~= '' then - gfx.setColor(Color[Color.yellow]) - gfx.print(edit_tag, s_n, start_text.y) - end - gfx.setColor(Color[Color.white]) - gfx.print(custom.name, s_n + ew, start_text.y) + gfx.print(custom.name, s_n, start_text.y) else --- normal statusline local pos_c = ':' .. c.c From 18a006f49de8b8e6a869e2f8332c189ad2daa3e9 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 22 Jul 2026 19:48:07 +0000 Subject: [PATCH 48/52] feat(editor): repeat-proof dialogs, error messages close on any exit key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key repeat on the device fires the invoking chord many times, and dialogs that confirmed on a repeated press were a hair-trigger: a held Shift+Esc could blow through the discard, a held Ctrl+Shift+K through the restore. The scheme is now repeat-proof by construction, as agreed: the confirming key differs from the invoking one, so repeats land on the idempotent cancel. - Every dialog (discard, checkpoint overwrite, restore) confirms on Enter or Space and cancels on anything else — the invoking chord and Esc included. Held, it oscillates ask/cancel and never fires. - One executor (EditorController:_confirm) runs the confirmed action; the key handlers only raise dialogs. - Space arrives as textinput on the device, so the dialog eats the glyph there and swallows the paired event on either delivery order. - A plain error message (a refusal) closes on Enter, Esc or Shift+Esc without re-submitting or leaving the block, matching the REPL's Enter; printables keep closing it via textinput as before. 749 green. --- src/controller/editorController.lua | 96 ++++++++++++++++++++-------- tests/editor/editor_spec.lua | 99 ++++++++++++++++++++++++++--- 2 files changed, 159 insertions(+), 36 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 94e464bb..beff455f 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -354,6 +354,7 @@ end --- @param t string function EditorController:textinput(t) self.view:update_input() + if self:_dialog_textinput(t) then return end if is_normal(self.mode) then local input = self.model.input if input:has_error() then @@ -507,15 +508,33 @@ function EditorController:discard_edit() if clean then return self:leave_edit() end - if self.pending_confirm ~= 'discard' then - self.pending_confirm = 'discard' - self.input:set_error({ - 'discard the changes?' - .. ' Shift+Esc again to confirm' - }) + self.pending_confirm = 'discard' + self.input:set_error({ + 'discard the changes? Enter confirms, Esc cancels' + }) +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 - self.pending_confirm = nil + 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 @@ -546,6 +565,24 @@ function EditorController:discard_edit() 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) @@ -1272,33 +1309,24 @@ function EditorController:_normal_mode_keys(k) self:refuse({ 'no checkpoint to restore' }) return end - if self.pending_confirm == 'restore' then - self.pending_confirm = nil - if con:restore_checkpoint(name) then - local text = con:_readfile(name) - self:reload_active(text) - end - return - end self.pending_confirm = 'restore' input:set_error({ string.format( 'restore from checkpoint %s over file %s?' - .. ' Ctrl+Shift+K again restores, Esc cancels', + .. ' Enter confirms, Esc cancels', stamp(cp_time), stamp(con:file_modtime(name)) ) }) return end - if cp_time and self.pending_confirm ~= 'overwrite' then + if cp_time then self.pending_confirm = 'overwrite' input:set_error({ string.format( 'checkpoint from %s exists;' - .. ' Ctrl+K again overwrites, Esc cancels', + .. ' Enter confirms, Esc cancels', stamp(cp_time) ) }) return end - self.pending_confirm = nil con:write_checkpoint(name) end @@ -1488,14 +1516,30 @@ end --- @param k string function EditorController:keypressed(k) self.input:update_view() - if self.pending_confirm - and not (Key.ctrl() and k == 'k') - and not (self.pending_confirm == 'discard' - and Key.shift() and not Key.ctrl() - and k == 'escape') then - --- anything else cancels the confirmation (Esc - --- included); the message clears with the keypress + 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 diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 8748e730..7cd33b2c 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -173,7 +173,8 @@ describe('Editor #editor', function() assert.same({ 'insert' }, input()) --- the compose is dirty: the discard asks mock.keystroke('S-escape', press) - mock.keystroke('S-escape', press) + --- Enter confirms (repeat-proof dialogs) + mock.keystroke('return', press) mock.keystroke('return', press) assert.same({ '-- test' }, input()) end) @@ -430,7 +431,8 @@ describe('Editor #editor', function() assert.is_true(seen) --- the typed draft asks; confirm to discard mock.keystroke('S-escape', press) - 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) @@ -586,12 +588,17 @@ describe('Editor #editor', function() assert.is_false(inter:has_error()) end) - it('an existing one asks, second press writes', function() + 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) @@ -609,7 +616,7 @@ describe('Editor #editor', function() cp_time = 1752400000 mock.keystroke('C-S-k', press) assert.same({}, calls) - mock.keystroke('C-S-k', press) + mock.keystroke('return', press) assert.same({ 'restore:main.lua' }, calls) --- buffer reloaded from the checkpoint content --- (reload replaces the model; re-fetch it) @@ -862,8 +869,8 @@ describe('Editor #editor', function() assert.same('edit', controller:get_mode()) assert.is_true(controller.input:has_error()) - --- second press discards; the file untouched - mock.keystroke('S-escape', press) + --- Enter confirms; the file untouched + mock.keystroke('return', press) assert.same('nav', controller:get_mode()) assert.same(orig, string.unlines( buffer:get_text_content())) @@ -899,7 +906,8 @@ describe('Editor #editor', function() controller.input:set_text( { 'function broken(' }) mock.keystroke('S-escape', press) - 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) @@ -989,6 +997,73 @@ describe('Editor #editor', function() assert.same('edit', controller:get_mode()) 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( @@ -1062,7 +1137,8 @@ describe('Editor #editor', function() assert.same(n0, buffer:get_content_length()) --- the word deletion made the draft dirty mock.keystroke('S-escape', press) - mock.keystroke('S-escape', press) + --- Enter confirms (repeat-proof dialogs) + mock.keystroke('return', press) --- navigation: it drops the block mock.keystroke('C-delete', press) @@ -1336,10 +1412,13 @@ describe('Editor #editor', function() --- and the cursor sits on the error's line assert.same(2, inter.model:get_cursor_info().cursor.l) - --- Shift+Esc still gets out (after the ask), - --- writing nothing + --- 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) From 4dba8cd9af5b8b14f77023d90bafca54b7ba323a Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 22 Jul 2026 19:50:35 +0000 Subject: [PATCH 49/52] =?UTF-8?q?feat(editor):=20Alt=20is=20scrolling=20?= =?UTF-8?q?=E2=80=94=20the=20peek=20moves=20to=20Alt-*,=20block=20moves=20?= =?UTF-8?q?to=20reorder=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As decided after the device pass: Alt+arrows moving blocks in navigation confused more than it helped (the blank-line churn around every swap), and the line swap while editing likewise. Alt now means one thing in both modes — the peek: Alt+Up/Down by a line, PageUp/PageDown (and Left/Right, the device shortcut) by a page, and Alt+Home/End to the file's edges, all without moving the selection. Ctrl+Alt keeps working as a synonym. Blocks move through the reorder mode (Ctrl+M) alone; _move_block is gone with its binding. The widget's line swap stays for the console, unreachable in the editor since the Alt branch consumes the chord. 750 green. --- src/controller/editorController.lua | 65 ++++++----------------------- tests/editor/editor_spec.lua | 65 ++++++++++++++++++++++------- 2 files changed, 62 insertions(+), 68 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index beff455f..1dc47db0 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -864,34 +864,6 @@ function EditorController:mousepressed(x, y, btn, touch, presses) self.input:mousepressed(x, y, btn, touch, presses) end ---- Swap the selected block with its neighbor (spec 2.7: ---- Alt+arrows in navigation), written through like reorder ---- @param dir VerticalDir -function EditorController:_move_block(dir) - local buf = self:get_active_buffer() - if self.input:has_error() then return end - if buf.readonly then return self:refuse() end - - local sel = buf:get_selection() - local last = buf:get_content_length() - if sel > last then return self:refuse() end - local target = sel - 1 - if dir == 'down' then target = sel + 1 end - if target < 1 or target > last then - return self:refuse() - end - - self:record_write(buf, function() - buf:move(sel, target) - buf:rechunk() - self:save(buf) - end) - buf:set_selection(target) - self.view:refresh() - self.view:get_current_buffer():follow_selection() - self:update_status() -end - --- Block-wise movement of the active line (spec 2.2) --- @param dir VerticalDir function EditorController:_jump_block(dir) @@ -1358,51 +1330,38 @@ function EditorController:_normal_mode_keys(k) end end local function navigate() - -- move the block: Alt+arrows in nav (2.7); in - -- editing Alt passes through to the input widget, - -- which moves the line - if Key.alt() and not Key.ctrl() then - if self.mode == 'nav' then - if k == "up" then - self:_move_block('up') - block_input() - end - if k == "down" then - self:_move_block('down') - block_input() - end - end - return - end - - -- peek: the view moves, the selection stays (2.2) - if Key.ctrl() and Key.alt() 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:_scroll('up', false, 1) - block_input() end if k == "down" then self:_scroll('down', false, 1) - block_input() end if k == "pageup" then self:_scroll('up', false) - block_input() end if k == "pagedown" then self:_scroll('down', false) - block_input() 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) - block_input() end if k == "right" then self:_scroll('down', false) - block_input() end + if k == "home" then + self:_scroll('up', true) + end + if k == "end" then + self:_scroll('down', true) + end + block_input() return end diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 7cd33b2c..33e16160 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -532,26 +532,51 @@ describe('Editor #editor', function() --- end plaintext describe('structured (lua) works', function() - it('moves the block with Alt+arrows', 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('M-down', press) - --- the block moved down, selection follows it + 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()) - --- and the swap is written through assert.same('', string.lines(savefile())[1]) - mock.keystroke('M-up', press) + 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()) - --- capped at the edge - mock.keystroke('M-up', press) - assert.same(1, buffer:get_selection()) + 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() @@ -845,10 +870,12 @@ describe('Editor #editor', function() buffer:get_text_content())) end) - it('undoes an Alt block move', function() + it('undoes a reorder block move', function() local orig = string.unlines( buffer:get_text_content()) - mock.keystroke('M-down', press) + 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) @@ -930,7 +957,9 @@ describe('Editor #editor', function() it('checkpoint restore clears the history', function() - mock.keystroke('M-down', press) + 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( @@ -946,9 +975,13 @@ describe('Editor #editor', function() end) it('a new write kills the redo tail', function() - mock.keystroke('M-down', press) + mock.keystroke('C-m', press) + mock.keystroke('down', press) + mock.keystroke('return', press) mock.keystroke('C-z', press) - mock.keystroke('M-down', 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()) @@ -1187,11 +1220,13 @@ describe('Editor #editor', function() mock.keystroke('pagedown', press) end), 'page move at the end') - --- Alt+arrow moving a block past the edge + --- reorder move past the edge mock.keystroke('home', press) + mock.keystroke('C-m', press) assert.is_true(knocked(function() - mock.keystroke('M-up', press) + 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() From 0a37373e92f98f988d5be2bc65eee6fffcf3f239 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 22 Jul 2026 22:31:06 +0000 Subject: [PATCH 50/52] fix(editor): returning from a require restores the view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+J into a file and Shift+Esc back parked the view at the file's end; only an arrow press snapped it to where you were. The buffer kept its position all along — open() places the view at the end range and pop_buffer never asked it to follow, unlike the file-open path. It follows now; the probe spec jumps from the middle of a 30-block file, where the end-parking and the stored line genuinely diverge. 752 green. --- src/controller/editorController.lua | 7 +++++- tests/editor/editor_spec.lua | 38 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 1dc47db0..26209d53 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -129,7 +129,12 @@ function EditorController:pop_buffer() 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 diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index 33e16160..a77bd1a8 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -1030,6 +1030,44 @@ describe('Editor #editor', function() assert.same('edit', controller:get_mode()) 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()) From 9853884d0f133190c4207866991186cb80631f33 Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 22 Jul 2026 22:31:20 +0000 Subject: [PATCH 51/52] fix(editor): Delete leaves the clipboard alone, cutting is Ctrl+X MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a block wrote it to the system clipboard — cut semantics on a delete key. On the device that clobbered whatever the child had copied, and every clipboard write pops the Android share overlay on top of the screen, so a plain deletion produced both a surprise and a popup. Delete now just deletes (undoable since 1.1, which is what gated it); Ctrl+X remains copy + delete. The spec holds a 'precious' clipboard across a deletion and sees it survive. 753 green. --- src/controller/editorController.lua | 7 +++++-- tests/editor/editor_spec.lua | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index 26209d53..cbeb8548 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -1043,11 +1043,14 @@ function EditorController:_normal_mode_keys(k) --- @type BufferModel local buf = self:get_active_buffer() + --- 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()) self:record_write(buf, function() buf:delete_selected_text() - love.system.setClipboardText(t) self:save(buf) end) self.view:refresh() diff --git a/tests/editor/editor_spec.lua b/tests/editor/editor_spec.lua index a77bd1a8..be1b4308 100644 --- a/tests/editor/editor_spec.lua +++ b/tests/editor/editor_spec.lua @@ -1030,6 +1030,33 @@ describe('Editor #editor', function() 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") From 16eb33d79fd8711e8c467d8581d47e6632b1607e Mon Sep 17 00:00:00 2001 From: Vadim1987 Date: Wed, 22 Jul 2026 22:31:38 +0000 Subject: [PATCH 52/52] =?UTF-8?q?fix(editor):=20dialog=20messages=20name?= =?UTF-8?q?=20their=20keys=20=E2=80=94=20Confirm=20[Enter]=20/=20Cancel=20?= =?UTF-8?q?[Esc]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One uniform trailer on all three dialogs (discard, checkpoint overwrite, restore), in the agreed format. 753 green. --- src/controller/editorController.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/controller/editorController.lua b/src/controller/editorController.lua index cbeb8548..0acbb172 100644 --- a/src/controller/editorController.lua +++ b/src/controller/editorController.lua @@ -515,7 +515,7 @@ function EditorController:discard_edit() self.pending_confirm = 'discard' self.input:set_error({ - 'discard the changes? Enter confirms, Esc cancels' + 'discard the changes? Confirm [Enter] / Cancel [Esc]' }) end @@ -1292,7 +1292,7 @@ function EditorController:_normal_mode_keys(k) self.pending_confirm = 'restore' input:set_error({ string.format( 'restore from checkpoint %s over file %s?' - .. ' Enter confirms, Esc cancels', + .. ' Confirm [Enter] / Cancel [Esc]', stamp(cp_time), stamp(con:file_modtime(name)) ) }) return @@ -1301,8 +1301,8 @@ function EditorController:_normal_mode_keys(k) if cp_time then self.pending_confirm = 'overwrite' input:set_error({ string.format( - 'checkpoint from %s exists;' - .. ' Enter confirms, Esc cancels', + 'checkpoint from %s exists.' + .. ' Confirm [Enter] / Cancel [Esc]', stamp(cp_time) ) }) return