Skip to content

Commit b763fcb

Browse files
committed
feat(theme): notify apps of color-scheme change via DECSET 2031
Apps that set terminal colors had to be restarted to pick up a theme change. Implement the DECSET 2031 light/dark notification so subscribed apps (helix, neovim) re-query colors and repaint live. Uses the DSR wire format per the contour spec, not OSC: - DECSET 2031 (DECSET/DECRST/DECRQM) opts in to notifications - on theme change, push unsolicited CSI ? 997 ; <1 dark|2 light> n - answer the CSI ? 996 n query with the stored scheme (ungated) Polarity comes from the resolved OS appearance / [adaptive-theme], threaded through update_config and Screen::new — not inferred from background luminance. New panes inherit the manager's current scheme.
1 parent eb23cef commit b763fcb

7 files changed

Lines changed: 184 additions & 23 deletions

File tree

frontends/rioterm/src/application.rs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
1414
use raw_window_handle::HasDisplayHandle;
1515
use rio_backend::clipboard::{Clipboard, ClipboardType};
1616
use rio_backend::config::colors::{ColorRgb, NamedColor};
17+
use rio_backend::config::theme::AppearanceTheme;
1718
use rio_window::application::ApplicationHandler;
1819
use rio_window::event::{
1920
ElementState, Ime, MouseButton, MouseScrollDelta, StartCause, TouchPhase, WindowEvent,
@@ -392,17 +393,19 @@ impl ApplicationHandler<EventPayload> for Application<'_> {
392393

393394
self.config = config;
394395

396+
let color_scheme = self.config.force_theme.or_else(|| {
397+
event_loop
398+
.system_theme()
399+
.map(AppearanceTheme::from_window_theme)
400+
});
395401
let mut has_checked_adaptive_colors = false;
396402
for (_id, route) in self.router.routes.iter_mut() {
397403
// Apply system theme to ensure colors are consistent
398404
if !has_checked_adaptive_colors {
399-
let system_theme = event_loop.system_theme();
400-
let theme = self
401-
.config
402-
.force_theme
403-
.map(|t| t.to_window_theme())
404-
.or(system_theme);
405-
update_colors_based_on_theme(&mut self.config, theme);
405+
update_colors_based_on_theme(
406+
&mut self.config,
407+
color_scheme.map(AppearanceTheme::to_window_theme),
408+
);
406409
has_checked_adaptive_colors = true;
407410
}
408411

@@ -422,6 +425,7 @@ impl ApplicationHandler<EventPayload> for Application<'_> {
422425
&self.config,
423426
&self.router.font_library,
424427
has_font_updates,
428+
color_scheme,
425429
);
426430
route.window.configure_window(&self.config);
427431

@@ -854,7 +858,6 @@ impl ApplicationHandler<EventPayload> for Application<'_> {
854858
}
855859
RioEventType::Rio(RioEvent::ToggleAppearanceTheme) => {
856860
if let Some(route) = self.router.routes.get_mut(&window_id) {
857-
use rio_backend::config::theme::AppearanceTheme;
858861
let current = self
859862
.config
860863
.force_theme
@@ -876,6 +879,7 @@ impl ApplicationHandler<EventPayload> for Application<'_> {
876879
&self.config,
877880
&self.router.font_library,
878881
false,
882+
Some(toggled),
879883
);
880884
route.window.configure_window(&self.config);
881885
}
@@ -1796,6 +1800,7 @@ impl ApplicationHandler<EventPayload> for Application<'_> {
17961800
&self.config,
17971801
&self.router.font_library,
17981802
false,
1803+
Some(AppearanceTheme::from_window_theme(new_theme)),
17991804
);
18001805
route.window.configure_window(&self.config);
18011806
}

frontends/rioterm/src/context/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ pub struct ContextManagerConfig {
134134
pub title: rio_backend::config::title::Title,
135135
pub keyboard: rio_backend::config::keyboard::Keyboard,
136136
pub scrollback_history_limit: usize,
137+
/// Initial color-scheme polarity for new panes, so the
138+
/// `CSI ? 996 n` query answers correctly before any theme change.
139+
pub color_scheme_is_dark: bool,
137140
}
138141

139142
const DEFAULT_CONTEXT_CAPACITY: usize = 28;
@@ -245,6 +248,7 @@ impl<T: EventListener + Clone + std::marker::Send + 'static> ContextManager<T> {
245248
config.scrollback_history_limit,
246249
);
247250
terminal.blinking_cursor = cursor_state.1;
251+
terminal.set_color_scheme(config.color_scheme_is_dark);
248252
let terminal: Arc<FairMutex<Crosswords<T>>> = Arc::new(FairMutex::new(terminal));
249253

250254
let pty;
@@ -1057,6 +1061,7 @@ impl<T: EventListener + Clone + std::marker::Send + 'static> ContextManager<T> {
10571061
title: config.title,
10581062
keyboard: config.keyboard,
10591063
scrollback_history_limit: config.scrollback_history_limit,
1064+
color_scheme_is_dark: self.config.color_scheme_is_dark,
10601065
};
10611066

10621067
let current = self.current();

frontends/rioterm/src/router/mod.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::screen::{Screen, ScreenWindowProperties};
99
use assistant::Assistant;
1010
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
1111
use rio_backend::clipboard::Clipboard;
12+
use rio_backend::config::theme::AppearanceTheme;
1213
use rio_backend::config::Config as RioConfig;
1314
use rio_backend::error::{RioError, RioErrorLevel, RioErrorType};
1415

@@ -89,10 +90,11 @@ impl Route<'_> {
8990
config: &RioConfig,
9091
db: &rio_backend::sugarloaf::font::FontLibrary,
9192
should_update_font: bool,
93+
color_scheme: Option<AppearanceTheme>,
9294
) {
9395
self.window
9496
.screen
95-
.update_config(config, db, should_update_font);
97+
.update_config(config, db, should_update_font, color_scheme);
9698
}
9799

98100
#[inline]
@@ -700,8 +702,18 @@ impl<'a> RouteWindow<'a> {
700702
window_id: winit_window.id(),
701703
};
702704

703-
let screen = Screen::new(properties, config, event_proxy, font_library, open_url)
704-
.expect("Screen not created");
705+
let color_scheme = config
706+
.force_theme
707+
.or_else(|| winit_window.theme().map(AppearanceTheme::from_window_theme));
708+
let screen = Screen::new(
709+
properties,
710+
config,
711+
event_proxy,
712+
font_library,
713+
open_url,
714+
color_scheme,
715+
)
716+
.expect("Screen not created");
705717

706718
if config.window.columns.is_some() || config.window.rows.is_some() {
707719
let (physical_width, physical_height) = compute_window_size_from_grid(

frontends/rioterm/src/screen/mod.rs

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use rio_backend::clipboard::Clipboard;
3636
use rio_backend::clipboard::ClipboardType;
3737
use rio_backend::config::layout::Margin;
3838
use rio_backend::config::renderer::Backend;
39+
use rio_backend::config::theme::AppearanceTheme;
3940
use rio_backend::crosswords::pos::{Boundary, CursorState, Direction, Line};
4041
use rio_backend::crosswords::search::RegexSearch;
4142
use rio_backend::error::{RioError, RioErrorLevel, RioErrorType};
@@ -101,6 +102,7 @@ impl Screen<'_> {
101102
event_proxy: EventProxy,
102103
font_library: &rio_backend::sugarloaf::font::FontLibrary,
103104
open_url: Option<String>,
105+
color_scheme: Option<AppearanceTheme>,
104106
) -> Result<Screen<'screen>, Box<dyn Error>> {
105107
let size = window_properties.size;
106108
let scale = window_properties.scale;
@@ -210,6 +212,7 @@ impl Screen<'_> {
210212
title: config.title.clone(),
211213
keyboard: config.keyboard,
212214
scrollback_history_limit: config.scrollback_history_limit,
215+
color_scheme_is_dark: !matches!(color_scheme, Some(AppearanceTheme::Light)),
213216
};
214217

215218
let rich_text_id = next_rich_text_id();
@@ -392,6 +395,7 @@ impl Screen<'_> {
392395
config: &rio_backend::config::Config,
393396
font_library: &rio_backend::sugarloaf::font::FontLibrary,
394397
should_update_font_library: bool,
398+
color_scheme: Option<AppearanceTheme>,
395399
) {
396400
let num_tabs = self.ctx().len();
397401
let padding_y_top = padding_top_from_config(
@@ -402,15 +406,16 @@ impl Screen<'_> {
402406
);
403407
let padding_y_bottom = config.margin.bottom;
404408

409+
// Resolved OS/adaptive scheme drives the DECSET 2031 notify.
410+
let scheme_is_dark = !matches!(color_scheme, Some(AppearanceTheme::Light));
411+
let color_scheme_changed =
412+
self.context_manager.config.color_scheme_is_dark != scheme_is_dark;
413+
405414
if should_update_font_library {
406415
self.sugarloaf.update_font(font_library);
407-
// Drop the grid rasterizer's font_id-keyed caches —
408-
// the new library reuses the same ids, so stale handles
409-
// and ascents would survive the swap.
416+
// New library reuses the same font_ids, so font_id-keyed
417+
// caches must be dropped or they serve old-font data.
410418
self.grid_rasterizer.reset_font_caches();
411-
// Each panel's grid atlas caches glyph bitmaps by
412-
// (font_id, glyph_id, size); the new font reuses font_ids,
413-
// so stale slots would serve the wrong glyph. Flush them.
414419
for grid in self.grids.values_mut() {
415420
grid.clear_glyph_cache();
416421
}
@@ -466,12 +471,8 @@ impl Screen<'_> {
466471
let mut terminal = current_context.terminal.lock();
467472
current_context.renderable_content =
468473
RenderableContent::from_cursor_config(&config.cursor);
469-
// Resetting renderable_content clears term_colors and
470-
// the dirty flag. Force a full repaint per pane so every
471-
// pane (not just the focused one) picks up the new theme
472-
// — the render loop skips non-dirty panes, and
473-
// resize_all_contexts only repaints via the PTY wakeup,
474-
// which idle panes never get.
474+
// Idle panes are skipped by the render loop, so force a
475+
// full repaint or they keep the old theme.
475476
current_context
476477
.renderable_content
477478
.pending_update
@@ -480,6 +481,10 @@ impl Screen<'_> {
480481
terminal.cursor_shape = shape;
481482
terminal.default_cursor_shape = shape;
482483
terminal.blinking_cursor = config.cursor.blinking;
484+
terminal.set_color_scheme(scheme_is_dark);
485+
if color_scheme_changed {
486+
terminal.report_color_scheme(scheme_is_dark);
487+
}
483488
drop(terminal);
484489
}
485490
}
@@ -489,6 +494,9 @@ impl Screen<'_> {
489494

490495
// Update keyboard config in context manager
491496
self.context_manager.config.keyboard = config.keyboard;
497+
// Panes spawned after a theme change must start with the new
498+
// scheme so their `CSI ? 996 n` query answers correctly.
499+
self.context_manager.config.color_scheme_is_dark = scheme_is_dark;
492500

493501
// Re-evaluate the opaque flag — toggling `window.opacity` /
494502
// `window.blur` at runtime should flip the compositor mode.

rio-backend/src/ansi/mode.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ impl PrivateMode {
6969
1049 => Self::Named(NamedPrivateMode::SwapScreenAndSetRestoreCursor),
7070
2004 => Self::Named(NamedPrivateMode::BracketedPaste),
7171
2026 => Self::Named(NamedPrivateMode::SyncUpdate),
72+
2031 => Self::Named(NamedPrivateMode::ColorSchemeUpdates),
7273
_ => Self::Unknown(mode),
7374
}
7475
}
@@ -120,6 +121,9 @@ pub enum NamedPrivateMode {
120121
BracketedPaste = 2004,
121122
/// The mode is handled automatically by [`Processor`].
122123
SyncUpdate = 2026,
124+
/// App opts in to unsolicited DSR `CSI ? 997 n` light/dark
125+
/// color-scheme notifications (DECSET 2031).
126+
ColorSchemeUpdates = 2031,
123127
}
124128

125129
/// Mode for clearing line.

0 commit comments

Comments
 (0)