This is an automated email from the git hooks/post-receive script.

git pushed a commit to branch master
in repository terminology.

View the commit online.

commit f9622c6610729985f586e37f915173c44500feac
Author: [email protected] <[email protected]>
AuthorDate: Thu Sep 10 10:09:38 2026 -0600

    fix(termpty): emoji-table-only codepoints widen only after VS16
    
    With emoji_dbl_width enabled, _termpty_is_wide() reported
    double-width for text-presentation emoji such as U+2733 ✳
    (East_Asian_Width = Narrow), which wcwidth-based applications
    like tmux count as single-width. This caused tmux's 80-column
    status line to consume 81 columns instead, triggering the
    deferred autowrap in termpty_text_append() and scrolling the
    screen one line per status redraw.
    
    Per Unicode TR51 (matching kitty, foot, wezterm), emoji-table-only
    codepoints are now only double-width when immediately followed by
    U+FE0F (VARIATION SELECTOR-16). Genuinely wide codepoints from
    the base table (e.g. U+1100..115F, U+1F600) are unaffected either
    way.
    
    Since a codepoint and its VS16 can straddle two termpty_text_append()
    calls or reads, lookahead is insufficient. The base character is
    always written narrow and retro-widened in place when the VS16
    arrives. The retro-widen is guarded by Termpty.vs16_base_x/y
    (position of the last text write), which is invalidated by any
    cursor movement (DECSTBM, DECSLRM, DECOM, HT, DECRC, ...) without
    needing explicit invalidation calls at those sites. VS16 itself
    never causes a wrap or scroll.
    
    The IME preedit path in termiointernals.c uses real lookahead since
    the whole string is available there.
    
    Includes 12 new unit tests covering the regression, bare codepoints,
    split calls, guard invalidation by various cursor moves, CJK modes,
    and base-table unaffectedness.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    Claude-Session: https://claude.ai/code/session_01DhK2URTVEpTirtvVwWKZuA
---
 src/bin/termiointernals.c |  11 +-
 src/bin/termpty.c         | 490 ++++++++++++++++++++++++++++++++++++++++++++++
 src/bin/termpty.h         |  10 +
 src/bin/termptydbl.h      |  24 ++-
 src/bin/termptyops.c      | 139 +++++++++----
 src/bin/tytest.c          |  12 ++
 src/bin/unit_tests.h      |  12 ++
 7 files changed, 656 insertions(+), 42 deletions(-)

diff --git a/src/bin/termiointernals.c b/src/bin/termiointernals.c
index 5acf80e4..6972e31d 100644
--- a/src/bin/termiointernals.c
+++ b/src/bin/termiointernals.c
@@ -2770,9 +2770,16 @@ termio_internal_render(Termio *sd,
                   Evas_Textgrid_Cell *tc;
                   Eina_Unicode g;
 
-                  jump = 1;
                   g = uni[i];
-                  dbl = _termpty_is_dblwidth_get(sd->pty, g);
+                  /* U+FE0F does not consume its own column: it is folded
+                   * into the preceding codepoint's width below via
+                   * lookahead (safe here since the whole preedit string
+                   * is available in uni[]). */
+                  if (EINA_UNLIKELY(g == 0xfe0f))
+                    continue;
+                  jump = 1;
+                  dbl = _termpty_is_dblwidth_get(sd->pty, g,
+                                                 (i + 1 < len) && (uni[i + 1] == 0xfe0f));
                   if (dbl) jump = 2;
                   backx = 0;
                   if ((x + jump) > sd->grid.w)
diff --git a/src/bin/termpty.c b/src/bin/termpty.c
index a24f1733..d04b4103 100644
--- a/src/bin/termpty.c
+++ b/src/bin/termpty.c
@@ -1495,6 +1495,8 @@ termpty_resize(Termpty *ty, int new_w, int new_h)
    ty->w = new_w;
    ty->h = new_h;
    ty->cursor_state.wrapnext = 0;
+   ty->vs16_base_x = -1;
+   ty->vs16_base_y = -1;
 
    if (altbuf)
      termpty_screen_swap(ty);
@@ -1863,6 +1865,16 @@ _ty_feed(Termpty *ty, const char *str)
    termpty_handle_buf(ty, buf, j);
 }
 
+/* Feed an array of raw Eina_Unicode codepoints as if the PTY sent them
+ * (bypasses UTF-8 decoding so codepoints such as U+2733 or U+FE0F can be
+ * fed directly, and so a codepoint pair can be split across two calls to
+ * exercise termpty_text_append()'s VS16 retro-widen). */
+static void
+_ty_feed_uni(Termpty *ty, const Eina_Unicode *codepoints, int len)
+{
+   termpty_handle_buf(ty, codepoints, len);
+}
+
 /* Minimal Termpty allocator for unit tests — no fd, no EFL object.
  * Requires eina+ecore init so that ecore_timer_add (watchdog) works. */
 static void
@@ -1878,6 +1890,11 @@ _ty_test_init(Termpty *ty, int w, int h)
    ty->slavefd = -1;
    ty->pid = -1;
    ty->backsize = 50;
+   /* Real defaults (emoji_dbl_width off) so codepoints above 0xA0, such as
+    * emoji, exercise the same ty->config->emoji_dbl_width path production
+    * code takes. */
+   ty->config = config_new();
+   assert(ty->config);
    termpty_resize_tabs(ty, 0, w);
    termpty_reset_state(ty);
    ty->screen  = calloc(1, sizeof(Termcell) * w * h);
@@ -1897,6 +1914,7 @@ _ty_test_shutdown(Termpty *ty)
    free(ty->tabs);
    free(ty->hl.bitmap);
    free(ty->buf);
+   config_del(ty->config);
 
    ecore_shutdown();
    eina_shutdown();
@@ -1912,6 +1930,16 @@ _ty_cell_cp(Termpty *ty, int x, int y)
    return cells[x].codepoint;
 }
 
+/* Helper: read att.dblwidth of cell (x,y) via the read accessor. */
+static Eina_Bool
+_ty_cell_dblwidth(Termpty *ty, int x, int y)
+{
+   ssize_t w = 0;
+   const Termcell *cells = termpty_cellrow_get(ty, y, &w);
+   if (!cells || x >= w) return EINA_FALSE;
+   return cells[x].att.dblwidth;
+}
+
 /* Test 1: Frame coherence.
  * BSU; write "foo"; ESU.  The read accessor must return the pre-BSU (blank)
  * row until ESU is received, and the live "foo" after. */
@@ -2336,4 +2364,466 @@ tytest_kitty_keyboard_ignored(void)
    return 0;
 }
 
+/* VS16-aware emoji width (retro-widen)
+ *
+ * emoji_dbl_width makes _termpty_is_wide() report double-width for a
+ * superset of codepoints that includes text-presentation symbols such as
+ * U+2733 (East_Asian_Width = Narrow), which wcwidth-based apps (tmux,
+ * readline, ...) count as single-width. Per Unicode TR51 / kitty / foot /
+ * wezterm, such a codepoint is only double-width when immediately
+ * followed by U+FE0F (VARIATION SELECTOR-16); genuinely wide codepoints
+ * (the base table, e.g. U+1100..115F, U+1F600) are unaffected either way.
+ *
+ * The base character is always written narrow first and retro-widened in
+ * place if/when U+FE0F arrives (see termpty_text_append()): a codepoint +
+ * VS16 pair can straddle two termpty_text_append() calls, or even two
+ * termpty_handle_buf() reads, so lookahead is not sufficient. */
+
+#define CP_STAR    0x2733  /* EIGHT SPOKED ASTERISK: emoji-table-only */
+#define CP_VS16    0xfe0f  /* VARIATION SELECTOR-16 */
+#define CP_GRINNING 0x1f600 /* GRINNING FACE: base (genuinely wide) table */
+
+/* Shared setup: a fresh 80x24 Termpty with emoji_dbl_width forced on. */
+static void
+_ty_vs16_test_init(Termpty *ty)
+{
+   _ty_test_init(ty, 80, 24);
+   ty->config->emoji_dbl_width = EINA_TRUE;
+}
+
+/* Test: the actual regression.  80x24, identifiable content on row 1,
+ * cursor forced to row 24, then a full 80-column "status line" containing
+ * one bare U+2733 -- as tmux would draw an 80-col status bar.  With the
+ * bug, wcwidth-based tmux believes this is 80 columns wide, but
+ * emoji_dbl_width made terminology see it as 81 columns, so the 80th
+ * character forces the deferred autowrap and the screen scrolls one line,
+ * pushing row 1 into the backlog. */
+int
+tytest_vs16_regression_no_scroll(void)
+{
+   Termpty ty;
+   Eina_Unicode line[80];
+   int i;
+
+   _ty_vs16_test_init(&ty);
+
+   /* Identifiable content on row 1. */
+   _ty_feed(&ty, "TOP-ROW-MARKER");
+
+   /* Force cursor to row 24 (0-indexed row 23), column 1. */
+   _ty_feed(&ty, "\x1b[24d\r");
+   assert(ty.cursor_state.cy == 23);
+   assert(ty.cursor_state.cx == 0);
+
+   /* Exactly 80 characters, one of them (mid-line) a bare U+2733. */
+   for (i = 0; i < 80; i++)
+     line[i] = (i == 40) ? CP_STAR : 'x';
+   _ty_feed_uni(&ty, line, 80);
+
+   /* No scroll: nothing pushed to the backlog. */
+   assert(ty.backpos == 0);
+
+   /* Row 1 content is still on screen. */
+   assert(_ty_cell_cp(&ty, 0, 0) == 'T');
+   assert(_ty_cell_cp(&ty, 1, 0) == 'O');
+   assert(_ty_cell_cp(&ty, 2, 0) == 'P');
+
+   /* The status text occupies row 24 (index 23) columns 1..80 exactly:
+    * cursor did not overflow past the last column via a spurious wide
+    * cell, and there is exactly one 'x' immediately either side of the
+    * (narrow) asterisk. */
+   assert(_ty_cell_cp(&ty, 39, 23) == 'x');
+   assert(_ty_cell_cp(&ty, 40, 23) == CP_STAR);
+   assert(_ty_cell_cp(&ty, 41, 23) == 'x');
+   assert(_ty_cell_cp(&ty, 79, 23) == 'x');
+
+   /* Cursor at 1;1 after CUP. */
+   _ty_feed(&ty, "\x1b[1;1H");
+   assert(ty.cursor_state.cx == 0);
+   assert(ty.cursor_state.cy == 0);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+/* Test: bare U+2733 (no VS16) -> width 1, cursor advances by 1. */
+int
+tytest_vs16_bare_narrow(void)
+{
+   Termpty ty;
+   Eina_Unicode cp = CP_STAR;
+
+   _ty_vs16_test_init(&ty);
+
+   _ty_feed_uni(&ty, &cp, 1);
+
+   assert(_ty_cell_cp(&ty, 0, 0) == CP_STAR);
+   assert(ty.cursor_state.cx == 1);
+   assert(_ty_cell_dblwidth(&ty, 0, 0) == 0);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+/* Test: U+2733 + U+FE0F in one termpty_text_append() (one
+ * termpty_handle_buf() call) -> width 2, cursor advances by 2, partner
+ * cell has codepoint 0 and dblwidth set on the base cell. */
+int
+tytest_vs16_widens_one_call(void)
+{
+   Termpty ty;
+   Eina_Unicode cps[2] = { CP_STAR, CP_VS16 };
+
+   _ty_vs16_test_init(&ty);
+
+   _ty_feed_uni(&ty, cps, 2);
+
+   assert(ty.cursor_state.cx == 2);
+   assert(_ty_cell_cp(&ty, 0, 0) == CP_STAR);
+   assert(_ty_cell_dblwidth(&ty, 0, 0) == 1);
+   assert(_ty_cell_cp(&ty, 1, 0) == 0);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+/* Test: same as above, but the base character and U+FE0F are fed via two
+ * separate termpty_text_append()/termpty_handle_buf() calls -- the case
+ * lookahead inside termpty_text_append() would miss, since a read can end
+ * exactly between the emoji and its VS16. */
+int
+tytest_vs16_widens_split_calls(void)
+{
+   Termpty ty;
+   Eina_Unicode star = CP_STAR;
+   Eina_Unicode vs16 = CP_VS16;
+
+   _ty_vs16_test_init(&ty);
+
+   _ty_feed_uni(&ty, &star, 1);
+   assert(ty.cursor_state.cx == 1); /* still narrow until VS16 arrives */
+
+   _ty_feed_uni(&ty, &vs16, 1);
+
+   assert(ty.cursor_state.cx == 2);
+   assert(_ty_cell_cp(&ty, 0, 0) == CP_STAR);
+   assert(_ty_cell_dblwidth(&ty, 0, 0) == 1);
+   assert(_ty_cell_cp(&ty, 1, 0) == 0);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+/* Test: U+FE0F arrives with no room to widen (base U+2733 was written
+ * into the very last column, so wrapnext is now pending) -> stays narrow,
+ * no wrap, no scroll: a mere presentation selector must never itself
+ * trigger the deferred autowrap. */
+int
+tytest_vs16_no_room_no_wrap(void)
+{
+   Termpty ty;
+   Eina_Unicode fill[80];
+   Eina_Unicode vs16 = CP_VS16;
+   int i;
+
+   _ty_vs16_test_init(&ty);
+
+   /* Fill the line so the last codepoint (the asterisk) lands in the last
+    * column, setting wrapnext. */
+   for (i = 0; i < 80; i++)
+     fill[i] = (i == 79) ? CP_STAR : 'x';
+   _ty_feed_uni(&ty, fill, 80);
+
+   assert(ty.cursor_state.wrapnext == 1);
+   assert(ty.cursor_state.cx == 79);
+   assert(ty.backpos == 0);
+
+   _ty_feed_uni(&ty, &vs16, 1);
+
+   /* Still narrow, still pending wrap at the same position, no scroll. */
+   assert(_ty_cell_cp(&ty, 79, 0) == CP_STAR);
+   assert(_ty_cell_dblwidth(&ty, 79, 0) == 0);
+   assert(ty.cursor_state.wrapnext == 1);
+   assert(ty.cursor_state.cx == 79);
+   assert(ty.backpos == 0);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+/* Test: U+1F600 (base table, genuinely wide) -> width 2 with and without
+ * VS16, unchanged behaviour. */
+int
+tytest_vs16_base_table_unaffected(void)
+{
+   Termpty ty;
+   Eina_Unicode cp = CP_GRINNING;
+   Eina_Unicode pair[2] = { CP_GRINNING, CP_VS16 };
+
+   _ty_vs16_test_init(&ty);
+
+   _ty_feed_uni(&ty, &cp, 1);
+   assert(_ty_cell_cp(&ty, 0, 0) == CP_GRINNING);
+   assert(_ty_cell_dblwidth(&ty, 0, 0) == 1);
+   assert(ty.cursor_state.cx == 2);
+
+   _ty_test_shutdown(&ty);
+
+   _ty_vs16_test_init(&ty);
+
+   _ty_feed_uni(&ty, pair, 2);
+   assert(_ty_cell_cp(&ty, 0, 0) == CP_GRINNING);
+   assert(_ty_cell_dblwidth(&ty, 0, 0) == 1);
+   /* VS16 folded away (skipped), no extra column consumed. */
+   assert(ty.cursor_state.cx == 2);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+/* Test: U+FE0F arriving after a cursor move (the recorded write position
+ * is now invalid) -> no widening, no corruption. */
+int
+tytest_vs16_guard_invalidated_by_cursor_move(void)
+{
+   Termpty ty;
+   Eina_Unicode star = CP_STAR;
+   Eina_Unicode vs16 = CP_VS16;
+
+   _ty_vs16_test_init(&ty);
+
+   _ty_feed_uni(&ty, &star, 1);
+   assert(ty.cursor_state.cx == 1);
+
+   /* Move the cursor to an unrelated position: the recorded write
+    * position (0,0) no longer matches (cx-1, cy), so the guard must
+    * refuse to widen wherever the cursor now is. (Landing back at
+    * exactly the recorded position is a different, accepted case: see
+    * tytest_vs16_widens_split_calls-style tests -- content still
+    * matches, so widening there is correct.) */
+   _ty_feed(&ty, "\x1b[3;6H");
+   assert(ty.cursor_state.cx == 5);
+   assert(ty.cursor_state.cy == 2);
+
+   _ty_feed_uni(&ty, &vs16, 1);
+
+   assert(_ty_cell_cp(&ty, 0, 0) == CP_STAR);
+   assert(_ty_cell_dblwidth(&ty, 0, 0) == 0); /* not widened: guard was invalid */
+   assert(_ty_cell_dblwidth(&ty, 4, 2) == 0); /* nor was the unrelated cell */
+   assert(ty.cursor_state.cx == 5);  /* VS16 consumed no column */
+   assert(ty.cursor_state.cy == 2);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+/* Test: cjk_ambiguous_wide on -- same VS16 gating, but via the ambiguous
+ * tables (_termpty_is_ambigous_wide()) instead of the plain ones. */
+int
+tytest_vs16_cjk_ambiguous_wide(void)
+{
+   Termpty ty;
+   Eina_Unicode cps[2] = { CP_STAR, CP_VS16 };
+
+   _ty_vs16_test_init(&ty);
+   ty.termstate.cjk_ambiguous_wide = 1;
+
+   _ty_feed_uni(&ty, cps, 2);
+
+   assert(_ty_cell_cp(&ty, 0, 0) == CP_STAR);
+   assert(_ty_cell_dblwidth(&ty, 0, 0) == 1);
+   assert(_ty_cell_cp(&ty, 1, 0) == 0);
+   assert(ty.cursor_state.cx == 2);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+/* --- Regression tests for review findings 1 & 2: DECSTBM, DECSLRM, DECOM
+ * and HT reposition the cursor without going through any of the sites
+ * that clear cursor_state.wrapnext, so a flag-based "is the previous
+ * write still valid" guard living on Term_Cursor would stay stale across
+ * them. Each test below writes a "decoy" emoji-table-only codepoint at
+ * some position, performs an unrelated real write elsewhere (so the
+ * decoy is *not* the actual last write), then uses the operation under
+ * test to reposition the cursor so that it lands exactly one column past
+ * the decoy -- purely by coincidence, not because the decoy was the last
+ * thing written. A correct implementation must not widen the decoy: the
+ * recorded write position (vs16_base_x/y) only matches the real last
+ * write, which is elsewhere. */
+
+/* Test: HT (tab) forward lands the cursor right after an old, unrelated
+ * emoji-table-only cell -> no widening of that unrelated cell. */
+int
+tytest_vs16_guard_invalidated_by_ht(void)
+{
+   Termpty ty;
+   Eina_Unicode vs16 = CP_VS16;
+   Eina_Unicode star = CP_STAR;
+
+   _ty_vs16_test_init(&ty);
+
+   /* Decoy: a star at column 7 (the coming HT will tab to column 8). */
+   _ty_feed(&ty, "\x1b[1;8H");
+   _ty_feed_uni(&ty, &star, 1);
+   assert(ty.cursor_state.cx == 8);
+   assert(_ty_cell_cp(&ty, 7, 0) == CP_STAR);
+
+   /* The real last write: back to column 0, another star (so the guard
+    * legitimately has something valid recorded, just not at column 7). */
+   _ty_feed(&ty, "\x1b[1;1H");
+   _ty_feed_uni(&ty, &star, 1);
+   assert(ty.cursor_state.cx == 1);
+
+   /* HT tabs forward from column 1 to the next default tab stop, column 8
+    * -- exactly one past the column-7 decoy. */
+   _ty_feed(&ty, "\t");
+   assert(ty.cursor_state.cx == 8);
+   assert(ty.cursor_state.cy == 0);
+
+   _ty_feed_uni(&ty, &vs16, 1);
+
+   /* The decoy at column 7 must not have been widened. */
+   assert(_ty_cell_dblwidth(&ty, 7, 0) == 0);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+/* Test: DECOM (origin mode, "ESC[?6h") repositions the cursor to
+ * (left_margin, top_margin) using margins set while origin mode was off
+ * -- landing right after an old, unrelated emoji-table-only cell -> no
+ * widening. */
+int
+tytest_vs16_guard_invalidated_by_decom(void)
+{
+   Termpty ty;
+   Eina_Unicode vs16 = CP_VS16;
+   Eina_Unicode star = CP_STAR;
+
+   _ty_vs16_test_init(&ty);
+
+   _ty_feed(&ty, "\x1b[?69h");  /* enable DECLRMM */
+   /* Set left_margin=8, top_margin=3 while origin mode is off: these
+    * moves land at (0,0), harmless, but the margins stick. */
+   _ty_feed(&ty, "\x1b[9;80s"); /* DECSLRM: left_margin = 9-1 = 8 */
+   _ty_feed(&ty, "\x1b[4;24r"); /* DECSTBM: top_margin = 4-1 = 3 */
+
+   /* Decoy: a star at row 3, column 7. */
+   _ty_feed(&ty, "\x1b[4;8H");
+   _ty_feed_uni(&ty, &star, 1);
+   assert(ty.cursor_state.cy == 3);
+   assert(ty.cursor_state.cx == 8);
+   assert(_ty_cell_cp(&ty, 7, 3) == CP_STAR);
+
+   /* Real last write, well away from the decoy. */
+   _ty_feed(&ty, "\x1b[11;31H");
+   _ty_feed_uni(&ty, &star, 1);
+
+   /* Enabling DECOM moves the cursor to (left_margin, top_margin) =
+    * (8, 3) -- exactly one column past the decoy, same row. */
+   _ty_feed(&ty, "\x1b[?6h");
+   assert(ty.cursor_state.cy == 3);
+   assert(ty.cursor_state.cx == 8);
+
+   _ty_feed_uni(&ty, &vs16, 1);
+
+   assert(_ty_cell_dblwidth(&ty, 7, 3) == 0);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+/* Test: DECSTBM ("ESC[r") itself repositions the cursor to
+ * (left_margin, top_margin) when origin mode is already on -- landing
+ * right after an old, unrelated emoji-table-only cell -> no widening. */
+int
+tytest_vs16_guard_invalidated_by_decstbm(void)
+{
+   Termpty ty;
+   Eina_Unicode vs16 = CP_VS16;
+   Eina_Unicode star = CP_STAR;
+
+   _ty_vs16_test_init(&ty);
+
+   _ty_feed(&ty, "\x1b[?69h"); /* enable DECLRMM */
+   _ty_feed(&ty, "\x1b[?6h");  /* enable DECOM: restrict_cursor on, margins still 0 */
+
+   /* Decoy: a star at row 3, column 7 (CUP is still unshifted: margins
+    * are 0 at this point, so relative == absolute coordinates). */
+   _ty_feed(&ty, "\x1b[4;8H");
+   _ty_feed_uni(&ty, &star, 1);
+   assert(ty.cursor_state.cy == 3);
+   assert(ty.cursor_state.cx == 8);
+   assert(_ty_cell_cp(&ty, 7, 3) == CP_STAR);
+
+   /* Set left_margin = 8 now (after the decoy was placed, so the decoy's
+    * CUP was not shifted by it). This also bounces the cursor to
+    * (8, top_margin) harmlessly. */
+   _ty_feed(&ty, "\x1b[9;80s"); /* DECSLRM: left_margin = 9-1 = 8 */
+
+   /* Real last write, well away from the decoy: relative cursor motion
+    * (CUD/CUF) is unaffected by margins, unlike absolute CUP. */
+   _ty_feed(&ty, "\x1b[10B\x1b[20C");
+   _ty_feed_uni(&ty, &star, 1);
+
+   /* DECSTBM sets top_margin = 3 and (with origin mode already on)
+    * repositions the cursor to (left_margin, top_margin) = (8, 3) --
+    * exactly one column past the decoy, same row. */
+   _ty_feed(&ty, "\x1b[4;24r");
+   assert(ty.cursor_state.cy == 3);
+   assert(ty.cursor_state.cx == 8);
+
+   _ty_feed_uni(&ty, &vs16, 1);
+
+   assert(_ty_cell_dblwidth(&ty, 7, 3) == 0);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+/* Test: DECRC ("ESC 8") restores a saved cursor position -- coinciding,
+ * by construction, with an old emoji-table-only write that is no longer
+ * the real last write -> no widening. This is finding 3: the fields must
+ * live on Termpty, not Term_Cursor, or DECRC's wholesale cursor_state
+ * assignment would resurrect a stale guard along with the position. */
+int
+tytest_vs16_guard_invalidated_by_decrc(void)
+{
+   Termpty ty;
+   Eina_Unicode vs16 = CP_VS16;
+   Eina_Unicode star = CP_STAR;
+
+   _ty_vs16_test_init(&ty);
+
+   /* Write the decoy immediately before saving, so that (were the guard
+    * still on Term_Cursor) it would be captured as "valid" in
+    * cursor_save[]. */
+   _ty_feed(&ty, "\x1b[1;8H");
+   _ty_feed_uni(&ty, &star, 1);
+   assert(ty.cursor_state.cx == 8);
+   _ty_feed(&ty, "\x1b" "7"); /* DECSC: save cursor at (8, 0) */
+
+   /* Intervening output: a real write elsewhere. */
+   _ty_feed(&ty, "\x1b[11;31H");
+   _ty_feed_uni(&ty, &star, 1);
+
+   /* DECRC restores the saved cursor: back to (8, 0), one past the
+    * decoy. */
+   _ty_feed(&ty, "\x1b" "8");
+   assert(ty.cursor_state.cx == 8);
+   assert(ty.cursor_state.cy == 0);
+
+   _ty_feed_uni(&ty, &vs16, 1);
+
+   assert(_ty_cell_dblwidth(&ty, 7, 0) == 0);
+
+   _ty_test_shutdown(&ty);
+   return 0;
+}
+
+#undef CP_STAR
+#undef CP_VS16
+#undef CP_GRINNING
+
 #endif /* BINARY_TYFUZZ || BINARY_TYTEST */
diff --git a/src/bin/termpty.h b/src/bin/termpty.h
index 909b8fe3..423ce37d 100644
--- a/src/bin/termpty.h
+++ b/src/bin/termpty.h
@@ -220,6 +220,16 @@ struct tag_Termpty
    Term_State termstate;
    Term_Cursor cursor_state;
    Term_Cursor cursor_save[2];
+   /* Column/row of the cell written by the last text codepoint appended by
+    * termpty_text_append(), or -1 when there is none. Used to validate
+    * VS16 (U+FE0F) retro-widen: a cursor move that lands anywhere else
+    * fails the match automatically, so escape sequences that reposition
+    * the cursor (DECSTBM, DECSLRM, DECOM, HT, DECRC, ...) need no explicit
+    * invalidation. Kept on Termpty rather than Term_Cursor so DECRC's
+    * wholesale cursor_state assignment does not resurrect a stale value.
+    * Reset to -1 in termpty_soft_reset_state() (also reached via
+    * termpty_reset_state()) and on resize. */
+   int vs16_base_x, vs16_base_y;
    int exit_code;
    pid_t pid;
    unsigned int altbuf     : 1;
diff --git a/src/bin/termptydbl.h b/src/bin/termptydbl.h
index 40fb7b3e..a1f453b8 100644
--- a/src/bin/termptydbl.h
+++ b/src/bin/termptydbl.h
@@ -6,15 +6,29 @@ Eina_Bool _termpty_is_wide(const Eina_Unicode g, Eina_Bool emoji_dbl_width);
 Eina_Bool _termpty_is_ambigous_wide(const Eina_Unicode g, Eina_Bool emoji_dbl_width);
 
 static inline Eina_Bool
-_termpty_is_dblwidth_get(const Termpty *ty, const Eina_Unicode g)
+_termpty_is_wide_table(const Termpty *ty, const Eina_Unicode g, Eina_Bool emoji)
+{
+   return ty->termstate.cjk_ambiguous_wide
+      ? _termpty_is_ambigous_wide(g, emoji)
+      : _termpty_is_wide(g, emoji);
+}
+
+/* vs16: whether this codepoint is immediately followed by U+FE0F
+ * (VARIATION SELECTOR-16). Only makes emoji-table-only codepoints wide
+ * when EINA_TRUE; the base (genuinely wide) table is unaffected either
+ * way. Callers writing a base character narrow-first (see
+ * termpty_text_append()'s VS16 retro-widen) should pass EINA_FALSE and
+ * apply the wide table's superset separately once VS16 is seen. */
+static inline Eina_Bool
+_termpty_is_dblwidth_get(const Termpty *ty, const Eina_Unicode g, Eina_Bool vs16)
 {
    /* optimize for latin1 non-ambiguous */
    if (g <= 0xA0)
      return EINA_FALSE;
-   if (!ty->termstate.cjk_ambiguous_wide)
-     return _termpty_is_wide(g, ty->config->emoji_dbl_width);
-   else
-     return _termpty_is_ambigous_wide(g, ty->config->emoji_dbl_width);
+   if (_termpty_is_wide_table(ty, g, EINA_FALSE))
+     return EINA_TRUE;
+   return ty->config->emoji_dbl_width && vs16 &&
+      _termpty_is_wide_table(ty, g, EINA_TRUE);
 }
 
 #endif
diff --git a/src/bin/termptyops.c b/src/bin/termptyops.c
index 5777458e..55c7f809 100644
--- a/src/bin/termptyops.c
+++ b/src/bin/termptyops.c
@@ -180,6 +180,43 @@ termpty_text_scroll_rev_test(Termpty *ty, Eina_Bool clear)
      }
 }
 
+/* Advance the cursor by `offset` columns (1 for a normal codepoint, 2 for
+ * a double-width one), honouring termstate.wrap the same way for any
+ * offset. Shared by the main text-append loop and the VS16 retro-widen
+ * block (offset == 1 there). */
+static void
+_cursor_advance(Termpty *ty, int offset, int max_right)
+{
+   ty->cursor_state.wrapnext = 0;
+   if (ty->termstate.wrap)
+     {
+        if (EINA_UNLIKELY(ty->cursor_state.cx >= (max_right - offset)))
+          ty->cursor_state.wrapnext = 1;
+        else
+          {
+             ty->cursor_state.cx += offset;
+             TERMPTY_RESTRICT_FIELD(ty->cursor_state.cx, 0, max_right);
+          }
+     }
+   else
+     {
+        ty->cursor_state.cx += offset;
+        if (ty->cursor_state.cx > (max_right - offset))
+          ty->cursor_state.cx = max_right - offset;
+        TERMPTY_RESTRICT_FIELD(ty->cursor_state.cx, 0, max_right);
+     }
+}
+
+/* Pair `cell` (already marked att.dblwidth) with the zero-codepoint
+ * partner cell that follows it, mirroring att. Shared by the main
+ * text-append loop and the VS16 retro-widen block. */
+static void
+_dblwidth_pair(Termpty *ty, Termcell *cell, Termcell *partner)
+{
+   cell->att.newline = 0;
+   termpty_cell_codepoint_att_fill(ty, 0, cell->att, partner, 1);
+}
+
 void
 termpty_text_append(Termpty *ty, const Eina_Unicode *codepoints, int len)
 {
@@ -199,6 +236,56 @@ termpty_text_append(Termpty *ty, const Eina_Unicode *codepoints, int len)
              max_right = ty->termstate.right_margin;
           }
 
+        g = _termpty_charset_trans(ty, codepoints[i]);
+
+        /* VARIATION SELECTOR-16: retro-widen the character that was just
+         * written narrow, if (and only if) it is emoji-table-only (not in
+         * the base wide table) and there is a valid, un-wrapped previous
+         * write with room for the widened partner cell. This never causes
+         * a wrap or scroll by itself, and must be handled *before* the
+         * pending-wrapnext block below: if wrapnext is set there is by
+         * definition no room to widen (the base char sits in the last
+         * usable column), and consuming the pending wrap here would wrap
+         * as a side effect of a mere presentation selector. See vs16-spec
+         * for rationale (a codepoint + VS16 pair can straddle two
+         * termpty_text_append() calls, so lookahead here is not
+         * sufficient). */
+        if (EINA_UNLIKELY(g == 0xfe0f) && ty->config->emoji_dbl_width)
+          {
+             /* Always consume the VS16 here without falling into the
+              * pending-wrapnext / insert / generic-skip code below: a
+              * pending wrapnext must be left untouched (no wrap, no
+              * scroll) if we cannot widen. The recorded position is the
+              * whole guard: any cursor move since the last text write
+              * (DECSTBM, DECSLRM, DECOM, HT, DECRC, ...) lands vs16_base_x/y
+              * somewhere that no longer matches cx-1/cy, so those paths
+              * need no explicit invalidation here. */
+             if (!ty->cursor_state.wrapnext &&
+                 ty->cursor_state.cx >= 1 && ty->cursor_state.cx < max_right &&
+                 ty->vs16_base_y == ty->cursor_state.cy &&
+                 ty->vs16_base_x == ty->cursor_state.cx - 1)
+               {
+                  int pcx = ty->cursor_state.cx - 1;
+
+                  if (!cells[pcx].att.dblwidth)
+                    {
+                       Eina_Unicode pg = cells[pcx].codepoint;
+                       Eina_Bool emoji_only =
+                          _termpty_is_wide_table(ty, pg, EINA_TRUE) &&
+                          !_termpty_is_wide_table(ty, pg, EINA_FALSE);
+
+                       if (emoji_only)
+                         {
+                            cells[pcx].att.dblwidth = 1;
+                            _dblwidth_pair(ty, &cells[pcx],
+                                           &cells[ty->cursor_state.cx]);
+                            _cursor_advance(ty, 1, max_right);
+                         }
+                    }
+               }
+             continue;
+          }
+
         if (ty->cursor_state.wrapnext)
           {
              cells[max_right-1].att.autowrapped = 1;
@@ -214,13 +301,12 @@ termpty_text_append(Termpty *ty, const Eina_Unicode *codepoints, int len)
                TERMPTY_CELL_COPY(ty, &(cells[j - 1]), &(cells[j]), 1);
           }
 
-        g = _termpty_charset_trans(ty, codepoints[i]);
         /* Skip 0-width space or RTL/LTR marks */
         if (EINA_UNLIKELY(g >= 0x200b && g <= 0x200f))
           {
              continue;
           }
-        /* Skip variation selectors */
+        /* Skip variation selectors (U+FE0F was already handled above) */
         if (EINA_UNLIKELY(g >= 0xfe00 && g <= 0xfe0f))
           {
              continue;
@@ -243,44 +329,25 @@ termpty_text_append(Termpty *ty, const Eina_Unicode *codepoints, int len)
              cells[ty->cursor_state.cx].att.strike = 1;
           }
 
-        cells[ty->cursor_state.cx].att.dblwidth = _termpty_is_dblwidth_get(ty, g);
+        /* Always written narrow here: emoji-table-only codepoints are
+         * retro-widened above when/if a following U+FE0F arrives. Genuine
+         * wide characters (base table) are unaffected. */
+        cells[ty->cursor_state.cx].att.dblwidth = _termpty_is_dblwidth_get(ty, g, EINA_FALSE);
         if (EINA_UNLIKELY((cells[ty->cursor_state.cx].att.dblwidth) && (ty->cursor_state.cx < (max_right - 1))))
           {
-             cells[ty->cursor_state.cx].att.newline = 0;
-             termpty_cell_codepoint_att_fill(ty, 0, cells[ty->cursor_state.cx].att,
-                                             &(cells[ty->cursor_state.cx + 1]), 1);
+             _dblwidth_pair(ty, &cells[ty->cursor_state.cx],
+                            &cells[ty->cursor_state.cx + 1]);
           }
 
-        if (ty->termstate.wrap)
-          {
-             unsigned char offset = 1;
+        /* Record the cell just written before advancing the cursor: this
+         * is the position the VS16 retro-widen guard above checks against. */
+        ty->vs16_base_x = ty->cursor_state.cx;
+        ty->vs16_base_y = ty->cursor_state.cy;
 
-             ty->cursor_state.wrapnext = 0;
-             if (EINA_UNLIKELY(cells[ty->cursor_state.cx].att.dblwidth))
-               offset = 2;
-             if (EINA_UNLIKELY(ty->cursor_state.cx >= (max_right - offset)))
-               ty->cursor_state.wrapnext = 1;
-             else
-               {
-                  ty->cursor_state.cx += offset;
-                  TERMPTY_RESTRICT_FIELD(ty->cursor_state.cx, 0, max_right);
-               }
-          }
-        else
-          {
-             unsigned char offset = 1;
-
-             ty->cursor_state.wrapnext = 0;
-             if (EINA_UNLIKELY(cells[ty->cursor_state.cx].att.dblwidth))
-               offset = 2;
-             ty->cursor_state.cx += offset;
-             if (ty->cursor_state.cx > (max_right - offset))
-               {
-                  ty->cursor_state.cx = max_right - offset;
-                  TERMPTY_RESTRICT_FIELD(ty->cursor_state.cx, 0, max_right);
-               }
-             TERMPTY_RESTRICT_FIELD(ty->cursor_state.cx, 0, max_right);
-          }
+        {
+           unsigned char offset = (cells[ty->cursor_state.cx].att.dblwidth) ? 2 : 1;
+           _cursor_advance(ty, offset, max_right);
+        }
      }
 }
 
@@ -447,6 +514,8 @@ termpty_soft_reset_state(Termpty *ty)
    ty->termstate.appcursor = 0;
    ty->termstate.wrap = 1;
    ty->cursor_state.wrapnext = 0;
+   ty->vs16_base_x = -1;
+   ty->vs16_base_y = -1;
    ty->termstate.crlf = 0;
    ty->termstate.send_bs = 0;
    ty->termstate.reverse = 0;
diff --git a/src/bin/tytest.c b/src/bin/tytest.c
index 701428dc..9365c0b3 100644
--- a/src/bin/tytest.c
+++ b/src/bin/tytest.c
@@ -59,6 +59,18 @@ static struct {
        { "xmodkeys_set", tytest_xmodkeys_set},
        { "xmodkeys_query", tytest_xmodkeys_query},
        { "kitty_keyboard_ignored", tytest_kitty_keyboard_ignored},
+       { "vs16_regression_no_scroll", tytest_vs16_regression_no_scroll},
+       { "vs16_bare_narrow", tytest_vs16_bare_narrow},
+       { "vs16_widens_one_call", tytest_vs16_widens_one_call},
+       { "vs16_widens_split_calls", tytest_vs16_widens_split_calls},
+       { "vs16_no_room_no_wrap", tytest_vs16_no_room_no_wrap},
+       { "vs16_base_table_unaffected", tytest_vs16_base_table_unaffected},
+       { "vs16_guard_invalidated_by_cursor_move", tytest_vs16_guard_invalidated_by_cursor_move},
+       { "vs16_cjk_ambiguous_wide", tytest_vs16_cjk_ambiguous_wide},
+       { "vs16_guard_invalidated_by_ht", tytest_vs16_guard_invalidated_by_ht},
+       { "vs16_guard_invalidated_by_decom", tytest_vs16_guard_invalidated_by_decom},
+       { "vs16_guard_invalidated_by_decstbm", tytest_vs16_guard_invalidated_by_decstbm},
+       { "vs16_guard_invalidated_by_decrc", tytest_vs16_guard_invalidated_by_decrc},
        { NULL, NULL},
 };
 
diff --git a/src/bin/unit_tests.h b/src/bin/unit_tests.h
index c1dc6cb0..4d07e422 100644
--- a/src/bin/unit_tests.h
+++ b/src/bin/unit_tests.h
@@ -33,5 +33,17 @@ int tytest_percent_decode(void);
 int tytest_xmodkeys_set(void);
 int tytest_xmodkeys_query(void);
 int tytest_kitty_keyboard_ignored(void);
+int tytest_vs16_regression_no_scroll(void);
+int tytest_vs16_bare_narrow(void);
+int tytest_vs16_widens_one_call(void);
+int tytest_vs16_widens_split_calls(void);
+int tytest_vs16_no_room_no_wrap(void);
+int tytest_vs16_base_table_unaffected(void);
+int tytest_vs16_guard_invalidated_by_cursor_move(void);
+int tytest_vs16_cjk_ambiguous_wide(void);
+int tytest_vs16_guard_invalidated_by_ht(void);
+int tytest_vs16_guard_invalidated_by_decom(void);
+int tytest_vs16_guard_invalidated_by_decstbm(void);
+int tytest_vs16_guard_invalidated_by_decrc(void);
 
 #endif

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.

Reply via email to