diff options
| author | WakaTaira <50697207+WakaTaira@users.noreply.github.com> | 2026-08-02 22:53:08 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-02 16:53:08 +0300 |
| commit | 890113943999bd0332f535864b982190907449c4 (patch) | |
| tree | 602fa09cf67a48ef19eab38ba3cb0b67a9f17189 /src/client | |
| parent | d742e51530a78b77e1debe23a72ea7178810f3fd (diff) | |
fix(client): query host cell size when the ioctl reports no pixels (#2160)
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/input.rs | 13 | ||||
| -rw-r--r-- | src/client/mod.rs | 178 |
2 files changed, 171 insertions, 20 deletions
diff --git a/src/client/input.rs b/src/client/input.rs index 09e0eb92..fd374491 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -39,11 +39,16 @@ pub fn stdin_reader_loop( event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, host_color_query_sent: bool, + host_cell_size_query_sent: bool, host_mouse_capture_active: Arc<AtomicBool>, ) { #[cfg(windows)] { - let _ = (host_color_query_sent, host_mouse_capture_active); + let _ = ( + host_color_query_sent, + host_cell_size_query_sent, + host_mouse_capture_active, + ); windows_stdin_reader_loop(event_tx, should_quit); } @@ -52,6 +57,7 @@ pub fn stdin_reader_loop( event_tx, should_quit, host_color_query_sent, + host_cell_size_query_sent, host_mouse_capture_active, ); } @@ -61,6 +67,7 @@ fn unix_stdin_reader_loop( event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, host_color_query_sent: bool, + host_cell_size_query_sent: bool, host_mouse_capture_active: Arc<AtomicBool>, ) { let stdin = io::stdin(); @@ -71,6 +78,9 @@ fn unix_stdin_reader_loop( framer.host_color_query_sent(); framer.enable_host_color_scheme_change_tracking(); } + if host_cell_size_query_sent { + framer.host_cell_size_query_sent(); + } let mut pending_palette = Vec::new(); while !should_quit.load(Ordering::Acquire) { @@ -422,6 +432,7 @@ fn windows_client_input_event_from_raw( crate::raw_input::RawInputEvent::HostDefaultColor { .. } | crate::raw_input::RawInputEvent::HostPaletteColors { .. } | crate::raw_input::RawInputEvent::HostColorSchemeChanged(_) + | crate::raw_input::RawInputEvent::HostCellSizeReport { .. } | crate::raw_input::RawInputEvent::Unsupported => None, } } diff --git a/src/client/mod.rs b/src/client/mod.rs index d7fcdf71..c0fe73d3 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -16,7 +16,7 @@ mod input; use std::collections::HashSet; use std::io::{self, BufRead, Write as _}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; @@ -1158,7 +1158,7 @@ fn run_client_with_mode( // Get the terminal geometry before handshake (before raw mode). let (cols, rows, cell_width_px, cell_height_px) = - current_terminal_geometry(kitty_graphics_enabled); + initial_terminal_geometry(kitty_graphics_enabled); // Perform handshake while the stream is still in blocking mode. let negotiated_encoding = match do_handshake( @@ -1239,6 +1239,8 @@ fn run_client_with_mode( stream, cols, rows, + cell_width_px, + cell_height_px, should_quit, loop_config, negotiated_encoding, @@ -1283,6 +1285,8 @@ async fn run_client_loop( stream: LocalStream, cols: u16, rows: u16, + initial_cell_width_px: u32, + initial_cell_height_px: u32, should_quit: Arc<AtomicBool>, config: ClientLoopConfig, negotiated_encoding: RenderEncoding, @@ -1312,6 +1316,9 @@ async fn run_client_loop( }; debug!(?negotiated_encoding, "client render encoding active"); let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active)); + // Cell size reported by the host terminal, packed as width<<32 | height. + // Zero means the host has not reported one. + let reported_cell_size = Arc::new(AtomicU64::new(0)); // Channel for events from the stdin, resize, and server reader threads. let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); @@ -1319,6 +1326,10 @@ async fn run_client_loop( // Spawn the stdin reader thread. let will_query_host_terminal_theme = state.attach_escape.is_none() && should_query_host_terminal_theme(); + // Terminals behind ConPTY report no pixel size through the ioctl, so ask the + // host terminal directly instead of falling back to an assumed cell size. + let will_query_host_cell_size = state.attach_escape.is_none() + && host_cell_size_query_required(state.kitty_graphics_enabled); let stdin_quit = should_quit.clone(); let stdin_tx = event_tx.clone(); let stdin_mouse_capture_active = host_mouse_capture_active.clone(); @@ -1327,6 +1338,7 @@ async fn run_client_loop( stdin_tx, &stdin_quit, will_query_host_terminal_theme, + will_query_host_cell_size, stdin_mouse_capture_active, ); }); @@ -1335,12 +1347,26 @@ async fn run_client_loop( query_host_terminal_theme(); } + if will_query_host_cell_size { + query_host_cell_size(); + } + // Spawn the resize poller thread. let resize_quit = should_quit.clone(); let resize_tx = event_tx.clone(); + let resize_cell_size = reported_cell_size.clone(); let kitty_graphics_enabled = state.kitty_graphics_enabled; std::thread::spawn(move || { - resize_poll_loop(resize_tx, cols, rows, kitty_graphics_enabled, &resize_quit); + resize_poll_loop( + resize_tx, + cols, + rows, + initial_cell_width_px, + initial_cell_height_px, + kitty_graphics_enabled, + &resize_cell_size, + &resize_quit, + ); }); // Spawn the server reader thread (blocking reads from the socket). @@ -1429,6 +1455,9 @@ async fn run_client_loop( if crate::raw_input::events_require_host_terminal_theme_query(&events) { query_host_terminal_theme(); } + if let Some((width_px, height_px)) = reported_cell_size_from_events(&events) { + store_reported_cell_size(&reported_cell_size, width_px, height_px); + } data }; if should_bridge_clipboard_image_paste( @@ -2085,23 +2114,56 @@ fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { // Resize polling // --------------------------------------------------------------------------- -fn current_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32) { - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - if !kitty_graphics_enabled { - return (cols, rows, 0, 0); - } - let Ok(size) = crossterm::terminal::window_size() else { - return (cols, rows, 8, 16); - }; +/// Cell size assumed when neither the terminal size ioctl nor the host +/// terminal reports pixel dimensions. +const DEFAULT_CELL_WIDTH_PX: u32 = 8; +const DEFAULT_CELL_HEIGHT_PX: u32 = 16; + +/// Cell size derived from the terminal size ioctl, if it reports pixels. +fn ioctl_cell_size() -> Option<(u32, u32)> { + let size = crossterm::terminal::window_size().ok()?; if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 { - return (cols, rows, 8, 16); + return None; } - ( - cols, - rows, + Some(( (size.width as u32 / size.columns as u32).max(1), (size.height as u32 / size.rows as u32).max(1), - ) + )) +} + +/// Cell size used when the ioctl reports no pixels. +fn cell_size_fallback(reported: u64) -> (u32, u32) { + unpack_cell_size(reported).unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX)) +} + +#[cfg(any(unix, test))] +fn pack_cell_size(width_px: u32, height_px: u32) -> u64 { + (u64::from(width_px) << 32) | u64::from(height_px) +} + +fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> { + let width_px = (packed >> 32) as u32; + let height_px = (packed & u64::from(u32::MAX)) as u32; + (width_px > 0 && height_px > 0).then_some((width_px, height_px)) +} + +fn current_terminal_geometry( + kitty_graphics_enabled: bool, + reported_cell_size: &AtomicU64, +) -> (u16, u16, u32, u32) { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + if !kitty_graphics_enabled { + return (cols, rows, 0, 0); + } + let (cell_width_px, cell_height_px) = ioctl_cell_size() + .unwrap_or_else(|| cell_size_fallback(reported_cell_size.load(Ordering::Acquire))); + (cols, rows, cell_width_px, cell_height_px) +} + +/// Reads the terminal geometry before the handshake, before any host cell +/// size report can exist. +fn initial_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32) { + current_terminal_geometry(kitty_graphics_enabled, &AtomicU64::new(0)) } /// Reports polled changes and signalled resizes that return to the same size. @@ -2114,16 +2176,21 @@ fn resize_report_required( } /// Watches the terminal size and sends resize events when it changes. +/// +/// The baseline cell size must match what the handshake sent to the server: +/// reading a fresh one here would race the host cell size reply and could +/// swallow the first change. fn resize_poll_loop( resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, initial_cols: u16, initial_rows: u16, + initial_cell_width: u32, + initial_cell_height: u32, kitty_graphics_enabled: bool, + reported_cell_size: &AtomicU64, should_quit: &Arc<AtomicBool>, ) { crate::platform::watch_terminal_resize_signal(); - let (_, _, initial_cell_width, initial_cell_height) = - current_terminal_geometry(kitty_graphics_enabled); let mut last_size = ( initial_cols, initial_rows, @@ -2133,7 +2200,7 @@ fn resize_poll_loop( while !should_quit.load(Ordering::Acquire) { std::thread::sleep(Duration::from_millis(100)); let signalled = crate::platform::take_terminal_resize_signal(); - let new_size = current_terminal_geometry(kitty_graphics_enabled); + let new_size = current_terminal_geometry(kitty_graphics_enabled, reported_cell_size); if resize_report_required(signalled, new_size, last_size) { last_size = new_size; if resize_tx @@ -2167,6 +2234,49 @@ fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> writer.flush() } +/// XTWINOPS request for the host terminal cell size in pixels. +const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t"; + +fn query_host_cell_size() { + let _ = write_host_cell_size_query(io::stdout()); +} + +fn should_query_host_cell_size() -> bool { + !cfg!(windows) +} + +/// Only pane graphics need pixel dimensions, and only when the ioctl cannot +/// provide them. +fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool { + kitty_graphics_enabled && should_query_host_cell_size() && ioctl_cell_size().is_none() +} + +fn write_host_cell_size_query(mut writer: impl io::Write) -> io::Result<()> { + writer.write_all(HOST_CELL_SIZE_QUERY)?; + writer.flush() +} + +#[cfg(any(unix, test))] +fn store_reported_cell_size(reported_cell_size: &AtomicU64, width_px: u32, height_px: u32) { + let packed = pack_cell_size(width_px, height_px); + if reported_cell_size.swap(packed, Ordering::AcqRel) != packed { + debug!(width_px, height_px, "host terminal reported cell size"); + } +} + +#[cfg(any(unix, test))] +fn reported_cell_size_from_events( + events: &[crate::raw_input::RawInputEvent], +) -> Option<(u32, u32)> { + events.iter().rev().find_map(|event| match event { + crate::raw_input::RawInputEvent::HostCellSizeReport { + width_px, + height_px, + } => Some((*width_px, *height_px)), + _ => None, + }) +} + fn init_logging() { crate::logging::init_file_logging("herdr-client.log"); } @@ -2484,6 +2594,36 @@ mod tests { } #[test] + fn write_host_cell_size_query_emits_xtwinops_request() { + let mut output = Vec::new(); + write_host_cell_size_query(&mut output).unwrap(); + + assert_eq!(output, b"\x1b[16t"); + } + + #[test] + fn host_cell_size_query_is_disabled_on_windows() { + assert_eq!(should_query_host_cell_size(), !cfg!(windows)); + } + + #[test] + fn cell_size_fallback_prefers_reported_size_over_default() { + assert_eq!(cell_size_fallback(0), (8, 16)); + assert_eq!(cell_size_fallback(pack_cell_size(10, 21)), (10, 21)); + assert_eq!(cell_size_fallback(pack_cell_size(10, 0)), (8, 16)); + assert_eq!(cell_size_fallback(pack_cell_size(0, 21)), (8, 16)); + } + + #[test] + fn reported_cell_size_is_taken_from_host_cell_size_events() { + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); + assert_eq!(reported_cell_size_from_events(&events), None); + + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t"); + assert_eq!(reported_cell_size_from_events(&events), Some((9, 18))); + } + + #[test] fn color_scheme_reports_are_enabled_only_for_full_clients() { assert_eq!( should_enable_host_color_scheme_reports(true), |
