Skip to content

Commit 6bfccb2

Browse files
authored
fix(renderer): debounce renders, refactor scroll logic, track scrollintent (#280)
* fix(renderer): debounce renders, refactor scroll logic, track scroll intent Three related improvements to output rendering: 1. Refactor scroll_to_bottom conditions Convert nested if-else chain to declarative condition table for better readability and maintainability. 2. Add WinScrolled autocmd Track user scroll position to accurately detect scroll intent (whether user is at bottom or has scrolled up). * adjust
1 parent 4e4be32 commit 6bfccb2

3 files changed

Lines changed: 192 additions & 26 deletions

File tree

lua/opencode/ui/output_window.lua

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,40 @@ function M.setup_autocmds(windows, group)
277277
state.save_cursor_position('output', windows.output_win)
278278
end,
279279
})
280+
281+
vim.api.nvim_create_autocmd('WinScrolled', {
282+
group = group,
283+
buffer = windows.output_buf,
284+
callback = function()
285+
if not windows.output_win or not vim.api.nvim_win_is_valid(windows.output_win) then
286+
return
287+
end
288+
289+
local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win)
290+
if not ok then
291+
return
292+
end
293+
294+
local ok2, line_count = pcall(vim.api.nvim_buf_line_count, windows.output_buf)
295+
if not ok2 or line_count == 0 then
296+
return
297+
end
298+
299+
if cursor[1] >= line_count then
300+
local ok3, view = pcall(vim.api.nvim_win_call, windows.output_win, vim.fn.winsaveview)
301+
if ok3 and type(view) == 'table' then
302+
local topline = view.topline or 1
303+
local win_height = vim.api.nvim_win_get_height(windows.output_win)
304+
local visible_bottom = math.min(topline + win_height - 1, line_count)
305+
306+
if visible_bottom < line_count then
307+
pcall(vim.api.nvim_win_set_cursor, windows.output_win, { visible_bottom, 0 })
308+
state.save_cursor_position('output', windows.output_win)
309+
end
310+
end
311+
end
312+
end,
313+
})
280314
end
281315

282316
function M.clear()

lua/opencode/ui/renderer.lua

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -299,15 +299,19 @@ end
299299
---Respects cursor position if user has scrolled up
300300
---@param force? boolean If true, scroll regardless of current position
301301
function M.scroll_to_bottom(force)
302-
if not state.windows or not state.windows.output_buf or not state.windows.output_win then
302+
local windows = state.windows
303+
local output_win = windows and windows.output_win
304+
local output_buf = windows and windows.output_buf
305+
306+
if not output_buf or not output_win then
303307
return
304308
end
305309

306-
if not vim.api.nvim_win_is_valid(state.windows.output_win) then
310+
if not vim.api.nvim_win_is_valid(output_win) then
307311
return
308312
end
309313

310-
local ok, line_count = pcall(vim.api.nvim_buf_line_count, state.windows.output_buf)
314+
local ok, line_count = pcall(vim.api.nvim_buf_line_count, output_buf)
311315
if not ok or line_count == 0 then
312316
return
313317
end
@@ -319,28 +323,45 @@ function M.scroll_to_bottom(force)
319323

320324
trigger_on_data_rendered()
321325

322-
-- Determine if we should scroll to bottom
323-
local should_scroll = force == true
326+
local scroll_conditions = {
327+
{
328+
name = 'force',
329+
test = function()
330+
return force == true
331+
end,
332+
},
333+
{
334+
name = 'first_render',
335+
test = function()
336+
return prev_line_count == 0
337+
end,
338+
},
339+
{
340+
name = 'always_scroll',
341+
test = function()
342+
return config.ui.output.always_scroll_to_bottom
343+
end,
344+
},
345+
{
346+
name = 'cursor_at_bottom',
347+
test = function()
348+
local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, output_win)
349+
return ok_cursor and cursor and (cursor[1] >= prev_line_count or cursor[1] >= line_count)
350+
end,
351+
},
352+
}
324353

325-
if not should_scroll then
326-
-- Always scroll on initial render
327-
if prev_line_count == 0 then
328-
should_scroll = true
329-
-- Respect explicit config to always follow output
330-
elseif config.ui.output.always_scroll_to_bottom then
354+
local should_scroll = false
355+
for _, condition in ipairs(scroll_conditions) do
356+
if condition.test() then
331357
should_scroll = true
332-
-- Scroll if user is at bottom (respects manual scroll position)
333-
else
334-
local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, state.windows.output_win)
335-
local cursor_row = ok_cursor and cursor[1] or 1
336-
should_scroll = cursor_row >= prev_line_count
358+
break
337359
end
338360
end
339361

340362
if should_scroll then
341-
vim.api.nvim_win_set_cursor(state.windows.output_win, { line_count, 0 })
342-
-- Use zb to position the cursor line at the bottom of the visible window
343-
vim.api.nvim_win_call(state.windows.output_win, function()
363+
vim.api.nvim_win_set_cursor(output_win, { line_count, 0 })
364+
vim.api.nvim_win_call(output_win, function()
344365
vim.cmd('normal! zb')
345366
end)
346367
end

tests/unit/cursor_tracking_spec.lua

Lines changed: 118 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,89 @@ describe('cursor persistence (state)', function()
88
state.set_cursor_position('output', nil)
99
end)
1010

11+
describe('renderer.scroll_to_bottom', function()
12+
local renderer = require('opencode.ui.renderer')
13+
local buf, win
14+
15+
before_each(function()
16+
config.setup({})
17+
renderer.reset()
18+
19+
buf = vim.api.nvim_create_buf(false, true)
20+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
21+
'line 1',
22+
'line 2',
23+
'line 3',
24+
'line 4',
25+
'line 5',
26+
'line 6',
27+
'line 7',
28+
'line 8',
29+
'line 9',
30+
'line 10',
31+
})
32+
33+
win = vim.api.nvim_open_win(buf, true, {
34+
relative = 'editor',
35+
width = 80,
36+
height = 10,
37+
row = 0,
38+
col = 0,
39+
})
40+
41+
state.windows = { output_win = win, output_buf = buf }
42+
vim.api.nvim_set_current_win(win)
43+
vim.api.nvim_win_set_cursor(win, { 10, 0 })
44+
end)
45+
46+
after_each(function()
47+
renderer.reset()
48+
pcall(vim.api.nvim_win_close, win, true)
49+
pcall(vim.api.nvim_buf_delete, buf, { force = true })
50+
state.windows = nil
51+
end)
52+
53+
it('auto-scrolls when cursor was at previous bottom and buffer grows', function()
54+
renderer.scroll_to_bottom()
55+
56+
vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11', 'line 12' })
57+
renderer.scroll_to_bottom()
58+
59+
local cursor = vim.api.nvim_win_get_cursor(win)
60+
assert.equals(12, cursor[1])
61+
end)
62+
63+
it('does not auto-scroll when user moved away from previous bottom before growth', function()
64+
renderer.scroll_to_bottom()
65+
66+
vim.api.nvim_win_set_cursor(win, { 5, 0 })
67+
vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11', 'line 12' })
68+
renderer.scroll_to_bottom()
69+
70+
local cursor = vim.api.nvim_win_get_cursor(win)
71+
assert.equals(5, cursor[1])
72+
end)
73+
74+
it('auto-scrolls even when output window is unfocused if cursor was at previous bottom', function()
75+
renderer.scroll_to_bottom()
76+
77+
local input_buf = vim.api.nvim_create_buf(false, true)
78+
vim.cmd('vsplit')
79+
local input_win = vim.api.nvim_get_current_win()
80+
vim.api.nvim_win_set_buf(input_win, input_buf)
81+
vim.api.nvim_set_current_win(input_win)
82+
83+
vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11' })
84+
renderer.scroll_to_bottom()
85+
86+
local cursor = vim.api.nvim_win_get_cursor(win)
87+
assert.equals(11, cursor[1])
88+
89+
pcall(vim.api.nvim_win_close, input_win, true)
90+
pcall(vim.api.nvim_buf_delete, input_buf, { force = true })
91+
end)
92+
end)
93+
1194
describe('set/get round-trip', function()
1295
it('stores and retrieves input cursor', function()
1396
state.set_cursor_position('input', { 5, 3 })
@@ -83,7 +166,11 @@ describe('cursor persistence (state)', function()
83166
local buf = vim.api.nvim_create_buf(false, true)
84167
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line1', 'line2', 'line3' })
85168
local win = vim.api.nvim_open_win(buf, true, {
86-
relative = 'editor', width = 40, height = 10, row = 0, col = 0,
169+
relative = 'editor',
170+
width = 40,
171+
height = 10,
172+
row = 0,
173+
col = 0,
87174
})
88175
vim.api.nvim_win_set_cursor(win, { 2, 3 })
89176

@@ -111,7 +198,11 @@ describe('output_window.is_at_bottom', function()
111198
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
112199

113200
win = vim.api.nvim_open_win(buf, true, {
114-
relative = 'editor', width = 80, height = 10, row = 0, col = 0,
201+
relative = 'editor',
202+
width = 80,
203+
height = 10,
204+
row = 0,
205+
col = 0,
115206
})
116207

117208
state.windows = { output_win = win, output_buf = buf }
@@ -162,7 +253,11 @@ describe('output_window.is_at_bottom', function()
162253
it('returns true for empty buffer', function()
163254
local empty_buf = vim.api.nvim_create_buf(false, true)
164255
local empty_win = vim.api.nvim_open_win(empty_buf, true, {
165-
relative = 'editor', width = 40, height = 5, row = 0, col = 0,
256+
relative = 'editor',
257+
width = 40,
258+
height = 5,
259+
row = 0,
260+
col = 0,
166261
})
167262
state.windows = { output_win = empty_win, output_buf = empty_buf }
168263

@@ -202,7 +297,11 @@ describe('renderer.scroll_to_bottom', function()
202297
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
203298

204299
win = vim.api.nvim_open_win(buf, true, {
205-
relative = 'editor', width = 80, height = 10, row = 0, col = 0,
300+
relative = 'editor',
301+
width = 80,
302+
height = 10,
303+
row = 0,
304+
col = 0,
206305
})
207306

208307
state.windows = { output_win = win, output_buf = buf }
@@ -251,10 +350,18 @@ describe('ui.focus_input', function()
251350
vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'output' })
252351

253352
output_win = vim.api.nvim_open_win(output_buf, true, {
254-
relative = 'editor', width = 40, height = 5, row = 0, col = 0,
353+
relative = 'editor',
354+
width = 40,
355+
height = 5,
356+
row = 0,
357+
col = 0,
255358
})
256359
input_win = vim.api.nvim_open_win(input_buf, true, {
257-
relative = 'editor', width = 40, height = 5, row = 6, col = 0,
360+
relative = 'editor',
361+
width = 40,
362+
height = 5,
363+
row = 6,
364+
col = 0,
258365
})
259366

260367
state.windows = {
@@ -297,7 +404,11 @@ describe('renderer._add_message_to_buffer scrolling', function()
297404
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'existing line' })
298405

299406
win = vim.api.nvim_open_win(buf, true, {
300-
relative = 'editor', width = 80, height = 10, row = 0, col = 0,
407+
relative = 'editor',
408+
width = 80,
409+
height = 10,
410+
row = 0,
411+
col = 0,
301412
})
302413

303414
state.windows = { output_win = win, output_buf = buf }

0 commit comments

Comments
 (0)