use std::io::{self, IsTerminal, Write};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crate::config::ThemeMode;
pub(crate) const QUERY: &[u8] = b"\x1b]11;?\x1b\\";
pub(crate) const ARMED_FOR: Duration = Duration::from_secs(3);
pub(crate) const HOLD: Duration = Duration::from_millis(100);
fn epoch() -> Instant {
static EPOCH: OnceLock<Instant> = OnceLock::new();
*EPOCH.get_or_init(Instant::now)
}
static ARMED_UNTIL: AtomicU64 = AtomicU64::new(0);
fn now_ms() -> u64 {
u64::try_from(epoch().elapsed().as_millis()).unwrap_or(u64::MAX)
}
pub(crate) fn armed() -> bool {
now_ms() < ARMED_UNTIL.load(Ordering::SeqCst)
}
pub(crate) fn disarm() {
ARMED_UNTIL.store(0, Ordering::SeqCst);
}
pub(crate) fn supported() -> bool {
can_ask(
cfg!(unix),
io::stdout().is_terminal(),
std::env::var("TERM").ok().as_deref(),
)
}
fn can_ask(unix: bool, tty: bool, term: Option<&str>) -> bool {
unix && tty && term.is_some_and(|term| !term.is_empty() && term != "linux" && term != "dumb")
}
pub(crate) fn terminal_key() -> String {
let var = |name| std::env::var(name).ok().filter(|v: &String| !v.is_empty());
var("TERM_PROGRAM")
.or_else(|| var("TERM"))
.unwrap_or_default()
}
pub(crate) fn ask(out: &mut impl Write) -> bool {
if armed() {
return false;
}
if out.write_all(QUERY).and_then(|()| out.flush()).is_err() {
return false;
}
let until = now_ms().saturating_add(u64::try_from(ARMED_FOR.as_millis()).unwrap_or(0));
ARMED_UNTIL.store(until, Ordering::SeqCst);
true
}
pub fn parse_color(body: &str) -> Option<[f64; 3]> {
let (channels, count) = match body.strip_prefix("rgb:") {
Some(rest) => (rest, 3),
None => (body.strip_prefix("rgba:")?, 4),
};
let parts = channels
.split('/')
.map(channel)
.collect::<Option<Vec<f64>>>()?;
match parts.as_slice() {
[r, g, b, ..] if parts.len() == count => Some([*r, *g, *b]),
_ => None,
}
}
fn channel(hex: &str) -> Option<f64> {
if hex.is_empty() || hex.len() > 4 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let value = u32::from_str_radix(hex, 16).ok()?;
let max = (1u32 << (4 * hex.len())) - 1;
Some(f64::from(value) / f64::from(max))
}
pub fn mode_for(rgb: [f64; 3]) -> ThemeMode {
fn linear(c: f64) -> f64 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
let luminance = 0.2126 * linear(rgb[0]) + 0.7152 * linear(rgb[1]) + 0.0722 * linear(rgb[2]);
if luminance > 0.179 {
ThemeMode::Light
} else {
ThemeMode::Dark
}
}
#[derive(Debug, PartialEq)]
pub(crate) enum Scanned {
Event(Event),
Background(Option<ThemeMode>),
}
#[derive(Debug, Default)]
pub(crate) struct ReplyScanner {
held: Vec<Event>,
text: String,
started: bool,
escaped: bool,
}
const MAX_BODY: usize = 48;
impl ReplyScanner {
pub(crate) fn holding(&self) -> bool {
!self.held.is_empty()
}
pub(crate) fn flush(&mut self, out: &mut Vec<Scanned>) {
out.extend(self.held.drain(..).map(Scanned::Event));
self.text.clear();
self.started = false;
self.escaped = false;
}
pub(crate) fn feed(&mut self, event: Event, armed: bool, out: &mut Vec<Scanned>) {
let key = match &event {
Event::Key(key) if key.is_press() => *key,
_ => {
self.flush(out);
out.push(Scanned::Event(event));
return;
}
};
if self.held.is_empty() {
if (armed && (is_esc(&key) || is_open(&key, true))) || is_late_open(&key) {
self.started = !is_esc(&key);
self.held.push(event);
} else {
out.push(Scanned::Event(event));
}
return;
}
if !self.started {
if is_open(&key, false) {
self.started = true;
self.held.push(event);
} else {
self.flush(out);
self.feed(event, armed, out);
}
return;
}
if self.escaped {
if key.code == KeyCode::Char('\\') && key.modifiers.is_empty() {
self.finish(out, event);
} else {
self.flush(out);
self.feed(event, armed, out);
}
return;
}
match key.code {
KeyCode::Char('g') if key.modifiers == KeyModifiers::CONTROL => self.finish(out, event),
KeyCode::Char('\\') if key.modifiers == KeyModifiers::ALT => self.finish(out, event),
KeyCode::Esc if key.modifiers.is_empty() && self.text.starts_with("11;") => {
self.escaped = true;
self.held.push(event);
}
KeyCode::Char(c)
if (key.modifiers - KeyModifiers::SHIFT).is_empty() && self.plausible_with(c) =>
{
self.text.push(c);
self.held.push(event);
}
_ => {
self.flush(out);
self.feed(event, armed, out);
}
}
}
fn plausible_with(&self, c: char) -> bool {
let len = self.text.len() + c.len_utf8();
if len <= 3 {
let mut text = self.text.clone();
text.push(c);
return "11;".starts_with(&text);
}
len <= MAX_BODY && (c.is_ascii_alphanumeric() || c == ':' || c == '/')
}
fn finish(&mut self, out: &mut Vec<Scanned>, terminator: Event) {
match self.text.strip_prefix("11;") {
Some(body) => {
out.push(Scanned::Background(parse_color(body).map(mode_for)));
self.held.clear();
self.flush(out);
}
None => {
self.flush(out);
out.push(Scanned::Event(terminator));
}
}
}
}
fn is_esc(key: &KeyEvent) -> bool {
key.code == KeyCode::Esc && key.modifiers.is_empty()
}
fn is_late_open(key: &KeyEvent) -> bool {
key.code == KeyCode::Char(']') && key.modifiers == KeyModifiers::ALT
}
fn is_open(key: &KeyEvent, alt_ok: bool) -> bool {
key.code == KeyCode::Char(']')
&& (key.modifiers.is_empty() || (alt_ok && key.modifiers == KeyModifiers::ALT))
}
#[cfg(test)]
mod tests;