Skip to content

Commit af6100b

Browse files
committed
fix: cursor scrolling
1 parent 25b8ce3 commit af6100b

2 files changed

Lines changed: 167 additions & 30 deletions

File tree

lua/opencode/ui/output_window.lua

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ local M = {}
66
M.namespace = vim.api.nvim_create_namespace('opencode_output')
77
M.debug_namespace = vim.api.nvim_create_namespace('opencode_output_debug')
88
M.markdown_namespace = vim.api.nvim_create_namespace('opencode_output_markdown')
9+
M._last_visible_bottom_by_win = {}
10+
M._viewport_cursor_tracking_by_win = {}
911

1012
local _update_depth = 0
1113
local _update_buf = nil
@@ -108,6 +110,97 @@ function M.is_at_bottom(win)
108110
return cursor[1] >= line_count
109111
end
110112

113+
---@param win? integer
114+
---@return integer|nil
115+
function M.get_visible_bottom_line(win)
116+
win = win or (state.windows and state.windows.output_win)
117+
if not win or not vim.api.nvim_win_is_valid(win) then
118+
return nil
119+
end
120+
121+
local buf = vim.api.nvim_win_get_buf(win)
122+
if not buf or not vim.api.nvim_buf_is_valid(buf) then
123+
return nil
124+
end
125+
126+
local line_count = vim.api.nvim_buf_line_count(buf)
127+
if line_count == 0 then
128+
return nil
129+
end
130+
131+
local ok_view, view = pcall(vim.api.nvim_win_call, win, vim.fn.winsaveview)
132+
if not ok_view or type(view) ~= 'table' then
133+
return nil
134+
end
135+
136+
local topline = math.max(1, view.topline or 1)
137+
local remaining_height = vim.api.nvim_win_get_height(win)
138+
for line = topline, line_count do
139+
local ok_height, result = pcall(vim.api.nvim_win_text_height, win, {
140+
start_row = line - 1,
141+
end_row = line - 1,
142+
})
143+
local line_height = ok_height and result and result.all or 1
144+
remaining_height = remaining_height - line_height
145+
if remaining_height <= 0 then
146+
return line
147+
end
148+
end
149+
150+
return line_count
151+
end
152+
153+
---@param win? integer
154+
function M.reset_scroll_tracking(win)
155+
if win then
156+
M._last_visible_bottom_by_win[win] = nil
157+
M._viewport_cursor_tracking_by_win[win] = nil
158+
return
159+
end
160+
161+
M._last_visible_bottom_by_win = {}
162+
M._viewport_cursor_tracking_by_win = {}
163+
end
164+
165+
---@param win? integer
166+
function M.sync_cursor_with_viewport(win)
167+
win = win or (state.windows and state.windows.output_win)
168+
if not win or not vim.api.nvim_win_is_valid(win) then
169+
return
170+
end
171+
172+
local windows = state.windows
173+
local buf = windows and windows.output_buf
174+
if not buf or not vim.api.nvim_buf_is_valid(buf) or vim.api.nvim_win_get_buf(win) ~= buf then
175+
M.reset_scroll_tracking(win)
176+
return
177+
end
178+
179+
local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, win)
180+
local ok_count, line_count = pcall(vim.api.nvim_buf_line_count, buf)
181+
local visible_bottom = M.get_visible_bottom_line(win)
182+
if not ok_cursor or not cursor or not ok_count or not line_count or line_count == 0 or not visible_bottom then
183+
return
184+
end
185+
186+
local last_visible_bottom = M._last_visible_bottom_by_win[win]
187+
local tracking = M._viewport_cursor_tracking_by_win[win] == true
188+
local anchored_to_viewport_bottom = tracking and last_visible_bottom and cursor[1] == last_visible_bottom
189+
190+
if cursor[1] > visible_bottom or (anchored_to_viewport_bottom and cursor[1] ~= visible_bottom) then
191+
M._viewport_cursor_tracking_by_win[win] = true
192+
pcall(vim.api.nvim_win_set_cursor, win, { math.min(visible_bottom, line_count), 0 })
193+
local pos = state.ui.get_window_cursor(win)
194+
if pos then
195+
state.ui.set_cursor_position('output', pos)
196+
end
197+
elseif not anchored_to_viewport_bottom then
198+
M._viewport_cursor_tracking_by_win[win] = false
199+
end
200+
201+
M._last_visible_bottom_by_win[win] = visible_bottom
202+
end
203+
111204
function M.setup(windows)
112205
window_options.set_window_option(
113206
'winhighlight',
@@ -137,6 +230,8 @@ function M.setup(windows)
137230
window_options.set_window_option('statuscolumn', '', windows.output_win, { save_original = true })
138231

139232
M.update_dimensions(windows)
233+
M.reset_scroll_tracking(windows.output_win)
234+
M._last_visible_bottom_by_win[windows.output_win] = M.get_visible_bottom_line(windows.output_win)
140235
M.setup_keymaps(windows)
141236
end
142237

@@ -329,6 +424,7 @@ function M.close()
329424
end
330425
---@cast state.windows { output_win: integer, output_buf: integer }
331426

427+
M.reset_scroll_tracking(state.windows.output_win)
332428
pcall(vim.api.nvim_win_close, state.windows.output_win, true)
333429
pcall(vim.api.nvim_buf_delete, state.windows.output_buf, { force = true })
334430
end
@@ -378,36 +474,7 @@ function M.setup_autocmds(windows, group)
378474
group = group,
379475
buffer = windows.output_buf,
380476
callback = function()
381-
if not windows.output_win or not vim.api.nvim_win_is_valid(windows.output_win) then
382-
return
383-
end
384-
385-
local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win)
386-
if not ok then
387-
return
388-
end
389-
390-
local ok2, line_count = pcall(vim.api.nvim_buf_line_count, windows.output_buf)
391-
if not ok2 or line_count == 0 then
392-
return
393-
end
394-
395-
if cursor[1] >= line_count then
396-
local ok3, view = pcall(vim.api.nvim_win_call, windows.output_win, vim.fn.winsaveview)
397-
if ok3 and type(view) == 'table' then
398-
local topline = view.topline or 1
399-
local win_height = vim.api.nvim_win_get_height(windows.output_win)
400-
local visible_bottom = math.min(topline + win_height - 1, line_count)
401-
402-
if visible_bottom < line_count then
403-
pcall(vim.api.nvim_win_set_cursor, windows.output_win, { visible_bottom, 0 })
404-
local pos = state.ui.get_window_cursor(windows.output_win)
405-
if pos then
406-
state.ui.set_cursor_position('output', pos)
407-
end
408-
end
409-
end
410-
end
477+
M.sync_cursor_with_viewport(windows.output_win)
411478
end,
412479
})
413480
end

tests/unit/cursor_tracking_spec.lua

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,76 @@ describe('output_window.is_at_bottom', function()
284284
-- This is the key behavioral difference from viewport-based check
285285
assert.is_true(output_window.is_at_bottom(win))
286286
end)
287+
288+
it('reports the actual visible bottom line in wrapped windows', function()
289+
local long_line = string.rep('x', 180)
290+
291+
vim.api.nvim_win_set_width(win, 20)
292+
vim.api.nvim_set_option_value('wrap', true, { win = win, scope = 'local' })
293+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', 'line 2', long_line, 'line 4', 'line 5' })
294+
vim.api.nvim_win_set_cursor(win, { 5, 0 })
295+
pcall(vim.api.nvim_win_call, win, function()
296+
vim.fn.winrestview({ topline = 1 })
297+
end)
298+
299+
local visible_bottom = output_window.get_visible_bottom_line(win)
300+
assert.equals(3, visible_bottom)
301+
end)
302+
end)
303+
304+
describe('output_window.sync_cursor_with_viewport', function()
305+
local output_window = require('opencode.ui.output_window')
306+
local buf, win
307+
308+
before_each(function()
309+
config.setup({})
310+
buf = vim.api.nvim_create_buf(false, true)
311+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
312+
'line 1',
313+
'line 2',
314+
string.rep('x', 180),
315+
'line 4',
316+
'line 5',
317+
})
318+
319+
win = vim.api.nvim_open_win(buf, true, {
320+
relative = 'editor',
321+
width = 20,
322+
height = 5,
323+
row = 0,
324+
col = 0,
325+
})
326+
327+
vim.api.nvim_set_option_value('wrap', true, { win = win, scope = 'local' })
328+
state.ui.set_windows({ output_win = win, output_buf = buf })
329+
output_window.reset_scroll_tracking(win)
330+
end)
331+
332+
after_each(function()
333+
output_window.reset_scroll_tracking(win)
334+
pcall(vim.api.nvim_win_close, win, true)
335+
pcall(vim.api.nvim_buf_delete, buf, { force = true })
336+
state.ui.set_windows(nil)
337+
end)
338+
339+
it('keeps the cursor aligned with the actual viewport bottom while scrolling', function()
340+
vim.api.nvim_win_set_cursor(win, { 5, 0 })
341+
pcall(vim.api.nvim_win_call, win, function()
342+
vim.fn.winrestview({ topline = 1 })
343+
end)
344+
output_window.sync_cursor_with_viewport(win)
345+
346+
local cursor = vim.api.nvim_win_get_cursor(win)
347+
assert.equals(3, cursor[1])
348+
end)
349+
350+
it('does not move the cursor when the user is already reading earlier content', function()
351+
vim.api.nvim_win_set_cursor(win, { 2, 0 })
352+
output_window.sync_cursor_with_viewport(win)
353+
354+
local cursor = vim.api.nvim_win_get_cursor(win)
355+
assert.equals(2, cursor[1])
356+
end)
287357
end)
288358

289359
describe('renderer.scroll_to_bottom', function()

0 commit comments

Comments
 (0)