From 4719b944437b62407d36e718e64dfb0d316934d1 Mon Sep 17 00:00:00 2001 From: Riley Bruins Date: Sun, 15 Feb 2026 09:16:51 -0800 Subject: [PATCH] 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. --- runtime/doc/news.txt | 2 ++ runtime/doc/options.txt | 2 ++ runtime/lua/vim/_meta/options.lua | 2 ++ src/nvim/option_vars.h | 4 +-- src/nvim/options.lua | 2 ++ src/nvim/statusline.c | 28 +++++++++++------- src/nvim/statusline_defs.h | 2 ++ test/functional/ui/statusline_spec.lua | 41 ++++++++++++++++++++++++++ test/old/testdir/gen_opt_test.vim | 4 +-- test/old/testdir/test_options.vim | 2 -- 10 files changed, 73 insertions(+), 16 deletions(-) diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index f86fe083d1..03e852779d 100644 --- 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 index 31185e4729..e00ead511f 100644 --- 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 index bd0bbbafa4..6c76085ebf 100644 --- 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 index f972d59cce..6a4a4b2217 100644 --- 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 index 144982dce3..5e374573dd 100644 --- 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 index 6aba33780f..d8e1e0ccf6 100644 --- 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 index 83dda1e035..ff55845669 100644 --- 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 index 5a4e20b843..60d46513b7 100644 --- 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 index 9cea88bcec..7d1969e43d 100644 --- 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 index 5244436b33..e7dbd56e64 100644 --- 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:')