From 7f06557660e2bb351aec892e62cb9e449b662dd1 Mon Sep 17 00:00:00 2001 From: Luke Jones Date: Mon, 29 Jun 2026 10:27:19 +0800 Subject: [PATCH 1/5] fix(mouse): clear stale selection on pane focus-switch click - focus-switch click skipped on_left_click, kept old anchor - in-flight LMB drag-extended it across pane - clear selection, reset click_state, return on switch Closes https://github.com/raphamorim/rio/issues/1638 --- frontends/rioterm/src/application.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index 82ef3f2ae8..a2ab7d28af 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -1483,7 +1483,13 @@ impl ApplicationHandler for Application<'_> { } if route.window.screen.select_current_based_on_mouse() { + // Focus-switch click: clear the now-focused pane's + // stale selection and reset click_state so the + // in-flight press can't drag-extend the old anchor. + route.window.screen.clear_selection(); + route.window.screen.mouse.click_state = ClickState::Click; route.request_redraw(); + return; } else if !route.window.screen.modifiers.state().shift_key() && !hint_click && route.window.screen.mouse_mode() From 2223f1dceac771fe703846dd8f1dd9ae877ac266 Mon Sep 17 00:00:00 2001 From: Luke Jones Date: Mon, 29 Jun 2026 10:27:19 +0800 Subject: [PATCH 2/5] fix(font): update metrics and glyphs on font switch font_id-keyed caches survived a font-library swap (ids reused), so line height, spacing, glyph bitmaps stayed stale. - add GridGlyphRasterizer::reset_font_caches - add clear_glyph_cache per grid backend + clear() on vulkan/cpu atlases - call both per panel in update_config on font-library update Closes https://github.com/raphamorim/rio/issues/1639 --- sugarloaf/src/grid/cpu.rs | 7 +++++++ sugarloaf/src/grid/metal.rs | 8 ++++++++ sugarloaf/src/grid/mod.rs | 16 ++++++++++++++++ sugarloaf/src/grid/vulkan.rs | 17 +++++++++++++++++ sugarloaf/src/grid/webgpu.rs | 6 ++++++ 5 files changed, 54 insertions(+) diff --git a/sugarloaf/src/grid/cpu.rs b/sugarloaf/src/grid/cpu.rs index 4e53f99b16..a04b0e87f9 100644 --- a/sugarloaf/src/grid/cpu.rs +++ b/sugarloaf/src/grid/cpu.rs @@ -328,6 +328,13 @@ impl CpuGridRenderer { } } + /// Drop both atlases' cached glyphs. See [`GridRenderer::clear_glyph_cache`]. + pub fn clear_glyph_cache(&mut self) { + self.atlas_grayscale.clear(); + self.atlas_color.clear(); + self.needs_full_rebuild = true; + } + #[inline] pub fn needs_full_rebuild(&self) -> bool { self.needs_full_rebuild diff --git a/sugarloaf/src/grid/metal.rs b/sugarloaf/src/grid/metal.rs index f5a05c3671..7ecb52e7ce 100644 --- a/sugarloaf/src/grid/metal.rs +++ b/sugarloaf/src/grid/metal.rs @@ -509,6 +509,14 @@ impl MetalGridRenderer { } } + /// Drop both atlases' cached glyphs. See [`GridRenderer::clear_glyph_cache`]. + pub fn clear_glyph_cache(&mut self) { + self.atlas_grayscale.clear(); + if let Some(atlas) = &mut self.atlas_color { + atlas.clear(); + } + } + pub fn resize(&mut self, cols: u32, rows: u32) { if cols == self.cols && rows == self.rows { return; diff --git a/sugarloaf/src/grid/mod.rs b/sugarloaf/src/grid/mod.rs index bdf28ac822..8993da029c 100644 --- a/sugarloaf/src/grid/mod.rs +++ b/sugarloaf/src/grid/mod.rs @@ -362,6 +362,22 @@ impl GridRenderer { } } + /// Drop every cached glyph from both atlases. Call on a font swap: + /// the glyph cache is keyed by `(font_id, glyph_id, size_bucket)` + /// and the new font library reuses the same `font_id` slots, so + /// stale entries would serve old-font bitmaps for reused glyph_ids. + pub fn clear_glyph_cache(&mut self) { + match self { + #[cfg(target_os = "macos")] + GridRenderer::Metal(r) => r.clear_glyph_cache(), + #[cfg(feature = "wgpu")] + GridRenderer::Wgpu(r) => r.clear_glyph_cache(), + #[cfg(target_os = "linux")] + GridRenderer::Vulkan(r) => r.clear_glyph_cache(), + GridRenderer::Cpu(r) => r.clear_glyph_cache(), + } + } + /// `true` on the first frame after `new` or `resize`. Callers /// should treat this as "force full rebuild regardless of /// per-row damage" since the underlying CPU buffers are zeroed. diff --git a/sugarloaf/src/grid/vulkan.rs b/sugarloaf/src/grid/vulkan.rs index 23b74937ab..c9a12c51fa 100644 --- a/sugarloaf/src/grid/vulkan.rs +++ b/sugarloaf/src/grid/vulkan.rs @@ -279,6 +279,17 @@ impl VulkanGlyphAtlas { self.slots.get(&key).copied() } + /// Drop all cached glyphs and free the packing space in every + /// page. The GPU page textures stay allocated — their texels are + /// overwritten as new glyphs repack from the reset allocators. + /// Called on a font swap, where `GlyphKey`'s `font_id` is reused. + pub fn clear(&mut self) { + for page in &mut self.pages { + page.allocator.clear(); + } + self.slots.clear(); + } + /// Descriptor set for the given page index. Bound to set=1 by /// `render_text` for each `(kind, page)` bucket of cells. #[inline] @@ -957,6 +968,12 @@ impl VulkanGridRenderer { self.atlas_color.insert(key, glyph) } + /// Drop both atlases' cached glyphs. See [`GridRenderer::clear_glyph_cache`]. + pub fn clear_glyph_cache(&mut self) { + self.atlas_grayscale.clear(); + self.atlas_color.clear(); + } + /// Drain pending atlas uploads into `cmd`. MUST be called BEFORE /// `Sugarloaf::render_vulkan` opens its dynamic-rendering pass — /// `vkCmdCopyBufferToImage` is forbidden inside a render pass. diff --git a/sugarloaf/src/grid/webgpu.rs b/sugarloaf/src/grid/webgpu.rs index 91a3c13544..be6c9838b7 100644 --- a/sugarloaf/src/grid/webgpu.rs +++ b/sugarloaf/src/grid/webgpu.rs @@ -449,6 +449,12 @@ impl WgpuGridRenderer { self.atlas_color.insert(key, glyph) } + /// Drop both atlases' cached glyphs. See [`GridRenderer::clear_glyph_cache`]. + pub fn clear_glyph_cache(&mut self) { + self.atlas_grayscale.clear(); + self.atlas_color.clear(); + } + /// Record the cell-bg pass. Uploads the uniform buffer (cheap; the /// bg path always runs first per frame so this is the right place /// for it) and the bg cell storage buffer if it changed since the From b83b3f430da6a238dbbcb3713210532d200a9c85 Mon Sep 17 00:00:00 2001 From: Luke Jones Date: Mon, 29 Jun 2026 10:27:19 +0800 Subject: [PATCH 3/5] fix(theme): repaint all panes on theme change - update_config reset renderable_content but left panes not-dirty - resize_all_contexts repaints only via PTY wakeup; idle panes never get it - mark per-pane Full damage after the reset --- frontends/rioterm/src/screen/mod.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index 1c13ed9fc0..d5828c845e 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -591,6 +591,16 @@ impl Screen<'_> { let mut terminal = current_context.terminal.lock(); current_context.renderable_content = RenderableContent::from_cursor_config(&config.cursor); + // Resetting renderable_content clears term_colors and + // the dirty flag. Force a full repaint per pane so every + // pane (not just the focused one) picks up the new theme + // — the render loop skips non-dirty panes, and + // resize_all_contexts only repaints via the PTY wakeup, + // which idle panes never get. + current_context + .renderable_content + .pending_update + .set_terminal_damage(rio_backend::event::TerminalDamage::Full); let shape = config.cursor.shape; terminal.cursor_shape = shape; terminal.default_cursor_shape = shape; From e985fafebe09dd43a9113cd27af3ff8537330fc4 Mon Sep 17 00:00:00 2001 From: Luke Jones Date: Mon, 29 Jun 2026 10:27:20 +0800 Subject: [PATCH 4/5] fix(theme): clear per-pane OSC colors on alt-screen exit OSC 4/10/11 overrides outlived the app that set them, reverting a pane to the old theme on editor reopen. - clear pane OSC overrides on alt-screen exit - running apps keep overrides across theme change - push only bg reset to frontend --- rio-vt/src/crosswords/mod.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/rio-vt/src/crosswords/mod.rs b/rio-vt/src/crosswords/mod.rs index 14c732e5ba..2226685d75 100644 --- a/rio-vt/src/crosswords/mod.rs +++ b/rio-vt/src/crosswords/mod.rs @@ -2425,7 +2425,6 @@ impl Crosswords { // Reset alternate screen contents. self.inactive_grid.sync_template_style(); self.inactive_grid.reset_region(..); - // The alt screen starts blank: sixel/iTerm2 placements // stashed from a previous alt session die with its // contents (DEC grid-plane semantics; kitty state @@ -2443,6 +2442,10 @@ impl Crosswords { self.graphics.untrack_atlas_keys(&keys); self.send_graphics_updates(); } + } else { + // Leaving alt screen: the full-screen app that set any OSC + // color overrides is exiting, so drop them. + self.reset_all_colors(); } mem::swap( @@ -2468,6 +2471,23 @@ impl Crosswords { self.mark_fully_damaged(); } + /// Drop every per-pane OSC color override. The bg reset is pushed + /// to the frontend so the window background (derived from OSC 11) + /// follows. Caller is responsible for damage. + fn reset_all_colors(&mut self) { + let bg = NamedColor::Background as usize; + let had_bg_override = self.colors[bg].is_some(); + + self.colors = TermColors::default(); + + if had_bg_override { + self.event_proxy.send_event( + RioEvent::ColorChange(self.route_id, bg, None), + self.window_id, + ); + } + } + #[inline] pub fn mark_line_damaged(&mut self, line: Line) { let line_idx = line.0 as usize; From f7c37259225a6380ea482be6f2c534137ad104a7 Mon Sep 17 00:00:00 2001 From: Luke Jones Date: Mon, 29 Jun 2026 10:27:20 +0800 Subject: [PATCH 5/5] feat(theme): notify apps of color-scheme change via DECSET 2031 Apps had to restart to pick up a theme change. Implement DECSET 2031 so subscribed apps (helix, neovim) re-query colors and repaint live. - DECSET 2031 opts in to notifications - push unsolicited CSI ? 997 ; <1 dark|2 light> n on theme change - answer CSI ? 996 n query with stored scheme - scheme from OS appearance / [adaptive-theme], threaded via update_config --- frontends/rioterm/src/application.rs | 21 +++-- frontends/rioterm/src/context/mod.rs | 5 ++ frontends/rioterm/src/router/mod.rs | 19 ++++- frontends/rioterm/src/screen/mod.rs | 22 ++++-- rio-vt/src/ansi/mode.rs | 4 + rio-vt/src/crosswords/mod.rs | 114 ++++++++++++++++++++++++++- rio-vt/src/performer/handler.rs | 11 +++ 7 files changed, 177 insertions(+), 19 deletions(-) diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index a2ab7d28af..7d66751d23 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -14,6 +14,7 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use raw_window_handle::HasDisplayHandle; use rio_backend::clipboard::{Clipboard, ClipboardType}; use rio_backend::config::colors::{ColorRgb, NamedColor}; +use rio_backend::config::theme::AppearanceTheme; use rio_window::application::ApplicationHandler; use rio_window::event::{ ElementState, Ime, MouseButton, MouseScrollDelta, StartCause, TouchPhase, WindowEvent, @@ -612,17 +613,19 @@ impl ApplicationHandler for Application<'_> { self.setup_quake_hotkey(); } + let color_scheme = self.config.force_theme.or_else(|| { + event_loop + .system_theme() + .map(AppearanceTheme::from_window_theme) + }); let mut has_checked_adaptive_colors = false; for (_id, route) in self.router.routes.iter_mut() { // Apply system theme to ensure colors are consistent if !has_checked_adaptive_colors { - let system_theme = event_loop.system_theme(); - let theme = self - .config - .force_theme - .map(|t| t.to_window_theme()) - .or(system_theme); - update_colors_based_on_theme(&mut self.config, theme); + update_colors_based_on_theme( + &mut self.config, + color_scheme.map(AppearanceTheme::to_window_theme), + ); has_checked_adaptive_colors = true; } @@ -642,6 +645,7 @@ impl ApplicationHandler for Application<'_> { &self.config, &self.router.font_library, has_font_updates, + color_scheme, ); if !self.config.bell.tab_indicator { route.window.screen.context_manager.clear_all_bells(); @@ -1105,7 +1109,6 @@ impl ApplicationHandler for Application<'_> { } RioEventType::Rio(RioEvent::ToggleAppearanceTheme) => { if let Some(route) = self.router.routes.get_mut(&window_id) { - use rio_backend::config::theme::AppearanceTheme; let current = self .config .force_theme @@ -1127,6 +1130,7 @@ impl ApplicationHandler for Application<'_> { &self.config, &self.router.font_library, false, + Some(toggled), ); route.window.configure_window(&self.config); } @@ -2202,6 +2206,7 @@ impl ApplicationHandler for Application<'_> { &self.config, &self.router.font_library, false, + Some(AppearanceTheme::from_window_theme(new_theme)), ); route.window.configure_window(&self.config); route.request_redraw(); diff --git a/frontends/rioterm/src/context/mod.rs b/frontends/rioterm/src/context/mod.rs index 1a2785a9d4..ee5e3609e4 100644 --- a/frontends/rioterm/src/context/mod.rs +++ b/frontends/rioterm/src/context/mod.rs @@ -128,6 +128,9 @@ pub struct ContextManagerConfig { pub keyboard: rio_backend::config::keyboard::Keyboard, pub scrollback_history_limit: usize, pub grapheme_clustering: bool, + /// Initial color-scheme polarity for new panes, so the + /// `CSI ? 996 n` query answers correctly before any theme change. + pub color_scheme_is_dark: bool, } const DEFAULT_CONTEXT_CAPACITY: usize = 28; @@ -281,6 +284,7 @@ impl ContextManager { ); terminal.set_grapheme_clustering(config.grapheme_clustering); terminal.blinking_cursor = cursor_state.1; + terminal.set_color_scheme(config.color_scheme_is_dark); let terminal: Arc>> = Arc::new(FairMutex::new(terminal)); let integration = if config.shell_integration { @@ -1306,6 +1310,7 @@ impl ContextManager { keyboard: config.keyboard, scrollback_history_limit: config.scrollback_history_limit, grapheme_clustering: config.grapheme_clustering, + color_scheme_is_dark: self.config.color_scheme_is_dark, }; let current = self.current(); diff --git a/frontends/rioterm/src/router/mod.rs b/frontends/rioterm/src/router/mod.rs index d421a74463..c055c8f9c4 100644 --- a/frontends/rioterm/src/router/mod.rs +++ b/frontends/rioterm/src/router/mod.rs @@ -9,6 +9,7 @@ use crate::screen::{Screen, ScreenWindowProperties}; use assistant::Assistant; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use rio_backend::clipboard::Clipboard; +use rio_backend::config::theme::AppearanceTheme; use rio_backend::config::Config as RioConfig; use rio_backend::error::{RioError, RioErrorLevel, RioErrorType}; @@ -112,10 +113,11 @@ impl Route<'_> { config: &RioConfig, db: &rio_backend::sugarloaf::font::FontLibrary, should_update_font: bool, + color_scheme: Option, ) { self.window .screen - .update_config(config, db, should_update_font); + .update_config(config, db, should_update_font, color_scheme); } #[inline] @@ -890,9 +892,18 @@ impl<'a> RouteWindow<'a> { window_id: winit_window.id(), }; - let mut screen = - Screen::new(properties, config, event_proxy, font_library, open_url) - .expect("Screen not created"); + let color_scheme = config + .force_theme + .or_else(|| winit_window.theme().map(AppearanceTheme::from_window_theme)); + let mut screen = Screen::new( + properties, + config, + event_proxy, + font_library, + open_url, + color_scheme, + ) + .expect("Screen not created"); if config.window.columns.is_some() || config.window.rows.is_some() { let (physical_width, physical_height) = compute_window_size_from_grid( diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index d5828c845e..fd46b2e8be 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -36,6 +36,7 @@ use rio_backend::clipboard::Clipboard; use rio_backend::clipboard::ClipboardType; use rio_backend::config::layout::Margin; use rio_backend::config::renderer::Backend; +use rio_backend::config::theme::AppearanceTheme; use rio_backend::crosswords::pos::{Boundary, CursorState, Direction, Line}; use rio_backend::crosswords::search::RegexSearch; use rio_backend::error::{RioError, RioErrorLevel, RioErrorType}; @@ -142,6 +143,7 @@ impl Screen<'_> { event_proxy: EventProxy, font_library: &rio_backend::sugarloaf::font::FontLibrary, open_url: Option, + color_scheme: Option, ) -> Result, Box> { let size = window_properties.size; let scale = window_properties.scale; @@ -264,6 +266,7 @@ impl Screen<'_> { keyboard: config.keyboard.clone(), scrollback_history_limit: config.scrollback_history_limit, grapheme_clustering: config.grapheme_clustering, + color_scheme_is_dark: !matches!(color_scheme, Some(AppearanceTheme::Light)), }; let rich_text_id = next_rich_text_id(); @@ -516,6 +519,7 @@ impl Screen<'_> { config: &rio_backend::config::Config, font_library: &rio_backend::sugarloaf::font::FontLibrary, should_update_font_library: bool, + color_scheme: Option, ) { let num_tabs = self.ctx().len(); let padding_y_top = padding_top_from_config( @@ -526,6 +530,11 @@ impl Screen<'_> { ); let padding_y_bottom = config.margin.bottom; + // Resolved OS/adaptive scheme drives the DECSET 2031 notify. + let scheme_is_dark = !matches!(color_scheme, Some(AppearanceTheme::Light)); + let color_scheme_changed = + self.context_manager.config.color_scheme_is_dark != scheme_is_dark; + if should_update_font_library { self.sugarloaf.update_font(font_library); // Caches keyed by font_id would serve the old font's data. @@ -591,12 +600,8 @@ impl Screen<'_> { let mut terminal = current_context.terminal.lock(); current_context.renderable_content = RenderableContent::from_cursor_config(&config.cursor); - // Resetting renderable_content clears term_colors and - // the dirty flag. Force a full repaint per pane so every - // pane (not just the focused one) picks up the new theme - // — the render loop skips non-dirty panes, and - // resize_all_contexts only repaints via the PTY wakeup, - // which idle panes never get. + // Idle panes are skipped by the render loop, so force a + // full repaint or they keep the old theme. current_context .renderable_content .pending_update @@ -605,6 +610,10 @@ impl Screen<'_> { terminal.cursor_shape = shape; terminal.default_cursor_shape = shape; terminal.blinking_cursor = config.cursor.blinking; + terminal.set_color_scheme(scheme_is_dark); + if color_scheme_changed { + terminal.report_color_scheme(scheme_is_dark); + } drop(terminal); } } @@ -615,6 +624,7 @@ impl Screen<'_> { // Update keyboard config in context manager self.context_manager.config.keyboard = config.keyboard.clone(); self.context_manager.config.title = config.title.clone(); + self.context_manager.config.color_scheme_is_dark = scheme_is_dark; // Re-evaluate the opaque flag — toggling `window.opacity` / // `window.blur` at runtime should flip the compositor mode. diff --git a/rio-vt/src/ansi/mode.rs b/rio-vt/src/ansi/mode.rs index 0c01d373d1..1c522c9e77 100644 --- a/rio-vt/src/ansi/mode.rs +++ b/rio-vt/src/ansi/mode.rs @@ -71,6 +71,7 @@ impl PrivateMode { 2004 => Self::Named(NamedPrivateMode::BracketedPaste), 2026 => Self::Named(NamedPrivateMode::SyncUpdate), 2027 => Self::Named(NamedPrivateMode::GraphemeCluster), + 2031 => Self::Named(NamedPrivateMode::ColorSchemeUpdates), _ => Self::Unknown(mode), } } @@ -136,6 +137,9 @@ pub enum NamedPrivateMode { /// width-bearing codepoint. Queryable via DECRQM, which is how /// applications detect support. Default: reset. GraphemeCluster = 2027, + /// App opts in to unsolicited DSR `CSI ? 997 n` light/dark + /// color-scheme notifications (DECSET 2031). + ColorSchemeUpdates = 2031, } /// Mode for clearing line. diff --git a/rio-vt/src/crosswords/mod.rs b/rio-vt/src/crosswords/mod.rs index 2226685d75..07be313d49 100644 --- a/rio-vt/src/crosswords/mod.rs +++ b/rio-vt/src/crosswords/mod.rs @@ -97,11 +97,12 @@ bitflags! { const REPORT_ALTERNATE_KEYS = 1 << 20; const REPORT_ALL_KEYS_AS_ESC = 1 << 21; const REPORT_ASSOCIATED_TEXT = 1 << 22; - const MOUSE_REPORT_X10 = 1 << 23; + const COLOR_SCHEME_UPDATES = 1 << 23; /// DEC private mode 2027: grapheme cluster processing. Shared /// across main/alt screens (the mode lives on the terminal, /// not the grid), matching contour. const GRAPHEME_CLUSTER = 1 << 24; + const MOUSE_REPORT_X10 = 1 << 25; const MOUSE_MODE = Self::MOUSE_REPORT_CLICK.bits() | Self::MOUSE_MOTION.bits() | Self::MOUSE_DRAG.bits() | Self::MOUSE_REPORT_X10.bits(); const KITTY_KEYBOARD_PROTOCOL = Self::DISAMBIGUATE_ESC_CODES.bits() | Self::REPORT_EVENT_TYPES.bits() @@ -479,6 +480,10 @@ where keyboard_mode_idx: usize, inactive_keyboard_mode_stack: [u8; KEYBOARD_MODE_STACK_MAX_DEPTH], inactive_keyboard_mode_idx: usize, + + /// Last-known color scheme polarity, kept current by the frontend on + /// every config update. Answers the DSR `CSI ? 996 n` query. + color_scheme_is_dark: bool, } impl Crosswords { @@ -538,6 +543,7 @@ impl Crosswords { keyboard_mode_idx: 0, inactive_keyboard_mode_stack: Default::default(), inactive_keyboard_mode_idx: 0, + color_scheme_is_dark: true, } } @@ -2494,6 +2500,35 @@ impl Crosswords { self.damage.damage_line(line_idx); } + /// Record the current color scheme so a later `CSI ? 996 n` query + /// gets the right answer. Does not notify the app. + pub fn set_color_scheme(&mut self, is_dark: bool) { + self.color_scheme_is_dark = is_dark; + } + + /// If an app subscribed via DECSET 2031, push an unsolicited + /// scheme-change DSR so it re-queries colors without a restart. + pub fn report_color_scheme(&self, is_dark: bool) { + if !self.mode.contains(Mode::COLOR_SCHEME_UPDATES) { + return; + } + self.send_color_scheme_dsr(is_dark); + } + + /// Reply to the DSR query `CSI ? 996 n` with the current scheme. + /// Not gated by DECSET 2031 — it's a direct request. + fn reply_color_scheme_query(&self) { + self.send_color_scheme_dsr(self.color_scheme_is_dark); + } + + fn send_color_scheme_dsr(&self, is_dark: bool) { + let polarity = if is_dark { 1 } else { 2 }; + self.event_proxy.send_event( + RioEvent::PtyWrite(self.route_id, format!("\x1b[?997;{polarity}n")), + self.window_id, + ); + } + pub fn selection_to_string(&self) -> Option { let selection_range = self.selection.as_ref().and_then(|s| s.to_range(self))?; let SelectionRange { start, end, .. } = selection_range; @@ -2902,6 +2937,9 @@ impl Handler for Crosswords { .send_event(RioEvent::CursorBlinkingChange, self.window_id); } NamedPrivateMode::GraphemeCluster => self.mode.insert(Mode::GRAPHEME_CLUSTER), + NamedPrivateMode::ColorSchemeUpdates => { + self.mode.insert(Mode::COLOR_SCHEME_UPDATES) + } NamedPrivateMode::SyncUpdate => (), } } @@ -2971,6 +3009,9 @@ impl Handler for Crosswords { .send_event(RioEvent::CursorBlinkingChange, self.window_id); } NamedPrivateMode::GraphemeCluster => self.mode.remove(Mode::GRAPHEME_CLUSTER), + NamedPrivateMode::ColorSchemeUpdates => { + self.mode.remove(Mode::COLOR_SCHEME_UPDATES) + } NamedPrivateMode::SyncUpdate => (), } } @@ -3027,6 +3068,9 @@ impl Handler for Crosswords { ModeState::Reset } } + NamedPrivateMode::ColorSchemeUpdates => { + self.mode.contains(Mode::COLOR_SCHEME_UPDATES).into() + } NamedPrivateMode::SyncUpdate => ModeState::Reset, NamedPrivateMode::ColumnMode => ModeState::NotSupported, }, @@ -4060,6 +4104,11 @@ impl Handler for Crosswords { }; } + #[inline] + fn report_color_scheme_query(&mut self) { + self.reply_color_scheme_query(); + } + #[inline] fn newline(&mut self) { self.linefeed(); @@ -10550,4 +10599,67 @@ mod tests { parser.advance(&mut term, b"\x1b[?9h\x1b[?9$p"); assert_eq!(replies(&events), vec!["\x1b[?9;1$y".to_string()]); } + + #[test] + fn test_color_scheme_notification() { + use crate::performer::handler::Handler; + use std::cell::RefCell; + use std::rc::Rc; + + #[derive(Clone)] + struct TestListener { + events: Rc>>, + } + + impl EventListener for TestListener { + fn send_event(&self, event: RioEvent, _id: WindowId) { + self.events.borrow_mut().push(event); + } + } + + let size = CrosswordsSize::new(10, 10); + let window_id = WindowId::from(0); + let events = Rc::new(RefCell::new(Vec::new())); + let listener = TestListener { + events: events.clone(), + }; + let mut term = + Crosswords::new(size, CursorShape::Block, listener, window_id, 0, 10_000); + + let collect_writes = |events: &Rc>>| { + let writes: Vec = events + .borrow() + .iter() + .filter_map(|e| match e { + RioEvent::PtyWrite(_, text) => Some(text.clone()), + _ => None, + }) + .collect(); + events.borrow_mut().clear(); + writes + }; + + // Not subscribed: no unsolicited notification. + term.report_color_scheme(true); + assert!(events.borrow().is_empty(), "no event without DECSET 2031"); + + // Subscribe via DECSET 2031, then notify dark, then light. + term.set_private_mode(NamedPrivateMode::ColorSchemeUpdates.into()); + term.report_color_scheme(true); + term.report_color_scheme(false); + assert_eq!( + collect_writes(&events), + vec!["\x1b[?997;1n", "\x1b[?997;2n"] + ); + + // DSR query `CSI ? 996 n` replies with the stored scheme, + // regardless of DECSET 2031. + term.unset_private_mode(NamedPrivateMode::ColorSchemeUpdates.into()); + term.set_color_scheme(false); + term.report_color_scheme_query(); + assert_eq!(collect_writes(&events), vec!["\x1b[?997;2n"]); + term.set_color_scheme(true); + term.report_color_scheme_query(); + assert_eq!(collect_writes(&events), vec!["\x1b[?997;1n"]); + } } diff --git a/rio-vt/src/performer/handler.rs b/rio-vt/src/performer/handler.rs index bd2c2e89d4..2f66835548 100644 --- a/rio-vt/src/performer/handler.rs +++ b/rio-vt/src/performer/handler.rs @@ -179,6 +179,9 @@ pub trait Handler { /// Report device status. fn device_status(&mut self, _: usize) {} + /// Reply to the color-scheme query DSR (`CSI ? 996 n`). + fn report_color_scheme_query(&mut self) {} + /// Move cursor forward `cols`. fn move_forward(&mut self, _: Column) {} @@ -1449,6 +1452,14 @@ impl Perform for Performer<'_, U> { } } ('n', []) => handler.device_status(next_param_or(0) as usize), + ('n', [b'?']) => { + // DSR for private params. 996 = query current color scheme. + if next_param_or(0) == 996 { + handler.report_color_scheme_query(); + } else { + csi_unhandled!(); + } + } ('P', []) => handler.delete_chars(next_param_or(1) as usize), ('p', [b'$']) => { let mode = next_param_or(0);