use crate::ColorTarget;
use crate::color::u8_to_x11;
use crate::host_profile::HostProfile;
use crate::screen::{ScreenEvent, XtGetTcapEntry, XtWinOpsReport};
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum HostReply {
Da1(Vec<u8>),
Da2(Vec<u8>),
Da3(Vec<u8>),
Xtversion(String),
DsrStatus,
DsrCursorPosition {
row: u16,
col: u16,
},
#[doc(alias = "DECRPM")]
#[doc(alias = "DECRQM")]
Decrqm {
mode: u16,
value: ModeStatus,
},
AnsiModeReport {
mode: u16,
value: ModeStatus,
},
Decrqss {
valid: bool,
response: Vec<u8>,
},
#[doc(alias = "XTGETTCAP")]
XtGetTcap {
entries: Vec<XtGetTcapEntry>,
},
ColorQuery {
target: ColorTarget,
color: crate::Color,
},
#[doc(alias = "kitty keyboard")]
KittyKeyboardFlags(u8),
XtWinOpsReport(XtWinOpsReport),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ModeStatus {
NotRecognized,
Set,
Reset,
PermanentlySet,
PermanentlyReset,
}
impl ModeStatus {
fn as_u8(self) -> u8 {
match self {
Self::NotRecognized => 0,
Self::Set => 1,
Self::Reset => 2,
Self::PermanentlySet => 3,
Self::PermanentlyReset => 4,
}
}
}
impl HostReply {
pub fn encode(&self) -> Vec<u8> {
match self {
Self::Da1(bytes) | Self::Da2(bytes) | Self::Da3(bytes) => bytes.clone(),
Self::Xtversion(name) => format!("\x1bP>|{name}\x1b\\").into_bytes(),
Self::DsrStatus => b"\x1b[0n".to_vec(),
Self::DsrCursorPosition { row, col } => format!("\x1b[{row};{col}R").into_bytes(),
Self::Decrqm { mode, value } => {
format!("\x1b[?{mode};{}$y", value.as_u8()).into_bytes()
}
Self::AnsiModeReport { mode, value } => {
format!("\x1b[{mode};{}$y", value.as_u8()).into_bytes()
}
Self::Decrqss { valid, response } => {
let flag: u8 = if *valid { b'1' } else { b'0' };
let mut out = Vec::with_capacity(5 + response.len());
out.extend_from_slice(b"\x1bP");
out.push(flag);
out.extend_from_slice(b"$r");
out.extend_from_slice(response);
out.extend_from_slice(b"\x1b\\");
out
}
Self::XtGetTcap { entries } => {
let mut out = Vec::new();
for entry in entries {
let frame = match &entry.value_hex {
Some(val) => {
format!("\x1bP1+r{key}={val}\x1b\\", key = entry.key_hex).into_bytes()
}
None => format!("\x1bP0+r{key}\x1b\\", key = entry.key_hex).into_bytes(),
};
out.extend_from_slice(&frame);
}
out
}
Self::ColorQuery { target, color } => {
let (r, g, b) = color.as_rgb().unwrap_or((0, 0, 0));
let (r16, g16, b16) = (u8_to_x11(r), u8_to_x11(g), u8_to_x11(b));
let osc = match target {
ColorTarget::Palette(idx) => {
format!("\x1b]4;{idx};rgb:{r16:04x}/{g16:04x}/{b16:04x}\x1b\\")
}
ColorTarget::Foreground => {
format!("\x1b]10;rgb:{r16:04x}/{g16:04x}/{b16:04x}\x1b\\")
}
ColorTarget::Background => {
format!("\x1b]11;rgb:{r16:04x}/{g16:04x}/{b16:04x}\x1b\\")
}
ColorTarget::CursorColor => {
format!("\x1b]12;rgb:{r16:04x}/{g16:04x}/{b16:04x}\x1b\\")
}
};
osc.into_bytes()
}
Self::KittyKeyboardFlags(flags) => format!("\x1b[?{flags}u").into_bytes(),
Self::XtWinOpsReport(report) => encode_xtwinops(*report),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum HostQuery {
Da1,
Da2,
Da3,
Xtversion,
DsrStatus,
DsrCursorPosition {
row: u16,
col: u16,
},
Decrqm {
mode: u16,
status: ModeStatus,
},
AnsiModeReport {
mode: u16,
status: ModeStatus,
},
Decrqss {
query: Vec<u8>,
response: Option<Vec<u8>>,
},
XtGetTcap {
entries: Vec<XtGetTcapEntry>,
},
ColorQuery {
target: ColorTarget,
color: crate::Color,
},
KittyKeyboardQuery {
flags: u8,
},
XtWinOpsReport(XtWinOpsReport),
}
impl HostQuery {
#[doc(hidden)]
#[must_use]
pub fn from_event(event: &ScreenEvent) -> Option<Self> {
Some(match event {
ScreenEvent::Da1 => Self::Da1,
ScreenEvent::Da2 => Self::Da2,
ScreenEvent::Da3 => Self::Da3,
ScreenEvent::Xtversion => Self::Xtversion,
ScreenEvent::DsrStatus => Self::DsrStatus,
ScreenEvent::DsrCursorPosition { row, col } => Self::DsrCursorPosition {
row: *row,
col: *col,
},
ScreenEvent::Decrqm { mode, status } => Self::Decrqm {
mode: *mode,
status: *status,
},
ScreenEvent::AnsiModeReport { mode, status } => Self::AnsiModeReport {
mode: *mode,
status: *status,
},
ScreenEvent::Decrqss { query, response } => Self::Decrqss {
query: query.clone(),
response: response.clone(),
},
ScreenEvent::XtGetTcap { entries } => Self::XtGetTcap {
entries: entries.clone(),
},
ScreenEvent::ColorQuery { target, color } => Self::ColorQuery {
target: *target,
color: *color,
},
ScreenEvent::KittyKeyboardQuery { flags } => Self::KittyKeyboardQuery { flags: *flags },
ScreenEvent::XtWinOpsReport(report) => Self::XtWinOpsReport(*report),
_ => return None,
})
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ReplyAction {
Send,
Replace(HostReply),
Drop,
}
#[doc(hidden)]
#[must_use]
pub fn auto_reply_bytes_for_query(query: &HostQuery, host: &HostProfile) -> Vec<u8> {
match query {
HostQuery::Da1 => HostReply::Da1(host.da1.clone()).encode(),
HostQuery::Da2 => HostReply::Da2(host.da2.clone()).encode(),
HostQuery::Da3 => HostReply::Da3(host.da3.clone()).encode(),
HostQuery::Xtversion => HostReply::Xtversion(host.xtversion_name.clone()).encode(),
HostQuery::DsrStatus => HostReply::DsrStatus.encode(),
HostQuery::DsrCursorPosition { row, col } => HostReply::DsrCursorPosition {
row: *row,
col: *col,
}
.encode(),
HostQuery::Decrqm { mode, status } => HostReply::Decrqm {
mode: *mode,
value: *status,
}
.encode(),
HostQuery::AnsiModeReport { mode, status } => HostReply::AnsiModeReport {
mode: *mode,
value: *status,
}
.encode(),
HostQuery::Decrqss { response, .. } => match response {
Some(payload) => HostReply::Decrqss {
valid: true,
response: payload.clone(),
}
.encode(),
None => HostReply::Decrqss {
valid: false,
response: Vec::new(),
}
.encode(),
},
HostQuery::XtGetTcap { entries } => HostReply::XtGetTcap {
entries: entries.clone(),
}
.encode(),
HostQuery::ColorQuery { target, color } => HostReply::ColorQuery {
target: *target,
color: *color,
}
.encode(),
HostQuery::KittyKeyboardQuery { flags } => HostReply::KittyKeyboardFlags(*flags).encode(),
HostQuery::XtWinOpsReport(report) => HostReply::XtWinOpsReport(*report).encode(),
}
}
#[must_use]
pub fn auto_reply_bytes(event: &ScreenEvent, host: &HostProfile) -> Option<Vec<u8>> {
HostQuery::from_event(event).map(|q| auto_reply_bytes_for_query(&q, host))
}
fn encode_xtwinops(report: XtWinOpsReport) -> Vec<u8> {
match report {
XtWinOpsReport::TextAreaPixels {
height_pixels,
width_pixels,
} => format!("\x1b[4;{height_pixels};{width_pixels}t").into_bytes(),
XtWinOpsReport::CellPixels { height, width } => {
format!("\x1b[6;{height};{width}t").into_bytes()
}
XtWinOpsReport::TextAreaCells { rows, cols } => {
format!("\x1b[8;{rows};{cols}t").into_bytes()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Color;
#[test]
fn da1_encodes_raw_bytes() {
let reply = HostReply::Da1(b"\x1b[?62;22c".to_vec());
assert_eq!(reply.encode(), b"\x1b[?62;22c");
}
#[test]
fn xtversion_wraps_in_dcs() {
let reply = HostReply::Xtversion("myterm(1.0)".into());
assert_eq!(reply.encode(), b"\x1bP>|myterm(1.0)\x1b\\");
}
#[test]
fn dsr_status_is_ok() {
assert_eq!(HostReply::DsrStatus.encode(), b"\x1b[0n");
}
#[test]
fn dsr_cursor_position_is_1_based() {
let reply = HostReply::DsrCursorPosition { row: 5, col: 10 };
assert_eq!(reply.encode(), b"\x1b[5;10R");
}
#[test]
fn decrqm_set() {
let reply = HostReply::Decrqm {
mode: 2004,
value: ModeStatus::Reset,
};
assert_eq!(reply.encode(), b"\x1b[?2004;2$y");
}
#[test]
fn decrqm_not_recognized() {
let reply = HostReply::Decrqm {
mode: 9999,
value: ModeStatus::NotRecognized,
};
assert_eq!(reply.encode(), b"\x1b[?9999;0$y");
}
#[test]
fn ansi_mode_report_no_question_mark() {
let reply = HostReply::AnsiModeReport {
mode: 4,
value: ModeStatus::Set,
};
assert_eq!(reply.encode(), b"\x1b[4;1$y");
}
#[test]
fn decrqss_valid_response() {
let reply = HostReply::Decrqss {
valid: true,
response: b"0m".to_vec(),
};
assert_eq!(reply.encode(), b"\x1bP1$r0m\x1b\\");
}
#[test]
fn decrqss_invalid_response() {
let reply = HostReply::Decrqss {
valid: false,
response: Vec::new(),
};
assert_eq!(reply.encode(), b"\x1bP0$r\x1b\\");
}
#[test]
fn decrqss_propagates_non_ascii_payload_without_replacement() {
let host = HostProfile::default();
let event = ScreenEvent::Decrqss {
query: b"m".to_vec(),
response: Some(vec![0xC3, 0x28, 0xFF, 0x80]),
};
let bytes = auto_reply_bytes(&event, &host).expect("DECRQSS auto-reply");
assert_eq!(bytes.as_slice(), b"\x1bP1$r\xC3\x28\xFF\x80\x1b\\");
}
#[test]
fn xtgettcap_encodes_single_entry_found() {
let reply = HostReply::XtGetTcap {
entries: vec![XtGetTcapEntry {
key_hex: "544E".into(),
value_hex: Some("787465726D".into()),
}],
};
assert_eq!(reply.encode(), b"\x1bP1+r544E=787465726D\x1b\\");
}
#[test]
fn xtgettcap_encodes_single_entry_not_found() {
let reply = HostReply::XtGetTcap {
entries: vec![XtGetTcapEntry {
key_hex: "544E".into(),
value_hex: None,
}],
};
assert_eq!(reply.encode(), b"\x1bP0+r544E\x1b\\");
}
#[test]
fn xtgettcap_encodes_multiple_entries() {
let reply = HostReply::XtGetTcap {
entries: vec![
XtGetTcapEntry {
key_hex: "544E".into(),
value_hex: Some("746173747479".into()),
},
XtGetTcapEntry {
key_hex: "ZZZZ".into(),
value_hex: None,
},
],
};
assert_eq!(
reply.encode(),
b"\x1bP1+r544E=746173747479\x1b\\\x1bP0+rZZZZ\x1b\\"
);
}
#[test]
fn color_query_foreground() {
let reply = HostReply::ColorQuery {
target: ColorTarget::Foreground,
color: Color::Rgb(255, 0, 128),
};
let encoded = reply.encode();
let expected = format!(
"\x1b]10;rgb:{:04x}/{:04x}/{:04x}\x1b\\",
0xffff,
0,
128 * 257
);
assert_eq!(encoded, expected.as_bytes());
}
#[test]
fn color_query_palette() {
let reply = HostReply::ColorQuery {
target: ColorTarget::Palette(42),
color: Color::Rgb(0, 0, 0),
};
assert_eq!(reply.encode(), b"\x1b]4;42;rgb:0000/0000/0000\x1b\\");
}
#[test]
fn color_query_background() {
let reply = HostReply::ColorQuery {
target: ColorTarget::Background,
color: Color::Rgb(255, 255, 255),
};
assert_eq!(reply.encode(), b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\");
}
#[test]
fn color_query_cursor() {
let reply = HostReply::ColorQuery {
target: ColorTarget::CursorColor,
color: Color::Rgb(229, 229, 229),
};
let expected = format!(
"\x1b]12;rgb:{:04x}/{:04x}/{:04x}\x1b\\",
229 * 257,
229 * 257,
229 * 257
);
assert_eq!(reply.encode(), expected.as_bytes());
}
#[test]
fn kitty_keyboard_flags() {
let reply = HostReply::KittyKeyboardFlags(3);
assert_eq!(reply.encode(), b"\x1b[?3u");
}
#[test]
fn xtwinops_text_area_pixels() {
let reply = HostReply::XtWinOpsReport(XtWinOpsReport::TextAreaPixels {
height_pixels: 480,
width_pixels: 640,
});
assert_eq!(reply.encode(), b"\x1b[4;480;640t");
}
use crate::host_profile::HostProfile;
use crate::screen::{ScreenEvent, XtGetTcapEntry, XtWinOpsReport};
#[test]
fn auto_reply_da1_uses_host_profile() {
let host = HostProfile {
da1: b"\x1b[?42c".to_vec(),
..HostProfile::default()
};
assert_eq!(
auto_reply_bytes(&ScreenEvent::Da1, &host).as_deref(),
Some(&b"\x1b[?42c"[..])
);
}
#[test]
fn auto_reply_da2_uses_host_profile() {
let host = HostProfile {
da2: b"\x1b[>1;2;3c".to_vec(),
..HostProfile::default()
};
assert_eq!(
auto_reply_bytes(&ScreenEvent::Da2, &host).as_deref(),
Some(&b"\x1b[>1;2;3c"[..])
);
}
#[test]
fn auto_reply_da3_uses_host_profile() {
let host = HostProfile::default();
assert_eq!(
auto_reply_bytes(&ScreenEvent::Da3, &host).as_deref(),
Some(&host.da3[..])
);
}
#[test]
fn auto_reply_xtversion_wraps_name_in_dcs() {
let host = HostProfile {
xtversion_name: "tastty-test(0)".into(),
..HostProfile::default()
};
assert_eq!(
auto_reply_bytes(&ScreenEvent::Xtversion, &host).as_deref(),
Some(&b"\x1bP>|tastty-test(0)\x1b\\"[..])
);
}
#[test]
fn auto_reply_dsr_status_is_constant() {
let host = HostProfile::default();
assert_eq!(
auto_reply_bytes(&ScreenEvent::DsrStatus, &host).as_deref(),
Some(&b"\x1b[0n"[..])
);
}
#[test]
fn auto_reply_dsr_cursor_position_uses_event_payload() {
let host = HostProfile::default();
let event = ScreenEvent::DsrCursorPosition { row: 5, col: 10 };
assert_eq!(
auto_reply_bytes(&event, &host).as_deref(),
Some(&b"\x1b[5;10R"[..])
);
}
#[test]
fn auto_reply_decrqm_uses_event_payload() {
let host = HostProfile::default();
let event = ScreenEvent::Decrqm {
mode: 2004,
status: ModeStatus::Set,
};
assert_eq!(
auto_reply_bytes(&event, &host).as_deref(),
Some(&b"\x1b[?2004;1$y"[..])
);
}
#[test]
fn auto_reply_ansi_mode_uses_event_payload() {
let host = HostProfile::default();
let event = ScreenEvent::AnsiModeReport {
mode: 4,
status: ModeStatus::Reset,
};
assert_eq!(
auto_reply_bytes(&event, &host).as_deref(),
Some(&b"\x1b[4;2$y"[..])
);
}
#[test]
fn auto_reply_decrqss_valid_frames_payload() {
let host = HostProfile::default();
let event = ScreenEvent::Decrqss {
query: b"m".to_vec(),
response: Some(b"0m".to_vec()),
};
assert_eq!(
auto_reply_bytes(&event, &host).as_deref(),
Some(&b"\x1bP1$r0m\x1b\\"[..])
);
}
#[test]
fn auto_reply_decrqss_unknown_returns_zero_frame() {
let host = HostProfile::default();
let event = ScreenEvent::Decrqss {
query: b"zzz".to_vec(),
response: None,
};
assert_eq!(
auto_reply_bytes(&event, &host).as_deref(),
Some(&b"\x1bP0$r\x1b\\"[..])
);
}
#[test]
fn auto_reply_xtgettcap_concatenates_entries() {
let host = HostProfile::default();
let event = ScreenEvent::XtGetTcap {
entries: vec![
XtGetTcapEntry {
key_hex: "544E".into(),
value_hex: Some("746173747479".into()),
},
XtGetTcapEntry {
key_hex: "ZZZZ".into(),
value_hex: None,
},
],
};
assert_eq!(
auto_reply_bytes(&event, &host).as_deref(),
Some(&b"\x1bP1+r544E=746173747479\x1b\\\x1bP0+rZZZZ\x1b\\"[..])
);
}
#[test]
fn auto_reply_color_query_uses_event_color() {
let host = HostProfile::default();
let event = ScreenEvent::ColorQuery {
target: ColorTarget::Foreground,
color: Color::Rgb(255, 255, 255),
};
assert_eq!(
auto_reply_bytes(&event, &host).as_deref(),
Some(&b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\"[..])
);
}
#[test]
fn auto_reply_kitty_keyboard_query_uses_event_flags() {
let host = HostProfile::default();
let event = ScreenEvent::KittyKeyboardQuery { flags: 7 };
assert_eq!(
auto_reply_bytes(&event, &host).as_deref(),
Some(&b"\x1b[?7u"[..])
);
}
#[test]
fn auto_reply_xtwinops_text_area_pixels() {
let host = HostProfile::default();
let event = ScreenEvent::XtWinOpsReport(XtWinOpsReport::TextAreaPixels {
height_pixels: 480,
width_pixels: 640,
});
assert_eq!(
auto_reply_bytes(&event, &host).as_deref(),
Some(&b"\x1b[4;480;640t"[..])
);
}
#[test]
fn auto_reply_xtwinops_cell_pixels() {
let host = HostProfile::default();
let event = ScreenEvent::XtWinOpsReport(XtWinOpsReport::CellPixels {
height: 24,
width: 12,
});
assert_eq!(
auto_reply_bytes(&event, &host).as_deref(),
Some(&b"\x1b[6;24;12t"[..])
);
}
#[test]
fn auto_reply_xtwinops_text_area_cells() {
let host = HostProfile::default();
let event =
ScreenEvent::XtWinOpsReport(XtWinOpsReport::TextAreaCells { rows: 24, cols: 80 });
assert_eq!(
auto_reply_bytes(&event, &host).as_deref(),
Some(&b"\x1b[8;24;80t"[..])
);
}
#[test]
fn auto_reply_clipboard_query_returns_none() {
let host = HostProfile::default();
let event = ScreenEvent::ClipboardQuery {
targets: vec![crate::ClipboardTarget::Primary],
};
assert!(auto_reply_bytes(&event, &host).is_none());
}
#[test]
fn auto_reply_state_change_returns_none() {
let host = HostProfile::default();
assert!(auto_reply_bytes(&ScreenEvent::Bell, &host).is_none());
assert!(auto_reply_bytes(&ScreenEvent::TitleChanged, &host).is_none());
}
#[test]
fn host_query_from_event_maps_query_variants() {
assert!(matches!(
HostQuery::from_event(&ScreenEvent::Da1),
Some(HostQuery::Da1)
));
assert!(matches!(
HostQuery::from_event(&ScreenEvent::Xtversion),
Some(HostQuery::Xtversion)
));
let cpr = ScreenEvent::DsrCursorPosition { row: 7, col: 3 };
assert_eq!(
HostQuery::from_event(&cpr),
Some(HostQuery::DsrCursorPosition { row: 7, col: 3 })
);
}
#[test]
fn host_query_from_event_skips_non_query_variants() {
assert!(HostQuery::from_event(&ScreenEvent::Bell).is_none());
assert!(HostQuery::from_event(&ScreenEvent::TitleChanged).is_none());
let clip = ScreenEvent::ClipboardQuery {
targets: vec![crate::ClipboardTarget::Primary],
};
assert!(HostQuery::from_event(&clip).is_none());
}
#[test]
fn auto_reply_bytes_for_query_matches_event_path() {
let host = HostProfile::default();
let event = ScreenEvent::Decrqm {
mode: 2004,
status: ModeStatus::Set,
};
let via_query = HostQuery::from_event(&event)
.map(|q| auto_reply_bytes_for_query(&q, &host))
.expect("Decrqm is a host query");
let via_event = auto_reply_bytes(&event, &host).expect("Decrqm event has a reply");
assert_eq!(via_query, via_event);
}
}