patch 9.2.0427: popup: opacity blend may leaks white bg color
Commit:
https://github.com/vim/vim/commit/7b218ae98c0f9a26ade91f0950e5c43d3c3c5b2b
Author: Yasuhiro Matsumoto <
matt...@gmail.com>
Date: Fri May 1 16:10:21 2026 +0000
patch 9.2.0427: popup: opacity blend may leaks white bg color
Problem: popup: opacity blend may leaks white bg color
Solution: Add cterm color blending for 256 color terminals, use
COLOR_INVALID() macro to check for invalid color
(Yasuhiro Matsumoto)
When a textprop highlight only set gui=undercurl/guisp (no fg/bg), the
CTERMCOLOR sentinel was treated by hl_blend_attr() as a real near-white
color, leaking white bg onto textprop-covered cells under an opacity
popup or pum. Add a cterm color blending path that approximates blends
in the xterm 256-color palette using the gui RGB when available, so
opacity now has a visible effect even without 'termguicolors' (in
256-color terminals). Below 256 colors the blend is skipped.
Also document the requirement (GUI, 'termguicolors', or 256-color
terminal) and update existing pumopt/popupwin opacity screendumps to
reflect the new blended output.
closes: #20095
Signed-off-by: Yasuhiro Matsumoto <
matt...@gmail.com>
Signed-off-by: Christian Brabandt <
c...@256bit.org>
diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt
index d9dbcd833..b0f3f1c72 100644
--- a/runtime/doc/options.txt
+++ b/runtime/doc/options.txt
@@ -7007,7 +7007,8 @@ A jump table for the options with a short description can be found at |Q_op|.
of 'fillchars' option.
opacity:{n} opacity percentage 0-100 (default 100).
When less than 100, background content shows
- through the popup menu.
+ through the popup menu. Requires the GUI,
+ 'termguicolors', or a 256-color terminal.
Flags (no value):
margin adds one-cell spacing inside the left and
diff --git a/runtime/doc/popup.txt b/runtime/doc/popup.txt
index 6b99ac47e..250127b9b 100644
--- a/runtime/doc/popup.txt
+++ b/runtime/doc/popup.txt
@@ -1,4 +1,4 @@
-*popup.txt* For Vim version 9.2. Last change: 2026 Apr 06
+*popup.txt* For Vim version 9.2. Last change: 2026 May 01
VIM REFERENCE MANUAL by Bram Moolenaar
@@ -1067,8 +1067,9 @@ The opacity value ranges from 0 to 100:
1-99 Partially transparent - the popup background is blended with
the underlying text, making both partially visible.
-The transparency effect requires using the GUI or having 'termguicolors'
-enabled in the terminal. Without it, the opacity setting has no effect.
+The transparency effect requires using the GUI, having 'termguicolors'
+enabled, or running in a 256-color terminal. On terminals with fewer
+than 256 colors the opacity setting has no effect.
When a popup is transparent:
- The popup's background color is blended with the background text
diff --git a/src/highlight.c b/src/highlight.c
index 67b551c67..b21e489e6 100644
--- a/src/highlight.c
+++ b/src/highlight.c
@@ -3126,9 +3126,19 @@ hl_combine_attr(int char_attr, int prim_attr)
return get_attr_entry(&term_attr_table, &new_en);
}
-#if defined(FEAT_GUI) || defined(FEAT_TERMGUICOLORS)
+// ANSI color order: Black, Red, Green, Yellow, Blue, Magenta,
+// Cyan, White, then bright variants. Approximate RGB values used when
+// only a cterm color number is known (no guifg/guibg). Real terminal
+// palettes may differ if the user reconfigured their emulator, but
+// these are reasonable xterm-ish defaults.
+static const long_u cterm_color_16[16] = {
+ 0x000000, 0xc00000, 0x008000, 0x808000,
+ 0x0000c0, 0xc000c0, 0x004080, 0xc0c0c0,
+ 0x808080, 0xff8080, 0x00ff00, 0xffff00,
+ 0x6060ff, 0xff40ff, 0x00ffff, 0xffffff
+};
-# ifdef FEAT_TERMGUICOLORS
+#ifdef FEAT_TERMGUICOLORS
/*
* Convert a cterm color number (1-16) to an RGB value.
* Used as a fallback when 'termguicolors' is set but only cterm colors are
@@ -3138,21 +3148,156 @@ hl_combine_attr(int char_attr, int prim_attr)
static guicolor_T
cterm_color_to_rgb(int color_nr)
{
- // ANSI color order: Black, Red, Green, Yellow, Blue, Magenta,
- // Cyan, White, then bright variants.
- static const guicolor_T cterm_color_16[16] = {
- 0x000000, 0xc00000, 0x008000, 0x808000,
- 0x0000c0, 0xc000c0, 0x004080, 0xc0c0c0,
- 0x808080, 0xff8080, 0x00ff00, 0xffff00,
- 0x6060ff, 0xff40ff, 0x00ffff, 0xffffff
- };
-
if (color_nr < 1 || color_nr > 16)
return INVALCOLOR;
- return cterm_color_16[color_nr - 1];
+ return (guicolor_T)cterm_color_16[color_nr - 1];
+}
+#endif
+
+/*
+ * Convert an xterm 256-color index (0-255) to an approximate RGB triple.
+ * Uses the standard xterm palette: 0-15 ANSI (cterm_color_16), 16-231
+ * 6x6x6 cube, 232-255 grayscale ramp.
+ */
+ static void
+cterm_idx_to_rgb(int idx, int *r, int *g, int *b)
+{
+ static const int cube[] = { 0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF };
+
+ if (idx < 0 || idx > 255)
+ {
+ *r = *g = *b = 0;
+ return;
+ }
+ if (idx < 16)
+ {
+ long_u rgb = cterm_color_16[idx];
+ *r = (rgb >> 16) & 0xFF;
+ *g = (rgb >> 8) & 0xFF;
+ *b = rgb & 0xFF;
+ }
+ else if (idx < 232)
+ {
+ int n = idx - 16;
+ *r = cube[n / 36 % 6];
+ *g = cube[n / 6 % 6];
+ *b = cube[n % 6];
+ }
+ else
+ {
+ int v = 8 + (idx - 232) * 10;
+ *r = *g = *b = v;
+ }
}
-# endif
+/*
+ * Approximate an RGB triple to the nearest xterm 256-color index.
+ * Searches the 6x6x6 cube and the grayscale ramp; returns 0-255.
+ */
+ static int
+rgb_to_cterm_idx(int r, int g, int b)
+{
+ static const int cube[] = { 0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF };
+ int best = 0;
+ long best_d = -1;
+ int i;
+
+ // Search the 6x6x6 cube.
+ for (i = 16; i < 232; ++i)
+ {
+ int n = i - 16;
+ int cr = cube[n / 36 % 6];
+ int cg = cube[n / 6 % 6];
+ int cb = cube[n % 6];
+ long d = (cr - r) * (cr - r) + (cg - g) * (cg - g) + (cb - b) * (cb - b);
+ if (best_d < 0 || d < best_d)
+ {
+ best_d = d;
+ best = i;
+ }
+ }
+ // Search the grayscale ramp.
+ for (i = 232; i < 256; ++i)
+ {
+ int v = 8 + (i - 232) * 10;
+ long d = (v - r) * (v - r) + (v - g) * (v - g) + (v - b) * (v - b);
+ if (d < best_d)
+ {
+ best_d = d;
+ best = i;
+ }
+ }
+ return best;
+}
+
+/*
+ * Resolve a color to an RGB triple. Prefer the gui RGB value (set by
+ * guifg/guibg) when valid, otherwise convert the cterm 256-color index.
+ * Returns true on success; false when no color is defined.
+ * cterm_c is 1-based (0 means "no color").
+ */
+ static bool
+resolve_color_to_rgb(int cterm_c, guicolor_T rgb UNUSED, int *r, int *g, int *b)
+{
+#ifdef FEAT_TERMGUICOLORS
+ if (!COLOR_INVALID(rgb))
+ {
+ *r = (rgb >> 16) & 0xFF;
+ *g = (rgb >> 8) & 0xFF;
+ *b = rgb & 0xFF;
+ return true;
+ }
+#endif
+ if (cterm_c > 0)
+ {
+ cterm_idx_to_rgb(cterm_c - 1, r, g, b);
+ return true;
+ }
+ return false;
+}
+
+/*
+ * Blend two colors expressed as (cterm 256 index, gui RGB) pairs and
+ * return the nearest 1-based cterm 256-color index. Prefers the gui
+ * RGB so highlight definitions like "guibg=#2D2A3D" without ctermbg
+ * still produce a meaningful blend in 256-color terminals.
+ *
+ * "default_rgb" is used as the underlying color when neither under_c
+ * nor under_rgb is set; pass 0xFFFFFF for fg-style blends (so text
+ * fades toward white) or 0x000000 for bg-style blends (so the popup
+ * fades toward terminal default dark).
+ *
+ * Requires t_colors >= 256; otherwise returns popup_c unchanged.
+ */
+ static int
+blend_cterm_colors(int popup_c, guicolor_T popup_rgb,
+ int under_c, guicolor_T under_rgb,
+ int default_rgb,
+ int blend_val)
+{
+ int pr, pg, pb, ur, ug, ub, r, g, b;
+
+ if (t_colors < 256)
+ return popup_c;
+ if (!resolve_color_to_rgb(popup_c, popup_rgb, &pr, &pg, &pb))
+ return under_c;
+ if (blend_val <= 0)
+ return rgb_to_cterm_idx(pr, pg, pb) + 1;
+ if (!resolve_color_to_rgb(under_c, under_rgb, &ur, &ug, &ub))
+ {
+ ur = (default_rgb >> 16) & 0xFF;
+ ug = (default_rgb >> 8) & 0xFF;
+ ub = default_rgb & 0xFF;
+ }
+ if (blend_val >= 100)
+ return rgb_to_cterm_idx(ur, ug, ub) + 1;
+ r = pr + (ur - pr) * blend_val / 100;
+ g = pg + (ug - pg) * blend_val / 100;
+ b = pb + (ub - pb) * blend_val / 100;
+ return rgb_to_cterm_idx(r, g, b) + 1;
+}
+
+#if defined(FEAT_GUI) || defined(FEAT_TERMGUICOLORS)
/*
* Blend two RGB colors based on blend value (0-100).
* blend: 0=use popup color, 100=use background color
@@ -3163,7 +3308,9 @@ blend_colors(guicolor_T popup_color, guicolor_T bg_color, int blend_val)
{
int r1, g1, b1, r2, g2, b2, r, g, b;
- if (popup_color == INVALCOLOR)
+ // CTERMCOLOR is a sentinel meaning "use the cterm color"; for blending
+ // it has no real RGB so treat it like INVALCOLOR.
+ if (COLOR_INVALID(popup_color))
return INVALCOLOR;
// Fully transparent: use underlying color as-is.
@@ -3174,7 +3321,7 @@ blend_colors(guicolor_T popup_color, guicolor_T bg_color, int blend_val)
g1 = (popup_color >> 8) & 0xFF;
b1 = popup_color & 0xFF;
- if (bg_color == INVALCOLOR)
+ if (COLOR_INVALID(bg_color))
{
// Background color unknown: fade popup color to black as blend increases
// This makes background text more visible at high blend values
@@ -3253,11 +3400,13 @@ hl_blend_attr(int char_attr, int popup_attr, int blend, int blend_fg UNUSED)
else
{
// blend_fg=FALSE: popup text is opaque. Replace the
- // underlying cell's attribute flags and fg with the
- // popup's, so the underlying syntax highlighting does
- // not bleed through.
+ // underlying cell's attribute flags, fg and special
+ // color with the popup's, so the underlying syntax
+ // highlighting and any decoration (textprop undercurl,
+ // ...) do not bleed through.
new_en.ae_attr = popup_aep->ae_attr;
new_en.ae_u.gui.fg_color = popup_aep->ae_u.gui.fg_color;
+ new_en.ae_u.gui.sp_color = popup_aep->ae_u.gui.sp_color;
}
// Blend background color: blend popup bg toward underlying bg
if (popup_aep->ae_u.gui.bg_color != INVALCOLOR)
@@ -3301,18 +3450,60 @@ hl_blend_attr(int char_attr, int popup_attr, int blend, int blend_fg UNUSED)
if (!blend_fg)
{
// blend_fg=FALSE: popup text is opaque. Replace the
- // underlying cell's attribute flags and fg with the
- // popup's, so the underlying syntax highlighting does
- // not bleed through.
+ // underlying cell's attribute flags, fg and underline
+ // color with the popup's, so the underlying syntax
+ // highlighting and any decoration (textprop undercurl,
+ // ...) do not bleed through. When the popup has no fg
+ // (e.g. "guifg=NONE") fall back to Normal's fg so the
+ // text is still readable instead of taking on whatever
+ // the underlying cell happened to have.
new_en.ae_attr = popup_aep->ae_attr;
- new_en.ae_u.cterm.fg_color = popup_aep->ae_u.cterm.fg_color;
+ if (popup_aep->ae_u.cterm.fg_color > 0)
+ new_en.ae_u.cterm.fg_color =
+ popup_aep->ae_u.cterm.fg_color;
+ else if (cterm_normal_fg_color > 0)
+ new_en.ae_u.cterm.fg_color = cterm_normal_fg_color;
+ else
+ new_en.ae_u.cterm.fg_color = 16; // white-ish
+ new_en.ae_u.cterm.ul_color = popup_aep->ae_u.cterm.ul_color;
+#ifdef FEAT_TERMGUICOLORS
+ new_en.ae_u.cterm.ul_rgb = popup_aep->ae_u.cterm.ul_rgb;
+#endif
+ }
+ else
+ {
+ // blend_fg=TRUE: fade underlying fg toward popup bg in
+ // the 256-color palette. Used when the popup is over a
+ // cell rendered with cterm colors (no termguicolors RGB).
+ int under_fg = (char_aep != NULL)
+ ? char_aep->ae_u.cterm.fg_color : 0;
+ guicolor_T under_fg_rgb = INVALCOLOR;
+ guicolor_T popup_bg_rgb = INVALCOLOR;
+#ifdef FEAT_TERMGUICOLORS
+ if (char_aep != NULL)
+ under_fg_rgb = char_aep->ae_u.cterm.fg_rgb;
+ popup_bg_rgb = popup_aep->ae_u.cterm.bg_rgb;
+#endif
+ new_en.ae_u.cterm.fg_color = blend_cterm_colors(
+ popup_aep->ae_u.cterm.bg_color, popup_bg_rgb,
+ under_fg, under_fg_rgb, 0xFFFFFF, blend);
+ }
+ // Approximate cterm bg by blending with the underlying bg
+ // in the 256-color palette and mapping to the nearest entry.
+ {
+ int under_bg = (char_aep != NULL)
+ ? char_aep->ae_u.cterm.bg_color : 0;
+ guicolor_T under_bg_rgb = INVALCOLOR;
+ guicolor_T popup_bg_rgb = INVALCOLOR;
+#ifdef FEAT_TERMGUICOLORS
+ if (char_aep != NULL)
+ under_bg_rgb = char_aep->ae_u.cterm.bg_rgb;
+ popup_bg_rgb = popup_aep->ae_u.cterm.bg_rgb;
+#endif
+ new_en.ae_u.cterm.bg_color = blend_cterm_colors(
+ popup_aep->ae_u.cterm.bg_color, popup_bg_rgb,
+ under_bg, under_bg_rgb, 0x000000, blend);
}
- else if (popup_aep->ae_u.cterm.fg_color > 0)
- // Blend foreground color
- new_en.ae_u.cterm.fg_color = popup_aep->ae_u.cterm.fg_color;
- // Use popup background color (cterm colors don't support blending)
- if (popup_aep->ae_u.cterm.bg_color > 0)
- new_en.ae_u.cterm.bg_color = popup_aep->ae_u.cterm.bg_color;
#ifdef FEAT_TERMGUICOLORS
// Blend RGB colors for termguicolors mode.
// Fall back to cterm color converted to RGB when
@@ -3336,24 +3527,38 @@ hl_blend_attr(int char_attr, int popup_attr, int blend, int blend_fg UNUSED)
if (popup_bg != INVALCOLOR)
{
int base_fg = 0xFFFFFF;
+ // CTERMCOLOR is a sentinel meaning "use the cterm
+ // color"; treat it as no underlying color so it is
+ // not blended in as a real near-white pixel.
if (char_aep != NULL
- && char_aep->ae_u.cterm.fg_rgb != INVALCOLOR)
+ && !COLOR_INVALID(char_aep->ae_u.cterm.fg_rgb))
base_fg = char_aep->ae_u.cterm.fg_rgb;
new_en.ae_u.cterm.fg_rgb = blend_colors(
base_fg, popup_bg, blend);
}
}
else
+ {
// blend_fg=FALSE: popup text is opaque. Replace fg
- // with popup's (even INVALCOLOR) so the underlying
- // syntax highlighting fg does not bleed. ae_attr
- // was already set above for this branch.
- new_en.ae_u.cterm.fg_rgb = popup_fg;
+ // with popup's so the underlying syntax highlighting
+ // fg does not bleed. ae_attr was already set above
+ // for this branch. When the popup has no fg fall
+ // back to Normal's fg, then to white, so the text
+ // stays readable instead of rendering as default
+ // (which can be black on dark themes).
+ if (!COLOR_INVALID(popup_fg))
+ new_en.ae_u.cterm.fg_rgb = popup_fg;
+ else if (!COLOR_INVALID(cterm_normal_fg_gui_color))
+ new_en.ae_u.cterm.fg_rgb = cterm_normal_fg_gui_color;
+ else
+ new_en.ae_u.cterm.fg_rgb = 0xFFFFFF;
+ }
if (popup_bg != INVALCOLOR)
{
// Blend popup bg toward underlying bg
guicolor_T underlying_bg = INVALCOLOR;
- if (char_aep != NULL)
+ if (char_aep != NULL
+ && !COLOR_INVALID(char_aep->ae_u.cterm.bg_rgb))
underlying_bg = char_aep->ae_u.cterm.bg_rgb;
new_en.ae_u.cterm.bg_rgb = blend_colors(
popup_bg, underlying_bg, blend);
@@ -3463,21 +3668,48 @@ hl_pum_blend_attr(int char_attr, int popup_attr, int blend UNUSED)
popup_aep = syn_cterm_attr2entry(popup_attr);
if (popup_aep != NULL)
{
- // Blend cterm fg: use popup bg (hides text when opaque)
- if (popup_aep->ae_u.cterm.fg_color > 0)
- new_en.ae_u.cterm.fg_color =
- popup_aep->ae_u.cterm.fg_color;
- // Use popup cterm bg.
- if (popup_aep->ae_u.cterm.bg_color > 0)
- new_en.ae_u.cterm.bg_color =
- popup_aep->ae_u.cterm.bg_color;
+ // Blend cterm fg: pum_bg toward underlying_fg in the
+ // 256-color palette (mirrors the fg_rgb blend below).
+ {
+ int under_fg = (char_aep != NULL)
+ ? char_aep->ae_u.cterm.fg_color : 0;
+ guicolor_T under_fg_rgb = INVALCOLOR;
+ guicolor_T popup_bg_rgb = INVALCOLOR;
+#ifdef FEAT_TERMGUICOLORS
+ if (char_aep != NULL)
+ under_fg_rgb = char_aep->ae_u.cterm.fg_rgb;
+ popup_bg_rgb = popup_aep->ae_u.cterm.bg_rgb;
+#endif
+ new_en.ae_u.cterm.fg_color = blend_cterm_colors(
+ popup_aep->ae_u.cterm.bg_color, popup_bg_rgb,
+ under_fg, under_fg_rgb, 0xFFFFFF, blend);
+ }
+ // Approximate cterm bg by blending with the underlying bg
+ // in the 256-color palette and mapping to the nearest entry.
+ {
+ int under_bg = (char_aep != NULL)
+ ? char_aep->ae_u.cterm.bg_color : 0;
+ guicolor_T under_bg_rgb = INVALCOLOR;
+ guicolor_T popup_bg_rgb = INVALCOLOR;
+#ifdef FEAT_TERMGUICOLORS
+ if (char_aep != NULL)
+ under_bg_rgb = char_aep->ae_u.cterm.bg_rgb;
+ popup_bg_rgb = popup_aep->ae_u.cterm.bg_rgb;
+#endif
+ new_en.ae_u.cterm.bg_color = blend_cterm_colors(
+ popup_aep->ae_u.cterm.bg_color, popup_bg_rgb,
+ under_bg, under_bg_rgb, 0x000000, blend);
+ }
#ifdef FEAT_TERMGUICOLORS
// Blend fg_rgb: pum_bg toward underlying_fg.
+ // CTERMCOLOR is a sentinel meaning "use the cterm color";
+ // treat it as no underlying color so it is not blended in
+ // as a real near-white pixel.
if (popup_aep->ae_u.cterm.bg_rgb != INVALCOLOR)
{
int base_fg = 0xFFFFFF;
if (char_aep != NULL
- && char_aep->ae_u.cterm.fg_rgb != INVALCOLOR)
+ && !COLOR_INVALID(char_aep->ae_u.cterm.fg_rgb))
base_fg = char_aep->ae_u.cterm.fg_rgb;
new_en.ae_u.cterm.fg_rgb = blend_colors(
popup_aep->ae_u.cterm.bg_rgb, base_fg, blend);
@@ -3486,7 +3718,8 @@ hl_pum_blend_attr(int char_attr, int popup_attr, int blend UNUSED)
if (popup_aep->ae_u.cterm.bg_rgb != INVALCOLOR)
{
guicolor_T underlying_bg = INVALCOLOR;
- if (char_aep != NULL)
+ if (char_aep != NULL
+ && !COLOR_INVALID(char_aep->ae_u.cterm.bg_rgb))
underlying_bg = char_aep->ae_u.cterm.bg_rgb;
new_en.ae_u.cterm.bg_rgb = blend_colors(
popup_aep->ae_u.cterm.bg_rgb,
diff --git a/src/testdir/dumps/Test_popupwin_opacity_hl_80.dump b/src/testdir/dumps/Test_popupwin_opacity_hl_80.dump
index 89700f9c4..d197c8dac 100644
--- a/src/testdir/dumps/Test_popupwin_opacity_hl_80.dump
+++ b/src/testdir/dumps/Test_popupwin_opacity_hl_80.dump
@@ -1,7 +1,7 @@
>1+0&#ffffff0| @73
|2| @73
-|3| @7|f+0#ff404010#5fd7ff255|o@1| @1|b+0#0000000&|a|r| +0&#ffffff0@57
-|4| @7|b+0fd7ff255|a|z| @4| +0&#ffffff0@57
+|3| @7|f+0#ff404010#5fafd7255|o@1| +0#87d7ff255&@1|b+0#ffffff16&|a|r| +0#0000000#ffffff0@57
+|4| @7|b+0#ffffff16#5fafd7255|a|z| +0#87d7ff255&@4| +0#0000000#ffffff0@57
|5| @73
|6| @73
|7| @73
diff --git a/src/testdir/dumps/Test_popupwin_opacity_textprop_undercurl.dump b/src/testdir/dumps/Test_popupwin_opacity_textprop_undercurl.dump
new file mode 100644
index 000000000..2d29cfdea
--- /dev/null
+++ b/src/testdir/dumps/Test_popupwin_opacity_textprop_undercurl.dump
@@ -0,0 +1,8 @@
+>a+0&#ffffff0@2| |b@2| |c|P+0#e5e9f0255#03050b255|O|P|U|P|d+0#61646f255&|C+0#e5e9f0255&|O|N|T|E|N|T|f+0#61646f255&| |g@2| |h@2| @7| +0#0000000#ffffff0@10
+|i@2| |j@2| |k|k+0#61646f255#03050b255@1| |l@2| |m@2| |n@2| |o@2| |p@2| @7| +0#0000000#ffffff0@10
+|q@2| |r@2| |s|s+0#61646f255#03050b255@1| |m+0#e5e9f0255&|i|d@1|l|e|u+0#61646f255&| |v@2| |w@2| |x@2| @7| +0#0000000#ffffff0@10
+|y@2| |z@2| |1@2| |2@2| |3@2| |4@2| |5@2| |6@2| @18
+|~+0#0000ff255&| @48
+|~| @48
+|~| @48
+| +0#0000000&@31|1|,|1| @10|A|l@1|
diff --git a/src/testdir/dumps/Test_popupwin_opacity_zero_01.dump b/src/testdir/dumps/Test_popupwin_opacity_zero_01.dump
index 17aa447f8..1209af25c 100644
--- a/src/testdir/dumps/Test_popupwin_opacity_zero_01.dump
+++ b/src/testdir/dumps/Test_popupwin_opacity_zero_01.dump
@@ -1,7 +1,7 @@
>b+0&#ffffff0|a|c|k|g|r|o|u|n|d| |t|e|x|t| |h|e|r|e| @54
|b|a|c|k|g|r|o|u|n|d| |t|e|x|t| |h|e|r|e| @54
-|b|a|c|k|b|l|u|e|n|p|o|p|u|p|t| |h|e|r|e| @54
-|b|a|c|k|g|r|o|r|e|d| |p|o|p|u|p|h|e|r|e| @54
+|b|a|c|k|b+0#ffffff16#00005f255|l|u|e|n+0#8787d7255&|p+0#ffffff16&|o|p|u|p|t+0#0000000#ffffff0| |h|e|r|e| @54
+|b|a|c|k|g|r|o|r+0#ffffff16#000000255|e|d| +0#0000000#ffffff0|p+0#ffffff16#000000255|o|p|u|p|h+0#0000000#ffffff0|e|r|e| @54
|b|a|c|k|g|r|o|u|n|d| |t|e|x|t| |h|e|r|e| @54
|b|a|c|k|g|r|o|u|n|d| |t|e|x|t| |h|e|r|e| @54
|b|a|c|k|g|r|o|u|n|d| |t|e|x|t| |h|e|r|e| @54
diff --git a/src/testdir/dumps/Test_popupwin_opacity_zero_02.dump b/src/testdir/dumps/Test_popupwin_opacity_zero_02.dump
index 03b9f4a59..c724edb7f 100644
--- a/src/testdir/dumps/Test_popupwin_opacity_zero_02.dump
+++ b/src/testdir/dumps/Test_popupwin_opacity_zero_02.dump
@@ -1,7 +1,7 @@
>b+0&#ffffff0|a|c|k|g|r|o|u|n|d| |t|e|x|t| |h|e|r|e| @54
|b|a|c|k|g|r|o|u|n|d| |t|e|x|t| |h|e|r|e| @54
-|b|a|c|k|b|l|u|e|n|p|o|p|u|p|t| |h|e|r|e| @54
-|b|a|c|k|g|r|o|r|e|d| |p|o|p|u|p|h|e|r|e| @54
+|b|a|c|k|b+0#ffffff16#00005f255|l|u|e|n+0#8787d7255&|p+0#ffffff16&|o|p|u|p|t+0#0000000#ffffff0| |h|e|r|e| @54
+|b|a|c|k|g|r|o|r+0#ffffff16#000000255|e|d| +0#0000000#ffffff0|p+0#ffffff16#000000255|o|p|u|p|h+0#0000000#ffffff0|e|r|e| @54
|b|a|c|k|g|r|o|u|n|d| |t|e|x|t| |h|e|r|e| @54
|b|a|c|k|g|r|o|u|n|d| |t|e|x|t| |h|e|r|e| @54
|b|a|c|k|g|r|o|u|n|d| |t|e|x|t| |h|e|r|e| @54
diff --git a/src/testdir/dumps/Test_pumopt_opacity_50.dump b/src/testdir/dumps/Test_pumopt_opacity_50.dump
index d9e44da0f..4490b1ef1 100644
--- a/src/testdir/dumps/Test_pumopt_opacity_50.dump
+++ b/src/testdir/dumps/Test_pumopt_opacity_50.dump
@@ -13,8 +13,8 @@
|B|A|C|K|G|R|O|U|N|D|B|A|C|K|G|R|O|U|N|D|B|A|C|K|G|R|O|U|N|D|B|A|C|K|G|R|O|U|N|D|B|A|C|K|G|R|O|U|N|D|B|A|C|K|G|R|O|U|N|D|B|A|C|K|G|R|O|U|N|D|B|A|C|K|G
|R|O|U|N|D| @69
|h|e|l@1|o> @69
-|h+0#0000001#e0e0e08|e|l@1|o| @9| +0#4040ff13#ffffff0@59
-|h+0#0000001#ffd7ff255|e|l|p| @10| +0#4040ff13#ffffff0@59
+|h+0#0000001#5f5f5f255|e|l@1|o| +0#5f5fd7255&@9| +0#4040ff13#ffffff0@59
+|h+0#0000001#875f87255|e|l|p| +0#875fff255&@10| +0#4040ff13#ffffff0@59
|~| @73
|~| @73
|-+2#0000000&@1| |K|e|y|w|o|r|d| |c|o|m|p|l|e|t|i|o|n| |(|^|N|^|P|)| |m+0#00e0003&|a|t|c|h| |1| |o|f| |2| +0#0000000&@33
diff --git a/src/testdir/dumps/Test_pumopt_opacity_text_attrs.dump b/src/testdir/dumps/Test_pumopt_opacity_text_attrs.dump
index 93ea3a5a4..0911a881c 100644
--- a/src/testdir/dumps/Test_pumopt_opacity_text_attrs.dump
+++ b/src/testdir/dumps/Test_pumopt_opacity_text_attrs.dump
@@ -1,7 +1,7 @@
|ほ*0&#ffffff0|げ> +&@70
-|ほ*0#0000001#ffff4012|げ|ほ|げ|ほ|げ|漢*4&&| +&| +4#e000e06#ffffff0|テ*&|ス|ト|あ*0#0000000&|い|う|え|お|カ|タ|カ|ナ| +&@34
-|ふ*0#ffffff16#0000e05|が|漢|字|ほ|げ|漢*4&&| +&| +4#e000e06#ffffff0|テ*&|ス|ト|あ*0#0000000&|い|う|え|お|カ|タ|カ|ナ| +&@34
-|カ*0#ffffff16#0000e05|タ|カ|ナ|候|補|漢*4&&| +&| +4#e000e06#ffffff0|テ*&|ス|ト|あ*0#0000000&|い|う|え|お|カ|タ|カ|ナ| +&@34
+|ほ*0#0000001#875f00255|げ|ほ*0#ffd787255&|げ|ほ|げ|漢*4#ff875f255&| +&| +4#e000e06#ffffff0|テ*&|ス|ト|あ*0#0000000&|い|う|え|お|カ|タ|カ|ナ| +&@34
+|ふ*0#ffffff16#00005f255|が|漢|字|ほ*0#87afaf255&|げ|漢*4#875faf255&| +&| +4#e000e06#ffffff0|テ*&|ス|ト|あ*0#0000000&|い|う|え|お|カ|タ|カ|ナ| +&@34
+|カ*0#ffffff16#00005f255|タ|カ|ナ|候|補|漢*4#875faf255&| +&| +4#e000e06#ffffff0|テ*&|ス|ト|あ*0#0000000&|い|う|え|お|カ|タ|カ|ナ| +&@34
|ほ*&|げ|ほ|げ|ほ|げ|漢*4#e000e06&|字|テ|ス|ト|あ*0#0000000&|い|う|え|お|カ|タ|カ|ナ| +&@34
|ほ*&|げ|ほ|げ|ほ|げ|漢*4#e000e06&|字|テ|ス|ト|あ*0#0000000&|い|う|え|お|カ|タ|カ|ナ| +&@34
|ほ*&|げ|ほ|げ|ほ|げ|漢*4#e000e06&|字|テ|ス|ト|あ*0#0000000&|い|う|え|お|カ|タ|カ|ナ| +&@34
diff --git a/src/testdir/dumps/Test_pumopt_opacity_textprop_undercurl.dump b/src/testdir/dumps/Test_pumopt_opacity_textprop_undercurl.dump
new file mode 100644
index 000000000..8b9b0a091
--- /dev/null
+++ b/src/testdir/dumps/Test_pumopt_opacity_textprop_undercurl.dump
@@ -0,0 +1,20 @@
+|p+0&#ffffff0|o|p|u|p|-|i|t|e|m|-|1> @62
+|p+0#000000255#5f5f5f255|o|p|u|p|-|i|t|e|m|-|1|d+0#dedede255&@2| +0#0000000#ffffff0|e@2| |f@2| |g@2| |h@2| @43
+|p+0#000000255#7f457f255|o|p|u|p|-|i|t|e|m|-|2|d+0#ffc5ff255&@2| +0#0000000#ffffff0|e@2| |f@2| |g@2| |h@2| @43
+|p+0#000000255#7f457f255|o|p|u|p|-|i|t|e|m|-|3|d+0#ffc5ff255&@2| +0#0000000#ffffff0|e@2| |f@2| |g@2| |h@2| @43
+|a@2| |b@2| |c@2| |d@2| |e@2| |f@2| |g@2| |h@2| @43
+|a@2| |b@2| |c@2| |d@2| |e@2| |f@2| |g@2| |h@2| @43
+|a@2| |b@2| |c@2| |d@2| |e@2| |f@2| |g@2| |h@2| @43
+|a@2| |b@2| |c@2| |d@2| |e@2| |f@2| |g@2| |h@2| @43
+|a@2| |b@2| |c@2| |d@2| |e@2| |f@2| |g@2| |h@2| @43
+|~+0#0000ff255&| @73
+|~| @73
+|~| @73
+|~| @73
+|~| @73
+|~| @73
+|~| @73
+|~| @73
+|~| @73
+|~| @73
+|-+2#0000000&@1| |I|N|S|E|R|T| |-@1| +0&&@44|1|,|1| @10|A|l@1|
diff --git a/src/testdir/dumps/Test_pumopt_opacity_wide_bg.dump b/src/testdir/dumps/Test_pumopt_opacity_wide_bg.dump
index ed52522f0..87c7bd671 100644
--- a/src/testdir/dumps/Test_pumopt_opacity_wide_bg.dump
+++ b/src/testdir/dumps/Test_pumopt_opacity_wide_bg.dump
@@ -1,8 +1,8 @@
|ほ*0&#ffffff0|げ> +&@70
|╭+0#0000001#ffd7ff255|─@15|╮|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
-|│+0#0000001#ffd7ff255| +0&#e0e0e08|ほ*&|げ@1|ほ|げ|漢|字| +&|│+0&#ffd7ff255|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
-|│+0#0000001#ffd7ff255| |ふ*&|が|漢|字|げ|漢|字| +&|│|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
-|│+0#0000001#ffd7ff255| |カ*&|タ|カ|ナ|候|補|字| +&|│|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
+|│+0#0000001#ffd7ff255| +0f5f5f255|ほ*&|げ|げ*0#dadada255&|ほ|げ|漢|字| +&|│+0#0000001#ffd7ff255|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
+|│+0#0000001#ffd7ff255| +0ͫf87255|ふ*&|が|漢|字|げ*0#ffd7ff255&|漢|字| +&|│+0#0000001#ffd7ff255|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
+|│+0#0000001#ffd7ff255| +0ͫf87255|カ*&|タ|カ|ナ|候|補|字*0#ffd7ff255&| +&|│+0#0000001#ffd7ff255|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
|╰+0#0000001#ffd7ff255|─@15|╯|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
|ほ*&|げ|ほ|げ|ほ|げ|漢|字|テ|ス|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
|ほ*&|げ|ほ|げ|ほ|げ|漢|字|テ|ス|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
diff --git a/src/testdir/dumps/Test_pumopt_opacity_wide_bg_shifted.dump b/src/testdir/dumps/Test_pumopt_opacity_wide_bg_shifted.dump
index c145bf080..ef1b9acf8 100644
--- a/src/testdir/dumps/Test_pumopt_opacity_wide_bg_shifted.dump
+++ b/src/testdir/dumps/Test_pumopt_opacity_wide_bg_shifted.dump
@@ -1,8 +1,8 @@
|ほ*0&#ffffff0|げ> +&@70
|╭+0#0000001#ffd7ff255|─@15|╮|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
-|│+0#0000001#ffd7ff255| +0&#e0e0e08|ほ*&|げ| +&|げ*&|ほ|げ|漢|字|│+0&#ffd7ff255| +0#0000000#ffffff0|ス*&|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@33
-|│+0#0000001#ffd7ff255| |ふ*&|が|漢|字|げ|漢|字| +&|│|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
-|│+0#0000001#ffd7ff255| |カ*&|タ|カ|ナ|候|補| +&|字*&|│+&| +0#0000000#ffffff0|ス*&|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@33
+|│+0#0000001#ffd7ff255| +0f5f5f255|ほ*&|げ| +0#dadada255&|げ*&|ほ|げ|漢|字|│+0#0000001#ffd7ff255| +0#0000000#ffffff0|ス*&|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@33
+|│+0#0000001#ffd7ff255| +0ͫf87255|ふ*&|が|漢|字|げ*0#ffd7ff255&|漢|字| +&|│+0#0000001#ffd7ff255|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
+|│+0#0000001#ffd7ff255| +0ͫf87255|カ*&|タ|カ|ナ|候|補| +0#ffd7ff255&|字*&|│+0#0000001#ffd7ff255| +0#0000000#ffffff0|ス*&|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@33
|╰+0#0000001#ffd7ff255|─@15|╯|ス*0#0000000#ffffff0|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
|a|ほ*&|げ|ほ|げ|ほ|げ|漢|字|テ|ス|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@33
|ほ*&|げ|ほ|げ|ほ|げ|漢|字|テ|ス|ト|あ|い|う|え|お|カ|タ|カ|ナ| +&@34
diff --git a/src/testdir/test_popup.vim b/src/testdir/test_popup.vim
index 3362cd14f..2ceaec945 100644
--- a/src/testdir/test_popup.vim
+++ b/src/testdir/test_popup.vim
@@ -2474,6 +2474,40 @@ func Test_pumopt_opacity_wide_bg()
call StopVimInTerminal(buf)
endfunc
+" hl_pum_blend_attr() treated the CTERMCOLOR sentinel as a real near-white
+" color, leaking white bg onto textprop-covered cells under pum opacity.
+" Triggered by a textprop hl that only sets guisp.
+func Test_pumopt_opacity_textprop_undercurl()
+ CheckScreendump
+ let lines =<< trim END
+ set termguicolors
+ set t_Cs= t_Ce=
+ set pumopt=opacity:50
+ set completeopt=menu
+ call setline(1, '')
+ for i in range(8)
+ call append(line('$'), 'aaa bbb ccc ddd eee fff ggg hhh')
+ endfor
+ hi MyError guisp=#ec7279
+ call prop_type_add('mytype', #{highlight: 'MyError', combine: 1})
+ for s:l in range(2, 8)
+ call prop_add(s:l, 5, #{type: 'mytype', length: 20})
+ endfor
+ normal gg
+ inoremap <F5> <Cmd>call complete(col('.'),
+ \ ['popup-item-1', 'popup-item-2', 'popup-item-3'])<CR>
+ END
+ call writefile(lines, 'Xpumoptopacitytextprop', 'D')
+ let buf = RunVimInTerminal('-S Xpumoptopacitytextprop', {})
+ call TermWait(buf)
+ call term_sendkeys(buf, "i\<F5>")
+ call TermWait(buf, 100)
+ call VerifyScreenDump(buf, 'Test_pumopt_opacity_textprop_undercurl', {})
+ call term_sendkeys(buf, "\<C-E>\<Esc>u")
+ call TermWait(buf)
+ call StopVimInTerminal(buf)
+endfunc
+
" Test pumopt opacity when every other background line is shifted by one
" narrow cell, so the background's wide-character boundaries do not align
" with the popup's wide-character grid. Exercises the blend path when:
diff --git a/src/testdir/test_popupwin.vim b/src/testdir/test_popupwin.vim
index 1bc9344df..d6aa634c7 100644
--- a/src/testdir/test_popupwin.vim
+++ b/src/testdir/test_popupwin.vim
@@ -5120,6 +5120,40 @@ func Test_popup_opacity_vsplit()
call StopVimInTerminal(buf)
endfunc
+func Test_popup_opacity_textprop_undercurl()
+ CheckScreendump
+
+ " hl_blend_attr() treated the CTERMCOLOR sentinel as a real near-white
+ " color, leaking white bg onto textprop-covered cells under an opacity
+ " popup. Triggered by a textprop hl that only sets guisp.
+ let lines =<< trim END
+ set termguicolors
+ set t_Cs= t_Ce=
+ call setline(1, ['aaa bbb ccc ddd eee fff ggg hhh',
+ \ 'iii jjj kkk lll mmm nnn ooo ppp',
+ \ 'qqq rrr sss ttt uuu vvv www xxx',
+ \ 'yyy zzz 111 222 333 444 555 666'])
+ hi MyError guisp=#ec7279
+ call prop_type_add('mytype', #{highlight: 'MyError', combine: 1})
+ for s:l in range(1, line('$'))
+ call prop_add(s:l, 5, #{type: 'mytype', length: 20})
+ endfor
+ hi PanelBg guibg=#0b1021 guifg=#e5e9f0
+ call popup_create(['POPUP CONTENT', ' ', ' middle '], #{
+ \ line: 1, col: 10,
+ \ minwidth: 30, minheight: 3,
+ \ opacity: 35,
+ \ highlight: 'PanelBg',
+ \ zindex: 200,
+ \ })
+ END
+ call writefile(lines, 'XtestPopupOpacityTextprop', 'D')
+ let buf = RunVimInTerminal('-S XtestPopupOpacityTextprop', #{rows: 8, cols: 50})
+ call VerifyScreenDump(buf, 'Test_popupwin_opacity_textprop_undercurl', {})
+
+ call StopVimInTerminal(buf)
+endfunc
+
func Test_popup_close_b_nwindows()
edit Xfoo
setlocal bufhidden=wipe
diff --git a/src/version.c b/src/version.c
index d79705029..d77ebcc1f 100644
--- a/src/version.c
+++ b/src/version.c
@@ -729,6 +729,8 @@ static char *(features[]) =
static int included_patches[] =
{ /* Add new patch number below this line */
+/**/
+ 427,
/**/
426,
/**/