commit 4719b944437b62407d36e718e64dfb0d316934d1
parent a1895f024a4cc9bb4389deae2a6686a34d3329f7
Author: Riley Bruins <ribru17@hotmail.com>
Date: Sun, 15 Feb 2026 09:16:51 -0800
feat(statusline): option to specify stacking highlight groups #37153
**Problem:** No easy way to stack highlight groups #35806.
**Solution:** Add a way to specify a new statusline chunk with a
highlight group that inherits from previous highlight attributes.
Also applies to tabline, etc.
Diffstat:
10 files changed, 73 insertions(+), 16 deletions(-)
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -353,6 +353,8 @@ OPTIONS
• 'busy' sets a buffer "busy" status. Indicated in the default statusline.
• 'pumborder' adds a border to the popup menu.
• |g:clipboard| autodetection only selects tmux when running inside tmux
+• 'statusline' allows "stacking" highlight groups (groups inherit from
+ previous highlight attributes)
PERFORMANCE
diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt
@@ -6409,6 +6409,8 @@ A jump table for the options with a short description can be found at |Q_op|.
Thus use %#HLname# for highlight group HLname. The same
highlighting is used, also for the statusline of non-current
windows.
+ $ - Same as `#`, except the `%$HLname$` group will inherit from
+ preceding highlight attributes.
* - Set highlight group to User{N}, where {N} is taken from the
minwid field, e.g. %1*. Restore normal highlight with %* or
%0*. The difference between User{N} and StatusLine will be
diff --git a/runtime/lua/vim/_meta/options.lua b/runtime/lua/vim/_meta/options.lua
@@ -6857,6 +6857,8 @@ vim.wo.stc = vim.wo.statuscolumn
--- Thus use %#HLname# for highlight group HLname. The same
--- highlighting is used, also for the statusline of non-current
--- windows.
+--- $ - Same as `#`, except the `%$HLname$` group will inherit from
+--- preceding highlight attributes.
--- * - Set highlight group to User{N}, where {N} is taken from the
--- minwid field, e.g. %1*. Restore normal highlight with %* or
--- %0*. The difference between User{N} and StatusLine will be
diff --git a/src/nvim/option_vars.h b/src/nvim/option_vars.h
@@ -222,8 +222,8 @@ enum {
STL_PREVIEWFLAG, STL_PREVIEWFLAG_ALT, STL_MODIFIED, STL_MODIFIED_ALT, \
STL_QUICKFIX, STL_PERCENTAGE, STL_ALTPERCENT, STL_ARGLISTSTAT, STL_PAGENUM, \
STL_SHOWCMD, STL_FOLDCOL, STL_SIGNCOL, STL_VIM_EXPR, STL_SEPARATE, \
- STL_TRUNCMARK, STL_USER_HL, STL_HIGHLIGHT, STL_TABPAGENR, STL_TABCLOSENR, \
- STL_CLICK_FUNC, STL_TABPAGENR, STL_TABCLOSENR, STL_CLICK_FUNC, \
+ STL_TRUNCMARK, STL_USER_HL, STL_HIGHLIGHT, STL_HIGHLIGHT_COMB, STL_TABPAGENR, \
+ STL_TABCLOSENR, STL_CLICK_FUNC, STL_TABPAGENR, STL_TABCLOSENR, STL_CLICK_FUNC, \
0, })
// arguments for can_bs()
diff --git a/src/nvim/options.lua b/src/nvim/options.lua
@@ -8914,6 +8914,8 @@ local options = {
Thus use %#HLname# for highlight group HLname. The same
highlighting is used, also for the statusline of non-current
windows.
+ $ - Same as `#`, except the `%$HLname$` group will inherit from
+ preceding highlight attributes.
* - Set highlight group to User{N}, where {N} is taken from the
minwid field, e.g. %1*. Restore normal highlight with %* or
%0*. The difference between User{N} and StatusLine will be
diff --git a/src/nvim/statusline.c b/src/nvim/statusline.c
@@ -374,7 +374,12 @@ static void win_redr_custom(win_T *wp, bool draw_winbar, bool draw_ruler, bool u
curattr = attr;
curgroup = (int)group;
} else if (sp->userhl < 0) {
- curattr = syn_id2attr(-sp->userhl);
+ int new_attr = syn_id2attr(-sp->userhl);
+ if (sp->item == STL_HIGHLIGHT_COMB) {
+ curattr = hl_combine_attr(curattr, new_attr);
+ } else {
+ curattr = new_attr;
+ }
curgroup = -sp->userhl;
} else {
int *userhl = (wp != NULL && wp != curwin && wp->w_status_height != 0)
@@ -1081,7 +1086,7 @@ int build_stl_str_hl(win_T *wp, char *out, size_t outlen, char *fmt, OptIndex op
// remove group if all items are empty and highlight group
// doesn't change
for (n = stl_groupitems[groupdepth] - 1; n >= 0; n--) {
- if (stl_items[n].type == Highlight) {
+ if (stl_items[n].type == Highlight || stl_items[n].type == HighlightCombining) {
group_start_userhl = group_end_userhl = stl_items[n].minwid;
break;
}
@@ -1090,7 +1095,7 @@ int build_stl_str_hl(win_T *wp, char *out, size_t outlen, char *fmt, OptIndex op
if (stl_items[n].type == Normal) {
break;
}
- if (stl_items[n].type == Highlight) {
+ if (stl_items[n].type == Highlight || stl_items[n].type == HighlightCombining) {
group_end_userhl = stl_items[n].minwid;
}
}
@@ -1100,7 +1105,7 @@ int build_stl_str_hl(win_T *wp, char *out, size_t outlen, char *fmt, OptIndex op
group_len = 0;
for (n = stl_groupitems[groupdepth] + 1; n < curitem; n++) {
// do not use the highlighting from the removed group
- if (stl_items[n].type == Highlight) {
+ if (stl_items[n].type == Highlight || stl_items[n].type == HighlightCombining) {
stl_items[n].type = Empty;
}
// adjust the start position of TabPage to the next
@@ -1682,17 +1687,18 @@ stcsign:
}
break;
+ case STL_HIGHLIGHT_COMB:
case STL_HIGHLIGHT: {
- // { The name of the highlight is surrounded by `#`
+ // { The name of the highlight is surrounded by `#` or `$`
char *t = fmt_p;
- while (*fmt_p != '#' && *fmt_p != NUL) {
+ while (*fmt_p != opt && *fmt_p != NUL) {
fmt_p++;
}
// }
// Create a highlight item based on the name
- if (*fmt_p == '#') {
- stl_items[curitem].type = Highlight;
+ if (*fmt_p == opt) {
+ stl_items[curitem].type = opt == STL_HIGHLIGHT_COMB ? HighlightCombining : Highlight;
stl_items[curitem].start = out_p;
stl_items[curitem].minwid = -syn_name2id_len(t, (size_t)(fmt_p - t));
curitem++;
@@ -2073,12 +2079,14 @@ stcsign:
*hltab = stl_hltab;
stl_hlrec_t *sp = stl_hltab;
for (int l = evalstart; l < itemcnt + evalstart; l++) {
- if (stl_items[l].type == Highlight
+ if (stl_items[l].type == Highlight || stl_items[l].type == HighlightCombining
|| stl_items[l].type == HighlightFold || stl_items[l].type == HighlightSign) {
sp->start = stl_items[l].start;
sp->userhl = stl_items[l].minwid;
unsigned type = stl_items[l].type;
- sp->item = type == HighlightSign ? STL_SIGNCOL : type == HighlightFold ? STL_FOLDCOL : 0;
+ sp->item = type == HighlightSign ? STL_SIGNCOL : type ==
+ HighlightFold ? STL_FOLDCOL : type ==
+ HighlightCombining ? STL_HIGHLIGHT_COMB : 0;
sp++;
}
}
diff --git a/src/nvim/statusline_defs.h b/src/nvim/statusline_defs.h
@@ -44,6 +44,7 @@ typedef enum {
STL_TRUNCMARK = '<', ///< Truncation mark if line is too long.
STL_USER_HL = '*', ///< Highlight from (User)1..9 or 0.
STL_HIGHLIGHT = '#', ///< Highlight name.
+ STL_HIGHLIGHT_COMB = '$', ///< Highlight name (combining previous attrs).
STL_TABPAGENR = 'T', ///< Tab page label nr.
STL_TABCLOSENR = 'X', ///< Tab page close nr.
STL_CLICK_FUNC = '@', ///< Click region start.
@@ -88,6 +89,7 @@ struct stl_item {
Group,
Separate,
Highlight,
+ HighlightCombining,
HighlightSign,
HighlightFold,
TabPage,
diff --git a/test/functional/ui/statusline_spec.lua b/test/functional/ui/statusline_spec.lua
@@ -99,6 +99,47 @@ for _, model in ipairs(mousemodels) do
eq('0 1 l', eval('g:testvar'))
end)
+ it('works with combined highlight attributes', function()
+ screen:add_extra_attr_ids({
+ [131] = { reverse = true, bold = true, background = Screen.colors.LightMagenta },
+ [132] = {
+ reverse = true,
+ foreground = Screen.colors.Magenta,
+ bold = true,
+ background = Screen.colors.LightMagenta,
+ },
+ [133] = { reverse = true, bold = true, foreground = Screen.colors.Magenta1 },
+ [134] = {
+ bold = true,
+ background = Screen.colors.LightMagenta,
+ reverse = true,
+ undercurl = true,
+ special = Screen.colors.Red,
+ },
+ [135] = {
+ bold = true,
+ background = Screen.colors.LightMagenta,
+ reverse = true,
+ undercurl = true,
+ foreground = Screen.colors.Fuchsia,
+ special = Screen.colors.Red,
+ },
+ })
+
+ api.nvim_set_option_value(
+ 'statusline',
+ '\t%#Pmenu#foo%$SpellBad$bar%$String$baz%#Constant#qux',
+ {}
+ )
+
+ screen:expect([[
+ ^ |
+ {1:~ }|*5
+ {3:^I}{131:foo}{134:bar}{135:baz}{133:qux }|
+ |
+ ]])
+ end)
+
it('works for winbar', function()
api.nvim_set_option_value('winbar', 'Not clicky stuff %0@MyClickFunc@Clicky stuff%T', {})
api.nvim_input_mouse('left', 'press', '', 0, 0, 17)
diff --git a/test/old/testdir/gen_opt_test.vim b/test/old/testdir/gen_opt_test.vim
@@ -336,13 +336,13 @@ let test_values = {
\ 'timeout:-1', 'file:/tmp/file', 'expr:Func()', 'double,33'],
\ ['xxx', '-1', 'timeout:', 'best,double', 'double,fast']],
\ 'splitkeep': [['cursor', 'screen', 'topline'], ['xxx']],
- \ 'statusline': [['', 'xxx'], ['%$', '%{', '%{%', '%{%}', '%(', '%)']],
+ \ 'statusline': [['', 'xxx'], ['%{', '%{%', '%{%}', '%(', '%)']],
"\ 'swapsync': [['', 'sync', 'fsync'], ['xxx']],
\ 'switchbuf': [['', 'useopen', 'usetab', 'split', 'vsplit', 'newtab',
\ 'uselast', 'split,newtab'],
\ ['xxx']],
\ 'tabclose': [['', 'left', 'uselast', 'left,uselast'], ['xxx']],
- \ 'tabline': [['', 'xxx'], ['%$', '%{', '%{%', '%{%}', '%(', '%)']],
+ \ 'tabline': [['', 'xxx'], ['%{', '%{%', '%{%}', '%(', '%)']],
\ 'tagcase': [['followic', 'followscs', 'ignore', 'match', 'smart'],
\ ['', 'xxx', 'smart,match']],
\ 'termencoding': [has('gui_gtk') ? [] : ['', 'utf-8'], ['xxx']],
diff --git a/test/old/testdir/test_options.vim b/test/old/testdir/test_options.vim
@@ -866,7 +866,6 @@ func Test_set_option_errors()
call assert_fails('set rulerformat=%15(%%', 'E542:')
" Test for 'statusline' errors
- call assert_fails('set statusline=%$', 'E539:')
call assert_fails('set statusline=%{', 'E540:')
call assert_fails('set statusline=%{%', 'E540:')
call assert_fails('set statusline=%{%}', 'E539:')
@@ -874,7 +873,6 @@ func Test_set_option_errors()
call assert_fails('set statusline=%)', 'E542:')
" Test for 'tabline' errors
- call assert_fails('set tabline=%$', 'E539:')
call assert_fails('set tabline=%{', 'E540:')
call assert_fails('set tabline=%{%', 'E540:')
call assert_fails('set tabline=%{%}', 'E539:')