diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index ee48bddc4d..ae97772b66 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -211,6 +211,9 @@ The following new APIs and features were added. • A clipboard provider which uses OSC 52 to copy the selection to the system clipboard is now bundled by default. |clipboard-osc52| +• The 'termsync' option asks the terminal emulator to buffer screen updates + until the redraw cycle is complete. Requires support from the terminal. + ============================================================================== CHANGED FEATURES *news-changed* diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index 603b777f55..07326c8c13 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -6523,6 +6523,14 @@ A jump table for the options with a short description can be found at |Q_op|. C1 Control characters 0x80...0x9F + *'termsync'* *'notermsync'* +'termsync' boolean (default on) + global + If the host terminal supports it, buffer all screen updates + made during a redraw cycle so that each screen is displayed in + the terminal all at once. This can prevent tearing or flickering + when the terminal updates faster than Nvim can redraw. + *'textwidth'* *'tw'* 'textwidth' 'tw' number (default 0) local to buffer diff --git a/runtime/lua/vim/_meta/options.lua b/runtime/lua/vim/_meta/options.lua index 0ef0fece90..19ae786177 100644 --- a/runtime/lua/vim/_meta/options.lua +++ b/runtime/lua/vim/_meta/options.lua @@ -6978,6 +6978,15 @@ vim.o.tpf = vim.o.termpastefilter vim.go.termpastefilter = vim.o.termpastefilter vim.go.tpf = vim.go.termpastefilter +--- If the host terminal supports it, buffer all screen updates +--- made during a redraw cycle so that each screen is displayed in +--- the terminal all at once. This can prevent tearing or flickering +--- when the terminal updates faster than Nvim can redraw. +--- +--- @type boolean +vim.o.termsync = true +vim.go.termsync = vim.o.termsync + --- Maximum width of text that is being inserted. A longer line will be --- broken after white space to get this width. A zero value disables --- this. diff --git a/src/nvim/option_vars.h b/src/nvim/option_vars.h index f8f6fa5670..f0c752a2b1 100644 --- a/src/nvim/option_vars.h +++ b/src/nvim/option_vars.h @@ -737,6 +737,7 @@ EXTERN OptInt p_uc; ///< 'updatecount' EXTERN OptInt p_ut; ///< 'updatetime' EXTERN char *p_shada; ///< 'shada' EXTERN char *p_shadafile; ///< 'shadafile' +EXTERN int p_termsync; ///< 'termsync' EXTERN char *p_vsts; ///< 'varsofttabstop' EXTERN char *p_vts; ///< 'vartabstop' EXTERN char *p_vdir; ///< 'viewdir' diff --git a/src/nvim/options.lua b/src/nvim/options.lua index 373dc3c460..9ca1396753 100644 --- a/src/nvim/options.lua +++ b/src/nvim/options.lua @@ -8809,6 +8809,21 @@ return { type = 'string', varname = 'p_tpf', }, + { + defaults = { if_true = true }, + desc = [=[ + If the host terminal supports it, buffer all screen updates + made during a redraw cycle so that each screen is displayed in + the terminal all at once. This can prevent tearing or flickering + when the terminal updates faster than Nvim can redraw. + ]=], + full_name = 'termsync', + redraw = { 'ui_option' }, + scope = { 'global' }, + short_desc = N_('synchronize redraw output with the host terminal'), + type = 'bool', + varname = 'p_termsync', + }, { defaults = { if_true = false }, full_name = 'terse', diff --git a/src/nvim/tui/input.c b/src/nvim/tui/input.c index 7dce8473d5..db1281a0b5 100644 --- a/src/nvim/tui/input.c +++ b/src/nvim/tui/input.c @@ -480,6 +480,8 @@ static void tk_getkeys(TermInput *input, bool force) } } else if (key.type == TERMKEY_TYPE_OSC) { handle_osc_event(input, &key); + } else if (key.type == TERMKEY_TYPE_MODEREPORT) { + handle_modereport(input, &key); } } @@ -579,9 +581,8 @@ static HandleState handle_bracketed_paste(TermInput *input) } static void handle_osc_event(TermInput *input, const TermKeyKey *key) + FUNC_ATTR_NONNULL_ALL { - assert(input); - const char *str = NULL; if (termkey_interpret_string(input->tk, key, &str) == TERMKEY_RES_KEY) { assert(str != NULL); @@ -601,6 +602,16 @@ static void handle_osc_event(TermInput *input, const TermKeyKey *key) } } +static void handle_modereport(TermInput *input, const TermKeyKey *key) + FUNC_ATTR_NONNULL_ALL +{ + // termkey_interpret_modereport incorrectly sign extends the mode so we parse the response + // ourselves + int mode = (uint8_t)key->code.mouse[1] << 8 | (uint8_t)key->code.mouse[2]; + TerminalModeState value = (uint8_t)key->code.mouse[3]; + tui_dec_report_mode(input->tui_data, (TerminalDecMode)mode, value); +} + static void handle_raw_buffer(TermInput *input, bool force) { HandleState is_paste = kNotApplicable; diff --git a/src/nvim/tui/tui.c b/src/nvim/tui/tui.c index 8aab4d836c..aff7b100d8 100644 --- a/src/nvim/tui/tui.c +++ b/src/nvim/tui/tui.c @@ -104,6 +104,7 @@ struct TUIData { bool mouse_enabled; bool mouse_move_enabled; bool title_enabled; + bool sync_output; bool busy, is_invisible, want_invisible; bool cork, overflow; bool set_cursor_color_as_str; @@ -137,6 +138,7 @@ struct TUIData { int set_underline_color; int enable_extended_keys, disable_extended_keys; int get_extkeys; + int sync; } unibi_ext; char *space_buf; bool stopped; @@ -217,6 +219,41 @@ static size_t unibi_pre_fmt_str(TUIData *tui, unsigned unibi_index, char *buf, s return unibi_run(str, tui->params, buf, len); } +/// Request the terminal's DEC mode (DECRQM). +/// +/// @see handle_modereport +static void tui_dec_request_mode(TUIData *tui, TerminalDecMode mode) +{ + // 5 bytes for \x1b[?$p, 1 byte for null terminator, 6 bytes for mode digits (more than enough) + char buf[12]; + int len = snprintf(buf, sizeof(buf), "\x1b[?%d$p", (int)mode); + assert((len > 0) && (len < (int)sizeof(buf))); + out(tui, buf, (size_t)len); +} + +/// Handle a DECRPM response from the terminal. +void tui_dec_report_mode(TUIData *tui, TerminalDecMode mode, TerminalModeState state) +{ + assert(tui); + switch (state) { + case kTerminalModeNotRecognized: + case kTerminalModePermanentlySet: + case kTerminalModePermanentlyReset: + // If the mode is not recognized, or if the terminal emulator does not allow it to be changed, + // then there is nothing to do + break; + case kTerminalModeSet: + case kTerminalModeReset: + // The terminal supports changing the given mode + switch (mode) { + case kDecModeSynchronizedOutput: + // Ref: https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 + tui->unibi_ext.sync = (int)unibi_add_ext_str(tui->ut, "Sync", + "\x1b[?2026%?%p1%{1}%-%tl%eh%;"); + } + } +} + static void terminfo_start(TUIData *tui) { tui->scroll_region_is_full_screen = true; @@ -253,6 +290,7 @@ static void terminfo_start(TUIData *tui) tui->unibi_ext.enable_extended_keys = -1; tui->unibi_ext.disable_extended_keys = -1; tui->unibi_ext.get_extkeys = -1; + tui->unibi_ext.sync = -1; tui->out_fd = STDOUT_FILENO; tui->out_isatty = os_isatty(tui->out_fd); tui->input.tui_data = tui; @@ -329,6 +367,11 @@ static void terminfo_start(TUIData *tui) // Enable bracketed paste unibi_out_ext(tui, tui->unibi_ext.enable_bracketed_paste); + // Query support for mode 2026 (Synchronized Output). Some terminals also + // support an older DCS sequence for synchronized output, but we will only use + // mode 2026 + tui_dec_request_mode(tui, kDecModeSynchronizedOutput); + // Query the terminal to see if it supports CSI u tui->input.waiting_for_csiu_response = 5; unibi_out_ext(tui, tui->unibi_ext.get_extkeys); @@ -395,6 +438,11 @@ static void terminfo_stop(TUIData *tui) unibi_out_ext(tui, tui->unibi_ext.disable_bracketed_paste); // Disable focus reporting unibi_out_ext(tui, tui->unibi_ext.disable_focus_reporting); + + // Disable synchronized output + UNIBI_SET_NUM_VAR(tui->params[0], 0); + unibi_out_ext(tui, tui->unibi_ext.sync); + flush_buf(tui); uv_tty_reset_mode(); uv_close((uv_handle_t *)&tui->output_handle, NULL); @@ -1257,6 +1305,20 @@ void tui_default_colors_set(TUIData *tui, Integer rgb_fg, Integer rgb_bg, Intege invalidate(tui, 0, tui->grid.height, 0, tui->grid.width); } +/// Enable synchronized output. When enabled, the terminal emulator will preserve the last rendered +/// state on subsequent re-renders. It will continue to process incoming events. When synchronized +/// mode is disabled again the emulator renders using the most recent state. This avoids tearing +/// when the terminal updates the screen faster than Nvim can redraw it. +static void tui_sync_output(TUIData *tui, bool enable) +{ + if (!tui->sync_output) { + return; + } + + UNIBI_SET_NUM_VAR(tui->params[0], enable ? 1 : 0); + unibi_out_ext(tui, tui->unibi_ext.sync); +} + void tui_flush(TUIData *tui) { UGrid *grid = &tui->grid; @@ -1273,6 +1335,8 @@ void tui_flush(TUIData *tui) tui_busy_stop(tui); // avoid hidden cursor } + tui_sync_output(tui, true); + while (kv_size(tui->invalid_regions)) { Rect r = kv_pop(tui->invalid_regions); assert(r.bot <= grid->height && r.right <= grid->width); @@ -1300,6 +1364,8 @@ void tui_flush(TUIData *tui) cursor_goto(tui, tui->row, tui->col); + tui_sync_output(tui, false); + flush_buf(tui); } @@ -1449,6 +1515,8 @@ void tui_option_set(TUIData *tui, String name, Object value) tui->input.ttimeoutlen = (OptInt)value.data.integer; } else if (strequal(name.data, "verbose")) { tui->verbose = value.data.integer; + } else if (strequal(name.data, "termsync")) { + tui->sync_output = value.data.boolean; } } diff --git a/src/nvim/tui/tui.h b/src/nvim/tui/tui.h index c89053d053..29afdef4de 100644 --- a/src/nvim/tui/tui.h +++ b/src/nvim/tui/tui.h @@ -5,6 +5,18 @@ typedef struct TUIData TUIData; +typedef enum { + kDecModeSynchronizedOutput = 2026, +} TerminalDecMode; + +typedef enum { + kTerminalModeNotRecognized = 0, + kTerminalModeSet = 1, + kTerminalModeReset = 2, + kTerminalModePermanentlySet = 3, + kTerminalModePermanentlyReset = 4, +} TerminalModeState; + #ifdef INCLUDE_GENERATED_DECLARATIONS # include "tui/tui.h.generated.h" #endif diff --git a/test/functional/ui/options_spec.lua b/test/functional/ui/options_spec.lua index 6af1820430..2c649709c6 100644 --- a/test/functional/ui/options_spec.lua +++ b/test/functional/ui/options_spec.lua @@ -23,6 +23,7 @@ describe('UI receives option updates', function() mousemoveevent=false, showtabline=1, termguicolors=false, + termsync=true, ttimeout=true, ttimeoutlen=50, verbose=0,