feat(graphics): add direct pane frame streaming (#2523)

* feat(graphics): add direct pane frame streaming

* feat(graphics): expose pane visibility

* test(graphics): preserve legacy terminal image output

* fix(graphics): harden streaming lifecycle

* fix(input): preserve mouse releases outside panes
This commit is contained in:
Can Celik 2026-08-09 02:04:39 +03:00 committed by GitHub
parent 36a8d36a72
commit 1777e9bba3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 6741 additions and 1893 deletions

View File

@ -11,6 +11,7 @@
### Changed
- Bumped the client/server protocol version to 20 for pane terminal bell forwarding.
- Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay.
### Fixed
- `herdr config check` now reports unknown built-in theme names instead of silently accepting them. (#2452)

View File

@ -2452,6 +2452,12 @@
},
"PaneGraphicsClearParams": {
"properties": {
"layer_id": {
"type": [
"string",
"null"
]
},
"pane_id": {
"type": "string"
}
@ -2465,7 +2471,8 @@
"enum": [
"png",
"rgb",
"rgba"
"rgba",
"bgra"
],
"type": "string"
},
@ -2515,6 +2522,12 @@
"minimum": 0,
"type": "integer"
},
"layer_id": {
"type": [
"string",
"null"
]
},
"pane_id": {
"type": "string"
},
@ -2526,6 +2539,11 @@
"viewport_col": 0,
"viewport_row": 0
}
},
"z_index": {
"default": 0,
"format": "int32",
"type": "integer"
}
},
"required": [
@ -9234,6 +9252,30 @@
],
"type": "object"
},
{
"properties": {
"revision": {
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"sequence": {
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"type": {
"const": "pane_graphics_frame_ack",
"type": "string"
}
},
"required": [
"type",
"sequence",
"revision"
],
"type": "object"
},
{
"properties": {
"cell_height_px": {
@ -9246,6 +9288,51 @@
"minimum": 0,
"type": "integer"
},
"file_frame_damage": {
"default": false,
"description": "Accepts damage metadata while still consuming a complete canonical file.",
"type": "boolean"
},
"file_frame_directory": {
"type": [
"string",
"null"
]
},
"file_frame_formats": {
"items": {
"type": "string"
},
"type": "array"
},
"file_frame_max_bytes": {
"format": "uint",
"minimum": 0,
"type": [
"integer",
"null"
]
},
"file_frame_transport": {
"type": [
"string",
"null"
]
},
"max_layers_per_pane": {
"default": 0,
"format": "uint",
"minimum": 0,
"type": "integer"
},
"pane_visible": {
"description": "True only when this pane is on the currently rendered terminal surface.",
"type": "boolean"
},
"pixel_mouse": {
"default": false,
"type": "boolean"
},
"type": {
"const": "pane_graphics_info",
"type": "string"
@ -9254,7 +9341,8 @@
"required": [
"type",
"cell_width_px",
"cell_height_px"
"cell_height_px",
"pane_visible"
],
"type": "object"
},

View File

@ -177,10 +177,20 @@ Clients can treat `offset_from_bottom == 0` as at-bottom state.
Pane graphics let a plugin place image data over a pane. They are available
only when `[experimental].kitty_graphics = true`; otherwise every pane graphics
method returns `feature_disabled`. Calling `pane.graphics.info` returns the
attached client's cell width and height in pixels without creating a graphics
layer. `pane.graphics.set` accepts `png`, `rgb`, or `rgba` data in `data_base64`,
and `pane.graphics.clear` removes the layer.
method returns `feature_disabled`. Calling `pane.graphics.info` explicitly
activates capability discovery and returns the attached client's cell size,
file-frame options, pixel-mouse support, the 16-layer limit, and `pane_visible`.
`pane_visible` is true only when the target is in the active workspace and tab
and is not hidden by zoom. Short-lived UI modes do not change it.
`pane.graphics.set`, `pane.graphics.clear`, and `pane.graphics.stream` accept an
optional `layer_id` (default `primary`). Set and stream also accept `z_index`;
layers are placed in stable `(z_index, layer_id)` order. Each stream exclusively owns its layer, and
closing it removes that layer. Inline frames accept `png`, `rgb`, `rgba`, or
`bgra`; BGRA is normalized once to owned RGBA. Herdr advances the host cache
one image transaction per render pass, so arbitrary layer sets progress without
an aggregate frame. Headless transport keeps each transaction within its 32 MiB
wire limit; local monolithic rendering does not apply that transport limit.
```json
{"id":"graphics_info","method":"pane.graphics.info","params":{"pane_id":"w1:p1"}}
@ -190,14 +200,31 @@ and `pane.graphics.clear` removes the layer.
For repeated frames, open a dedicated socket with `pane.graphics.stream`. After
Herdr replies with `ok`, send one JSON header and then exactly `data_length` raw
bytes per frame. A stream owns that pane's graphics layer until the socket
closes; concurrent set, clear, or stream requests return `stream_conflict`.
bytes per inline frame. Concurrent operations on that layer return
`stream_conflict`.
```json
{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1"}}
{"id":"graphics_stream","method":"pane.graphics.stream","params":{"pane_id":"w1:p1","z_index":0}}
{"format":"png","image_width":800,"image_height":600,"data_length":12345,"placement":{"viewport_col":0,"viewport_row":0,"grid_cols":80,"grid_rows":30}}
```
When `pane.graphics.info` advertises `file_frame_transport: "direct-kitty"`, an
eligible local Ghostty, kitty, or WezTerm client may submit an immutable private
`rgba` or `bgra` file with `file.path`, `sequence`, and `revision`. Direct Kitty
file transport is reserved for the default `primary` page layer; named secondary
layers use owned inline RGBA. BGRA is always copied, swizzled, and rendered
inline. Herdr replies with a `pane_graphics_frame_ack` only after the terminal
accepts the file, or after a safe owned inline fallback is installed. Confirmed
file-transport failure disables direct files for that client connection without
disabling exact pixel mouse. A timeout or client loss closes the stream without
acknowledging source reuse. Monolithic `--no-session` mode advertises neither
fast file transport nor exact pixel mouse and remains on owned inline fallback.
Direct files are always complete canonical `width * height * 4` RGBA frames.
`file_frame_damage: true` means Herdr accepts optional damage metadata for
producer-side canonical-ring efficiency; it still copies or presents the full file.
Resize and full redraw replay placements without retransmitting pixels.
`pane.layout` returns the tab layout snapshot with `workspace_id`, `tab_id`,
`zoomed`, outer `area`, `focused_pane_id`, pane rects, and split rects/ratios.
`pane.neighbor` and `pane.edges` include that same layout snapshot so clients

View File

@ -7,7 +7,6 @@ mod subscriptions;
mod wait;
pub use event_hub::EventHub;
pub(crate) use server::cancel_inactive_pane_graphics_streams;
pub use server::{start_server, start_server_with_capabilities, ServerHandle};
pub use status::{read_runtime_status_at, RuntimeStatus};
@ -62,6 +61,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::PaneGraphicsClear(_)
| Method::PaneGraphicsStream(_)
| Method::PaneGraphicsStreamSet(_)
| Method::PaneGraphicsStreamDirect(_)
| Method::PaneGraphicsStreamOpen(_)
| Method::PaneGraphicsStreamClose(_)
| Method::PaneReportAgent(_)
@ -84,6 +84,7 @@ pub struct ApiRequestMessage {
pub request: Request,
pub respond_to: std::sync::mpsc::Sender<String>,
pub response_write_complete: Option<std::sync::mpsc::Receiver<()>>,
pub stream_active: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
pub type ApiRequestSender = mpsc::UnboundedSender<ApiRequestMessage>;

View File

@ -187,6 +187,9 @@ pub enum Method {
PaneGraphicsStreamSet(PaneGraphicsSetParams),
#[serde(skip)]
#[schemars(skip)]
PaneGraphicsStreamDirect(PaneGraphicsDirectParams),
#[serde(skip)]
#[schemars(skip)]
PaneGraphicsStreamOpen(PaneGraphicsStreamParams),
#[serde(skip)]
#[schemars(skip)]

View File

@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize};
pub(crate) const PANE_GRAPHICS_SET_MAX_BYTES: usize = 512 * 1024;
pub(crate) const PANE_GRAPHICS_STREAM_MAX_BYTES: usize = 16 * 1024 * 1024;
pub(crate) const PANE_GRAPHICS_MAX_LAYERS_PER_PANE: usize = 16;
pub(crate) const PANE_GRAPHICS_MAX_LAYERS_TOTAL: usize = 64;
pub(crate) const PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL: usize = 64 * 1024 * 1024;
pub(crate) const PANE_GRAPHICS_PRIMARY_LAYER_ID: &str = "primary";
use super::agents::AgentSessionInfo;
use super::common::{AgentStatus, PaneAgentState, ReadFormat, ReadSource, SplitDirection};
@ -287,11 +291,16 @@ pub enum PaneGraphicsFormat {
Png,
Rgb,
Rgba,
Bgra,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneGraphicsSetParams {
pub pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_id: Option<String>,
#[serde(default)]
pub z_index: i32,
#[serde(skip)]
#[schemars(skip)]
pub owner: String,
@ -323,11 +332,32 @@ pub struct PaneGraphicsPlacementParams {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneGraphicsClearParams {
pub pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneGraphicsDirectParams {
pub pane_id: String,
pub layer_id: Option<String>,
pub z_index: i32,
pub owner: String,
pub image_width: u32,
pub image_height: u32,
pub format: PaneGraphicsFormat,
pub path: String,
pub sequence: u64,
pub revision: u64,
pub placement: PaneGraphicsPlacementParams,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneGraphicsStreamParams {
pub pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_id: Option<String>,
#[serde(default)]
pub z_index: i32,
#[serde(skip)]
#[schemars(skip)]
pub owner: String,

View File

@ -162,9 +162,30 @@ pub enum ResponseResult {
PaneRead {
read: PaneReadResult,
},
PaneGraphicsFrameAck {
sequence: u64,
revision: u64,
},
PaneGraphicsInfo {
cell_width_px: u32,
cell_height_px: u32,
/// True only when this pane is on the currently rendered terminal surface.
pane_visible: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
file_frame_directory: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
file_frame_formats: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
file_frame_max_bytes: Option<usize>,
/// Accepts damage metadata while still consuming a complete canonical file.
#[serde(default)]
file_frame_damage: bool,
#[serde(default)]
max_layers_per_pane: usize,
#[serde(default)]
pixel_mouse: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
file_frame_transport: Option<String>,
},
AgentExplain {
explain: serde_json::Value,

View File

@ -23,7 +23,6 @@ use crate::ipc::{
};
mod pane_graphics_stream;
pub(crate) use pane_graphics_stream::cancel_inactive_streams as cancel_inactive_pane_graphics_streams;
const SOCKET_PERMISSION_MODE: u32 = 0o600;
pub(super) const CONNECTION_POLL_INTERVAL: Duration = Duration::from_millis(100);
@ -332,12 +331,7 @@ fn handle_request(
r#"{"id":"","error":{"code":"internal_error","message":"failed to encode response"}}"#
.to_string()
}),
_ => dispatch_to_app_with_timeout_and_write_completion(
request,
api_tx,
None,
response_write_complete,
),
_ => dispatch_to_app(request, api_tx, None, response_write_complete, None),
}
}
@ -413,6 +407,7 @@ fn api_method_name(method: &Method) -> &'static str {
Method::PaneGraphicsInfo(_) => "pane.graphics.info",
Method::PaneGraphicsStream(_) => "pane.graphics.stream",
Method::PaneGraphicsStreamSet(_) => "pane.graphics.stream.set",
Method::PaneGraphicsStreamDirect(_) => "pane.graphics.stream.direct",
Method::PaneGraphicsStreamOpen(_) => "pane.graphics.stream.open",
Method::PaneGraphicsStreamClose(_) => "pane.graphics.stream.close",
Method::PaneReportAgent(_) => "pane.report_agent",
@ -749,22 +744,51 @@ pub(super) fn dispatch_to_app_with_timeout(
api_tx: &ApiRequestSender,
timeout: Option<Duration>,
) -> String {
dispatch_to_app_with_timeout_and_write_completion(request, api_tx, timeout, None)
dispatch_to_app(request, api_tx, timeout, None, None)
}
fn dispatch_to_app_with_timeout_and_write_completion(
pub(super) fn dispatch_stream_open(
request: Request,
api_tx: &ApiRequestSender,
timeout: Duration,
active: Arc<AtomicBool>,
) -> String {
dispatch_to_app(request, api_tx, Some(timeout), None, Some(active))
}
pub(super) fn dispatch_stream_frame(
request: Request,
api_tx: &ApiRequestSender,
active: Arc<AtomicBool>,
) -> String {
dispatch_to_app(
request,
api_tx,
Some(crate::app::pane_graphics::DIRECT_OUTER_TIMEOUT),
None,
Some(active),
)
}
fn dispatch_to_app(
request: Request,
api_tx: &ApiRequestSender,
timeout: Option<Duration>,
response_write_complete: Option<std::sync::mpsc::Receiver<()>>,
stream_active: Option<Arc<AtomicBool>>,
) -> String {
let request_id = request.id.clone();
let request_active = stream_active.clone();
let (respond_to, response_rx) = std::sync::mpsc::channel();
if let Err(err) = api_tx.send(ApiRequestMessage {
request,
respond_to,
response_write_complete,
stream_active,
}) {
if let Some(active) = request_active {
active.store(false, Ordering::Release);
}
return error_response_json(
request_id,
"server_unavailable",
@ -793,11 +817,16 @@ fn dispatch_to_app_with_timeout_and_write_completion(
match response {
Ok(response) => response,
Err(err) => error_response_json(
request_id,
"server_unavailable",
format!("request handling failed: {err}"),
),
Err(err) => {
if let Some(active) = request_active {
active.store(false, Ordering::Release);
}
error_response_json(
request_id,
"server_unavailable",
format!("request handling failed: {err}"),
)
}
}
}
@ -1335,6 +1364,8 @@ mod pane_graphics_request_tests {
id: "graphics-max".into(),
method: Method::PaneGraphicsSet(crate::api::schema::PaneGraphicsSetParams {
pane_id: "pane_1".into(),
layer_id: None,
z_index: 0,
owner: String::new(),
format: crate::api::schema::PaneGraphicsFormat::Png,
image_width: 1,

View File

@ -1,7 +1,6 @@
use std::collections::HashMap;
use std::io::{self, Read};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use interprocess::local_socket::traits::Stream as _;
@ -14,9 +13,9 @@ use crate::api::ApiRequestSender;
use crate::ipc::{is_connection_closed_error, LocalStream};
use super::{
api_response_outcome, dispatch_to_app_with_timeout, write_json_line,
write_json_line_allow_disconnect, write_text_line_allow_disconnect, APP_RESPONSE_TIMEOUT,
CONNECTION_POLL_INTERVAL,
api_response_outcome, dispatch_stream_frame, dispatch_stream_open,
dispatch_to_app_with_timeout, write_json_line, write_json_line_allow_disconnect,
write_text_line_allow_disconnect, APP_RESPONSE_TIMEOUT, CONNECTION_POLL_INTERVAL,
};
const MAX_STREAM_FRAME_HEADER_BYTES: usize = 64 * 1024;
@ -28,15 +27,25 @@ const STREAM_FRAME_BODY_TIMEOUT: Duration = Duration::from_secs(30);
const STREAM_FALLBACK_POLL_INTERVAL: Duration = Duration::from_millis(1);
const STREAM_FALLBACK_FAST_POLLS: u8 = 32;
static NEXT_PANE_GRAPHICS_STREAM_OWNER: AtomicU64 = AtomicU64::new(1);
static REGISTERED_STREAM_COUNT: AtomicUsize = AtomicUsize::new(0);
static REGISTERED_STREAMS: OnceLock<Mutex<HashMap<String, Weak<AtomicBool>>>> = OnceLock::new();
#[derive(serde::Deserialize)]
struct FrameFile {
path: String,
}
#[derive(serde::Deserialize)]
struct FrameHeader {
format: crate::api::schema::PaneGraphicsFormat,
image_width: u32,
image_height: u32,
data_length: usize,
#[serde(default)]
data_length: Option<usize>,
#[serde(default)]
file: Option<FrameFile>,
#[serde(default)]
sequence: u64,
#[serde(default)]
revision: u64,
#[serde(default)]
placement: crate::api::schema::PaneGraphicsPlacementParams,
}
@ -102,19 +111,24 @@ fn serve_with_timeouts(
read_timeouts: ReadTimeouts,
) -> std::io::Result<()> {
let pane_id = params.pane_id.clone();
let layer_id = params.layer_id.clone();
let z_index = params.z_index;
let owner = next_owner();
params.owner = owner.clone();
let open_response = dispatch_to_app_with_timeout(
let stream_active = Arc::new(AtomicBool::new(true));
let open_response = dispatch_stream_open(
Request {
id: request_id.clone(),
method: Method::PaneGraphicsStreamOpen(params),
},
api_tx,
Some(open_timeout),
open_timeout,
Arc::clone(&stream_active),
);
if api_response_outcome(&open_response) != "ok" {
stream_active.store(false, Ordering::Release);
let write_result = write_text_line_allow_disconnect(&mut stream, &open_response);
clear_layer(&pane_id, &owner, api_tx);
clear_layer(&pane_id, layer_id.as_deref(), z_index, &owner, api_tx);
write_result?;
return Ok(());
}
@ -126,27 +140,28 @@ fn serve_with_timeouts(
result: ResponseResult::Ok {},
},
) {
clear_layer(&pane_id, &owner, api_tx);
stream_active.store(false, Ordering::Release);
clear_layer(&pane_id, layer_id.as_deref(), z_index, &owner, api_tx);
if is_connection_closed_error(&err) {
return Ok(());
}
return Err(err);
}
let stream_active = Arc::new(AtomicBool::new(true));
register_stream(&owner, &stream_active);
let result = serve_frames(
&mut stream,
&request_id,
&owner,
&pane_id,
layer_id.as_deref(),
z_index,
api_tx,
running,
&stream_active,
read_timeouts,
);
unregister_stream(&owner);
clear_layer(&pane_id, &owner, api_tx);
stream_active.store(false, Ordering::Release);
clear_layer(&pane_id, layer_id.as_deref(), z_index, &owner, api_tx);
result
}
@ -155,6 +170,8 @@ fn serve_frames(
request_id: &str,
owner: &str,
pane_id: &str,
layer_id: Option<&str>,
z_index: i32,
api_tx: &ApiRequestSender,
running: &Arc<AtomicBool>,
stream_active: &Arc<AtomicBool>,
@ -193,7 +210,67 @@ fn serve_frames(
return Ok(());
}
};
if header.data_length == 0 {
if let Some(file) = header.file {
if !matches!(
header.format,
crate::api::schema::PaneGraphicsFormat::Rgba
| crate::api::schema::PaneGraphicsFormat::Bgra
) {
write_json_line_allow_disconnect(
stream,
&ErrorResponse {
id: request_id.to_string(),
error: ErrorBody {
code: "invalid_frame".into(),
message: "file frames require rgba or bgra".into(),
},
},
)?;
return Ok(());
}
let response = dispatch_stream_frame(
Request {
id: format!("{request_id}:file:{}", header.sequence),
method: Method::PaneGraphicsStreamDirect(
crate::api::schema::PaneGraphicsDirectParams {
pane_id: pane_id.to_owned(),
layer_id: layer_id.map(str::to_owned),
z_index,
owner: owner.to_owned(),
image_width: header.image_width,
image_height: header.image_height,
format: header.format,
path: file.path,
sequence: header.sequence,
revision: header.revision,
placement: header.placement,
},
),
},
api_tx,
Arc::clone(stream_active),
);
write_text_line_allow_disconnect(stream, &response)?;
if api_response_outcome(&response) != "ok" {
return Ok(());
}
continue;
}
let Some(data_length) = header.data_length else {
write_json_line_allow_disconnect(
stream,
&ErrorResponse {
id: request_id.to_string(),
error: ErrorBody {
code: "invalid_frame".into(),
message: "frame requires data_length or file".into(),
},
},
)?;
return Ok(());
};
if data_length == 0 {
write_json_line_allow_disconnect(
stream,
&ErrorResponse {
@ -206,7 +283,7 @@ fn serve_frames(
)?;
return Ok(());
}
if header.data_length > crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES {
if data_length > crate::api::schema::PANE_GRAPHICS_STREAM_MAX_BYTES {
write_json_line_allow_disconnect(
stream,
&ErrorResponse {
@ -222,7 +299,7 @@ fn serve_frames(
let Some(data) = read_exact(
stream,
header.data_length,
data_length,
running,
stream_active,
timeouts.body_idle,
@ -239,6 +316,8 @@ fn serve_frames(
id: frame_id,
method: Method::PaneGraphicsStreamSet(PaneGraphicsSetParams {
pane_id: pane_id.to_string(),
layer_id: layer_id.map(str::to_owned),
z_index,
owner: owner.to_string(),
format: header.format,
image_width: header.image_width,
@ -260,52 +339,6 @@ fn serve_frames(
Ok(())
}
fn stream_registry() -> &'static Mutex<HashMap<String, Weak<AtomicBool>>> {
REGISTERED_STREAMS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn register_stream(owner: &str, active: &Arc<AtomicBool>) {
let Ok(mut streams) = stream_registry().lock() else {
active.store(false, Ordering::Release);
return;
};
if streams
.insert(owner.to_string(), Arc::downgrade(active))
.is_none()
{
REGISTERED_STREAM_COUNT.fetch_add(1, Ordering::Release);
}
}
fn unregister_stream(owner: &str) {
let Ok(mut streams) = stream_registry().lock() else {
return;
};
if streams.remove(owner).is_some() {
REGISTERED_STREAM_COUNT.fetch_sub(1, Ordering::Release);
}
}
pub(crate) fn cancel_inactive_streams(mut is_active: impl FnMut(&str) -> bool) {
if REGISTERED_STREAM_COUNT.load(Ordering::Acquire) == 0 {
return;
}
let Ok(mut streams) = stream_registry().lock() else {
return;
};
let before = streams.len();
streams.retain(|owner, active| {
let keep = is_active(owner);
if !keep {
if let Some(active) = active.upgrade() {
active.store(false, Ordering::Release);
}
}
keep
});
REGISTERED_STREAM_COUNT.fetch_sub(before.saturating_sub(streams.len()), Ordering::Release);
}
fn stream_is_running(running: &AtomicBool, stream_active: &AtomicBool) -> bool {
running.load(Ordering::Relaxed) && stream_active.load(Ordering::Acquire)
}
@ -315,12 +348,20 @@ fn next_owner() -> String {
format!("pane.graphics.stream:{}:{id}", std::process::id())
}
fn clear_layer(pane_id: &str, owner: &str, api_tx: &ApiRequestSender) {
fn clear_layer(
pane_id: &str,
layer_id: Option<&str>,
z_index: i32,
owner: &str,
api_tx: &ApiRequestSender,
) {
let _response = dispatch_to_app_with_timeout(
Request {
id: format!("pane.graphics.stream.clear:{pane_id}"),
method: Method::PaneGraphicsStreamClose(PaneGraphicsStreamParams {
pane_id: pane_id.to_string(),
layer_id: layer_id.map(str::to_owned),
z_index,
owner: owner.to_string(),
}),
},
@ -574,7 +615,9 @@ fn read_should_retry(err: &io::Error) -> bool {
mod tests {
use super::*;
use crate::api::schema::{ErrorResponse, Method, ResponseResult, SuccessResponse};
use crate::api::{ApiRequestMessage, EventHub};
use crate::api::ApiRequestMessage;
#[cfg(unix)]
use crate::api::EventHub;
use crate::ipc::LocalStream;
use interprocess::local_socket::traits::Listener as _;
use std::io::{BufRead, BufReader, Write};
@ -610,6 +653,42 @@ mod tests {
assert!(owner.starts_with("pane.graphics.stream:"));
}
fn respond_ok(message: ApiRequestMessage) {
let response = serde_json::to_string(&SuccessResponse {
id: message.request.id,
result: ResponseResult::Ok {},
})
.unwrap();
message.respond_to.send(response).unwrap();
}
fn assert_close_and_respond(message: ApiRequestMessage, pane_id: &str, owner: &str) {
match &message.request.method {
Method::PaneGraphicsStreamClose(params) => {
assert_eq!(params.pane_id, pane_id);
assert_eq!(params.owner, owner);
}
other => panic!("unexpected close request: {other:?}"),
}
respond_ok(message);
}
#[test]
fn browser_file_header_accepts_damage_but_keeps_full_canonical_frame() {
let header: FrameHeader = serde_json::from_str(
r#"{"format":"rgba","image_width":2,"image_height":3,"sequence":7,"revision":8,"file":{"path":"/private/frame"},"damage":{"x":1,"y":1,"width":1,"height":1},"transport":"direct-kitty","placement":{"grid_cols":2,"grid_rows":3}}"#,
)
.unwrap();
assert_eq!(header.data_length, None);
assert_eq!(header.file.unwrap().path, "/private/frame");
assert_eq!((header.sequence, header.revision), (7, 8));
assert_eq!((header.image_width, header.image_height), (2, 3));
assert_eq!(
(header.placement.grid_cols, header.placement.grid_rows),
(2, 3)
);
}
#[test]
fn timed_read_skips_reset_after_stream_ends() {
let mut reset_called = false;
@ -650,15 +729,7 @@ mod tests {
}
other => panic!("unexpected open request: {other:?}"),
};
open.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: open.request.id,
result: ResponseResult::Ok {},
})
.unwrap(),
)
.unwrap();
respond_ok(open);
let ack: SuccessResponse = serde_json::from_str(&read_response_line(&mut client)).unwrap();
assert_eq!(ack.id, "stream_1");
@ -687,36 +758,11 @@ mod tests {
}
other => panic!("unexpected request: {other:?}"),
}
msg.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: msg.request.id,
result: ResponseResult::Ok {},
})
.unwrap(),
)
.unwrap();
respond_ok(msg);
drop(client);
running.store(false, Ordering::Relaxed);
let clear = api_rx.blocking_recv().unwrap();
match &clear.request.method {
Method::PaneGraphicsStreamClose(params) => {
assert_eq!(params.pane_id, "pane_1");
assert_eq!(params.owner, stream_owner);
}
other => panic!("unexpected clear request: {other:?}"),
}
clear
.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: clear.request.id,
result: ResponseResult::Ok {},
})
.unwrap(),
)
.unwrap();
assert_close_and_respond(api_rx.blocking_recv().unwrap(), "pane_1", &stream_owner);
assert!(server_thread.join().unwrap().is_ok());
}
@ -761,24 +807,7 @@ mod tests {
assert_eq!(response.id, "stream_2");
assert_eq!(response.error.code, "feature_disabled");
let close = api_rx.blocking_recv().unwrap();
match &close.request.method {
Method::PaneGraphicsStreamClose(params) => {
assert_eq!(params.pane_id, "pane_1");
assert_eq!(params.owner, stream_owner);
}
other => panic!("unexpected close request: {other:?}"),
}
close
.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: close.request.id,
result: ResponseResult::Ok {},
})
.unwrap(),
)
.unwrap();
assert_close_and_respond(api_rx.blocking_recv().unwrap(), "pane_1", &stream_owner);
drop(client);
running.store(false, Ordering::Relaxed);
@ -798,6 +827,8 @@ mod tests {
"stream_timeout".into(),
PaneGraphicsStreamParams {
pane_id: "pane_1".into(),
layer_id: None,
z_index: 0,
owner: String::new(),
},
&api_tx,
@ -823,24 +854,7 @@ mod tests {
assert_eq!(response.error.code, "server_unavailable");
assert!(response.error.message.contains("timed out"));
let close = api_rx.blocking_recv().unwrap();
match &close.request.method {
Method::PaneGraphicsStreamClose(params) => {
assert_eq!(params.pane_id, "pane_1");
assert_eq!(params.owner, stream_owner);
}
other => panic!("unexpected close request: {other:?}"),
}
close
.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: close.request.id,
result: ResponseResult::Ok {},
})
.unwrap(),
)
.unwrap();
assert_close_and_respond(api_rx.blocking_recv().unwrap(), "pane_1", &stream_owner);
drop(open);
drop(client);
@ -879,15 +893,7 @@ mod tests {
};
drop(client);
open.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: open.request.id,
result: ResponseResult::Ok {},
})
.unwrap(),
)
.unwrap();
respond_ok(open);
let (close_tx, close_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
@ -897,23 +903,7 @@ mod tests {
.recv_timeout(Duration::from_secs(2))
.unwrap()
.unwrap();
match &close.request.method {
Method::PaneGraphicsStreamClose(params) => {
assert_eq!(params.pane_id, "pane_1");
assert_eq!(params.owner, stream_owner);
}
other => panic!("unexpected close request: {other:?}"),
}
close
.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: close.request.id,
result: ResponseResult::Ok {},
})
.unwrap(),
)
.unwrap();
assert_close_and_respond(close, "pane_1", &stream_owner);
running.store(false, Ordering::Relaxed);
assert!(server_thread.join().unwrap().is_ok());
@ -944,75 +934,6 @@ mod tests {
stopper.join().unwrap();
}
#[test]
fn inactive_owner_cancels_idle_stream_and_dispatches_close() {
let (mut client, server, _path) = local_stream_pair("graphics-owner-cancel");
let (api_tx, mut api_rx) = mpsc::unbounded_channel::<ApiRequestMessage>();
let running = Arc::new(AtomicBool::new(true));
let server_running = Arc::clone(&running);
let server_thread = std::thread::spawn(move || {
serve_with_timeouts(
server,
"stream-cancel".into(),
PaneGraphicsStreamParams {
pane_id: "pane_1".into(),
owner: String::new(),
},
&api_tx,
&server_running,
Duration::from_secs(1),
READ_TIMEOUTS,
)
});
let open = api_rx.blocking_recv().unwrap();
let owner = match &open.request.method {
Method::PaneGraphicsStreamOpen(params) => params.owner.clone(),
other => panic!("unexpected open request: {other:?}"),
};
open.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: open.request.id,
result: ResponseResult::Ok {},
})
.unwrap(),
)
.unwrap();
let ack: SuccessResponse = serde_json::from_str(&read_response_line(&mut client)).unwrap();
assert_eq!(ack.id, "stream-cancel");
cancel_inactive_streams(|registered| registered != owner);
let (close_tx, close_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || close_tx.send(api_rx.blocking_recv()).unwrap());
let close = close_rx
.recv_timeout(Duration::from_secs(1))
.expect("canceled idle stream should dispatch a close")
.expect("API request channel should remain open");
match &close.request.method {
Method::PaneGraphicsStreamClose(params) => {
assert_eq!(params.pane_id, "pane_1");
assert_eq!(params.owner, owner);
}
other => panic!("unexpected close request: {other:?}"),
}
close
.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: close.request.id,
result: ResponseResult::Ok {},
})
.unwrap(),
)
.unwrap();
drop(client);
running.store(false, Ordering::Relaxed);
assert!(server_thread.join().unwrap().is_ok());
}
#[test]
fn fallback_poll_backoff_preserves_fast_window_then_reaches_poll_ceiling() {
let mut backoff = PollBackoff::new();
@ -1102,6 +1023,8 @@ mod tests {
"stream-timeout".into(),
PaneGraphicsStreamParams {
pane_id: "pane_1".into(),
layer_id: None,
z_index: 0,
owner: String::new(),
},
&api_tx,
@ -1121,15 +1044,7 @@ mod tests {
Method::PaneGraphicsStreamOpen(params) => params.owner.clone(),
other => panic!("unexpected open request: {other:?}"),
};
open.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: open.request.id,
result: ResponseResult::Ok {},
})
.unwrap(),
)
.unwrap();
respond_ok(open);
let ack: SuccessResponse = serde_json::from_str(&read_response_line(&mut client)).unwrap();
assert_eq!(ack.id, "stream-timeout");
client.write_all(b"{").unwrap();
@ -1141,23 +1056,7 @@ mod tests {
.recv_timeout(Duration::from_secs(1))
.unwrap()
.unwrap();
match &close.request.method {
Method::PaneGraphicsStreamClose(params) => {
assert_eq!(params.pane_id, "pane_1");
assert_eq!(params.owner, owner);
}
other => panic!("unexpected close request: {other:?}"),
}
close
.respond_to
.send(
serde_json::to_string(&SuccessResponse {
id: close.request.id,
result: ResponseResult::Ok {},
})
.unwrap(),
)
.unwrap();
assert_close_and_respond(close, "pane_1", &owner);
let error = server_thread.join().unwrap().unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::TimedOut);
@ -1176,6 +1075,8 @@ mod tests {
"stream-oversized",
"owner-1",
"pane_1",
None,
0,
&api_tx,
&server_running,
&stream_active,

View File

@ -1662,8 +1662,6 @@ impl AppState {
}
for pane_id in pane_ids {
self.plugin_panes.remove(&pane_id);
self.pane_graphics_layers.remove(&pane_id);
self.pane_graphics_streams.remove(&pane_id);
}
}
@ -3402,26 +3400,6 @@ mod tests {
state
}
fn insert_test_pane_graphics_layer(state: &mut AppState, pane_id: PaneId) {
state.pane_graphics_layers.insert(
pane_id,
crate::app::state::PaneGraphicsLayer::new(
crate::api::schema::PaneGraphicsFormat::Rgba,
1,
1,
vec![1, 2, 3, 4],
crate::api::schema::PaneGraphicsPlacementParams::default(),
),
);
}
fn insert_test_pane_graphics_state(state: &mut AppState, pane_id: PaneId) {
insert_test_pane_graphics_layer(state, pane_id);
state
.pane_graphics_streams
.insert(pane_id, "test-stream".into());
}
fn mark_linked_worktree(state: &mut AppState, ws_idx: usize) {
state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
key: "repo-key".into(),
@ -5834,13 +5812,9 @@ mod tests {
entrypoint: "board".into(),
},
);
insert_test_pane_graphics_state(&mut state, closed);
state.close_pane();
assert_eq!(state.workspaces[0].panes.len(), 1);
assert!(!state.plugin_panes.contains_key(&closed));
assert!(!state.pane_graphics_layers.contains_key(&closed));
assert!(!state.pane_graphics_streams.contains_key(&closed));
state.assert_invariants_for_test();
}
@ -5910,14 +5884,10 @@ mod tests {
entrypoint: "board".into(),
},
);
insert_test_pane_graphics_state(&mut state, pane_id);
state.close_tab();
assert!(!state.terminals.contains_key(&terminal_id));
assert!(!state.plugin_panes.contains_key(&pane_id));
assert!(!state.pane_graphics_layers.contains_key(&pane_id));
assert!(!state.pane_graphics_streams.contains_key(&pane_id));
state.assert_invariants_for_test();
}
@ -5933,14 +5903,10 @@ mod tests {
entrypoint: "board".into(),
},
);
insert_test_pane_graphics_state(&mut state, pane_id);
state.close_selected_workspace();
assert!(!state.terminals.contains_key(&terminal_id));
assert!(!state.plugin_panes.contains_key(&pane_id));
assert!(!state.pane_graphics_layers.contains_key(&pane_id));
assert!(!state.pane_graphics_streams.contains_key(&pane_id));
state.assert_invariants_for_test();
}

View File

@ -1102,6 +1102,9 @@ impl App {
Method::PaneGraphicsStreamSet(params) => {
return self.handle_pane_graphics_stream_set(request.id, params);
}
Method::PaneGraphicsStreamDirect(params) => {
return self.handle_pane_graphics_stream_direct(request.id, params);
}
Method::PaneGraphicsStreamOpen(params) => {
return self.handle_pane_graphics_stream_open(request.id, params);
}

File diff suppressed because it is too large Load Diff

View File

@ -705,8 +705,10 @@ fn manifest_actions(
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use crate::api::schema::PaneListParams;
use crate::api::schema::{
Method, PaneListParams, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse,
Method, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse,
};
use std::time::{SystemTime, UNIX_EPOCH};

View File

@ -103,7 +103,7 @@ impl App {
None
}
pub(super) fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> {
pub(crate) fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> {
if let Some(alias) = self.state.public_pane_id_aliases.get(id).copied() {
return self.find_pane(alias).map(|(ws_idx, _)| (ws_idx, alias));
}

View File

@ -493,15 +493,17 @@ impl App {
self.close_popup_pane();
return;
};
let column = mouse.column.saturating_sub(inner.x);
let row = mouse.row.saturating_sub(inner.y);
let position = crate::input::mouse::Position::Cell {
column: mouse.column.saturating_sub(inner.x),
row: mouse.row.saturating_sub(inner.y),
};
let bytes = match mouse.kind {
MouseEventKind::ScrollUp
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => match rt.wheel_routing() {
Some(crate::pane::WheelRouting::MouseReport) => {
rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers)
rt.encode_mouse_wheel(mouse.kind, position, mouse.modifiers)
}
Some(crate::pane::WheelRouting::AlternateScroll) => {
rt.encode_alternate_scroll(mouse.kind)
@ -517,11 +519,9 @@ impl App {
}
},
MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => {
rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers)
}
MouseEventKind::Moved => {
rt.encode_mouse_motion(mouse.kind, column, row, mouse.modifiers)
rt.encode_mouse_button(mouse.kind, position, mouse.modifiers)
}
MouseEventKind::Moved => rt.encode_mouse_motion(mouse.kind, position, mouse.modifiers),
};
let Some(bytes) = bytes else {
return;

View File

@ -1667,6 +1667,33 @@ impl AppState {
}
}
fn pane_mouse_position(
&self,
runtime: &crate::terminal::TerminalRuntime,
inner: Rect,
mouse: MouseEvent,
) -> Option<crate::input::mouse::Position> {
let column = mouse.column.saturating_sub(inner.x);
let row = mouse.row.saturating_sub(inner.y);
let cell = crate::input::mouse::Position::Cell { column, row };
let Some(host) = self.host_mouse_pixels else {
return Some(cell);
};
let wants_pixels = runtime.input_state().is_some_and(|state| {
state.mouse_protocol_encoding == crate::input::MouseProtocolEncoding::SgrPixels
});
if !wants_pixels {
return Some(cell);
}
let Some((width_px, height_px)) = runtime.pixel_size() else {
return Some(cell);
};
Some(
host.pane_position(inner, width_px, height_px)
.unwrap_or(cell),
)
}
pub(super) fn forward_pane_mouse_button(
&self,
terminal_runtimes: &TerminalRuntimeRegistry,
@ -1680,9 +1707,10 @@ impl AppState {
else {
return false;
};
let column = mouse.column.saturating_sub(info.inner_rect.x);
let row = mouse.row.saturating_sub(info.inner_rect.y);
let Some(bytes) = rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers) else {
let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else {
return false;
};
let Some(bytes) = rt.encode_mouse_button(mouse.kind, position, mouse.modifiers) else {
return false;
};
rt.scroll_reset();
@ -1705,9 +1733,10 @@ impl AppState {
else {
return false;
};
let column = mouse.column.saturating_sub(info.inner_rect.x);
let row = mouse.row.saturating_sub(info.inner_rect.y);
let Some(bytes) = rt.encode_mouse_motion(mouse.kind, column, row, mouse.modifiers) else {
let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else {
return false;
};
let Some(bytes) = rt.encode_mouse_motion(mouse.kind, position, mouse.modifiers) else {
return false;
};
if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
@ -1733,9 +1762,10 @@ impl AppState {
return false;
}
rt.scroll_reset();
let column = mouse.column.saturating_sub(info.inner_rect.x);
let row = mouse.row.saturating_sub(info.inner_rect.y);
let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers) else {
let Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else {
return false;
};
let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, position, mouse.modifiers) else {
warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event");
return true;
};
@ -1761,18 +1791,7 @@ impl AppState {
match rt.wheel_routing() {
Some(crate::pane::WheelRouting::HostScroll) | None => false,
Some(crate::pane::WheelRouting::MouseReport) => {
rt.scroll_reset();
let column = mouse.column.saturating_sub(info.inner_rect.x);
let row = mouse.row.saturating_sub(info.inner_rect.y);
let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers)
else {
warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event");
return true;
};
if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event");
}
true
self.forward_pane_reported_wheel(terminal_runtimes, info, mouse)
}
Some(crate::pane::WheelRouting::AlternateScroll) => {
rt.scroll_reset();
@ -2146,7 +2165,7 @@ mod tests {
}
#[tokio::test]
async fn configured_right_click_passthrough_forwards_full_gesture_to_pane() {
async fn configured_right_click_passthrough_forwards_gesture_outside_pane() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
@ -2177,11 +2196,11 @@ mod tests {
});
app.handle_mouse(MouseEvent {
modifiers: KeyModifiers::CONTROL,
..mouse(MouseEventKind::Drag(MouseButton::Right), col + 1, row + 1)
..mouse(MouseEventKind::Drag(MouseButton::Right), 0, 0)
});
app.handle_mouse(MouseEvent {
modifiers: KeyModifiers::CONTROL,
..mouse(MouseEventKind::Up(MouseButton::Right), col + 1, row + 1)
..mouse(MouseEventKind::Up(MouseButton::Right), 0, 0)
});
assert_eq!(app.state.mode, Mode::Terminal);
@ -2193,11 +2212,11 @@ mod tests {
);
assert_eq!(
input_rx.try_recv().expect("forwarded right mouse drag"),
Bytes::from_static(b"\x1b[<34;4;5M")
Bytes::from_static(b"\x1b[<34;1;1M")
);
assert_eq!(
input_rx.try_recv().expect("forwarded right mouse up"),
Bytes::from_static(b"\x1b[<2;4;5m")
Bytes::from_static(b"\x1b[<2;1;1m")
);
assert!(input_rx.try_recv().is_err());
}
@ -2321,7 +2340,7 @@ mod tests {
}
#[tokio::test]
async fn mouse_dispatcher_downgrades_sgr_pixel_motion_to_cell_coordinates() {
async fn ordinary_cell_mouse_downgrades_pixel_mode_to_cell_coordinates() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
@ -2339,8 +2358,16 @@ mod tests {
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.host_cell_size = crate::kitty_graphics::HostCellSize {
width_px: 10,
height_px: 20,
};
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let info = app.state.view.pane_infos[0].clone();
app.state
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
.unwrap()
.resize(info.inner_rect.height, info.inner_rect.width, 10, 20);
assert!(info.inner_rect.x > 0, "sidebar offset should be present");
assert!(info.inner_rect.y > 0, "tab bar offset should be present");
@ -2357,6 +2384,77 @@ mod tests {
assert!(input_rx.try_recv().is_err());
}
#[tokio::test]
async fn dedicated_client_pixel_mouse_preserves_subcell_position() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let (runtime, mut input_rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
80,
18,
0,
b"\x1b[?1003h\x1b[?1006h\x1b[?1016h",
4,
);
ws.insert_test_runtime(pane_id, runtime);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.mouse_capture = false;
app.state.host_cell_size = crate::kitty_graphics::HostCellSize {
width_px: 10,
height_px: 20,
};
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let inner = app.state.view.pane_infos[0].inner_rect;
let geometry = crate::input::mouse::HostGeometry::new(106, 20, 1_060, 400).unwrap();
let x = u32::from(inner.x + 2) * 10 + 8;
let y = u32::from(inner.y + 3) * 20 + 9;
let report = format!("\x1b[<35;{x};{y}M");
app.state.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry });
let runtime = app
.state
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
.unwrap();
assert_eq!(runtime.pixel_size(), None);
assert_eq!(
app.state.pane_mouse_position(
runtime,
inner,
mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 3),
),
Some(crate::input::mouse::Position::Cell { column: 2, row: 3 })
);
runtime.resize(inner.height, inner.width, 10, 20);
let runtime = app
.state
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
.unwrap();
assert_eq!(
runtime.pixel_size(),
Some((u32::from(inner.width) * 10, u32::from(inner.height) * 20))
);
assert_eq!(
app.state.pane_mouse_position(
runtime,
inner,
mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 3),
),
Some(crate::input::mouse::Position::Pixels { x: 28, y: 69 })
);
app.state.host_mouse_pixels = None;
assert!(app.route_client_pixel_mouse(7, report.as_bytes(), geometry));
assert_eq!(
input_rx.try_recv().expect("forwarded exact mouse motion"),
Bytes::from_static(b"\x1b[<35;28;69M")
);
assert!(input_rx.try_recv().is_err());
assert!(app.state.host_mouse_pixels.is_none());
}
#[tokio::test]
async fn mouse_dispatcher_does_not_forward_motion_behind_herdr_modes() {
let mut app = app_for_mouse_test();

View File

@ -16,6 +16,7 @@ mod creation;
mod git_refresh;
mod ids;
mod input;
pub(crate) mod pane_graphics;
mod popup;
mod runtime;
mod runtime_mutations;
@ -95,6 +96,10 @@ impl PaneClickState {
pub struct App {
pub state: AppState,
pub(crate) pane_graphics: pane_graphics::Runtime,
pub(crate) pane_graphics_files: Arc<crate::pane_graphics_files::FileStore>,
pub(crate) direct_graphics_available: bool,
pub(crate) pixel_mouse_available: bool,
pub(crate) terminal_runtimes: crate::terminal::TerminalRuntimeRegistry,
pub event_tx: mpsc::Sender<AppEvent>,
pub(crate) event_rx: mpsc::Receiver<AppEvent>,
@ -672,9 +677,6 @@ impl App {
integration_install_messages: Vec::new(),
installed_plugins: load_plugin_registry(no_session),
plugin_panes: std::collections::HashMap::new(),
pane_graphics_layers: std::collections::HashMap::new(),
pane_graphics_streams: std::collections::HashMap::new(),
pane_graphics_revision: 0,
popup_pane: None,
plugin_command_logs: Vec::new(),
next_plugin_command_log_id: 1,
@ -682,6 +684,7 @@ impl App {
global_menu: state::MenuListState::new(0),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
host_cell_size: crate::kitty_graphics::HostCellSize::default(),
host_mouse_pixels: None,
session_dirty: false,
terminal_runtime_shutdowns: Vec::new(),
};
@ -726,6 +729,10 @@ impl App {
copy_feedback_deadline: None,
last_api_notification_at: None,
state,
pane_graphics: pane_graphics::Runtime::default(),
pane_graphics_files: Arc::new(crate::pane_graphics_files::FileStore::default()),
direct_graphics_available: false,
pixel_mouse_available: false,
terminal_runtimes: restored_terminal_runtimes,
event_tx,
event_rx,
@ -1033,6 +1040,10 @@ impl App {
needs_render = true;
}
if self.pane_graphics.retain_live_panes(&self.state) {
needs_render = true;
}
let now = Instant::now();
self.sync_host_mouse_capture(&mut host_mouse_capture_active)?;
self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?;
@ -1048,18 +1059,19 @@ impl App {
terminal.swap_buffers();
self.full_redraw_pending = false;
}
let mut cell_size = crate::kitty_graphics::HostCellSize::default();
let mut cell_size = self.state.host_cell_size;
terminal.draw(|frame| {
let area = frame.area();
if kitty_graphics_enabled {
let observed_cell_size =
crate::kitty_graphics::HostCellSize::try_from_terminal(area);
if let Some(observed_cell_size) = observed_cell_size {
if let Some(observed_cell_size) =
crate::kitty_graphics::HostCellSize::try_from_terminal(area)
{
self.state.host_cell_size = observed_cell_size;
cell_size = observed_cell_size;
} else if !cell_size.is_known() {
cell_size =
crate::kitty_graphics::HostCellSize::fallback_for_area(area);
}
cell_size = observed_cell_size.unwrap_or_else(|| {
crate::kitty_graphics::HostCellSize::fallback_for_area(area)
});
crate::ui::compute_view_with_cell_size(
&mut self.state,
&self.terminal_runtimes,
@ -1082,6 +1094,7 @@ impl App {
if kitty_graphics_enabled {
crate::kitty_graphics::paint_local_pane_graphics(
&self.state,
&self.pane_graphics,
&self.terminal_runtimes,
cell_size,
)?;
@ -1468,8 +1481,7 @@ impl App {
crate::kitty_graphics::set_enabled(config.experimental.kitty_graphics);
if was_kitty_graphics_enabled && !config.experimental.kitty_graphics {
let _ = crate::kitty_graphics::clear_all_host_graphics();
self.state.pane_graphics_layers.clear();
self.state.pane_graphics_streams.clear();
self.pane_graphics.clear();
self.state.host_cell_size = crate::kitty_graphics::HostCellSize::default();
}
self.state.reveal_hidden_cursor_for_cjk_ime =
@ -1663,6 +1675,31 @@ impl App {
self.route_client_events(events, true);
}
pub(crate) fn route_client_pixel_mouse(
&mut self,
source_id: InputSourceId,
data: &[u8],
geometry: crate::input::mouse::HostGeometry,
) -> bool {
let Some((x, y)) = crate::input::mouse::parse_report(data) else {
return false;
};
let Some((column, row)) = geometry.cell(x, y) else {
return false;
};
let Some(cell_report) = crate::input::mouse::report_at_cell(data, column, row) else {
return false;
};
let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report);
if events.len() != 1 || !matches!(events[0], crate::raw_input::RawInputEvent::Mouse(_)) {
return false;
}
self.state.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry });
self.route_client_events_from(source_id, std::mem::take(&mut events), false);
self.state.host_mouse_pixels = None;
true
}
pub(crate) fn route_client_events(
&mut self,
events: Vec<crate::raw_input::RawInputEvent>,

347
src/app/pane_graphics.rs Normal file
View File

@ -0,0 +1,347 @@
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use crate::api::schema::{PaneGraphicsFormat, PaneGraphicsPlacementParams};
use crate::layout::PaneId;
pub(crate) type Key = (PaneId, String);
pub(crate) const DIRECT_DELIVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub(crate) const DIRECT_RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
pub(crate) const DIRECT_OUTER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(9);
#[derive(Debug)]
pub(crate) enum Backing {
Inline(Vec<u8>),
Direct(crate::pane_graphics_files::Lease),
Resident { len: usize, client_id: u64 },
}
#[derive(Debug)]
pub(crate) struct Layer {
pub(crate) format: PaneGraphicsFormat,
pub(crate) image_width: u32,
pub(crate) image_height: u32,
pub(crate) backing: Backing,
pub(crate) data_fingerprint: u64,
pub(crate) render: PaneGraphicsPlacementParams,
pub(crate) z_index: i32,
}
#[derive(Debug)]
pub(crate) struct DirectGate {
pub(crate) transfer_id: u64,
pub(crate) client_id: u64,
pub(crate) deadline: std::time::Instant,
pub(crate) written: bool,
pub(crate) success_response: String,
pub(crate) respond_to: std::sync::mpsc::Sender<String>,
}
#[derive(Debug)]
pub(crate) struct Slot {
pub(crate) host_image_id: u32,
pub(crate) layer: Option<Layer>,
pub(crate) stream_owner: Option<String>,
pub(crate) stream_active: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
pub(crate) direct_gate: Option<DirectGate>,
}
impl Slot {
#[cfg(test)]
pub(crate) fn test(host_image_id: u32, layer: Option<Layer>) -> Self {
Self {
host_image_id,
layer,
stream_owner: None,
stream_active: None,
direct_gate: None,
}
}
pub(crate) fn stream_is_active(&self) -> bool {
self.stream_active
.as_ref()
.is_some_and(|active| active.load(std::sync::atomic::Ordering::Acquire))
}
}
impl Drop for Slot {
fn drop(&mut self) {
if let Some(active) = &self.stream_active {
active.store(false, std::sync::atomic::Ordering::Release);
}
}
}
#[derive(Debug)]
pub(crate) struct Runtime {
pub(crate) slots: HashMap<Key, Slot>,
revision: u64,
next_host_image_id: u32,
}
impl Default for Runtime {
fn default() -> Self {
Self {
slots: HashMap::new(),
revision: 0,
next_host_image_id: 1 << 31,
}
}
}
impl Runtime {
pub(crate) fn revision(&self) -> u64 {
self.revision
}
pub(crate) fn mark_changed(&mut self) {
self.revision = self.revision.wrapping_add(1);
}
pub(crate) fn clear(&mut self) {
self.slots.clear();
self.mark_changed();
}
pub(crate) fn reserve_image_id(&mut self, key: &Key) -> Option<u32> {
if let Some(id) = self.slots.get(key).map(|slot| slot.host_image_id) {
return Some(id);
}
for _ in 0..=self.slots.len() {
let id = self.next_host_image_id | (1 << 31);
self.next_host_image_id = id.wrapping_add(1) | (1 << 31);
if self.slots.values().all(|slot| slot.host_image_id != id) {
return Some(id);
}
}
None
}
pub(crate) fn layer_count(&self, pane_id: PaneId) -> usize {
self.slots.keys().filter(|(id, _)| *id == pane_id).count()
}
pub(crate) fn can_add_slot(&self, key: &Key) -> bool {
self.slots.contains_key(key)
|| self.slots.len() < crate::api::schema::panes::PANE_GRAPHICS_MAX_LAYERS_TOTAL
}
pub(crate) fn can_store_inline(&self, key: &Key, len: usize) -> bool {
let replaced = self
.slots
.get(key)
.and_then(|slot| slot.layer.as_ref())
.and_then(Layer::inline_data)
.map_or(0, <[u8]>::len);
self.inline_bytes()
.saturating_sub(replaced)
.saturating_add(len)
<= crate::api::schema::panes::PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL
}
fn inline_bytes(&self) -> usize {
self.slots
.values()
.filter_map(|slot| slot.layer.as_ref())
.filter_map(Layer::inline_data)
.map(<[u8]>::len)
.sum()
}
pub(crate) fn active_for_pane(&self, pane_id: PaneId) -> bool {
self.slots
.iter()
.any(|((id, _), slot)| *id == pane_id && slot.layer.is_some())
}
pub(crate) fn attach_stream_active(
&mut self,
key: &Key,
owner: &str,
active: std::sync::Arc<std::sync::atomic::AtomicBool>,
) {
if let Some(slot) = self
.slots
.get_mut(key)
.filter(|slot| slot.stream_owner.as_deref() == Some(owner))
{
slot.stream_active = Some(active);
}
}
pub(crate) fn retain_live_panes(&mut self, state: &crate::app::state::AppState) -> bool {
if self.slots.is_empty() {
return false;
}
let before = self.slots.len();
self.slots.retain(|(pane_id, _), _| {
state
.workspaces
.iter()
.any(|workspace| workspace.pane_state(*pane_id).is_some())
});
if self.slots.len() == before {
return false;
}
self.mark_changed();
true
}
}
impl Layer {
pub(crate) fn inline(
format: PaneGraphicsFormat,
image_width: u32,
image_height: u32,
data: Vec<u8>,
render: PaneGraphicsPlacementParams,
z_index: i32,
) -> Self {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
data.hash(&mut hasher);
Self {
format,
image_width,
image_height,
backing: Backing::Inline(data),
data_fingerprint: hasher.finish(),
render,
z_index,
}
}
pub(crate) fn direct(
image_width: u32,
image_height: u32,
lease: crate::pane_graphics_files::Lease,
render: PaneGraphicsPlacementParams,
z_index: i32,
) -> Self {
let data_fingerprint = lease.fingerprint();
Self {
format: PaneGraphicsFormat::Rgba,
image_width,
image_height,
backing: Backing::Direct(lease),
data_fingerprint,
render,
z_index,
}
}
pub(crate) fn data_len(&self) -> usize {
match &self.backing {
Backing::Inline(data) => data.len(),
Backing::Direct(lease) => lease.len(),
Backing::Resident { len, .. } => *len,
}
}
pub(crate) fn inline_data(&self) -> Option<&[u8]> {
match &self.backing {
Backing::Inline(data) => Some(data),
Backing::Direct(_) | Backing::Resident { .. } => None,
}
}
pub(crate) fn direct_lease(&self) -> Option<&crate::pane_graphics_files::Lease> {
match &self.backing {
Backing::Direct(lease) => Some(lease),
Backing::Inline(_) | Backing::Resident { .. } => None,
}
}
pub(crate) fn mark_resident(&mut self, client_id: u64) -> bool {
let Backing::Direct(lease) = &self.backing else {
return false;
};
self.backing = Backing::Resident {
len: lease.len(),
client_id,
};
true
}
pub(crate) fn resident_client(&self) -> Option<u64> {
match self.backing {
Backing::Resident { client_id, .. } => Some(client_id),
Backing::Inline(_) | Backing::Direct(_) => None,
}
}
pub(crate) fn terminal_only(&self) -> bool {
matches!(self.backing, Backing::Direct(_) | Backing::Resident { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stream_liveness_attaches_only_to_the_exact_owned_layer() {
let mut runtime = Runtime::default();
let first = (PaneId::from_raw(1), "first".into());
let second = (PaneId::from_raw(2), "second".into());
for (key, id) in [(&first, 1), (&second, 2)] {
runtime.slots.insert(
key.clone(),
Slot {
host_image_id: (1 << 31) | id,
layer: None,
stream_owner: Some("shared-owner".into()),
stream_active: None,
direct_gate: None,
},
);
}
let active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
runtime.attach_stream_active(&second, "shared-owner", active.clone());
assert!(runtime.slots[&first].stream_active.is_none());
assert!(std::sync::Arc::ptr_eq(
runtime.slots[&second].stream_active.as_ref().unwrap(),
&active
));
}
#[test]
fn runtime_bounds_total_slots_and_inline_bytes() {
let mut runtime = Runtime::default();
for index in 0..crate::api::schema::panes::PANE_GRAPHICS_MAX_LAYERS_TOTAL {
let key = (PaneId::from_raw(1), format!("layer-{index}"));
runtime
.slots
.insert(key, Slot::test((1 << 31) | index as u32, None));
}
assert!(!runtime.can_add_slot(&(PaneId::from_raw(2), "extra".into())));
let mut runtime = Runtime::default();
let key = (PaneId::from_raw(1), "primary".into());
runtime.slots.insert(
key.clone(),
Slot::test(
1 << 31,
Some(Layer::inline(
PaneGraphicsFormat::Rgba,
1,
1,
vec![0; 4],
PaneGraphicsPlacementParams::default(),
0,
)),
),
);
assert!(runtime.can_store_inline(
&key,
crate::api::schema::panes::PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL,
));
assert!(!runtime.can_store_inline(
&(PaneId::from_raw(2), "primary".into()),
crate::api::schema::panes::PANE_GRAPHICS_MAX_INLINE_BYTES_TOTAL,
));
}
}

View File

@ -61,6 +61,11 @@ impl App {
msg: crate::api::ApiRequestMessage,
) -> bool {
let previous_mode = self.state.mode;
let stream_open = match &msg.request.method {
crate::api::schema::Method::PaneGraphicsStreamOpen(params) => Some(params.clone()),
_ => None,
};
let stream_active = msg.stream_active.clone();
let mut changed = self.expire_due_metadata(Instant::now());
changed |= crate::api::request_changes_ui(&msg.request);
let skip_default_workspace = matches!(
@ -83,6 +88,9 @@ impl App {
return changed | deferred_changed;
}
let response = self.handle_api_request(msg.request);
if let (Some(params), Some(active)) = (stream_open.as_ref(), stream_active) {
self.attach_pane_graphics_stream_active(params, active, &response);
}
if !skip_default_workspace {
changed |= self.ensure_default_workspace();
}

View File

@ -4,7 +4,6 @@ use crate::config::{
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::layout::{Direction, Rect};
use ratatui::style::Color;
use std::hash::{Hash, Hasher};
use crate::detect::AgentState;
use crate::layout::{PaneId, PaneInfo, SplitBorder};
@ -12,49 +11,12 @@ use crate::selection::Selection;
pub(crate) type InstalledPluginRegistry =
std::collections::HashMap<String, crate::api::schema::InstalledPluginInfo>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PluginPaneRecord {
pub plugin_id: String,
pub entrypoint: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PaneGraphicsLayer {
pub format: crate::api::schema::PaneGraphicsFormat,
pub image_width: u32,
pub image_height: u32,
pub data: Vec<u8>,
pub data_fingerprint: u64,
pub render: crate::api::schema::PaneGraphicsPlacementParams,
}
impl PaneGraphicsLayer {
pub(crate) fn new(
format: crate::api::schema::PaneGraphicsFormat,
image_width: u32,
image_height: u32,
data: Vec<u8>,
render: crate::api::schema::PaneGraphicsPlacementParams,
) -> Self {
let data_fingerprint = pane_graphics_data_fingerprint(&data);
Self {
format,
image_width,
image_height,
data,
data_fingerprint,
render,
}
}
}
fn pane_graphics_data_fingerprint(data: &[u8]) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
data.hash(&mut hasher);
hasher.finish()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PopupPaneState {
pub pane_id: PaneId,
@ -1520,12 +1482,6 @@ pub struct AppState {
pub(crate) installed_plugins: InstalledPluginRegistry,
/// Pane ids opened through the plugin pane API.
pub(crate) plugin_panes: std::collections::HashMap<PaneId, PluginPaneRecord>,
/// Runtime image layers owned by API clients and composited over panes.
pub(crate) pane_graphics_layers: std::collections::HashMap<PaneId, PaneGraphicsLayer>,
/// Active streaming graphics owner token by pane id.
pub(crate) pane_graphics_streams: std::collections::HashMap<PaneId, String>,
/// Monotonic marker for accepted pane graphics mutations.
pub(crate) pane_graphics_revision: u64,
/// Session-modal terminal popup. This is intentionally outside workspace layouts.
pub(crate) popup_pane: Option<PopupPaneState>,
/// Recent plugin action/event command executions.
@ -1538,6 +1494,8 @@ pub struct AppState {
pub host_terminal_theme: TerminalTheme,
/// Last known foreground host terminal cell size in pixels.
pub(crate) host_cell_size: crate::kitty_graphics::HostCellSize,
/// Exact pixel provenance only while one confirmed SGR report is dispatched.
pub(crate) host_mouse_pixels: Option<crate::input::mouse::HostPixels>,
/// Set when a persisted session snapshot would change.
pub session_dirty: bool,
/// Terminal runtimes that should be shut down by the app/runtime layer
@ -1884,9 +1842,6 @@ impl AppState {
integration_install_messages: Vec::new(),
installed_plugins: std::collections::HashMap::new(),
plugin_panes: std::collections::HashMap::new(),
pane_graphics_layers: std::collections::HashMap::new(),
pane_graphics_streams: std::collections::HashMap::new(),
pane_graphics_revision: 0,
popup_pane: None,
plugin_command_logs: Vec::new(),
next_plugin_command_log_id: 1,
@ -1894,6 +1849,7 @@ impl AppState {
global_menu: MenuListState::new(0),
host_terminal_theme: TerminalTheme::default(),
host_cell_size: crate::kitty_graphics::HostCellSize::default(),
host_mouse_pixels: None,
session_dirty: false,
terminal_runtime_shutdowns: Vec::new(),
}
@ -2000,6 +1956,10 @@ impl AppState {
self.context_menu.is_none(),
"empty app state must not keep context menu"
);
assert!(
self.host_mouse_pixels.is_none(),
"empty app state must not keep host mouse pixel provenance"
);
return;
}

View File

@ -0,0 +1,393 @@
const MAX_RESPONSE_BYTES: usize = 4096;
const KITTY_PREFIX: &[u8] = b"\x1b_G";
const KITTY_SUFFIX: &[u8] = b"\x1b\\";
const RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
const LATE_RESPONSE_DRAIN: std::time::Duration = std::time::Duration::from_secs(1);
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Response {
pub(super) transfer_id: u64,
pub(super) image_id: u32,
pub(super) success: bool,
}
#[derive(Debug, Default)]
pub(super) struct ResponseMatcher {
expected: Option<(u64, u32, Option<std::time::Instant>)>,
retired: Option<(u32, std::time::Instant)>,
active: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl ResponseMatcher {
pub(super) fn active_handle(&self) -> std::sync::Arc<std::sync::atomic::AtomicBool> {
self.active.clone()
}
fn refresh_active(&self) {
self.active.store(
self.expected.is_some() || self.retired.is_some(),
std::sync::atomic::Ordering::Release,
);
}
pub(super) fn interested(&mut self) -> bool {
self.expire();
self.expected.is_some() || self.retired.is_some()
}
pub(super) fn arm(&mut self, transfer_id: u64, image_id: u32) -> bool {
self.expire();
if self.expected.is_some() {
return false;
}
self.expected = Some((transfer_id, image_id, None));
self.refresh_active();
true
}
pub(super) fn start(&mut self, transfer_id: u64) {
self.start_at(transfer_id, std::time::Instant::now());
}
fn start_at(&mut self, transfer_id: u64, now: std::time::Instant) {
if let Some((id, _, deadline)) = &mut self.expected {
if *id == transfer_id {
*deadline = Some(now + RESPONSE_TIMEOUT);
}
}
}
pub(super) fn cancel(&mut self, transfer_id: u64) {
if self.expected.is_some_and(|(id, _, _)| id == transfer_id) {
self.expected = None;
self.refresh_active();
}
}
pub(super) fn retire(&mut self, transfer_id: u64) {
if self.expected.is_some_and(|(id, _, _)| id == transfer_id) {
if let Some((_, image_id, _)) = self.expected.take() {
self.retired = Some((image_id, std::time::Instant::now() + LATE_RESPONSE_DRAIN));
self.refresh_active();
}
}
}
pub(super) fn expire(&mut self) {
self.expire_at(std::time::Instant::now());
}
fn expire_at(&mut self, now: std::time::Instant) {
if self
.expected
.is_some_and(|(_, _, deadline)| deadline.is_some_and(|deadline| deadline <= now))
{
if let Some((_, image_id, _)) = self.expected.take() {
self.retired = Some((image_id, now + LATE_RESPONSE_DRAIN));
}
}
if self.retired.is_some_and(|(_, deadline)| deadline <= now) {
self.retired = None;
}
self.refresh_active();
}
pub(super) fn consume(&mut self, bytes: &[u8]) -> Option<Option<Response>> {
self.expire();
if !bytes.starts_with(b"\x1b_G")
|| bytes.len() > MAX_RESPONSE_BYTES
|| !bytes.ends_with(b"\x1b\\")
{
return None;
}
let payload = &bytes[3..bytes.len() - 2];
let separator = payload.iter().position(|byte| *byte == b';')?;
if let Some((retired_id, _)) = self.retired {
if matching_response_controls(&payload[..separator], retired_id) {
self.retired = None;
self.refresh_active();
return Some(None);
}
}
let (transfer_id, image_id, _) = self.expected?;
if !matching_response_controls(&payload[..separator], image_id) {
return None;
}
self.expected = None;
self.refresh_active();
Some(Some(Response {
transfer_id,
image_id,
success: &payload[separator + 1..] == b"OK",
}))
}
}
#[derive(Default)]
pub(super) struct InputFilter {
pending: Vec<u8>,
}
impl InputFilter {
pub(super) fn has_pending(&self) -> bool {
!self.pending.is_empty()
}
pub(super) fn flush_if_inactive(&mut self, matcher: &mut ResponseMatcher) -> Option<Vec<u8>> {
(self.has_pending() && !matcher.interested()).then(|| std::mem::take(&mut self.pending))
}
pub(super) fn push(
&mut self,
bytes: &[u8],
matcher: &mut ResponseMatcher,
) -> (Vec<Vec<u8>>, Vec<Response>) {
self.pending.extend_from_slice(bytes);
let mut output = Vec::new();
let mut responses = Vec::new();
if !matcher.interested() {
output.push(std::mem::take(&mut self.pending));
return (output, responses);
}
loop {
let Some(start) = self
.pending
.windows(3)
.position(|window| window == KITTY_PREFIX)
else {
let keep = (1..KITTY_PREFIX.len())
.rev()
.find(|len| self.pending.ends_with(&KITTY_PREFIX[..*len]))
.unwrap_or(0);
let emit = self.pending.len().saturating_sub(keep);
if emit > 0 {
output.push(self.pending.drain(..emit).collect());
}
break;
};
if start > 0 {
output.push(self.pending.drain(..start).collect());
}
let end = self
.pending
.windows(2)
.position(|window| window == KITTY_SUFFIX);
let Some(end) = end else {
if self.pending.len() > MAX_RESPONSE_BYTES {
output.push(std::mem::take(&mut self.pending));
}
break;
};
let command: Vec<u8> = self.pending.drain(..end + 2).collect();
match matcher.consume(&command) {
Some(Some(response)) => responses.push(response),
Some(None) => {}
None => output.push(command),
}
}
(output, responses)
}
}
fn matching_response_controls(bytes: &[u8], expected: u32) -> bool {
let mut matched = false;
for field in bytes.split(|byte| *byte == b',') {
let Some(separator) = field.iter().position(|byte| *byte == b'=') else {
return false;
};
let (key, value) = field.split_at(separator);
let value = &value[1..];
if value.is_empty() || !value.iter().all(u8::is_ascii_digit) {
return false;
}
match key {
b"i" if !matched
&& std::str::from_utf8(value).ok().and_then(|v| v.parse().ok())
== Some(expected) =>
{
matched = true
}
b"I" | b"p" => {}
_ => return false,
}
}
matched
}
pub(super) fn valid_control(control: &str, image_id: u32) -> bool {
if control.len() > 1024 || control.contains([';', '\x1b']) {
return false;
}
let mut action = false;
let mut format = false;
let mut image = false;
let mut quiet = false;
let mut cursor = false;
for field in control.split(',') {
let Some((key, value)) = field.split_once('=') else {
return false;
};
if key == "t"
|| !matches!(
key,
"a" | "f"
| "s"
| "v"
| "i"
| "p"
| "c"
| "r"
| "z"
| "C"
| "q"
| "x"
| "y"
| "w"
| "h"
| "X"
| "Y"
)
{
return false;
}
let numeric = value
.strip_prefix('-')
.unwrap_or(value)
.bytes()
.all(|byte| byte.is_ascii_digit())
&& !value.is_empty();
if key != "a" && !numeric {
return false;
}
match key {
"a" => action = value == "T",
"f" => format = value == "32",
"i" => image = value.parse() == Ok(image_id),
"q" => quiet = value == "0",
"C" => cursor = value == "1",
_ => {}
}
}
action && format && image && quiet && cursor
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_only_the_armed_image_and_preserves_unrelated_input() {
let mut matcher = ResponseMatcher::default();
let active = matcher.active_handle();
assert!(matcher.arm(7, 42));
assert!(active.load(std::sync::atomic::Ordering::Acquire));
assert!(!matcher.arm(8, 43));
assert_eq!(matcher.consume(b"typed"), None);
assert_eq!(matcher.consume(b"\x1b_Gi=41;OK\x1b\\"), None);
assert_eq!(
matcher.consume(b"\x1b_Gi=42;OK\x1b\\"),
Some(Some(Response {
transfer_id: 7,
image_id: 42,
success: true,
}))
);
assert!(!active.load(std::sync::atomic::Ordering::Acquire));
}
#[test]
fn explicit_error_is_reported_and_malformed_responses_are_ignored() {
let mut matcher = ResponseMatcher::default();
matcher.arm(9, 44);
assert_eq!(
matcher.consume(b"\x1b_Gi=44;ENOENT\x1b\\"),
Some(Some(Response {
transfer_id: 9,
image_id: 44,
success: false,
}))
);
matcher.arm(10, 45);
assert_eq!(matcher.consume(b"\x1b_Gi=45;broken"), None);
matcher.cancel(10);
matcher.arm(11, 46);
assert_eq!(matcher.consume(b"\x1b_Gi=46oops;OK\x1b\\"), None);
}
#[test]
fn validated_control_is_one_owned_rgba_transmit_and_display() {
assert!(valid_control(
"a=T,f=32,s=10,v=20,i=42,p=7,c=5,r=6,z=-1,C=1,q=0,x=2",
42
));
for invalid in [
"a=T,f=24,i=42,C=1,q=0",
"a=T,f=32,i=41,C=1,q=0",
"a=T,t=f,f=32,i=42,C=1,q=0",
"a=p,f=32,i=42,C=1,q=0",
] {
assert!(!valid_control(invalid, 42), "{invalid}");
}
}
#[test]
fn fragmented_response_is_held_before_generic_input_timeout() {
let mut matcher = ResponseMatcher::default();
let mut filter = InputFilter::default();
matcher.arm(19, 49);
for foreign in [
b"\x1b_Ga=p,i=49;OK\x1b\\".as_slice(),
b"\x1b_Gi=49oops;OK\x1b\\",
] {
let (output, responses) = filter.push(foreign, &mut matcher);
assert_eq!(output, [foreign.to_vec()]);
assert!(responses.is_empty());
}
let (output, responses) = filter.push(b"typed\x1b_Gi=49;", &mut matcher);
assert_eq!(output, [b"typed".to_vec()]);
assert!(responses.is_empty());
let (output, responses) = filter.push(b"OK\x1b\\tail", &mut matcher);
assert_eq!(output, [b"tail".to_vec()]);
assert_eq!(responses[0].transfer_id, 19);
assert!(responses[0].success);
}
#[test]
fn attempted_output_failure_retires_response_without_forwarding_it() {
let mut matcher = ResponseMatcher::default();
let mut filter = InputFilter::default();
matcher.arm(20, 50);
matcher.retire(20);
for foreign in [
b"\x1b_Ga=p,i=50;OK\x1b\\".as_slice(),
b"\x1b_Gi=50oops;OK\x1b\\",
] {
let (output, responses) = filter.push(foreign, &mut matcher);
assert_eq!(output, [foreign.to_vec()]);
assert!(responses.is_empty());
}
assert!(filter
.push(b"\x1b_Gi=50;EINVAL\x1b\\", &mut matcher)
.0
.is_empty());
}
#[test]
fn timed_out_expectation_requires_a_new_transfer_before_matching() {
let mut matcher = ResponseMatcher::default();
let started = std::time::Instant::now();
assert!(matcher.arm(12, 47));
matcher.start_at(12, started);
assert!(!matcher.arm(13, 48));
matcher.expire_at(started + RESPONSE_TIMEOUT);
assert!(matcher.arm(13, 48));
assert_eq!(matcher.consume(b"typed"), None);
assert_eq!(matcher.consume(b"\x1b_Gi=47;OK\x1b\\"), Some(None));
assert_eq!(
matcher.consume(b"\x1b_Gi=48;OK\x1b\\"),
Some(Some(Response {
transfer_id: 13,
image_id: 48,
success: true,
}))
);
}
}

View File

@ -41,6 +41,9 @@ pub fn stdin_reader_loop(
host_color_query_sent: bool,
host_cell_size_query_sent: bool,
host_mouse_capture_active: Arc<AtomicBool>,
host_sgr_pixels_active: Arc<AtomicBool>,
#[cfg(unix)] direct_response: Arc<std::sync::Mutex<super::direct_graphics::ResponseMatcher>>,
#[cfg(unix)] direct_response_active: Arc<AtomicBool>,
) {
#[cfg(windows)]
{
@ -48,6 +51,7 @@ pub fn stdin_reader_loop(
host_color_query_sent,
host_cell_size_query_sent,
host_mouse_capture_active,
host_sgr_pixels_active,
);
windows_stdin_reader_loop(event_tx, should_quit);
}
@ -59,6 +63,9 @@ pub fn stdin_reader_loop(
host_color_query_sent,
host_cell_size_query_sent,
host_mouse_capture_active,
host_sgr_pixels_active,
direct_response,
direct_response_active,
);
}
@ -69,6 +76,9 @@ fn unix_stdin_reader_loop(
host_color_query_sent: bool,
host_cell_size_query_sent: bool,
host_mouse_capture_active: Arc<AtomicBool>,
host_sgr_pixels_active: Arc<AtomicBool>,
direct_response: Arc<std::sync::Mutex<super::direct_graphics::ResponseMatcher>>,
direct_response_active: Arc<AtomicBool>,
) {
let stdin = io::stdin();
let mut reader = stdin.lock();
@ -83,15 +93,71 @@ fn unix_stdin_reader_loop(
framer.host_cell_size_query_sent();
}
let mut pending_palette = Vec::new();
let mut pending_mode = None;
let mut last_geometry = None;
let mut direct_filter = super::direct_graphics::InputFilter::default();
while !should_quit.load(Ordering::Acquire) {
if direct_filter.has_pending()
&& stdin_read_ready(&reader, crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS)
== Some(false)
{
let released = direct_response
.lock()
.ok()
.and_then(|mut matcher| direct_filter.flush_if_inactive(&mut matcher));
if let Some(data) = released {
if event_tx
.blocking_send(ClientLoopEvent::StdinInput(data))
.is_err()
{
return;
}
}
continue;
}
match reader.read(&mut scratch) {
Ok(0) => break,
Ok(n) => {
let sgr_pixels = *pending_mode
.get_or_insert_with(|| host_sgr_pixels_active.load(Ordering::Acquire));
if sgr_pixels {
last_geometry = retain_geometry(
last_geometry,
crate::input::mouse::HostGeometry::current(),
);
}
let filtered = filter_direct_input(
&scratch[..n],
&mut direct_filter,
&direct_response,
&direct_response_active,
);
let chunks = if let Some((raw_chunks, responses)) = filtered {
for response in responses {
if event_tx
.blocking_send(ClientLoopEvent::DirectGraphicsResponse(response))
.is_err()
{
return;
}
}
raw_chunks
.into_iter()
.flat_map(|chunk| framer.push(&chunk))
.collect()
} else {
framer.push(&scratch[..n])
};
if !framer.has_pending_input() {
pending_mode = None;
}
if !send_unix_input_chunks(
framer.push(&scratch[..n]),
chunks,
&event_tx,
&mut pending_palette,
sgr_pixels,
last_geometry,
) {
return;
}
@ -104,8 +170,18 @@ fn unix_stdin_reader_loop(
let had_pending = framer.has_pending_input();
let chunks = framer.flush_timeout();
let held_escape = had_pending && chunks.is_empty();
if !send_unix_input_chunks(chunks, &event_tx, &mut pending_palette)
|| !flush_unix_palette_input(&event_tx, &mut pending_palette)
let sgr_pixels = pending_mode
.unwrap_or_else(|| host_sgr_pixels_active.load(Ordering::Acquire));
if !framer.has_pending_input() {
pending_mode = None;
}
if !send_unix_input_chunks(
chunks,
&event_tx,
&mut pending_palette,
sgr_pixels,
last_geometry,
) || !flush_unix_palette_input(&event_tx, &mut pending_palette)
{
return;
}
@ -114,13 +190,20 @@ fn unix_stdin_reader_loop(
&reader,
crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS,
) == Some(false)
&& !send_unix_input_chunks(
framer.flush_timeout(),
{
let chunks = framer.flush_timeout();
if !framer.has_pending_input() {
pending_mode = None;
}
if !send_unix_input_chunks(
chunks,
&event_tx,
&mut pending_palette,
)
{
return;
sgr_pixels,
last_geometry,
) {
return;
}
}
}
}
@ -134,11 +217,31 @@ fn unix_stdin_reader_loop(
}
}
#[cfg(unix)]
fn filter_direct_input(
bytes: &[u8],
filter: &mut super::direct_graphics::InputFilter,
response: &std::sync::Mutex<super::direct_graphics::ResponseMatcher>,
active: &AtomicBool,
) -> Option<(Vec<Vec<u8>>, Vec<super::direct_graphics::Response>)> {
if !active.load(Ordering::Acquire) && !filter.has_pending() {
return None;
}
Some(
response
.lock()
.map(|mut matcher| filter.push(bytes, &mut matcher))
.unwrap_or_else(|_| (vec![bytes.to_vec()], Vec::new())),
)
}
#[cfg(unix)]
fn send_unix_input_chunks(
chunks: Vec<Vec<u8>>,
event_tx: &mpsc::Sender<ClientLoopEvent>,
pending_palette: &mut Vec<Vec<u8>>,
sgr_pixels: bool,
geometry: Option<crate::input::mouse::HostGeometry>,
) -> bool {
for data in chunks {
let palette_response = std::str::from_utf8(&data)
@ -160,16 +263,36 @@ fn send_unix_input_chunks(
if !default_color_response && !flush_unix_palette_input(event_tx, pending_palette) {
return false;
}
if event_tx
.blocking_send(ClientLoopEvent::StdinInput(data))
.is_err()
{
let Some(event) = classify_unix_input(data, sgr_pixels, geometry) else {
continue;
};
if event_tx.blocking_send(event).is_err() {
return false;
}
}
true
}
#[cfg(unix)]
fn retain_geometry(
last: Option<crate::input::mouse::HostGeometry>,
observed: Option<crate::input::mouse::HostGeometry>,
) -> Option<crate::input::mouse::HostGeometry> {
observed.or(last)
}
#[cfg(unix)]
fn classify_unix_input(
data: Vec<u8>,
sgr_pixels: bool,
geometry: Option<crate::input::mouse::HostGeometry>,
) -> Option<ClientLoopEvent> {
if sgr_pixels && crate::input::mouse::parse_report(&data).is_some() {
return geometry.map(|geometry| ClientLoopEvent::PixelMouse(data, geometry));
}
Some(ClientLoopEvent::StdinInput(data))
}
#[cfg(unix)]
fn flush_unix_palette_input(
event_tx: &mpsc::Sender<ClientLoopEvent>,
@ -495,6 +618,50 @@ mod tests {
}
}
#[test]
fn inactive_direct_input_bypasses_filter() {
let response =
std::sync::Mutex::new(super::super::direct_graphics::ResponseMatcher::default());
let active = response.lock().unwrap().active_handle();
let mut filter = super::super::direct_graphics::InputFilter::default();
assert!(filter_direct_input(b"typed", &mut filter, &response, &active).is_none());
assert!(!filter.has_pending());
}
#[test]
fn pixel_mouse_classification_is_narrow_and_uses_read_geometry() {
let geometry = crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap();
let report = b"\x1b[<35;321;241M".to_vec();
let Some(ClientLoopEvent::PixelMouse(data, captured)) =
classify_unix_input(report.clone(), true, Some(geometry))
else {
panic!("expected dedicated pixel mouse event");
};
assert_eq!(data, report);
assert_eq!(captured, geometry);
assert!(classify_unix_input(report, true, None).is_none());
for raw in [
b"key".as_slice(),
b"\x1b[200~paste\x1b[201~".as_slice(),
b"\x1b_Gi=7;unrelated\x1b\\".as_slice(),
b"\x1b[<35;2;3Mtail".as_slice(),
] {
let Some(ClientLoopEvent::StdinInput(data)) =
classify_unix_input(raw.to_vec(), true, Some(geometry))
else {
panic!("unrelated input must remain raw");
};
assert_eq!(data, raw);
}
}
#[test]
fn transient_geometry_failure_keeps_last_real_value() {
let geometry = crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap();
assert_eq!(retain_geometry(Some(geometry), None), Some(geometry));
}
#[test]
fn palette_replies_are_forwarded_as_one_input_batch() {
let (tx, mut rx) = mpsc::channel(4);
@ -506,6 +673,8 @@ mod tests {
],
&tx,
&mut pending,
false,
None,
));
assert!(rx.try_recv().is_err());

View File

@ -12,9 +12,13 @@
//! - Forwards OSC 52 clipboard writes from server to its own stdout
//! - Displays sound/toast notifications forwarded from server
#[cfg(unix)]
mod direct_graphics;
mod input;
use std::collections::HashSet;
#[cfg(unix)]
use std::io::IsTerminal as _;
use std::io::{self, BufRead, Write as _};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
@ -75,6 +79,12 @@ struct ClientState {
sound_config: crate::config::SoundConfig,
/// Whether this client may write Kitty graphics bytes to its host terminal.
kitty_graphics_enabled: bool,
/// One bounded matcher, inactive unless a direct transmission is armed.
#[cfg(unix)]
direct_graphics_response: Arc<Mutex<direct_graphics::ResponseMatcher>>,
/// One server-retired direct transfer to suppress if it was still queued.
#[cfg(unix)]
retired_direct_graphics: Option<(u64, u32)>,
/// Direct attach prefix escape state. None for full-app clients.
attach_escape: Option<AttachEscapeState>,
/// Rows scrolled for one direct-attach wheel notch.
@ -356,9 +366,9 @@ fn setup_terminal_with_capabilities(
if enable_client_protocols {
if mouse_capture {
set_mouse_capture(true)?;
set_mouse_capture(true, false)?;
} else {
set_mouse_capture(false)?;
set_mouse_capture(false, false)?;
}
execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?;
if host_color_scheme_reports {
@ -370,9 +380,9 @@ fn setup_terminal_with_capabilities(
write_host_color_scheme_report_mode(&mut io::stdout(), false)?;
}
if mouse_capture {
set_mouse_capture(true)?;
set_mouse_capture(true, false)?;
} else {
set_mouse_capture(false)?;
set_mouse_capture(false, false)?;
}
}
@ -549,10 +559,15 @@ fn restore_windows_input_mode_value(mode: u32) {
}
}
fn set_mouse_capture(enabled: bool) -> io::Result<()> {
fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> {
crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
if enabled {
execute!(io::stdout(), EnableMouseCapture)
execute!(io::stdout(), EnableMouseCapture)?;
if sgr_pixels {
io::stdout().write_all(b"\x1b[?1016h")?;
io::stdout().flush()?;
}
Ok(())
} else {
match execute!(io::stdout(), DisableMouseCapture) {
Ok(()) => Ok(()),
@ -685,6 +700,44 @@ fn handshake_read_timeout() -> Duration {
LOCAL_HANDSHAKE_READ_TIMEOUT
}
#[cfg(any(unix, test))]
fn direct_graphics_profile_values(
term_program: &str,
term: &str,
kitty_window: bool,
blocked_transport: bool,
terminals: bool,
) -> bool {
let supported = term_program.eq_ignore_ascii_case("ghostty")
|| term_program.eq_ignore_ascii_case("wezterm")
|| matches!(term, "xterm-ghostty" | "xterm-kitty" | "xterm-wezterm")
|| kitty_window;
supported && !blocked_transport && terminals
}
#[cfg(unix)]
fn direct_graphics_profile_allowed(direct_attach: bool) -> bool {
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
let term = std::env::var("TERM").unwrap_or_default();
direct_graphics_profile_values(
&term_program,
&term,
std::env::var_os("KITTY_WINDOW_ID").is_some(),
direct_attach
|| is_remote_client_process()
|| std::env::var_os("SSH_CONNECTION").is_some()
|| std::env::var_os("SSH_TTY").is_some()
|| std::env::var_os("TMUX").is_some()
|| std::env::var_os("STY").is_some(),
io::stdin().is_terminal() && io::stdout().is_terminal(),
)
}
#[cfg(not(unix))]
fn direct_graphics_profile_allowed(_direct_attach: bool) -> bool {
false
}
fn requested_keybindings() -> ClientKeybindings {
match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR)
.ok()
@ -726,6 +779,25 @@ fn set_handshake_recv_timeout(
.map_err(ClientError::ConnectionFailed)
}
fn client_launch_mode(
direct_attach_requested: bool,
exact_cell_size: bool,
cell_width_px: u32,
cell_height_px: u32,
) -> ClientLaunchMode {
if direct_attach_requested {
ClientLaunchMode::TerminalAttach
} else if exact_cell_size
&& cell_width_px > 0
&& cell_height_px > 0
&& direct_graphics_profile_allowed(false)
{
ClientLaunchMode::AppDirectGraphics
} else {
ClientLaunchMode::App
}
}
/// Performs the client→server handshake.
///
/// Sends Hello with the terminal size and protocol version, reads the Welcome
@ -736,6 +808,7 @@ fn do_handshake(
rows: u16,
cell_width_px: u32,
cell_height_px: u32,
exact_cell_size: bool,
requested_encoding: RenderEncoding,
direct_attach_requested: bool,
) -> Result<RenderEncoding, ClientError> {
@ -752,11 +825,12 @@ fn do_handshake(
cell_height_px,
requested_encoding,
keybindings: requested_keybindings(),
launch_mode: if direct_attach_requested {
ClientLaunchMode::TerminalAttach
} else {
ClientLaunchMode::App
},
launch_mode: client_launch_mode(
direct_attach_requested,
exact_cell_size,
cell_width_px,
cell_height_px,
),
};
protocol::write_message(stream, &hello)
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
@ -801,6 +875,11 @@ enum ClientLoopEvent {
/// Raw input bytes from stdin.
#[cfg(unix)]
StdinInput(Vec<u8>),
/// One confirmed SGR pixel report with geometry captured by the reader.
#[cfg(unix)]
PixelMouse(Vec<u8>, crate::input::mouse::HostGeometry),
#[cfg(unix)]
DirectGraphicsResponse(direct_graphics::Response),
/// Structured input events from platforms without Unix-style stdin bytes.
#[cfg(windows)]
StdinEvents(Vec<crate::protocol::ClientInputEvent>),
@ -929,6 +1008,7 @@ fn connect_terminal_session_stream(
rows,
0,
0,
false,
RenderEncoding::TerminalAnsi,
true,
) {
@ -1149,7 +1229,7 @@ fn run_client_with_mode(
};
// Get the terminal geometry before handshake (before raw mode).
let (cols, rows, cell_width_px, cell_height_px) =
let (cols, rows, cell_width_px, cell_height_px, exact_cell_size) =
initial_terminal_geometry(kitty_graphics_enabled);
// Perform handshake while the stream is still in blocking mode.
@ -1159,6 +1239,7 @@ fn run_client_with_mode(
rows,
cell_width_px,
cell_height_px,
exact_cell_size,
requested_encoding,
direct_attach_requested,
) {
@ -1296,6 +1377,10 @@ async fn run_client_loop(
reported_size: (cols, rows),
sound_config: config.sound_config,
kitty_graphics_enabled: config.kitty_graphics_enabled,
#[cfg(unix)]
direct_graphics_response: Arc::new(Mutex::new(direct_graphics::ResponseMatcher::default())),
#[cfg(unix)]
retired_direct_graphics: None,
attach_escape,
#[cfg(unix)]
mouse_scroll_lines: config.mouse_scroll_lines,
@ -1309,6 +1394,7 @@ async fn run_client_loop(
// 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));
let host_sgr_pixels_active = Arc::new(AtomicBool::new(false));
// Channel for events from the stdin, resize, and server reader threads.
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256);
@ -1323,6 +1409,14 @@ async fn run_client_loop(
let stdin_quit = should_quit.clone();
let stdin_tx = event_tx.clone();
let stdin_mouse_capture_active = host_mouse_capture_active.clone();
let stdin_sgr_pixels_active = host_sgr_pixels_active.clone();
#[cfg(unix)]
let stdin_direct_response = state.direct_graphics_response.clone();
#[cfg(unix)]
let stdin_direct_response_active = stdin_direct_response
.lock()
.map(|matcher| matcher.active_handle())
.unwrap_or_default();
std::thread::spawn(move || {
input::stdin_reader_loop(
stdin_tx,
@ -1330,6 +1424,11 @@ async fn run_client_loop(
will_query_host_terminal_theme,
will_query_host_cell_size,
stdin_mouse_capture_active,
stdin_sgr_pixels_active,
#[cfg(unix)]
stdin_direct_response,
#[cfg(unix)]
stdin_direct_response_active,
);
});
@ -1475,6 +1574,30 @@ async fn run_client_loop(
return Err(ClientError::ConnectionLost(e));
}
}
#[cfg(unix)]
ClientLoopEvent::DirectGraphicsResponse(response) => {
let message = ClientMessage::GraphicsTransmissionResult {
transfer_id: response.transfer_id,
image_id: response.image_id,
success: response.success,
};
if let Err(err) = write_to_server(&mut write_stream, &message) {
return Err(ClientError::ConnectionLost(err));
}
}
#[cfg(unix)]
ClientLoopEvent::PixelMouse(data, geometry) => {
let message = ClientMessage::InputPixels {
data,
cols: geometry.cols,
rows: geometry.rows,
width_px: geometry.width_px,
height_px: geometry.height_px,
};
if let Err(err) = write_to_server(&mut write_stream, &message) {
return Err(ClientError::ConnectionLost(err));
}
}
#[cfg(windows)]
ClientLoopEvent::StdinEvents(events) => {
if state.attach_escape.is_some() {
@ -1578,6 +1701,98 @@ async fn run_client_loop(
warn!(err = %err, "failed to emit terminal bell");
}
}
ServerMessage::GraphicsFile {
path,
expected_len,
image_id,
transfer_id,
leading,
control,
} => {
#[cfg(unix)]
{
if state.retired_direct_graphics.take() == Some((transfer_id, image_id)) {
continue;
}
let valid = state.kitty_graphics_enabled
&& usize::try_from(expected_len).ok().is_some_and(|len| {
crate::pane_graphics_files::validate_direct_source(
std::path::Path::new(&path),
len,
)
.is_ok()
})
&& direct_graphics::valid_control(&control, image_id)
&& state
.direct_graphics_response
.lock()
.is_ok_and(|mut matcher| matcher.arm(transfer_id, image_id));
let sent = if valid {
let mut command = Vec::new();
crate::kitty_graphics::encode_kitty_regular_file(
&mut command,
&leading,
&control,
&path,
);
let mut stdout = io::stdout();
let written = stdout
.write_all(&command)
.and_then(|()| stdout.flush())
.is_ok();
if written {
record_received_kitty_graphics(&command);
}
written
} else {
false
};
if sent {
if let Ok(mut matcher) = state.direct_graphics_response.lock() {
matcher.start(transfer_id);
}
let started = ClientMessage::GraphicsTransmissionStarted {
transfer_id,
image_id,
};
if let Err(err) = write_to_server(&mut write_stream, &started) {
return Err(ClientError::ConnectionLost(err));
}
} else {
if let Ok(mut matcher) = state.direct_graphics_response.lock() {
if valid {
matcher.retire(transfer_id);
} else {
matcher.cancel(transfer_id);
}
}
let result = ClientMessage::GraphicsTransmissionResult {
transfer_id,
image_id,
success: false,
};
if let Err(err) = write_to_server(&mut write_stream, &result) {
return Err(ClientError::ConnectionLost(err));
}
}
}
#[cfg(not(unix))]
let _ = (path, expected_len, image_id, transfer_id, leading, control);
}
ServerMessage::GraphicsTransmissionRetired {
transfer_id,
image_id,
} => {
#[cfg(unix)]
{
state.retired_direct_graphics = Some((transfer_id, image_id));
if let Ok(mut matcher) = state.direct_graphics_response.lock() {
matcher.retire(transfer_id);
}
}
#[cfg(not(unix))]
let _ = (transfer_id, image_id);
}
ServerMessage::ServerShutdown { reason } => {
return Err(ClientError::ServerShutdown { reason });
}
@ -1604,17 +1819,24 @@ async fn run_client_loop(
&mut state.remote_image_paste_key,
);
}
ServerMessage::MouseCapture { enabled } => {
let desired = enabled;
if desired != state.mouse_capture_active {
set_mouse_capture(desired).map_err(ClientError::ConnectionFailed)?;
ServerMessage::MouseCapture {
enabled,
sgr_pixels,
} => {
let next_sgr_pixels = enabled && sgr_pixels;
let mouse_mode_changed = enabled != state.mouse_capture_active
|| next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire);
if mouse_mode_changed {
set_mouse_capture(enabled, next_sgr_pixels)
.map_err(ClientError::ConnectionFailed)?;
#[cfg(windows)]
if windows_vti_input_backend_enabled() {
if enabled && windows_vti_input_backend_enabled() {
let _ = enable_windows_virtual_terminal_input();
}
state.mouse_capture_active = desired;
host_mouse_capture_active.store(desired, Ordering::Release);
}
state.mouse_capture_active = enabled;
host_mouse_capture_active.store(enabled, Ordering::Release);
host_sgr_pixels_active.store(next_sgr_pixels, Ordering::Release);
}
ServerMessage::KittyKeyboardReportAll { enabled } => {
if enabled != state.keyboard_report_all_active {
@ -1643,7 +1865,12 @@ async fn run_client_loop(
"server closed connection",
)));
}
ClientLoopEvent::Timer => {}
ClientLoopEvent::Timer => {
#[cfg(unix)]
if let Ok(mut matcher) = state.direct_graphics_response.lock() {
matcher.expire();
}
}
}
}
@ -2204,8 +2431,10 @@ fn ioctl_cell_size() -> Option<(u32, u32)> {
}
/// 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))
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))]
@ -2222,20 +2451,35 @@ fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> {
fn current_terminal_geometry(
kitty_graphics_enabled: bool,
reported_cell_size: &AtomicU64,
last_cell_size: Option<(u32, u32)>,
) -> (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)));
let (cell_width_px, cell_height_px) = ioctl_cell_size().unwrap_or_else(|| {
cell_size_fallback(reported_cell_size.load(Ordering::Acquire), last_cell_size)
});
(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))
/// Reads terminal geometry before the handshake. Direct graphics is eligible
/// only when the host supplied exact pixel dimensions through the ioctl.
fn initial_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32, bool) {
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
if !kitty_graphics_enabled {
return (cols, rows, 0, 0, false);
}
match ioctl_cell_size() {
Some((width, height)) => (cols, rows, width, height, true),
None => (
cols,
rows,
DEFAULT_CELL_WIDTH_PX,
DEFAULT_CELL_HEIGHT_PX,
false,
),
}
}
/// Reports polled changes and signalled resizes that return to the same size.
@ -2272,7 +2516,11 @@ 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, reported_cell_size);
let new_size = current_terminal_geometry(
kitty_graphics_enabled,
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
@ -2388,6 +2636,40 @@ mod tests {
assert!(resize_report_required(false, (120, 40, 9, 18), size));
}
#[test]
fn approximate_cell_size_never_enables_direct_graphics() {
assert_eq!(
client_launch_mode(false, false, 8, 16),
ClientLaunchMode::App
);
assert_eq!(
client_launch_mode(true, false, 8, 16),
ClientLaunchMode::TerminalAttach
);
}
#[test]
fn direct_graphics_profile_is_narrow_and_transport_safe() {
for (program, term, kitty, expected) in [
("ghostty", "", false, true),
("WezTerm", "", false, true),
("", "xterm-kitty", false, true),
("", "xterm-256color", true, true),
("", "xterm-256color", false, false),
] {
assert_eq!(
direct_graphics_profile_values(program, term, kitty, false, true),
expected
);
}
assert!(!direct_graphics_profile_values(
"ghostty", "", false, true, true
));
assert!(!direct_graphics_profile_values(
"ghostty", "", false, false, false
));
}
fn restore_env_var(key: &str, value: Option<OsString>) {
if let Some(value) = value {
std::env::set_var(key, value);
@ -2758,11 +3040,15 @@ mod tests {
}
#[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));
fn cell_size_fallback_prefers_reported_then_previous_size() {
assert_eq!(cell_size_fallback(0, None), (8, 16));
assert_eq!(cell_size_fallback(0, Some((11, 22))), (11, 22));
assert_eq!(
cell_size_fallback(pack_cell_size(10, 21), Some((11, 22))),
(10, 21)
);
assert_eq!(cell_size_fallback(pack_cell_size(10, 0), None), (8, 16));
assert_eq!(cell_size_fallback(pack_cell_size(0, 21), None), (8, 16));
}
#[test]

View File

@ -1416,11 +1416,11 @@ impl Terminal {
self.get_optional_rgb_color(TERMINAL_DATA_COLOR_CURSOR)
}
fn width_px(&self) -> Result<u32, Error> {
pub(crate) fn width_px(&self) -> Result<u32, Error> {
self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_WIDTH_PX)
}
fn height_px(&self) -> Result<u32, Error> {
pub(crate) fn height_px(&self) -> Result<u32, Error> {
self.get_u32(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_HEIGHT_PX)
}

View File

@ -125,7 +125,7 @@ fn encode_mouse_cb(
encoding: MouseProtocolEncoding,
) -> Option<Vec<u8>> {
let mut cb = match (encoding, release) {
(MouseProtocolEncoding::Sgr, true) => base_button,
(MouseProtocolEncoding::Sgr | MouseProtocolEncoding::SgrPixels, true) => base_button,
(_, true) => 3,
(_, false) => base_button,
};
@ -143,7 +143,7 @@ fn encode_mouse_cb(
let row = row as u32 + 1;
match encoding {
MouseProtocolEncoding::Sgr => Some(
MouseProtocolEncoding::Sgr | MouseProtocolEncoding::SgrPixels => Some(
format!(
"\x1b[<{cb};{column};{row}{}",
if release { 'm' } else { 'M' }

View File

@ -1,5 +1,6 @@
mod encode;
mod model;
pub(crate) mod mouse;
mod parse;
#[allow(unused_imports)]

View File

@ -317,6 +317,7 @@ pub enum MouseProtocolEncoding {
Default,
Utf8,
Sgr,
SgrPixels,
}
#[cfg(test)]

194
src/input/mouse.rs Normal file
View File

@ -0,0 +1,194 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Position {
Cell { column: u16, row: u16 },
Pixels { x: u32, y: u32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct HostGeometry {
pub(crate) cols: u16,
pub(crate) rows: u16,
pub(crate) width_px: u32,
pub(crate) height_px: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct HostPixels {
pub(crate) x: u32,
pub(crate) y: u32,
pub(crate) geometry: HostGeometry,
}
impl HostGeometry {
pub(crate) fn new(cols: u16, rows: u16, width_px: u32, height_px: u32) -> Option<Self> {
(cols > 0 && rows > 0 && width_px > 0 && height_px > 0).then_some(Self {
cols,
rows,
width_px,
height_px,
})
}
#[cfg(unix)]
pub(crate) fn current() -> Option<Self> {
let size = crossterm::terminal::window_size().ok()?;
Self::new(
size.columns,
size.rows,
u32::from(size.width),
u32::from(size.height),
)
}
pub(crate) fn cell(self, x: u32, y: u32) -> Option<(u16, u16)> {
Some((
grid_cell(x.checked_sub(1)?, self.cols, self.width_px)?,
grid_cell(y.checked_sub(1)?, self.rows, self.height_px)?,
))
}
fn column_boundary(self, column: u16) -> Option<u32> {
boundary(column, self.cols, self.width_px)
}
fn row_boundary(self, row: u16) -> Option<u32> {
boundary(row, self.rows, self.height_px)
}
}
impl HostPixels {
pub(crate) fn pane_position(
self,
inner: ratatui::layout::Rect,
child_width_px: u32,
child_height_px: u32,
) -> Option<Position> {
let start_x = self.geometry.column_boundary(inner.x)?;
let start_y = self.geometry.row_boundary(inner.y)?;
let end_x = self
.geometry
.column_boundary(inner.x.checked_add(inner.width)?)?;
let end_y = self
.geometry
.row_boundary(inner.y.checked_add(inner.height)?)?;
let x = self.x.checked_sub(1)?.checked_sub(start_x)?;
let y = self.y.checked_sub(1)?.checked_sub(start_y)?;
let source_width = end_x.checked_sub(start_x)?;
let source_height = end_y.checked_sub(start_y)?;
if x >= source_width || y >= source_height || child_width_px == 0 || child_height_px == 0 {
return None;
}
Some(Position::Pixels {
x: scale(x, source_width, child_width_px).checked_add(1)?,
y: scale(y, source_height, child_height_px).checked_add(1)?,
})
}
}
pub(crate) fn parse_report(data: &[u8]) -> Option<(u32, u32)> {
let body = data.strip_prefix(b"\x1b[<")?;
let body = body
.strip_suffix(b"M")
.or_else(|| body.strip_suffix(b"m"))?;
let mut fields = body.split(|byte| *byte == b';');
parse_number(fields.next()?)?;
let x = parse_number(fields.next()?)?;
let y = parse_number(fields.next()?)?;
fields.next().is_none().then_some((x, y))
}
pub(crate) fn report_at_cell(data: &[u8], column: u16, row: u16) -> Option<Vec<u8>> {
let body = data.strip_prefix(b"\x1b[<")?;
let suffix = if body.ends_with(b"M") { 'M' } else { 'm' };
let body = body.strip_suffix(&[suffix as u8])?;
let buttons = body.split(|byte| *byte == b';').next()?;
Some(
format!(
"\x1b[<{};{};{}{}",
std::str::from_utf8(buttons).ok()?,
u32::from(column) + 1,
u32::from(row) + 1,
suffix
)
.into_bytes(),
)
}
fn parse_number(value: &[u8]) -> Option<u32> {
(!value.is_empty() && value.iter().all(u8::is_ascii_digit))
.then(|| std::str::from_utf8(value).ok()?.parse().ok())
.flatten()
}
fn boundary(index: u16, count: u16, extent: u32) -> Option<u32> {
(count > 0 && index <= count && extent > 0)
.then(|| (u64::from(index) * u64::from(extent) / u64::from(count)) as u32)
}
fn grid_cell(pixel: u32, count: u16, extent: u32) -> Option<u16> {
if count == 0 || extent == 0 || pixel >= extent {
return None;
}
let cell = ((u64::from(pixel) + 1) * u64::from(count) - 1) / u64::from(extent);
u16::try_from(cell).ok().filter(|cell| *cell < count)
}
fn scale(pixel: u32, source: u32, target: u32) -> u32 {
((u64::from(pixel) * u64::from(target)) / u64::from(source))
.min(u64::from(target.saturating_sub(1))) as u32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parser_accepts_only_complete_sgr_mouse_reports() {
for (input, expected) in [
(b"\x1b[<35;321;241M".as_slice(), Some((321, 241))),
(b"\x1b[<0;1;2m".as_slice(), Some((1, 2))),
(b"key".as_slice(), None),
(b"\x1b[<0;1;2Mkey".as_slice(), None),
(b"\x1b[<0;1M".as_slice(), None),
] {
assert_eq!(parse_report(input), expected);
}
}
#[test]
fn fractional_geometry_maps_exactly_to_pane_pixels() {
let geometry = HostGeometry::new(211, 57, 2_537, 1_429).unwrap();
let inner = ratatui::layout::Rect::new(157, 7, 53, 49);
let start_x = geometry.column_boundary(inner.x).unwrap();
let end_x = geometry.column_boundary(inner.x + inner.width).unwrap();
let start_y = geometry.row_boundary(inner.y).unwrap();
let end_y = geometry.row_boundary(inner.y + inner.height).unwrap();
assert_eq!(
HostPixels {
x: start_x + 1,
y: start_y + 1,
geometry,
}
.pane_position(inner, 636, 1_225),
Some(Position::Pixels { x: 1, y: 1 })
);
assert_eq!(
HostPixels {
x: end_x,
y: end_y,
geometry,
}
.pane_position(inner, 636, 1_225),
Some(Position::Pixels { x: 636, y: 1_225 })
);
}
#[test]
fn geometry_rejects_outside_pixels_and_maps_cells() {
let geometry = HostGeometry::new(80, 24, 800, 480).unwrap();
assert_eq!(geometry.cell(1, 1), Some((0, 0)));
assert_eq!(geometry.cell(800, 480), Some((79, 23)));
assert_eq!(geometry.cell(801, 1), None);
assert_eq!(geometry.cell(0, 1), None);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -75,6 +75,7 @@ mod logging;
mod metadata_tokens;
mod noninteractive_process;
mod pane;
mod pane_graphics_files;
mod persist;
mod platform;
mod plugin_command;

View File

@ -2780,40 +2780,41 @@ impl PaneRuntime {
pub fn encode_mouse_button(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
if !self.input_state()?.mouse_protocol_mode.reporting_enabled() {
return None;
}
self.terminal
.encode_mouse_button(kind, column, row, modifiers)
self.terminal.encode_mouse_button(kind, position, modifiers)
}
pub fn encode_mouse_motion(
pub(crate) fn encode_mouse_motion(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
self.terminal
.encode_mouse_motion(kind, column, row, modifiers)
self.terminal.encode_mouse_motion(kind, position, modifiers)
}
pub fn encode_mouse_wheel(
pub(crate) fn encode_mouse_wheel(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
if self.wheel_routing()? != WheelRouting::MouseReport {
return None;
}
self.terminal
.encode_mouse_wheel(kind, column, row, modifiers)
self.terminal.encode_mouse_wheel(kind, position, modifiers)
}
pub(crate) fn pixel_size(&self) -> Option<(u32, u32)> {
let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get();
let width = u32::from(cols).checked_mul(cell_width_px)?;
let height = u32::from(rows).checked_mul(cell_height_px)?;
(width > 0 && height > 0).then_some((width, height))
}
pub fn encode_alternate_scroll(

View File

@ -60,23 +60,43 @@ pub(super) fn ghostty_mods_from_key_modifiers(modifiers: crossterm::event::KeyMo
pub(super) fn ghostty_mouse_encoder_for_terminal(
terminal: &crate::ghostty::Terminal,
position: crate::input::mouse::Position,
) -> Option<crate::ghostty::MouseEncoder> {
let mut encoder = crate::ghostty::MouseEncoder::new().ok()?;
encoder.set_from_terminal(terminal);
if terminal
.mode_get(crate::ghostty::MODE_MOUSE_SGR_PIXELS)
.ok()?
{
// Herdr receives host mouse positions in terminal cells. Downgrade
// SGR-pixels to normal SGR so forwarded coordinates stay cell-local.
encoder.set_format(crate::ghostty::MOUSE_FORMAT_SGR);
}
let cols = terminal.cols().ok()? as u32;
let rows = terminal.rows().ok()? as u32;
encoder.set_size(cols, rows, 1, 1);
match position {
crate::input::mouse::Position::Cell { .. } => {
if terminal
.mode_get(crate::ghostty::MODE_MOUSE_SGR_PIXELS)
.ok()?
{
encoder.set_format(crate::ghostty::MOUSE_FORMAT_SGR);
}
encoder.set_size(cols, rows, 1, 1);
}
crate::input::mouse::Position::Pixels { .. } => {
let width_px = terminal.width_px().ok()?;
let height_px = terminal.height_px().ok()?;
if width_px == 0 || height_px == 0 || cols == 0 || rows == 0 {
return None;
}
encoder.set_size(width_px, height_px, width_px / cols, height_px / rows);
}
}
Some(encoder)
}
pub(super) fn ghostty_mouse_position_for_terminal(
position: crate::input::mouse::Position,
) -> Option<(f32, f32)> {
match position {
crate::input::mouse::Position::Pixels { x, y } => Some((x as f32, y as f32)),
crate::input::mouse::Position::Cell { column, row } => Some((column as f32, row as f32)),
}
}
pub(super) fn ghostty_mouse_event_from_button_kind(
kind: crossterm::event::MouseEventKind,
column: u16,

View File

@ -23,7 +23,8 @@ use super::{
input::{
ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal,
ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_motion_kind,
ghostty_mouse_event_from_wheel_kind, ghostty_prefers_herdr_text_encoding,
ghostty_mouse_event_from_wheel_kind, ghostty_mouse_position_for_terminal,
ghostty_prefers_herdr_text_encoding,
},
kitty_keyboard::KittyKeyboardTracker,
osc::{
@ -537,37 +538,31 @@ impl PaneTerminal {
self.ghostty.encode_terminal_key(key, protocol)
}
pub fn encode_mouse_button(
pub(crate) fn encode_mouse_button(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
self.ghostty
.encode_mouse_button(kind, column, row, modifiers)
self.ghostty.encode_mouse_button(kind, position, modifiers)
}
pub fn encode_mouse_motion(
pub(crate) fn encode_mouse_motion(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
self.ghostty
.encode_mouse_motion(kind, column, row, modifiers)
self.ghostty.encode_mouse_motion(kind, position, modifiers)
}
pub fn encode_mouse_wheel(
pub(crate) fn encode_mouse_wheel(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
self.ghostty
.encode_mouse_wheel(kind, column, row, modifiers)
self.ghostty.encode_mouse_wheel(kind, position, modifiers)
}
}
@ -1488,6 +1483,9 @@ impl GhosttyPaneTerminal {
let _ = core
.terminal
.mode_set(crate::ghostty::MODE_MOUSE_SGR, false);
let _ = core
.terminal
.mode_set(crate::ghostty::MODE_MOUSE_SGR_PIXELS, false);
match input_state.mouse_protocol_encoding {
crate::input::MouseProtocolEncoding::Default => {}
crate::input::MouseProtocolEncoding::Utf8 => {
@ -1498,6 +1496,11 @@ impl GhosttyPaneTerminal {
crate::input::MouseProtocolEncoding::Sgr => {
let _ = core.terminal.mode_set(crate::ghostty::MODE_MOUSE_SGR, true);
}
crate::input::MouseProtocolEncoding::SgrPixels => {
let _ = core
.terminal
.mode_set(crate::ghostty::MODE_MOUSE_SGR_PIXELS, true);
}
}
if input_state.modify_other_keys {
@ -1688,6 +1691,10 @@ impl GhosttyPaneTerminal {
.terminal
.mode_get(crate::ghostty::MODE_MOUSE_UTF8)
.ok()?;
let mouse_sgr_pixels = core
.terminal
.mode_get(crate::ghostty::MODE_MOUSE_SGR_PIXELS)
.ok()?;
let mouse_alternate_scroll = core
.terminal
.mode_get(crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL)
@ -1703,7 +1710,9 @@ impl GhosttyPaneTerminal {
} else {
crate::input::MouseProtocolMode::None
};
let mouse_protocol_encoding = if mouse_sgr {
let mouse_protocol_encoding = if mouse_sgr_pixels {
crate::input::MouseProtocolEncoding::SgrPixels
} else if mouse_sgr {
crate::input::MouseProtocolEncoding::Sgr
} else if mouse_utf8 {
crate::input::MouseProtocolEncoding::Utf8
@ -1838,57 +1847,58 @@ impl GhosttyPaneTerminal {
}
}
pub fn encode_mouse_button(
pub(crate) fn encode_mouse_button(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
let Ok(core) = self.core.lock() else {
return None;
};
let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?;
let event = ghostty_mouse_event_from_button_kind(kind, column, row, modifiers)?;
encoder
.encode(&event)
.ok()
.filter(|bytes| !bytes.is_empty())
self.encode_mouse_event(
ghostty_mouse_event_from_button_kind(kind, 0, 0, modifiers)?,
position,
false,
)
}
pub fn encode_mouse_motion(
pub(crate) fn encode_mouse_motion(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
let Ok(core) = self.core.lock() else {
return None;
};
if !core.terminal.mode_get(MODE_MOUSE_ANY_MOTION).ok()? {
self.encode_mouse_event(
ghostty_mouse_event_from_motion_kind(kind, 0, 0, modifiers)?,
position,
true,
)
}
pub(crate) fn encode_mouse_wheel(
&self,
kind: crossterm::event::MouseEventKind,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
self.encode_mouse_event(
ghostty_mouse_event_from_wheel_kind(kind, 0, 0, modifiers)?,
position,
false,
)
}
fn encode_mouse_event(
&self,
mut event: crate::ghostty::MouseEvent,
position: crate::input::mouse::Position,
require_any_motion: bool,
) -> Option<Vec<u8>> {
let core = self.core.lock().ok()?;
if require_any_motion && !core.terminal.mode_get(MODE_MOUSE_ANY_MOTION).ok()? {
return None;
}
let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?;
let event = ghostty_mouse_event_from_motion_kind(kind, column, row, modifiers)?;
encoder
.encode(&event)
.ok()
.filter(|bytes| !bytes.is_empty())
}
pub fn encode_mouse_wheel(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
let Ok(core) = self.core.lock() else {
return None;
};
let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?;
let event = ghostty_mouse_event_from_wheel_kind(kind, column, row, modifiers)?;
let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal, position)?;
let (x, y) = ghostty_mouse_position_for_terminal(position)?;
event.set_position(x, y);
encoder
.encode(&event)
.ok()
@ -4670,8 +4680,7 @@ mod tests {
let encoded = pane.encode_mouse_button(
crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
11,
9,
crate::input::mouse::Position::Cell { column: 11, row: 9 },
crossterm::event::KeyModifiers::empty(),
);
@ -4687,8 +4696,7 @@ mod tests {
let encoded = pane.encode_mouse_button(
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
4,
6,
crate::input::mouse::Position::Cell { column: 4, row: 6 },
crossterm::event::KeyModifiers::SHIFT,
);
@ -4704,8 +4712,7 @@ mod tests {
let encoded = pane.encode_mouse_button(
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
4,
6,
crate::input::mouse::Position::Cell { column: 4, row: 6 },
crossterm::event::KeyModifiers::empty(),
);
@ -4721,8 +4728,7 @@ mod tests {
let encoded = pane.encode_mouse_motion(
crossterm::event::MouseEventKind::Moved,
4,
6,
crate::input::mouse::Position::Cell { column: 4, row: 6 },
crossterm::event::KeyModifiers::empty(),
);
@ -4730,20 +4736,26 @@ mod tests {
}
#[test]
fn ghostty_mouse_sgr_pixels_downgrades_to_cell_coordinates() {
fn ghostty_mouse_sgr_pixels_preserves_exact_and_downgrades_cell_input() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
terminal.resize(80, 24, 10, 20).unwrap();
terminal.write(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let encoded = pane.encode_mouse_motion(
let exact = pane.encode_mouse_motion(
crossterm::event::MouseEventKind::Moved,
4,
6,
crate::input::mouse::Position::Pixels { x: 48, y: 139 },
crossterm::event::KeyModifiers::empty(),
);
let fallback = pane.encode_mouse_motion(
crossterm::event::MouseEventKind::Moved,
crate::input::mouse::Position::Cell { column: 4, row: 6 },
crossterm::event::KeyModifiers::empty(),
);
assert_eq!(encoded.as_deref(), Some(&b"\x1b[<35;5;7M"[..]));
assert_eq!(exact.as_deref(), Some(&b"\x1b[<35;48;139M"[..]));
assert_eq!(fallback.as_deref(), Some(&b"\x1b[<35;5;7M"[..]));
}
#[test]

529
src/pane_graphics_files.rs Normal file
View File

@ -0,0 +1,529 @@
#[cfg(unix)]
use std::fs::OpenOptions;
use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
#[cfg(unix)]
use std::os::unix::fs::{FileExt, MetadataExt, OpenOptionsExt, PermissionsExt};
#[cfg(unix)]
const DIRECTORY_MODE: u32 = 0o700;
#[cfg(unix)]
const FILE_MODE: u32 = 0o600;
#[derive(Debug)]
pub(crate) struct FileStore {
base: PathBuf,
generation: OnceLock<Arc<Generation>>,
next_fingerprint: AtomicU64,
}
#[derive(Debug)]
struct Generation {
root: PathBuf,
source: PathBuf,
}
#[derive(Clone, Debug)]
pub(crate) struct Lease {
inner: Arc<LeaseInner>,
}
#[derive(Debug)]
struct LeaseInner {
path: PathBuf,
file: File,
generation: Arc<Generation>,
metadata: fs::Metadata,
len: usize,
fingerprint: u64,
}
impl Default for FileStore {
fn default() -> Self {
Self::new(runtime_base())
}
}
impl FileStore {
fn new(base: PathBuf) -> Self {
Self {
base,
generation: OnceLock::new(),
next_fingerprint: AtomicU64::new(1),
}
}
pub(crate) fn source_directory(&self) -> io::Result<PathBuf> {
Ok(self.generation()?.source.clone())
}
pub(crate) fn lease(&self, path: &Path, expected_len: usize) -> io::Result<Lease> {
let generation = self.generation()?;
validate_child(path, &generation.source)?;
let file = open_no_follow(path)?;
let metadata = file.metadata()?;
validate_metadata(&metadata, expected_len)?;
validate_path_identity(path, &metadata)?;
let fingerprint = self.next_fingerprint.fetch_add(1, Ordering::Relaxed);
Ok(Lease {
inner: Arc::new(LeaseInner {
path: path.to_owned(),
file,
generation,
metadata,
len: expected_len,
fingerprint,
}),
})
}
fn generation(&self) -> io::Result<Arc<Generation>> {
if let Some(generation) = self.generation.get() {
return Ok(Arc::clone(generation));
}
let generation = Arc::new(create_generation(&self.base)?);
match self.generation.set(Arc::clone(&generation)) {
Ok(()) => Ok(generation),
Err(_) => self
.generation
.get()
.cloned()
.ok_or_else(|| io::Error::other("pane graphics generation was lost")),
}
}
#[cfg(test)]
pub(crate) fn is_initialized(&self) -> bool {
self.generation.get().is_some()
}
}
impl Lease {
pub(crate) fn path(&self) -> &Path {
&self.inner.path
}
pub(crate) fn len(&self) -> usize {
self.inner.len
}
pub(crate) fn fingerprint(&self) -> u64 {
self.inner.fingerprint
}
pub(crate) fn copy_rgba(&self) -> io::Result<Vec<u8>> {
let _keep_generation_alive = &self.inner.generation;
validate_path_identity(&self.inner.path, &self.inner.metadata)?;
let mut data = vec![0; self.inner.len];
read_exact_at(&self.inner.file, &mut data)?;
if has_byte_at(&self.inner.file, self.inner.len as u64)? {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"frame length changed while leased",
));
}
validate_metadata(&self.inner.file.metadata()?, self.inner.len)?;
validate_path_identity(&self.inner.path, &self.inner.metadata)?;
Ok(data)
}
}
impl Drop for Generation {
fn drop(&mut self) {
if let Err(err) = fs::remove_dir_all(&self.root) {
if err.kind() != io::ErrorKind::NotFound {
tracing::warn!(path = %self.root.display(), err = %err, "failed to remove pane graphics directory");
}
}
}
}
#[cfg(unix)]
pub(crate) fn validate_direct_source(path: &Path, expected_len: usize) -> io::Result<()> {
let source = path.parent().ok_or_else(invalid_path)?;
let generation = source.parent().ok_or_else(invalid_path)?;
if !path.is_absolute()
|| path.file_name().is_none()
|| source.file_name().and_then(|name| name.to_str()) != Some("source")
|| generation.parent() != Some(runtime_base().as_path())
|| !generation
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("server-"))
{
return Err(invalid_path());
}
for directory in [runtime_base(), generation.to_owned(), source.to_owned()] {
validate_directory(&directory)?;
}
let file = open_no_follow(path)?;
let metadata = file.metadata()?;
validate_metadata(&metadata, expected_len)?;
validate_path_identity(path, &metadata)
}
fn create_generation(base: &Path) -> io::Result<Generation> {
#[cfg(not(unix))]
{
let _ = base;
Err(io::Error::new(
io::ErrorKind::Unsupported,
"file-backed pane graphics require Unix",
))
}
#[cfg(unix)]
{
fs::create_dir_all(base)?;
fs::set_permissions(base, fs::Permissions::from_mode(DIRECTORY_MODE))?;
validate_directory(base)?;
remove_stale_generations(base);
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let root = base.join(format!("server-{}-{nonce}", std::process::id()));
fs::create_dir(&root)?;
fs::set_permissions(&root, fs::Permissions::from_mode(DIRECTORY_MODE))?;
let source = root.join("source");
fs::create_dir(&source)?;
fs::set_permissions(&source, fs::Permissions::from_mode(DIRECTORY_MODE))?;
validate_directory(&root)?;
validate_directory(&source)?;
Ok(Generation { root, source })
}
}
fn runtime_base() -> PathBuf {
#[cfg(unix)]
{
let root = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| PathBuf::from("/var/tmp"));
root.join(format!("herdr-pane-graphics-{}", effective_uid()))
}
#[cfg(not(unix))]
{
PathBuf::from("pane-graphics-unavailable")
}
}
#[cfg(unix)]
fn read_exact_at(file: &File, data: &mut [u8]) -> io::Result<()> {
file.read_exact_at(data, 0)
}
#[cfg(not(unix))]
fn read_exact_at(_file: &File, _data: &mut [u8]) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only"))
}
#[cfg(unix)]
fn has_byte_at(file: &File, offset: u64) -> io::Result<bool> {
Ok(file.read_at(&mut [0], offset)? != 0)
}
#[cfg(not(unix))]
fn has_byte_at(_file: &File, _offset: u64) -> io::Result<bool> {
Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only"))
}
#[cfg(unix)]
fn remove_stale_generations(base: &Path) {
let Ok(entries) = fs::read_dir(base) else {
return;
};
for entry in entries.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|name| name.strip_prefix("server-"))
.and_then(|name| name.split('-').next())
.and_then(|pid| pid.parse::<i32>().ok())
.filter(|pid| *pid > 0)
else {
continue;
};
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
// SAFETY: kill with signal 0 probes process existence without sending a signal.
let alive = unsafe { libc::kill(pid, 0) } == 0;
if alive || io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) {
continue;
}
if let Err(err) = fs::remove_dir_all(entry.path()) {
tracing::warn!(path = %entry.path().display(), err = %err, "failed to remove stale pane graphics directory");
}
}
}
fn validate_child(path: &Path, directory: &Path) -> io::Result<()> {
if !path.is_absolute() || path.parent() != Some(directory) || path.file_name().is_none() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"frame path must be a direct child of the advertised source directory",
));
}
Ok(())
}
fn open_no_follow(path: &Path) -> io::Result<File> {
#[cfg(unix)]
{
OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(path)
}
#[cfg(not(unix))]
{
let _ = path;
Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only"))
}
}
fn validate_metadata(metadata: &fs::Metadata, expected_len: usize) -> io::Result<()> {
#[cfg(unix)]
{
if !metadata.file_type().is_file()
|| metadata.uid() != effective_uid()
|| metadata.mode() & 0o777 != FILE_MODE
|| metadata.nlink() != 1
|| metadata.len() != expected_len as u64
{
return Err(invalid(
"frame must be same-uid regular 0600 single-link exact-length file",
));
}
Ok(())
}
#[cfg(not(unix))]
{
let _ = (metadata, expected_len);
Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only"))
}
}
#[cfg(unix)]
fn validate_directory(path: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.file_type().is_dir()
|| metadata.uid() != effective_uid()
|| metadata.mode() & 0o777 != DIRECTORY_MODE
{
return Err(invalid("pane graphics directory is not private"));
}
Ok(())
}
fn validate_path_identity(path: &Path, expected: &fs::Metadata) -> io::Result<()> {
#[cfg(unix)]
{
let actual = fs::symlink_metadata(path)?;
if actual.dev() != expected.dev() || actual.ino() != expected.ino() {
return Err(invalid("frame path changed while leased"));
}
Ok(())
}
#[cfg(not(unix))]
{
let _ = (path, expected);
Err(io::Error::new(io::ErrorKind::Unsupported, "Unix only"))
}
}
#[cfg(unix)]
fn effective_uid() -> u32 {
// SAFETY: geteuid takes no arguments and has no preconditions.
unsafe { libc::geteuid() }
}
#[cfg(unix)]
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
#[cfg(unix)]
fn invalid_path() -> io::Error {
io::Error::new(
io::ErrorKind::PermissionDenied,
"invalid pane graphics source path",
)
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::io::Write;
use std::os::unix::fs::{symlink, PermissionsExt};
use std::sync::atomic::AtomicU64;
static NEXT: AtomicU64 = AtomicU64::new(1);
fn store() -> (FileStore, PathBuf) {
let base = PathBuf::from(format!(
"/var/tmp/herdr-graphics-test-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
(FileStore::new(base.clone()), base)
}
fn frame(store: &FileStore, name: &str, data: &[u8]) -> PathBuf {
let path = store.source_directory().unwrap().join(name);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(FILE_MODE)
.open(&path)
.unwrap();
file.write_all(data).unwrap();
path
}
#[test]
fn source_directory_is_lazy_private_and_removed_with_store() {
let (store, base) = store();
assert!(!store.is_initialized());
let source = store.source_directory().unwrap();
assert!(store.is_initialized());
validate_directory(&source).unwrap();
drop(store);
assert!(!source.exists());
let _ = fs::remove_dir(base);
}
#[test]
fn lease_holds_exact_file_and_copies_from_validated_fd() {
let (store, base) = store();
let path = frame(&store, "frame", &[1, 2, 3, 4]);
let lease = store.lease(&path, 4).unwrap();
assert_eq!(lease.path(), path);
assert_eq!(lease.len(), 4);
assert!(lease.fingerprint() > 0);
assert_eq!(lease.copy_rgba().unwrap(), [1, 2, 3, 4]);
drop(lease);
drop(store);
let _ = fs::remove_dir(base);
}
#[test]
fn validation_rejects_links_modes_lengths_and_outside_paths() {
let cases = ["mode", "length", "hard-link", "symlink", "outside"];
for case in cases {
let (store, base) = store();
let path = frame(&store, "frame", &[1, 2, 3, 4]);
let result = match case {
"mode" => {
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
store.lease(&path, 4)
}
"length" => store.lease(&path, 8),
"hard-link" => {
fs::hard_link(&path, store.source_directory().unwrap().join("other")).unwrap();
store.lease(&path, 4)
}
"symlink" => {
let link = store.source_directory().unwrap().join("link");
symlink(&path, &link).unwrap();
store.lease(&link, 4)
}
"outside" => store.lease(&base.join("outside"), 4),
_ => unreachable!(),
};
assert!(result.is_err(), "{case}");
drop(store);
let _ = fs::remove_dir_all(base);
}
}
#[test]
fn direct_source_validation_accepts_only_the_runtime_generation() {
let runtime_store = FileStore::default();
let runtime_path = frame(&runtime_store, "direct", &[1, 2, 3, 4]);
validate_direct_source(&runtime_path, 4).unwrap();
assert!(validate_direct_source(&runtime_path, 8).is_err());
assert!(validate_direct_source(Path::new("relative"), 4).is_err());
let generation = runtime_path.parent().unwrap().parent().unwrap();
let wrong_source = generation.join("frames");
fs::create_dir(&wrong_source).unwrap();
fs::set_permissions(&wrong_source, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap();
let wrong_source_path = wrong_source.join("frame");
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(FILE_MODE)
.open(&wrong_source_path)
.unwrap();
file.write_all(&[1, 2, 3, 4]).unwrap();
assert!(validate_direct_source(&wrong_source_path, 4).is_err());
let (other_store, other_base) = store();
let other_path = frame(&other_store, "other", &[1, 2, 3, 4]);
assert!(validate_direct_source(&other_path, 4).is_err());
drop(other_store);
let _ = fs::remove_dir_all(other_base);
let invalid_generation = runtime_base().join(format!(
"invalid-generation-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let invalid_source = invalid_generation.join("source");
fs::create_dir_all(&invalid_source).unwrap();
fs::set_permissions(
&invalid_generation,
fs::Permissions::from_mode(DIRECTORY_MODE),
)
.unwrap();
fs::set_permissions(&invalid_source, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap();
let invalid_path = invalid_source.join("frame");
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(FILE_MODE)
.open(&invalid_path)
.unwrap();
file.write_all(&[1, 2, 3, 4]).unwrap();
assert!(validate_direct_source(&invalid_path, 4).is_err());
let _ = fs::remove_dir_all(invalid_generation);
}
#[test]
fn generation_creation_removes_dead_server_directories() {
let (store, base) = store();
fs::create_dir_all(&base).unwrap();
fs::set_permissions(&base, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap();
let stale = base.join("server-2147483647-stale");
fs::create_dir(&stale).unwrap();
fs::set_permissions(&stale, fs::Permissions::from_mode(DIRECTORY_MODE)).unwrap();
store.source_directory().unwrap();
assert!(!stale.exists());
drop(store);
let _ = fs::remove_dir_all(base);
}
#[test]
fn replacement_after_lease_is_detected_before_fallback_copy() {
let (store, base) = store();
let path = frame(&store, "frame", &[1, 2, 3, 4]);
let lease = store.lease(&path, 4).unwrap();
fs::remove_file(&path).unwrap();
let _replacement = frame(&store, "frame", &[5, 6, 7, 8]);
assert!(lease.copy_rgba().is_err());
drop(lease);
drop(store);
let _ = fs::remove_dir_all(base);
}
}

View File

@ -57,6 +57,8 @@ pub enum ClientKeybindings {
pub enum ClientLaunchMode {
/// Full app client.
App,
/// Full app client eligible for audited local direct graphics.
AppDirectGraphics,
/// Direct terminal attach client.
TerminalAttach,
}
@ -428,6 +430,25 @@ pub enum ClientMessage {
/// Replace an existing writable controller for this terminal.
takeover: bool,
},
/// Result of the one armed Herdr-owned direct Kitty transmission.
GraphicsTransmissionResult {
transfer_id: u64,
image_id: u32,
success: bool,
},
/// One confirmed SGR 1016 mouse report with read-time host geometry.
InputPixels {
data: Vec<u8>,
cols: u16,
rows: u16,
width_px: u32,
height_px: u32,
},
/// The direct command was written and flushed; terminal response timing starts now.
GraphicsTransmissionStarted { transfer_id: u64, image_id: u32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -696,6 +717,8 @@ pub enum ServerMessage {
MouseCapture {
/// True when Herdr mouse UI is enabled or the focused pane app requests mouse reporting.
enabled: bool,
/// True only while the focused pane requests DEC SGR pixel mode 1016.
sgr_pixels: bool,
},
/// Whether the focused terminal requests Kitty report-all keyboard input.
@ -717,6 +740,19 @@ pub enum ServerMessage {
/// Number of BEL characters parsed from one PTY read.
count: u16,
},
/// One validated Herdr-owned Kitty regular-file RGBA transmission.
GraphicsFile {
path: String,
expected_len: u64,
image_id: u32,
transfer_id: u64,
leading: Vec<u8>,
control: String,
},
/// Suppress a direct command that expired before terminal delivery.
GraphicsTransmissionRetired { transfer_id: u64, image_id: u32 },
}
// ---------------------------------------------------------------------------
@ -1148,7 +1184,7 @@ mod tests {
],
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
// Freeze the protocol 19 input envelope before it is published.
// Freeze the protocol 20 input envelope before it is published.
assert_eq!(
encoded,
vec![
@ -1534,7 +1570,10 @@ mod tests {
#[test]
fn server_mouse_capture_roundtrip() {
let msg = ServerMessage::MouseCapture { enabled: true };
let msg = ServerMessage::MouseCapture {
enabled: true,
sgr_pixels: true,
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
let (decoded, _): (ServerMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
@ -1550,6 +1589,44 @@ mod tests {
assert_eq!(msg, decoded);
}
#[test]
fn direct_graphics_messages_roundtrip() {
let client = ClientMessage::GraphicsTransmissionResult {
transfer_id: 7,
image_id: 42,
success: false,
};
let encoded = bincode::serde::encode_to_vec(&client, bincode::config::standard()).unwrap();
let (decoded, _): (ClientMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(client, decoded);
let server = ServerMessage::GraphicsFile {
path: "/run/user/1000/herdr/source/frame".into(),
expected_len: 4,
image_id: 42,
transfer_id: 7,
leading: b"\x1b[2;3H".to_vec(),
control: "a=T,f=32,i=42,q=0".into(),
};
let encoded = bincode::serde::encode_to_vec(&server, bincode::config::standard()).unwrap();
let (decoded, _): (ServerMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(server, decoded);
let pixels = ClientMessage::InputPixels {
data: b"\x1b[<35;321;241M".to_vec(),
cols: 80,
rows: 24,
width_px: 800,
height_px: 480,
};
let encoded = bincode::serde::encode_to_vec(&pixels, bincode::config::standard()).unwrap();
let (decoded, _): (ClientMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(pixels, decoded);
}
#[test]
fn server_prefix_input_source_roundtrip() {
for active in [true, false] {

View File

@ -373,7 +373,11 @@ fn send_wheel(
let column = snapshot.cols.saturating_sub(1) / 2;
let row = u16::try_from(snapshot.rows.len().saturating_sub(1) / 2).unwrap_or(0);
let event = runtime
.encode_mouse_wheel(kind, column, row, KeyModifiers::empty())
.encode_mouse_wheel(
kind,
crate::input::mouse::Position::Cell { column, row },
KeyModifiers::empty(),
)
.ok_or(())?;
let mut bytes = Vec::with_capacity(event.len().saturating_mul(events));
for _ in 0..events {

View File

@ -41,6 +41,8 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4);
const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB
/// Maximum structured input events accepted in one client message.
const MAX_INPUT_EVENT_BATCH: usize = 4096;
/// Maximum encoded mouse report accepted with pixel geometry.
const MAX_PIXEL_MOUSE_PAYLOAD: usize = 128;
/// Channels owned by the server side of a client writer thread.
#[derive(Clone, Debug)]
@ -51,115 +53,99 @@ pub(crate) struct ClientWriter {
pub(crate) render: ClientRenderWriter,
}
#[cfg(test)]
impl ClientWriter {
pub(crate) fn replace_with_cleanup(&self, data: Vec<u8>) {
self.render.queue.replace_with_cleanup(data);
}
#[cfg(test)]
pub(crate) fn test_fill_render(&self, data: Vec<u8>) {
self.render.try_send(data).unwrap();
}
#[cfg(test)]
pub(crate) fn test_close(&self) {
self.render.queue.close_writer();
}
#[cfg(test)]
pub(crate) fn test_channel(
control: std::sync::mpsc::Sender<Vec<u8>>,
render: std::sync::mpsc::SyncSender<Vec<u8>>,
) -> Self {
Self {
control: ClientControlWriter {
target: ClientControlTarget::Channel(control),
},
render: ClientRenderWriter {
target: ClientRenderTarget::Channel(render),
},
}
let queue = ClientWriterQueue::new();
let drain = queue.clone();
let control_writer = ClientControlWriter::queue(queue.clone());
let mut render_writer = ClientRenderWriter::queue(queue);
render_writer.test_render = Some(render.clone());
let writer = Self {
control: control_writer,
render: render_writer,
};
std::thread::spawn(move || {
while let Some(item) = drain.recv() {
let sent = match item {
ClientWriteItem::Control(data) => control.send(data).is_ok(),
ClientWriteItem::Render(data) => render.send(data).is_ok(),
};
if !sent {
break;
}
}
drain.close_writer();
});
writer
}
}
#[derive(Debug)]
pub(crate) struct ClientControlWriter {
target: ClientControlTarget,
}
#[derive(Debug)]
enum ClientControlTarget {
Queue(Arc<ClientWriterQueue>),
queue: Arc<ClientWriterQueue>,
#[cfg(test)]
Channel(std::sync::mpsc::Sender<Vec<u8>>),
test_render: Option<std::sync::mpsc::SyncSender<Vec<u8>>>,
}
#[derive(Debug)]
pub(crate) struct ClientRenderWriter {
target: ClientRenderTarget,
}
#[derive(Debug)]
enum ClientRenderTarget {
Queue(Arc<ClientWriterQueue>),
queue: Arc<ClientWriterQueue>,
#[cfg(test)]
Channel(std::sync::mpsc::SyncSender<Vec<u8>>),
test_render: Option<std::sync::mpsc::SyncSender<Vec<u8>>>,
}
impl Clone for ClientControlWriter {
fn clone(&self) -> Self {
match &self.target {
ClientControlTarget::Queue(queue) => {
queue.add_sender();
macro_rules! writer_handle {
($type:ty) => {
impl Clone for $type {
fn clone(&self) -> Self {
self.queue.add_sender();
Self {
target: ClientControlTarget::Queue(queue.clone()),
queue: self.queue.clone(),
#[cfg(test)]
test_render: self.test_render.clone(),
}
}
#[cfg(test)]
ClientControlTarget::Channel(sender) => Self {
target: ClientControlTarget::Channel(sender.clone()),
},
}
}
}
impl Drop for ClientControlWriter {
fn drop(&mut self) {
match &self.target {
ClientControlTarget::Queue(queue) => queue.remove_sender(),
#[cfg(test)]
ClientControlTarget::Channel(_) => {}
impl Drop for $type {
fn drop(&mut self) {
self.queue.remove_sender();
}
}
}
};
}
writer_handle!(ClientControlWriter);
writer_handle!(ClientRenderWriter);
impl ClientControlWriter {
fn queue(queue: Arc<ClientWriterQueue>) -> Self {
queue.add_sender();
Self {
target: ClientControlTarget::Queue(queue),
queue,
#[cfg(test)]
test_render: None,
}
}
pub(crate) fn send(&self, data: Vec<u8>) -> Result<(), SendError<Vec<u8>>> {
match &self.target {
ClientControlTarget::Queue(queue) => queue.send_control(data),
#[cfg(test)]
ClientControlTarget::Channel(sender) => sender.send(data),
}
}
}
impl Clone for ClientRenderWriter {
fn clone(&self) -> Self {
match &self.target {
ClientRenderTarget::Queue(queue) => {
queue.add_sender();
Self {
target: ClientRenderTarget::Queue(queue.clone()),
}
}
#[cfg(test)]
ClientRenderTarget::Channel(sender) => Self {
target: ClientRenderTarget::Channel(sender.clone()),
},
}
}
}
impl Drop for ClientRenderWriter {
fn drop(&mut self) {
match &self.target {
ClientRenderTarget::Queue(queue) => queue.remove_sender(),
#[cfg(test)]
ClientRenderTarget::Channel(_) => {}
}
self.queue.send_control(data)
}
}
@ -167,16 +153,22 @@ impl ClientRenderWriter {
fn queue(queue: Arc<ClientWriterQueue>) -> Self {
queue.add_sender();
Self {
target: ClientRenderTarget::Queue(queue),
queue,
#[cfg(test)]
test_render: None,
}
}
pub(crate) fn try_send(&self, data: Vec<u8>) -> Result<(), TrySendError<Vec<u8>>> {
match &self.target {
ClientRenderTarget::Queue(queue) => queue.try_send_render(data),
#[cfg(test)]
ClientRenderTarget::Channel(sender) => sender.try_send(data),
#[cfg(test)]
if let Some(sender) = &self.test_render {
return sender.try_send(data);
}
self.queue.try_send_render(data)
}
pub(crate) fn send_ordered(&self, data: Vec<u8>) -> Result<(), TrySendError<Vec<u8>>> {
self.queue.send_ordered(data)
}
}
@ -189,6 +181,7 @@ struct ClientWriterQueue {
#[derive(Debug, Default)]
struct ClientWriterQueueState {
control: VecDeque<Vec<u8>>,
ordered: VecDeque<Vec<u8>>,
render: Option<Vec<u8>>,
senders: usize,
writer_alive: bool,
@ -245,16 +238,45 @@ impl ClientWriterQueue {
Ok(())
}
fn send_ordered(&self, data: Vec<u8>) -> Result<(), TrySendError<Vec<u8>>> {
let mut state = self.lock_state();
if !state.writer_alive {
return Err(TrySendError::Disconnected(data));
}
if !state.ordered.is_empty() {
return Err(TrySendError::Full(data));
}
if let Some(older) = state.render.take() {
state.ordered.push_back(older);
}
state.ordered.push_back(data);
self.ready.notify_one();
Ok(())
}
fn replace_with_cleanup(&self, data: Vec<u8>) {
let mut state = self.lock_state();
state.render = None;
state.ordered.clear();
if state.writer_alive {
state.control.push_back(data);
self.ready.notify_one();
}
}
fn recv(&self) -> Option<ClientWriteItem> {
let mut state = self.lock_state();
loop {
if let Some(data) = state.control.pop_front() {
return Some(ClientWriteItem::Control(data));
}
if let Some(data) = state.render.take() {
if let Some(data) = state.ordered.pop_front() {
self.ready.notify_one();
return Some(ClientWriteItem::Render(data));
}
if let Some(data) = state.render.take() {
return Some(ClientWriteItem::Render(data));
}
if state.senders == 0 {
return None;
}
@ -268,6 +290,8 @@ impl ClientWriterQueue {
fn close_writer(&self) {
let mut state = self.lock_state();
state.writer_alive = false;
state.render = None;
state.ordered.clear();
self.ready.notify_all();
}
@ -291,10 +315,29 @@ pub(crate) enum ServerEvent {
render_encoding: RenderEncoding,
keybindings: Option<Box<crate::config::LiveKeybindConfig>>,
direct_attach_requested: bool,
direct_graphics: bool,
writer: ClientWriter,
},
/// A client sent an input message.
ClientInput { client_id: u64, data: Vec<u8> },
/// A client reported the one armed Kitty regular-file response.
GraphicsTransmissionResult {
client_id: u64,
transfer_id: u64,
image_id: u32,
success: bool,
},
GraphicsTransmissionStarted {
client_id: u64,
transfer_id: u64,
image_id: u32,
},
/// One confirmed SGR pixel report with client read-time geometry.
ClientInputPixels {
client_id: u64,
data: Vec<u8>,
geometry: crate::input::mouse::HostGeometry,
},
/// A client sent structured input events.
ClientInputEvents {
client_id: u64,
@ -515,6 +558,7 @@ pub(crate) fn handle_client_handshake(
render_encoding,
keybindings,
direct_attach_requested,
direct_graphics,
) = match hello {
ClientMessage::Hello {
version,
@ -564,6 +608,7 @@ pub(crate) fn handle_client_handshake(
requested_encoding,
keybindings,
launch_mode == ClientLaunchMode::TerminalAttach,
launch_mode == ClientLaunchMode::AppDirectGraphics,
)
}
_ => {
@ -618,6 +663,7 @@ pub(crate) fn handle_client_handshake(
render_encoding,
keybindings,
direct_attach_requested,
direct_graphics,
writer,
});
@ -633,19 +679,17 @@ fn client_writer_loop(
server_event_tx: mpsc::Sender<ServerEvent>,
) {
while let Some(item) = writer_queue.recv() {
match item {
ClientWriteItem::Control(data) => {
if !write_framed_bytes(&mut stream, &data) {
break;
}
}
let written = match item {
ClientWriteItem::Control(data) => write_framed_bytes(&mut stream, &data),
ClientWriteItem::Render(data) => {
let _ =
server_event_tx.blocking_send(ServerEvent::ClientWriterDrained { client_id });
if !write_framed_bytes(&mut stream, &data) {
break;
}
write_framed_bytes(&mut stream, &data)
}
};
if !written {
let _ = server_event_tx.blocking_send(ServerEvent::ClientDisconnected { client_id });
break;
}
}
writer_queue.close_writer();
@ -728,6 +772,47 @@ fn client_read_loop(
ServerEvent::ClientInput { client_id, data }
}
}
ClientMessage::InputPixels {
data,
cols,
rows,
width_px,
height_px,
} => {
let Some(geometry) =
crate::input::mouse::HostGeometry::new(cols, rows, width_px, height_px)
else {
warn!(
client_id,
cols,
rows,
width_px,
height_px,
"invalid pixel mouse geometry from client, closing"
);
let _ = server_event_tx
.blocking_send(ServerEvent::ClientDisconnected { client_id });
break;
};
if data.len() > MAX_PIXEL_MOUSE_PAYLOAD
|| crate::input::mouse::parse_report(&data).is_none()
{
warn!(
client_id,
size = data.len(),
max = MAX_PIXEL_MOUSE_PAYLOAD,
"invalid pixel mouse report from client, closing"
);
let _ = server_event_tx
.blocking_send(ServerEvent::ClientDisconnected { client_id });
break;
}
ServerEvent::ClientInputPixels {
client_id,
data,
geometry,
}
}
ClientMessage::InputEvents { events } => match input_event_limit(&events) {
InputEventLimit::WithinLimits => {
ServerEvent::ClientInputEvents { client_id, events }
@ -777,6 +862,24 @@ fn client_read_loop(
takeover,
}
}
ClientMessage::GraphicsTransmissionResult {
transfer_id,
image_id,
success,
} => ServerEvent::GraphicsTransmissionResult {
client_id,
transfer_id,
image_id,
success,
},
ClientMessage::GraphicsTransmissionStarted {
transfer_id,
image_id,
} => ServerEvent::GraphicsTransmissionStarted {
client_id,
transfer_id,
image_id,
},
ClientMessage::ClipboardImage { extension, data } => {
if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
warn!(
@ -947,6 +1050,30 @@ mod tests {
));
}
#[test]
fn ordered_direct_follows_older_render_and_stays_bounded() {
let (writer, queue) = test_queue_writer();
writer.render.try_send(b"old".to_vec()).unwrap();
writer.render.send_ordered(b"direct".to_vec()).unwrap();
assert!(matches!(
writer.render.send_ordered(b"second".to_vec()),
Err(TrySendError::Full(_))
));
writer.render.try_send(b"new".to_vec()).unwrap();
for expected in [b"old".as_slice(), b"direct", b"new"] {
assert_eq!(
queue.recv(),
Some(ClientWriteItem::Render(expected.to_vec()))
);
}
queue.close_writer();
assert!(matches!(
writer.render.send_ordered(b"closed".to_vec()),
Err(TrySendError::Disconnected(_))
));
}
#[test]
fn client_writer_prioritizes_control_and_reports_render_drain() {
let (mut client_stream, server_stream, _path) = local_stream_pair("client-writer-priority");
@ -1203,6 +1330,7 @@ new_tab = "ctrl+notakey"
render_encoding,
keybindings,
direct_attach_requested,
direct_graphics,
writer,
} => {
assert_eq!(client_id, 42);
@ -1211,6 +1339,7 @@ new_tab = "ctrl+notakey"
assert_eq!(render_encoding, RenderEncoding::TerminalAnsi);
assert!(keybindings.is_none());
assert!(!direct_attach_requested);
assert!(!direct_graphics);
drop(writer);
}
other => panic!("expected ClientConnected, got {other:?}"),
@ -1394,6 +1523,76 @@ new_tab = "ctrl+notakey"
.expect("read thread result");
}
#[test]
fn client_read_loop_disconnects_invalid_pixel_mouse_geometry() {
let (mut client_stream, server_stream, _path) =
local_stream_pair("client-read-invalid-pixel-geometry");
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
let should_quit = Arc::new(AtomicBool::new(false));
let read_quit = should_quit.clone();
let handle = std::thread::spawn(move || {
client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
});
protocol::write_message(
&mut client_stream,
&ClientMessage::InputPixels {
data: b"\x1b[<35;1;1M".to_vec(),
cols: 0,
rows: 24,
width_px: 800,
height_px: 480,
},
)
.expect("write invalid pixel geometry");
assert!(matches!(
recv_server_event(&mut server_event_rx, "invalid pixel geometry disconnect"),
ServerEvent::ClientDisconnected { client_id: 7 }
));
drop(client_stream);
should_quit.store(true, Ordering::Release);
handle
.join()
.expect("read thread join")
.expect("read thread result");
}
#[test]
fn client_read_loop_disconnects_invalid_pixel_mouse_report() {
let (mut client_stream, server_stream, _path) =
local_stream_pair("client-read-invalid-pixel-report");
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
let should_quit = Arc::new(AtomicBool::new(false));
let read_quit = should_quit.clone();
let handle = std::thread::spawn(move || {
client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
});
protocol::write_message(
&mut client_stream,
&ClientMessage::InputPixels {
data: vec![b'x'; MAX_PIXEL_MOUSE_PAYLOAD + 1],
cols: 80,
rows: 24,
width_px: 800,
height_px: 480,
},
)
.expect("write invalid pixel report");
assert!(matches!(
recv_server_event(&mut server_event_rx, "invalid pixel report disconnect"),
ServerEvent::ClientDisconnected { client_id: 7 }
));
drop(client_stream);
should_quit.store(true, Ordering::Release);
handle
.join()
.expect("read thread join")
.expect("read thread result");
}
#[test]
fn client_read_loop_disconnects_marker_wrapped_invalid_utf8() {
let (mut client_stream, server_stream, _path) =

View File

@ -24,7 +24,6 @@ pub(crate) type RenderTarget = (
pub(crate) enum DeferredRender {
#[default]
None,
Graphics,
Full,
}
@ -56,14 +55,18 @@ pub(crate) struct ClientConnection {
pub(crate) render_state: ClientRenderState,
/// Client-local host Kitty graphics cache.
pub(crate) graphics_cache: crate::kitty_graphics::HostGraphicsCache,
/// Passive eligibility for audited local Kitty regular-file graphics.
pub(crate) direct_graphics: bool,
/// Whether this frontend preserves exact SGR pixel reports.
pub(crate) pixel_mouse: bool,
/// Whether the next graphics frame must clear and rebuild host-side Kitty state.
pub(crate) graphics_surface_reset_pending: bool,
/// Whether an ordinary render was skipped because the render channel was full.
pub(crate) render_pending: bool,
/// Whether a pane-graphics-only render was skipped because the channel was full.
pane_graphics_render_pending: bool,
/// Last host mouse capture mode sent to this client.
pub(crate) host_mouse_capture_active: Option<bool>,
/// Last SGR pixel provenance mode sent to this client.
pub(crate) host_sgr_pixels_active: Option<bool>,
/// Last Kitty report-all mode sent to this client's host terminal.
pub(crate) host_keyboard_report_all_active: Option<bool>,
/// Temporary files staged from this client's local clipboard image pastes.
@ -125,10 +128,12 @@ impl ClientConnection {
last_activity,
render_state: ClientRenderState::new(render_encoding),
graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(),
direct_graphics: false,
pixel_mouse: false,
graphics_surface_reset_pending: false,
render_pending: false,
pane_graphics_render_pending: false,
host_mouse_capture_active: None,
host_sgr_pixels_active: None,
host_keyboard_report_all_active: None,
staged_clipboard_files: Vec::new(),
writer,
@ -137,14 +142,11 @@ impl ClientConnection {
pub(crate) fn request_repaint(&mut self) {
self.render_state.request_repaint();
self.pane_graphics_render_pending = false;
}
pub(crate) fn deferred_render(&self) -> DeferredRender {
if self.render_pending {
DeferredRender::Full
} else if self.pane_graphics_render_pending {
DeferredRender::Graphics
} else {
DeferredRender::None
}
@ -152,18 +154,10 @@ impl ClientConnection {
pub(crate) fn clear_deferred_render(&mut self) {
self.render_pending = false;
self.pane_graphics_render_pending = false;
}
pub(crate) fn defer_full_render(&mut self) {
self.render_pending = true;
self.pane_graphics_render_pending = false;
}
pub(crate) fn defer_pane_graphics_render(&mut self) {
if !self.render_pending {
self.pane_graphics_render_pending = true;
}
}
pub(crate) fn take_deferred_render(&mut self) -> DeferredRender {

View File

@ -44,7 +44,6 @@ use crate::ipc::{
};
use crate::protocol::{
self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE,
MAX_GRAPHICS_FRAME_SIZE,
};
#[cfg(unix)]
use crate::server::client_accept::{
@ -66,8 +65,7 @@ use crate::server::terminal_attach::paste_payload_for_runtime;
mod pane_graphics;
#[cfg(test)]
use pane_graphics::frame_pane_graphics_for_client;
use crate::protocol::MAX_GRAPHICS_FRAME_SIZE;
use pane_graphics::RetainedGraphicsOutcome;
#[cfg(test)]
@ -367,12 +365,13 @@ fn apply_terminal_attach_scroll(
match runtime.wheel_routing() {
Some(crate::pane::WheelRouting::MouseReport) => {
runtime.scroll_reset();
let column = column.unwrap_or(0);
let row = row.unwrap_or(0);
let position = crate::input::mouse::Position::Cell {
column: column.unwrap_or(0),
row: row.unwrap_or(0),
};
let Some(bytes) = runtime.encode_mouse_wheel(
wheel_kind,
column,
row,
position,
KeyModifiers::from_bits_truncate(modifiers),
) else {
return Err(format!(
@ -671,7 +670,14 @@ impl HeadlessServer {
crate::render_prof::event("full_render_cause.default_workspace");
}
self.cancel_inactive_pane_graphics_streams();
if self.app.pane_graphics.retain_live_panes(&self.app.state) {
needs_render = true;
needs_graphics_render = true;
}
if self.expire_direct_graphics(now) {
needs_render = true;
needs_graphics_render = true;
}
self.drain_client_config_reload_request();
self.stream_host_mouse_capture_mode();
@ -1002,6 +1008,7 @@ impl HeadlessServer {
},
respond_to,
response_write_complete: None,
stream_active: None,
},
true,
);
@ -1083,6 +1090,15 @@ impl HeadlessServer {
}
fn sync_foreground_client_state(&mut self) {
self.app.direct_graphics_available = self.direct_graphics_available();
self.app.pixel_mouse_available = self.foreground_client_id.is_some_and(|id| {
self.clients
.get(&id)
.is_some_and(|client| client.pixel_mouse)
});
if !self.app.direct_graphics_available {
self.retire_all_direct_graphics();
}
let Some(client_id) = self.foreground_client_id else {
self.effective_size = (MIN_COLS, MIN_ROWS);
self.app.state.outer_terminal_focus = None;
@ -1485,11 +1501,21 @@ impl HeadlessServer {
.count()
}
fn direct_graphics_available(&self) -> bool {
self.app_client_count() == 1
&& self.foreground_client_id.is_some_and(|id| {
self.clients.get(&id).is_some_and(|client| {
client.is_full_app_client() && client.writer.is_some() && client.direct_graphics
})
})
}
fn has_app_client(&self) -> bool {
self.app_client_count() > 0
}
fn remove_client(&mut self, client_id: u64) -> bool {
self.retire_direct_graphics_for_client(client_id);
let was_foreground = self.foreground_client_id == Some(client_id);
self.app.clear_input_source(client_id);
self.send_client_graphics_cleanup(client_id);
@ -1551,7 +1577,7 @@ impl HeadlessServer {
let Ok(serialized) = Self::frame_server_message(&ServerMessage::Graphics { bytes }) else {
return;
};
let _ = writer.control.send(serialized);
writer.replace_with_cleanup(serialized);
}
fn send_all_clients_graphics_cleanup(&mut self) {
@ -2781,6 +2807,7 @@ impl HeadlessServer {
writer,
render_encoding,
direct_attach_requested,
direct_graphics,
} => {
if self.handoff_in_progress {
if let Ok(message) =
@ -2806,24 +2833,24 @@ impl HeadlessServer {
"client connected"
);
let last_activity = self.allocate_activity_stamp();
self.clients.insert(
client_id,
ClientConnection::new_with_mode(
ClientConnectionMode::App,
keybindings,
(cols, rows),
crate::kitty_graphics::HostCellSize {
width_px: cell_width_px,
height_px: cell_height_px,
},
crate::terminal_theme::TerminalTheme::default(),
None,
last_activity,
render_encoding,
direct_attach_requested,
Some(writer),
),
let mut connection = ClientConnection::new_with_mode(
ClientConnectionMode::App,
keybindings,
(cols, rows),
crate::kitty_graphics::HostCellSize {
width_px: cell_width_px,
height_px: cell_height_px,
},
crate::terminal_theme::TerminalTheme::default(),
None,
last_activity,
render_encoding,
direct_attach_requested,
Some(writer),
);
connection.direct_graphics = direct_graphics;
connection.pixel_mouse = direct_graphics;
self.clients.insert(client_id, connection);
if !direct_attach_requested {
self.foreground_client_id = Some(client_id);
}
@ -2835,6 +2862,17 @@ impl HeadlessServer {
self.nudge_handoff_panes_on_first_client_attach();
true
}
ServerEvent::GraphicsTransmissionResult {
client_id,
transfer_id,
image_id,
success,
} => self.complete_direct_graphics(client_id, transfer_id, image_id, success),
ServerEvent::GraphicsTransmissionStarted {
client_id,
transfer_id,
image_id,
} => self.start_direct_graphics_response(client_id, transfer_id, image_id),
ServerEvent::ClientAttachTerminal {
client_id,
terminal_id,
@ -2859,6 +2897,35 @@ impl HeadlessServer {
} => self.handle_terminal_attach_scroll(
client_id, source, direction, lines, column, row, modifiers,
),
ServerEvent::ClientInputPixels {
client_id,
data,
geometry,
} => {
let coordinates_valid = crate::input::mouse::parse_report(&data)
.and_then(|(x, y)| geometry.cell(x, y))
.is_some();
let valid = coordinates_valid
&& self.clients.get(&client_id).is_some_and(|client| {
let cell = client.cell_size;
client.is_full_app_client()
&& client.host_sgr_pixels_active == Some(true)
&& client.terminal_size == (geometry.cols, geometry.rows)
&& cell.is_known()
&& cell.width_px == geometry.width_px / u32::from(geometry.cols)
&& cell.height_px == geometry.height_px / u32::from(geometry.rows)
});
if !valid || self.handoff_in_progress || !self.focused_pane_graphics_demand() {
return false;
}
let foreground_changed = self.promote_client_to_foreground(client_id);
if foreground_changed {
self.resize_shared_runtime_to_effective_size_before_input();
}
self.app
.route_client_pixel_mouse(client_id, &data, geometry)
|| foreground_changed
}
ServerEvent::ClientInput { client_id, data } => {
if self.handoff_in_progress {
debug!(
@ -2987,18 +3054,21 @@ impl HeadlessServer {
}) = self.clients.get_mut(&client_id)
{
*terminal_size = (cols, rows);
*cell_size = crate::kitty_graphics::HostCellSize {
let observed = crate::kitty_graphics::HostCellSize {
width_px: cell_width_px,
height_px: cell_height_px,
};
if observed.is_known() {
*cell_size = observed;
}
render_state.request_repaint();
Some(terminal_id.clone())
Some((terminal_id.clone(), *cell_size))
} else {
None
};
if let Some(terminal_id) = direct_terminal_id {
if let Some((terminal_id, cell_size)) = direct_terminal_id {
if let Some(runtime) = self.runtime_for_terminal_id_string(&terminal_id) {
runtime.resize(rows, cols, cell_width_px, cell_height_px);
runtime.resize(rows, cols, cell_size.width_px, cell_size.height_px);
}
return true;
}
@ -3011,19 +3081,25 @@ impl HeadlessServer {
}) = self.clients.get_mut(&client_id)
{
*terminal_size = (cols, rows);
*cell_size = crate::kitty_graphics::HostCellSize {
let observed = crate::kitty_graphics::HostCellSize {
width_px: cell_width_px,
height_px: cell_height_px,
};
if observed.is_known() {
*cell_size = observed;
}
render_state.request_repaint();
return true;
}
if let Some(client) = self.clients.get_mut(&client_id) {
client.terminal_size = (cols, rows);
client.cell_size = crate::kitty_graphics::HostCellSize {
let observed = crate::kitty_graphics::HostCellSize {
width_px: cell_width_px,
height_px: cell_height_px,
};
if observed.is_known() {
client.cell_size = observed;
}
}
self.promote_client_to_foreground(client_id);
self.resize_shared_runtime_to_effective_size();
@ -3055,19 +3131,10 @@ impl HeadlessServer {
}
fn handle_server_event_with_render_impact(&mut self, ev: ServerEvent) -> RenderImpact {
let deferred_render = match &ev {
ServerEvent::ClientWriterDrained { client_id } => self
.clients
.get(client_id)
.map_or(DeferredRender::None, ClientConnection::deferred_render),
_ => DeferredRender::None,
};
if !self.handle_server_event(ev) {
return RenderImpact::None;
}
match deferred_render {
DeferredRender::Graphics => RenderImpact::Graphics,
DeferredRender::None | DeferredRender::Full => RenderImpact::Full,
if self.handle_server_event(ev) {
RenderImpact::Full
} else {
RenderImpact::None
}
}
@ -3299,6 +3366,7 @@ impl HeadlessServer {
if matches!(
&msg.request.method,
api::schema::Method::PaneGraphicsStreamSet(_)
| api::schema::Method::PaneGraphicsStreamDirect(_)
) {
return self.handle_pane_graphics_stream_frame(msg);
}
@ -3341,6 +3409,11 @@ impl HeadlessServer {
};
let metadata_expired = self.app.expire_due_metadata(Instant::now());
let stream_open = match &msg.request.method {
api::schema::Method::PaneGraphicsStreamOpen(params) => Some(params.clone()),
_ => None,
};
let stream_active = msg.stream_active.clone();
if let api::schema::Method::ServerLiveHandoff(params) = &msg.request.method {
let handoff_result = self.perform_live_handoff(params.clone());
@ -3398,7 +3471,7 @@ impl HeadlessServer {
| api::schema::Method::PaneGraphicsStreamOpen(_)
| api::schema::Method::PaneGraphicsStreamClose(_)
)
.then_some(self.app.state.pane_graphics_revision);
.then_some(self.app.pane_graphics.revision());
let mut changed = metadata_expired
| (pane_graphics_revision_before.is_none() && api::request_changes_ui(&msg.request));
let skip_default_workspace = skip_default_workspace_for_request
@ -3501,6 +3574,10 @@ impl HeadlessServer {
}
}
}
if let (Some(params), Some(active)) = (stream_open.as_ref(), stream_active) {
self.app
.attach_pane_graphics_stream_active(params, active, &response);
}
if let Some(spec) = alt_screen_read_spec {
if let Ok(success) = serde_json::from_str::<api::schema::SuccessResponse>(&response) {
if let api::schema::ResponseResult::PaneRead { read } = success.result {
@ -3523,7 +3600,7 @@ impl HeadlessServer {
let _ = msg.respond_to.send(response);
if let Some(revision_before) = pane_graphics_revision_before {
changed |= revision_before != self.app.state.pane_graphics_revision;
changed |= revision_before != self.app.pane_graphics.revision();
}
// Forward new toast state only when a client-local delivery mode is selected.
@ -3671,32 +3748,73 @@ impl HeadlessServer {
changed
}
fn focused_pane_graphics_demand(&self) -> bool {
self.app
.state
.active
.and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
.and_then(crate::workspace::Workspace::focused_pane_id)
.is_some_and(|pane_id| self.app.pane_graphics.active_for_pane(pane_id))
}
fn stream_host_mouse_capture_mode(&mut self) {
let enabled = self
.app
.state
.should_capture_host_mouse_from(&self.app.terminal_runtimes);
let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { enabled })
{
Ok(framed) => framed,
Err(err) => {
warn!(err = %err, "failed to serialize mouse capture mode for clients");
return;
}
};
let pixel_mouse_requested = self
.clients
.values()
.any(|client| client.is_full_app_client() && client.pixel_mouse);
let sgr_pixels = pixel_mouse_requested
&& self.focused_pane_graphics_demand()
&& self
.app
.state
.active
.and_then(|ws_idx| {
self.app
.state
.workspaces
.get(ws_idx)
.and_then(crate::workspace::Workspace::focused_pane_id)
.and_then(|pane_id| {
self.app.state.runtime_for_pane_in_workspace(
&self.app.terminal_runtimes,
ws_idx,
pane_id,
)
})
})
.and_then(crate::terminal::TerminalRuntime::input_state)
.is_some_and(|state| {
state.mouse_protocol_encoding == crate::input::MouseProtocolEncoding::SgrPixels
});
let mut broken_clients: Vec<u64> = Vec::new();
for (&client_id, client) in &mut self.clients {
if !client.is_full_app_client() {
continue;
}
if client.host_mouse_capture_active == Some(enabled) {
let client_sgr_pixels = sgr_pixels && client.pixel_mouse;
if client.host_mouse_capture_active == Some(enabled)
&& client.host_sgr_pixels_active == Some(client_sgr_pixels)
{
continue;
}
let Some(writer) = &client.writer else {
continue;
};
if writer.control.send(serialized.clone()).is_err() {
let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture {
enabled,
sgr_pixels: client_sgr_pixels,
}) {
Ok(framed) => framed,
Err(err) => {
warn!(err = %err, "failed to serialize mouse capture mode for client");
continue;
}
};
if writer.control.send(serialized).is_err() {
debug!(
client_id,
"client writer channel closed during mouse capture update"
@ -3705,6 +3823,7 @@ impl HeadlessServer {
continue;
}
client.host_mouse_capture_active = Some(enabled);
client.host_sgr_pixels_active = Some(client_sgr_pixels);
}
for client_id in broken_clients {
@ -3877,6 +3996,7 @@ impl HeadlessServer {
&& cell_size.is_known()
&& crate::kitty_graphics::has_visible_pane_graphics(
&self.app.state,
&self.app.pane_graphics,
&self.app.terminal_runtimes,
self.app.state.view.tab_surface(),
*cell_size,
@ -4166,31 +4286,48 @@ impl HeadlessServer {
continue;
};
let mut next_graphics_cache = client.graphics_cache.clone();
let graphics_surface_reset_pending = client.graphics_surface_reset_pending;
if is_app_client && self.app.state.kitty_graphics_enabled && cell_size.is_known() {
if graphics_surface_reset_pending {
frame.graphics = next_graphics_cache.clear_bytes();
let mut reset_graphics = Vec::new();
let mut encoded = if is_app_client
&& self.app.state.kitty_graphics_enabled
&& cell_size.is_known()
{
if client.graphics_surface_reset_pending {
if self.app.pane_graphics.slots.is_empty() {
reset_graphics = next_graphics_cache.clear_bytes();
} else {
next_graphics_cache = crate::kitty_graphics::HostGraphicsCache::default();
}
}
let graphics_started = crate::render_prof::timer();
frame
.graphics
.extend(crate::kitty_graphics::encode_local_pane_graphics(
&self.app.state,
&self.app.terminal_runtimes,
self.app.state.view.tab_surface(),
cell_size,
&mut next_graphics_cache,
));
let encoded = crate::kitty_graphics::encode_local_pane_graphics(
&self.app.state,
&self.app.pane_graphics,
&self.app.terminal_runtimes,
self.app.state.view.tab_surface(),
cell_size,
Some(crate::kitty_graphics::HEADLESS_GRAPHICS_TRANSACTION_BUDGET),
&mut next_graphics_cache,
);
crate::render_prof::duration_since("full_render.graphics_encode", graphics_started);
encoded
} else if self.app.pane_graphics.slots.is_empty() {
crate::kitty_graphics::EncodedGraphics {
bytes: next_graphics_cache.clear_bytes(),
incomplete: false,
}
} else {
frame.graphics = next_graphics_cache.clear_bytes();
next_graphics_cache.clear_next()
};
if !reset_graphics.is_empty() {
reset_graphics.extend(encoded.bytes);
encoded.bytes = reset_graphics;
}
frame.graphics = encoded.bytes;
let Some(writer) = client.writer.as_ref().cloned() else {
crate::render_prof::event("full_render.writer_missing");
continue;
};
let mut commit_graphics_cache = true;
if frame.graphics.len() > MAX_GRAPHICS_FRAME_SIZE {
warn!(
@ -4201,32 +4338,30 @@ impl HeadlessServer {
);
frame.graphics.clear();
commit_graphics_cache = false;
encoded.incomplete = false;
}
let max_frame_size = if frame.graphics.is_empty() {
MAX_FRAME_SIZE
} else {
MAX_GRAPHICS_FRAME_SIZE
};
let has_graphics = !frame.graphics.is_empty();
let prepare_started = crate::render_prof::timer();
let Some(mut prepared) = client.render_state.prepare_frame(frame) else {
client.clear_deferred_render();
if commit_graphics_cache {
client.graphics_cache = next_graphics_cache;
client.graphics_surface_reset_pending = false;
}
if encoded.incomplete {
client.defer_full_render();
deferred_frame = true;
} else {
client.clear_deferred_render();
}
crate::render_prof::event("full_render.skip_identical");
crate::render_prof::duration_since("full_render.prepare_frame", prepare_started);
continue;
};
crate::render_prof::duration_since("full_render.prepare_frame", prepare_started);
let serialize_started = crate::render_prof::timer();
let serialized = match Self::frame_server_message_with_max(
prepared.message(),
max_frame_size,
) {
Ok(framed) => {
crate::render_prof::duration_since("full_render.serialize", serialize_started);
framed
}
let max = if has_graphics {
MAX_GRAPHICS_FRAME_SIZE
} else {
crate::protocol::MAX_FRAME_SIZE
};
let serialized = match Self::frame_server_message_with_max(prepared.message(), max) {
Ok(frame) => frame,
Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => {
warn!(
client_id,
@ -4234,10 +4369,6 @@ impl HeadlessServer {
);
let Some(mut text_only_frame) = prepared.into_frame() else {
crate::render_prof::event("full_render.serialize_error");
crate::render_prof::duration_since(
"full_render.serialize",
serialize_started,
);
continue;
};
text_only_frame.graphics.clear();
@ -4246,10 +4377,6 @@ impl HeadlessServer {
else {
client.clear_deferred_render();
crate::render_prof::event("full_render.skip_identical_text_only");
crate::render_prof::duration_since(
"full_render.serialize",
serialize_started,
);
continue;
};
let framed = match Self::frame_server_message(text_only_prepared.message()) {
@ -4258,16 +4385,12 @@ impl HeadlessServer {
warn!(client_id, err = %err, "failed to serialize text-only frame for client");
broken_clients.push(client_id);
crate::render_prof::event("full_render.serialize_error");
crate::render_prof::duration_since(
"full_render.serialize",
serialize_started,
);
continue;
}
};
prepared = text_only_prepared;
commit_graphics_cache = false;
crate::render_prof::duration_since("full_render.serialize", serialize_started);
encoded.incomplete = false;
framed
}
Err(protocol::FramingError::Oversized { claimed, max }) => {
@ -4276,45 +4399,36 @@ impl HeadlessServer {
claimed, max, "skipping oversized frame for client"
);
crate::render_prof::event("full_render.serialize_oversized");
crate::render_prof::duration_since("full_render.serialize", serialize_started);
continue;
}
Err(err) => {
warn!(client_id, err = %err, "failed to serialize frame for client");
warn!(client_id, err = %err, "failed to serialize frame");
broken_clients.push(client_id);
crate::render_prof::event("full_render.serialize_error");
crate::render_prof::duration_since("full_render.serialize", serialize_started);
continue;
}
};
crate::render_prof::counter("full_render.bytes", serialized.len() as u64);
let send_started = crate::render_prof::timer();
match writer.render.try_send(serialized) {
Ok(()) => {
client.clear_deferred_render();
if commit_graphics_cache {
client.graphics_cache = next_graphics_cache;
client.graphics_surface_reset_pending = false;
}
client.render_state.commit_sent_frame(prepared);
if encoded.incomplete {
client.defer_full_render();
deferred_frame = true;
} else {
client.clear_deferred_render();
}
crate::render_prof::event("full_render.sent");
crate::render_prof::duration_since("full_render.try_send", send_started);
}
Err(std::sync::mpsc::TrySendError::Full(_)) => {
client.defer_full_render();
deferred_frame = true;
crate::render_prof::event("full_render.queue_full");
crate::render_prof::duration_since("full_render.try_send", send_started);
debug!(client_id, "render queue full, deferring latest frame");
continue;
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
debug!(client_id, "client writer channel closed, marking as broken");
broken_clients.push(client_id);
crate::render_prof::event("full_render.writer_disconnected");
crate::render_prof::duration_since("full_render.try_send", send_started);
continue;
}
}
}
@ -5044,7 +5158,9 @@ mod tests {
}
fn read_server_frame(bytes: Vec<u8>) -> FrameData {
match read_server_message(bytes) {
match protocol::read_message(&mut std::io::Cursor::new(bytes), MAX_GRAPHICS_FRAME_SIZE)
.expect("decode server frame")
{
ServerMessage::Frame(frame) => frame,
other => panic!("expected frame, got {other:?}"),
}
@ -5139,6 +5255,7 @@ mod tests {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
let response: api::schema::SuccessResponse =
serde_json::from_str(&response_rx.recv().unwrap()).unwrap();
@ -5179,6 +5296,7 @@ mod tests {
},
respond_to,
response_write_complete: None,
stream_active: None,
})
);
let response = response_rx
@ -5439,6 +5557,42 @@ mod tests {
&& matches!(mark, '\u{ff9e}' | '\u{ff9f}')
}
#[test]
fn direct_graphics_requires_one_negotiated_app_client() {
let mut server = test_headless_server();
let (writer_a, _control_a, _render_a) = test_client_writer();
assert!(server.handle_server_event(ServerEvent::ClientConnected {
client_id: 1,
cols: 80,
rows: 24,
cell_width_px: 10,
cell_height_px: 20,
render_encoding: RenderEncoding::SemanticFrame,
keybindings: None,
direct_attach_requested: false,
direct_graphics: true,
writer: writer_a,
}));
assert!(server.clients[&1].direct_graphics);
assert!(server.clients[&1].pixel_mouse);
assert!(server.direct_graphics_available());
let (writer_b, _control_b, _render_b) = test_client_writer();
assert!(server.handle_server_event(ServerEvent::ClientConnected {
client_id: 2,
cols: 80,
rows: 24,
cell_width_px: 10,
cell_height_px: 20,
render_encoding: RenderEncoding::SemanticFrame,
keybindings: None,
direct_attach_requested: false,
direct_graphics: false,
writer: writer_b,
}));
assert!(!server.direct_graphics_available());
}
#[test]
fn foreground_client_applies_client_keybindings() {
let mut server = test_headless_server();
@ -5463,6 +5617,7 @@ new_tab = "prefix+t"
render_encoding: RenderEncoding::SemanticFrame,
keybindings: Some(Box::new(local_keybindings)),
direct_attach_requested: false,
direct_graphics: false,
writer: writer_a,
}));
assert_eq!(
@ -5487,6 +5642,7 @@ new_tab = "prefix+t"
render_encoding: RenderEncoding::SemanticFrame,
keybindings: None,
direct_attach_requested: false,
direct_graphics: false,
writer: writer_b,
}));
assert_eq!(
@ -5527,6 +5683,7 @@ new_tab = "prefix+t"
render_encoding: RenderEncoding::SemanticFrame,
keybindings: Some(Box::new(local_keybindings)),
direct_attach_requested: false,
direct_graphics: false,
writer: writer_a,
}));
assert_eq!(server.app.state.config_diagnostic, without_keybindings);
@ -5540,6 +5697,7 @@ new_tab = "prefix+t"
render_encoding: RenderEncoding::SemanticFrame,
keybindings: None,
direct_attach_requested: false,
direct_graphics: false,
writer: writer_b,
}));
assert_eq!(
@ -5583,6 +5741,7 @@ next_tab = ""
render_encoding: RenderEncoding::SemanticFrame,
keybindings: Some(Box::new(local_keybindings)),
direct_attach_requested: false,
direct_graphics: false,
writer,
}));
server.app.state.mode = crate::app::Mode::Settings;
@ -5658,6 +5817,7 @@ next_tab = ""
render_encoding: RenderEncoding::SemanticFrame,
keybindings: Some(Box::new(local_config.live_keybinds().unwrap())),
direct_attach_requested: false,
direct_graphics: false,
writer: writer_a,
}));
server.app.state.mode = crate::app::Mode::Settings;
@ -5678,6 +5838,7 @@ next_tab = ""
render_encoding: RenderEncoding::SemanticFrame,
keybindings: None,
direct_attach_requested: false,
direct_graphics: false,
writer: writer_b,
}));
assert_eq!(
@ -5712,6 +5873,7 @@ next_tab = ""
render_encoding: RenderEncoding::TerminalAnsi,
keybindings: None,
direct_attach_requested: true,
direct_graphics: false,
writer,
}));
assert!(server.clients.contains_key(&7));
@ -5777,6 +5939,7 @@ next_tab = ""
render_encoding: RenderEncoding::TerminalAnsi,
keybindings: None,
direct_attach_requested: true,
direct_graphics: false,
writer,
}));
control_rx
@ -6189,6 +6352,7 @@ next_tab = ""
render_encoding,
keybindings: None,
direct_attach_requested: false,
direct_graphics: false,
writer,
}));
@ -6223,6 +6387,7 @@ next_tab = ""
render_encoding: RenderEncoding::TerminalAnsi,
keybindings: None,
direct_attach_requested: true,
direct_graphics: false,
writer,
}));
@ -6256,6 +6421,7 @@ next_tab = ""
render_encoding: RenderEncoding::SemanticFrame,
keybindings: None,
direct_attach_requested: false,
direct_graphics: false,
writer,
}));
assert!(server.has_app_client());
@ -6356,6 +6522,7 @@ next_tab = ""
render_encoding: RenderEncoding::TerminalAnsi,
keybindings: None,
direct_attach_requested: true,
direct_graphics: false,
writer,
}));
assert!(
@ -7778,6 +7945,51 @@ next_tab = ""
assert_eq!(server.foreground_client_id, Some(3));
}
#[test]
fn terminal_attach_resize_preserves_known_cell_size_when_pixels_are_omitted() {
with_terminal_session_test_server(|server, _terminal_id, terminal_id, _pane_id| {
let mut client = ClientConnection::new(
(80, 24),
crate::kitty_graphics::HostCellSize {
width_px: 10,
height_px: 20,
},
crate::terminal_theme::TerminalTheme::default(),
Some(true),
1,
RenderEncoding::SemanticFrame,
None,
);
client.mode = ClientConnectionMode::TerminalAttach {
terminal_id: terminal_id.clone(),
};
server.clients.insert(1, client);
assert!(server.handle_server_event(ServerEvent::ClientResize {
client_id: 1,
cols: 100,
rows: 30,
cell_width_px: 0,
cell_height_px: 0,
}));
assert_eq!(
server
.runtime_for_terminal_id_string(&terminal_id)
.unwrap()
.pixel_size(),
Some((1_000, 600))
);
assert_eq!(
server.clients[&1].cell_size,
crate::kitty_graphics::HostCellSize {
width_px: 10,
height_px: 20,
}
);
});
}
#[tokio::test]
async fn passive_mouse_motion_forwards_without_requesting_render() {
let mut server = test_headless_server();
@ -8335,6 +8547,7 @@ next_tab = ""
render_encoding: RenderEncoding::TerminalAnsi,
keybindings: None,
direct_attach_requested: true,
direct_graphics: false,
writer,
}));
assert!(
@ -8531,16 +8744,11 @@ next_tab = ""
let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
.expect("serialize dummy message");
server
.clients
.get(&1)
.unwrap()
server.clients[&1]
.writer
.as_ref()
.unwrap()
.render
.try_send(queued)
.expect("pre-fill render queue");
.test_fill_render(queued);
assert!(server.handle_server_event(ServerEvent::ClientInput {
client_id: 1,
@ -8671,10 +8879,7 @@ next_tab = ""
let (client_tx, _client_control_rx, client_rx) = test_client_writer();
let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
.expect("serialize dummy message");
client_tx
.render
.try_send(queued)
.expect("pre-fill render queue");
client_tx.test_fill_render(queued);
server.clients.insert(
1,
@ -8715,10 +8920,7 @@ next_tab = ""
let (client_tx, _client_control_rx, client_rx) = test_client_writer();
let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
.expect("serialize dummy message");
client_tx
.render
.try_send(queued)
.expect("pre-fill render queue");
client_tx.test_fill_render(queued);
server.clients.insert(
1,
@ -9003,7 +9205,7 @@ next_tab = ""
.recv_timeout(Duration::from_millis(100))
.expect("mouse capture message")
),
ServerMessage::MouseCapture { enabled: true }
ServerMessage::MouseCapture { enabled: true, .. }
));
}
@ -9525,16 +9727,11 @@ next_tab = ""
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
.expect("serialize dummy message");
server
.clients
.get(&1)
.unwrap()
server.clients[&1]
.writer
.as_ref()
.unwrap()
.render
.try_send(queued)
.expect("pre-fill render queue");
.test_fill_render(queued);
server.app.full_redraw_pending = true;
server.render_and_stream();
@ -9742,6 +9939,7 @@ next_tab = ""
let mut server = test_headless_server();
let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer();
drop(foreground_control_rx);
foreground_tx.test_close();
server.clients.insert(
1,
@ -10125,6 +10323,7 @@ next_tab = ""
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert!(changed);
@ -10211,6 +10410,7 @@ next_tab = ""
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert!(changed);
@ -10263,6 +10463,7 @@ next_tab = ""
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert!(changed);
@ -10295,6 +10496,7 @@ next_tab = ""
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert!(changed);
@ -10332,6 +10534,7 @@ next_tab = ""
},
respond_to,
response_write_complete: None,
stream_active: None,
})
);
@ -10388,6 +10591,7 @@ next_tab = ""
},
respond_to,
response_write_complete: None,
stream_active: None,
})
);
@ -10688,6 +10892,7 @@ next_tab = ""
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert!(changed);

View File

@ -1,5 +1,3 @@
use tracing::warn;
use super::{HeadlessServer, RenderImpact};
use crate::api;
use crate::protocol::{ServerMessage, MAX_GRAPHICS_FRAME_SIZE};
@ -12,31 +10,16 @@ pub(super) enum RetainedGraphicsOutcome {
Fallback,
}
pub(super) fn frame_pane_graphics_for_client(bytes: Vec<u8>) -> Vec<u8> {
pub(super) fn frame_pane_graphics(bytes: Vec<u8>) -> Vec<u8> {
if bytes.is_empty() {
return bytes;
}
let mut framed = Vec::with_capacity(bytes.len() + 4);
framed.extend_from_slice(b"\x1b7");
framed.extend_from_slice(&bytes);
framed.extend_from_slice(b"\x1b8");
framed
[b"\x1b7".as_slice(), &bytes, b"\x1b8"].concat()
}
impl HeadlessServer {
pub(super) fn pane_graphics_runtime_active(&self) -> bool {
!self.app.state.pane_graphics_layers.is_empty()
|| !self.app.state.pane_graphics_streams.is_empty()
}
pub(super) fn cancel_inactive_pane_graphics_streams(&self) {
api::cancel_inactive_pane_graphics_streams(|owner| {
self.app
.state
.pane_graphics_streams
.values()
.any(|active_owner| active_owner == owner)
});
!self.app.pane_graphics.slots.is_empty()
}
pub(super) fn handle_pane_graphics_stream_frame(
@ -49,12 +32,164 @@ impl HeadlessServer {
}
let internal_changed = self.drain_all_internal_events_with_forwarding();
let direct = match &msg.request.method {
api::schema::Method::PaneGraphicsStreamDirect(params) => Some(params.clone()),
_ => None,
};
let direct_key = direct.as_ref().and_then(|params| {
self.app.parse_pane_id(&params.pane_id).map(|(_, pane_id)| {
(
pane_id,
params
.layer_id
.clone()
.unwrap_or_else(|| api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID.into()),
)
})
});
let direct_client = self
.direct_graphics_available()
.then_some(self.foreground_client_id)
.flatten();
let gate_busy = self
.app
.pane_graphics
.slots
.values()
.any(|slot| slot.direct_gate.is_some());
self.app.direct_graphics_available = direct_client.is_some() && !gate_busy;
let response = self
.app
.handle_api_request_after_internal_events_drained(msg.request);
self.app.direct_graphics_available = self.direct_graphics_available();
let succeeded = serde_json::from_str::<api::schema::SuccessResponse>(&response).is_ok();
let _ = msg.respond_to.send(response);
if succeeded {
if let (Some(params), Some(client_id)) = (direct, direct_client) {
let direct_frame = direct_key.as_ref().and_then(|key| {
self.app.pane_graphics.slots.get(key).and_then(|slot| {
let active_owner = slot.stream_owner.as_deref() == Some(&params.owner)
&& slot.stream_is_active();
active_owner.then_some(())?;
slot.layer
.as_ref()
.and_then(crate::app::pane_graphics::Layer::direct_lease)
.map(|lease| {
(
slot.host_image_id,
lease.path().to_string_lossy().into_owned(),
lease.len() as u64,
lease.fingerprint(),
)
})
})
});
if let (Some(key), Some((image_id, path, expected_len, transfer_id))) =
(direct_key.clone(), direct_frame)
{
let mut next_cache = self
.clients
.get(&client_id)
.map(|client| client.graphics_cache.clone())
.unwrap_or_default();
let command = (!internal_changed)
.then(|| {
crate::kitty_graphics::prepare_direct_file(
&self.app.state,
&self.app.pane_graphics,
self.app.state.view.tab_surface(),
self.clients
.get(&client_id)
.map(|client| client.cell_size)
.unwrap_or_default(),
&mut next_cache,
&key,
)
})
.flatten();
let Some(command) = command else {
if self.install_inline_fallback(&key) {
if msg.respond_to.send(response).is_err() {
self.retire_direct_gate(&key);
}
return if internal_changed {
RenderImpact::Full
} else {
RenderImpact::Graphics
};
}
self.retire_direct_gate(&key);
return if internal_changed {
RenderImpact::Full
} else {
RenderImpact::Graphics
};
};
let message = ServerMessage::GraphicsFile {
path,
expected_len,
image_id,
transfer_id,
leading: command.leading,
control: command.control,
};
let send =
Self::frame_server_message_with_max(&message, MAX_GRAPHICS_FRAME_SIZE)
.map_err(|_| std::sync::mpsc::TrySendError::Full(Vec::new()))
.and_then(|framed| {
let Some(writer) = self
.clients
.get(&client_id)
.and_then(|client| client.writer.as_ref())
else {
return Err(std::sync::mpsc::TrySendError::Disconnected(
framed,
));
};
writer.render.send_ordered(framed)
});
match send {
Ok(()) => {
if let Some(slot) = self.app.pane_graphics.slots.get_mut(&key) {
slot.direct_gate = Some(crate::app::pane_graphics::DirectGate {
transfer_id,
client_id,
deadline: std::time::Instant::now()
+ crate::app::pane_graphics::DIRECT_DELIVERY_TIMEOUT,
written: false,
success_response: response,
respond_to: msg.respond_to,
});
}
if let Some(client) = self.clients.get_mut(&client_id) {
client.graphics_cache = next_cache;
}
return if internal_changed {
RenderImpact::Full
} else {
RenderImpact::None
};
}
Err(error) => {
self.handle_unwritten_direct_failure(
&key,
response,
msg.respond_to,
error,
);
return RenderImpact::Graphics;
}
}
}
}
}
let response_failed = msg.respond_to.send(response).is_err();
if succeeded && response_failed {
if let Some(key) = direct_key {
self.retire_direct_gate(&key);
}
}
if internal_changed {
RenderImpact::Full
} else if succeeded {
@ -64,6 +199,276 @@ impl HeadlessServer {
}
}
fn install_inline_fallback(&mut self, key: &crate::app::pane_graphics::Key) -> bool {
let len = self
.app
.pane_graphics
.slots
.get(key)
.and_then(|slot| slot.layer.as_ref())
.and_then(crate::app::pane_graphics::Layer::direct_lease)
.map(crate::pane_graphics_files::Lease::len);
let Some(len) = len else {
return false;
};
if !self.app.pane_graphics.can_store_inline(key, len) {
return false;
}
let data = self
.app
.pane_graphics
.slots
.get(key)
.and_then(|slot| slot.layer.as_ref())
.and_then(crate::app::pane_graphics::Layer::direct_lease)
.and_then(|lease| lease.copy_rgba().ok());
let Some(data) = data else {
return false;
};
let slot = self
.app
.pane_graphics
.slots
.get_mut(key)
.expect("matched slot");
if !slot.stream_is_active() {
return false;
}
let layer = slot.layer.take().expect("direct layer");
slot.layer = Some(crate::app::pane_graphics::Layer::inline(
layer.format,
layer.image_width,
layer.image_height,
data,
layer.render,
layer.z_index,
));
self.app.pane_graphics.mark_changed();
true
}
pub(super) fn handle_unwritten_direct_failure(
&mut self,
key: &crate::app::pane_graphics::Key,
response: String,
respond_to: std::sync::mpsc::Sender<String>,
error: std::sync::mpsc::TrySendError<Vec<u8>>,
) -> bool {
if matches!(error, std::sync::mpsc::TrySendError::Full(_))
&& self.install_inline_fallback(key)
&& respond_to.send(response).is_ok()
{
return true;
}
self.retire_direct_gate(key);
false
}
pub(super) fn retire_direct_gate(&mut self, key: &crate::app::pane_graphics::Key) {
if self.app.pane_graphics.slots.remove(key).is_some() {
self.app.pane_graphics.mark_changed();
}
}
pub(super) fn start_direct_graphics_response(
&mut self,
client_id: u64,
transfer_id: u64,
image_id: u32,
) -> bool {
if let Some(gate) = self.app.pane_graphics.slots.values_mut().find_map(|slot| {
(slot.host_image_id == image_id && slot.stream_is_active())
.then_some(slot.direct_gate.as_mut())
.flatten()
.filter(|gate| gate.client_id == client_id && gate.transfer_id == transfer_id)
}) {
gate.written = true;
gate.deadline =
std::time::Instant::now() + crate::app::pane_graphics::DIRECT_RESPONSE_TIMEOUT;
}
false
}
pub(super) fn complete_direct_graphics(
&mut self,
client_id: u64,
transfer_id: u64,
image_id: u32,
success: bool,
) -> bool {
if self.shutting_down {
self.retire_all_direct_graphics();
return false;
}
let key = self.app.pane_graphics.slots.iter().find_map(|(key, slot)| {
slot.direct_gate
.as_ref()
.is_some_and(|gate| {
slot.stream_is_active()
&& gate.client_id == client_id
&& gate.transfer_id == transfer_id
&& slot.host_image_id == image_id
&& (!success || gate.written)
})
.then(|| key.clone())
});
let Some(key) = key else {
return false;
};
let pane_is_live = self
.app
.state
.workspaces
.iter()
.any(|workspace| workspace.pane_state(key.0).is_some());
if !pane_is_live {
self.retire_direct_gate(&key);
return false;
}
if success {
let (gate, host_image_id) = {
let slot = self
.app
.pane_graphics
.slots
.get_mut(&key)
.expect("matched slot");
if !slot.stream_is_active() {
return false;
}
if let Some(layer) = slot.layer.as_mut() {
layer.mark_resident(client_id);
}
(
slot.direct_gate.take().expect("matched gate"),
slot.host_image_id,
)
};
if let (Some(client), Some(layer)) = (
self.clients.get_mut(&client_id),
self.app
.pane_graphics
.slots
.get(&key)
.and_then(|slot| slot.layer.as_ref()),
) {
client
.graphics_cache
.trust_pane_layer(&key, host_image_id, layer);
}
if gate.respond_to.send(gate.success_response).is_err() {
self.retire_direct_gate(&key);
return true;
}
return true;
}
if let Some(client) = self.clients.get_mut(&client_id) {
client.direct_graphics = false;
client.graphics_cache.forget_pane_layer(&key, image_id);
}
self.app.direct_graphics_available = false;
if !self.install_inline_fallback(&key) {
self.retire_direct_gate(&key);
self.retire_all_direct_graphics();
return true;
}
let gate = self
.app
.pane_graphics
.slots
.get_mut(&key)
.filter(|slot| slot.stream_is_active())
.and_then(|slot| slot.direct_gate.take());
let Some(gate) = gate else {
self.retire_direct_gate(&key);
return true;
};
if gate.respond_to.send(gate.success_response).is_err() {
self.retire_direct_gate(&key);
return true;
}
self.retire_all_direct_graphics();
true
}
pub(super) fn expire_direct_graphics(&mut self, now: std::time::Instant) -> bool {
let expired = self
.app
.pane_graphics
.slots
.iter()
.filter_map(|(key, slot)| {
slot.direct_gate
.as_ref()
.filter(|gate| gate.deadline <= now)
.map(|gate| (key.clone(), gate.client_id))
})
.collect::<Vec<_>>();
for (key, client_id) in &expired {
if let Some(slot) = self.app.pane_graphics.slots.get(key) {
if let Some(gate) = &slot.direct_gate {
self.send_to_client(
*client_id,
ServerMessage::GraphicsTransmissionRetired {
transfer_id: gate.transfer_id,
image_id: slot.host_image_id,
},
);
}
}
if let Some(client) = self.clients.get_mut(client_id) {
client.direct_graphics = false;
}
self.retire_direct_gate(key);
}
if !expired.is_empty() {
self.app.direct_graphics_available = false;
self.retire_all_direct_graphics();
}
!expired.is_empty()
}
pub(super) fn retire_all_direct_graphics(&mut self) {
let keys = self
.app
.pane_graphics
.slots
.iter()
.filter(|(_, slot)| {
slot.layer
.as_ref()
.is_some_and(crate::app::pane_graphics::Layer::terminal_only)
})
.map(|(key, _)| key.clone())
.collect::<Vec<_>>();
for key in keys {
self.retire_direct_gate(&key);
}
}
pub(super) fn retire_direct_graphics_for_client(&mut self, client_id: u64) {
let keys = self
.app
.pane_graphics
.slots
.iter()
.filter_map(|(key, slot)| {
let layer = slot.layer.as_ref()?;
let owned = layer.resident_client() == Some(client_id)
|| (layer.direct_lease().is_some()
&& slot
.direct_gate
.as_ref()
.is_some_and(|gate| gate.client_id == client_id));
(layer.terminal_only() && owned).then(|| key.clone())
})
.collect::<Vec<_>>();
for key in keys {
self.retire_direct_gate(&key);
}
}
pub(super) fn render_retained_graphics_update_and_stream(&mut self) -> RetainedGraphicsOutcome {
crate::render_prof::event("retained_graphics.attempt");
if self.app.full_redraw_pending {
@ -117,74 +522,62 @@ impl HeadlessServer {
let mut next_graphics_cache = client.graphics_cache.clone();
let encode_started = crate::render_prof::timer();
let bytes =
frame_pane_graphics_for_client(crate::kitty_graphics::encode_local_pane_graphics(
&self.app.state,
&self.app.terminal_runtimes,
self.app.state.view.tab_surface(),
cell_size,
&mut next_graphics_cache,
));
let encoded = crate::kitty_graphics::encode_local_pane_graphics(
&self.app.state,
&self.app.pane_graphics,
&self.app.terminal_runtimes,
self.app.state.view.tab_surface(),
cell_size,
Some(crate::kitty_graphics::HEADLESS_GRAPHICS_TRANSACTION_BUDGET),
&mut next_graphics_cache,
);
crate::render_prof::duration_since("retained_graphics.graphics_encode", encode_started);
if bytes.len() > MAX_GRAPHICS_FRAME_SIZE {
warn!(
client_id,
graphics_bytes = bytes.len(),
max = MAX_GRAPHICS_FRAME_SIZE,
"dropping oversized retained graphics payload"
);
continue;
}
let serialized = if bytes.is_empty() {
None
} else {
let serialize_started = crate::render_prof::timer();
let framed = match Self::frame_server_message_with_max(
&ServerMessage::Graphics { bytes },
MAX_GRAPHICS_FRAME_SIZE,
) {
Ok(framed) => framed,
Err(err) => {
warn!(client_id, err = %err, "failed to serialize retained graphics");
return RetainedGraphicsOutcome::Fallback;
}
};
crate::render_prof::duration_since(
"retained_graphics.serialize",
serialize_started,
);
Some(framed)
};
prepared.push((client_id, serialized, next_graphics_cache));
prepared.push((client_id, encoded, next_graphics_cache));
}
let mut broken_clients = Vec::new();
for (client_id, serialized, next_graphics_cache) in prepared {
for (client_id, encoded, next_graphics_cache) in prepared {
let Some(client) = self.clients.get_mut(&client_id) else {
continue;
};
let Some(serialized) = serialized else {
client.graphics_cache = next_graphics_cache;
client.clear_deferred_render();
continue;
let serialized = if encoded.bytes.is_empty() {
None
} else {
match Self::frame_server_message_with_max(
&ServerMessage::Graphics {
bytes: frame_pane_graphics(encoded.bytes),
},
MAX_GRAPHICS_FRAME_SIZE,
) {
Ok(serialized) => Some(serialized),
Err(_) => {
crate::render_prof::event("retained_graphics_fallback.oversized");
return RetainedGraphicsOutcome::Fallback;
}
}
};
let Some(writer) = client.writer.as_ref().cloned() else {
broken_clients.push(client_id);
continue;
let result = match (serialized, client.writer.as_ref()) {
(None, _) => Ok(()),
(Some(bytes), Some(writer)) => writer.render.try_send(bytes),
(Some(bytes), None) => Err(std::sync::mpsc::TrySendError::Disconnected(bytes)),
};
match writer.render.try_send(serialized) {
match result {
Ok(()) => {
client.graphics_cache = next_graphics_cache;
client.clear_deferred_render();
if encoded.incomplete {
client.defer_full_render();
deferred = true;
} else {
client.clear_deferred_render();
}
crate::render_prof::event("retained_graphics.sent");
}
Err(std::sync::mpsc::TrySendError::Full(_)) => {
client.defer_pane_graphics_render();
client.defer_full_render();
deferred = true;
crate::render_prof::event("retained_graphics.deferred");
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
broken_clients.push(client_id);
broken_clients.push(client_id)
}
}
}

View File

@ -1,12 +1,52 @@
use super::*;
#[test]
fn frames_preserve_cursor_without_changing_ordinary_messages() {
assert_eq!(frame_pane_graphics_for_client(Vec::new()), Vec::<u8>::new());
assert_eq!(
frame_pane_graphics_for_client(b"graphics".to_vec()),
b"\x1b7graphics\x1b8"
);
fn receive_render(receiver: &std::sync::mpsc::Receiver<Vec<u8>>, timeout: Duration) -> Vec<u8> {
receiver.recv_timeout(timeout).unwrap()
}
#[tokio::test]
async fn cold_redraw_advances_one_bounded_layer_after_each_send() {
let (mut server, client_rx, pane_id) = retained_test_server(b"cold redraw");
server.app.state.kitty_graphics_enabled = true;
server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize {
width_px: 10,
height_px: 20,
};
const LAYERS: usize = 8;
for index in 0..LAYERS {
set_named_graphics_layer(
&mut server,
pane_id,
&format!("layer-{index:02}"),
vec![index as u8; 1024 * 1024],
index as i32,
);
}
fill_render_lane(&server);
server.render_and_stream();
assert!(server.clients[&1].graphics_cache.is_empty());
let _older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap();
for expected in 1..=LAYERS {
server.render_and_stream();
let bytes = client_rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(bytes.len() <= MAX_GRAPHICS_FRAME_SIZE + 4);
let frame = read_server_frame(bytes);
assert_eq!(
frame
.graphics
.windows(4)
.filter(|part| *part == b"a=t,")
.count(),
1
);
assert_eq!(
server.clients[&1].graphics_cache.test_image_count(),
expected
);
}
assert_eq!(server.clients[&1].deferred_render(), DeferredRender::None);
}
fn enable_graphics_and_render(
@ -19,39 +59,72 @@ fn enable_graphics_and_render(
height_px: 20,
};
server.render_and_stream();
read_server_frame(
client_rx
.recv_timeout(Duration::from_millis(100))
.expect("initial frame"),
)
read_server_frame(receive_render(client_rx, Duration::from_millis(100)))
}
fn graphics_key(pane_id: crate::layout::PaneId) -> crate::app::pane_graphics::Key {
(pane_id, api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID.into())
}
fn active_gate() -> std::sync::Arc<std::sync::atomic::AtomicBool> {
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true))
}
fn set_graphics_layer(server: &mut HeadlessServer, pane_id: crate::layout::PaneId, data: Vec<u8>) {
server.app.state.pane_graphics_layers.insert(
set_named_graphics_layer(
server,
pane_id,
crate::app::state::PaneGraphicsLayer::new(
api::schema::PaneGraphicsFormat::Png,
1,
1,
data,
api::schema::PaneGraphicsPlacementParams::default(),
),
api::schema::PANE_GRAPHICS_PRIMARY_LAYER_ID,
data,
0,
);
}
fn set_named_graphics_layer(
server: &mut HeadlessServer,
pane_id: crate::layout::PaneId,
layer_id: &str,
data: Vec<u8>,
z_index: i32,
) {
let key = (pane_id, layer_id.into());
let host_image_id = server.app.pane_graphics.reserve_image_id(&key).unwrap();
let layer = crate::app::pane_graphics::Layer::inline(
api::schema::PaneGraphicsFormat::Png,
1,
1,
data,
Default::default(),
z_index,
);
server.app.pane_graphics.slots.insert(
key,
crate::app::pane_graphics::Slot::test(host_image_id, Some(layer)),
);
}
fn set_stream_owner(server: &mut HeadlessServer, pane_id: crate::layout::PaneId, owner: &str) {
let key = graphics_key(pane_id);
if let Some(slot) = server.app.pane_graphics.slots.get_mut(&key) {
slot.stream_owner = Some(owner.into());
slot.stream_active = Some(active_gate());
} else {
let host_image_id = server.app.pane_graphics.reserve_image_id(&key).unwrap();
let mut slot = crate::app::pane_graphics::Slot::test(host_image_id, None);
slot.stream_owner = Some(owner.into());
slot.stream_active = Some(active_gate());
server.app.pane_graphics.slots.insert(key, slot);
}
}
fn fill_render_lane(server: &HeadlessServer) {
let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
.expect("dummy frame");
server
.clients
.get(&1)
.unwrap()
server.clients[&1]
.writer
.as_ref()
.unwrap()
.render
.try_send(queued)
.expect("pre-fill render lane");
.test_fill_render(queued);
}
fn stream_set_message(
@ -68,6 +141,8 @@ fn stream_set_message(
method: api::schema::Method::PaneGraphicsStreamSet(
api::schema::PaneGraphicsSetParams {
pane_id: pane_id.into(),
layer_id: None,
z_index: 0,
owner: owner.into(),
format: api::schema::PaneGraphicsFormat::Png,
image_width: 1,
@ -80,11 +155,114 @@ fn stream_set_message(
},
respond_to,
response_write_complete: None,
stream_active: None,
},
response_rx,
)
}
#[tokio::test]
async fn pixel_mouse_activation_requires_graphics_demand_not_direct_transport() {
let (mut server, _client_rx, pane_id) =
retained_test_server(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h");
let (writer, control_rx, _render_rx) = test_client_writer();
let client = server.clients.get_mut(&1).unwrap();
client.writer = Some(writer);
client.direct_graphics = false;
client.pixel_mouse = true;
client.host_mouse_capture_active = None;
client.host_sgr_pixels_active = None;
server.app.direct_graphics_available = false;
server.stream_host_mouse_capture_mode();
assert!(matches!(
read_server_message(control_rx.recv_timeout(Duration::from_millis(100)).unwrap()),
ServerMessage::MouseCapture {
enabled: true,
sgr_pixels: false
}
));
set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]);
server.stream_host_mouse_capture_mode();
assert!(matches!(
read_server_message(control_rx.recv_timeout(Duration::from_millis(100)).unwrap()),
ServerMessage::MouseCapture {
enabled: true,
sgr_pixels: true
}
));
}
#[tokio::test]
async fn pixel_input_metadata_cannot_resize_authoritative_client_state() {
let (mut server, _client_rx, pane_id) =
retained_test_server(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h");
set_graphics_layer(&mut server, pane_id, vec![1]);
let client = server.clients.get_mut(&1).unwrap();
client.pixel_mouse = true;
client.host_sgr_pixels_active = Some(true);
server.foreground_client_id = None;
assert!(!server.handle_server_event(ServerEvent::ClientInputPixels {
client_id: 1,
data: b"\x1b[<0;500;300M".to_vec(),
geometry: crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(),
}));
server.clients.get_mut(&1).unwrap().cell_size = crate::kitty_graphics::HostCellSize {
width_px: 10,
height_px: 20,
};
for (geometry, data) in [
(
crate::input::mouse::HostGeometry::new(100, 30, 1_000, 600).unwrap(),
b"\x1b[<0;500;300M".as_slice(),
),
(
crate::input::mouse::HostGeometry::new(80, 24, 960, 480).unwrap(),
b"\x1b[<0;500;300M",
),
(
crate::input::mouse::HostGeometry::new(80, 24, 800, 480).unwrap(),
b"\x1b[<0;0;1M",
),
] {
assert!(!server.handle_server_event(ServerEvent::ClientInputPixels {
client_id: 1,
data: data.to_vec(),
geometry,
}));
}
assert_eq!(server.clients[&1].terminal_size, (80, 24));
assert_eq!(
(server.effective_size, server.foreground_client_id),
((80, 24), None)
);
}
#[test]
fn direct_eligibility_is_installed_with_the_client_connection() {
let mut server = test_headless_server();
let (writer, _control_rx, _render_rx) = test_client_writer();
assert!(server.handle_server_event(ServerEvent::ClientConnected {
client_id: 7,
cols: 80,
rows: 24,
cell_width_px: 10,
cell_height_px: 20,
render_encoding: RenderEncoding::SemanticFrame,
keybindings: None,
direct_attach_requested: false,
direct_graphics: true,
writer,
}));
let client = server.clients.get(&7).expect("connected client");
assert!(client.direct_graphics);
assert_eq!(server.foreground_client_id, Some(7));
assert!(server.app.direct_graphics_available);
}
#[tokio::test]
async fn focus_repaint_preserves_uploaded_graphics() {
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
@ -109,11 +287,8 @@ async fn focus_repaint_preserves_uploaded_graphics() {
let initial_graphics = String::from_utf8_lossy(&initial.graphics);
assert!(initial_graphics.contains("a=t"));
assert!(initial_graphics.contains("a=p"));
let client_2_initial = read_server_frame(
client_2_rx
.recv_timeout(Duration::from_millis(100))
.expect("second client initial frame"),
);
let client_2_initial =
read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100)));
assert!(String::from_utf8_lossy(&client_2_initial.graphics).contains("a=t"));
assert!(server.handle_server_event(ServerEvent::ClientInput {
@ -123,12 +298,68 @@ async fn focus_repaint_preserves_uploaded_graphics() {
assert_eq!(server.foreground_client_id, Some(2));
server.render_and_stream();
let focused = read_server_frame(
client_2_rx
.recv_timeout(Duration::from_millis(100))
.expect("focus redraw"),
let focused = read_server_frame(receive_render(&client_2_rx, Duration::from_millis(100)));
let focused_graphics = String::from_utf8_lossy(&focused.graphics);
assert!(focused_graphics.contains("a=p"));
assert!(!focused_graphics.contains("a=t"));
}
#[tokio::test]
async fn resize_replays_placement_without_retransmitting_or_closing_stream() {
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]);
set_stream_owner(&mut server, pane_id, "owner-resize");
let initial = enable_graphics_and_render(&mut server, &client_rx);
assert!(String::from_utf8_lossy(&initial.graphics).contains("a=t"));
for (cols, rows, cell_width_px, cell_height_px) in
[(100, 30, 10, 20), (100, 30, 12, 24), (100, 30, 12, 24)]
{
assert!(server.handle_server_event(ServerEvent::ClientResize {
client_id: 1,
cols,
rows,
cell_width_px,
cell_height_px,
}));
server.render_and_stream();
let frame = read_server_frame(receive_render(&client_rx, Duration::from_millis(100)));
let graphics = String::from_utf8_lossy(&frame.graphics);
assert!(!graphics.contains("a=t"));
assert!(graphics.contains("a=p"));
}
assert_eq!(
server
.app
.pane_graphics
.slots
.get(&graphics_key(pane_id))
.and_then(|slot| slot.stream_owner.as_deref()),
Some("owner-resize")
);
assert!(focused.graphics.is_empty());
}
#[tokio::test]
async fn graphics_pruning_preserves_live_panes_and_removes_closed_panes() {
let (mut server, _client_rx, pane_id) = retained_test_server(b"aaaa");
set_graphics_layer(&mut server, pane_id, vec![1, 2, 3]);
assert!(!server
.app
.pane_graphics
.retain_live_panes(&server.app.state));
assert!(server
.app
.pane_graphics
.slots
.contains_key(&graphics_key(pane_id)));
server.app.state.workspaces.clear();
assert!(server
.app
.pane_graphics
.retain_live_panes(&server.app.state));
assert!(server.app.pane_graphics.slots.is_empty());
}
#[tokio::test]
@ -164,58 +395,25 @@ async fn retained_update_sends_only_graphics_message() {
}
#[tokio::test]
async fn retained_update_defers_on_full_render_lane() {
async fn retained_graphics_stays_ordered_after_an_older_render() {
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
let _ = enable_graphics_and_render(&mut server, &client_rx);
fill_render_lane(&server);
set_graphics_layer(&mut server, pane_id, vec![4, 5, 6]);
let older = client_rx.recv_timeout(Duration::from_secs(1)).unwrap();
assert_eq!(
server.render_retained_graphics_update_and_stream(),
RetainedGraphicsOutcome::Deferred
RetainedGraphicsOutcome::Sent
);
let client = server.clients.get(&1).unwrap();
assert_eq!(client.deferred_render(), DeferredRender::Graphics);
let graphics = client_rx.recv_timeout(Duration::from_secs(1)).unwrap();
assert!(matches!(
read_server_message(client_rx.recv_timeout(Duration::from_millis(100)).unwrap()),
read_server_message(older),
ServerMessage::ReloadSoundConfig
));
assert_eq!(
server.handle_server_event_with_render_impact(ServerEvent::ClientWriterDrained {
client_id: 1
}),
RenderImpact::Graphics
);
}
#[tokio::test]
async fn retained_update_does_not_downgrade_pending_full_render() {
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
let _ = enable_graphics_and_render(&mut server, &client_rx);
fill_render_lane(&server);
let client = server.clients.get_mut(&1).unwrap();
client.request_repaint();
server.render_and_stream();
assert_eq!(
server.clients.get(&1).unwrap().deferred_render(),
DeferredRender::Full
);
set_graphics_layer(&mut server, pane_id, vec![7, 8, 9]);
assert_eq!(
server.render_retained_graphics_update_and_stream(),
RetainedGraphicsOutcome::Deferred
);
assert_eq!(
server.clients.get(&1).unwrap().deferred_render(),
DeferredRender::Full
);
assert_eq!(
server.handle_server_event_with_render_impact(ServerEvent::ClientWriterDrained {
client_id: 1
}),
RenderImpact::Full
);
assert!(matches!(
read_server_message(graphics),
ServerMessage::Graphics { .. }
));
}
#[tokio::test]
@ -246,6 +444,64 @@ async fn retained_update_falls_back_for_mixed_app_geometry() {
);
}
#[test]
fn stream_open_gate_is_owned_by_the_layer_and_cancels_on_removal() {
let mut server = test_headless_server();
server.app.state.kitty_graphics_enabled = true;
let workspace = crate::workspace::Workspace::test_new("gated");
let pane_id = workspace.tabs[0].root_pane;
let public = format!("{}:p1", workspace.id);
server.app.state.workspaces = vec![workspace];
server.app.state.active = Some(0);
let active = active_gate();
let (respond_to, response_rx) = std::sync::mpsc::channel();
server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
request: api::schema::Request {
id: "open-gated".into(),
method: api::schema::Method::PaneGraphicsStreamOpen(
api::schema::PaneGraphicsStreamParams {
pane_id: public.clone(),
layer_id: None,
z_index: 0,
owner: "worker-1".into(),
},
),
},
respond_to,
response_write_complete: None,
stream_active: Some(active.clone()),
});
assert!(
serde_json::from_str::<api::schema::SuccessResponse>(&response_rx.recv().unwrap()).is_ok()
);
let (frame, frame_response) =
stream_set_message("gated-frame", &public, "worker-1", vec![1, 2, 3]);
assert_eq!(
server.handle_api_request_with_render_impact(frame),
RenderImpact::Graphics
);
assert!(frame_response.recv().is_ok());
assert!(active.load(std::sync::atomic::Ordering::Acquire));
active.store(false, std::sync::atomic::Ordering::Release);
let (delayed, delayed_response) =
stream_set_message("delayed-frame", &public, "worker-1", vec![4, 5, 6]);
assert_eq!(
server.handle_api_request_with_render_impact(delayed),
RenderImpact::None
);
let error: api::schema::ErrorResponse =
serde_json::from_str(&delayed_response.recv().unwrap()).unwrap();
assert_eq!(error.error.code, "stream_closed");
assert!(server
.app
.pane_graphics
.slots
.remove(&graphics_key(pane_id))
.is_some());
assert!(!active.load(std::sync::atomic::Ordering::Acquire));
}
#[test]
fn stream_set_has_graphics_only_render_impact() {
let mut server = test_headless_server();
@ -256,11 +512,7 @@ fn stream_set_has_graphics_only_render_impact() {
server.app.state.active = Some(0);
server.app.state.selected = 0;
server.app.state.kitty_graphics_enabled = true;
server
.app
.state
.pane_graphics_streams
.insert(pane_id, "owner-a".into());
set_stream_owner(&mut server, pane_id, "owner-a");
let (request, response_rx) =
stream_set_message("wrong-owner", &public_pane_id, "owner-b", vec![1, 2, 3]);
@ -307,13 +559,15 @@ fn stream_set_has_graphics_only_render_impact() {
RenderImpact::Full
);
server.app.state.pane_graphics_streams.clear();
server.app.pane_graphics.clear();
let (respond_to, _response_rx) = std::sync::mpsc::channel();
let impact = server.handle_api_request_with_render_impact(api::ApiRequestMessage {
request: api::schema::Request {
id: "direct-frame".into(),
method: api::schema::Method::PaneGraphicsSet(api::schema::PaneGraphicsSetParams {
pane_id: public_pane_id,
layer_id: None,
z_index: 0,
owner: String::new(),
format: api::schema::PaneGraphicsFormat::Png,
image_width: 1,
@ -325,6 +579,7 @@ fn stream_set_has_graphics_only_render_impact() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert_eq!(impact, RenderImpact::Full);
}
@ -345,6 +600,8 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() {
id: "disabled-set".into(),
method: api::schema::Method::PaneGraphicsSet(api::schema::PaneGraphicsSetParams {
pane_id: public_pane_id.clone(),
layer_id: None,
z_index: 0,
owner: String::new(),
format: api::schema::PaneGraphicsFormat::Png,
image_width: 1,
@ -356,6 +613,7 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() {
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert!(!changed);
let response = response_rx
@ -370,11 +628,7 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() {
);
server.app.state.kitty_graphics_enabled = true;
server
.app
.state
.pane_graphics_streams
.insert(pane_id, "current-owner".into());
set_stream_owner(&mut server, pane_id, "current-owner");
let (respond_to, response_rx) = std::sync::mpsc::channel();
let impact = server.handle_api_request_with_render_impact(api::ApiRequestMessage {
request: api::schema::Request {
@ -382,17 +636,25 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() {
method: api::schema::Method::PaneGraphicsStreamClose(
api::schema::PaneGraphicsStreamParams {
pane_id: public_pane_id,
layer_id: None,
z_index: 0,
owner: "stale-owner".into(),
},
),
},
respond_to,
response_write_complete: None,
stream_active: None,
});
assert_eq!(impact, RenderImpact::None);
assert_eq!(
server.app.state.pane_graphics_streams.get(&pane_id),
Some(&"current-owner".to_string())
server
.app
.pane_graphics
.slots
.get(&graphics_key(pane_id))
.and_then(|slot| slot.stream_owner.as_deref()),
Some("current-owner")
);
assert!(serde_json::from_str::<api::schema::SuccessResponse>(
&response_rx
@ -401,3 +663,282 @@ fn rejected_or_stale_requests_do_not_schedule_rendering() {
)
.is_ok());
}
#[cfg(unix)]
fn direct_gate_server(
data: &[u8],
) -> (
HeadlessServer,
crate::app::pane_graphics::Key,
std::sync::mpsc::Receiver<String>,
) {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut server = test_headless_server();
let workspace = crate::workspace::Workspace::test_new("direct-gate");
let pane_id = workspace.tabs[0].root_pane;
server.app.state.workspaces = vec![workspace];
server.app.state.active = Some(0);
let key = graphics_key(pane_id);
let path = server
.app
.pane_graphics_files
.source_directory()
.unwrap()
.join("gate-frame");
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.unwrap();
file.write_all(data).unwrap();
drop(file);
let lease = server
.app
.pane_graphics_files
.lease(&path, data.len())
.unwrap();
let (respond_to, response_rx) = std::sync::mpsc::channel();
let layer =
crate::app::pane_graphics::Layer::direct(1, 1, lease.clone(), Default::default(), 0);
let mut slot = crate::app::pane_graphics::Slot::test((1 << 31) | 900, Some(layer));
slot.stream_owner = Some("owner".into());
slot.stream_active = Some(active_gate());
slot.direct_gate = Some(crate::app::pane_graphics::DirectGate {
transfer_id: lease.fingerprint(),
client_id: 7,
deadline: std::time::Instant::now() + Duration::from_secs(1),
written: true,
success_response: "ack".into(),
respond_to,
});
server.app.pane_graphics.slots.insert(key.clone(), slot);
(server, key, response_rx)
}
#[cfg(unix)]
fn direct_ids(server: &HeadlessServer, key: &crate::app::pane_graphics::Key) -> (u64, u32) {
let slot = &server.app.pane_graphics.slots[key];
(
slot.direct_gate.as_ref().unwrap().transfer_id,
slot.host_image_id,
)
}
#[cfg(unix)]
fn add_direct_client(server: &mut HeadlessServer, client_id: u64) {
let (writer, control_rx, render_rx) = test_client_writer();
std::mem::forget((control_rx, render_rx));
let mut client = ClientConnection::new(
(80, 24),
crate::kitty_graphics::HostCellSize {
width_px: 10,
height_px: 20,
},
crate::terminal_theme::TerminalTheme::default(),
None,
1,
RenderEncoding::SemanticFrame,
Some(writer),
);
client.direct_graphics = true;
client.pixel_mouse = true;
server.clients.insert(client_id, client);
}
#[cfg(unix)]
#[test]
fn terminal_response_deadline_starts_only_after_client_flush() {
let (mut server, key, _response_rx) = direct_gate_server(&[1, 2, 3, 4]);
let slot = server.app.pane_graphics.slots.get_mut(&key).unwrap();
let gate = slot.direct_gate.as_mut().unwrap();
gate.written = false;
let (transfer_id, image_id) = (gate.transfer_id, slot.host_image_id);
assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true));
assert!(!server.start_direct_graphics_response(7, transfer_id, image_id));
let gate = server.app.pane_graphics.slots[&key]
.direct_gate
.as_ref()
.unwrap();
assert!(gate.written && gate.deadline > std::time::Instant::now());
}
#[cfg(unix)]
#[test]
fn outer_timeout_covers_both_direct_phases_and_cancellation_blocks_late_results() {
assert!(
crate::app::pane_graphics::DIRECT_OUTER_TIMEOUT
> crate::app::pane_graphics::DIRECT_DELIVERY_TIMEOUT
+ crate::app::pane_graphics::DIRECT_RESPONSE_TIMEOUT
);
let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
let slot = server.app.pane_graphics.slots.get_mut(&key).unwrap();
slot.stream_active
.as_ref()
.unwrap()
.store(false, std::sync::atomic::Ordering::Release);
let (transfer_id, image_id) = (
slot.direct_gate.as_ref().unwrap().transfer_id,
slot.host_image_id,
);
assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true));
assert!(response_rx.try_recv().is_err());
assert!(server.app.pane_graphics.slots[&key]
.layer
.as_ref()
.unwrap()
.direct_lease()
.is_some());
}
#[cfg(unix)]
#[test]
fn matching_terminal_ok_releases_producer_and_acknowledges() {
let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
let (transfer_id, image_id) = direct_ids(&server, &key);
assert!(server.complete_direct_graphics(7, transfer_id, image_id, true));
assert_eq!(response_rx.recv().unwrap(), "ack");
let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap();
assert!(layer.terminal_only());
assert!(layer.direct_lease().is_none());
}
#[cfg(unix)]
#[test]
fn explicit_terminal_error_acks_only_after_owned_inline_fallback() {
let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
add_direct_client(&mut server, 7);
let (transfer_id, image_id) = direct_ids(&server, &key);
let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap();
server
.clients
.get_mut(&7)
.unwrap()
.graphics_cache
.trust_pane_layer(&key, image_id, layer);
assert!(server.complete_direct_graphics(7, transfer_id, image_id, false));
let layer = server.app.pane_graphics.slots[&key].layer.as_ref().unwrap();
assert_eq!(
(
response_rx.recv().unwrap(),
layer.inline_data(),
server.clients[&7].direct_graphics,
server.clients[&7].pixel_mouse,
),
("ack".into(), Some([1, 2, 3, 4].as_slice()), false, true)
);
assert!(server.clients[&7].graphics_cache.is_empty());
}
#[cfg(unix)]
#[test]
fn unwritten_direct_full_falls_back_without_stickiness_but_disconnect_retires() {
for error in [
std::sync::mpsc::TrySendError::Full(Vec::new()),
std::sync::mpsc::TrySendError::Disconnected(Vec::new()),
] {
let should_ack = matches!(error, std::sync::mpsc::TrySendError::Full(_));
let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
add_direct_client(&mut server, 7);
let gate = server
.app
.pane_graphics
.slots
.get_mut(&key)
.and_then(|slot| slot.direct_gate.take())
.unwrap();
let result = server.handle_unwritten_direct_failure(
&key,
gate.success_response,
gate.respond_to,
error,
);
let inline = server
.app
.pane_graphics
.slots
.get(&key)
.and_then(|slot| slot.layer.as_ref()?.inline_data())
.is_some();
assert_eq!(
(
result,
response_rx.try_recv().ok().as_deref() == Some("ack"),
inline,
server.clients[&7].direct_graphics,
),
(should_ack, should_ack, should_ack, true)
);
}
}
#[cfg(unix)]
#[test]
fn client_loss_retires_only_its_direct_stream() {
let (mut pending, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
pending.retire_direct_graphics_for_client(8);
assert!(pending.app.pane_graphics.slots.contains_key(&key));
pending.retire_direct_graphics_for_client(7);
assert!(!pending.app.pane_graphics.slots.contains_key(&key));
assert!(response_rx.recv().is_err());
let (mut resident, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
let slot = resident.app.pane_graphics.slots.get(&key).unwrap();
assert!(resident.complete_direct_graphics(
7,
slot.direct_gate.as_ref().unwrap().transfer_id,
slot.host_image_id,
true,
));
assert_eq!(response_rx.recv().unwrap(), "ack");
resident.retire_direct_graphics_for_client(8);
assert!(resident.app.pane_graphics.slots.contains_key(&key));
resident.retire_direct_graphics_for_client(7);
assert!(!resident.app.pane_graphics.slots.contains_key(&key));
}
#[cfg(unix)]
#[test]
fn pane_removal_and_shutdown_drop_direct_without_ack() {
let setups: [fn(&mut HeadlessServer); 2] = [
|server| server.app.state.workspaces.clear(),
|server| server.shutting_down = true,
];
for setup in setups {
let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
let (transfer_id, image_id) = direct_ids(&server, &key);
setup(&mut server);
assert!(!server.complete_direct_graphics(7, transfer_id, image_id, true));
assert!(response_rx.recv().is_err());
assert!(!server.app.pane_graphics.slots.contains_key(&key));
}
}
#[cfg(unix)]
#[test]
fn timeout_retires_stream_without_producer_ack() {
let (mut server, key, response_rx) = direct_gate_server(&[1, 2, 3, 4]);
add_direct_client(&mut server, 7);
server
.app
.pane_graphics
.slots
.get_mut(&key)
.unwrap()
.direct_gate
.as_mut()
.unwrap()
.deadline = std::time::Instant::now() - Duration::from_millis(1);
assert!(server.expire_direct_graphics(std::time::Instant::now()));
assert!(response_rx.recv().is_err());
assert!(!server.app.pane_graphics.slots.contains_key(&key));
assert!(!server.clients[&7].direct_graphics);
assert!(server.clients[&7].pixel_mouse);
}

View File

@ -465,31 +465,32 @@ impl TerminalRuntime {
pub fn encode_mouse_button(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
self.0.encode_mouse_button(kind, column, row, modifiers)
self.0.encode_mouse_button(kind, position, modifiers)
}
pub fn encode_mouse_motion(
pub(crate) fn encode_mouse_motion(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
self.0.encode_mouse_motion(kind, column, row, modifiers)
self.0.encode_mouse_motion(kind, position, modifiers)
}
pub fn encode_mouse_wheel(
pub(crate) fn encode_mouse_wheel(
&self,
kind: crossterm::event::MouseEventKind,
column: u16,
row: u16,
position: crate::input::mouse::Position,
modifiers: crossterm::event::KeyModifiers,
) -> Option<Vec<u8>> {
self.0.encode_mouse_wheel(kind, column, row, modifiers)
self.0.encode_mouse_wheel(kind, position, modifiers)
}
pub(crate) fn pixel_size(&self) -> Option<(u32, u32)> {
self.0.pixel_size()
}
pub fn encode_alternate_scroll(