aboutsummaryrefslogtreecommitdiff
path: root/src/raw_input.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-18 02:52:35 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-18 02:53:31 +0300
commit36b4001f4526b60f1c9b67a4a34596e5ad2bcca1 (patch)
tree520ab28a0a3f3f798e6e655fc83529e8617720e9 /src/raw_input.rs
parentaca35ea76f3df15ec68fd07959d939f5e7a78a6c (diff)
feat: sync theme with host appearance
refs #675
Diffstat (limited to 'src/raw_input.rs')
-rw-r--r--src/raw_input.rs213
1 files changed, 211 insertions, 2 deletions
diff --git a/src/raw_input.rs b/src/raw_input.rs
index 57ab6274..9978cc0c 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -95,9 +95,13 @@ use std::os::fd::AsRawFd;
use tokio::sync::mpsc;
use crate::input::{parse_terminal_key_sequence, TerminalKey};
-use crate::terminal_theme::{parse_default_color_response, DefaultColorKind, RgbColor};
+use crate::terminal_theme::{
+ parse_default_color_response, DefaultColorKind, HostAppearance, RgbColor,
+};
const ESC: u8 = 0x1b;
+pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n";
+pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n";
const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~";
const BRACKETED_PASTE_END: &[u8] = b"\x1b[201~";
@@ -112,6 +116,7 @@ pub enum RawInputEvent {
kind: DefaultColorKind,
color: RgbColor,
},
+ HostColorSchemeChanged(HostAppearance),
Unsupported,
}
@@ -129,6 +134,14 @@ impl RawInputFramer {
self.byte_framer.host_color_query_sent();
}
+ pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) {
+ self.byte_framer.enable_host_color_scheme_change_tracking();
+ }
+
+ pub(crate) fn has_pending_input(&self) -> bool {
+ self.byte_framer.has_pending_input()
+ }
+
pub(crate) fn flush_timeout(&mut self) -> Vec<RawInputEvent> {
Self::events_from_chunks(self.byte_framer.flush_timeout())
}
@@ -159,6 +172,7 @@ pub(crate) struct RawInputByteFramer {
discarded_tail_bytes: usize,
host_color_replies_awaited: u8,
held_pending_color_esc: bool,
+ host_color_scheme_change_tracking: bool,
}
const HOST_COLOR_QUERY_REPLIES: u8 = 2;
@@ -176,6 +190,14 @@ impl RawInputByteFramer {
self.held_pending_color_esc = false;
}
+ pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) {
+ self.host_color_scheme_change_tracking = true;
+ }
+
+ pub(crate) fn has_pending_input(&self) -> bool {
+ !self.buffer.is_empty()
+ }
+
pub(crate) fn flush_timeout(&mut self) -> Vec<Vec<u8>> {
let mut chunks = self.drain_available_chunks();
@@ -217,6 +239,17 @@ impl RawInputByteFramer {
return chunks;
}
+ if starts_with_incomplete_host_color_scheme_report(&self.buffer) {
+ tracing::debug!(
+ len = self.buffer.len(),
+ "discarding incomplete host color scheme report after input timeout"
+ );
+ self.discard_until = Some(ControlStringFamily::HostColorSchemeCsi);
+ self.discarded_tail_bytes = 0;
+ self.buffer.clear();
+ return chunks;
+ }
+
if let Some(ControlString::Incomplete { family }) = control_string(&self.buffer) {
tracing::debug!(
len = self.buffer.len(),
@@ -291,6 +324,10 @@ impl RawInputByteFramer {
};
if matches!(event, RawInputEvent::HostDefaultColor { .. }) {
self.host_color_replies_awaited = self.host_color_replies_awaited.saturating_sub(1);
+ } else if self.host_color_scheme_change_tracking
+ && matches!(event, RawInputEvent::HostColorSchemeChanged(_))
+ {
+ self.host_color_query_sent();
}
self.held_pending_color_esc = false;
chunks.push(self.buffer[..consumed].to_vec());
@@ -327,6 +364,9 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) ->
)
}),
ControlStringFamily::StTerminated => buffer.last() == Some(&ESC),
+ ControlStringFamily::HostColorSchemeCsi => buffer
+ .iter()
+ .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'?' | b'n')),
}
}
@@ -340,6 +380,12 @@ pub(crate) fn events_require_host_surface_redraw(
.any(|event| matches!(event, RawInputEvent::OuterFocusGained))
}
+pub(crate) fn events_require_host_terminal_theme_query(events: &[RawInputEvent]) -> bool {
+ events
+ .iter()
+ .any(|event| matches!(event, RawInputEvent::HostColorSchemeChanged(_)))
+}
+
pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
let (tx, rx) = mpsc::channel(256);
@@ -349,6 +395,7 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
let mut scratch = [0u8; 1024];
let mut framer = RawInputFramer::default();
framer.host_color_query_sent();
+ framer.enable_host_color_scheme_change_tracking();
loop {
match reader.read(&mut scratch) {
@@ -357,7 +404,13 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
send_raw_input_events(framer.push(&scratch[..n]), &tx);
if stdin_read_ready(&reader, 10) == Some(false) {
- send_raw_input_events(framer.flush_timeout(), &tx);
+ let had_pending = framer.has_pending_input();
+ let events = framer.flush_timeout();
+ let held_escape = had_pending && events.is_empty();
+ send_raw_input_events(events, &tx);
+ if held_escape && stdin_read_ready(&reader, 10) == Some(false) {
+ send_raw_input_events(framer.flush_timeout(), &tx);
+ }
}
}
Err(_) => break,
@@ -497,6 +550,10 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
_ => {}
}
+ if let Some(appearance) = parse_host_color_scheme_report(&buffer[..seq_len]) {
+ return Some((RawInputEvent::HostColorSchemeChanged(appearance), seq_len));
+ }
+
if let Some(mouse) = parse_sgr_mouse(seq) {
return Some((RawInputEvent::Mouse(mouse), seq_len));
}
@@ -519,6 +576,7 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
enum ControlStringFamily {
Osc,
StTerminated,
+ HostColorSchemeCsi,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -532,6 +590,14 @@ enum ControlString {
},
}
+fn parse_host_color_scheme_report(buffer: &[u8]) -> Option<HostAppearance> {
+ match buffer {
+ GHOSTTY_COLOR_SCHEME_DARK_REPORT => Some(HostAppearance::Dark),
+ GHOSTTY_COLOR_SCHEME_LIGHT_REPORT => Some(HostAppearance::Light),
+ _ => None,
+ }
+}
+
fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool {
matches!(
control_string(buffer),
@@ -541,6 +607,13 @@ fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool {
) && matches!(buffer.get(..5), Some(b"\x1b]10;" | b"\x1b]11;"))
}
+fn starts_with_incomplete_host_color_scheme_report(buffer: &[u8]) -> bool {
+ buffer.starts_with(b"\x1b[?")
+ && (GHOSTTY_COLOR_SCHEME_DARK_REPORT.starts_with(buffer)
+ || GHOSTTY_COLOR_SCHEME_LIGHT_REPORT.starts_with(buffer))
+ && buffer.len() < GHOSTTY_COLOR_SCHEME_DARK_REPORT.len()
+}
+
fn control_string(buffer: &[u8]) -> Option<ControlString> {
let family = match buffer.get(..2)? {
b"\x1b]" => ControlStringFamily::Osc,
@@ -650,6 +723,10 @@ fn control_string_terminator_for_family(
match family {
ControlStringFamily::Osc => osc_string_terminator(buffer),
ControlStringFamily::StTerminated => st_string_terminator(buffer),
+ ControlStringFamily::HostColorSchemeCsi => buffer
+ .iter()
+ .position(|byte| *byte == b'n')
+ .map(|idx| idx + 1),
}
}
@@ -937,6 +1014,69 @@ mod tests {
}
#[test]
+ fn parses_ghostty_color_scheme_reports() {
+ for bytes in [
+ GHOSTTY_COLOR_SCHEME_DARK_REPORT,
+ GHOSTTY_COLOR_SCHEME_LIGHT_REPORT,
+ ] {
+ let events = parse_raw_input_bytes_sync(bytes);
+ assert_eq!(events.len(), 1, "bytes: {bytes:?}");
+ assert!(matches!(
+ events[0],
+ RawInputEvent::HostColorSchemeChanged(HostAppearance::Dark | HostAppearance::Light)
+ ));
+ assert!(events_require_host_terminal_theme_query(&events));
+ }
+ }
+
+ #[test]
+ fn ghostty_color_scheme_report_parser_is_exact() {
+ for bytes in [
+ b"\x1b[?997;0n".as_slice(),
+ b"\x1b[?997;3n".as_slice(),
+ b"\x1b[?998;1n".as_slice(),
+ ] {
+ let events = parse_raw_input_bytes_sync(bytes);
+ assert_eq!(events.len(), 1, "bytes: {bytes:?}");
+ assert!(matches!(events[0], RawInputEvent::Unsupported));
+ assert!(!events_require_host_terminal_theme_query(&events));
+ }
+ }
+
+ #[test]
+ fn raw_input_framer_reassembles_split_color_scheme_report() {
+ let mut framer = RawInputFramer::default();
+
+ assert!(framer.push(b"\x1b[?997;").is_empty());
+ let events = framer.push(b"1n");
+
+ assert_eq!(events.len(), 1);
+ assert!(matches!(
+ events[0],
+ RawInputEvent::HostColorSchemeChanged(HostAppearance::Dark)
+ ));
+ }
+
+ #[test]
+ fn split_color_scheme_timeout_does_not_swallow_legacy_alt_bracket() {
+ let mut framer = RawInputByteFramer::default();
+
+ assert!(framer.push(b"\x1b[").is_empty());
+ assert_eq!(framer.flush_timeout(), vec![b"\x1b[".to_vec()]);
+ }
+
+ #[test]
+ fn raw_input_byte_framer_discards_timed_out_split_color_scheme_report_tail() {
+ let mut framer = RawInputByteFramer::default();
+
+ assert!(framer.push(b"\x1b[?997;").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+ assert!(framer.push(b"1n").is_empty());
+ assert_eq!(framer.push(b"a"), vec![b"a".to_vec()]);
+ assert!(framer.flush_timeout().is_empty());
+ }
+
+ #[test]
fn parses_xterm_alt_up_arrow() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[1;3A").unwrap() else {
panic!("expected key");
@@ -1800,6 +1940,75 @@ mod tests {
}
#[test]
+ fn default_byte_framer_does_not_rearm_after_color_scheme_report() {
+ let mut framer = RawInputByteFramer::default();
+
+ assert_eq!(
+ framer.push(GHOSTTY_COLOR_SCHEME_DARK_REPORT),
+ vec![GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()]
+ );
+ assert!(framer.push(b"\x1b").is_empty());
+ assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
+ }
+
+ #[test]
+ fn opt_in_does_not_delay_plain_escape_without_color_scheme_report() {
+ let mut framer = RawInputByteFramer::default();
+ framer.enable_host_color_scheme_change_tracking();
+
+ assert!(framer.push(b"\x1b").is_empty());
+ assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
+ }
+
+ #[test]
+ fn opted_in_byte_framer_rearms_after_color_scheme_report() {
+ let mut framer = RawInputByteFramer::default();
+ framer.enable_host_color_scheme_change_tracking();
+
+ assert_eq!(
+ framer.push(GHOSTTY_COLOR_SCHEME_DARK_REPORT),
+ vec![GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()]
+ );
+
+ assert!(framer.push(b"\x1b").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+ let chunks = framer.push(b"]10;#abcdef\x07");
+ assert_eq!(chunks.len(), 1);
+ let (event, _) = extract_one_event(&chunks[0]).unwrap();
+ assert!(matches!(
+ event,
+ RawInputEvent::HostDefaultColor {
+ kind: DefaultColorKind::Foreground,
+ color: RgbColor {
+ r: 0xab,
+ g: 0xcd,
+ b: 0xef
+ }
+ }
+ ));
+
+ assert!(framer.push(b"\x1b").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+ let chunks = framer.push(b"]11;#123456\x07");
+ assert_eq!(chunks.len(), 1);
+ let (event, _) = extract_one_event(&chunks[0]).unwrap();
+ assert!(matches!(
+ event,
+ RawInputEvent::HostDefaultColor {
+ kind: DefaultColorKind::Background,
+ color: RgbColor {
+ r: 0x12,
+ g: 0x34,
+ b: 0x56
+ }
+ }
+ ));
+
+ assert!(framer.push(b"\x1b").is_empty());
+ assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
+ }
+
+ #[test]
fn flushes_lone_escape_when_not_awaiting_host_color_reply() {
let mut framer = RawInputByteFramer::default();