Skip to main content

max / shop

Answer XTVERSION, the size reports, and the colour queries Completes the probe a client runs against a terminal it does not recognise. DA1 and the kitty query landed already; these are the rest of what yazi asks for, and between them they decide whether a preview is placed correctly rather than merely drawn. The three answers need facts the grid cannot derive: the renderer owns cell geometry, the theme owns the default colours, and the binary owns its own version. Rather than three setters, one `Identity` the binary keeps current. Cell size is physical pixels, so it is recomputed whenever the output scale moves. - XTVERSION (`CSI > q`) reports `shop(version)`, the form kitty and foot both use, which is what makes it parseable by whoever asks. - XTWINOPS 14, 16 and 18 report the text area in pixels, one cell in pixels, and the text area in cells. Cell size is the one that matters: a program placing an image needs it, and TIOCSWINSZ carries it only for the programs that read the ioctl. Height comes first in the reply, and a test pins that, because reversing it misplaces every image in a way that reads as a rendering bug. - OSC 10 and 11 report default foreground and background, query form only. This is how a program tells a light terminal from a dark one, so it decides whether anything that adapts to polarity adapts the right way. Answered with the terminator the question carried. The other XTWINOPS verbs move and resize windows. Those belong to the compositor and stay unimplemented, with a test asserting a resize request changes nothing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 19:37 UTC
Signed with PGP, not checked
Commit: 01860580aa68804c7260f86208afb78fadb59071
Parent: 1c22eee
2 files changed, +251 insertions, -1 deletion
@@ -214,6 +214,48 @@
214 214 pub resized: bool,
215 215 }
216 216
217 + /// What the terminal answers about itself.
218 + ///
219 + /// Every field here is something the grid cannot work out and a program can
220 + /// ask for: the renderer owns cell geometry, the theme owns the default
221 + /// colours, and the binary owns its own name. The grid holds them only so the
222 + /// query arms have something true to say, and the binary keeps them current
223 + /// through [`Grid::set_identity`].
224 + ///
225 + /// The defaults are shop's own, so a grid nobody configured still answers
226 + /// plausibly rather than answering zero.
227 + #[derive(Clone, Debug, PartialEq, Eq)]
228 + pub struct Identity {
229 + /// Reported by XTVERSION, as `name(version)`.
230 + pub name: String,
231 + pub version: String,
232 + /// One cell in physical pixels, width then height. Follows the output
233 + /// scale, so it changes when the window moves between displays.
234 + pub cell_px: (u16, u16),
235 + /// Default foreground and background, sRGB, as OSC 10 and 11 report them.
236 + pub fg: [u8; 3],
237 + pub bg: [u8; 3],
238 + }
239 +
240 + impl Default for Identity {
241 + fn default() -> Self {
242 + Self {
243 + name: "shop".into(),
244 + version: "0".into(),
245 + cell_px: (8, 17),
246 + fg: [0xe6, 0xde, 0xd3],
247 + bg: [0x25, 0x23, 0x1f],
248 + }
249 + }
250 + }
251 +
252 + /// One channel as OSC 10/11 want it: four hex digits, the 8-bit value
253 + /// doubled. `0x25` becomes `2525`, which is the 16-bit reading of the same
254 + /// intensity and what every terminal sends.
255 + fn osc_channel(v: u8) -> String {
256 + format!("{v:02x}{v:02x}")
257 + }
258 +
217 259 /// Cursor state (position + deferred-wrap flag).
218 260 #[derive(Copy, Clone, Debug, Default)]
219 261 pub struct Cursor {
@@ -287,6 +329,7 @@
287 329 cursor_keys_application: bool,
288 330 keypad_application: bool,
289 331 pending_title: Option<String>,
332 + identity: Identity,
290 333 // Bytes the terminal owes the program, from queries it answered. The grid
291 334 // has no handle on the PTY, so it queues and the binary drains after every
292 335 // parse, the way it already does for the title.
@@ -340,6 +383,7 @@
340 383 cursor_keys_application: false,
341 384 keypad_application: false,
342 385 pending_title: None,
386 + identity: Identity::default(),
343 387 pending_replies: Vec::new(),
344 388 // Initial state: everything dirty so first render populates the
345 389 // per-row cache.
@@ -437,6 +481,13 @@
437 481 self.pending_replies.extend_from_slice(bytes);
438 482 }
439 483
484 + /// Tell the grid what to say about itself. Call at startup and whenever
485 + /// any of it moves: cell size follows the output scale, and the colours
486 + /// follow the theme.
487 + pub fn set_identity(&mut self, identity: Identity) {
488 + self.identity = identity;
489 + }
490 +
440 491 pub fn cols(&self) -> u16 {
441 492 self.cols
442 493 }
@@ -1236,6 +1287,42 @@
1236 1287 // sixel, and claiming it means a client picks sixel over kitty
1237 1288 // graphics and draws nothing.
1238 1289 ('c', false) if intermediates.is_empty() => self.reply(b"\x1b[?62;22c"),
1290 + // XTVERSION. `DCS > | name(version) ST`, the form kitty and foot
1291 + // both answer in, which is what makes it parseable by the clients
1292 + // that ask.
1293 + ('q', false) if intermediates.first().copied() == Some(b'>') => {
1294 + let reply = format!(
1295 + "\x1bP>|{}({})\x1b\\",
1296 + self.identity.name, self.identity.version
1297 + );
1298 + self.reply(reply.as_bytes());
1299 + }
1300 + // XTWINOPS reports. Only the three read-only ones: the rest of
1301 + // this sequence moves and resizes windows, which is the
1302 + // compositor's business and not something a program on a PTY gets
1303 + // to do here.
1304 + //
1305 + // Sizes are physical pixels. Programs that place images need cell
1306 + // size in particular, and the ioctl that also carries it
1307 + // (TIOCSWINSZ) is not what all of them read.
1308 + ('t', false) if intermediates.is_empty() => {
1309 + let (cw, ch) = self.identity.cell_px;
1310 + match param1(params, 0) {
1311 + // Text area, in pixels.
1312 + 14 => {
1313 + let (w, h) = (self.cols * cw, self.rows * ch);
1314 + self.reply(format!("\x1b[4;{h};{w}t").as_bytes());
1315 + }
1316 + // One cell, in pixels. Height first, as the report orders it.
1317 + 16 => self.reply(format!("\x1b[6;{ch};{cw}t").as_bytes()),
1318 + // Text area, in cells.
1319 + 18 => {
1320 + let (rows, cols) = (self.rows, self.cols);
1321 + self.reply(format!("\x1b[8;{rows};{cols}t").as_bytes());
1322 + }
1323 + other => trace!("unhandled XTWINOPS {other}"),
1324 + }
1325 + }
1239 1326 ('S', false) => {
1240 1327 self.scroll_up_in_region(param1(params, 1));
1241 1328 }
@@ -1314,7 +1401,7 @@
1314 1401 }
1315 1402 }
1316 1403
1317 - fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
1404 + fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool) {
1318 1405 let Some(id) = params.first().and_then(|p| std::str::from_utf8(p).ok()) else {
1319 1406 return;
1320 1407 };
@@ -1328,6 +1415,30 @@
1328 1415 self.pending_title = Some(s.to_string());
1329 1416 }
1330 1417 }
1418 + // OSC 10 and 11, default foreground and background. Only the `?`
1419 + // query form: setting them is a separate feature, and answering a
1420 + // set request would be worse than ignoring it.
1421 + //
1422 + // Programs ask in order to tell light from dark, so this decides
1423 + // whether anything that adapts to the terminal's polarity adapts
1424 + // the right way. shop's theme knows the answer; nothing else does.
1425 + "10" | "11" if params.get(1) == Some(&b"?".as_slice()) => {
1426 + let c = if id == "10" {
1427 + self.identity.fg
1428 + } else {
1429 + self.identity.bg
1430 + };
1431 + let colour = format!(
1432 + "rgb:{}/{}/{}",
1433 + osc_channel(c[0]),
1434 + osc_channel(c[1]),
1435 + osc_channel(c[2])
1436 + );
1437 + // Terminated the way the question was. A client that asked
1438 + // with BEL may well be parsing for one.
1439 + let end: &str = if bell_terminated { "\x07" } else { "\x1b\\" };
1440 + self.reply(format!("\x1b]{id};{colour}{end}").as_bytes());
1441 + }
1331 1442 _ => {}
1332 1443 }
1333 1444 }
@@ -1747,6 +1858,109 @@
1747 1858 assert_cursor(&g, 0, 2);
1748 1859 }
1749 1860
1861 + // ---- identity queries ----------------------------------------------
1862 +
1863 + fn identified() -> Grid {
1864 + let mut g = Grid::new(80, 24);
1865 + g.set_identity(Identity {
1866 + name: "shop".into(),
1867 + version: "1.2.3".into(),
1868 + cell_px: (9, 20),
1869 + fg: [0xe6, 0xde, 0xd3],
1870 + bg: [0x25, 0x23, 0x1f],
1871 + });
1872 + g
1873 + }
1874 +
1875 + fn reply_to(g: &mut Grid, bytes: &[u8]) -> String {
1876 + feed(g, bytes);
1877 + String::from_utf8(g.take_pending_replies()).unwrap()
1878 + }
1879 +
1880 + #[test]
1881 + fn xtversion_names_the_terminal() {
1882 + let mut g = identified();
1883 + assert_eq!(reply_to(&mut g, b"\x1b[>q"), "\x1bP>|shop(1.2.3)\x1b\\");
1884 + }
1885 +
1886 + #[test]
1887 + fn cell_size_is_reported_height_first() {
1888 + // `CSI 6 ; height ; width t`. Getting the order wrong puts every
1889 + // image preview at the wrong aspect, which is the sort of bug that
1890 + // reads as a rendering problem rather than a reply problem.
1891 + let mut g = identified();
1892 + assert_eq!(reply_to(&mut g, b"\x1b[16t"), "\x1b[6;20;9t");
1893 + }
1894 +
1895 + #[test]
1896 + fn text_area_is_reported_in_both_units() {
1897 + let mut g = identified();
1898 + // 80x24 cells of 9x20 px.
1899 + assert_eq!(reply_to(&mut g, b"\x1b[14t"), "\x1b[4;480;720t");
1900 + assert_eq!(reply_to(&mut g, b"\x1b[18t"), "\x1b[8;24;80t");
1901 + }
1902 +
1903 + #[test]
1904 + fn cell_size_follows_the_identity() {
1905 + let mut g = identified();
1906 + g.set_identity(Identity {
1907 + cell_px: (18, 40),
1908 + ..Identity::default()
1909 + });
1910 + assert_eq!(reply_to(&mut g, b"\x1b[16t"), "\x1b[6;40;18t");
1911 + }
1912 +
1913 + #[test]
1914 + fn window_manipulation_is_not_obeyed() {
1915 + // The same sequence resizes and moves windows. Those are the
1916 + // compositor's, and a program on the PTY does not get to ask.
1917 + let mut g = identified();
1918 + assert_eq!(reply_to(&mut g, b"\x1b[3;0;0t"), "", "move window");
1919 + assert_eq!(reply_to(&mut g, b"\x1b[8;50;100t"), "", "resize window");
1920 + assert_eq!(g.cols(), 80);
1921 + assert_eq!(g.rows(), 24);
1922 + }
1923 +
1924 + #[test]
1925 + fn the_default_colours_are_answered_as_sixteen_bit() {
1926 + let mut g = identified();
1927 + assert_eq!(
1928 + reply_to(&mut g, b"\x1b]11;?\x1b\\"),
1929 + "\x1b]11;rgb:2525/2323/1f1f\x1b\\"
1930 + );
1931 + assert_eq!(
1932 + reply_to(&mut g, b"\x1b]10;?\x1b\\"),
1933 + "\x1b]10;rgb:e6e6/dede/d3d3\x1b\\"
1934 + );
1935 + }
1936 +
1937 + #[test]
1938 + fn a_colour_query_is_terminated_the_way_it_was_asked() {
1939 + let mut g = identified();
1940 + let bel = reply_to(&mut g, b"\x1b]11;?\x07");
1941 + assert!(bel.ends_with('\x07'), "got {bel:?}");
1942 + let st = reply_to(&mut g, b"\x1b]11;?\x1b\\");
1943 + assert!(st.ends_with("\x1b\\"), "got {st:?}");
1944 + }
1945 +
1946 + #[test]
1947 + fn setting_a_colour_is_not_mistaken_for_asking() {
1948 + // OSC 11 with a value is a set request. shop does not implement it,
1949 + // and answering it with the current colour would be a lie about
1950 + // having done something.
1951 + let mut g = identified();
1952 + assert_eq!(reply_to(&mut g, b"\x1b]11;#ff0000\x1b\\"), "");
1953 + }
1954 +
1955 + #[test]
1956 + fn identity_queries_leave_the_screen_alone() {
1957 + let mut g = identified();
1958 + feed(&mut g, b"hi");
1959 + let _ = reply_to(&mut g, b"\x1b[>q\x1b[16t\x1b]11;?\x1b\\");
1960 + assert_eq!(row_str(&g, 0), "hi");
1961 + assert_cursor(&g, 0, 2);
1962 + }
1963 +
1750 1964 #[test]
1751 1965 fn decckm_toggles_cursor_key_mode() {
1752 1966 let mut g = Grid::new(10, 3);
@@ -295,6 +295,8 @@
295 295 app.surface_config.width,
296 296 app.surface_config.height,
297 297 );
298 + let id = identity(&app.palette, app.scale);
299 + app.grid.set_identity(id);
298 300
299 301 // Wayland + PTY sources onto the loop built above.
300 302 WaylandSource::new(conn, event_queue)
@@ -396,6 +398,10 @@
396 398 let cols = grid_cols(logical_w);
397 399 let rows = grid_rows(logical_h);
398 400 app.grid.resize(cols, rows);
401 + // Scale may have moved since the last configure, and cell
402 + // size is reported in physical pixels.
403 + let id = identity(&app.palette, s);
404 + app.grid.set_identity(id);
399 405 let _ = app.pty.resize(PtySize {
400 406 cols,
401 407 rows,
@@ -628,6 +634,36 @@
628 634 }
629 635 }
630 636
637 + /// What the grid should answer about the terminal, at the current scale.
638 + ///
639 + /// Cell size is physical pixels, so it moves with the output scale and this
640 + /// has to be recomputed whenever that changes. The colours come from the
641 + /// theme, which is the only thing that knows them: a program asking OSC 11
642 + /// whether it is on a light or a dark terminal gets the wrong answer from
643 + /// anything else.
644 + fn identity(palette: &Palette, scale: u32) -> shop_grid::Identity {
645 + let s = scale.max(1) as f32;
646 + shop_grid::Identity {
647 + name: "shop".into(),
648 + version: env!("CARGO_PKG_VERSION").into(),
649 + cell_px: (
650 + (CELL_ADVANCE * s).round() as u16,
651 + (CELL_HEIGHT * s).round() as u16,
652 + ),
653 + fg: srgb_bytes(palette.fg),
654 + bg: srgb_bytes(palette.bg),
655 + }
656 + }
657 +
658 + /// A palette colour back as the three bytes the theme file spelled it with.
659 + fn srgb_bytes([r, g, b, _]: [f32; 4]) -> [u8; 3] {
660 + [
661 + (r * 255.0).round() as u8,
662 + (g * 255.0).round() as u8,
663 + (b * 255.0).round() as u8,
664 + ]
665 + }
666 +
631 667 fn grid_cols(px_w: u32) -> u16 {
632 668 let usable = (px_w as f32 - 2.0 * PAD_X).max(CELL_ADVANCE);
633 669 (usable / CELL_ADVANCE) as u16