···
1
+
(*---------------------------------------------------------------------------
2
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
3
+
SPDX-License-Identifier: ISC
4
+
---------------------------------------------------------------------------*)
6
+
(* kgpcat - Display images in the terminal using Kitty Graphics Protocol *)
12
+
type align = Center | Left | Right
13
+
type fit = Width | Height | Both | None_
16
+
files : string list;
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"]
25
+
unicode_placeholder : bool;
26
+
no_trailing_newline : bool;
28
+
graphics_mode : K.Terminal.graphics_mode;
31
+
(* Default image size in cells when not using --place *)
32
+
let default_rows = 10
33
+
let default_cols = 20
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
43
+
(* Read from stdin *)
45
+
let buf = Buffer.create 4096 in
48
+
Buffer.add_channel buf stdin 4096
51
+
with End_of_file -> Buffer.contents buf
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)
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
64
+
(* Check if file is a supported format (PNG only) *)
65
+
let is_supported_format filename =
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 -> ""
72
+
ext = "png" || filename = "stdin"
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
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
86
+
match config.place with
87
+
| Some (w, h, _, _) -> (w, h)
88
+
| None -> (default_cols, default_rows)
90
+
if use_unicode then (
91
+
(* Unicode placeholder mode: transmit image, then output placeholder chars *)
92
+
let image_id = K.next_image_id () in
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 ()
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
103
+
(* Transmit the image data with virtual placement *)
105
+
K.transmit_and_display ~image_id ~format:`Png ~placement ~quiet:`Errors_only ()
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 ()
114
+
(* Direct graphics mode *)
115
+
let image_id = K.next_image_id () in
117
+
match config.place with
118
+
| Some (w, h, x, y) ->
120
+
(K.Placement.make ~columns:w ~rows:h ~cell_x_offset:x ~cell_y_offset:y
121
+
~z_index:config.z_index ~cursor:`Static ())
123
+
let z = if config.z_index <> 0 then Some config.z_index else None in
125
+
(K.Placement.make ?z_index:z
126
+
~cursor:(if config.no_trailing_newline then `Static else `Move) ())
129
+
K.transmit_and_display ~image_id ~format:`Png ?placement ~quiet:`Errors_only ()
131
+
send ~tmux:use_tmux cmd ~data;
132
+
if not config.no_trailing_newline && config.place = None then print_newline ()
135
+
(* Clear all images *)
137
+
send (K.delete `All_visible) ~data:""
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:""
144
+
(* Detect terminal support *)
145
+
let do_detect_support () =
146
+
if K.Terminal.is_graphics_terminal () then (
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"
154
+
Printf.eprintf "%s\n" mode;
157
+
Printf.eprintf "not supported\n";
161
+
(* Main run function *)
163
+
(* Handle clear operations first *)
164
+
if config.clear_all then do_clear_all ()
165
+
else if config.clear then do_clear ();
167
+
(* Handle detect support *)
168
+
if config.detect_support then exit (do_detect_support ());
170
+
(* Process files *)
172
+
if config.files = [] && stdin_has_data () then [ "-" ] else config.files
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";
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
187
+
if file = "-" then read_stdin () else read_file file
189
+
display_image config name data
192
+
Printf.eprintf "Error reading %s: %s\n" file msg
194
+
Printf.eprintf "Error processing %s: %s\n" file (Printexc.to_string exn))
197
+
(* Ensure all output is flushed before exiting *)
200
+
if config.hold then (
201
+
Printf.eprintf "Press Enter to exit...";
203
+
ignore (read_line ()))
205
+
(* Cmdliner argument definitions *)
208
+
Arg.(value & pos_all file [] & info [] ~docv:"IMAGE" ~doc:"Image files to display.")
211
+
let doc = "Remove all images currently displayed on the screen." in
212
+
Arg.(value & flag & info [ "clear" ] ~doc)
214
+
let clear_all_arg =
215
+
let doc = "Remove all images from screen and scrollback." in
216
+
Arg.(value & flag & info [ "clear-all" ] ~doc)
218
+
let detect_support_arg =
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."
223
+
Arg.(value & flag & info [ "detect-support" ] ~doc)
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")
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"
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
247
+
with _ -> Error (`Msg "Invalid place format. Use WxH@X,Y (e.g., 40x20@10,5)")
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")
255
+
"Scale up images smaller than the specified area to use as much of the \
258
+
Arg.(value & flag & info [ "scale-up" ] ~doc)
261
+
let doc = "Control how the image is scaled relative to the screen." in
263
+
Arg.enum [ ("width", Width); ("height", Height); ("both", Both); ("none", None_) ]
265
+
Arg.(value & opt fit_enum Width & info [ "fit" ] ~doc ~docv:"FIT")
269
+
"Z-index of the image. Negative values display text on top of the image."
271
+
Arg.(value & opt int 0 & info [ "z"; "z-index" ] ~doc ~docv:"Z")
273
+
let unicode_placeholder_arg =
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."
279
+
Arg.(value & flag & info [ "unicode-placeholder" ] ~doc)
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)
286
+
let doc = "Wait for a key press before exiting after displaying images." in
287
+
Arg.(value & flag & info [ "hold" ] ~doc)
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 =
302
+
unicode_placeholder;
303
+
no_trailing_newline;
313
+
$ detect_support_arg
319
+
$ unicode_placeholder_arg
320
+
$ no_trailing_newline_arg
322
+
$ Kgp_cli.graphics_term)
325
+
let doc = "Display images in the terminal using Kitty Graphics Protocol" in
328
+
`S Manpage.s_description;
330
+
"$(tname) displays images in terminals that support the Kitty Graphics \
331
+
Protocol (Kitty, WezTerm, Konsole, Ghostty, etc.).";
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;
349
+
let info = Cmd.info "kgpcat" ~version:"%%VERSION%%" ~doc ~man in
350
+
Cmd.v info Term.(const run $ config_term)
352
+
let () = exit (Cmd.eval cmd)