Kitty Graphics Protocol in OCaml
terminal graphics ocaml

kgpcat

+357
+5
bin/dune
··· 1 + (executable 2 + (name kgpcat) 3 + (public_name kgpcat) 4 + (package kgp) 5 + (libraries kgp kgp.cli cmdliner unix))
+352
bin/kgpcat.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (* kgpcat - Display images in the terminal using Kitty Graphics Protocol *) 7 + 8 + open Cmdliner 9 + 10 + module K = Kgp 11 + 12 + type align = Center | Left | Right 13 + type fit = Width | Height | Both | None_ 14 + 15 + type config = { 16 + files : string list; 17 + clear : bool; 18 + clear_all : bool; 19 + detect_support : bool; 20 + align : align; [@warning "-69"] 21 + place : (int * int * int * int) option; 22 + scale_up : bool; [@warning "-69"] 23 + fit : fit; [@warning "-69"] 24 + z_index : int; 25 + unicode_placeholder : bool; 26 + no_trailing_newline : bool; 27 + hold : bool; 28 + graphics_mode : K.Terminal.graphics_mode; 29 + } 30 + 31 + (* Default image size in cells when not using --place *) 32 + let default_rows = 10 33 + let default_cols = 20 34 + 35 + (* Read file contents *) 36 + let read_file filename = 37 + let ic = open_in_bin filename in 38 + let n = in_channel_length ic in 39 + let s = really_input_string ic n in 40 + close_in ic; 41 + s 42 + 43 + (* Read from stdin *) 44 + let read_stdin () = 45 + let buf = Buffer.create 4096 in 46 + try 47 + while true do 48 + Buffer.add_channel buf stdin 4096 49 + done; 50 + assert false 51 + with End_of_file -> Buffer.contents buf 52 + 53 + (* Detect if stdin has data *) 54 + let stdin_has_data () = 55 + let fd = Unix.descr_of_in_channel stdin in 56 + not (Unix.isatty fd) 57 + 58 + (* Send command to terminal *) 59 + let send ?(tmux = false) cmd ~data = 60 + let s = if tmux then K.to_string_tmux cmd ~data else K.to_string cmd ~data in 61 + print_string s; 62 + flush stdout 63 + 64 + (* Check if file is a supported format (PNG only) *) 65 + let is_supported_format filename = 66 + let ext = 67 + try 68 + let dot = String.rindex filename '.' in 69 + String.lowercase_ascii (String.sub filename (dot + 1) (String.length filename - dot - 1)) 70 + with Not_found -> "" 71 + in 72 + ext = "png" || filename = "stdin" 73 + 74 + (* Display a single image *) 75 + let display_image config filename data = 76 + let resolved_mode = K.Terminal.resolve_mode config.graphics_mode in 77 + match resolved_mode with 78 + | `Placeholder -> 79 + (* No graphics support - show placeholder text *) 80 + Printf.printf "[Image: %s (%d bytes)]\n" filename (String.length data) 81 + | `Graphics | `Tmux -> 82 + let use_tmux = resolved_mode = `Tmux in 83 + (* Use unicode placeholders if requested or if in tmux mode *) 84 + let use_unicode = config.unicode_placeholder || use_tmux in 85 + let cols, rows = 86 + match config.place with 87 + | Some (w, h, _, _) -> (w, h) 88 + | None -> (default_cols, default_rows) 89 + in 90 + if use_unicode then ( 91 + (* Unicode placeholder mode: transmit image, then output placeholder chars *) 92 + let image_id = K.next_image_id () in 93 + let placement = 94 + match config.place with 95 + | Some (w, h, x, y) -> 96 + K.Placement.make ~columns:w ~rows:h ~cell_x_offset:x ~cell_y_offset:y 97 + ~z_index:config.z_index ~unicode_placeholder:true ~cursor:`Static () 98 + | None -> 99 + let z = if config.z_index <> 0 then Some config.z_index else None in 100 + K.Placement.make ~columns:cols ~rows ?z_index:z ~unicode_placeholder:true 101 + ~cursor:`Static () 102 + in 103 + (* Transmit the image data with virtual placement *) 104 + let cmd = 105 + K.transmit_and_display ~image_id ~format:`Png ~placement ~quiet:`Errors_only () 106 + in 107 + send ~tmux:use_tmux cmd ~data; 108 + (* Output unicode placeholder characters that reference the image *) 109 + let buf = Buffer.create 256 in 110 + K.Unicode_placeholder.write buf ~image_id ~rows ~cols (); 111 + print_string (Buffer.contents buf); 112 + if not config.no_trailing_newline then print_newline () 113 + ) else ( 114 + (* Direct graphics mode *) 115 + let image_id = K.next_image_id () in 116 + let placement = 117 + match config.place with 118 + | Some (w, h, x, y) -> 119 + Some 120 + (K.Placement.make ~columns:w ~rows:h ~cell_x_offset:x ~cell_y_offset:y 121 + ~z_index:config.z_index ~cursor:`Static ()) 122 + | None -> 123 + let z = if config.z_index <> 0 then Some config.z_index else None in 124 + Some 125 + (K.Placement.make ?z_index:z 126 + ~cursor:(if config.no_trailing_newline then `Static else `Move) ()) 127 + in 128 + let cmd = 129 + K.transmit_and_display ~image_id ~format:`Png ?placement ~quiet:`Errors_only () 130 + in 131 + send ~tmux:use_tmux cmd ~data; 132 + if not config.no_trailing_newline && config.place = None then print_newline () 133 + ) 134 + 135 + (* Clear all images *) 136 + let do_clear () = 137 + send (K.delete `All_visible) ~data:"" 138 + 139 + (* Clear all images including scrollback *) 140 + let do_clear_all () = 141 + (* Delete all images by ID range 1 to max, freeing data *) 142 + send (K.delete ~free:true (`By_id_range (1, 4294967295))) ~data:"" 143 + 144 + (* Detect terminal support *) 145 + let do_detect_support () = 146 + if K.Terminal.is_graphics_terminal () then ( 147 + let mode = 148 + if K.Terminal.is_tmux () then "tmux" 149 + else if K.Terminal.is_kitty () then "kitty" 150 + else if K.Terminal.is_wezterm () then "wezterm" 151 + else if K.Terminal.is_ghostty () then "ghostty" 152 + else "stream" 153 + in 154 + Printf.eprintf "%s\n" mode; 155 + 0 156 + ) else ( 157 + Printf.eprintf "not supported\n"; 158 + 1 159 + ) 160 + 161 + (* Main run function *) 162 + let run config = 163 + (* Handle clear operations first *) 164 + if config.clear_all then do_clear_all () 165 + else if config.clear then do_clear (); 166 + 167 + (* Handle detect support *) 168 + if config.detect_support then exit (do_detect_support ()); 169 + 170 + (* Process files *) 171 + let files = 172 + if config.files = [] && stdin_has_data () then [ "-" ] else config.files 173 + in 174 + if files = [] && not config.clear && not config.clear_all then ( 175 + Printf.eprintf "Usage: kgpcat [OPTIONS] IMAGE_FILE...\n"; 176 + Printf.eprintf "Try 'kgpcat --help' for more information.\n"; 177 + exit 1); 178 + 179 + List.iter 180 + (fun file -> 181 + let name = if file = "-" then "stdin" else file in 182 + if not (is_supported_format name) then 183 + Printf.eprintf "Error: %s is not a PNG file (only PNG format is supported)\n" file 184 + else 185 + try 186 + let data = 187 + if file = "-" then read_stdin () else read_file file 188 + in 189 + display_image config name data 190 + with 191 + | Sys_error msg -> 192 + Printf.eprintf "Error reading %s: %s\n" file msg 193 + | exn -> 194 + Printf.eprintf "Error processing %s: %s\n" file (Printexc.to_string exn)) 195 + files; 196 + 197 + (* Ensure all output is flushed before exiting *) 198 + flush stdout; 199 + 200 + if config.hold then ( 201 + Printf.eprintf "Press Enter to exit..."; 202 + flush stderr; 203 + ignore (read_line ())) 204 + 205 + (* Cmdliner argument definitions *) 206 + 207 + let files_arg = 208 + Arg.(value & pos_all file [] & info [] ~docv:"IMAGE" ~doc:"Image files to display.") 209 + 210 + let clear_arg = 211 + let doc = "Remove all images currently displayed on the screen." in 212 + Arg.(value & flag & info [ "clear" ] ~doc) 213 + 214 + let clear_all_arg = 215 + let doc = "Remove all images from screen and scrollback." in 216 + Arg.(value & flag & info [ "clear-all" ] ~doc) 217 + 218 + let detect_support_arg = 219 + let doc = 220 + "Detect support for image display in the terminal. Exits with code 0 if \ 221 + supported, 1 otherwise. Prints the supported transfer mode to stderr." 222 + in 223 + Arg.(value & flag & info [ "detect-support" ] ~doc) 224 + 225 + let align_arg = 226 + let doc = "Horizontal alignment for the displayed image." in 227 + let align_enum = Arg.enum [ ("center", Center); ("left", Left); ("right", Right) ] in 228 + Arg.(value & opt align_enum Center & info [ "align" ] ~doc ~docv:"ALIGN") 229 + 230 + let place_arg = 231 + let doc = 232 + "Display image in specified rectangle. Format: WxH@X,Y where W and H are \ 233 + width and height in cells, and X,Y is the position. Example: 40x20@10,5" 234 + in 235 + let parse s = 236 + try 237 + let at_pos = String.index s '@' in 238 + let size_part = String.sub s 0 at_pos in 239 + let pos_part = String.sub s (at_pos + 1) (String.length s - at_pos - 1) in 240 + let x_pos = String.index size_part 'x' in 241 + let w = int_of_string (String.sub size_part 0 x_pos) in 242 + let h = int_of_string (String.sub size_part (x_pos + 1) (String.length size_part - x_pos - 1)) in 243 + let comma_pos = String.index pos_part ',' in 244 + let x = int_of_string (String.sub pos_part 0 comma_pos) in 245 + let y = int_of_string (String.sub pos_part (comma_pos + 1) (String.length pos_part - comma_pos - 1)) in 246 + Ok (w, h, x, y) 247 + with _ -> Error (`Msg "Invalid place format. Use WxH@X,Y (e.g., 40x20@10,5)") 248 + in 249 + let print ppf (w, h, x, y) = Format.fprintf ppf "%dx%d@%d,%d" w h x y in 250 + let place_conv = Arg.conv (parse, print) in 251 + Arg.(value & opt (some place_conv) None & info [ "place" ] ~doc ~docv:"WxH@X,Y") 252 + 253 + let scale_up_arg = 254 + let doc = 255 + "Scale up images smaller than the specified area to use as much of the \ 256 + area as possible." 257 + in 258 + Arg.(value & flag & info [ "scale-up" ] ~doc) 259 + 260 + let fit_arg = 261 + let doc = "Control how the image is scaled relative to the screen." in 262 + let fit_enum = 263 + Arg.enum [ ("width", Width); ("height", Height); ("both", Both); ("none", None_) ] 264 + in 265 + Arg.(value & opt fit_enum Width & info [ "fit" ] ~doc ~docv:"FIT") 266 + 267 + let z_index_arg = 268 + let doc = 269 + "Z-index of the image. Negative values display text on top of the image." 270 + in 271 + Arg.(value & opt int 0 & info [ "z"; "z-index" ] ~doc ~docv:"Z") 272 + 273 + let unicode_placeholder_arg = 274 + let doc = 275 + "Use Unicode placeholder method to display images. This allows images to \ 276 + scroll properly with text in terminals and multiplexers. Automatically \ 277 + enabled when using tmux passthrough mode." 278 + in 279 + Arg.(value & flag & info [ "unicode-placeholder" ] ~doc) 280 + 281 + let no_trailing_newline_arg = 282 + let doc = "Don't move cursor to next line after displaying an image." in 283 + Arg.(value & flag & info [ "n"; "no-trailing-newline" ] ~doc) 284 + 285 + let hold_arg = 286 + let doc = "Wait for a key press before exiting after displaying images." in 287 + Arg.(value & flag & info [ "hold" ] ~doc) 288 + 289 + let config_term = 290 + let combine files clear clear_all detect_support align place scale_up fit z_index 291 + unicode_placeholder no_trailing_newline hold graphics_mode = 292 + { 293 + files; 294 + clear; 295 + clear_all; 296 + detect_support; 297 + align; 298 + place; 299 + scale_up; 300 + fit; 301 + z_index; 302 + unicode_placeholder; 303 + no_trailing_newline; 304 + hold; 305 + graphics_mode; 306 + } 307 + in 308 + Term.( 309 + const combine 310 + $ files_arg 311 + $ clear_arg 312 + $ clear_all_arg 313 + $ detect_support_arg 314 + $ align_arg 315 + $ place_arg 316 + $ scale_up_arg 317 + $ fit_arg 318 + $ z_index_arg 319 + $ unicode_placeholder_arg 320 + $ no_trailing_newline_arg 321 + $ hold_arg 322 + $ Kgp_cli.graphics_term) 323 + 324 + let cmd = 325 + let doc = "Display images in the terminal using Kitty Graphics Protocol" in 326 + let man = 327 + [ 328 + `S Manpage.s_description; 329 + `P 330 + "$(tname) displays images in terminals that support the Kitty Graphics \ 331 + Protocol (Kitty, WezTerm, Konsole, Ghostty, etc.)."; 332 + `P 333 + "You can specify multiple image files. If no files are given and stdin \ 334 + is not a terminal, image data is read from stdin."; 335 + `S Manpage.s_examples; 336 + `P "Display an image:"; 337 + `Pre " $(tname) photo.png"; 338 + `P "Display multiple images:"; 339 + `Pre " $(tname) *.png"; 340 + `P "Display image from stdin:"; 341 + `Pre " curl -s https://example.com/image.png | $(tname)"; 342 + `P "Display image at specific position:"; 343 + `Pre " $(tname) --place 40x20@10,5 photo.png"; 344 + `P "Clear all displayed images:"; 345 + `Pre " $(tname) --clear"; 346 + `S Kgp_cli.graphics_docs; 347 + ] 348 + in 349 + let info = Cmd.info "kgpcat" ~version:"%%VERSION%%" ~doc ~man in 350 + Cmd.v info Term.(const run $ config_term) 351 + 352 + let () = exit (Cmd.eval cmd)