use std::{ collections::VecDeque, io::{self, Write}, time::{Duration, Instant}, }; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyEventState, KeyModifiers}; const QUERY_TIMEOUT: Duration = Duration::from_millis(250); // TODO: Remove periodic polling once a released Herdr fix reliably delivers palette-ready // DEC 2031 notifications, including changes with unchanged light/dark preference. // Verify notification-triggered queries return fresh colors before removing this fallback. // Upstream: https://github.com/herdrdev/herdr/issues/4183 const REFRESH_INTERVAL: Duration = Duration::from_secs(1); const ESCAPE_TIMEOUT: Duration = Duration::from_millis(25); const HEADERS: [&[u8]; 3] = [b"11;", b"4;1;", b"4;2;"]; const MAX_BODY: usize = 32; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ThemeColors { pub background: [u8; 3], pub red: [u8; 3], pub green: [u8; 3], } struct Query { deadline: Instant, colors: [Option<[u8; 3]>; 3], } #[derive(Default)] enum Capture { #[default] Idle, Escape(Instant), Report(Instant), Terminator, } /// Adapts crossterm's key-shaped OSC replies without reading from stdin itself. /// Only complete color reports are consumed; rejected candidates retain their /// original events, including modifiers, kind, and state. #[derive(Default)] pub(crate) struct TerminalColors { pub colors: Option, query: Option, refresh_at: Option, capture: Capture, original: Vec, body: [u8; MAX_BODY], len: usize, ready: VecDeque, } impl TerminalColors { pub fn refresh_due(&self, now: Instant) -> bool { self.query.is_none() && self.refresh_at.is_none_or(|deadline| now >= deadline) } pub fn query(&mut self, output: &mut impl Write, now: Instant) -> io::Result<()> { self.expire(now); self.refresh_at = Some(now + REFRESH_INTERVAL); self.query = None; if let Err(error) = output .write_all(b"\x1b]11;?\x1b\\\x1b]4;1;?;2;?\x1b\\") .and_then(|()| output.flush()) { self.colors = None; return Err(error); } self.query = Some(Query { deadline: now + QUERY_TIMEOUT, colors: [None; 3], }); Ok(()) } pub fn push(&mut self, event: Event, now: Instant) { self.expire(now); self.accept(event, now); } pub fn expire(&mut self, now: Instant) { if self .query .as_ref() .is_some_and(|query| now >= query.deadline) { self.query = None; self.colors = None; } let ambiguous_expired = match self.capture { Capture::Escape(deadline) => now >= deadline, Capture::Report(deadline) => { now >= deadline && !HEADERS .iter() .any(|header| self.body[..self.len].starts_with(header)) } _ => false, }; if ambiguous_expired { self.replay(); } // Retain a bounded, recognized OSC candidate across query expiry: a // late remainder must still be swallowed, not inserted into the editor. // Neither late replies nor partial triples can update the palette. } pub fn pop(&mut self) -> Option { self.ready.pop_front() } fn accept(&mut self, event: Event, now: Instant) { let token = token(&event); match self.capture { Capture::Idle => match token { Token::Start => { self.original.push(event); self.capture = Capture::Report(now + QUERY_TIMEOUT); } Token::Escape => { self.original.push(event); self.capture = Capture::Escape(now + ESCAPE_TIMEOUT); } _ => self.ready.push_back(event), }, Capture::Escape(_) if matches!(token, Token::Byte(b']')) => { self.original.push(event); self.capture = Capture::Report(now + QUERY_TIMEOUT); } Capture::Report(_) if matches!(token, Token::Escape) => { self.original.push(event); self.capture = Capture::Terminator; } Capture::Report(_) if matches!(token, Token::End) => { self.original.push(event); self.finish(); } Capture::Terminator if matches!(token, Token::Byte(b'\\')) => { self.original.push(event); self.finish(); } Capture::Report(_) => { if let Token::Byte(byte) = token && self.len < MAX_BODY { self.body[self.len] = byte; if report_prefix(&self.body[..self.len + 1]) { self.len += 1; self.original.push(event); return; } } self.replay(); self.accept(event, now); } _ => { self.replay(); self.accept(event, now); } } } fn finish(&mut self) { let Some((slot, color)) = parse_report(&self.body[..self.len]) else { self.replay(); return; }; self.original.clear(); self.len = 0; self.capture = Capture::Idle; let Some(query) = self.query.as_mut() else { return; }; query.colors[slot] = Some(color); if let [Some(background), Some(red), Some(green)] = query.colors { self.colors = Some(ThemeColors { background, red, green, }); self.query = None; } } fn replay(&mut self) { self.ready.extend(self.original.drain(..)); self.len = 0; self.capture = Capture::Idle; } } #[derive(Clone, Copy)] enum Token { Start, End, Escape, Byte(u8), Other, } fn token(event: &Event) -> Token { let Event::Key(key) = event else { return Token::Other; }; if key.kind != KeyEventKind::Press || key.state != KeyEventState::NONE { return Token::Other; } match (key.code, key.modifiers) { (KeyCode::Char(']'), KeyModifiers::ALT) => Token::Start, (KeyCode::Char('\\'), KeyModifiers::ALT) | (KeyCode::Char('g'), KeyModifiers::CONTROL) => { Token::End } (KeyCode::Esc, KeyModifiers::NONE) => Token::Escape, (KeyCode::Char(ch), modifiers) if ch.is_ascii() && (modifiers == KeyModifiers::NONE || (ch.is_ascii_uppercase() && modifiers == KeyModifiers::SHIFT)) => { Token::Byte(ch as u8) } _ => Token::Other, } } fn report_prefix(body: &[u8]) -> bool { HEADERS.iter().any(|header| { header.starts_with(body) || body.strip_prefix(*header).is_some_and(color_prefix) }) } fn color_prefix(color: &[u8]) -> bool { if b"rgb:".starts_with(color) { return true; } if let Some(hex) = color.strip_prefix(b"#") { return hex.len() <= 6 && hex.iter().all(u8::is_ascii_hexdigit); } let Some(rgb) = color.strip_prefix(b"rgb:") else { return false; }; let mut components = rgb.split(|&byte| byte == b'/'); for index in 0..3 { let Some(component) = components.next() else { return true; }; if component.len() > 4 || !component.iter().all(u8::is_ascii_hexdigit) { return false; } if component.is_empty() && (index < 2 && components.clone().next().is_some()) { return false; } } components.next().is_none() } fn parse_report(body: &[u8]) -> Option<(usize, [u8; 3])> { for (slot, header) in HEADERS.iter().enumerate() { if let Some(color) = body.strip_prefix(*header) { return parse_color(color).map(|color| (slot, color)); } } None } fn parse_color(color: &[u8]) -> Option<[u8; 3]> { let mut result = [0; 3]; if let Some(hex) = color.strip_prefix(b"#") { if hex.len() != 6 { return None; } for (channel, component) in result.iter_mut().zip(hex.chunks_exact(2)) { *channel = hex_value(component)? as u8; } return Some(result); } let mut components = color.strip_prefix(b"rgb:")?.split(|&byte| byte == b'/'); for channel in &mut result { let component = components.next()?; if component.is_empty() || component.len() > 4 { return None; } let value = hex_value(component)?; let maximum = (1_u32 << (component.len() * 4)) - 1; *channel = ((value * 255 + maximum / 2) / maximum) as u8; } components.next().is_none().then_some(result) } fn hex_value(component: &[u8]) -> Option { component.iter().try_fold(0, |value, &byte| { (byte as char).to_digit(16).map(|digit| value * 16 + digit) }) } #[cfg(test)] mod tests { use super::*; use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind}; fn key(code: KeyCode, modifiers: KeyModifiers) -> Event { Event::Key(KeyEvent::new(code, modifiers)) } fn bytes(collector: &mut TerminalColors, text: &str, now: Instant) { for ch in text.chars() { let modifiers = if ch.is_ascii_uppercase() { KeyModifiers::SHIFT } else { KeyModifiers::NONE }; collector.push(key(KeyCode::Char(ch), modifiers), now); } } fn report(collector: &mut TerminalColors, body: &str, now: Instant) { collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), now); bytes(collector, body, now); collector.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL), now); } fn drain(collector: &mut TerminalColors) -> Vec { std::iter::from_fn(|| collector.pop()).collect() } #[test] fn fragmented_triple_accepts_bel_and_both_st_encodings() { let now = Instant::now(); let mut collector = TerminalColors::default(); collector.query(&mut Vec::new(), now).unwrap(); collector.push(key(KeyCode::Esc, KeyModifiers::NONE), now); collector.push( key(KeyCode::Char(']'), KeyModifiers::NONE), now + Duration::from_millis(1), ); bytes( &mut collector, "11;rgb:0/12/345", now + Duration::from_millis(2), ); collector.push( key(KeyCode::Esc, KeyModifiers::NONE), now + Duration::from_millis(3), ); collector.push( key(KeyCode::Char('\\'), KeyModifiers::NONE), now + Duration::from_millis(4), ); assert_eq!(collector.colors, None); report( &mut collector, "4;2;rgb:f/ffff/ABCD", now + Duration::from_millis(5), ); collector.push( key(KeyCode::Char(']'), KeyModifiers::ALT), now + Duration::from_millis(6), ); bytes( &mut collector, "4;1;#FF1234", now + Duration::from_millis(7), ); collector.push( key(KeyCode::Char('\\'), KeyModifiers::ALT), now + Duration::from_millis(8), ); assert_eq!( collector.colors, Some(ThemeColors { background: [0, 18, 52], red: [255, 18, 52], green: [255, 255, 171], }) ); assert!(drain(&mut collector).is_empty()); } #[test] fn focus_refresh_atomically_replaces_light_and_dark_triples() { let now = Instant::now(); let mut collector = TerminalColors::default(); let palettes = [ ("11;#FDF6E3", [253, 246, 227]), ("11;rgb:0000/0000/0000", [0, 0, 0]), ]; for (cycle, (background, expected)) in palettes.into_iter().enumerate() { let time = now + Duration::from_secs(cycle as u64); let focus = Event::FocusGained; collector.push(focus.clone(), time); assert_eq!(collector.pop(), Some(focus)); let previous = collector.colors; collector.query(&mut Vec::new(), time).unwrap(); report(&mut collector, "4;1;rgb:ff/00/00", time); report(&mut collector, background, time); assert_eq!(collector.colors, previous); report(&mut collector, "4;2;rgb:00/ff/00", time); assert_eq!( collector.colors, Some(ThemeColors { background: expected, red: [255, 0, 0], green: [0, 255, 0], }) ); assert!(drain(&mut collector).is_empty()); } } #[test] fn periodic_refresh_detects_silent_change_without_overlapping_queries() { let now = Instant::now(); let mut collector = TerminalColors::default(); let mut output = Vec::new(); assert!(collector.refresh_due(now)); collector.query(&mut output, now).unwrap(); report(&mut collector, "11;#101010", now); report(&mut collector, "4;1;#FF0000", now); report(&mut collector, "4;2;#00FF00", now); let previous = collector.colors; assert!(previous.is_some()); // The terminal changes its palette without emitting a notification. let refresh = now + Duration::from_secs(1); assert!(!collector.refresh_due(refresh - Duration::from_nanos(1))); assert!(collector.refresh_due(refresh)); collector.query(&mut output, refresh).unwrap(); report(&mut collector, "11;#EEEEEE", refresh); report(&mut collector, "4;1;#AA1122", refresh); assert_eq!(collector.colors, previous); let typed = key(KeyCode::Char('i'), KeyModifiers::NONE); collector.push(typed.clone(), refresh); assert_eq!(drain(&mut collector), vec![typed]); // Even an overdue probe must wait until the incomplete query expires. let retry = refresh + Duration::from_secs(1); assert!(!collector.refresh_due(retry)); collector.expire(retry); assert_eq!(collector.colors, None); assert!(collector.refresh_due(retry)); collector.query(&mut output, retry).unwrap(); report(&mut collector, "11;#EEEEEE", retry); report(&mut collector, "4;1;#AA1122", retry); assert_eq!(collector.colors, None); report(&mut collector, "4;2;#22BB33", retry); assert_eq!( collector.colors, Some(ThemeColors { background: [238, 238, 238], red: [170, 17, 34], green: [34, 187, 51], }) ); assert!(drain(&mut collector).is_empty()); assert!(!collector.refresh_due(retry + Duration::from_millis(999))); assert!(collector.refresh_due(retry + Duration::from_secs(1))); } #[test] fn failed_refresh_clears_stale_colors_and_consumes_late_partial_reply() { let now = Instant::now(); let mut collector = TerminalColors::default(); collector.query(&mut Vec::new(), now).unwrap(); report(&mut collector, "11;#101010", now); report(&mut collector, "4;1;#FF0000", now); report(&mut collector, "4;2;#00FF00", now); assert!(collector.colors.is_some()); let refresh = now + Duration::from_secs(1); collector.query(&mut Vec::new(), refresh).unwrap(); report(&mut collector, "11;#EEEEEE", refresh); collector.push(key(KeyCode::Char(']'), KeyModifiers::ALT), refresh); bytes(&mut collector, "4;1;rgb:ffff/", refresh); collector.expire(refresh + QUERY_TIMEOUT); assert_eq!(collector.colors, None); let late = refresh + Duration::from_secs(1); bytes(&mut collector, "0000/0000", late); collector.push(key(KeyCode::Esc, KeyModifiers::NONE), late); collector.expire(late + Duration::from_secs(1)); collector.push( key(KeyCode::Char('\\'), KeyModifiers::NONE), late + Duration::from_secs(1), ); report(&mut collector, "4;2;#00FF00", late + Duration::from_secs(1)); assert_eq!(collector.colors, None); assert!(drain(&mut collector).is_empty()); let typed = key(KeyCode::Char('i'), KeyModifiers::NONE); collector.push(typed.clone(), late + Duration::from_secs(2)); assert_eq!(collector.pop(), Some(typed)); } #[test] fn unrelated_events_and_rejected_candidates_replay_exactly() { let now = Instant::now(); let mut collector = TerminalColors::default(); collector.query(&mut Vec::new(), now).unwrap(); let events = vec![ key(KeyCode::Char(']'), KeyModifiers::ALT), key(KeyCode::Char('4'), KeyModifiers::NONE), key(KeyCode::Char(';'), KeyModifiers::NONE), key(KeyCode::Char('3'), KeyModifiers::NONE), key(KeyCode::Char('g'), KeyModifiers::CONTROL), key(KeyCode::Char(']'), KeyModifiers::ALT | KeyModifiers::SHIFT), Event::Key(KeyEvent::new_with_kind( KeyCode::Char(']'), KeyModifiers::ALT, KeyEventKind::Release, )), Event::Key(KeyEvent::new_with_kind( KeyCode::Char('A'), KeyModifiers::SHIFT, KeyEventKind::Repeat, )), Event::Key(KeyEvent { code: KeyCode::Char('a'), modifiers: KeyModifiers::CONTROL, kind: KeyEventKind::Press, state: KeyEventState::CAPS_LOCK, }), Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 2, row: 3, modifiers: KeyModifiers::CONTROL, }), Event::FocusLost, Event::Resize(80, 24), key(KeyCode::Char(']'), KeyModifiers::ALT), Event::Paste("\x1b]11;rgb:ff/ff/ff\x07".into()), Event::FocusGained, ]; for event in &events { collector.push(event.clone(), now); } assert_eq!(drain(&mut collector), events); assert_eq!(collector.colors, None); } #[test] fn malformed_or_oversized_reports_and_lone_escape_do_not_eat_input() { let now = Instant::now(); let mut collector = TerminalColors::default(); for body in [ "11;rgb:12345/0/0", "11;rgb:1//3", "11;#12345", "11;rgb:1/2/3/4", ] { let mut expected = vec![key(KeyCode::Char(']'), KeyModifiers::ALT)]; expected.extend( body.chars() .map(|ch| key(KeyCode::Char(ch), KeyModifiers::NONE)), ); expected.push(key(KeyCode::Char('g'), KeyModifiers::CONTROL)); for event in &expected { collector.push(event.clone(), now); } assert_eq!(drain(&mut collector), expected); } let escape = key(KeyCode::Esc, KeyModifiers::NONE); collector.push(escape.clone(), now); assert_eq!(collector.pop(), None); collector.expire(now + ESCAPE_TIMEOUT); assert_eq!(collector.pop(), Some(escape)); assert_eq!(collector.pop(), None); let start = key(KeyCode::Char(']'), KeyModifiers::ALT); collector.push(start.clone(), now + Duration::from_secs(1)); collector.expire(now + Duration::from_secs(1) + QUERY_TIMEOUT); assert_eq!(collector.pop(), Some(start)); assert_eq!(collector.pop(), None); } }