diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/client/terminal_geometry.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/client/terminal_geometry.rs')
| -rw-r--r-- | src/client/terminal_geometry.rs | 238 |
1 files changed, 238 insertions, 0 deletions
diff --git a/src/client/terminal_geometry.rs b/src/client/terminal_geometry.rs new file mode 100644 index 00000000..d1f305ab --- /dev/null +++ b/src/client/terminal_geometry.rs @@ -0,0 +1,238 @@ +use std::io; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(any(unix, test))] +use tracing::debug; + +use super::ClientLoopEvent; + +const DEFAULT_CELL_WIDTH_PX: u32 = 8; +const DEFAULT_CELL_HEIGHT_PX: u32 = 16; + +/// Average cell size derived from a terminal ioctl pixel extent. +/// +/// The extent need not divide evenly by the grid: terminals may include padding, +/// and pixel mouse coordinates retain the raw extent for proportional mapping. +pub(super) fn ioctl_cell_size( + columns: u16, + rows: u16, + width_px: u32, + height_px: u32, +) -> Option<(u32, u32)> { + if columns == 0 || rows == 0 || width_px == 0 || height_px == 0 { + return None; + } + Some(( + (width_px / u32::from(columns)).max(1), + (height_px / u32::from(rows)).max(1), + )) +} + +fn ioctl_terminal_geometry() -> Option<(u16, u16, u32, u32)> { + let size = crossterm::terminal::window_size().ok()?; + let (cell_width_px, cell_height_px) = ioctl_cell_size( + size.columns, + size.rows, + u32::from(size.width), + u32::from(size.height), + )?; + Some((size.columns, size.rows, cell_width_px, cell_height_px)) +} + +pub(super) fn cell_size_fallback(reported: u64, last: Option<(u32, u32)>) -> (u32, u32) { + unpack_cell_size(reported) + .or(last.filter(|(width, height)| *width > 0 && *height > 0)) + .unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX)) +} + +#[cfg(any(unix, test))] +pub(super) 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( + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, + reported_cell_size: &AtomicU64, + last_cell_size: Option<(u32, u32)>, +) -> (u16, u16, u32, u32, bool) { + if !pixel_geometry_enabled { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + return (cols, rows, 0, 0, false); + } + if let Some((cols, rows, cell_width_px, cell_height_px)) = ioctl_terminal_geometry() { + return (cols, rows, cell_width_px, cell_height_px, true); + } + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + if !pixel_geometry_fallback { + return (cols, rows, 0, 0, false); + } + let (cell_width_px, cell_height_px) = + cell_size_fallback(reported_cell_size.load(Ordering::Acquire), last_cell_size); + (cols, rows, cell_width_px, cell_height_px, false) +} + +/// Reads terminal geometry before the handshake. Pixel input and direct graphics +/// are eligible only when one ioctl supplied a coherent exact geometry snapshot. +pub(super) fn initial_terminal_geometry( + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, +) -> (u16, u16, u32, u32, bool) { + if !pixel_geometry_enabled { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + return (cols, rows, 0, 0, false); + } + match ioctl_terminal_geometry() { + Some((cols, rows, width, height)) => (cols, rows, width, height, true), + None => { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + if pixel_geometry_fallback { + ( + cols, + rows, + DEFAULT_CELL_WIDTH_PX, + DEFAULT_CELL_HEIGHT_PX, + false, + ) + } else { + (cols, rows, 0, 0, false) + } + } + } +} + +pub(super) fn resize_report_required( + signalled: bool, + new_size: (u16, u16, u32, u32, bool), + last_size: (u16, u16, u32, u32, bool), +) -> bool { + signalled || new_size != last_size +} + +/// 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. +#[allow(clippy::too_many_arguments)] // The arguments are one immutable launch snapshot, not shared state. +pub(super) 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, + initial_pixel_geometry_exact: bool, + pixel_geometry_enabled: bool, + pixel_geometry_fallback: bool, + reported_cell_size: &AtomicU64, + should_quit: &Arc<AtomicBool>, +) { + crate::platform::watch_terminal_resize_signal(); + let mut last_size = ( + initial_cols, + initial_rows, + initial_cell_width, + initial_cell_height, + initial_pixel_geometry_exact, + ); + 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( + pixel_geometry_enabled, + pixel_geometry_fallback, + reported_cell_size, + Some((last_size.2, last_size.3)), + ); + if resize_report_required(signalled, new_size, last_size) { + last_size = new_size; + if resize_tx + .blocking_send(ClientLoopEvent::Resize( + new_size.0, new_size.1, new_size.2, new_size.3, new_size.4, + )) + .is_err() + { + break; + } + } + } +} + +#[cfg(any(not(windows), test))] +pub(super) fn query_host_terminal_appearance() { + let _ = write_host_terminal_appearance_query(io::stdout()); +} + +#[cfg(any(not(windows), test))] +pub(super) fn write_host_terminal_appearance_query(mut writer: impl io::Write) -> io::Result<()> { + writer.write_all(crate::terminal_theme::HOST_COLOR_SCHEME_QUERY_SEQUENCE.as_bytes())?; + writer.flush() +} + +pub(super) fn query_host_terminal_theme() { + let _ = write_host_terminal_theme_query(io::stdout()); +} + +pub(super) fn should_query_host_terminal_theme() -> bool { + !cfg!(windows) +} + +pub(super) fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> { + let query = crate::terminal_theme::host_terminal_theme_query_sequence( + crate::platform::should_query_host_terminal_palette(), + ); + writer.write_all(query.as_bytes())?; + writer.flush() +} + +const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t"; + +pub(super) fn query_host_cell_size() { + let _ = write_host_cell_size_query(io::stdout()); +} + +pub(super) fn should_query_host_cell_size() -> bool { + !cfg!(windows) +} + +pub(super) fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool { + kitty_graphics_enabled && should_query_host_cell_size() && ioctl_terminal_geometry().is_none() +} + +pub(super) 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))] +pub(super) 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))] +pub(super) 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, + }) +} |
