Kitty Graphics Protocol in OCaml
terminal
graphics
ocaml
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)