use std::sync::mpsc::{self as stdmpsc, TryRecvError};
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::event::{DisableFocusChange, EnableFocusChange};
use crossterm::execute;
use tokio::sync::mpsc;
use super::InputEvent;
fn paste_candidate_char(ev: &Event) -> Option<char> {
let Event::Key(KeyEvent {
kind,
code,
modifiers,
..
}) = ev
else {
return None;
};
if *kind != KeyEventKind::Press {
return None;
}
let allowed = KeyModifiers::SHIFT | KeyModifiers::NONE;
if !(modifiers.difference(allowed).is_empty()) {
return None;
}
match code {
KeyCode::Char(c) => Some(*c),
KeyCode::Enter if modifiers.contains(KeyModifiers::SHIFT) => None,
KeyCode::Enter => Some('\n'),
KeyCode::Tab => Some('\t'),
_ => None,
}
}
fn is_paste_burst(chars: &[char]) -> bool {
if chars.len() < 2 {
return false;
}
let mut has_enter = false;
let mut has_text_char = false;
let mut newline_count = 0usize;
for &c in chars {
if c == '\n' {
has_enter = true;
newline_count += 1;
}
if !c.is_whitespace() {
has_text_char = true;
}
}
if !has_enter || !has_text_char {
return false;
}
let line_count = newline_count + 1;
let non_newline_count = chars.len() - newline_count;
if line_count >= 3 && non_newline_count <= line_count {
return false;
}
true
}
#[derive(Debug)]
pub enum ReaderCommand {
Pause,
Resume,
Shutdown,
}
pub struct ReaderHandle {
join: Option<std::thread::JoinHandle<()>>,
cmd_tx: stdmpsc::Sender<(ReaderCommand, Option<stdmpsc::Sender<()>>)>,
focus_tracking_enabled: bool,
}
impl ReaderHandle {
pub fn pause_blocking(&self) -> std::io::Result<()> {
let (ack_tx, ack_rx) = stdmpsc::channel();
if self
.cmd_tx
.send((ReaderCommand::Pause, Some(ack_tx)))
.is_err()
{
return Ok(()); }
match ack_rx.recv_timeout(Duration::from_secs(2)) {
Ok(()) => Ok(()),
Err(_) => Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"reader thread did not ack Pause within 2s",
)),
}
}
pub fn resume(&self) {
let _ = self.cmd_tx.send((ReaderCommand::Resume, None));
}
}
impl Drop for ReaderHandle {
fn drop(&mut self) {
let _ = self.cmd_tx.send((ReaderCommand::Shutdown, None));
if self.focus_tracking_enabled {
let _ = execute!(std::io::stdout(), DisableFocusChange);
atomcode_core::notify::set_terminal_focus_state(None);
}
if let Some(join) = self.join.take() {
drop(join);
}
}
}
pub fn spawn(tx: mpsc::UnboundedSender<InputEvent>) -> ReaderHandle {
let focus_tracking_enabled = terminal_supports_focus_tracking();
if focus_tracking_enabled {
let _ = execute!(std::io::stdout(), EnableFocusChange);
atomcode_core::notify::set_terminal_focus_state(Some(true));
}
let (cmd_tx, cmd_rx) = stdmpsc::channel::<(ReaderCommand, Option<stdmpsc::Sender<()>>)>();
let join = std::thread::spawn(move || run(tx, cmd_rx));
ReaderHandle {
join: Some(join),
cmd_tx,
focus_tracking_enabled,
}
}
fn terminal_supports_focus_tracking() -> bool {
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
let lc_terminal = std::env::var("LC_TERMINAL").unwrap_or_default();
term_program == "iTerm.app"
|| term_program.eq_ignore_ascii_case("iTerm2")
|| lc_terminal.eq_ignore_ascii_case("iTerm2")
}
#[derive(Debug, PartialEq, Eq)]
enum PollAction {
Read,
Continue,
Exit,
Sleep,
}
fn classify_poll(res: std::io::Result<bool>, tx_closed: bool) -> PollAction {
match res {
Ok(true) => PollAction::Read,
Ok(false) if tx_closed => PollAction::Exit,
Ok(false) => PollAction::Continue,
Err(_) => PollAction::Sleep,
}
}
const MODIFIER_ENTER_DEDUP: Duration = Duration::from_millis(40);
fn run(
tx: mpsc::UnboundedSender<InputEvent>,
cmd_rx: stdmpsc::Receiver<(ReaderCommand, Option<stdmpsc::Sender<()>>)>,
) {
let mut paused = false;
let mut last_mod_enter: Option<(KeyModifiers, std::time::Instant)> = None;
loop {
if paused {
match cmd_rx.recv() {
Ok((ReaderCommand::Resume, _)) => {
paused = false;
}
Ok((ReaderCommand::Shutdown, _)) | Err(_) => return,
Ok((ReaderCommand::Pause, ack)) => {
if let Some(ack) = ack {
let _ = ack.send(());
}
}
}
continue;
}
match cmd_rx.try_recv() {
Ok((ReaderCommand::Pause, ack)) => {
paused = true;
if let Some(ack) = ack {
let _ = ack.send(());
}
continue;
}
Ok((ReaderCommand::Resume, _)) => {
}
Ok((ReaderCommand::Shutdown, _)) => return,
Err(TryRecvError::Disconnected) => return,
Err(TryRecvError::Empty) => {}
}
match classify_poll(event::poll(Duration::from_millis(100)), tx.is_closed()) {
PollAction::Read => {}
PollAction::Continue => continue,
PollAction::Exit => return,
PollAction::Sleep => {
std::thread::sleep(Duration::from_millis(50));
continue;
}
}
let ev = match event::read() {
Ok(e) => e,
Err(_) => {
std::thread::sleep(Duration::from_millis(50));
continue;
}
};
if let Event::Key(k) = &ev {
if k.kind == KeyEventKind::Press && k.code == KeyCode::Enter && !k.modifiers.is_empty()
{
let now = std::time::Instant::now();
if let Some((last_mods, last_at)) = last_mod_enter {
if last_mods == k.modifiers
&& now.duration_since(last_at) < MODIFIER_ENTER_DEDUP
{
crate::tuix_trace!("RD", "dedup mod+Enter {:?}", k.modifiers);
last_mod_enter = Some((k.modifiers, now));
continue;
}
}
last_mod_enter = Some((k.modifiers, now));
}
}
if let Some(c0) = paste_candidate_char(&ev) {
let mut chars = vec![c0];
let mut trailing: Option<Event> = None;
const BATCH_CAP: usize = 8192;
while chars.len() < BATCH_CAP {
match event::poll(Duration::from_millis(2)) {
Ok(true) => {}
_ => break,
}
let nxt = match event::read() {
Ok(e) => e,
Err(_) => break,
};
if let Event::Key(k) = &nxt {
if k.kind != KeyEventKind::Press {
continue;
}
}
match paste_candidate_char(&nxt) {
Some(c) => {
chars.push(c);
}
None => {
trailing = Some(nxt);
break;
}
}
}
if is_paste_burst(&chars) {
let text: String = chars.into_iter().collect();
crate::tuix_trace!("RD", "paste-burst synth len={}", text.len());
if tx.send(InputEvent::Paste(text)).is_err() {
return;
}
} else {
for c in chars {
let code = match c {
'\n' => KeyCode::Enter,
'\t' => KeyCode::Tab,
other => KeyCode::Char(other),
};
let k = KeyEvent::new(code, KeyModifiers::NONE);
if tx.send(InputEvent::Key(k)).is_err() {
return;
}
}
}
if let Some(ev) = trailing {
let msg = match ev {
Event::Key(k) => {
crate::tuix_trace!("RD", "key {:?} {:?}", k.kind, k.code);
InputEvent::Key(k)
}
Event::Paste(p) => InputEvent::Paste(p),
Event::Resize(w, h) => InputEvent::Resize(w, h),
Event::Mouse(m) => match mouse_input_event(m) {
Some(ev) => ev,
None => continue,
},
Event::FocusGained => {
atomcode_core::notify::set_terminal_focus_state(Some(true));
continue;
}
Event::FocusLost => {
atomcode_core::notify::set_terminal_focus_state(Some(false));
continue;
}
};
if tx.send(msg).is_err() {
return;
}
}
continue;
}
let msg = match ev {
Event::Key(k) => {
crate::tuix_trace!("RD", "key {:?} {:?}", k.kind, k.code);
InputEvent::Key(k)
}
Event::Paste(p) => {
crate::tuix_trace!("RD", "paste len={}", p.len());
InputEvent::Paste(p)
}
Event::Resize(w, h) => {
crate::tuix_trace!("RD", "resize {}x{}", w, h);
InputEvent::Resize(w, h)
}
Event::Mouse(m) => match mouse_input_event(m) {
Some(ev) => ev,
None => continue,
},
Event::FocusGained => {
atomcode_core::notify::set_terminal_focus_state(Some(true));
continue;
}
Event::FocusLost => {
atomcode_core::notify::set_terminal_focus_state(Some(false));
continue;
}
};
if tx.send(msg).is_err() {
return;
}
}
}
fn mouse_input_event(m: crossterm::event::MouseEvent) -> Option<InputEvent> {
crate::tuix_trace!("RD", "mouse kind={:?} col={} row={}", m.kind, m.column, m.row);
match m.kind {
crossterm::event::MouseEventKind::ScrollUp => {
crate::tuix_trace!("RD", "mouse scroll up");
Some(InputEvent::MouseScroll(-3))
}
crossterm::event::MouseEventKind::ScrollDown => {
crate::tuix_trace!("RD", "mouse scroll down");
Some(InputEvent::MouseScroll(3))
}
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
Some(InputEvent::MouseDown {
col: m.column,
row: m.row,
})
}
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
Some(InputEvent::MouseDrag {
col: m.column,
row: m.row,
})
}
crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
Some(InputEvent::MouseUp)
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pause_acks_then_resume_wakes() {
let (tx, _rx) = mpsc::unbounded_channel::<InputEvent>();
let (cmd_tx, cmd_rx) = stdmpsc::channel();
let worker = std::thread::spawn(move || run(tx, cmd_rx));
let (ack_tx, ack_rx) = stdmpsc::channel();
cmd_tx
.send((ReaderCommand::Pause, Some(ack_tx)))
.expect("send pause");
ack_rx
.recv_timeout(Duration::from_secs(2))
.expect("pause ACK arrives within 2s");
let (ack_tx2, ack_rx2) = stdmpsc::channel();
cmd_tx
.send((ReaderCommand::Pause, Some(ack_tx2)))
.expect("send second pause");
ack_rx2
.recv_timeout(Duration::from_secs(2))
.expect("re-entrant pause also ACKs");
cmd_tx
.send((ReaderCommand::Resume, None))
.expect("send resume");
cmd_tx
.send((ReaderCommand::Shutdown, None))
.expect("send shutdown");
worker.join().expect("worker thread joins cleanly");
}
#[test]
fn modifier_enter_dedup_window_brackets_autorepeat_but_not_humans() {
let win = MODIFIER_ENTER_DEDUP.as_millis() as u64;
assert!(
win > 30,
"dedup window {}ms must exceed typical OS autorepeat (30ms) \
so autorepeat duplicates are caught",
win
);
assert!(
win < 80,
"dedup window {}ms must stay below fastest realistic human \
chord repeat (~100ms) so intentional Shift+Enter×2 still works",
win
);
}
#[test]
fn paste_candidate_rejects_shift_enter() {
let ev = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
assert_eq!(
paste_candidate_char(&ev),
None,
"Shift+Enter is a command (InsertNewline), not paste content"
);
}
#[test]
fn paste_candidate_accepts_plain_enter() {
let ev = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(paste_candidate_char(&ev), Some('\n'));
}
#[test]
fn pure_newline_burst_is_not_paste() {
assert!(!is_paste_burst(&['\n', '\n']));
assert!(!is_paste_burst(&['\n', '\n', '\n']));
}
#[test]
fn whitespace_only_burst_is_not_paste() {
assert!(!is_paste_burst(&[' ', '\n']));
assert!(!is_paste_burst(&['\t', '\n']));
assert!(!is_paste_burst(&['\n', ' ', '\t', '\n']));
}
#[test]
fn text_with_newline_burst_is_paste() {
assert!(is_paste_burst(&['h', 'i', '\n']));
assert!(is_paste_burst(&['\n', 'h', 'i']));
assert!(is_paste_burst(&['l', 'i', 'n', 'e', '1', '\n', 'l', 'i', 'n', 'e', '2']));
}
#[test]
fn no_newline_burst_is_not_paste() {
assert!(!is_paste_burst(&['a', 'b', 'c', 'd']));
}
#[test]
fn ime_commit_storm_is_not_paste() {
assert!(!is_paste_burst(&['首', '\n', '页', '\n', '中', '\n', '的', '\n']));
assert!(!is_paste_burst(&['首', '\n', '页', '\n', '中']));
assert!(!is_paste_burst(&['a', '\n', 'b', '\n', 'c', '\n']));
}
#[test]
fn two_line_short_paste_still_recognised() {
assert!(is_paste_burst(&['a', '\n', 'b']));
}
#[test]
fn cjk_multi_line_paste_still_recognised() {
assert!(is_paste_burst(&['你', '好', '世', '界', '\n', '再', '见']));
}
#[test]
fn singleton_burst_is_not_paste() {
assert!(!is_paste_burst(&['\n']));
assert!(!is_paste_burst(&['x']));
assert!(!is_paste_burst(&[]));
}
#[test]
fn classify_poll_err_is_sleep_not_exit() {
let boom = std::io::Error::new(std::io::ErrorKind::Other, "resize glitch");
assert_eq!(classify_poll(Err(boom), false), PollAction::Sleep);
let boom = std::io::Error::new(std::io::ErrorKind::Other, "another glitch");
assert_eq!(
classify_poll(Err(boom), true),
PollAction::Sleep,
"Err must NOT be Exit even when tx is closed — exit path \
is only for clean shutdown via Ok(false) + closed tx"
);
}
#[test]
fn classify_poll_ok_branches() {
assert_eq!(classify_poll(Ok(true), false), PollAction::Read);
assert_eq!(
classify_poll(Ok(true), true),
PollAction::Read,
"Ok(true) always reads — caller will notice tx closed on send"
);
assert_eq!(classify_poll(Ok(false), false), PollAction::Continue);
assert_eq!(classify_poll(Ok(false), true), PollAction::Exit);
}
#[test]
fn paused_worker_exits_on_sender_drop() {
let (tx, _rx) = mpsc::unbounded_channel::<InputEvent>();
let (cmd_tx, cmd_rx) = stdmpsc::channel();
let worker = std::thread::spawn(move || run(tx, cmd_rx));
let (ack_tx, ack_rx) = stdmpsc::channel();
cmd_tx
.send((ReaderCommand::Pause, Some(ack_tx)))
.expect("send pause");
ack_rx
.recv_timeout(Duration::from_secs(2))
.expect("pause ACK");
drop(cmd_tx); worker
.join()
.expect("paused worker joins after sender drop");
}
}