Files
neovim/test/unit/fixtures/vterm_test.c
mgleonard425 b38173e493 feat(terminal): synchronized output (mode 2026) #38284
Problem:
Applications running inside :terminal that use DEC private mode 2026
(synchronized output) to batch screen updates get garbled rendering.
Neovim's embedded libvterm does not handle mode 2026, so the
synchronization sequences are ignored and intermediate screen states
leak through as visual corruption.

Solution:
Add mode 2026 support to libvterm's state machine and wire it through
to terminal.c. When an application enables mode 2026, invalidation of
the terminal buffer is deferred until the application disables it,
causing all accumulated screen updates to flush as a single
atomic refresh.

* fix(terminal): harden sync output redraw gating

Problem:
The initial mode 2026 implementation gated invalidate_terminal()
but missed three other redraw paths: term_sb_push/term_sb_pop
bypassed the gate by directly adding to invalidated_terminals,
refresh_timer_cb could fire mid-sync flushing partial state, and
the 10ms timer delay after sync-end left a window for stale
repaints.

Solution:
- Gate term_sb_push/term_sb_pop during synchronized output
- Skip syncing terminals in refresh_timer_cb
- On sync end, schedule a zero-delay full-screen refresh via
  sync_flush_pending flag in terminal_receive()
- Add news.txt entry for mode 2026 support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(terminal): add vterm unit tests for mode 2026

Add unit-level tests for synchronized output (mode 2026) to
vterm_spec.lua, covering settermprop callbacks and DECRQM
query/response.

Suggested-by: justinmk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(terminal): address review feedback for mode 2026

- Use multiqueue_put(main_loop.events) instead of restarting the
  global refresh timer on sync end, to avoid affecting other
  invalidated terminals.
- Add screen:expect_unchanged() to verify screen doesn't update
  during sync mode.
- Merge buffer-lines test into existing test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:40:49 -04:00

814 lines
17 KiB
C

#include <stdio.h>
#include <string.h>
#include "nvim/grid.h"
#include "nvim/mbyte.h"
#include "nvim/vterm/pen.h"
#include "nvim/vterm/screen.h"
#include "nvim/vterm/vterm_internal_defs.h"
#include "vterm_test.h"
int parser_text(const char bytes[], size_t len, void *user)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "text ");
size_t i;
for (i = 0; i < len; i++) {
unsigned char b = (unsigned char)bytes[i];
if (b < 0x20 || b == 0x7f || (b >= 0x80 && b < 0xa0)) {
break;
}
fprintf(f, i ? ",%x" : "%x", b);
}
fprintf(f, "\n");
fclose(f);
return (int)i;
}
static void printchars(const char *s, size_t len, FILE *f)
{
while (len--) {
fprintf(f, "%c", (s++)[0]);
}
}
int parser_csi(const char *leader, const long args[], int argcount, const char *intermed,
char command, void *user)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "csi %02x", command);
if (leader && leader[0]) {
fprintf(f, " L=");
for (int i = 0; leader[i]; i++) {
fprintf(f, "%02x", leader[i]);
}
}
for (int i = 0; i < argcount; i++) {
char sep = i ? ',' : ' ';
if (args[i] == CSI_ARG_MISSING) {
fprintf(f, "%c*", sep);
} else {
fprintf(f, "%c%ld%s", sep, CSI_ARG(args[i]), CSI_ARG_HAS_MORE(args[i]) ? "+" : "");
}
}
if (intermed && intermed[0]) {
fprintf(f, " I=");
for (int i = 0; intermed[i]; i++) {
fprintf(f, "%02x", intermed[i]);
}
}
fprintf(f, "\n");
fclose(f);
return 1;
}
int parser_osc(int command, VTermStringFragment frag, void *user)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "osc ");
if (frag.initial) {
if (command == -1) {
fprintf(f, "[");
} else {
fprintf(f, "[%d;", command);
}
}
printchars(frag.str, frag.len, f);
if (frag.final) {
fprintf(f, "]");
}
fprintf(f, "\n");
fclose(f);
return 1;
}
int parser_dcs(const char *command, size_t commandlen, VTermStringFragment frag, void *user)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "dcs ");
if (frag.initial) {
fprintf(f, "[");
for (size_t i = 0; i < commandlen; i++) {
fprintf(f, "%c", command[i]);
}
}
printchars(frag.str, frag.len, f);
if (frag.final) {
fprintf(f, "]");
}
fprintf(f, "\n");
fclose(f);
return 1;
}
int parser_apc(VTermStringFragment frag, void *user)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "apc ");
if (frag.initial) {
fprintf(f, "[");
}
printchars(frag.str, frag.len, f);
if (frag.final) {
fprintf(f, "]");
}
fprintf(f, "\n");
fclose(f);
return 1;
}
int parser_pm(VTermStringFragment frag, void *user)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "pm ");
if (frag.initial) {
fprintf(f, "[");
}
printchars(frag.str, frag.len, f);
if (frag.final) {
fprintf(f, "]");
}
fprintf(f, "\n");
fclose(f);
return 1;
}
int parser_sos(VTermStringFragment frag, void *user)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "sos ");
if (frag.initial) {
fprintf(f, "[");
}
printchars(frag.str, frag.len, f);
if (frag.final) {
fprintf(f, "]");
}
fprintf(f, "\n");
fclose(f);
return 1;
}
int selection_set(VTermSelectionMask mask, VTermStringFragment frag, void *user)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "selection-set mask=%04X ", mask);
if (frag.initial) {
fprintf(f, "[");
}
printchars(frag.str, frag.len, f);
if (frag.final) {
fprintf(f, "]");
}
fprintf(f, "\n");
fclose(f);
return 1;
}
int selection_query(VTermSelectionMask mask, void *user)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "selection-query mask=%04X\n", mask);
fclose(f);
return 1;
}
static void print_schar(FILE *f, schar_T schar)
{
char buf[MAX_SCHAR_SIZE];
schar_get(buf, schar);
StrCharInfo ci = utf_ptr2StrCharInfo(buf);
bool did = false;
while (*ci.ptr != 0) {
if (did) {
fprintf(f, ",");
}
if (ci.chr.len == 1 && ci.chr.value >= 0x80) {
fprintf(f, "??%x", ci.chr.value);
} else {
fprintf(f, "%x", ci.chr.value);
}
did = true;
ci = utf_ptr2StrCharInfo(ci.ptr + ci.chr.len);
}
}
bool want_state_putglyph;
int state_putglyph(VTermGlyphInfo *info, VTermPos pos, void *user)
{
if (!want_state_putglyph) {
return 1;
}
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "putglyph ");
print_schar(f, info->schar);
fprintf(f, " %d %d,%d", info->width, pos.row, pos.col);
if (info->protected_cell) {
fprintf(f, " prot");
}
if (info->dwl) {
fprintf(f, " dwl");
}
if (info->dhl) {
fprintf(f, " dhl-%s", info->dhl == 1 ? "top" : info->dhl == 2 ? "bottom" : "?");
}
fprintf(f, "\n");
fclose(f);
return 1;
}
bool want_state_movecursor;
VTermPos state_pos;
int state_movecursor(VTermPos pos, VTermPos oldpos, int visible, void *user)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
state_pos = pos;
if (want_state_movecursor) {
fprintf(f, "movecursor %d,%d\n", pos.row, pos.col);
}
fclose(f);
return 1;
}
bool want_state_scrollrect;
int state_scrollrect(VTermRect rect, int downward, int rightward, void *user)
{
if (!want_state_scrollrect) {
return 0;
}
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "scrollrect %d..%d,%d..%d => %+d,%+d\n",
rect.start_row, rect.end_row, rect.start_col, rect.end_col,
downward, rightward);
fclose(f);
return 1;
}
bool want_state_moverect;
int state_moverect(VTermRect dest, VTermRect src, void *user)
{
if (!want_state_moverect) {
return 0;
}
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "moverect %d..%d,%d..%d -> %d..%d,%d..%d\n",
src.start_row, src.end_row, src.start_col, src.end_col,
dest.start_row, dest.end_row, dest.start_col, dest.end_col);
fclose(f);
return 1;
}
void print_color(const VTermColor *col)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
if (VTERM_COLOR_IS_RGB(col)) {
fprintf(f, "rgb(%d,%d,%d", col->rgb.red, col->rgb.green, col->rgb.blue);
} else if (VTERM_COLOR_IS_INDEXED(col)) {
fprintf(f, "idx(%d", col->indexed.idx);
} else {
fprintf(f, "invalid(%d", col->type);
}
if (VTERM_COLOR_IS_DEFAULT_FG(col)) {
fprintf(f, ",is_default_fg");
}
if (VTERM_COLOR_IS_DEFAULT_BG(col)) {
fprintf(f, ",is_default_bg");
}
fprintf(f, ")");
fclose(f);
}
static VTermValueType vterm_get_prop_type(VTermProp prop)
{
switch (prop) {
case VTERM_PROP_CURSORVISIBLE:
return VTERM_VALUETYPE_BOOL;
case VTERM_PROP_CURSORBLINK:
return VTERM_VALUETYPE_BOOL;
case VTERM_PROP_ALTSCREEN:
return VTERM_VALUETYPE_BOOL;
case VTERM_PROP_TITLE:
return VTERM_VALUETYPE_STRING;
case VTERM_PROP_ICONNAME:
return VTERM_VALUETYPE_STRING;
case VTERM_PROP_REVERSE:
return VTERM_VALUETYPE_BOOL;
case VTERM_PROP_CURSORSHAPE:
return VTERM_VALUETYPE_INT;
case VTERM_PROP_MOUSE:
return VTERM_VALUETYPE_INT;
case VTERM_PROP_FOCUSREPORT:
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;
}
return 0; // UNREACHABLE
}
bool want_state_settermprop;
int state_settermprop(VTermProp prop, VTermValue *val, void *user)
{
if (!want_state_settermprop) {
return 1;
}
int errcode = 0;
FILE *f = fopen(VTERM_TEST_FILE, "a");
VTermValueType type = vterm_get_prop_type(prop);
switch (type) {
case VTERM_VALUETYPE_BOOL:
fprintf(f, "settermprop %d %s\n", prop, val->boolean ? "true" : "false");
errcode = 1;
goto end;
case VTERM_VALUETYPE_INT:
fprintf(f, "settermprop %d %d\n", prop, val->number);
errcode = 1;
goto end;
case VTERM_VALUETYPE_STRING:
fprintf(f, "settermprop %d %s\"%.*s\"%s\n", prop,
val->string.initial ? "[" : "", (int)val->string.len, val->string.str,
val->string.final ? "]" : "");
errcode = 0;
goto end;
case VTERM_VALUETYPE_COLOR:
fprintf(f, "settermprop %d ", prop);
print_color(&val->color);
fprintf(f, "\n");
errcode = 1;
goto end;
case VTERM_N_VALUETYPES:
goto end;
}
end:
fclose(f);
return errcode;
}
bool want_state_erase;
int state_erase(VTermRect rect, int selective, void *user)
{
if (!want_state_erase) {
return 1;
}
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "erase %d..%d,%d..%d%s\n",
rect.start_row, rect.end_row, rect.start_col, rect.end_col,
selective ? " selective" : "");
fclose(f);
return 1;
}
struct {
int bold;
int underline;
int italic;
int blink;
int reverse;
int conceal;
int strike;
int font;
int small;
int baseline;
int dim;
int overline;
VTermColor foreground;
VTermColor background;
} state_pen;
int state_setpenattr(VTermAttr attr, VTermValue *val, void *user)
{
switch (attr) {
case VTERM_ATTR_BOLD:
state_pen.bold = val->boolean;
break;
case VTERM_ATTR_UNDERLINE:
state_pen.underline = val->number;
break;
case VTERM_ATTR_ITALIC:
state_pen.italic = val->boolean;
break;
case VTERM_ATTR_BLINK:
state_pen.blink = val->boolean;
break;
case VTERM_ATTR_REVERSE:
state_pen.reverse = val->boolean;
break;
case VTERM_ATTR_CONCEAL:
state_pen.conceal = val->boolean;
break;
case VTERM_ATTR_STRIKE:
state_pen.strike = val->boolean;
break;
case VTERM_ATTR_FONT:
state_pen.font = val->number;
break;
case VTERM_ATTR_SMALL:
state_pen.small = val->boolean;
break;
case VTERM_ATTR_BASELINE:
state_pen.baseline = val->number;
break;
case VTERM_ATTR_DIM:
state_pen.dim = val->boolean;
break;
case VTERM_ATTR_OVERLINE:
state_pen.overline = val->boolean;
break;
case VTERM_ATTR_FOREGROUND:
state_pen.foreground = val->color;
break;
case VTERM_ATTR_BACKGROUND:
state_pen.background = val->color;
break;
case VTERM_N_ATTRS:
return 0;
default:
break;
}
return 1;
}
bool want_state_scrollback;
int state_sb_clear(void *user)
{
if (!want_state_scrollback) {
return 1;
}
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "sb_clear\n");
fclose(f);
return 0;
}
bool want_screen_scrollback;
int screen_sb_pushline(int cols, const VTermScreenCell *cells, void *user)
{
if (!want_screen_scrollback) {
return 1;
}
int eol = cols;
while (eol && !cells[eol - 1].schar) {
eol--;
}
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "sb_pushline %d =", cols);
for (int c = 0; c < eol; c++) {
fprintf(f, " ");
print_schar(f, cells[c].schar);
}
fprintf(f, "\n");
fclose(f);
return 1;
}
int screen_sb_popline(int cols, VTermScreenCell *cells, void *user)
{
if (!want_screen_scrollback) {
return 0;
}
// All lines of scrollback contain "ABCDE"
for (int col = 0; col < cols; col++) {
if (col < 5) {
cells[col].schar = schar_from_ascii((uint32_t)('A' + col));
} else {
cells[col].schar = 0;
}
cells[col].width = 1;
}
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "sb_popline %d\n", cols);
fclose(f);
return 1;
}
int screen_sb_clear(void *user)
{
if (!want_screen_scrollback) {
return 1;
}
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "sb_clear\n");
fclose(f);
return 0;
}
void term_output(const char *s, size_t len, void *user)
{
FILE *f = fopen(VTERM_TEST_FILE, "a");
fprintf(f, "output ");
for (size_t i = 0; i < len; i++) {
fprintf(f, "%x%s", (unsigned char)s[i], i < len - 1 ? "," : "\n");
}
fclose(f);
}
int vterm_state_get_penattr(const VTermState *state, VTermAttr attr, VTermValue *val)
{
switch (attr) {
case VTERM_ATTR_BOLD:
val->boolean = state->pen.bold;
return 1;
case VTERM_ATTR_UNDERLINE:
val->number = state->pen.underline;
return 1;
case VTERM_ATTR_ITALIC:
val->boolean = state->pen.italic;
return 1;
case VTERM_ATTR_BLINK:
val->boolean = state->pen.blink;
return 1;
case VTERM_ATTR_REVERSE:
val->boolean = state->pen.reverse;
return 1;
case VTERM_ATTR_CONCEAL:
val->boolean = state->pen.conceal;
return 1;
case VTERM_ATTR_STRIKE:
val->boolean = state->pen.strike;
return 1;
case VTERM_ATTR_FONT:
val->number = state->pen.font;
return 1;
case VTERM_ATTR_FOREGROUND:
val->color = state->pen.fg;
return 1;
case VTERM_ATTR_BACKGROUND:
val->color = state->pen.bg;
return 1;
case VTERM_ATTR_SMALL:
val->boolean = state->pen.small;
return 1;
case VTERM_ATTR_BASELINE:
val->number = state->pen.baseline;
return 1;
case VTERM_ATTR_URI:
val->number = state->pen.uri;
return 1;
case VTERM_ATTR_DIM:
val->boolean = state->pen.dim;
return 1;
case VTERM_ATTR_OVERLINE:
val->boolean = state->pen.overline;
return 1;
case VTERM_N_ATTRS:
return 0;
}
return 0;
}
static int attrs_differ(VTermAttrMask attrs, ScreenCell *a, ScreenCell *b)
{
if ((attrs & VTERM_ATTR_BOLD_MASK) && (a->pen.bold != b->pen.bold)) {
return 1;
}
if ((attrs & VTERM_ATTR_UNDERLINE_MASK) && (a->pen.underline != b->pen.underline)) {
return 1;
}
if ((attrs & VTERM_ATTR_ITALIC_MASK) && (a->pen.italic != b->pen.italic)) {
return 1;
}
if ((attrs & VTERM_ATTR_BLINK_MASK) && (a->pen.blink != b->pen.blink)) {
return 1;
}
if ((attrs & VTERM_ATTR_REVERSE_MASK) && (a->pen.reverse != b->pen.reverse)) {
return 1;
}
if ((attrs & VTERM_ATTR_CONCEAL_MASK) && (a->pen.conceal != b->pen.conceal)) {
return 1;
}
if ((attrs & VTERM_ATTR_STRIKE_MASK) && (a->pen.strike != b->pen.strike)) {
return 1;
}
if ((attrs & VTERM_ATTR_FONT_MASK) && (a->pen.font != b->pen.font)) {
return 1;
}
if ((attrs & VTERM_ATTR_FOREGROUND_MASK) && !vterm_color_is_equal(&a->pen.fg, &b->pen.fg)) {
return 1;
}
if ((attrs & VTERM_ATTR_BACKGROUND_MASK) && !vterm_color_is_equal(&a->pen.bg, &b->pen.bg)) {
return 1;
}
if ((attrs & VTERM_ATTR_SMALL_MASK) && (a->pen.small != b->pen.small)) {
return 1;
}
if ((attrs & VTERM_ATTR_BASELINE_MASK) && (a->pen.baseline != b->pen.baseline)) {
return 1;
}
if ((attrs & VTERM_ATTR_URI_MASK) && (a->pen.uri != b->pen.uri)) {
return 1;
}
if ((attrs & VTERM_ATTR_DIM_MASK) && (a->pen.dim != b->pen.dim)) {
return 1;
}
if ((attrs & VTERM_ATTR_OVERLINE_MASK) && (a->pen.overline != b->pen.overline)) {
return 1;
}
return 0;
}
int vterm_screen_get_attrs_extent(const VTermScreen *screen, VTermRect *extent, VTermPos pos,
VTermAttrMask attrs)
{
ScreenCell *target = getcell(screen, pos.row, pos.col);
// TODO(vterm): bounds check
extent->start_row = pos.row;
extent->end_row = pos.row + 1;
if (extent->start_col < 0) {
extent->start_col = 0;
}
if (extent->end_col < 0) {
extent->end_col = screen->cols;
}
int col;
for (col = pos.col - 1; col >= extent->start_col; col--) {
if (attrs_differ(attrs, target, getcell(screen, pos.row, col))) {
break;
}
}
extent->start_col = col + 1;
for (col = pos.col + 1; col < extent->end_col; col++) {
if (attrs_differ(attrs, target, getcell(screen, pos.row, col))) {
break;
}
}
extent->end_col = col - 1;
return 1;
}
/// Does not NUL-terminate the buffer
size_t vterm_screen_get_text(const VTermScreen *screen, char *buffer, size_t len,
const VTermRect rect)
{
size_t outpos = 0;
int padding = 0;
#define PUT(bytes, thislen) \
if (true) { \
if (buffer && outpos + thislen <= len) \
memcpy((char *)buffer + outpos, bytes, thislen); \
outpos += thislen; \
} \
for (int row = rect.start_row; row < rect.end_row; row++) {
for (int col = rect.start_col; col < rect.end_col; col++) {
ScreenCell *cell = getcell(screen, row, col);
if (cell->schar == 0) {
// Erased cell, might need a space
padding++;
} else if (cell->schar == (uint32_t)-1) {
// Gap behind a double-width char, do nothing
} else {
while (padding) {
PUT(" ", 1);
padding--;
}
char buf[MAX_SCHAR_SIZE + 1];
size_t thislen = schar_get(buf, cell->schar);
PUT(buf, thislen);
}
}
if (row < rect.end_row - 1) {
PUT("\n", 1);
padding = 0;
}
}
return outpos;
}
int vterm_screen_is_eol(const VTermScreen *screen, VTermPos pos)
{
// This cell is EOL if this and every cell to the right is black
for (; pos.col < screen->cols; pos.col++) {
ScreenCell *cell = getcell(screen, pos.row, pos.col);
if (cell->schar != 0) {
return 0;
}
}
return 1;
}
void vterm_state_get_cursorpos(const VTermState *state, VTermPos *cursorpos)
{
*cursorpos = state->pos;
}
void vterm_state_set_bold_highbright(VTermState *state, int bold_is_highbright)
{
state->bold_is_highbright = bold_is_highbright;
}
/// Compares two colours. Returns true if the colors are equal, false otherwise.
int vterm_color_is_equal(const VTermColor *a, const VTermColor *b)
{
// First make sure that the two colours are of the same type (RGB/Indexed)
if (a->type != b->type) {
return false;
}
// Depending on the type inspect the corresponding members
if (VTERM_COLOR_IS_INDEXED(a)) {
return a->indexed.idx == b->indexed.idx;
} else if (VTERM_COLOR_IS_RGB(a)) {
return (a->rgb.red == b->rgb.red)
&& (a->rgb.green == b->rgb.green)
&& (a->rgb.blue == b->rgb.blue);
}
return 0;
}