aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorWakaTaira <50697207+WakaTaira@users.noreply.github.com>2026-08-02 22:53:08 +0900
committerGitHub <noreply@github.com>2026-08-02 16:53:08 +0300
commit890113943999bd0332f535864b982190907449c4 (patch)
tree602fa09cf67a48ef19eab38ba3cb0b67a9f17189 /src/client
parentd742e51530a78b77e1debe23a72ea7178810f3fd (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.rs13
-rw-r--r--src/client/mod.rs178
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),