[PATCH 2/3] vt: add DEC private mode 2027 to control VS16 widening
From: Nicolas Pitre
Date: Mon Oct 19 2026 - 17:20:45 EST
From: Nicolas Pitre <npitre@xxxxxxxxxxxx>
VS16 (U+FE0F) after a single-width character currently turns it into a
double-width one, giving emoji presentation sequences such as U+203C
U+FE0F (‼️) a square aspect.
There is no consensus on this among terminals. According to the
ucs-detect survey [1], these widen such sequences to 2 cells, as the
Linux console does:
kitty, foot, Ghostty, Contour, Konsole, iTerm2, Windows Terminal,
mintty, Rio, tmux (3.4)
while these keep them at 1 cell:
xterm, VTE based terminals (GNOME Terminal, XFCE Terminal, ...),
Alacritty, st, urxvt, Apple Terminal, GNU Screen, libvterm,
xterm.js (WezTerm only partially widens)
On the application side, glibc's wcwidth() is per code point and
returns 0 for VS16, and so do the tools relying on it. Even vim, which
has its own emoji width table, counts U+203C U+FE0F as 1 column.
Applications that track the cursor position themselves therefore get
out of sync with a widening terminal. For example, mosh writes full
width rows and moves with relative CR/LF. A row containing ‼️ is one
column wider on the console than in mosh's model, so its last cell
wraps to the next line and the whole screen ends up scrolled by one.
Others are reconsidering widening by default too: tmux 3.6 added the
"variation-selector-always-wide" option to turn it off, and xterm's
emoji width handling is configurable.
DEC private mode 2027, from the Contour terminal's Unicode core
proposal [2], is meant to give applications this control. With it set,
VS16 promotes a grapheme cluster to emoji presentation and a width of 2
cells. With it reset, the behavior is left to the terminal. Contour,
Ghostty, foot and WezTerm implement it.
Make VS16 widening follow that mode. Applications, or the user before
starting them, can opt out with CSI ? 2027 l, in which case VS16 is
zero-width as wcwidth() says. Only the VS16 part of the proposal is
implemented here.
The initial and reset state of the mode comes from the new
vt.default_vs16_wide parameter, similar to vt.default_utf8. It
defaults to 1, preserving the existing behavior, and can be set to 0
on systems where applications relying on wcwidth() are the norm.
[1] https://ucs-detect.readthedocs.io/results.html
[2] https://github.com/contour-terminal/terminal-unicode-core
Assisted-by: LLM
Signed-off-by: Nicolas Pitre <npitre@xxxxxxxxxxxx>
---
Documentation/admin-guide/kernel-parameters.txt | 10 ++++++++++
drivers/tty/vt/vt.c | 15 ++++++++++++++-
include/linux/console_struct.h | 1 +
3 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/Documentation/admin-guide/kernel-parameters.txt b/Documentation/admin-guide/kernel-parameters.txt
index e75344f4e0cd..983afb6c531e 100644
--- a/Documentation/admin-guide/kernel-parameters.txt
+++ b/Documentation/admin-guide/kernel-parameters.txt
@@ -8451,6 +8451,16 @@ Kernel parameters
Default is 1, i.e. UTF-8 mode is enabled for all
newly opened terminals.
+ vt.default_vs16_wide=
+ [VT]
+ Format=<0|1>
+ Set system-wide default for whether VS16 (U+FE0F)
+ turns the preceding single-width character into a
+ double-width one, as in emoji presentation sequences.
+ This is the initial and reset state of DEC private
+ mode 2027 on all tty's, which applications can also
+ change with CSI ? 2027 h/l. Default is 1.
+
vt.global_cursor_default=
[VT]
Format=<-1|0|1>
diff --git a/drivers/tty/vt/vt.c b/drivers/tty/vt/vt.c
index 335253aec549..4d5c9cabd058 100644
--- a/drivers/tty/vt/vt.c
+++ b/drivers/tty/vt/vt.c
@@ -166,6 +166,9 @@ EXPORT_SYMBOL(global_cursor_default);
static int cur_default = CUR_UNDERLINE;
module_param(cur_default, int, S_IRUGO | S_IWUSR);
+static bool default_vs16_wide = true;
+module_param(default_vs16_wide, bool, 0644);
+
/*
* ignore_poke: don't unblank the screen when things are typed. This is
* mainly for the privacy of braille terminal users.
@@ -1984,6 +1987,7 @@ enum {
CSI_DEC_hl_MOUSE_VT200 = 1000,
CSI_DEC_hl_ALT_SCREEN = 1049,
CSI_DEC_hl_BRACKETED_PASTE = 2004,
+ CSI_DEC_hl_GRAPHEME_WIDTH = 2027, /* VS16 widening */
};
/* console_lock is held */
@@ -2039,6 +2043,9 @@ static void csi_DEC_hl(struct vc_data *vc, bool on_off)
case CSI_DEC_hl_BRACKETED_PASTE:
vc->vc_bracketed_paste = on_off;
break;
+ case CSI_DEC_hl_GRAPHEME_WIDTH:
+ vc->vc_vs16_wide = on_off;
+ break;
case CSI_DEC_hl_ALT_SCREEN:
if (on_off)
enter_alt_screen(vc);
@@ -2284,6 +2291,7 @@ static void reset_terminal(struct vc_data *vc, int do_clear)
vc->vc_decawm = 1;
vc->vc_deccm = global_cursor_default;
vc->vc_decim = 0;
+ vc->vc_vs16_wide = default_vs16_wide;
if (vc->vc_saved_screen != NULL) {
kfree(vc->vc_saved_screen);
@@ -3139,12 +3147,17 @@ static int vc_process_ucs(struct vc_data *vc, int *c, int *tc)
prev_c = vc_uniscr_getc(vc, -1);
- if (curr_c == UCS_VS16 && prev_c != 0) {
+ if (curr_c == UCS_VS16 && prev_c != 0 && vc->vc_vs16_wide) {
/*
* VS16 (U+FE0F) is special. It typically turns the preceding
* single-width character into a double-width one. Let it
* have a width of 1 effectively making the combination with
* the preceding character double-width.
+ *
+ * This can be disabled with DEC private mode 2027 for
+ * applications that compute widths per code point with
+ * wcwidth(), where VS16 is always zero-width. Otherwise
+ * they lose track of the cursor position.
*/
*tc = ' ';
return 1;
diff --git a/include/linux/console_struct.h b/include/linux/console_struct.h
index fe915afdece5..8a1031cd097e 100644
--- a/include/linux/console_struct.h
+++ b/include/linux/console_struct.h
@@ -167,6 +167,7 @@ struct vc_data {
unsigned int vc_decawm : 1; /* Autowrap Mode */
unsigned int vc_deccm : 1; /* Cursor Visible */
unsigned int vc_decim : 1; /* Insert Mode */
+ unsigned int vc_vs16_wide : 1; /* VS16 makes preceding char wide */
/* misc */
unsigned int vc_priv : 3;
unsigned int vc_need_wrap : 1;
--
2.55.0