aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 00:07:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 16:29:44 +0300
commit3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch)
treea52280bccc824ca656f8bc986a756f30901974e4 /src/client
parent5d7e56737ef30d9de5802b0684a9428c40131dda (diff)
feat: add native windows beta support
Diffstat (limited to 'src/client')
-rw-r--r--src/client/input.rs310
-rw-r--r--src/client/mod.rs170
2 files changed, 432 insertions, 48 deletions
diff --git a/src/client/input.rs b/src/client/input.rs
index e9592db8..bbc37a58 100644
--- a/src/client/input.rs
+++ b/src/client/input.rs
@@ -1,22 +1,24 @@
//! Stdin input reading for the thin client.
//!
-//! Reads stdin bytes and forwards framed input to the main event loop.
-//! Unlike the monolithic herdr, the thin client does NOT parse input into
-//! key/mouse/paste events. It keeps enough byte-framing state to avoid splitting
-//! terminal control strings, then sends bytes to the server as `ClientMessage::Input`.
-//! The server handles semantic parsing.
+//! On Unix, reads stdin bytes and forwards framed input to the main event loop.
+//! The server handles semantic parsing. On Windows, crossterm may surface
+//! terminal control strings as character key events, so the reader re-frames
+//! those control bytes before forwarding semantic client input events.
//!
//! This is simpler and more reliable because:
//! - The server has the same input parsing code
//! - We avoid duplicating parsing logic in the client
//! - Host terminal control replies can be buffered or discarded before they leak
-use std::io::{self, Read};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[cfg(unix)]
+use std::io::{self, Read};
+#[cfg(unix)]
use std::os::fd::AsRawFd;
+#[cfg(windows)]
+use std::time::Duration;
use tokio::sync::mpsc;
use super::ClientLoopEvent;
@@ -31,6 +33,15 @@ use super::ClientLoopEvent;
/// The main loop receives the raw bytes and forwards them as
/// `ClientMessage::Input` to the server.
pub fn stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>) {
+ #[cfg(windows)]
+ return windows_stdin_reader_loop(event_tx, should_quit);
+
+ #[cfg(unix)]
+ unix_stdin_reader_loop(event_tx, should_quit);
+}
+
+#[cfg(unix)]
+fn unix_stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>) {
let stdin = io::stdin();
let mut reader = stdin.lock();
let mut scratch = [0u8; 4096];
@@ -70,16 +81,179 @@ pub fn stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &
}
}
+#[cfg(windows)]
+fn windows_stdin_reader_loop(
+ event_tx: mpsc::Sender<ClientLoopEvent>,
+ should_quit: &Arc<AtomicBool>,
+) {
+ let mut framer = crate::raw_input::RawInputFramer::default();
+ let mut raw_sequence_pending = false;
+
+ while !should_quit.load(Ordering::Acquire) {
+ match crossterm::event::poll(Duration::from_millis(10)) {
+ Ok(true) => {}
+ Ok(false) => {
+ if raw_sequence_pending {
+ tracing::debug!("windows input raw sequence timed out; flushing");
+ if !send_windows_raw_events(framer.flush_timeout(), &event_tx) {
+ return;
+ }
+ raw_sequence_pending = false;
+ }
+ continue;
+ }
+ Err(_) => break,
+ }
+
+ let event = match crossterm::event::read() {
+ Ok(event) => event,
+ Err(_) => break,
+ };
+
+ if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) {
+ tracing::debug!(
+ bytes = ?bytes,
+ pending_before = raw_sequence_pending,
+ "windows input routed through raw framer"
+ );
+ let events = framer.push(&bytes);
+ raw_sequence_pending = events.is_empty();
+ if !send_windows_raw_events(events, &event_tx) {
+ return;
+ }
+ continue;
+ }
+
+ if raw_sequence_pending {
+ tracing::debug!("windows input raw sequence interrupted by semantic event; flushing");
+ if !send_windows_raw_events(framer.flush_timeout(), &event_tx) {
+ return;
+ }
+ raw_sequence_pending = false;
+ }
+
+ if windows_event_is_control_key(&event) {
+ tracing::debug!(event = ?event, "windows control key forwarded as semantic input");
+ }
+
+ let Some(event) = crate::protocol::ClientInputEvent::from_crossterm(event) else {
+ continue;
+ };
+ if event_tx
+ .blocking_send(ClientLoopEvent::StdinEvents(vec![event]))
+ .is_err()
+ {
+ return;
+ }
+ }
+
+ if raw_sequence_pending {
+ let _ = send_windows_raw_events(framer.flush_timeout(), &event_tx);
+ }
+}
+
+#[cfg(windows)]
+fn windows_event_is_control_key(event: &crossterm::event::Event) -> bool {
+ use crossterm::event::{Event, KeyModifiers};
+
+ matches!(
+ event,
+ Event::Key(key)
+ if key.modifiers.contains(KeyModifiers::CONTROL)
+ || matches!(key.code, crossterm::event::KeyCode::Char(ch) if ch.is_control())
+ )
+}
+
+#[cfg(windows)]
+fn windows_key_raw_bytes(
+ event: &crossterm::event::Event,
+ raw_sequence_pending: bool,
+) -> Option<Vec<u8>> {
+ use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
+
+ let Event::Key(key) = event else {
+ return None;
+ };
+ if key.kind == KeyEventKind::Release {
+ return None;
+ }
+
+ match key.code {
+ KeyCode::Esc if key.modifiers.is_empty() => Some(vec![0x1b]),
+ KeyCode::Char(ch) if raw_sequence_pending || ch.is_control() => {
+ let mut bytes = Vec::new();
+ if key.modifiers.contains(KeyModifiers::ALT) {
+ bytes.push(0x1b);
+ }
+ let mut buf = [0; 4];
+ bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
+ Some(bytes)
+ }
+ _ => None,
+ }
+}
+
+#[cfg(windows)]
+fn send_windows_raw_events(
+ events: Vec<crate::raw_input::RawInputEvent>,
+ event_tx: &mpsc::Sender<ClientLoopEvent>,
+) -> bool {
+ let raw_event_count = events.len();
+ let events = events
+ .into_iter()
+ .filter_map(windows_client_input_event_from_raw)
+ .collect::<Vec<_>>();
+ if events.is_empty() {
+ return true;
+ }
+
+ tracing::debug!(
+ raw_event_count,
+ forwarded_event_count = events.len(),
+ "windows raw-framed input events forwarded"
+ );
+ event_tx
+ .blocking_send(ClientLoopEvent::StdinEvents(events))
+ .is_ok()
+}
+
+#[cfg(windows)]
+fn windows_client_input_event_from_raw(
+ event: crate::raw_input::RawInputEvent,
+) -> Option<crate::protocol::ClientInputEvent> {
+ match event {
+ crate::raw_input::RawInputEvent::Key(key) => Some(crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::from_crossterm(key.code)?,
+ modifiers: key.modifiers.bits(),
+ kind: crate::protocol::ClientKeyKind::from_crossterm(key.kind),
+ }),
+ crate::raw_input::RawInputEvent::Mouse(mouse) => {
+ Some(crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?,
+ column: mouse.column,
+ row: mouse.row,
+ modifiers: mouse.modifiers.bits(),
+ })
+ }
+ crate::raw_input::RawInputEvent::Paste(text) => {
+ Some(crate::protocol::ClientInputEvent::Paste { text })
+ }
+ crate::raw_input::RawInputEvent::OuterFocusGained => {
+ Some(crate::protocol::ClientInputEvent::FocusGained)
+ }
+ crate::raw_input::RawInputEvent::OuterFocusLost => {
+ Some(crate::protocol::ClientInputEvent::FocusLost)
+ }
+ crate::raw_input::RawInputEvent::HostDefaultColor { .. }
+ | crate::raw_input::RawInputEvent::Unsupported => None,
+ }
+}
+
#[cfg(unix)]
fn stdin_read_ready<R: AsRawFd>(reader: &R, timeout_ms: i32) -> Option<bool> {
poll_read_ready(reader.as_raw_fd(), timeout_ms)
}
-#[cfg(not(unix))]
-fn stdin_read_ready<R>(_reader: &R, _timeout_ms: i32) -> Option<bool> {
- None
-}
-
#[cfg(unix)]
fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> {
#[repr(C)]
@@ -113,7 +287,7 @@ fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> {
// Tests
// ---------------------------------------------------------------------------
-#[cfg(test)]
+#[cfg(all(test, unix))]
mod tests {
// The stdin reader thread is hard to unit test since it reads from actual stdin.
// Integration tests will verify the full client→server input flow.
@@ -121,6 +295,7 @@ mod tests {
use super::*;
+ #[cfg(unix)]
#[test]
fn stdin_input_event_carries_raw_bytes() {
let data = vec![0x1b, b'[', b'A']; // Up arrow escape sequence
@@ -131,3 +306,114 @@ mod tests {
}
}
}
+
+#[cfg(all(test, windows))]
+mod windows_tests {
+ use super::*;
+ use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
+
+ #[test]
+ fn windows_control_chars_are_reframed_as_raw_bytes() {
+ let escape = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
+ assert_eq!(
+ windows_key_raw_bytes(&escape, false).as_deref(),
+ Some(b"\x1b".as_slice())
+ );
+
+ let enter = Event::Key(KeyEvent::new(KeyCode::Char('\r'), KeyModifiers::empty()));
+ assert_eq!(
+ windows_key_raw_bytes(&enter, false).as_deref(),
+ Some(b"\r".as_slice())
+ );
+
+ let printable = Event::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()));
+ assert_eq!(windows_key_raw_bytes(&printable, false), None);
+
+ let pending_arrow_tail =
+ Event::Key(KeyEvent::new(KeyCode::Char('['), KeyModifiers::empty()));
+ assert_eq!(
+ windows_key_raw_bytes(&pending_arrow_tail, true).as_deref(),
+ Some(b"[".as_slice())
+ );
+ }
+
+ #[test]
+ fn windows_ctrl_d_semantic_event_encodes_to_eot() {
+ let event = Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
+ assert_eq!(windows_key_raw_bytes(&event, false), None);
+
+ let event =
+ crate::protocol::ClientInputEvent::from_crossterm(event).expect("ctrl-d converts");
+ let raw = event.to_raw_input_event();
+ let crate::raw_input::RawInputEvent::Key(key) = raw else {
+ panic!("expected key");
+ };
+ assert_eq!(key.code, KeyCode::Char('d'));
+ assert_eq!(key.modifiers, KeyModifiers::CONTROL);
+ assert_eq!(
+ crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy),
+ b"\x04"
+ );
+ }
+
+ #[test]
+ fn windows_eot_control_char_normalizes_to_ctrl_d() {
+ let event = Event::Key(KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::empty()));
+ let bytes = windows_key_raw_bytes(&event, false).expect("eot routes through raw framer");
+ assert_eq!(bytes, b"\x04");
+
+ let mut framer = crate::raw_input::RawInputFramer::default();
+ let events = framer.push(&bytes);
+ assert_eq!(events.len(), 1);
+
+ let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap())
+ .expect("raw eot converts");
+ assert_eq!(
+ event,
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('d'),
+ modifiers: KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ }
+ );
+ }
+
+ #[test]
+ fn windows_pending_escape_sequence_converts_to_semantic_arrow() {
+ let mut framer = crate::raw_input::RawInputFramer::default();
+ assert!(framer.push(b"\x1b").is_empty());
+ assert!(framer.push(b"[").is_empty());
+ let events = framer.push(b"A");
+ assert_eq!(events.len(), 1);
+
+ let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap())
+ .expect("raw arrow converts");
+ assert_eq!(
+ event,
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Up,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }
+ );
+ }
+
+ #[test]
+ fn windows_bare_escape_flushes_to_semantic_escape() {
+ let mut framer = crate::raw_input::RawInputFramer::default();
+ assert!(framer.push(b"\x1b").is_empty());
+ let events = framer.flush_timeout();
+ assert_eq!(events.len(), 1);
+
+ let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap())
+ .expect("raw escape converts");
+ assert_eq!(
+ event,
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Esc,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }
+ );
+ }
+}
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 564b5e27..a895dae0 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -16,25 +16,31 @@ mod input;
use std::collections::HashSet;
use std::io::{self, Write as _};
-use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
- EnableFocusChange, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind,
- PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
+ EnableFocusChange, EnableMouseCapture,
};
+#[cfg(unix)]
+use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
+#[cfg(not(windows))]
+use crossterm::event::{PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags};
use crossterm::execute;
+use interprocess::local_socket::traits::Stream as _;
+use interprocess::TryClone as _;
use tracing::{debug, info, warn};
+use crate::ipc::LocalStream;
use crate::protocol::render_ansi;
use crate::protocol::{
- self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
- ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
- MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
+ self, ClientKeybindings, ClientLaunchMode, ClientMessage, NotifyKind, RenderEncoding,
+ ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
};
+#[cfg(unix)]
+use crate::protocol::{AttachScrollDirection, AttachScrollSource, MAX_CLIPBOARD_IMAGE_PAYLOAD};
use crate::server::socket_paths::client_socket_path;
static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
@@ -58,17 +64,24 @@ struct ClientState {
/// Direct attach prefix escape state. None for full-app clients.
attach_escape: Option<AttachEscapeState>,
/// Rows scrolled for one direct-attach wheel notch.
+ #[cfg(unix)]
mouse_scroll_lines: usize,
/// Whether outer focus gain should force a full host-terminal redraw.
redraw_on_focus_gained: bool,
}
#[derive(Debug, Default)]
+#[cfg(windows)]
+struct AttachEscapeState;
+
+#[derive(Debug, Default)]
+#[cfg(unix)]
struct AttachEscapeState {
pending_prefix: bool,
}
#[derive(Debug)]
+#[cfg(unix)]
enum AttachInputAction {
Forward(Vec<u8>),
Scroll {
@@ -84,6 +97,7 @@ enum AttachInputAction {
}
impl AttachEscapeState {
+ #[cfg(unix)]
fn filter_input(
&mut self,
data: Vec<u8>,
@@ -126,6 +140,7 @@ impl AttachEscapeState {
}
}
+#[cfg(unix)]
fn attach_scroll_action(
data: &[u8],
viewport_rows: u16,
@@ -304,20 +319,16 @@ fn setup_terminal_with_capabilities(
if enable_client_protocols {
if mouse_capture {
- execute!(io::stdout(), EnableMouseCapture)?;
+ set_mouse_capture(true)?;
} else {
- execute!(io::stdout(), DisableMouseCapture)?;
+ set_mouse_capture(false)?;
}
- execute!(
- io::stdout(),
- EnableBracketedPaste,
- EnableFocusChange,
- PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
- )?;
+ execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?;
+ push_keyboard_enhancement_flags()?;
} else if mouse_capture {
- execute!(io::stdout(), EnableMouseCapture)?;
+ set_mouse_capture(true)?;
} else {
- execute!(io::stdout(), DisableMouseCapture)?;
+ set_mouse_capture(false)?;
}
let modify_other_keys_mode = enable_client_protocols
@@ -354,7 +365,12 @@ fn set_mouse_capture(enabled: bool) -> io::Result<()> {
if enabled {
execute!(io::stdout(), EnableMouseCapture)
} else {
- execute!(io::stdout(), DisableMouseCapture)
+ match execute!(io::stdout(), DisableMouseCapture) {
+ Ok(()) => Ok(()),
+ #[cfg(windows)]
+ Err(err) if err.to_string() == "Initial console modes not set" => Ok(()),
+ Err(err) => Err(err),
+ }
}
}
@@ -367,9 +383,9 @@ fn restore_terminal_state(reset_modify_other_keys: bool) {
let _ = io::stdout().flush();
}
+ let _ = pop_keyboard_enhancement_flags();
let _ = execute!(
io::stdout(),
- PopKeyboardEnhancementFlags,
DisableFocusChange,
DisableBracketedPaste,
DisableMouseCapture
@@ -378,6 +394,29 @@ fn restore_terminal_state(reset_modify_other_keys: bool) {
let _ = write_terminal_restore_postlude(&mut io::stdout());
}
+#[cfg(not(windows))]
+fn push_keyboard_enhancement_flags() -> io::Result<()> {
+ execute!(
+ io::stdout(),
+ PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
+ )
+}
+
+#[cfg(windows)]
+fn push_keyboard_enhancement_flags() -> io::Result<()> {
+ Ok(())
+}
+
+#[cfg(not(windows))]
+fn pop_keyboard_enhancement_flags() -> io::Result<()> {
+ execute!(io::stdout(), PopKeyboardEnhancementFlags)
+}
+
+#[cfg(windows)]
+fn pop_keyboard_enhancement_flags() -> io::Result<()> {
+ Ok(())
+}
+
impl Drop for TerminalGuard {
fn drop(&mut self) {
restore_terminal_state(self.reset_modify_other_keys);
@@ -414,7 +453,7 @@ fn requested_keybindings() -> ClientKeybindings {
/// Sends Hello with the terminal size and protocol version, reads the Welcome
/// response. Returns Ok(()) on success, or an error if the server rejects us.
fn do_handshake(
- stream: &mut UnixStream,
+ stream: &mut LocalStream,
cols: u16,
rows: u16,
cell_width_px: u32,
@@ -445,13 +484,13 @@ fn do_handshake(
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
// Read Welcome.
- stream
- .set_read_timeout(Some(Duration::from_secs(5)))
- .map_err(ClientError::ConnectionFailed)?;
+ if let Err(err) = stream.set_recv_timeout(Some(Duration::from_secs(5))) {
+ debug!(err = %err, "client handshake read timeout unavailable");
+ }
let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?;
- stream
- .set_read_timeout(None)
- .map_err(ClientError::ConnectionFailed)?;
+ if let Err(err) = stream.set_recv_timeout(None) {
+ debug!(err = %err, "failed to clear client handshake read timeout");
+ }
match welcome {
ServerMessage::Welcome {
@@ -478,7 +517,11 @@ fn do_handshake(
/// Internal events for the client event loop.
enum ClientLoopEvent {
/// Raw input bytes from stdin.
+ #[cfg(unix)]
StdinInput(Vec<u8>),
+ /// Structured input events from platforms without Unix-style stdin bytes.
+ #[cfg(windows)]
+ StdinEvents(Vec<crate::protocol::ClientInputEvent>),
/// Terminal resize detected.
Resize(u16, u16, u32, u32),
/// Server message received.
@@ -503,6 +546,7 @@ pub fn run_client() -> io::Result<()> {
}
/// Runs a direct terminal attach client.
+#[cfg(unix)]
pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()> {
run_client_with_mode(
RenderEncoding::TerminalAnsi,
@@ -512,6 +556,16 @@ pub fn run_terminal_attach(terminal_id: String, takeover: bool) -> io::Result<()
)
}
+/// Direct terminal attach is Unix raw-byte input only until Windows gets a semantic attach path.
+#[cfg(windows)]
+pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result<()> {
+ debug_assert!(!crate::platform::capabilities().direct_terminal_attach);
+ Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "direct terminal attach is not supported on Windows yet",
+ ))
+}
+
fn run_client_with_mode(
requested_encoding: RenderEncoding,
attach_request: Option<(String, bool)>,
@@ -521,6 +575,7 @@ fn run_client_with_mode(
init_logging();
let loaded_config = crate::config::Config::load();
+ let mouse_capture = loaded_config.config.ui.mouse_capture;
let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines();
let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained;
let sound_config = loaded_config.config.ui.sound;
@@ -533,7 +588,7 @@ fn run_client_with_mode(
info!(path = %socket_path.display(), "{log_message}");
// Try to connect to the server.
- let mut stream = match UnixStream::connect(&socket_path) {
+ let mut stream = match crate::ipc::connect_local_stream(&socket_path) {
Ok(s) => s,
Err(err) => {
// Server unreachable — show clear error and exit.
@@ -578,10 +633,10 @@ fn run_client_with_mode(
// Now set up the terminal. This must happen AFTER the handshake succeeds,
// so we don't leave the terminal in raw mode if the server rejects us.
let direct_attach = attach_escape.is_some();
- let _guard = if direct_attach {
+ let terminal_guard = if direct_attach {
setup_direct_attach_terminal()
} else {
- setup_terminal(false)
+ setup_terminal(mouse_capture)
}
.map_err(|err| {
eprintln!("herdr: failed to set up terminal: {err}");
@@ -589,10 +644,10 @@ fn run_client_with_mode(
})?;
// Install a panic hook to restore the terminal on panic (same as monolithic).
- let in_tmux = std::env::var("TMUX").is_ok();
+ let panic_resets_modify_other_keys = terminal_guard.reset_modify_other_keys;
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
- restore_terminal_state(in_tmux);
+ restore_terminal_state(panic_resets_modify_other_keys);
original_hook(info);
}));
@@ -620,7 +675,7 @@ fn run_client_with_mode(
mouse_scroll_lines,
redraw_on_focus_gained,
kitty_graphics_enabled,
- false,
+ mouse_capture,
negotiated_encoding,
attach_escape,
)
@@ -628,7 +683,7 @@ fn run_client_with_mode(
});
// Restore the terminal before printing any final status message.
- drop(_guard);
+ drop(terminal_guard);
if let Err(err) = result {
eprintln!("herdr: {err}");
@@ -660,7 +715,7 @@ fn run_client_with_mode(
/// - server reader thread → reads ServerMessages and sends to main loop
/// - main loop: coordinates input, output, and server communication
async fn run_client_loop(
- stream: UnixStream,
+ stream: LocalStream,
cols: u16,
rows: u16,
should_quit: Arc<AtomicBool>,
@@ -672,6 +727,9 @@ async fn run_client_loop(
negotiated_encoding: RenderEncoding,
attach_escape: Option<AttachEscapeState>,
) -> Result<(), ClientError> {
+ #[cfg(windows)]
+ let _ = mouse_scroll_lines;
+
let mut state = ClientState {
blit_encoder: render_ansi::BlitEncoder::new(),
mouse_capture_active,
@@ -679,6 +737,7 @@ async fn run_client_loop(
sound_config,
kitty_graphics_enabled,
attach_escape,
+ #[cfg(unix)]
mouse_scroll_lines,
redraw_on_focus_gained,
};
@@ -694,7 +753,7 @@ async fn run_client_loop(
input::stdin_reader_loop(stdin_tx, &stdin_quit);
});
- if state.attach_escape.is_none() {
+ if state.attach_escape.is_none() && should_query_host_terminal_theme() {
query_host_terminal_theme();
}
@@ -739,6 +798,7 @@ async fn run_client_loop(
};
match event {
+ #[cfg(unix)]
ClientLoopEvent::StdinInput(data) => {
let data = if let Some(attach_escape) = &mut state.attach_escape {
match attach_escape.filter_input(
@@ -817,6 +877,26 @@ async fn run_client_loop(
return Err(ClientError::ConnectionLost(e));
}
}
+ #[cfg(windows)]
+ ClientLoopEvent::StdinEvents(events) => {
+ if state.attach_escape.is_some() {
+ continue;
+ }
+ let raw_events = events
+ .iter()
+ .map(crate::protocol::ClientInputEvent::to_raw_input_event)
+ .collect::<Vec<_>>();
+ if crate::raw_input::events_require_host_surface_redraw(
+ &raw_events,
+ state.redraw_on_focus_gained,
+ ) {
+ state.request_full_redraw();
+ }
+ let msg = ClientMessage::InputEvents { events };
+ if let Err(e) = write_to_server(&mut write_stream, &msg) {
+ return Err(ClientError::ConnectionLost(e));
+ }
+ }
ClientLoopEvent::Resize(new_cols, new_rows, cell_width_px, cell_height_px) => {
state.reported_size = (new_cols, new_rows);
let msg = ClientMessage::Resize {
@@ -917,7 +997,7 @@ async fn run_client_loop(
/// Blocking thread that reads ServerMessages from the server and sends them
/// to the main event loop.
fn server_reader_thread(
- mut stream: UnixStream,
+ mut stream: LocalStream,
event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
max_frame_size: usize,
@@ -970,7 +1050,7 @@ fn server_reader_thread(
// ---------------------------------------------------------------------------
/// Writes a message to the server stream (blocking).
-fn write_to_server(stream: &mut UnixStream, msg: &ClientMessage) -> io::Result<()> {
+fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result<()> {
protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string()))
}
@@ -1063,6 +1143,7 @@ fn sound_from_notify_message(message: &str) -> Option<crate::sound::Sound> {
}
}
+#[cfg(unix)]
fn should_bridge_clipboard_image_paste(data: &[u8]) -> bool {
if data == b"\x1b[200~\x1b[201~" {
return true;
@@ -1254,6 +1335,10 @@ fn query_host_terminal_theme() {
let _ = write_host_terminal_theme_query(io::stdout());
}
+fn should_query_host_terminal_theme() -> bool {
+ !cfg!(windows)
+}
+
fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> {
writer.write_all(crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes())?;
writer.flush()
@@ -1330,6 +1415,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[test]
fn clipboard_image_paste_bridge_triggers_on_ctrl_v_and_empty_paste() {
assert!(should_bridge_clipboard_image_paste(&[0x16]));
@@ -1400,12 +1486,18 @@ mod tests {
}
#[test]
+ fn host_terminal_theme_query_is_disabled_on_windows() {
+ assert_eq!(should_query_host_terminal_theme(), !cfg!(windows));
+ }
+
+ #[test]
fn terminal_restore_postlude_restores_visible_default_cursor() {
let mut output = Vec::new();
write_terminal_restore_postlude(&mut output).unwrap();
assert_eq!(output, b"\x1b[?25h\x1b[0 q");
}
+ #[cfg(unix)]
#[test]
fn attach_escape_detaches_on_prefix_q() {
let mut escape = AttachEscapeState::default();
@@ -1419,6 +1511,7 @@ mod tests {
));
}
+ #[cfg(unix)]
#[test]
fn attach_escape_sends_literal_prefix_on_double_prefix() {
let mut escape = AttachEscapeState::default();
@@ -1432,6 +1525,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[test]
fn attach_escape_forwards_prefix_before_non_escape_key() {
let mut escape = AttachEscapeState::default();
@@ -1445,6 +1539,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[test]
fn attach_escape_turns_wheel_into_scroll_action() {
let mut escape = AttachEscapeState::default();
@@ -1467,6 +1562,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[test]
fn attach_escape_swallows_non_wheel_mouse_reports() {
let mut escape = AttachEscapeState::default();
@@ -1476,6 +1572,7 @@ mod tests {
));
}
+ #[cfg(unix)]
#[test]
fn attach_escape_turns_plain_page_keys_into_scroll_actions() {
let mut escape = AttachEscapeState::default();
@@ -1518,6 +1615,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[test]
fn attach_escape_forwards_modified_page_key() {
let mut escape = AttachEscapeState::default();