Skip to content

Commit d460928

Browse files
authored
feat(tui): update 'background' on theme change events (#31350)
Enabling private DEC mode 2031 tells the terminal to notify Nvim whenever the OS theme changes (i.e. light mode to dark mode or vice versa) or the terminal emulator's palette changes. When we receive one of these notifications we query the terminal color's background color again to see if it has changed and update the value of 'background' if it has. We only do this though if the user has not explicitly set the value of 'bg' themselves. The help text is updated slightly to hint to users that they probably shouldn't set this value: on modern terminal emulators Nvim is able to completely determine this automatically.
1 parent 99b5ffd commit d460928

File tree

6 files changed

+100
-27
lines changed

6 files changed

+100
-27
lines changed

runtime/doc/news.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,8 @@ TUI
284284
:lua =vim.api.nvim_get_chan_info(vim.api.nvim_list_uis()[1].chan)
285285
|log| messages written by the builtin UI client (TUI, |--remote-ui|) are
286286
now prefixed with "ui" instead of "?".
287+
• The TUI will re-query the terminal's background color when a theme update
288+
notification is received and Nvim will update 'background' accordingly.
287289

288290
UI
289291

runtime/lua/vim/_defaults.lua

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -546,8 +546,9 @@ do
546546
---
547547
--- @param option string Option name
548548
--- @param value any Option value
549-
local function setoption(option, value)
550-
if vim.api.nvim_get_option_info2(option, {}).was_set then
549+
--- @param force boolean? Always set the value, even if already set
550+
local function setoption(option, value, force)
551+
if not force and vim.api.nvim_get_option_info2(option, {}).was_set then
551552
-- Don't do anything if option is already set
552553
return
553554
end
@@ -563,7 +564,7 @@ do
563564
once = true,
564565
nested = true,
565566
callback = function()
566-
setoption(option, value)
567+
setoption(option, value, force)
567568
end,
568569
})
569570
end
@@ -645,11 +646,15 @@ do
645646
return nil, nil, nil
646647
end
647648

648-
local timer = assert(vim.uv.new_timer())
649-
649+
-- This autocommand updates the value of 'background' anytime we receive
650+
-- an OSC 11 response from the terminal emulator. If the user has set
651+
-- 'background' explictly then we will delete this autocommand,
652+
-- effectively disabling automatic background setting.
653+
local force = false
650654
local id = vim.api.nvim_create_autocmd('TermResponse', {
651655
group = group,
652656
nested = true,
657+
desc = "Update the value of 'background' automatically based on the terminal emulator's background color",
653658
callback = function(args)
654659
local resp = args.data ---@type string
655660
local r, g, b = parseosc11(resp)
@@ -661,27 +666,33 @@ do
661666
if rr and gg and bb then
662667
local luminance = (0.299 * rr) + (0.587 * gg) + (0.114 * bb)
663668
local bg = luminance < 0.5 and 'dark' or 'light'
664-
setoption('background', bg)
669+
setoption('background', bg, force)
670+
671+
-- On the first query response, don't force setting the option in
672+
-- case the user has already set it manually. If they have, then
673+
-- this autocommand will be deleted. If they haven't, then we do
674+
-- want to force setting the option to override the value set by
675+
-- this autocommand.
676+
if not force then
677+
force = true
678+
end
665679
end
680+
end
681+
end,
682+
})
666683

667-
return true
684+
vim.api.nvim_create_autocmd('VimEnter', {
685+
group = group,
686+
nested = true,
687+
once = true,
688+
callback = function()
689+
if vim.api.nvim_get_option_info2('background', {}).was_set then
690+
vim.api.nvim_del_autocmd(id)
668691
end
669692
end,
670693
})
671694

672695
io.stdout:write('\027]11;?\007')
673-
674-
timer:start(1000, 0, function()
675-
-- Delete the autocommand if no response was received
676-
vim.schedule(function()
677-
-- Suppress error if autocommand has already been deleted
678-
pcall(vim.api.nvim_del_autocmd, id)
679-
end)
680-
681-
if not timer:is_closing() then
682-
timer:close()
683-
end
684-
end)
685696
end
686697

687698
--- If the TUI (term_has_truecolor) was able to determine that the host

src/nvim/tui/input.c

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,16 @@ void tinput_init(TermInput *input, Loop *loop)
160160
// initialize a timer handle for handling ESC with libtermkey
161161
uv_timer_init(&loop->uv, &input->timer_handle);
162162
input->timer_handle.data = input;
163+
164+
uv_timer_init(&loop->uv, &input->bg_query_timer);
165+
input->bg_query_timer.data = input;
163166
}
164167

165168
void tinput_destroy(TermInput *input)
166169
{
167170
map_destroy(int, &kitty_key_map);
168171
uv_close((uv_handle_t *)&input->timer_handle, NULL);
172+
uv_close((uv_handle_t *)&input->bg_query_timer, NULL);
169173
rstream_may_close(&input->read_stream);
170174
termkey_destroy(input->tk);
171175
}
@@ -179,6 +183,7 @@ void tinput_stop(TermInput *input)
179183
{
180184
rstream_stop(&input->read_stream);
181185
uv_timer_stop(&input->timer_handle);
186+
uv_timer_stop(&input->bg_query_timer);
182187
}
183188

184189
static void tinput_done_event(void **argv)
@@ -474,6 +479,13 @@ static void tinput_timer_cb(uv_timer_t *handle)
474479
tinput_flush(input);
475480
}
476481

482+
static void bg_query_timer_cb(uv_timer_t *handle)
483+
FUNC_ATTR_NONNULL_ALL
484+
{
485+
TermInput *input = handle->data;
486+
tui_query_bg_color(input->tui_data);
487+
}
488+
477489
/// Handle focus events.
478490
///
479491
/// If the upcoming sequence of bytes in the input stream matches the termcode
@@ -660,6 +672,33 @@ static void handle_unknown_csi(TermInput *input, const TermKeyKey *key)
660672
}
661673
}
662674
break;
675+
case 'n':
676+
// Device Status Report (DSR)
677+
if (nparams == 2) {
678+
int args[2];
679+
for (size_t i = 0; i < ARRAY_SIZE(args); i++) {
680+
if (termkey_interpret_csi_param(params[i], &args[i], NULL, NULL) != TERMKEY_RES_KEY) {
681+
return;
682+
}
683+
}
684+
685+
if (args[0] == 997) {
686+
// Theme update notification
687+
// https://github.com/contour-terminal/contour/blob/master/docs/vt-extensions/color-palette-update-notifications.md
688+
// The second argument tells us whether the OS theme is set to light
689+
// mode or dark mode, but all we care about is the background color of
690+
// the terminal emulator. We query for that with OSC 11 and the response
691+
// is handled by the autocommand created in _defaults.lua. The terminal
692+
// may send us multiple notifications all at once so we use a timer to
693+
// coalesce the queries.
694+
if (uv_timer_get_due_in(&input->bg_query_timer) > 0) {
695+
return;
696+
}
697+
698+
uv_timer_start(&input->bg_query_timer, bg_query_timer_cb, 100, 0);
699+
}
700+
}
701+
break;
663702
default:
664703
break;
665704
}

src/nvim/tui/input.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ typedef struct {
3232
TermKey *tk;
3333
TermKey_Terminfo_Getstr_Hook *tk_ti_hook_fn; ///< libtermkey terminfo hook
3434
uv_timer_t timer_handle;
35+
uv_timer_t bg_query_timer; ///< timer used to batch background color queries
3536
Loop *loop;
3637
RStream read_stream;
3738
TUIData *tui_data;

src/nvim/tui/tui.c

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -241,16 +241,19 @@ void tui_handle_term_mode(TUIData *tui, TermMode mode, TermModeState state)
241241
tui->unibi_ext.sync = (int)unibi_add_ext_str(tui->ut, "Sync",
242242
"\x1b[?2026%?%p1%{1}%-%tl%eh%;");
243243
break;
244-
case kTermModeResizeEvents:
245-
signal_watcher_stop(&tui->winch_handle);
246-
tui_set_term_mode(tui, mode, true);
247-
break;
248244
case kTermModeGraphemeClusters:
249245
if (!is_set) {
250246
tui_set_term_mode(tui, mode, true);
251247
tui->did_set_grapheme_cluster_mode = true;
252248
}
253249
break;
250+
case kTermModeThemeUpdates:
251+
tui_set_term_mode(tui, mode, true);
252+
break;
253+
case kTermModeResizeEvents:
254+
signal_watcher_stop(&tui->winch_handle);
255+
tui_set_term_mode(tui, mode, true);
256+
break;
254257
}
255258
}
256259
}
@@ -320,6 +323,18 @@ static void tui_reset_key_encoding(TUIData *tui)
320323
}
321324
}
322325

326+
/// Write the OSC 11 sequence to the terminal emulator to query the current
327+
/// background color.
328+
///
329+
/// The response will be handled by the TermResponse autocommand created in
330+
/// _defaults.lua.
331+
void tui_query_bg_color(TUIData *tui)
332+
FUNC_ATTR_NONNULL_ALL
333+
{
334+
out(tui, S_LEN("\x1b]11;?\x07"));
335+
flush_buf(tui);
336+
}
337+
323338
/// Enable the alternate screen and emit other control sequences to start the TUI.
324339
///
325340
/// This is also called when the TUI is resumed after being suspended. We reinitialize all state
@@ -438,14 +453,13 @@ static void terminfo_start(TUIData *tui)
438453
// Enable bracketed paste
439454
unibi_out_ext(tui, tui->unibi_ext.enable_bracketed_paste);
440455

441-
// Query support for mode 2026 (Synchronized Output). Some terminals also
442-
// support an older DCS sequence for synchronized output, but we will only use
443-
// mode 2026.
456+
// Query support for private DEC modes that Nvim can take advantage of.
444457
// Some terminals (such as Terminal.app) do not support DECRQM, so skip the query.
445458
if (!nsterm) {
446459
tui_request_term_mode(tui, kTermModeSynchronizedOutput);
447-
tui_request_term_mode(tui, kTermModeResizeEvents);
448460
tui_request_term_mode(tui, kTermModeGraphemeClusters);
461+
tui_request_term_mode(tui, kTermModeThemeUpdates);
462+
tui_request_term_mode(tui, kTermModeResizeEvents);
449463
}
450464

451465
// Don't use DECRQSS in screen or tmux, as they behave strangely when receiving it.
@@ -493,6 +507,10 @@ static void terminfo_start(TUIData *tui)
493507
/// Disable the alternate screen and prepare for the TUI to close.
494508
static void terminfo_stop(TUIData *tui)
495509
{
510+
// Disable theme update notifications. We do this first to avoid getting any
511+
// more notifications after we reset the cursor and any color palette changes.
512+
tui_set_term_mode(tui, kTermModeThemeUpdates, false);
513+
496514
// Destroy output stuff
497515
tui_mode_change(tui, NULL_STRING, SHAPE_IDX_N);
498516
tui_mouse_off(tui);
@@ -509,6 +527,7 @@ static void terminfo_stop(TUIData *tui)
509527
if (tui->did_set_grapheme_cluster_mode) {
510528
tui_set_term_mode(tui, kTermModeGraphemeClusters, false);
511529
}
530+
512531
// May restore old title before exiting alternate screen.
513532
tui_set_title(tui, NULL_STRING);
514533
if (ui_client_exit_status == 0) {

src/nvim/tui/tui_defs.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ typedef struct TUIData TUIData;
55
typedef enum {
66
kTermModeSynchronizedOutput = 2026,
77
kTermModeGraphemeClusters = 2027,
8+
kTermModeThemeUpdates = 2031,
89
kTermModeResizeEvents = 2048,
910
} TermMode;
1011

0 commit comments

Comments
 (0)