Support querying and detecting updates to terminal theme mode

This change adds support for dark and light mode detection by means of
a VT extension proposed[^1] by the Contour terminal. This has four
parts:

* A command to subscribe to color scheme updates
* The inverse: a command to unsubscribe
* A new event emitted when subscribed in a terminal supporting the
  extension, upon direction of that terminal that its color scheme has
  changed.
* A function under `terminal` to query the current theme mode - this
  should be used on application startup to determine the initial theme
  mode.

[^1]: https://github.com/contour-terminal/contour/blob/master/docs/vt-extensions/color-palette-update-notifications.md
This commit is contained in:
Michael Davis
2024-12-28 09:43:21 -05:00
parent a70abd5c18
commit 6f8bfa3309
7 changed files with 201 additions and 6 deletions

View File

@@ -54,6 +54,7 @@
//! #[cfg(feature = "bracketed-paste")]
//! Event::Paste(data) => println!("{:?}", data),
//! Event::Resize(width, height) => println!("New size {}x{}", width, height),
//! Event::ThemeModeChanged(mode) => println!("New theme mode {:?}", mode),
//! }
//! }
//! execute!(
@@ -100,6 +101,7 @@
//! #[cfg(feature = "bracketed-paste")]
//! Event::Paste(data) => println!("Pasted {:?}", data),
//! Event::Resize(width, height) => println!("New size {}x{}", width, height),
//! Event::ThemeModeChanged(mode) => println!("New theme mode {:?}", mode),
//! }
//! } else {
//! // Timeout expired and no `Event` is available
@@ -542,6 +544,60 @@ impl Command for PopKeyboardEnhancementFlags {
}
}
/// A command which subscribes to updates of the terminal's selected theme mode (dark/light).
///
/// See [`ThemeMode`] for more information.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EnableThemeModeUpdates;
impl Command for EnableThemeModeUpdates {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
f.write_str(csi!("?2031h"))
}
#[cfg(windows)]
fn execute_winapi(&self) -> std::io::Result<()> {
use std::io;
Err(io::Error::new(
io::ErrorKind::Unsupported,
"Theme mode updates are not implemented for the legacy Windows API",
))
}
#[cfg(windows)]
fn is_ansi_code_supported(&self) -> bool {
false
}
}
/// A command which unsubscribes to updates of the terminal's selected theme mode (dark/light).
///
/// See [ThemeMode] and [EnableThemeModeUpdates] for more information.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DisableThemeModeUpdates;
impl Command for DisableThemeModeUpdates {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
f.write_str(csi!("?2031l"))
}
#[cfg(windows)]
fn execute_winapi(&self) -> std::io::Result<()> {
use std::io;
Err(io::Error::new(
io::ErrorKind::Unsupported,
"Theme mode updates are not implemented for the legacy Windows API",
))
}
#[cfg(windows)]
fn is_ansi_code_supported(&self) -> bool {
false
}
}
/// Represents an event.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "derive-more", derive(IsVariant))]
@@ -563,6 +619,7 @@ pub enum Event {
/// An resize event with new dimensions after resize (columns, rows).
/// **Note** that resize events can occur in batches.
Resize(u16, u16),
ThemeModeChanged(ThemeMode),
}
impl Event {
@@ -1486,6 +1543,19 @@ pub(crate) enum InternalEvent {
PrimaryDeviceAttributes,
}
/// The selected color scheme of the terminal.
///
/// This information can be queried from terminals implementing Contour's [VT extension for theme
/// mode updates](https://github.com/contour-terminal/contour/blob/master/docs/vt-extensions/color-palette-update-notifications.md).
/// Applications can subscribe to updates to the theme mode with [EnableThemeModeUpdates] and
/// unsubscribe with [DisableThemeModeUpdates].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ThemeMode {
Light,
Dark,
}
#[cfg(test)]
mod tests {
use std::collections::hash_map::DefaultHasher;

View File

@@ -46,6 +46,25 @@ impl Filter for PrimaryDeviceAttributesFilter {
}
}
#[cfg(unix)]
#[derive(Debug, Clone)]
pub(crate) struct ThemeModeFilter;
#[cfg(unix)]
impl Filter for ThemeModeFilter {
fn eval(&self, event: &InternalEvent) -> bool {
use crate::event::Event;
// See `KeyboardEnhancementFlagsFilter` above: `PrimaryDeviceAttributes` is
// used to elicit a response from the terminal even if it doesn't support the
// theme mode query.
matches!(
*event,
InternalEvent::Event(Event::ThemeModeChanged(_))
| InternalEvent::PrimaryDeviceAttributes
)
}
}
#[derive(Debug, Clone)]
pub(crate) struct EventFilter;

View File

@@ -2,7 +2,7 @@ use std::io;
use crate::event::{
Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, KeyboardEnhancementFlags,
MediaKeyCode, ModifierKeyCode, MouseButton, MouseEvent, MouseEventKind,
MediaKeyCode, ModifierKeyCode, MouseButton, MouseEvent, MouseEventKind, ThemeMode,
};
use super::super::super::InternalEvent;
@@ -180,6 +180,7 @@ pub(crate) fn parse_csi(buffer: &[u8]) -> io::Result<Option<InternalEvent>> {
b'?' => match buffer[buffer.len() - 1] {
b'u' => return parse_csi_keyboard_enhancement_flags(buffer),
b'c' => return parse_csi_primary_device_attributes(buffer),
b'n' => return parse_csi_theme_mode(buffer),
_ => None,
},
b'0'..=b'9' => {
@@ -300,6 +301,32 @@ fn parse_csi_primary_device_attributes(buffer: &[u8]) -> io::Result<Option<Inter
Ok(Some(InternalEvent::PrimaryDeviceAttributes))
}
fn parse_csi_theme_mode(buffer: &[u8]) -> io::Result<Option<InternalEvent>> {
// dark mode: CSI ? 997 ; 1 n
// light mode: CSI ? 997 ; 2 n
assert!(buffer.starts_with(b"\x1B[?"));
assert!(buffer.ends_with(b"n"));
let s = std::str::from_utf8(&buffer[3..buffer.len() - 1])
.map_err(|_| could_not_parse_event_error())?;
let mut split = s.split(';');
if next_parsed::<u16>(&mut split)? != 997 {
return Ok(None);
}
let theme_mode = match next_parsed::<u8>(&mut split)? {
1 => ThemeMode::Dark,
2 => ThemeMode::Light,
_ => return Ok(None),
};
Ok(Some(InternalEvent::Event(Event::ThemeModeChanged(
theme_mode,
))))
}
fn parse_modifiers(mask: u8) -> KeyModifiers {
let modifier_mask = mask.saturating_sub(1);
let mut modifiers = KeyModifiers::empty();

View File

@@ -99,7 +99,9 @@ use crate::{csi, impl_display};
pub(crate) mod sys;
#[cfg(feature = "events")]
pub use sys::{query_keyboard_enhancement_flags, supports_keyboard_enhancement};
pub use sys::{
query_keyboard_enhancement_flags, query_terminal_theme_mode, supports_keyboard_enhancement,
};
/// Tells whether the raw mode is enabled.
///

View File

@@ -6,7 +6,9 @@ pub(crate) use self::unix::{
};
#[cfg(unix)]
#[cfg(feature = "events")]
pub use self::unix::{query_keyboard_enhancement_flags, supports_keyboard_enhancement};
pub use self::unix::{
query_keyboard_enhancement_flags, query_terminal_theme_mode, supports_keyboard_enhancement,
};
#[cfg(all(windows, test))]
pub(crate) use self::windows::temp_screen_buffer;
#[cfg(windows)]
@@ -16,7 +18,9 @@ pub(crate) use self::windows::{
};
#[cfg(windows)]
#[cfg(feature = "events")]
pub use self::windows::{query_keyboard_enhancement_flags, supports_keyboard_enhancement};
pub use self::windows::{
query_keyboard_enhancement_flags, query_terminal_theme_mode, supports_keyboard_enhancement,
};
#[cfg(windows)]
mod windows;

View File

@@ -1,7 +1,7 @@
//! UNIX related logic for terminal manipulation.
#[cfg(feature = "events")]
use crate::event::KeyboardEnhancementFlags;
use crate::event::{KeyboardEnhancementFlags, ThemeMode};
use crate::terminal::{
sys::file_descriptor::{tty_fd, FileDesc},
WindowSize,
@@ -180,6 +180,71 @@ fn set_terminal_attr(fd: impl AsFd, termios: &Termios) -> io::Result<()> {
Ok(())
}
/// Queries the currently selected theme mode (dark/light) from the terminal.
///
/// On unix systems, this function will block and possibly time out while
/// [`crossterm::event::read`](crate::event::read) or [`crossterm::event::poll`](crate::event::poll) are being called.
#[cfg(feature = "events")]
pub fn query_terminal_theme_mode() -> io::Result<Option<ThemeMode>> {
if is_raw_mode_enabled() {
query_terminal_theme_mode_raw()
} else {
query_terminal_theme_mode_nonraw()
}
}
#[cfg(feature = "events")]
fn query_terminal_theme_mode_nonraw() -> io::Result<Option<ThemeMode>> {
enable_raw_mode()?;
let theme_mode = query_terminal_theme_mode_raw();
disable_raw_mode()?;
theme_mode
}
#[cfg(feature = "events")]
fn query_terminal_theme_mode_raw() -> io::Result<Option<ThemeMode>> {
use crate::event::{
filter::{PrimaryDeviceAttributesFilter, ThemeModeFilter},
poll_internal, read_internal, Event, InternalEvent,
};
use std::io::Write;
use std::time::Duration;
// ESC [ ? 996 n Query current terminal theme mode
// ESC [ c Query primary device attributes (widely supported)
const QUERY: &[u8] = b"\x1B[?996n\x1B[c";
let result = File::open("/dev/tty").and_then(|mut file| {
file.write_all(QUERY)?;
file.flush()
});
if result.is_err() {
let mut stdout = io::stdout();
stdout.write_all(QUERY)?;
stdout.flush()?;
}
loop {
match poll_internal(Some(Duration::from_millis(2000)), &ThemeModeFilter) {
Ok(true) => match read_internal(&ThemeModeFilter) {
Ok(InternalEvent::Event(Event::ThemeModeChanged(theme_mode))) => {
// Flush the PrimaryDeviceAttributes out of the event queue.
read_internal(&PrimaryDeviceAttributesFilter).ok();
return Ok(Some(theme_mode));
}
_ => return Ok(None),
},
Ok(false) => {
return Err(io::Error::new(
io::ErrorKind::Other,
"The theme mode could not be read in a normal duration",
));
}
Err(_) => {}
}
}
}
/// Queries the terminal's support for progressive keyboard enhancement.
///
/// On unix systems, this function will block and possibly time out while

View File

@@ -10,7 +10,7 @@ use winapi::{
};
#[cfg(feature = "events")]
use crate::event::KeyboardEnhancementFlags;
use crate::event::{KeyboardEnhancementFlags, ThemeMode};
use crate::{
cursor,
terminal::{ClearType, WindowSize},
@@ -86,6 +86,14 @@ pub fn query_keyboard_enhancement_flags() -> io::Result<Option<KeyboardEnhanceme
Ok(None)
}
/// Queries the currently selected theme mode (dark/light) from the terminal.
///
/// This always returns `Ok(None)` on Windows.
#[cfg(feature = "events")]
pub fn query_terminal_theme_mode() -> io::Result<Option<ThemeMode>> {
Ok(None)
}
pub(crate) fn clear(clear_type: ClearType) -> std::io::Result<()> {
let screen_buffer = ScreenBuffer::current()?;
let csbi = screen_buffer.info()?;