diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-08-09 02:04:39 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-09 02:04:39 +0300 |
| commit | 1777e9bba32b953ed1ad203b4a16d01105539000 (patch) | |
| tree | 22096973812c9baba822453d8470bdcfe64fc2a6 /src/input | |
| parent | 36a8d36a7258e08b40c01bf521b1f4006525f143 (diff) | |
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
Diffstat (limited to 'src/input')
| -rw-r--r-- | src/input/encode.rs | 4 | ||||
| -rw-r--r-- | src/input/mod.rs | 1 | ||||
| -rw-r--r-- | src/input/model.rs | 1 | ||||
| -rw-r--r-- | src/input/mouse.rs | 194 |
4 files changed, 198 insertions, 2 deletions
diff --git a/src/input/encode.rs b/src/input/encode.rs index c475e0da..8ec8a1c5 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -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' } diff --git a/src/input/mod.rs b/src/input/mod.rs index 0a716f9c..21893303 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,5 +1,6 @@ mod encode; mod model; +pub(crate) mod mouse; mod parse; #[allow(unused_imports)] diff --git a/src/input/model.rs b/src/input/model.rs index 9c3c14e7..21252d2d 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -317,6 +317,7 @@ pub enum MouseProtocolEncoding { Default, Utf8, Sgr, + SgrPixels, } #[cfg(test)] diff --git a/src/input/mouse.rs b/src/input/mouse.rs new file mode 100644 index 00000000..62b5f6c2 --- /dev/null +++ b/src/input/mouse.rs @@ -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); + } +} |
