diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 9a04cb0f9a..49d513288b 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -419,6 +419,8 @@ TERMINAL • |nvim_open_term()| can be called with a non-empty buffer. The buffer contents are piped to the PTY and displayed as terminal output. • CSI 3 J (the sequence to clear terminal scrollback) is now supported. +• DEC private mode 2026 (synchronized output) is now supported. Applications + running in |:terminal| can batch screen updates to avoid tearing. • A suspended PTY process is now indicated by "[Process suspended]" at the bottom-left of the buffer and can be resumed by pressing a key. • On terminal exit, "[Process exited]" is shown as virtual text (instead of diff --git a/src/nvim/terminal.c b/src/nvim/terminal.c index c8772a7ffc..8b79395eed 100644 --- a/src/nvim/terminal.c +++ b/src/nvim/terminal.c @@ -199,6 +199,8 @@ struct terminal { } pending; bool theme_updates; ///< Send a theme update notification when 'bg' changes + bool synchronized_output; ///< Mode 2026: suppress redraws until end of synchronized update + bool sync_flush_pending; ///< Set when mode 2026 ends; triggers immediate buffer refresh bool color_set[16]; @@ -1361,6 +1363,20 @@ static void terminal_send_key(Terminal *term, int c) } } +/// Callback scheduled on the main loop when a synchronized update ends. +/// Refreshes a single terminal with full-screen damage. +static void on_sync_flush(void **argv) +{ + handle_T buf_handle = (handle_T)(intptr_t)argv[0]; + buf_T *buf = handle_get_buffer(buf_handle); + if (!buf || !buf->terminal) { + return; + } + block_autocmds(); + refresh_terminal(buf->terminal); + unblock_autocmds(); +} + void terminal_receive(Terminal *term, const char *data, size_t len) { if (!data) { @@ -1383,6 +1399,22 @@ void terminal_receive(Terminal *term, const char *data, size_t len) vterm_input_write(term->vt, data, len); } vterm_screen_flush_damage(term->vts); + + // When a synchronized update just ended, refresh the buffer immediately + // instead of waiting for the 10ms timer. This eliminates the window where + // neovim's UI could repaint showing stale buffer content. + if (term->sync_flush_pending) { + term->sync_flush_pending = false; + // Schedule a full-screen refresh for this terminal on the main loop. + // Force full-screen damage so every row is updated, not just + // the rows with accumulated damage from individual callbacks. + int height; + vterm_get_size(term->vt, &height, NULL); + term->invalid_start = 0; + term->invalid_end = height; + multiqueue_put(main_loop.events, on_sync_flush, + (void *)(intptr_t)term->buf_handle); + } } static int get_rgb(VTermState *state, VTermColor color) @@ -1627,6 +1659,15 @@ static int term_settermprop(VTermProp prop, VTermValue *val, void *data) term->theme_updates = val->boolean; break; + case VTERM_PROP_SYNCOUTPUT: + term->synchronized_output = val->boolean; + if (!val->boolean) { + // Mark that sync just ended; terminal_receive() will flush + // the buffer immediately rather than waiting for the 10ms timer. + term->sync_flush_pending = true; + } + break; + default: return 0; } @@ -1699,7 +1740,9 @@ static int term_sb_push(int cols, const VTermScreenCell *cells, void *data) } memcpy(sbrow->cells, cells, sizeof(cells[0]) * c); - set_put(ptr_t, &invalidated_terminals, term); + if (!term->synchronized_output) { + set_put(ptr_t, &invalidated_terminals, term); + } return 1; } @@ -1739,7 +1782,9 @@ static int term_sb_pop(int cols, VTermScreenCell *cells, void *data) } xfree(sbrow); - set_put(ptr_t, &invalidated_terminals, term); + if (!term->synchronized_output) { + set_put(ptr_t, &invalidated_terminals, term); + } return 1; } @@ -2321,6 +2366,12 @@ static void invalidate_terminal(Terminal *term, int start_row, int end_row) term->invalid_end = MAX(term->invalid_end, end_row); } + // During synchronized output (mode 2026), accumulate damage but defer + // the actual refresh until the synchronized update ends. + if (term->synchronized_output) { + return; + } + set_put(ptr_t, &invalidated_terminals, term); if (!refresh_pending) { time_watcher_start(&refresh_timer, refresh_timer_cb, REFRESH_DELAY, 0); @@ -2427,7 +2478,11 @@ static void refresh_timer_cb(TimeWatcher *watcher, void *data) // don't process autocommands while updating terminal buffers block_autocmds(); set_foreach(&invalidated_terminals, term, { - refresh_terminal(term); + // Skip terminals in synchronized output — they will be refreshed + // when the synchronized update ends (mode 2026 reset). + if (!term->synchronized_output) { + refresh_terminal(term); + } }); set_clear(ptr_t, &invalidated_terminals); unblock_autocmds(); diff --git a/src/nvim/vterm/state.c b/src/nvim/vterm/state.c index 13ca49541c..fe5a0020e8 100644 --- a/src/nvim/vterm/state.c +++ b/src/nvim/vterm/state.c @@ -837,6 +837,10 @@ static void set_dec_mode(VTermState *state, int num, int val) state->mode.bracketpaste = (unsigned)val; break; + case 2026: // Synchronized output + settermprop_bool(state, VTERM_PROP_SYNCOUTPUT, val); + break; + case 2031: settermprop_bool(state, VTERM_PROP_THEMEUPDATES, val); break; @@ -916,6 +920,10 @@ static void request_dec_mode(VTermState *state, int num) reply = state->mode.bracketpaste; break; + case 2026: + reply = state->mode.synchronized_output; + break; + case 2031: reply = state->mode.theme_updates; break; @@ -2431,6 +2439,9 @@ int vterm_state_set_termprop(VTermState *state, VTermProp prop, VTermValue *val) case VTERM_PROP_THEMEUPDATES: state->mode.theme_updates = (unsigned)val->boolean; return 1; + case VTERM_PROP_SYNCOUTPUT: + state->mode.synchronized_output = (unsigned)val->boolean; + return 1; case VTERM_N_PROPS: return 0; diff --git a/src/nvim/vterm/vterm_defs.h b/src/nvim/vterm/vterm_defs.h index ed0aca8204..7d6dbc3df7 100644 --- a/src/nvim/vterm/vterm_defs.h +++ b/src/nvim/vterm/vterm_defs.h @@ -89,6 +89,7 @@ typedef enum { VTERM_PROP_MOUSE, // number VTERM_PROP_FOCUSREPORT, // bool VTERM_PROP_THEMEUPDATES, // bool + VTERM_PROP_SYNCOUTPUT, // bool VTERM_N_PROPS, } VTermProp; diff --git a/src/nvim/vterm/vterm_internal_defs.h b/src/nvim/vterm/vterm_internal_defs.h index b3d051ab9d..ef9c82c327 100644 --- a/src/nvim/vterm/vterm_internal_defs.h +++ b/src/nvim/vterm/vterm_internal_defs.h @@ -144,6 +144,7 @@ struct VTermState { unsigned bracketpaste:1; unsigned report_focus:1; unsigned theme_updates:1; + unsigned synchronized_output:1; } mode; VTermEncodingInstance encoding[4], encoding_utf8; diff --git a/test/functional/terminal/synchronized_output_spec.lua b/test/functional/terminal/synchronized_output_spec.lua new file mode 100644 index 0000000000..16db6255d1 --- /dev/null +++ b/test/functional/terminal/synchronized_output_spec.lua @@ -0,0 +1,159 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() +local Screen = require('test.functional.ui.screen') + +local clear = n.clear +local api = n.api +local eq = t.eq +local poke_eventloop = n.poke_eventloop +local assert_alive = n.assert_alive + +local CSI = '\027[' + +describe(':terminal synchronized output (mode 2026)', function() + local screen, chan, buf + + before_each(function() + clear() + screen = Screen.new(50, 7) + buf = api.nvim_create_buf(true, true) + chan = api.nvim_open_term(buf, {}) + api.nvim_win_set_buf(0, buf) + end) + + it('renders content sent inside a synchronized update', function() + api.nvim_chan_send(chan, CSI .. '?2026h') + api.nvim_chan_send(chan, 'synced line 1\r\n') + api.nvim_chan_send(chan, 'synced line 2\r\n') + api.nvim_chan_send(chan, CSI .. '?2026l') + screen:expect([[ + ^synced line 1 | + synced line 2 | + |*4 + | + ]]) + end) + + it('renders all lines from a synchronized update', function() + api.nvim_chan_send(chan, CSI .. '?2026h') + for i = 1, 5 do + api.nvim_chan_send(chan, 'line ' .. i .. '\r\n') + end + api.nvim_chan_send(chan, CSI .. '?2026l') + screen:expect([[ + ^line 1 | + line 2 | + line 3 | + line 4 | + line 5 | + | + | + ]]) + -- Buffer lines should also match. + local lines = api.nvim_buf_get_lines(buf, 0, -1, true) + eq('line 1', lines[1]) + eq('line 2', lines[2]) + eq('line 5', lines[5]) + end) + + it('handles multiple synchronized update cycles', function() + -- First cycle. + api.nvim_chan_send(chan, CSI .. '?2026h') + api.nvim_chan_send(chan, 'cycle 1\r\n') + api.nvim_chan_send(chan, CSI .. '?2026l') + screen:expect([[ + ^cycle 1 | + |*5 + | + ]]) + + -- Second cycle. + api.nvim_chan_send(chan, CSI .. '?2026h') + api.nvim_chan_send(chan, 'cycle 2\r\n') + api.nvim_chan_send(chan, CSI .. '?2026l') + screen:expect([[ + ^cycle 1 | + cycle 2 | + |*4 + | + ]]) + end) + + it('works with content before and after sync', function() + -- Unsynchronized content. + api.nvim_chan_send(chan, 'before\r\n') + poke_eventloop() + screen:expect([[ + ^before | + |*5 + | + ]]) + + -- Synchronized content. + api.nvim_chan_send(chan, CSI .. '?2026h') + api.nvim_chan_send(chan, 'during\r\n') + api.nvim_chan_send(chan, CSI .. '?2026l') + screen:expect([[ + ^before | + during | + |*4 + | + ]]) + + -- More unsynchronized content. + api.nvim_chan_send(chan, 'after\r\n') + screen:expect([[ + ^before | + during | + after | + |*3 + | + ]]) + end) + + it('does not crash when mode 2026 is set and queried', function() + api.nvim_chan_send(chan, CSI .. '?2026h') + assert_alive() + api.nvim_chan_send(chan, CSI .. '?2026l') + assert_alive() + end) + + it('defers screen update during sync mode', function() + -- Establish a known screen state first. + api.nvim_chan_send(chan, 'visible\r\n') + screen:expect([[ + ^visible | + |*5 + | + ]]) + -- Begin sync and send more content — screen should not change. + api.nvim_chan_send(chan, CSI .. '?2026h') + api.nvim_chan_send(chan, 'deferred\r\n') + screen:expect_unchanged() + -- End sync — now the deferred content should appear. + api.nvim_chan_send(chan, CSI .. '?2026l') + screen:expect([[ + ^visible | + deferred | + |*4 + | + ]]) + end) + + it('handles rapid sync on/off toggling', function() + for i = 1, 5 do + api.nvim_chan_send(chan, CSI .. '?2026h') + api.nvim_chan_send(chan, 'rapid ' .. i .. '\r\n') + api.nvim_chan_send(chan, CSI .. '?2026l') + end + screen:expect([[ + ^rapid 1 | + rapid 2 | + rapid 3 | + rapid 4 | + rapid 5 | + | + | + ]]) + end) +end) diff --git a/test/unit/fixtures/vterm_test.c b/test/unit/fixtures/vterm_test.c index e3558fee02..a3b40d65da 100644 --- a/test/unit/fixtures/vterm_test.c +++ b/test/unit/fixtures/vterm_test.c @@ -347,6 +347,8 @@ static VTermValueType vterm_get_prop_type(VTermProp prop) return VTERM_VALUETYPE_BOOL; case VTERM_PROP_THEMEUPDATES: return VTERM_VALUETYPE_BOOL; + case VTERM_PROP_SYNCOUTPUT: + return VTERM_VALUETYPE_BOOL; case VTERM_N_PROPS: return 0; diff --git a/test/unit/vterm_spec.lua b/test/unit/vterm_spec.lua index 80a85bb011..ee4decd531 100644 --- a/test/unit/vterm_spec.lua +++ b/test/unit/vterm_spec.lua @@ -2621,6 +2621,22 @@ putglyph 1f3f4,200d,2620,fe0f 2 0,4]]) vterm.vterm_state_focus_out(state) expect_output('\x1b[O') + -- Synchronized output mode 2026 + push('\x1b[?2026h', vt) + expect('settermprop 11 true') + push('\x1b[?2026l', vt) + expect('settermprop 11 false') + + -- DECRQM on synchronized output + push('\x1b[?2026h', vt) + expect('settermprop 11 true') + push('\x1b[?2026$p', vt) + expect_output('\x1b[?2026;1$y') + push('\x1b[?2026l', vt) + expect('settermprop 11 false') + push('\x1b[?2026$p', vt) + expect_output('\x1b[?2026;2$y') + -- Disambiguate escape codes disabled push('\x1b[