fix: blend diff highlights with terminal theme colors

This commit is contained in:
2026-10-11 20:52:48 +02:00
parent a80f97309b
commit d6f6203fb6
5 changed files with 716 additions and 130 deletions
+512
View File
@@ -0,0 +1,512 @@
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);
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<ThemeColors>,
query: Option<Query>,
capture: Capture,
original: Vec<Event>,
body: [u8; MAX_BODY],
len: usize,
ready: VecDeque<Event>,
}
impl TerminalColors {
pub fn query(&mut self, output: &mut impl Write, now: Instant) -> io::Result<()> {
self.expire(now);
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<Event> {
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<u32> {
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<Event> {
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 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);
}
}