diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 5b1836e8..05d3229f 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -201,6 +201,19 @@ function M.get_visible_top_line(win) return (ok and line and line > 0) and line or nil end +---@param win integer +---@param topline integer +function M.restore_view_topline(win, topline) + vim.api.nvim_win_call(win, function() + local view = vim.fn.winsaveview() + view.topline = math.max(1, topline) + view.skipcol = 0 + vim.fn.winrestview(view) + -- Apply the restored viewport before later line('w$') reads inspect it. + vim.cmd('redraw') + end) +end + ---@param win? integer function M.reset_scroll_tracking(win) if win then @@ -763,9 +776,7 @@ function M.setup_autocmds(windows, group) if rendered and rendered.line_start then local restored_top = math.max(1, rendered.line_start + anchor_offset) pcall(vim.api.nvim_win_set_cursor, windows.output_win, { restored_top, 0 }) - pcall(vim.api.nvim_win_call, windows.output_win, function() - vim.cmd('normal! zt') - end) + pcall(M.restore_view_topline, windows.output_win, restored_top) return end end diff --git a/lua/opencode/ui/renderer/scroll.lua b/lua/opencode/ui/renderer/scroll.lua index bbdd593b..6fc8a1f3 100644 --- a/lua/opencode/ui/renderer/scroll.lua +++ b/lua/opencode/ui/renderer/scroll.lua @@ -3,31 +3,6 @@ local output_window = require('opencode.ui.output_window') local M = {} -local function with_window_event_autocmds_ignored(fn) - local previous = vim.o.eventignore - local ignored = { - WinEnter = true, - WinLeave = true, - BufEnter = true, - } - - for event in previous:gmatch('[^,]+') do - if event ~= '' then - ignored[event] = true - end - end - - local events = vim.tbl_keys(ignored) - table.sort(events) - vim.o.eventignore = table.concat(events, ',') - - local ok, err = pcall(fn) - vim.o.eventignore = previous - if not ok then - error(err) - end -end - ---@param win integer ---@return boolean local function window_wraps(win) @@ -44,6 +19,12 @@ local function get_text_width(win) return math.max(1, width - textoff) end +---@param win integer +---@param line integer +local function restore_view_with_line_at_bottom(win, line) + output_window.restore_view_topline(win, line - vim.api.nvim_win_get_height(win) + 1) +end + ---@param buf integer ---@param win integer ---@param target_line integer @@ -123,18 +104,7 @@ function M.scroll_win_to_bottom(win, buf) end if needs_bottom_align then - local windows = state.windows - if windows and vim.api.nvim_get_current_win() == windows.input_win then - with_window_event_autocmds_ignored(function() - vim.api.nvim_win_call(win, function() - vim.cmd('normal! zb') - end) - end) - else - vim.api.nvim_win_call(win, function() - vim.cmd('normal! zb') - end) - end + restore_view_with_line_at_bottom(win, target_line) end output_window._prev_line_count_by_win[win] = line_count diff --git a/tests/replay/lazy_render_scroll_spec.lua b/tests/replay/lazy_render_scroll_spec.lua index 68c2b626..47112cb8 100644 --- a/tests/replay/lazy_render_scroll_spec.lua +++ b/tests/replay/lazy_render_scroll_spec.lua @@ -113,6 +113,17 @@ describe('replay lazy-render upward loading', function() assert.are.equal(1, output_window.get_visible_top_line(win)) assert.are.equal(5, vim.api.nvim_win_get_cursor(win)[1]) + local modechanged_count = 0 + local group = vim.api.nvim_create_augroup('OpencodeLazyRenderViewRegression', { clear = true }) + vim.api.nvim_create_autocmd('ModeChanged', { + group = group, + pattern = 'i:n', + callback = function() + modechanged_count = modechanged_count + 1 + end, + }) + local cmd_stub = require('luassert.stub')(vim, 'cmd') + vim.api.nvim_exec_autocmds('WinScrolled', { buffer = state.windows.output_buf, modeline = false, @@ -123,5 +134,10 @@ describe('replay lazy-render upward loading', function() end) assert.is_true(loaded, 'Expected viewport-at-top WinScrolled to load older replayed messages') + assert.equals(0, modechanged_count) + assert.stub(cmd_stub).was_not_called_with('normal! zt') + + cmd_stub:revert() + pcall(vim.api.nvim_del_augroup_by_id, group) end) end) diff --git a/tests/unit/cursor_tracking_spec.lua b/tests/unit/cursor_tracking_spec.lua index 68dbce66..ef61c4c9 100644 --- a/tests/unit/cursor_tracking_spec.lua +++ b/tests/unit/cursor_tracking_spec.lua @@ -474,7 +474,7 @@ describe('renderer.scroll_to_bottom', function() cmd_stub:revert() end) - it('uses zb when the followed bottom line is below the viewport', function() + it('bottom-aligns when the followed bottom line is below the viewport', function() local lines = {} for i = 1, 40 do lines[i] = 'line ' .. i @@ -483,26 +483,19 @@ describe('renderer.scroll_to_bottom', function() vim.api.nvim_win_set_height(win, 5) vim.api.nvim_win_set_cursor(win, { 1, 0 }) - local cmd_stub = stub(vim, 'cmd').invokes(function(cmd) - if cmd == 'normal! zb' then - vim.api.nvim_win_call(win, function() - vim.fn.winrestview({ topline = 36 }) - end) - return - end - return vim.api.nvim_cmd(vim.api.nvim_parse_cmd(cmd, {}), {}) - end) + local cmd_stub = stub(vim, 'cmd') local scroll = require('opencode.ui.renderer.scroll') scroll.scroll_win_to_bottom(win, buf) local cursor = vim.api.nvim_win_get_cursor(win) assert.equals(40, cursor[1]) - assert.stub(cmd_stub).was_called_with('normal! zb') + assert.equals(40, output_window.get_visible_bottom_line(win)) + assert.stub(cmd_stub).was_not_called_with('normal! zb') cmd_stub:revert() end) - it('uses zb when a wrapped bottom line grows past the last screen row', function() + it('keeps the visual end selected when a wrapped bottom line grows past the last screen row', function() local long_line = string.rep('x', 80) local longer_line = string.rep('x', 180) @@ -511,12 +504,7 @@ describe('renderer.scroll_to_bottom', function() vim.api.nvim_set_option_value('wrap', true, { win = win, scope = 'local' }) vim.api.nvim_buf_set_lines(buf, 0, -1, false, { long_line }) - local cmd_stub = stub(vim, 'cmd').invokes(function(cmd) - if cmd == 'normal! zb' then - return - end - return vim.api.nvim_cmd(vim.api.nvim_parse_cmd(cmd, {}), {}) - end) + local cmd_stub = stub(vim, 'cmd') local scroll = require('opencode.ui.renderer.scroll') scroll.scroll_win_to_bottom(win, buf) @@ -528,7 +516,7 @@ describe('renderer.scroll_to_bottom', function() local cursor = vim.api.nvim_win_get_cursor(win) assert.equals(1, cursor[1]) assert.equals(#longer_line - 1, cursor[2]) - assert.stub(cmd_stub).was_called_with('normal! zb') + assert.stub(cmd_stub).was_not_called_with('normal! zb') cmd_stub:revert() end) @@ -545,16 +533,41 @@ describe('renderer.scroll_to_bottom', function() state.ui.set_windows({ output_win = win, output_buf = buf, input_win = input_win, input_buf = input_buf }) vim.api.nvim_set_current_win(input_win) - local winleave_count = 0 + local events = { + BufEnter = 0, + ModeChanged = 0, + WinEnter = 0, + WinLeave = 0, + } local group = vim.api.nvim_create_augroup('OpencodeScrollImeRegression', { clear = true }) + vim.api.nvim_create_autocmd('WinEnter', { + group = group, + callback = function() + events.WinEnter = events.WinEnter + 1 + end, + }) vim.api.nvim_create_autocmd('WinLeave', { group = group, - buffer = input_buf, callback = function() - winleave_count = winleave_count + 1 + events.WinLeave = events.WinLeave + 1 + end, + }) + vim.api.nvim_create_autocmd('BufEnter', { + group = group, + callback = function() + events.BufEnter = events.BufEnter + 1 + end, + }) + vim.api.nvim_create_autocmd('ModeChanged', { + group = group, + pattern = 'i:n', + callback = function() + events.ModeChanged = events.ModeChanged + 1 end, }) + local cmd_stub = require('luassert.stub')(vim, 'cmd') + vim.api.nvim_win_set_height(win, 5) vim.api.nvim_win_set_cursor(win, { 1, 0 }) config.values.ui.output.always_scroll_to_bottom = true @@ -562,9 +575,16 @@ describe('renderer.scroll_to_bottom', function() renderer.scroll_to_bottom() assert.equals(input_win, vim.api.nvim_get_current_win()) - assert.equals(0, winleave_count) + assert.same({ + BufEnter = 0, + ModeChanged = 0, + WinEnter = 0, + WinLeave = 0, + }, events) assert.equals(50, vim.api.nvim_win_get_cursor(win)[1]) + assert.stub(cmd_stub).was_not_called_with('normal! zb') + cmd_stub:revert() config.values.ui.output.always_scroll_to_bottom = false pcall(vim.api.nvim_del_augroup_by_id, group) end)