Kitty Graphics Protocol in OCaml
terminal graphics ocaml
at main 12 kB view raw
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 8open Cmdliner 9 10module K = Kgp 11 12type align = Center | Left | Right 13type fit = Width | Height | Both | None_ 14 15type 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 *) 32let default_rows = 10 33let default_cols = 20 34 35(* Read file contents *) 36let 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 *) 44let 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 *) 54let stdin_has_data () = 55 let fd = Unix.descr_of_in_channel stdin in 56 not (Unix.isatty fd) 57 58(* Send command to terminal *) 59let 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) *) 65let 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 *) 75let 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 *) 136let do_clear () = 137 send (K.delete `All_visible) ~data:"" 138 139(* Clear all images including scrollback *) 140let 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 *) 145let 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 *) 162let 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 207let files_arg = 208 Arg.(value & pos_all file [] & info [] ~docv:"IMAGE" ~doc:"Image files to display.") 209 210let clear_arg = 211 let doc = "Remove all images currently displayed on the screen." in 212 Arg.(value & flag & info [ "clear" ] ~doc) 213 214let clear_all_arg = 215 let doc = "Remove all images from screen and scrollback." in 216 Arg.(value & flag & info [ "clear-all" ] ~doc) 217 218let 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 225let 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 230let 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 253let 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 260let 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 267let 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 273let 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 281let 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 285let hold_arg = 286 let doc = "Wait for a key press before exiting after displaying images." in 287 Arg.(value & flag & info [ "hold" ] ~doc) 288 289let 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 324let 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 352let () = exit (Cmd.eval cmd)