···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)