brewery
notashelf /
92135ddbc13cd4c6de3d544796450f30ad319472

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 92135ddbc13c

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 20:29 UTC

unverified4 files changed+53-13
beer: report pixel geometry so graphics clients can size images

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4c11941c887bc75134200cd1471a792b6a6a6964
diff --git a/crates/beer/src/pty.rs b/crates/beer/src/pty.rsindex 7bb559e..5524b47 100644--- a/crates/beer/src/pty.rs+++ b/crates/beer/src/pty.rs@@ -19,9 +19,10 @@ pub struct Pty { }  impl Pty {-    /// Open a PTY, size it to `cols`x`rows`, and exec the user's login shell on-    /// the slave end with `TERM=term`.-    pub fn spawn(cols: u16, rows: u16, term: &str) -> anyhow::Result<Self> {+    /// Open a PTY, size it to `cols`x`rows` (with `cell` giving the cell size in+    /// pixels, so the kernel reports a pixel geometry for graphics clients), and+    /// exec the user's login shell on the slave end with `TERM=term`.+    pub fn spawn(cols: u16, rows: u16, cell: (u16, u16), term: &str) -> anyhow::Result<Self> {         let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)             .context("open pty master")?;         grantpt(&master).context("grantpt")?;@@ -29,7 +30,7 @@ impl Pty {         let slave = ioctl_tiocgptpeer(&master, OpenptFlags::RDWR | OpenptFlags::NOCTTY)             .context("open pty slave")?; -        set_winsize(&master, cols, rows)?;+        set_winsize(&master, cols, rows, cell)?;          let shell = std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into());         let argv0 = login_argv0(&shell);@@ -73,9 +74,10 @@ impl Pty {         &self.master     } -    /// Inform the kernel (and thus the child) of a new terminal size.-    pub fn resize(&self, cols: u16, rows: u16) -> anyhow::Result<()> {-        set_winsize(&self.master, cols, rows)+    /// Inform the kernel (and thus the child) of a new terminal size; `cell` is+    /// the cell size in pixels, carried into the winsize pixel fields.+    pub fn resize(&self, cols: u16, rows: u16, cell: (u16, u16)) -> anyhow::Result<()> {+        set_winsize(&self.master, cols, rows, cell)     }      /// Reap the child if it has exited.@@ -84,12 +86,14 @@ impl Pty {     } } -fn set_winsize(master: &OwnedFd, cols: u16, rows: u16) -> anyhow::Result<()> {+fn set_winsize(master: &OwnedFd, cols: u16, rows: u16, cell: (u16, u16)) -> anyhow::Result<()> {     let ws = Winsize {         ws_row: rows,         ws_col: cols,-        ws_xpixel: 0,-        ws_ypixel: 0,+        // The pixel geometry lets graphics-protocol clients size images; it is+        // the grid size times the cell size.+        ws_xpixel: cols.saturating_mul(cell.0),+        ws_ypixel: rows.saturating_mul(cell.1),     };     tcsetwinsize(master.as_fd(), ws).context("set pty winsize") }diff --git a/crates/beer/src/vt/mod.rs b/crates/beer/src/vt/mod.rsindex a64563c..9656472 100644--- a/crates/beer/src/vt/mod.rs+++ b/crates/beer/src/vt/mod.rs@@ -290,6 +290,20 @@ impl Term {         self.graphics.is_animating()     } +    /// Answer a `CSI 14/16/18 t` geometry query: `14` reports the text area in+    /// pixels (`CSI 4 ; h ; w t`), `16` the cell size in pixels (`CSI 6 ; …`),+    /// `18` the text area in characters (`CSI 8 ; …`). Graphics clients read+    /// these to size and place images.+    fn report_geometry(&mut self, kind: u16) {+        let (cw, ch) = self.cell_px;+        let (cols, rows) = (self.grid.cols() as u32, self.grid.rows() as u32);+        let _ = match kind {+            14 => write!(self.response, "\x1b[4;{};{}t", rows * ch, cols * cw),+            16 => write!(self.response, "\x1b[6;{ch};{cw}t"),+            _ => write!(self.response, "\x1b[8;{rows};{cols}t"),+        };+    }+     /// The working directory last reported by the shell (OSC 7), if any.     pub fn cwd(&self) -> Option<&str> {         self.cwd.as_deref()@@ -683,6 +697,19 @@ mod tests {     }      #[test]+    fn reports_pixel_geometry_for_graphics_clients() {+        // The test harness feeds with an 8x16 cell. A 20x4 grid is then 160x64+        // pixels. These answers are what an image client needs to size images.+        let mut t = Term::new(20, 4);+        feed(&mut t, b"\x1b[16t"); // cell size in pixels+        assert_eq!(t.take_response(), b"\x1b[6;16;8t");+        feed(&mut t, b"\x1b[14t"); // text area in pixels+        assert_eq!(t.take_response(), b"\x1b[4;64;160t");+        feed(&mut t, b"\x1b[18t"); // text area in cells+        assert_eq!(t.take_response(), b"\x1b[8;4;20t");+    }++    #[test]     fn kitty_unicode_placeholder_virtual_placement() {         // Transmit + a virtual placement (U=1): no cells are stamped, but the         // placement is registered for placeholder cells to reference.diff --git a/crates/beer/src/vt/perform.rs b/crates/beer/src/vt/perform.rsindex dd681da..effe0c0 100644--- a/crates/beer/src/vt/perform.rs+++ b/crates/beer/src/vt/perform.rs@@ -93,7 +93,12 @@ impl Perform for Term {                     .kitty_set(n(params, 0, 0) as u8, n(params, 1, 1) as u8),                 _ => self.grid.restore_cursor(),             },-            't' => self.title_stack_op(params),+            // `CSI 14/16/18 t` report pixel/character geometry (used by graphics+            // clients to size images); other `t` operations are title-stack ops.+            't' => match raw(params, 0) {+                14 | 16 | 18 => self.report_geometry(raw(params, 0)),+                _ => self.title_stack_op(params),+            },             'g' => match raw(params, 0) {                 3 => self.grid.clear_all_tabs(),                 _ => self.grid.clear_tab(),diff --git a/crates/beer/src/wayland/mod.rs b/crates/beer/src/wayland/mod.rsindex 8ffe255..b426db1 100644--- a/crates/beer/src/wayland/mod.rs+++ b/crates/beer/src/wayland/mod.rs@@ -575,7 +575,9 @@ impl App {     /// Spawn the shell at the current window size and start reading its output.     fn spawn_session(&mut self) {         let (cols, rows) = self.grid_dims();-        let pty = match Pty::spawn(cols, rows, &self.config.main.term) {+        let m = self.renderer.metrics();+        let cell = (m.width as u16, m.height as u16);+        let pty = match Pty::spawn(cols, rows, cell, &self.config.main.term) {             Ok(pty) => pty,             Err(err) => {                 tracing::error!("spawn shell: {err:#}");@@ -1857,6 +1859,8 @@ impl App {     /// PTY about it if it changed.     fn resize_grid(&mut self) {         let (cols, rows) = self.grid_dims();+        let m = self.renderer.metrics();+        let cell = (m.width as u16, m.height as u16);         let Some(session) = self.session.as_mut() else {             return;         };@@ -1866,7 +1870,7 @@ impl App {             return;         }         session.term.resize(cols as usize, rows as usize);-        if let Err(err) = session.pty.resize(cols, rows) {+        if let Err(err) = session.pty.resize(cols, rows, cell) {             tracing::warn!("resize pty: {err}");         }     }