feat(tui): add 'termsync' option (#25871)

The 'termsync' option enables a mode (provided the underlying terminal
supports it) where all screen updates during a redraw cycle are buffered
and drawn together when the redraw is complete. This eliminates tearing
or flickering in cases where Nvim redraws slower than the terminal
redraws the screen.
This commit is contained in:
Gregory Anders 2023-11-14 08:53:58 -06:00 committed by GitHub
parent b73a829837
commit ac8ed77afb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 130 additions and 2 deletions

View File

@ -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 • A clipboard provider which uses OSC 52 to copy the selection to the system
clipboard is now bundled by default. |clipboard-osc52| 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* CHANGED FEATURES *news-changed*

View File

@ -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 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'*
'textwidth' 'tw' number (default 0) 'textwidth' 'tw' number (default 0)
local to buffer local to buffer

View File

@ -6978,6 +6978,15 @@ vim.o.tpf = vim.o.termpastefilter
vim.go.termpastefilter = vim.o.termpastefilter vim.go.termpastefilter = vim.o.termpastefilter
vim.go.tpf = vim.go.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 --- 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 --- broken after white space to get this width. A zero value disables
--- this. --- this.

View File

@ -737,6 +737,7 @@ EXTERN OptInt p_uc; ///< 'updatecount'
EXTERN OptInt p_ut; ///< 'updatetime' EXTERN OptInt p_ut; ///< 'updatetime'
EXTERN char *p_shada; ///< 'shada' EXTERN char *p_shada; ///< 'shada'
EXTERN char *p_shadafile; ///< 'shadafile' EXTERN char *p_shadafile; ///< 'shadafile'
EXTERN int p_termsync; ///< 'termsync'
EXTERN char *p_vsts; ///< 'varsofttabstop' EXTERN char *p_vsts; ///< 'varsofttabstop'
EXTERN char *p_vts; ///< 'vartabstop' EXTERN char *p_vts; ///< 'vartabstop'
EXTERN char *p_vdir; ///< 'viewdir' EXTERN char *p_vdir; ///< 'viewdir'

View File

@ -8809,6 +8809,21 @@ return {
type = 'string', type = 'string',
varname = 'p_tpf', 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 }, defaults = { if_true = false },
full_name = 'terse', full_name = 'terse',

View File

@ -480,6 +480,8 @@ static void tk_getkeys(TermInput *input, bool force)
} }
} else if (key.type == TERMKEY_TYPE_OSC) { } else if (key.type == TERMKEY_TYPE_OSC) {
handle_osc_event(input, &key); 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) static void handle_osc_event(TermInput *input, const TermKeyKey *key)
FUNC_ATTR_NONNULL_ALL
{ {
assert(input);
const char *str = NULL; const char *str = NULL;
if (termkey_interpret_string(input->tk, key, &str) == TERMKEY_RES_KEY) { if (termkey_interpret_string(input->tk, key, &str) == TERMKEY_RES_KEY) {
assert(str != NULL); 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) static void handle_raw_buffer(TermInput *input, bool force)
{ {
HandleState is_paste = kNotApplicable; HandleState is_paste = kNotApplicable;

View File

@ -104,6 +104,7 @@ struct TUIData {
bool mouse_enabled; bool mouse_enabled;
bool mouse_move_enabled; bool mouse_move_enabled;
bool title_enabled; bool title_enabled;
bool sync_output;
bool busy, is_invisible, want_invisible; bool busy, is_invisible, want_invisible;
bool cork, overflow; bool cork, overflow;
bool set_cursor_color_as_str; bool set_cursor_color_as_str;
@ -137,6 +138,7 @@ struct TUIData {
int set_underline_color; int set_underline_color;
int enable_extended_keys, disable_extended_keys; int enable_extended_keys, disable_extended_keys;
int get_extkeys; int get_extkeys;
int sync;
} unibi_ext; } unibi_ext;
char *space_buf; char *space_buf;
bool stopped; 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); 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) static void terminfo_start(TUIData *tui)
{ {
tui->scroll_region_is_full_screen = true; 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.enable_extended_keys = -1;
tui->unibi_ext.disable_extended_keys = -1; tui->unibi_ext.disable_extended_keys = -1;
tui->unibi_ext.get_extkeys = -1; tui->unibi_ext.get_extkeys = -1;
tui->unibi_ext.sync = -1;
tui->out_fd = STDOUT_FILENO; tui->out_fd = STDOUT_FILENO;
tui->out_isatty = os_isatty(tui->out_fd); tui->out_isatty = os_isatty(tui->out_fd);
tui->input.tui_data = tui; tui->input.tui_data = tui;
@ -329,6 +367,11 @@ static void terminfo_start(TUIData *tui)
// Enable bracketed paste // Enable bracketed paste
unibi_out_ext(tui, tui->unibi_ext.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 // Query the terminal to see if it supports CSI u
tui->input.waiting_for_csiu_response = 5; tui->input.waiting_for_csiu_response = 5;
unibi_out_ext(tui, tui->unibi_ext.get_extkeys); 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); unibi_out_ext(tui, tui->unibi_ext.disable_bracketed_paste);
// Disable focus reporting // Disable focus reporting
unibi_out_ext(tui, tui->unibi_ext.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); flush_buf(tui);
uv_tty_reset_mode(); uv_tty_reset_mode();
uv_close((uv_handle_t *)&tui->output_handle, NULL); 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); 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) void tui_flush(TUIData *tui)
{ {
UGrid *grid = &tui->grid; UGrid *grid = &tui->grid;
@ -1273,6 +1335,8 @@ void tui_flush(TUIData *tui)
tui_busy_stop(tui); // avoid hidden cursor tui_busy_stop(tui); // avoid hidden cursor
} }
tui_sync_output(tui, true);
while (kv_size(tui->invalid_regions)) { while (kv_size(tui->invalid_regions)) {
Rect r = kv_pop(tui->invalid_regions); Rect r = kv_pop(tui->invalid_regions);
assert(r.bot <= grid->height && r.right <= grid->width); 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); cursor_goto(tui, tui->row, tui->col);
tui_sync_output(tui, false);
flush_buf(tui); flush_buf(tui);
} }
@ -1449,6 +1515,8 @@ void tui_option_set(TUIData *tui, String name, Object value)
tui->input.ttimeoutlen = (OptInt)value.data.integer; tui->input.ttimeoutlen = (OptInt)value.data.integer;
} else if (strequal(name.data, "verbose")) { } else if (strequal(name.data, "verbose")) {
tui->verbose = value.data.integer; tui->verbose = value.data.integer;
} else if (strequal(name.data, "termsync")) {
tui->sync_output = value.data.boolean;
} }
} }

View File

@ -5,6 +5,18 @@
typedef struct TUIData TUIData; 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 #ifdef INCLUDE_GENERATED_DECLARATIONS
# include "tui/tui.h.generated.h" # include "tui/tui.h.generated.h"
#endif #endif

View File

@ -23,6 +23,7 @@ describe('UI receives option updates', function()
mousemoveevent=false, mousemoveevent=false,
showtabline=1, showtabline=1,
termguicolors=false, termguicolors=false,
termsync=true,
ttimeout=true, ttimeout=true,
ttimeoutlen=50, ttimeoutlen=50,
verbose=0, verbose=0,